Skip to main content

spg_engine/
lib.rs

1//! SPG execution engine — v0.3 wires the SQL front-end to the in-memory
2//! storage layer. Implements `CREATE TABLE`, single-row `INSERT VALUES`, and
3//! `SELECT * FROM <table>` (no WHERE yet — that lands in v0.4 alongside
4//! expression evaluation against rows).
5#![no_std]
6
7extern crate alloc;
8
9pub mod aggregate;
10pub mod copy;
11pub mod describe;
12pub mod eval;
13pub mod fts;
14pub mod json;
15pub mod memoize;
16pub mod plan_cache;
17pub mod publications;
18pub mod query_stats;
19pub mod reorder;
20pub mod selectivity;
21pub mod statistics;
22pub mod subscriptions;
23pub mod triggers;
24pub mod users;
25
26pub use crate::users::{Role, ScramSecrets, UserError, UserStore};
27
28use alloc::borrow::Cow;
29use alloc::boxed::Box;
30use alloc::collections::BTreeMap;
31use alloc::string::{String, ToString};
32use alloc::vec::Vec;
33use core::fmt;
34
35use spg_sql::ast::{
36    BinOp, ColumnDef, ColumnName, ColumnTypeName, CreateIndexStatement, CreatePublicationStatement,
37    CreateSubscriptionStatement, CreateTableStatement, CreateUserStatement, Expr, FrameBound,
38    FrameKind, FromClause, IndexMethod, InsertStatement, JoinKind, Literal, OrderBy, SelectItem,
39    SelectStatement, Statement, TableRef, UnOp, UnionKind, VecEncoding as SqlVecEncoding,
40    WindowFrame,
41};
42// v7.16.0 — re-export the parsed-statement AST so downstream
43// crates (spg-embedded → spg-sqlx) don't need a direct dep on
44// spg-sql for the prepare/bind handle.
45pub use spg_sql::ast::Statement as ParsedStatement;
46use spg_sql::parser::{self, ParseError};
47use spg_storage::{
48    Catalog, ColumnSchema, CompactReport, DataType, IndexKey, IndexKind, Row, StorageError, Table,
49    TableSchema, Value, VecEncoding,
50};
51
52use crate::eval::{EvalContext, EvalError};
53
54/// Result of executing one statement.
55#[derive(Debug, Clone, PartialEq)]
56#[non_exhaustive]
57pub enum QueryResult {
58    /// DDL or DML succeeded.
59    ///
60    /// `affected` is the row count for `INSERT` and 0 elsewhere.
61    /// `modified_catalog` tells the server whether this statement
62    /// caused the *committed* catalog to change — it's the signal to
63    /// snapshot/audit. False for `BEGIN`/`ROLLBACK`, false for writeful
64    /// statements executed inside a transaction (those only touch the
65    /// shadow), and true for `COMMIT` and for writes outside a TX.
66    CommandOk {
67        affected: usize,
68        modified_catalog: bool,
69    },
70    /// `SELECT` returned a (possibly empty) row set.
71    Rows {
72        columns: Vec<ColumnSchema>,
73        rows: Vec<Row>,
74    },
75}
76
77/// All errors the engine can return.
78///
79/// Marked `#[non_exhaustive]` from v7.5.0 onward: external `match`
80/// must include a `_` arm so new variants in subsequent v7.x releases
81/// are not breaking changes.
82#[derive(Debug, Clone, PartialEq)]
83#[non_exhaustive]
84pub enum EngineError {
85    Parse(ParseError),
86    Storage(StorageError),
87    Eval(EvalError),
88    /// Front-end accepted a construct that the v0.x executor doesn't support.
89    Unsupported(String),
90    /// `BEGIN` while another transaction is already open.
91    TransactionAlreadyOpen,
92    /// `COMMIT` / `ROLLBACK` with no active transaction.
93    NoActiveTransaction,
94    /// v4.0 sentinel: `execute_readonly` got a statement that
95    /// mutates engine state (INSERT / CREATE / BEGIN / COMMIT / …).
96    /// The caller should retake the write lock and dispatch through
97    /// `execute(&mut self)` instead.
98    WriteRequired,
99    /// v4.2: a SELECT would have returned more rows than the
100    /// configured `max_query_rows` cap. Carries the cap.
101    RowLimitExceeded(usize),
102    /// v4.5: cooperative cancellation — the host (server's
103    /// per-query watchdog) set the cancel flag while a long-running
104    /// SELECT / UPDATE / DELETE was scanning rows. The partial work
105    /// is discarded; the caller should surface this as a timeout
106    /// to the client.
107    Cancelled,
108}
109
110impl fmt::Display for EngineError {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match self {
113            Self::Parse(e) => write!(f, "parse: {e}"),
114            Self::Storage(e) => write!(f, "storage: {e}"),
115            Self::Eval(e) => write!(f, "eval: {e}"),
116            Self::Unsupported(s) => write!(f, "unsupported: {s}"),
117            Self::TransactionAlreadyOpen => f.write_str("a transaction is already open"),
118            Self::NoActiveTransaction => f.write_str("no active transaction"),
119            Self::WriteRequired => {
120                f.write_str("statement requires a write lock (use execute, not execute_readonly)")
121            }
122            Self::RowLimitExceeded(n) => {
123                write!(f, "query exceeded max_query_rows={n}")
124            }
125            Self::Cancelled => f.write_str("query cancelled (timeout or client request)"),
126        }
127    }
128}
129
130impl From<ParseError> for EngineError {
131    fn from(e: ParseError) -> Self {
132        Self::Parse(e)
133    }
134}
135impl From<StorageError> for EngineError {
136    fn from(e: StorageError) -> Self {
137        Self::Storage(e)
138    }
139}
140impl From<EvalError> for EngineError {
141    fn from(e: EvalError) -> Self {
142        Self::Eval(e)
143    }
144}
145
146/// The execution engine. Holds the catalog and (later) other server-scope
147/// state. `Engine::new()` is intentionally cheap so callers can construct one
148/// per database, per test.
149/// Function pointer that returns "now" as microseconds since Unix
150/// epoch. The engine is `no_std`, so it can't reach for `std::time`
151/// itself — callers (`spg-server`, the sqllogictest runner) inject a
152/// concrete implementation. `None` means `NOW()` / `CURRENT_*` raise
153/// `Unsupported`.
154pub type ClockFn = fn() -> i64;
155
156/// Function pointer that produces 16 cryptographically random bytes.
157/// Like `ClockFn`, the engine is `no_std` and can't reach for /dev/urandom
158/// itself — host (`spg-server`) injects an OS-backed source. `None`
159/// means SQL-driven `CREATE USER` falls back to a deterministic salt
160/// derived from the username (acceptable in tests; the server always
161/// installs a real RNG so production paths never see this).
162pub type SaltFn = fn() -> [u8; 16];
163
164/// v4.5 cooperative cancellation token. A long-running SELECT /
165/// UPDATE / DELETE checks `is_cancelled` at row-loop checkpoints
166/// and bails with `EngineError::Cancelled`. The host
167/// (`spg-server`) creates an `AtomicBool` per query, spawns a
168/// watchdog thread that sets it after `SPG_QUERY_TIMEOUT_MS`,
169/// and passes it via `execute_with_cancel` / `execute_readonly_with_cancel`.
170///
171/// `CancelToken::none()` is a no-op — used by the legacy `execute`
172/// and `execute_readonly` entry points so existing callers don't
173/// change.
174/// v7.17.0 Phase 2.3 — monotonic time source for deadline-aware
175/// cancellation (PG `statement_timeout`). Returns microseconds
176/// since some host-stable monotonic origin (typically the first
177/// call into `Instant::now()` on the server). The engine never
178/// calls `Instant::now()` directly so the crate stays `#![no_std]`.
179pub type MonotonicNowFn = fn() -> u64;
180
181#[derive(Debug, Clone, Copy)]
182struct Deadline {
183    now_fn: MonotonicNowFn,
184    /// Absolute deadline in `now_fn()` units (microseconds).
185    deadline_us: u64,
186}
187
188#[derive(Debug, Clone, Copy)]
189pub struct CancelToken<'a> {
190    flag: Option<&'a core::sync::atomic::AtomicBool>,
191    // v7.17.0 Phase 2.3 — when set, every existing `cancel.check()`
192    // checkpoint also fires `EngineError::Cancelled` once
193    // `(now_fn)() >= deadline_us`. No new check sites, no thread
194    // spawn per query — the monotonic now-fn read is a vDSO
195    // `clock_gettime(CLOCK_MONOTONIC)` (~20ns) and only runs when
196    // the host actually wired a deadline (statement_timeout > 0).
197    deadline: Option<Deadline>,
198}
199
200impl<'a> CancelToken<'a> {
201    #[must_use]
202    pub const fn none() -> Self {
203        Self {
204            flag: None,
205            deadline: None,
206        }
207    }
208
209    #[must_use]
210    pub const fn from_flag(f: &'a core::sync::atomic::AtomicBool) -> Self {
211        Self {
212            flag: Some(f),
213            deadline: None,
214        }
215    }
216
217    /// v7.17.0 Phase 2.3 — attach a monotonic deadline. `now_fn`
218    /// must return microseconds since a stable origin; the token
219    /// trips when `now_fn() >= deadline_us`. Compose with
220    /// `from_flag(...)` when both a watchdog flag and a per-statement
221    /// timeout are in play (e.g. server-wide `SPG_QUERY_TIMEOUT_MS`
222    /// plus session `statement_timeout`); the tighter of the two
223    /// wins by virtue of either signaling first.
224    #[must_use]
225    pub const fn with_deadline(mut self, now_fn: MonotonicNowFn, deadline_us: u64) -> Self {
226        self.deadline = Some(Deadline {
227            now_fn,
228            deadline_us,
229        });
230        self
231    }
232
233    #[must_use]
234    pub fn is_cancelled(self) -> bool {
235        if self
236            .flag
237            .is_some_and(|f| f.load(core::sync::atomic::Ordering::Relaxed))
238        {
239            return true;
240        }
241        // Deadline check is the second branch so the "no timeout"
242        // hot path (`deadline: None`) elides the now-fn call —
243        // predicted-not-taken on the SLO INSERT loop.
244        if let Some(d) = self.deadline
245            && (d.now_fn)() >= d.deadline_us
246        {
247            return true;
248        }
249        false
250    }
251
252    /// Returns `Err(Cancelled)` if the token has been tripped.
253    /// Used at row-loop checkpoints to bail cooperatively without
254    /// scattering raw `is_cancelled` checks across the executor.
255    #[inline]
256    pub fn check(self) -> Result<(), EngineError> {
257        if self.is_cancelled() {
258            Err(EngineError::Cancelled)
259        } else {
260            Ok(())
261        }
262    }
263}
264
265// ---- snapshot envelope (v4.1, extended with CRC32 in v4.37,  ----
266// ----   publications in v6.1.2 v3, subscriptions in v6.1.4 v4) ----
267//
268// Wraps a catalog blob + a user blob behind a small header so the
269// server can persist both atomically without inventing a new file.
270// Bare catalog blobs (v3.x) still load via `restore_envelope` since
271// the magic check fails fast and the function falls back to
272// `Catalog::deserialize`.
273//
274// Layout — v1 (v4.1, no CRC):
275//   [8 bytes magic "SPGENV01"]
276//   [u8 version = 1]
277//   [u32 catalog_len][catalog bytes]
278//   [u32 users_len][users bytes]
279//
280// Layout — v2 (v4.37, CRC32 of body):
281//   [8 bytes magic "SPGENV01"]
282//   [u8 version = 2]
283//   [u32 catalog_len][catalog bytes]
284//   [u32 users_len][users bytes]
285//   [u32 crc32]                      ← CRC32 of every byte before it.
286//
287// Layout — v3 (v6.1.2, publications trailer):
288//   [8 bytes magic "SPGENV01"]
289//   [u8 version = 3]
290//   [u32 catalog_len][catalog bytes]
291//   [u32 users_len][users bytes]
292//   [u32 pubs_len][publications bytes]
293//   [u32 crc32]
294//
295// Layout — v4 (v6.1.4, subscriptions trailer):
296//   [8 bytes magic "SPGENV01"]
297//   [u8 version = 4]
298//   [u32 catalog_len][catalog bytes]
299//   [u32 users_len][users bytes]
300//   [u32 pubs_len][publications bytes]
301//   [u32 subs_len][subscriptions bytes]
302//   [u32 crc32]
303//
304// Layout — v5 (v6.2.0, statistics trailer):
305//   [8 bytes magic "SPGENV01"]
306//   [u8 version = 5]
307//   [u32 catalog_len][catalog bytes]
308//   [u32 users_len][users bytes]
309//   [u32 pubs_len][publications bytes]
310//   [u32 subs_len][subscriptions bytes]
311//   [u32 stats_len][statistics bytes]      ← NEW
312//   [u32 crc32]
313//
314// Writers emit v5 from v6.2.0 on. Readers accept all of {v1, v2,
315// v3, v4, v5}: v1/v2 load with empty publications / subscriptions /
316// statistics; v3 loads with empty subscriptions + statistics; v4
317// loads with empty statistics; v5 deserialises all three. Older
318// SPG versions reading a v5 envelope fall through the version
319// match to `EnvelopeParse::Bare` — pre-v6.2.0 binaries cannot
320// open v6.2.0+ snapshots (matches the v6.1.2 / v6.1.4 breaks).
321
322const ENVELOPE_MAGIC: &[u8; 8] = b"SPGENV01";
323const ENVELOPE_VERSION_V1: u8 = 1;
324const ENVELOPE_VERSION_V2: u8 = 2;
325const ENVELOPE_VERSION_V3: u8 = 3;
326const ENVELOPE_VERSION_V4: u8 = 4;
327const ENVELOPE_VERSION_V5: u8 = 5;
328
329fn build_envelope(catalog: &[u8], users: &[u8], pubs: &[u8], subs: &[u8], stats: &[u8]) -> Vec<u8> {
330    let mut out = Vec::with_capacity(
331        8 + 1
332            + 4
333            + catalog.len()
334            + 4
335            + users.len()
336            + 4
337            + pubs.len()
338            + 4
339            + subs.len()
340            + 4
341            + stats.len()
342            + 4,
343    );
344    out.extend_from_slice(ENVELOPE_MAGIC);
345    out.push(ENVELOPE_VERSION_V5);
346    out.extend_from_slice(
347        &u32::try_from(catalog.len())
348            .expect("≤ 4G catalog")
349            .to_le_bytes(),
350    );
351    out.extend_from_slice(catalog);
352    out.extend_from_slice(
353        &u32::try_from(users.len())
354            .expect("≤ 4G users")
355            .to_le_bytes(),
356    );
357    out.extend_from_slice(users);
358    out.extend_from_slice(
359        &u32::try_from(pubs.len())
360            .expect("≤ 4G publications")
361            .to_le_bytes(),
362    );
363    out.extend_from_slice(pubs);
364    out.extend_from_slice(
365        &u32::try_from(subs.len())
366            .expect("≤ 4G subscriptions")
367            .to_le_bytes(),
368    );
369    out.extend_from_slice(subs);
370    out.extend_from_slice(
371        &u32::try_from(stats.len())
372            .expect("≤ 4G statistics")
373            .to_le_bytes(),
374    );
375    out.extend_from_slice(stats);
376    let crc = spg_crypto::crc32::crc32(&out);
377    out.extend_from_slice(&crc.to_le_bytes());
378    out
379}
380
381/// Outcome of envelope parsing: either bare-catalog fallback, a
382/// successfully split section trio from a v1/v2/v3 envelope, or an
383/// explicit corruption error from a v2/v3 CRC mismatch. `Bare`
384/// (catalog-only fallback) preserves v3.x readability. v1/v2
385/// envelopes set `publications` to `None`; v3 sets it to the
386/// publications byte slice.
387enum EnvelopeParse<'a> {
388    Bare,
389    Pair {
390        catalog: &'a [u8],
391        users: &'a [u8],
392        publications: Option<&'a [u8]>,
393        subscriptions: Option<&'a [u8]>,
394        statistics: Option<&'a [u8]>,
395    },
396    CrcMismatch {
397        expected: u32,
398        computed: u32,
399    },
400}
401
402/// Returns `EnvelopeParse::Pair` for a valid v1 / v2 / v3 envelope,
403/// `Bare` for a buffer that doesn't look like an envelope (v3.x
404/// bare catalog fallback), and `CrcMismatch` for a v2/v3 envelope
405/// whose trailing CRC32 doesn't match the body.
406fn split_envelope(buf: &[u8]) -> EnvelopeParse<'_> {
407    if buf.len() < 8 + 1 + 4 || &buf[..8] != ENVELOPE_MAGIC {
408        return EnvelopeParse::Bare;
409    }
410    let version = buf[8];
411    if !matches!(
412        version,
413        ENVELOPE_VERSION_V1
414            | ENVELOPE_VERSION_V2
415            | ENVELOPE_VERSION_V3
416            | ENVELOPE_VERSION_V4
417            | ENVELOPE_VERSION_V5
418    ) {
419        return EnvelopeParse::Bare;
420    }
421    let mut p = 9usize;
422    let Some(cat_len_bytes) = buf.get(p..p + 4) else {
423        return EnvelopeParse::Bare;
424    };
425    let Ok(cat_len_arr) = cat_len_bytes.try_into() else {
426        return EnvelopeParse::Bare;
427    };
428    let cat_len = u32::from_le_bytes(cat_len_arr) as usize;
429    p += 4;
430    if p + cat_len + 4 > buf.len() {
431        return EnvelopeParse::Bare;
432    }
433    let catalog = &buf[p..p + cat_len];
434    p += cat_len;
435    let Some(user_len_bytes) = buf.get(p..p + 4) else {
436        return EnvelopeParse::Bare;
437    };
438    let Ok(user_len_arr) = user_len_bytes.try_into() else {
439        return EnvelopeParse::Bare;
440    };
441    let user_len = u32::from_le_bytes(user_len_arr) as usize;
442    p += 4;
443    if p + user_len > buf.len() {
444        return EnvelopeParse::Bare;
445    }
446    let users = &buf[p..p + user_len];
447    p += user_len;
448    let publications = if matches!(
449        version,
450        ENVELOPE_VERSION_V3 | ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5
451    ) {
452        // [u32 pubs_len][publications bytes]
453        let Some(pubs_len_bytes) = buf.get(p..p + 4) else {
454            return EnvelopeParse::Bare;
455        };
456        let Ok(pubs_len_arr) = pubs_len_bytes.try_into() else {
457            return EnvelopeParse::Bare;
458        };
459        let pubs_len = u32::from_le_bytes(pubs_len_arr) as usize;
460        p += 4;
461        if p + pubs_len > buf.len() {
462            return EnvelopeParse::Bare;
463        }
464        let pubs_slice = &buf[p..p + pubs_len];
465        p += pubs_len;
466        Some(pubs_slice)
467    } else {
468        None
469    };
470    let subscriptions = if matches!(version, ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5) {
471        // [u32 subs_len][subscriptions bytes]
472        let Some(subs_len_bytes) = buf.get(p..p + 4) else {
473            return EnvelopeParse::Bare;
474        };
475        let Ok(subs_len_arr) = subs_len_bytes.try_into() else {
476            return EnvelopeParse::Bare;
477        };
478        let subs_len = u32::from_le_bytes(subs_len_arr) as usize;
479        p += 4;
480        if p + subs_len > buf.len() {
481            return EnvelopeParse::Bare;
482        }
483        let subs_slice = &buf[p..p + subs_len];
484        p += subs_len;
485        Some(subs_slice)
486    } else {
487        None
488    };
489    let statistics = if version == ENVELOPE_VERSION_V5 {
490        // [u32 stats_len][statistics bytes]
491        let Some(stats_len_bytes) = buf.get(p..p + 4) else {
492            return EnvelopeParse::Bare;
493        };
494        let Ok(stats_len_arr) = stats_len_bytes.try_into() else {
495            return EnvelopeParse::Bare;
496        };
497        let stats_len = u32::from_le_bytes(stats_len_arr) as usize;
498        p += 4;
499        if p + stats_len > buf.len() {
500            return EnvelopeParse::Bare;
501        }
502        let stats_slice = &buf[p..p + stats_len];
503        p += stats_len;
504        Some(stats_slice)
505    } else {
506        None
507    };
508    if matches!(
509        version,
510        ENVELOPE_VERSION_V2 | ENVELOPE_VERSION_V3 | ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5
511    ) {
512        if p + 4 != buf.len() {
513            return EnvelopeParse::Bare;
514        }
515        let Ok(crc_arr) = buf[p..p + 4].try_into() else {
516            return EnvelopeParse::Bare;
517        };
518        let expected = u32::from_le_bytes(crc_arr);
519        let computed = spg_crypto::crc32::crc32(&buf[..p]);
520        if expected != computed {
521            return EnvelopeParse::CrcMismatch { expected, computed };
522        }
523    } else if p != buf.len() {
524        // v1: must end exactly at the users section.
525        return EnvelopeParse::Bare;
526    }
527    EnvelopeParse::Pair {
528        catalog,
529        users,
530        publications,
531        subscriptions,
532        statistics,
533    }
534}
535
536/// v4.41.1 opaque transaction handle. Returned by `Engine::alloc_tx_id`,
537/// threaded through `Engine::execute_in` so dispatch can identify which
538/// in-flight TX a statement belongs to. `IMPLICIT_TX` is the reserved
539/// slot every legacy caller — engine self-tests, spg-cli, spg-embedded,
540/// startup replay — implicitly uses through the unchanged
541/// `Engine::execute(sql)` API. v4.41.1 keeps at most one active slot at
542/// runtime (dispatch holds `engine.write()` across the wrap, same as
543/// v4.34); the map shape is here to let v4.42 turn on N in-flight
544/// implicit TXs without reshuffling the engine internals.
545#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
546pub struct TxId(pub u64);
547
548/// Reserved slot used by `Engine::execute(sql)` — the legacy single-
549/// global-shadow path. New `alloc_tx_id` handles start at 1.
550pub const IMPLICIT_TX: TxId = TxId(0);
551
552/// v6.7.3 — default segment-size threshold used by `COMPACT COLD
553/// SEGMENTS` when no explicit target is supplied. Segments whose
554/// `OwnedSegment::bytes().len()` is **strictly** less than this
555/// value are eligible to merge. spg-server reads
556/// `SPG_COMPACTION_TARGET_SEGMENT_BYTES` to override.
557pub const COMPACTION_TARGET_DEFAULT_BYTES: u64 = 4 * 1024 * 1024;
558
559/// Per-slot transaction state. Held inside `tx_catalogs[tx_id]` for the
560/// lifetime of a BEGIN..COMMIT (or BEGIN..ROLLBACK) window. Drops when
561/// the TX commits (its `catalog` is moved over `Engine.catalog`) or
562/// rolls back (slot removed, catalog discarded).
563#[derive(Debug, Default, Clone)]
564struct TxState {
565    /// The TX's shadow copy of the catalog. Started as a clone of
566    /// `Engine.catalog` at BEGIN time; writes flow into it; COMMIT
567    /// installs it over `Engine.catalog`. `Catalog::clone()` is O(1)
568    /// since v4.40 (`PersistentVec` rows + `PersistentBTreeMap` indices).
569    catalog: Catalog,
570    /// Per-TX savepoint stack. Each entry pairs the savepoint name with
571    /// a clone of `catalog` at the moment `SAVEPOINT <name>` fired.
572    /// `ROLLBACK TO <name>` restores from the entry and pops everything
573    /// after it; `RELEASE <name>` discards the entry and everything
574    /// after; COMMIT/ROLLBACK clears the whole stack.
575    savepoints: Vec<(String, Catalog)>,
576}
577
578/// v7.11.0 — frozen read-only view of the engine's committed state.
579/// Constructed via [`Engine::clone_snapshot`]. Holds clones of the
580/// catalog, statistics, clock function, and row-cap config — the
581/// four fields the `execute_readonly` path actually reads. Cheap to
582/// `Clone` (each clone shares the underlying `PersistentVec` row
583/// storage; only the trie root pointers copy). Send + Sync so a
584/// snapshot can be moved across `tokio::task::spawn_blocking`
585/// boundaries without coordination.
586///
587/// The contract: a snapshot reflects the engine's state at the
588/// moment `clone_snapshot()` returned. Subsequent writes to the
589/// engine are NOT visible. Callers who need fresher data take a
590/// new snapshot.
591#[derive(Debug, Clone)]
592pub struct CatalogSnapshot {
593    catalog: Catalog,
594    statistics: statistics::Statistics,
595    clock: Option<ClockFn>,
596    max_query_rows: Option<usize>,
597}
598
599#[derive(Debug, Default)]
600pub struct Engine {
601    /// Committed catalog — what survives `Engine::snapshot()` and what
602    /// outside-TX `SELECT`s read.
603    catalog: Catalog,
604    /// Active TX slots, keyed by `TxId`. Empty when no TX is in flight.
605    /// v4.41.1 runtime invariant: at most one entry (single-writer
606    /// model unchanged). v4.42 will let dispatch hold multiple entries
607    /// concurrently for group commit + engine MVCC.
608    tx_catalogs: BTreeMap<TxId, TxState>,
609    /// Which slot the next exec_* call should mutate. Set by
610    /// `execute_in(sql, tx_id)` at the entry point; legacy `execute(sql)`
611    /// sets it to `IMPLICIT_TX`. None when no TX is in flight (read /
612    /// write goes straight against `catalog`).
613    current_tx: Option<TxId>,
614    /// Monotonic counter for `alloc_tx_id`. Starts at 1 — slot 0 is
615    /// reserved for `IMPLICIT_TX`.
616    next_tx_id: u64,
617    /// v7.22 (round-13 T3) — session string-literal dialect. `false`
618    /// (default) = PG semantics (backslash literal, `''` escape);
619    /// `true` = MySQL semantics (`\'` etc.). Flipped by the
620    /// deterministic session signals each dump emits: `SET sql_mode`
621    /// (only MySQL clients/dumps send it) turns it on,
622    /// `SET standard_conforming_strings = on` (every pg_dump
623    /// preamble) turns it off. The plan cache is cleared on every
624    /// flip — the same SQL text lexes differently per dialect.
625    backslash_escapes: bool,
626    /// Optional wall clock used to satisfy `NOW()` / `CURRENT_TIMESTAMP`
627    /// / `CURRENT_DATE`. Set by the host environment.
628    clock: Option<ClockFn>,
629    /// v4.1 cryptographic RNG for per-user password salt. Set by the
630    /// host. `None` means SQL-driven `CREATE USER` uses a
631    /// deterministic fallback — see `SaltFn`.
632    salt_fn: Option<SaltFn>,
633    /// v4.2 per-query row cap. `None` = unlimited. When set, a
634    /// SELECT that materialises more than `n` rows returns
635    /// `EngineError::RowLimitExceeded`. Enforced before the result
636    /// is shaped into wire frames so a runaway scan can't blow the
637    /// server's heap.
638    max_query_rows: Option<usize>,
639    /// v4.1 RBAC user table. Empty means "no RBAC configured yet" —
640    /// the server decides what that means at the auth boundary
641    /// (open mode vs legacy single-password mode). User CRUD goes
642    /// through `create_user`/`drop_user`/`verify_user`; persistence
643    /// rides the snapshot envelope alongside the catalog.
644    users: UserStore,
645    /// v6.1.2 logical-replication publication catalog. Empty until
646    /// `CREATE PUBLICATION` runs. Persistence rides the v3 envelope
647    /// trailer (see `build_envelope`).
648    publications: publications::Publications,
649    /// v6.1.4 logical-replication subscription catalog. Empty until
650    /// `CREATE SUBSCRIPTION` runs. Persistence rides the v4 envelope
651    /// trailer.
652    subscriptions: subscriptions::Subscriptions,
653    /// v6.2.0 — per-column statistics for the cost-based optimizer.
654    /// Populated by `ANALYZE`; queried via `spg_statistic` virtual
655    /// table. Persistence rides the v5 envelope trailer.
656    statistics: statistics::Statistics,
657    /// v6.3.0 — engine-level plan cache. Caches the post-`prepare()`
658    /// `Statement` keyed on SQL text. In-memory only — does NOT ride
659    /// the snapshot envelope (rebuilt on demand after restart).
660    plan_cache: plan_cache::PlanCache,
661    /// v6.5.1 — per-distinct-SQL execution stats. In-memory only,
662    /// surfaced via `spg_stat_query` virtual table. Updated by the
663    /// `execute_*` paths after a successful execute.
664    query_stats: query_stats::QueryStats,
665    /// v6.5.2 — connection-state provider callback. spg-server
666    /// registers a function at startup that snapshots its
667    /// per-pgwire-connection registry into `ActivityRow`s; engine
668    /// reads through it on every `SELECT * FROM spg_stat_activity`.
669    /// `None` ⇒ no-data (returns empty rows; matches the no_std
670    /// embedded callers that don't run pgwire).
671    activity_provider: Option<ActivityProvider>,
672    /// v6.5.3 — audit-chain provider + verifier. Same pattern as
673    /// activity_provider: spg-server registers both at startup;
674    /// engine reads through on `SELECT * FROM spg_audit_chain` and
675    /// `SELECT * FROM spg_audit_verify`. `None` ⇒ no-data.
676    audit_chain_provider: Option<AuditChainProvider>,
677    audit_verifier: Option<AuditVerifier>,
678    /// v6.5.6 — slow-query log threshold in microseconds. When set,
679    /// every successful execute whose elapsed exceeds the threshold
680    /// gets fed to the registered slow-query log callback (so
681    /// spg-server can emit a structured log line). Default `None`
682    /// = no slow-query logging.
683    slow_query_threshold_us: Option<u64>,
684    slow_query_logger: Option<SlowQueryLogger>,
685    /// v7.12.1 — session parameters set via `SET <name> = <value>`.
686    /// Only `default_text_search_config` is consumed by the engine
687    /// today (the FTS function dispatcher reads it when
688    /// `to_tsvector(text)` is called without an explicit config).
689    /// All other names are accepted + recorded so PG-dump output
690    /// loads, but have no behavioural effect.
691    session_params: BTreeMap<String, String>,
692    /// v7.12.7 — depth counter for trigger-emitted embedded SQL.
693    /// Each time the engine executes a `DeferredEmbeddedStmt` it
694    /// increments this; the recursive `execute_stmt_with_cancel`
695    /// inside that path checks against [`MAX_TRIGGER_RECURSION`]
696    /// to bound runaway cascades (trigger A's UPDATE on table B
697    /// fires trigger B which UPDATEs table A which fires trigger
698    /// A again…). Reset to 0 once the original DML returns.
699    trigger_recursion_depth: u32,
700    /// v7.14.0 — when `SET FOREIGN_KEY_CHECKS=0` is in effect
701    /// (mysqldump preamble), the FK existence + arity check at
702    /// CREATE TABLE time is deferred. FKs referencing a
703    /// not-yet-existing parent land in `pending_foreign_keys`
704    /// keyed by child table; `SET FOREIGN_KEY_CHECKS=1` drains
705    /// the queue and resolves each FK against the now-complete
706    /// catalog. Empty by default; the queue is drained on every
707    /// `RESET ALL` too.
708    foreign_key_checks: bool,
709    /// v7.16.2 — true on the temp Engine an outer
710    /// `exec_select_with_meta_views` builds, telling that
711    /// temp engine "stop short-circuiting into the meta-view
712    /// path — your catalog already has the materialised
713    /// tables; just run the regular SELECT." Without this we'd
714    /// infinite-loop since the meta-view name (e.g.
715    /// `__spg_info_columns`) still triggers
716    /// `select_references_meta_view`.
717    meta_views_materialised: bool,
718    pending_foreign_keys: Vec<(alloc::string::String, spg_sql::ast::ForeignKeyConstraint)>,
719}
720
721/// v7.12.7 — hard cap on nested trigger-emitted embedded SQL
722/// fires. 16 deep is well past anything a normal trigger graph
723/// uses while still preventing infinite-loop wedging.
724const MAX_TRIGGER_RECURSION: u32 = 16;
725
726/// v6.5.6 — callback signature for slow-query log emission. Called
727/// with `(sql, elapsed_us)` once per successful execute that crosses
728/// the threshold.
729pub type SlowQueryLogger = fn(&str, u64);
730
731/// v6.5.4 — synthesise a `CREATE TABLE` statement from catalog
732/// state. Round-trips through `Engine::execute` to recreate the
733/// same schema (sans data + indexes — indexes are emitted as a
734/// separate `CREATE INDEX` chain in `spg_database_ddl`).
735fn render_create_table(name: &str, columns: &[ColumnSchema]) -> String {
736    let mut out = alloc::format!("CREATE TABLE {name} (");
737    for (i, col) in columns.iter().enumerate() {
738        if i > 0 {
739            out.push_str(", ");
740        }
741        out.push_str(&col.name);
742        out.push(' ');
743        out.push_str(&render_data_type(col.ty));
744        if !col.nullable {
745            out.push_str(" NOT NULL");
746        }
747        if col.auto_increment {
748            out.push_str(" AUTO_INCREMENT");
749        }
750    }
751    out.push(')');
752    out
753}
754
755fn render_data_type(ty: DataType) -> String {
756    match ty {
757        DataType::SmallInt => "SMALLINT".into(),
758        DataType::Int => "INT".into(),
759        DataType::BigInt => "BIGINT".into(),
760        DataType::Float => "FLOAT".into(),
761        DataType::Text => "TEXT".into(),
762        DataType::Varchar(n) => alloc::format!("VARCHAR({n})"),
763        DataType::Char(n) => alloc::format!("CHAR({n})"),
764        DataType::Bool => "BOOL".into(),
765        DataType::Vector { dim, encoding } => match encoding {
766            spg_storage::VecEncoding::F32 => alloc::format!("VECTOR({dim})"),
767            spg_storage::VecEncoding::Sq8 => alloc::format!("VECTOR({dim}) USING SQ8"),
768            spg_storage::VecEncoding::F16 => alloc::format!("VECTOR({dim}) USING HALF"),
769        },
770        DataType::Numeric { precision, scale } => {
771            alloc::format!("NUMERIC({precision},{scale})")
772        }
773        DataType::Date => "DATE".into(),
774        DataType::Timestamp => "TIMESTAMP".into(),
775        DataType::Interval => "INTERVAL".into(),
776        DataType::Json => "JSON".into(),
777        DataType::Jsonb => "JSONB".into(),
778        DataType::Timestamptz => "TIMESTAMPTZ".into(),
779        DataType::Bytes => "BYTEA".into(),
780        DataType::TextArray => "TEXT[]".into(),
781        DataType::IntArray => "INT[]".into(),
782        DataType::BigIntArray => "BIGINT[]".into(),
783        DataType::TsVector => "TSVECTOR".into(),
784        DataType::TsQuery => "TSQUERY".into(),
785        DataType::Uuid => "UUID".into(),
786        DataType::Time => "TIME".into(),
787        DataType::Year => "YEAR".into(),
788        DataType::TimeTz => "TIMETZ".into(),
789        DataType::Money => "MONEY".into(),
790        DataType::Range(k) => k.keyword().into(),
791        DataType::Hstore => "HSTORE".into(),
792        DataType::IntArray2D => "INT[][]".into(),
793        DataType::BigIntArray2D => "BIGINT[][]".into(),
794        DataType::TextArray2D => "TEXT[][]".into(),
795    }
796}
797
798/// v6.5.2 — one row of `spg_stat_activity`. Engine-public so
799/// spg-server can construct rows without re-exporting internal
800/// dispatch types.
801#[derive(Debug, Clone)]
802pub struct ActivityRow {
803    pub pid: u32,
804    pub user: String,
805    pub started_at_us: i64,
806    pub current_sql: String,
807    pub wait_event: String,
808    pub elapsed_us: i64,
809    pub in_transaction: bool,
810    /// v7.17 Phase 2.4 — startup-param `application_name` (or the
811    /// last value the client sent via `SET application_name = '...'`).
812    /// Empty when the client never declared one.
813    pub application_name: String,
814}
815
816/// v6.5.2 — provider callback type. Fresh snapshot returned each
817/// call; engine doesn't cache the slice.
818pub type ActivityProvider = fn() -> Vec<ActivityRow>;
819
820/// v6.5.3 — one row of `spg_audit_chain`. Engine-public so
821/// spg-server can construct rows directly from `AuditEntry`.
822#[derive(Debug, Clone)]
823pub struct AuditRow {
824    pub seq: i64,
825    pub ts_ms: i64,
826    pub prev_hash_hex: String,
827    pub entry_hash_hex: String,
828    pub sql: String,
829}
830
831/// v6.5.3 — chain-table provider + verifier. spg-server registers
832/// fn pointers that snapshot / verify the audit log. `verify`
833/// returns `(verified_count, broken_at_seq)` — `broken_at_seq` is
834/// `-1` on a clean chain.
835pub type AuditChainProvider = fn() -> Vec<AuditRow>;
836pub type AuditVerifier = fn() -> (i64, i64);
837
838impl Engine {
839    pub fn new() -> Self {
840        Self {
841            catalog: Catalog::new(),
842            tx_catalogs: BTreeMap::new(),
843            current_tx: None,
844            backslash_escapes: false,
845            next_tx_id: 1,
846            clock: None,
847            salt_fn: None,
848            max_query_rows: None,
849            users: UserStore::new(),
850            publications: publications::Publications::new(),
851            subscriptions: subscriptions::Subscriptions::new(),
852            statistics: statistics::Statistics::new(),
853            plan_cache: plan_cache::PlanCache::new(),
854            query_stats: query_stats::QueryStats::new(),
855            activity_provider: None,
856            audit_chain_provider: None,
857            audit_verifier: None,
858            slow_query_threshold_us: None,
859            slow_query_logger: None,
860            session_params: BTreeMap::new(),
861            trigger_recursion_depth: 0,
862            foreign_key_checks: true,
863            meta_views_materialised: false,
864            pending_foreign_keys: Vec::new(),
865        }
866    }
867
868    /// v7.11.0 — clone the engine's committed catalog + read-time
869    /// state into a frozen `CatalogSnapshot`. Cheap (`Catalog` is
870    /// backed by `PersistentVec`; cloning is O(log n) per table).
871    /// Subsequent writes to this engine are invisible to the
872    /// snapshot; the snapshot is self-contained and can be moved
873    /// to another thread for concurrent `execute_readonly_on_snapshot`
874    /// calls. The basis for [`AsyncReadHandle`] in spg-embedded-tokio
875    /// and any other read-fanout pattern.
876    #[must_use]
877    pub fn clone_snapshot(&self) -> CatalogSnapshot {
878        CatalogSnapshot {
879            catalog: self.active_catalog().clone(),
880            statistics: self.statistics.clone(),
881            clock: self.clock,
882            max_query_rows: self.max_query_rows,
883        }
884    }
885
886    /// v7.11.1 — execute a read-only SQL statement against a
887    /// `CatalogSnapshot` without touching this engine. Same
888    /// semantics as `execute_readonly` but parameterised on the
889    /// snapshot's catalog. Reject DDL/DML the same way
890    /// `execute_readonly` does. Static-on-Self so the caller can
891    /// dispatch without holding an `Engine` borrow alongside the
892    /// snapshot.
893    pub fn execute_readonly_on_snapshot(
894        snapshot: &CatalogSnapshot,
895        sql: &str,
896    ) -> Result<QueryResult, EngineError> {
897        Self::execute_readonly_on_snapshot_with_cancel(snapshot, sql, CancelToken::none())
898    }
899
900    /// v7.11.1 — `execute_readonly_on_snapshot` with cooperative
901    /// cancellation. Builds a transient `Engine` over the snapshot
902    /// state, runs `execute_readonly_with_cancel`, drops. The
903    /// transient engine is cheap to construct (no I/O; everything
904    /// is just struct moves) and lets the existing read path stay
905    /// untouched.
906    pub fn execute_readonly_on_snapshot_with_cancel(
907        snapshot: &CatalogSnapshot,
908        sql: &str,
909        cancel: CancelToken<'_>,
910    ) -> Result<QueryResult, EngineError> {
911        let transient = Engine {
912            catalog: snapshot.catalog.clone(),
913            statistics: snapshot.statistics.clone(),
914            clock: snapshot.clock,
915            max_query_rows: snapshot.max_query_rows,
916            ..Engine::default()
917        };
918        transient.execute_readonly_with_cancel(sql, cancel)
919    }
920
921    /// v7.18 — execute a previously-prepared `Statement` against a
922    /// `CatalogSnapshot` in read-only mode. Mirror of
923    /// [`Engine::execute_prepared`] for the fan-out read path:
924    /// substitutes `Expr::Placeholder(n)` nodes from `params`, then
925    /// dispatches through [`Engine::execute_readonly_stmt_with_cancel`]
926    /// (writes / DDL hit `EngineError::WriteRequired`). Static-on-Self
927    /// so multiple readonly threads can dispatch against the same
928    /// snapshot concurrently without an `Engine` borrow.
929    ///
930    /// **Schema drift contract**. The `Statement` was prepared against
931    /// some prior catalog. If the snapshot's catalog has since
932    /// diverged (DDL renamed / dropped a referenced column / table),
933    /// execution surfaces the normal `EngineError` — same shape as
934    /// PG's "cached plan must not change result type". Caller decides
935    /// whether to re-prepare; engine does NOT auto-retry.
936    pub fn execute_readonly_prepared_on_snapshot(
937        snapshot: &CatalogSnapshot,
938        stmt: Statement,
939        params: &[Value],
940    ) -> Result<QueryResult, EngineError> {
941        Self::execute_readonly_prepared_on_snapshot_with_cancel(
942            snapshot,
943            stmt,
944            params,
945            CancelToken::none(),
946        )
947    }
948
949    /// v7.18 — cancellable variant of
950    /// [`Engine::execute_readonly_prepared_on_snapshot`].
951    pub fn execute_readonly_prepared_on_snapshot_with_cancel(
952        snapshot: &CatalogSnapshot,
953        mut stmt: Statement,
954        params: &[Value],
955        cancel: CancelToken<'_>,
956    ) -> Result<QueryResult, EngineError> {
957        cancel.check()?;
958        substitute_placeholders(&mut stmt, params)?;
959        let transient = Engine {
960            catalog: snapshot.catalog.clone(),
961            statistics: snapshot.statistics.clone(),
962            clock: snapshot.clock,
963            max_query_rows: snapshot.max_query_rows,
964            ..Engine::default()
965        };
966        transient.execute_readonly_stmt_with_cancel(stmt, cancel)
967    }
968
969    /// v7.18 — describe a prepared `Statement` against a
970    /// `CatalogSnapshot`. Same `(parameter_oids, output_columns)`
971    /// shape as [`Engine::describe_prepared`]; resolves names
972    /// against the snapshot's catalog instead of `self`. Pure
973    /// function — no engine state read.
974    pub fn describe_prepared_on_snapshot(
975        snapshot: &CatalogSnapshot,
976        stmt: &Statement,
977    ) -> (Vec<u32>, Vec<ColumnSchema>) {
978        describe::describe_prepared(stmt, &snapshot.catalog)
979    }
980
981    /// v7.18 — does this SQL string classify as read-only? Parses
982    /// `sql` with the engine parser and consults
983    /// `Statement::is_readonly()`. A parse error returns `false`
984    /// (route to the writer path so the user sees the canonical
985    /// parse error from the writer's simple-query dispatch).
986    /// Static-on-Self so the spg-sqlx connection layer can ask
987    /// without an `Engine` borrow.
988    #[must_use]
989    pub fn is_readonly_sql(sql: &str) -> bool {
990        parser::parse_statement(sql)
991            .as_ref()
992            .map(spg_sql::ast::Statement::is_readonly)
993            .unwrap_or(false)
994    }
995
996    /// v7.18 — parse + plan a SQL string against a
997    /// `CatalogSnapshot`. Mirror of [`Engine::prepare`] for the
998    /// readonly fan-out path: applies the same prepare-time
999    /// transforms (clock rewrite, `GROUP BY ALL` expansion, ORDER
1000    /// BY position resolve, cost-based JOIN reorder) but resolves
1001    /// catalog + statistics against the snapshot, not a live
1002    /// engine. Static-on-Self — `AsyncReadHandle::prepare` calls
1003    /// this without taking the writer lock so multiple read
1004    /// handles can prepare concurrently against frozen views.
1005    ///
1006    /// # Errors
1007    /// Propagates [`ParseError`] from the parser. Schema
1008    /// validation deferred to execute time, same as
1009    /// [`Engine::prepare`].
1010    pub fn prepare_on_snapshot(
1011        snapshot: &CatalogSnapshot,
1012        sql: &str,
1013    ) -> Result<Statement, ParseError> {
1014        let mut stmt = parser::parse_statement(sql)?;
1015        let now_micros = snapshot.clock.map(|f| f());
1016        rewrite_clock_calls(&mut stmt, now_micros);
1017        if let Statement::Select(s) = &mut stmt {
1018            expand_group_by_all(s);
1019            resolve_order_by_position(s);
1020            reorder::reorder_joins(s, &snapshot.catalog, &snapshot.statistics);
1021        }
1022        Ok(stmt)
1023    }
1024
1025    /// Construct an engine restored from a previously-snapshotted catalog
1026    /// (see `snapshot()`).
1027    pub fn restore(catalog: Catalog) -> Self {
1028        Self {
1029            catalog,
1030            tx_catalogs: BTreeMap::new(),
1031            current_tx: None,
1032            backslash_escapes: false,
1033            next_tx_id: 1,
1034            clock: None,
1035            salt_fn: None,
1036            max_query_rows: None,
1037            users: UserStore::new(),
1038            publications: publications::Publications::new(),
1039            subscriptions: subscriptions::Subscriptions::new(),
1040            statistics: statistics::Statistics::new(),
1041            plan_cache: plan_cache::PlanCache::new(),
1042            query_stats: query_stats::QueryStats::new(),
1043            activity_provider: None,
1044            audit_chain_provider: None,
1045            audit_verifier: None,
1046            slow_query_threshold_us: None,
1047            slow_query_logger: None,
1048            session_params: BTreeMap::new(),
1049            trigger_recursion_depth: 0,
1050            foreign_key_checks: true,
1051            meta_views_materialised: false,
1052            pending_foreign_keys: Vec::new(),
1053        }
1054    }
1055
1056    /// Restore an engine + user table from a v4.1 envelope produced
1057    /// by `snapshot_with_users()`. Falls back to plain catalog-only
1058    /// restore if the envelope magic isn't present (so v3.x snapshot
1059    /// files still load). v6.1.2 adds the optional publications
1060    /// trailer (envelope v3); a v1/v2 envelope deserialises to an
1061    /// empty publication table.
1062    pub fn restore_envelope(buf: &[u8]) -> Result<Self, EngineError> {
1063        match split_envelope(buf) {
1064            EnvelopeParse::Pair {
1065                catalog: catalog_bytes,
1066                users: user_bytes,
1067                publications: pub_bytes,
1068                subscriptions: sub_bytes,
1069                statistics: stats_bytes,
1070            } => {
1071                let catalog = Catalog::deserialize(catalog_bytes).map_err(EngineError::Storage)?;
1072                let users = users::deserialize_users(user_bytes)
1073                    .map_err(|e| EngineError::Unsupported(alloc::format!("users restore: {e}")))?;
1074                let publications = match pub_bytes {
1075                    Some(b) => publications::Publications::deserialize(b).map_err(|e| {
1076                        EngineError::Unsupported(alloc::format!("publications restore: {e:?}"))
1077                    })?,
1078                    None => publications::Publications::new(),
1079                };
1080                let subscriptions = match sub_bytes {
1081                    Some(b) => subscriptions::Subscriptions::deserialize(b).map_err(|e| {
1082                        EngineError::Unsupported(alloc::format!("subscriptions restore: {e:?}"))
1083                    })?,
1084                    None => subscriptions::Subscriptions::new(),
1085                };
1086                let statistics = match stats_bytes {
1087                    Some(b) => statistics::Statistics::deserialize(b).map_err(|e| {
1088                        EngineError::Unsupported(alloc::format!("statistics restore: {e:?}"))
1089                    })?,
1090                    None => statistics::Statistics::new(),
1091                };
1092                Ok(Self {
1093                    catalog,
1094                    tx_catalogs: BTreeMap::new(),
1095                    current_tx: None,
1096                    backslash_escapes: false,
1097                    next_tx_id: 1,
1098                    clock: None,
1099                    salt_fn: None,
1100                    max_query_rows: None,
1101                    users,
1102                    publications,
1103                    subscriptions,
1104                    statistics,
1105                    plan_cache: plan_cache::PlanCache::new(),
1106                    query_stats: query_stats::QueryStats::new(),
1107                    activity_provider: None,
1108                    audit_chain_provider: None,
1109                    audit_verifier: None,
1110                    slow_query_threshold_us: None,
1111                    slow_query_logger: None,
1112                    session_params: BTreeMap::new(),
1113                    trigger_recursion_depth: 0,
1114                    foreign_key_checks: true,
1115                    meta_views_materialised: false,
1116                    pending_foreign_keys: Vec::new(),
1117                })
1118            }
1119            EnvelopeParse::CrcMismatch { expected, computed } => {
1120                Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
1121                    "snapshot envelope CRC32 mismatch (expected={expected:#010x}, computed={computed:#010x})"
1122                ))))
1123            }
1124            EnvelopeParse::Bare => {
1125                let catalog = Catalog::deserialize(buf).map_err(EngineError::Storage)?;
1126                Ok(Self::restore(catalog))
1127            }
1128        }
1129    }
1130
1131    pub const fn users(&self) -> &UserStore {
1132        &self.users
1133    }
1134
1135    /// `salt` is supplied by the caller (the host has a random
1136    /// source; the engine is `no_std`). Caller should pass a fresh
1137    /// 16-byte random value per user.
1138    pub fn create_user(
1139        &mut self,
1140        name: &str,
1141        password: &str,
1142        role: Role,
1143        salt: [u8; 16],
1144    ) -> Result<(), UserError> {
1145        self.users.create(name, password, role, salt)?;
1146        // v4.8: also derive SCRAM-SHA-256 secrets so PG-wire SASL
1147        // auth can verify without re-running PBKDF2 per attempt.
1148        // Uses a fresh salt from the host RNG (falls back to a
1149        // deterministic per-username salt when no RNG is wired, same
1150        // as the legacy hash path).
1151        let scram_salt = self.salt_fn.map_or_else(
1152            || {
1153                let mut s = [0u8; users::SCRAM_SALT_LEN];
1154                let digest = spg_crypto::hash(name.as_bytes());
1155                // Use bytes 16..32 of BLAKE3 so we don't reuse the
1156                // exact same fallback salt as the BLAKE3 hash path.
1157                s.copy_from_slice(&digest[16..32]);
1158                s
1159            },
1160            |f| f(),
1161        );
1162        self.users
1163            .enable_scram(name, password, scram_salt, users::SCRAM_DEFAULT_ITERS)?;
1164        Ok(())
1165    }
1166
1167    pub fn drop_user(&mut self, name: &str) -> Result<(), UserError> {
1168        self.users.drop(name)
1169    }
1170
1171    pub fn verify_user(&self, name: &str, password: &str) -> Option<Role> {
1172        self.users.verify(name, password)
1173    }
1174
1175    /// Builder: attach a wall clock so `NOW()` / `CURRENT_TIMESTAMP` /
1176    /// `CURRENT_DATE` evaluate to a real value instead of erroring out.
1177    #[must_use]
1178    pub const fn with_clock(mut self, clock: ClockFn) -> Self {
1179        self.clock = Some(clock);
1180        self
1181    }
1182
1183    /// Builder: attach an OS-backed RNG for per-user password salts.
1184    /// The host (`spg-server`) typically wires this to `/dev/urandom`.
1185    #[must_use]
1186    pub const fn with_salt_fn(mut self, f: SaltFn) -> Self {
1187        self.salt_fn = Some(f);
1188        self
1189    }
1190
1191    /// Builder: cap the number of rows a single SELECT may return.
1192    /// Exceeding the cap raises `EngineError::RowLimitExceeded` —
1193    /// the bound is checked inside the executor so a runaway
1194    /// catalog scan can't allocate millions of rows before the
1195    /// server gets a chance to reject the result.
1196    #[must_use]
1197    pub const fn with_max_query_rows(mut self, n: usize) -> Self {
1198        self.max_query_rows = Some(n);
1199        self
1200    }
1201
1202    /// The *committed* catalog. Note: during a transaction this returns the
1203    /// pre-TX state — `SELECT` inside a TX goes through `execute()` and reads
1204    /// the shadow. Tests that inspect outside-TX state should use this.
1205    pub const fn catalog(&self) -> &Catalog {
1206        &self.catalog
1207    }
1208
1209    /// Serialize the *committed* catalog to bytes. v0.6 was full-snapshot; v0.9
1210    /// adds the rule that an open TX's shadow is never snapshotted — only the
1211    /// post-COMMIT state is persisted. v4.1 wraps the catalog in an envelope
1212    /// when there are users to persist; an empty user table snapshots as the
1213    /// bare catalog format (backwards-compat with v3.x readers). v6.1.2
1214    /// adds publications to the envelope condition: either non-empty
1215    /// users OR non-empty publications now triggers the envelope path.
1216    pub fn snapshot(&self) -> Vec<u8> {
1217        if self.users.is_empty()
1218            && self.publications.is_empty()
1219            && self.subscriptions.is_empty()
1220            && self.statistics.is_empty()
1221        {
1222            self.catalog.serialize()
1223        } else {
1224            build_envelope(
1225                &self.catalog.serialize(),
1226                &users::serialize_users(&self.users),
1227                &self.publications.serialize(),
1228                &self.subscriptions.serialize(),
1229                &self.statistics.serialize(),
1230            )
1231        }
1232    }
1233
1234    /// True when at least one TX slot is in flight. v4.41.1 runtime
1235    /// invariant: at most one slot active at a time (dispatch holds
1236    /// `engine.write()` across the entire wrap). v4.42 will let this
1237    /// return true with multiple slots concurrently.
1238    pub fn in_transaction(&self) -> bool {
1239        !self.tx_catalogs.is_empty()
1240    }
1241
1242    /// v4.41.1 allocate a fresh TX handle. Used by spg-server dispatch
1243    /// to scope each implicit-wrap BEGIN..stmt..COMMIT to its own slot
1244    /// in `tx_catalogs`. v4.42 — the commit-barrier leader allocates
1245    /// one of these per task in its group, runs `BEGIN`+sql+`COMMIT`
1246    /// sequentially under a single `engine.write()` so each task's
1247    /// mutations accumulate into shared state, then either keeps the
1248    /// accumulated state (fsync OK) or restores the pre-image via
1249    /// `replace_catalog` (fsync err).
1250    pub fn alloc_tx_id(&mut self) -> TxId {
1251        let id = TxId(self.next_tx_id);
1252        self.next_tx_id = self.next_tx_id.saturating_add(1);
1253        id
1254    }
1255
1256    /// v4.42 — atomically replace the live catalog. Used by the
1257    /// commit-barrier leader to roll back a group whose batched
1258    /// fsync failed: the leader snapshots `engine.catalog().clone()`
1259    /// (O(1) Arc bump after the v4.39/v4.40 persistent migration)
1260    /// at group start, sequentially applies each task's BEGIN+sql+
1261    /// COMMIT under the same write lock to accumulate mutations
1262    /// into shared state, batches the WAL bytes, fsyncs once, and
1263    /// on failure calls this with the pre-image to undo every
1264    /// task in the group at once.
1265    ///
1266    /// **Does NOT touch `tx_catalogs` / `current_tx`.** Any
1267    /// explicit-TX slot from a concurrent client (created via the
1268    /// legacy `IMPLICIT_TX`-less dispatch path or via the future
1269    /// MVCC-readers v5+ work) has its own snapshot baked into the
1270    /// slot — restoring `self.catalog` to the pre-image leaves
1271    /// those slots untouched, exactly as they were when the leader
1272    /// took the lock. The leader's own implicit-TX slots are all
1273    /// already discarded (`exec_commit` removed them as each
1274    /// task's COMMIT ran) by the time this is reached.
1275    pub fn replace_catalog(&mut self, catalog: Catalog) {
1276        self.catalog = catalog;
1277    }
1278
1279    /// v6.7.0 — public shim around `Catalog::freeze_oldest_to_cold`
1280    /// so tests + the spg-server freezer can drive a freeze without
1281    /// reaching into the private `active_catalog_mut`. v6.7.4
1282    /// parallel freezer will build on this surface.
1283    ///
1284    /// Marks the table's cached `cold_row_count` stale because the
1285    /// freeze added cold locators that ANALYZE hasn't yet refreshed.
1286    pub fn freeze_oldest_to_cold(
1287        &mut self,
1288        table_name: &str,
1289        index_name: &str,
1290        max_rows: usize,
1291    ) -> Result<spg_storage::FreezeReport, EngineError> {
1292        let report = self
1293            .active_catalog_mut()
1294            .freeze_oldest_to_cold(table_name, index_name, max_rows)
1295            .map_err(EngineError::Storage)?;
1296        if let Some(t) = self.active_catalog_mut().get_mut(table_name) {
1297            t.mark_cold_row_count_stale();
1298        }
1299        Ok(report)
1300    }
1301
1302    /// v6.7.5 — public shim used by the spg-server follower's
1303    /// segment-forwarding receiver. Registers a cold-tier segment
1304    /// at a specific id (the master's id, as transmitted on the
1305    /// wire) so the follower's BTree-Cold locators stay byte-
1306    /// identical with the master's. Wraps
1307    /// `Catalog::load_segment_bytes_at` under the standard
1308    /// clone-mutate-replace pattern.
1309    ///
1310    /// Returns `Ok(())` on success **and** on the "slot already
1311    /// occupied" case — a follower mid-reconnect may receive a
1312    /// segment chunk for a segment_id it already has on disk
1313    /// (forwarded last session); the caller should treat that
1314    /// path as a no-op rather than a fatal error.
1315    pub fn receive_cold_segment(
1316        &mut self,
1317        segment_id: u32,
1318        bytes: Vec<u8>,
1319    ) -> Result<(), EngineError> {
1320        let mut new_cat = self.catalog.clone();
1321        match new_cat.load_segment_bytes_at(segment_id, bytes) {
1322            Ok(()) => {
1323                self.replace_catalog(new_cat);
1324                Ok(())
1325            }
1326            Err(StorageError::Corrupt(msg)) if msg.contains("already occupied") => Ok(()),
1327            Err(e) => Err(EngineError::Storage(e)),
1328        }
1329    }
1330
1331    /// v6.7.3 — public shim around `Catalog::compact_cold_segments`
1332    /// driving every BTree index on every user table. Returns one
1333    /// `(table, index, report)` triple for each merge that
1334    /// actually happened (no-op (table, index) pairs are filtered
1335    /// out so callers can size persist-side work to the live
1336    /// merges). Caller is responsible for persisting each
1337    /// `report.merged_segment_bytes` and updating the on-disk
1338    /// segment registry; engine layer is no_std and never
1339    /// touches disk.
1340    ///
1341    /// Marks every touched table's cached `cold_row_count` stale
1342    /// — compaction GC'd some shadowed rows, so the count must be
1343    /// re-derived on the next ANALYZE.
1344    pub fn compact_cold_segments_with_target(
1345        &mut self,
1346        target_segment_bytes: u64,
1347    ) -> Result<Vec<(String, String, CompactReport)>, EngineError> {
1348        let table_names = self.active_catalog().table_names();
1349        let mut reports: Vec<(String, String, CompactReport)> = Vec::new();
1350        for tname in table_names {
1351            if is_internal_table_name(&tname) {
1352                continue;
1353            }
1354            let idx_names: Vec<String> = {
1355                let Some(t) = self.active_catalog().get(&tname) else {
1356                    continue;
1357                };
1358                t.indices()
1359                    .iter()
1360                    .filter(|i| matches!(i.kind, IndexKind::BTree(_)))
1361                    .map(|i| i.name.clone())
1362                    .collect()
1363            };
1364            for iname in idx_names {
1365                let report = self
1366                    .active_catalog_mut()
1367                    .compact_cold_segments(&tname, &iname, target_segment_bytes)
1368                    .map_err(EngineError::Storage)?;
1369                if report.merged_segment_id.is_some() {
1370                    if let Some(t) = self.active_catalog_mut().get_mut(&tname) {
1371                        t.mark_cold_row_count_stale();
1372                    }
1373                    reports.push((tname.clone(), iname, report));
1374                }
1375            }
1376        }
1377        Ok(reports)
1378    }
1379
1380    fn active_catalog(&self) -> &Catalog {
1381        match self.current_tx {
1382            Some(t) => self
1383                .tx_catalogs
1384                .get(&t)
1385                .map_or(&self.catalog, |s| &s.catalog),
1386            None => &self.catalog,
1387        }
1388    }
1389
1390    /// v7.12.4 — snapshot every row-level trigger on `table` that
1391    /// fires for `event` (`"INSERT"` / `"UPDATE"` / `"DELETE"`) at
1392    /// the given `timing` (`"BEFORE"` / `"AFTER"`), and clone its
1393    /// referenced function definition. Returned as a vec of owned
1394    /// `FunctionDef` so the row-write loop can fire them without
1395    /// holding a borrow on the catalog (which would conflict with
1396    /// the table.insert / update_row / delete mutable borrows).
1397    /// v7.16.2 — top-level DO block executor. Walks the
1398    /// PlPgSqlBlock via [`triggers::execute_do_block_top_level`],
1399    /// then runs each collected EmbeddedSql statement through
1400    /// the engine's regular execute path (NOT deferred — DO is
1401    /// outside any row-write borrow). Errors from any step
1402    /// abort the block and propagate verbatim.
1403    /// v7.16.2 — resolve every subquery inside a PlPgSqlBlock's
1404    /// expression slots so the downstream trigger-flavoured
1405    /// evaluator (which expects pre-resolved Expr::Literal /
1406    /// Binary chains) doesn't trip on raw Exists/ScalarSubquery
1407    /// nodes. Walks IF conditions, Assign values, RAISE args.
1408    /// EmbeddedSql statements re-enter the engine for execution
1409    /// later so their subqueries get the normal SELECT-side
1410    /// resolution.
1411    fn resolve_plpgsql_block_subqueries(
1412        &self,
1413        block: &mut spg_sql::ast::PlPgSqlBlock,
1414        cancel: CancelToken<'_>,
1415    ) -> Result<(), EngineError> {
1416        for d in &mut block.declarations {
1417            if let Some(e) = &mut d.default {
1418                self.resolve_expr_subqueries(e, cancel)?;
1419            }
1420        }
1421        self.resolve_plpgsql_stmts_subqueries(&mut block.statements, cancel)
1422    }
1423
1424    fn resolve_plpgsql_stmts_subqueries(
1425        &self,
1426        stmts: &mut [spg_sql::ast::PlPgSqlStmt],
1427        cancel: CancelToken<'_>,
1428    ) -> Result<(), EngineError> {
1429        use spg_sql::ast::PlPgSqlStmt;
1430        for stmt in stmts {
1431            match stmt {
1432                PlPgSqlStmt::Assign { value, .. } => {
1433                    self.resolve_expr_subqueries(value, cancel)?;
1434                }
1435                PlPgSqlStmt::Return(spg_sql::ast::ReturnTarget::Expr(e)) => {
1436                    self.resolve_expr_subqueries(e, cancel)?;
1437                }
1438                PlPgSqlStmt::Return(_) => {}
1439                PlPgSqlStmt::If {
1440                    branches,
1441                    else_branch,
1442                } => {
1443                    for (cond, body) in branches.iter_mut() {
1444                        self.resolve_expr_subqueries(cond, cancel)?;
1445                        self.resolve_plpgsql_stmts_subqueries(body, cancel)?;
1446                    }
1447                    self.resolve_plpgsql_stmts_subqueries(else_branch, cancel)?;
1448                }
1449                PlPgSqlStmt::Raise { args, .. } => {
1450                    for a in args {
1451                        self.resolve_expr_subqueries(a, cancel)?;
1452                    }
1453                }
1454                PlPgSqlStmt::EmbeddedSql(_) => {
1455                    // Embedded SQL goes back through execute_stmt
1456                    // _with_cancel which runs the SELECT-side
1457                    // resolver itself; nothing to do here.
1458                }
1459                PlPgSqlStmt::SelectInto { body, .. } => {
1460                    // SELECT INTO runs through Engine::execute
1461                    // when reached, so subquery resolution
1462                    // happens via the normal SELECT-side path.
1463                    // Still walk for nested subqueries inside
1464                    // the SELECT body so eval doesn't trip.
1465                    self.resolve_select_subqueries(body, cancel)?;
1466                }
1467            }
1468        }
1469        Ok(())
1470    }
1471
1472    fn exec_do_block(
1473        &mut self,
1474        body: spg_sql::ast::PlPgSqlBlock,
1475    ) -> Result<QueryResult, EngineError> {
1476        // v7.16.2 — pre-resolve every subquery the body's
1477        // expressions reach. `eval::eval_expr` errors on
1478        // unresolved Exists/ScalarSubquery/InSubquery; the
1479        // top-level SELECT path runs `resolve_select_subqueries`
1480        // for the caller — for plpgsql we have to do the
1481        // equivalent before the body walker runs. Catches the
1482        // mailrs idiom `IF EXISTS (SELECT 1 FROM
1483        // information_schema.columns WHERE …) THEN …`.
1484        let mut body = body;
1485        self.resolve_plpgsql_block_subqueries(&mut body, CancelToken::none())?;
1486        let dts = self
1487            .session_param("default_text_search_config")
1488            .map(String::from);
1489        // v7.16.2 — SELECT … INTO resolver. The walker calls
1490        // this synchronously when it hits a SelectInto stmt
1491        // so the IF / locals scope sees the result before the
1492        // next statement. Body walks for trigger paths (no
1493        // resolver) error loudly on SelectInto.
1494        // SAFETY: the closure shares this engine borrow with
1495        // the walker, but the walker only borrows for the
1496        // duration of `execute_do_block_top_level` and doesn't
1497        // reach back into the engine through any other path —
1498        // so the recursive `&mut` is sound. We use a `RefCell`
1499        // for interior mutability since the closure is
1500        // Fn-shaped.
1501        let engine_cell = core::cell::RefCell::new(&mut *self);
1502        let resolver_fn =
1503            |stmt: &spg_sql::ast::Statement| -> Result<Value, triggers::TriggerError> {
1504                let mut eng = engine_cell.borrow_mut();
1505                let r = eng
1506                    .execute_stmt_with_cancel(stmt.clone(), CancelToken::none())
1507                    .map_err(|e| triggers::TriggerError::EvalFailed {
1508                        function: "DO".into(),
1509                        cause: eval::EvalError::TypeMismatch {
1510                            detail: alloc::format!("SELECT … INTO failed: {e}"),
1511                        },
1512                    })?;
1513                match r {
1514                    QueryResult::Rows { rows, .. } => match rows.into_iter().next() {
1515                        Some(row) => Ok(row.values.into_iter().next().unwrap_or(Value::Null)),
1516                        None => Ok(Value::Null),
1517                    },
1518                    _ => Err(triggers::TriggerError::EvalFailed {
1519                        function: "DO".into(),
1520                        cause: eval::EvalError::TypeMismatch {
1521                            detail: "SELECT … INTO body must be a SELECT".into(),
1522                        },
1523                    }),
1524                }
1525            };
1526        let collected =
1527            triggers::execute_do_block_top_level(&body, dts.as_deref(), Some(&resolver_fn))
1528                .map_err(|e| {
1529                    EngineError::Storage(StorageError::Corrupt(alloc::format!("DO: {e}")))
1530                })?;
1531        // engine_cell goes out of scope here, releasing the &mut self borrow
1532        // Run each embedded statement against the engine. The
1533        // statements were already substitute-walked for NEW/OLD/
1534        // locals (those evaluate to engine literals before they
1535        // land here) so dispatch is plain execute_stmt_with_cancel.
1536        for stmt in collected {
1537            // v7.16.2 — preserve current_tx wrap so an outer
1538            // BEGIN/COMMIT around a DO block keeps the
1539            // EmbeddedSql writes inside that same tx slot.
1540            self.execute_stmt_with_cancel(stmt, CancelToken::none())?;
1541        }
1542        Ok(QueryResult::CommandOk {
1543            affected: 0,
1544            modified_catalog: !self.in_transaction(),
1545        })
1546    }
1547
1548    fn snapshot_row_triggers(
1549        &self,
1550        table: &str,
1551        event: &str,
1552        timing: &str,
1553    ) -> Vec<spg_storage::FunctionDef> {
1554        let cat = self.active_catalog();
1555        cat.triggers()
1556            .iter()
1557            .filter(|t| {
1558                // v7.16.1 — skip disabled triggers (mailrs
1559                // round-9 A.2.b — pg_dump --disable-triggers).
1560                t.enabled
1561                    && t.table == table
1562                    && t.timing.eq_ignore_ascii_case(timing)
1563                    && t.for_each.eq_ignore_ascii_case("row")
1564                    && t.events.iter().any(|e| e.eq_ignore_ascii_case(event))
1565            })
1566            .filter_map(|t| cat.functions().get(&t.function).cloned())
1567            .collect()
1568    }
1569
1570    /// v7.13.0 — UPDATE-side snapshot that pairs each trigger's
1571    /// function with its `UPDATE OF cols` filter (mailrs round-5
1572    /// G7). Empty filter Vec means "fire unconditionally", matching
1573    /// the v7.12 behaviour.
1574    fn snapshot_update_row_triggers(
1575        &self,
1576        table: &str,
1577        timing: &str,
1578    ) -> Vec<(spg_storage::FunctionDef, Vec<String>)> {
1579        let cat = self.active_catalog();
1580        cat.triggers()
1581            .iter()
1582            .filter(|t| {
1583                // v7.16.1 — skip disabled triggers.
1584                t.enabled
1585                    && t.table == table
1586                    && t.timing.eq_ignore_ascii_case(timing)
1587                    && t.for_each.eq_ignore_ascii_case("row")
1588                    && t.events.iter().any(|e| e.eq_ignore_ascii_case("UPDATE"))
1589            })
1590            .filter_map(|t| {
1591                cat.functions()
1592                    .get(&t.function)
1593                    .cloned()
1594                    .map(|fd| (fd, t.update_columns.clone()))
1595            })
1596            .collect()
1597    }
1598
1599    /// v7.12.7 — drain the trigger-emitted embedded SQL queue.
1600    /// Called by the INSERT / UPDATE / DELETE executors after
1601    /// their main row-write loop returns. Each statement runs
1602    /// inside the same cancel scope as the firing DML and bumps
1603    /// the recursion counter; nested embedded SQL beyond
1604    /// [`MAX_TRIGGER_RECURSION`] errors with a clear message so
1605    /// a trigger-graph cycle surfaces as a query failure instead
1606    /// of stack-blowing the engine.
1607    fn execute_deferred_trigger_stmts(
1608        &mut self,
1609        deferred: Vec<triggers::DeferredEmbeddedStmt>,
1610        cancel: CancelToken<'_>,
1611    ) -> Result<(), EngineError> {
1612        for d in deferred {
1613            if self.trigger_recursion_depth >= MAX_TRIGGER_RECURSION {
1614                return Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
1615                    "trigger embedded SQL recursion depth {} exceeded (trigger function \
1616                     {:?} would push past the {} cap — check for trigger cycles)",
1617                    self.trigger_recursion_depth,
1618                    d.function,
1619                    MAX_TRIGGER_RECURSION,
1620                ))));
1621            }
1622            self.trigger_recursion_depth += 1;
1623            let res = self.execute_stmt_with_cancel(d.stmt, cancel);
1624            self.trigger_recursion_depth -= 1;
1625            res?;
1626        }
1627        Ok(())
1628    }
1629
1630    fn active_catalog_mut(&mut self) -> &mut Catalog {
1631        let tx = self.current_tx;
1632        match tx {
1633            Some(t) => match self.tx_catalogs.get_mut(&t) {
1634                Some(s) => &mut s.catalog,
1635                None => &mut self.catalog,
1636            },
1637            None => &mut self.catalog,
1638        }
1639    }
1640
1641    /// Read-only execute path. Succeeds for `SELECT` / `SHOW TABLES`
1642    /// / `SHOW COLUMNS`; returns `EngineError::WriteRequired` for
1643    /// every other statement, so the caller can fall through to the
1644    /// `&mut self` `execute` path under a write lock. Engine state is
1645    /// not mutated even on the success path (`rewrite_clock_calls`
1646    /// and `resolve_order_by_position` both mutate the locally-owned
1647    /// AST, not `self`).
1648    ///
1649    /// **v4.0 concurrency**: this is the entry point the server takes
1650    /// under an `RwLock::read()` so multiple `SELECT` clients run in
1651    /// parallel without serialising on a single mutex.
1652    pub fn execute_readonly(&self, sql: &str) -> Result<QueryResult, EngineError> {
1653        self.execute_readonly_with_cancel(sql, CancelToken::none())
1654    }
1655
1656    /// v4.5 — read path with cooperative cancellation. Token's
1657    /// `is_cancelled` is checked at the start (so a watchdog that
1658    /// already fired returns Cancelled immediately) and at row-loop
1659    /// checkpoints inside `exec_select`. SHOW paths are O(small) and
1660    /// don't bother checking.
1661    pub fn execute_readonly_with_cancel(
1662        &self,
1663        sql: &str,
1664        cancel: CancelToken<'_>,
1665    ) -> Result<QueryResult, EngineError> {
1666        cancel.check()?;
1667        let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
1668        let now_micros = self.clock.map(|f| f());
1669        rewrite_clock_calls(&mut stmt, now_micros);
1670        if let Statement::Select(s) = &mut stmt {
1671            resolve_order_by_position(s);
1672            // v6.2.3 — cost-based JOIN reorder (read path).
1673            reorder::reorder_joins(s, &self.catalog, &self.statistics);
1674        }
1675        self.execute_readonly_stmt_with_cancel(stmt, cancel)
1676    }
1677
1678    /// v7.18 — readonly dispatch on a pre-parsed `Statement`.
1679    /// Internal helper shared by the SQL-string path
1680    /// ([`Engine::execute_readonly_with_cancel`]) and the prepared-
1681    /// statement path ([`Engine::execute_readonly_prepared_on_snapshot_with_cancel`]).
1682    /// Statement-level transforms (clock rewrite, ORDER BY position,
1683    /// JOIN reorder, placeholder substitution) are the caller's
1684    /// responsibility — this helper assumes the AST is already
1685    /// execution-ready. Writes / DDL hit
1686    /// [`EngineError::WriteRequired`] the same way the SQL path does.
1687    fn execute_readonly_stmt_with_cancel(
1688        &self,
1689        stmt: Statement,
1690        cancel: CancelToken<'_>,
1691    ) -> Result<QueryResult, EngineError> {
1692        let result = match stmt {
1693            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
1694            Statement::ShowTables => Ok(self.exec_show_tables()),
1695            Statement::ShowDatabases => Ok(self.exec_show_databases()),
1696            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
1697            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
1698            Statement::ShowStatus => Ok(self.exec_show_status()),
1699            Statement::ShowVariables => Ok(self.exec_show_variables()),
1700            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
1701            Statement::ShowColumns(table) => self.exec_show_columns(&table),
1702            Statement::ShowUsers => Ok(self.exec_show_users()),
1703            Statement::ShowPublications => Ok(self.exec_show_publications()),
1704            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
1705            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
1706                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
1707            )),
1708            Statement::Explain(e) => self.exec_explain(&e, cancel),
1709            _ => Err(EngineError::WriteRequired),
1710        };
1711        self.enforce_row_limit(result)
1712    }
1713
1714    /// v4.2: cap result-set size. Applied after the executor
1715    /// materialises rows but before they leave the engine — wrapping
1716    /// every Rows-returning exec_* function would scatter the check.
1717    fn enforce_row_limit(
1718        &self,
1719        result: Result<QueryResult, EngineError>,
1720    ) -> Result<QueryResult, EngineError> {
1721        if let (Ok(QueryResult::Rows { rows, .. }), Some(cap)) = (&result, self.max_query_rows)
1722            && rows.len() > cap
1723        {
1724            return Err(EngineError::RowLimitExceeded(cap));
1725        }
1726        result
1727    }
1728
1729    pub fn execute(&mut self, sql: &str) -> Result<QueryResult, EngineError> {
1730        self.execute_in_with_cancel(sql, IMPLICIT_TX, CancelToken::none())
1731    }
1732
1733    /// v4.5 — write path with cooperative cancellation. Same dispatch
1734    /// as `execute_in_with_cancel(sql, IMPLICIT_TX, cancel)`. Kept as
1735    /// a separate entry point for backward-compat with the v4.5
1736    /// public API.
1737    pub fn execute_with_cancel(
1738        &mut self,
1739        sql: &str,
1740        cancel: CancelToken<'_>,
1741    ) -> Result<QueryResult, EngineError> {
1742        self.execute_in_with_cancel(sql, IMPLICIT_TX, cancel)
1743    }
1744
1745    /// v4.41.1 multi-slot write entry. Routes `sql` through the TX
1746    /// slot identified by `tx_id` so spg-server dispatch can scope
1747    /// each implicit-wrap BEGIN..stmt..COMMIT to its own slot in
1748    /// `tx_catalogs`. `IMPLICIT_TX` is the legacy single-slot path
1749    /// every other caller (engine self-tests, replay, spg-embedded)
1750    /// implicitly takes via `execute()` / `execute_with_cancel()`.
1751    pub fn execute_in(&mut self, sql: &str, tx_id: TxId) -> Result<QueryResult, EngineError> {
1752        self.execute_in_with_cancel(sql, tx_id, CancelToken::none())
1753    }
1754
1755    /// v4.41.1 write path with cooperative cancellation + explicit TX
1756    /// scope. Sets `self.current_tx` for the duration of the call so
1757    /// every `exec_*` helper transparently sees its TX's shadow
1758    /// catalog and savepoint stack; restores on exit so the field is
1759    /// only valid mid-call (no leakage across calls).
1760    pub fn execute_in_with_cancel(
1761        &mut self,
1762        sql: &str,
1763        tx_id: TxId,
1764        cancel: CancelToken<'_>,
1765    ) -> Result<QueryResult, EngineError> {
1766        let saved = self.current_tx;
1767        self.current_tx = Some(tx_id);
1768        let result = self.execute_inner_with_cancel(sql, cancel);
1769        self.current_tx = saved;
1770        result
1771    }
1772
1773    /// v6.1.1 — parse and pre-process a SQL string ONCE so the
1774    /// resulting [`Statement`] can be cached and re-executed via
1775    /// [`Engine::execute_prepared`]. Returns the same `Statement`
1776    /// the simple-query path would synthesise internally (clock
1777    /// rewrites + ORDER BY position-ref resolution applied at
1778    /// prepare time, since both are session-independent). The
1779    /// `$N` placeholders in the SQL stay as `Expr::Placeholder(n)`
1780    /// nodes; they're resolved to concrete values per-call by
1781    /// `execute_prepared`'s substitution walk.
1782    ///
1783    /// Pgwire's `Parse` (P) message lands here.
1784    pub fn prepare(&self, sql: &str) -> Result<Statement, ParseError> {
1785        let mut stmt = parser::parse_statement_with(sql, self.backslash_escapes)?;
1786        let now_micros = self.clock.map(|f| f());
1787        rewrite_clock_calls(&mut stmt, now_micros);
1788        if let Statement::Select(s) = &mut stmt {
1789            // v6.4.1 — expand `GROUP BY ALL` to every non-aggregate
1790            // SELECT-list item BEFORE position / alias resolution so
1791            // downstream passes see the explicit list.
1792            expand_group_by_all(s);
1793            resolve_order_by_position(s);
1794            // v6.2.3 — cost-based JOIN reorder. No-op for
1795            // single-table FROMs or any non-INNER join shape.
1796            reorder::reorder_joins(s, &self.catalog, &self.statistics);
1797        }
1798        Ok(stmt)
1799    }
1800
1801    /// v6.3.0 — cached prepare. Returns a cloned `Statement` from
1802    /// the plan cache on hit, runs the full `prepare()` path on miss
1803    /// and inserts the resulting plan before returning. Skipping the
1804    /// parse + JOIN-reorder pipeline on hit is the dominant win for
1805    /// JDBC / sqlx / pgx clients that reuse the same SQL string.
1806    ///
1807    /// Returns a cloned `Statement` (not a borrow) because the
1808    /// pgwire layer owns its `PreparedStmt` map per-session and the
1809    /// engine-level cache must stay available for other sessions.
1810    /// Clone cost on a 5-table JOIN AST is well under the parse cost
1811    /// it replaces.
1812    pub fn prepare_cached(&mut self, sql: &str) -> Result<Statement, ParseError> {
1813        // v6.3.1 — version-aware lookup. If the cached plan was
1814        // prepared before the most recent ANALYZE, evict and replan.
1815        let current_version = self.statistics.version();
1816        if let Some(plan) = self.plan_cache.get(sql) {
1817            if plan.statistics_version == current_version {
1818                return Ok(plan.stmt.clone());
1819            }
1820            // Stale entry — fall through to evict + re-prepare.
1821        }
1822        self.plan_cache.evict(sql);
1823        let stmt = self.prepare(sql)?;
1824        let source_tables = plan_cache::collect_source_tables(&stmt);
1825        let plan = plan_cache::PreparedPlan {
1826            stmt: stmt.clone(),
1827            statistics_version: current_version,
1828            source_tables,
1829            describe_columns: alloc::vec::Vec::new(),
1830        };
1831        self.plan_cache.insert(String::from(sql), plan);
1832        Ok(stmt)
1833    }
1834
1835    /// v6.3.0 — read-only accessor for tests and v6.3.1 invalidation.
1836    pub fn plan_cache(&self) -> &plan_cache::PlanCache {
1837        &self.plan_cache
1838    }
1839
1840    /// v6.3.0 — mutable accessor for v6.3.1 invalidation hooks.
1841    pub fn plan_cache_mut(&mut self) -> &mut plan_cache::PlanCache {
1842        &mut self.plan_cache
1843    }
1844
1845    /// v6.3.3 — Describe a prepared `Statement` without executing.
1846    /// Returns `(parameter_oids, output_columns)`. Empty
1847    /// `output_columns` means the statement has no row-producing
1848    /// shape we could resolve here (JOIN, subquery, non-SELECT, …)
1849    /// — pgwire layer maps that to a `NoData` reply.
1850    pub fn describe_prepared(&self, stmt: &Statement) -> (Vec<u32>, Vec<ColumnSchema>) {
1851        describe::describe_prepared(stmt, self.active_catalog())
1852    }
1853
1854    /// v6.1.1 — execute a [`Statement`] previously returned by
1855    /// [`Engine::prepare`], substituting `Expr::Placeholder(n)`
1856    /// nodes for the corresponding [`Value`] in `params` (1-based
1857    /// per PG: `$1` → `params[0]`). Bind-time string parameters
1858    /// are decoded into typed `Value`s by the pgwire layer before
1859    /// this call so the resulting AST hits the same execution
1860    /// path as a simple query — no SQL re-parse.
1861    ///
1862    /// Pgwire's `Execute` (E) message after a `Bind` (B) lands here.
1863    pub fn execute_prepared(
1864        &mut self,
1865        stmt: Statement,
1866        params: &[Value],
1867    ) -> Result<QueryResult, EngineError> {
1868        self.execute_prepared_with_cancel(stmt, params, CancelToken::none())
1869    }
1870
1871    /// v7.17.0 Phase 2.3 — prepared-statement entry that honors a
1872    /// caller-supplied `CancelToken`. Mirrors `execute_prepared`'s
1873    /// `current_tx` save/restore so the extended-query path stays
1874    /// transactionally consistent with the simple-query path.
1875    pub fn execute_prepared_with_cancel(
1876        &mut self,
1877        mut stmt: Statement,
1878        params: &[Value],
1879        cancel: CancelToken<'_>,
1880    ) -> Result<QueryResult, EngineError> {
1881        substitute_placeholders(&mut stmt, params)?;
1882        // v7.16.0 — set `current_tx` for the duration of the
1883        // dispatch so the `exec_*` helpers see the right TX
1884        // slot (matches what `execute_in_with_cancel` does for
1885        // simple-query). Pre-v7.16 the simple-query path
1886        // worked because every public entry point routed
1887        // through `execute_in_with_cancel`; the prepared path
1888        // skipped the wrap and so its INSERTs/UPDATEs landed
1889        // in the no-tx default slot, silently invisible to a
1890        // BEGIN/COMMIT-bracketed flow. Caught by spg-sqlx's
1891        // first transaction-visibility test.
1892        let saved = self.current_tx;
1893        self.current_tx = Some(IMPLICIT_TX);
1894        let result = self.execute_stmt_with_cancel(stmt, cancel);
1895        self.current_tx = saved;
1896        result
1897    }
1898
1899    fn execute_inner_with_cancel(
1900        &mut self,
1901        sql: &str,
1902        cancel: CancelToken<'_>,
1903    ) -> Result<QueryResult, EngineError> {
1904        cancel.check()?;
1905        let stmt = self.prepare(sql)?;
1906        // v6.5.1 — wrap the executor with a wall-clock window so we
1907        // can record into spg_stat_query. Skip when the engine has
1908        // no clock attached (no_std embedded callers).
1909        let start_us = self.clock.map(|f| f());
1910        let result = self.execute_stmt_with_cancel(stmt, cancel);
1911        if let (Some(t0), Ok(_)) = (start_us, &result) {
1912            let now = self.clock.map_or(t0, |f| f());
1913            let elapsed = now.saturating_sub(t0).max(0) as u64;
1914            self.query_stats.record(sql, elapsed, now as u64);
1915            // v6.5.6 — slow-query log: fire callback when elapsed
1916            // exceeds the configured floor.
1917            if let (Some(threshold), Some(logger)) =
1918                (self.slow_query_threshold_us, self.slow_query_logger)
1919                && elapsed >= threshold
1920            {
1921                logger(sql, elapsed);
1922            }
1923        }
1924        result
1925    }
1926
1927    fn execute_stmt_with_cancel(
1928        &mut self,
1929        stmt: Statement,
1930        cancel: CancelToken<'_>,
1931    ) -> Result<QueryResult, EngineError> {
1932        cancel.check()?;
1933        // v7.17.0 Phase 1.1 — pre-resolve nextval / currval /
1934        // setval calls in the statement tree. Walks SELECT
1935        // projection, INSERT VALUES, UPDATE SET, DELETE WHERE,
1936        // and DEFAULT exprs; replaces sequence FunctionCall
1937        // nodes with concrete Literal values minted against the
1938        // catalog. This is the only place that mutates sequence
1939        // state from a SELECT-shaped path (exec_select_cancel is
1940        // `&self` and can't reach the catalog mutably).
1941        //
1942        // Fast-path: when no sequences exist anywhere in the
1943        // catalog (the typical hot-path INSERT load), skip the
1944        // walker entirely. Single map-emptiness check on the
1945        // catalog beats walking every expression on every call.
1946        let mut stmt = stmt;
1947        // v7.17 dump-compat — the fast-path check
1948        // `sequences().is_empty()` skips pre-resolve when no
1949        // sequence exists in the *currently active* catalog
1950        // snapshot. The committed catalog or the implicit-TX
1951        // catalog may legitimately disagree on this between
1952        // CREATE SEQUENCE and a later setval(): always run the
1953        // resolver — the walk is O(expr-count) and dwarfed by
1954        // the parse cost we just paid.
1955        self.pre_resolve_sequence_calls_in_statement(&mut stmt)?;
1956        let result = match stmt {
1957            Statement::CreateTable(s) => self.exec_create_table(s),
1958            // v7.9.15 — CREATE EXTENSION is a no-op on SPG. Returns
1959            // CommandOk with affected=0; modified_catalog=false so
1960            // the WAL doesn't grow a useless entry. mailrs F3.
1961            Statement::CreateExtension(_) => Ok(QueryResult::CommandOk {
1962                affected: 0,
1963                modified_catalog: false,
1964            }),
1965            // v7.16.2 — DO $$ ... $$ block. mailrs round-10 A.2
1966            // — the pre-v7.9.27 no-op SILENTLY swallowed every
1967            // mailrs migrate-038/-040/-042 idempotent rename
1968            // (the IF EXISTS … THEN ALTER … END block never
1969            // ran). v7.16.2 dispatches to exec_do_block which
1970            // runs the PlPgSqlBlock at top level via the same
1971            // execute_stmts machinery the trigger executor
1972            // uses (NEW=None, OLD=None — DO blocks have no
1973            // row context).
1974            Statement::DoBlock(body) => self.exec_do_block(body),
1975            // v7.14.0 — empty-statement no-op for pg_dump /
1976            // mysqldump preamble lines that collapse to nothing
1977            // after comment-stripping.
1978            Statement::Empty => Ok(QueryResult::CommandOk {
1979                affected: 0,
1980                modified_catalog: false,
1981            }),
1982            Statement::DropTable { names, if_exists } => self.exec_drop_table(names, if_exists),
1983            Statement::DropIndex { name, if_exists } => self.exec_drop_index(name, if_exists),
1984            Statement::CreateIndex(s) => self.exec_create_index(s),
1985            Statement::Insert(s) => self.exec_insert(s),
1986            Statement::Update(mut s) => {
1987                // Materialise uncorrelated subqueries in SET / WHERE
1988                // before the row walk — the SELECT path has done this
1989                // since v4.10; UPDATE gained it for mailrs's
1990                // `UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP
1991                // LOCKED)` claim pattern (embed round-12).
1992                for (_, e) in &mut s.assignments {
1993                    self.resolve_expr_subqueries(e, cancel)?;
1994                }
1995                if let Some(w) = &mut s.where_ {
1996                    self.resolve_expr_subqueries(w, cancel)?;
1997                }
1998                self.exec_update_cancel(&s, cancel)
1999            }
2000            Statement::Delete(mut s) => {
2001                if let Some(w) = &mut s.where_ {
2002                    self.resolve_expr_subqueries(w, cancel)?;
2003                }
2004                self.exec_delete_cancel(&s, cancel)
2005            }
2006            Statement::Merge(s) => self.exec_merge_cancel(&s, cancel),
2007            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
2008            Statement::Begin => self.exec_begin(),
2009            Statement::Commit => self.exec_commit(),
2010            Statement::Rollback => self.exec_rollback(),
2011            Statement::Savepoint(name) => self.exec_savepoint(name),
2012            Statement::RollbackToSavepoint(name) => self.exec_rollback_to_savepoint(&name),
2013            Statement::ReleaseSavepoint(name) => self.exec_release_savepoint(&name),
2014            Statement::ShowTables => Ok(self.exec_show_tables()),
2015            Statement::ShowDatabases => Ok(self.exec_show_databases()),
2016            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
2017            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
2018            Statement::ShowStatus => Ok(self.exec_show_status()),
2019            Statement::ShowVariables => Ok(self.exec_show_variables()),
2020            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
2021            Statement::ShowColumns(table) => self.exec_show_columns(&table),
2022            Statement::ShowUsers => Ok(self.exec_show_users()),
2023            Statement::ShowPublications => Ok(self.exec_show_publications()),
2024            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
2025            Statement::CreateUser(s) => self.exec_create_user(&s),
2026            Statement::DropUser(name) => self.exec_drop_user(&name),
2027            Statement::Explain(e) => self.exec_explain(&e, cancel),
2028            Statement::AlterIndex(s) => self.exec_alter_index(s),
2029            Statement::AlterTable(s) => self.exec_alter_table(s),
2030            Statement::CreatePublication(s) => self.exec_create_publication(s),
2031            Statement::DropPublication(name) => self.exec_drop_publication(&name),
2032            Statement::CreateSubscription(s) => self.exec_create_subscription(s),
2033            Statement::DropSubscription(name) => self.exec_drop_subscription(&name),
2034            // v6.1.7 — WAIT FOR WAL POSITION needs `lag_state`,
2035            // which lives in spg-server's ServerState. The engine
2036            // surfaces a clear error; the server-layer dispatch
2037            // intercepts the SQL before it reaches the engine on
2038            // a server build, so this arm only fires for
2039            // engine-only callers (spg-embedded, lib tests).
2040            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
2041                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
2042            )),
2043            // v6.2.0 — ANALYZE recomputes per-column histograms.
2044            Statement::Analyze(target) => self.exec_analyze(target.as_deref()),
2045            // v6.7.3 — COMPACT COLD SEGMENTS.
2046            Statement::CompactColdSegments => self.exec_compact_cold_segments(),
2047            // v7.12.1 — SET / RESET session parameter. Engine
2048            // tracks the value in `session_params`; FTS dispatcher
2049            // reads `default_text_search_config`. Everything else
2050            // is a recorded no-op (PG dump compat).
2051            Statement::SetParameter { name, value } => {
2052                self.set_session_param(name, value);
2053                Ok(QueryResult::CommandOk {
2054                    affected: 0,
2055                    modified_catalog: false,
2056                })
2057            }
2058            // v7.14.0 — MySQL multi-assignment SET. Each pair runs
2059            // through `set_session_param` so engine-known params
2060            // (FOREIGN_KEY_CHECKS, session_replication_role, …) take
2061            // effect; unknown pairs (including `@VAR` LHS from the
2062            // mysqldump preamble) are recorded then ignored.
2063            Statement::SetParameterList(pairs) => {
2064                for (name, value) in pairs {
2065                    self.set_session_param(name, value);
2066                }
2067                Ok(QueryResult::CommandOk {
2068                    affected: 0,
2069                    modified_catalog: false,
2070                })
2071            }
2072            // v7.12.4 — CREATE FUNCTION / CREATE TRIGGER / DROP …
2073            // for the PL/pgSQL trigger surface. exec_* methods are
2074            // defined alongside the existing CREATE handlers below.
2075            Statement::CreateFunction(s) => self.exec_create_function(s),
2076            Statement::CreateTrigger(s) => self.exec_create_trigger(s),
2077            Statement::DropTrigger {
2078                name,
2079                table,
2080                if_exists,
2081            } => self.exec_drop_trigger(&name, &table, if_exists),
2082            Statement::DropFunction { name, if_exists } => {
2083                self.exec_drop_function(&name, if_exists)
2084            }
2085            Statement::CreateSequence(s) => self.exec_create_sequence(s),
2086            Statement::AlterSequence(s) => self.exec_alter_sequence(s),
2087            Statement::DropSequence { names, if_exists } => {
2088                self.exec_drop_sequence(&names, if_exists)
2089            }
2090            Statement::CreateView(s) => self.exec_create_view(s),
2091            Statement::DropView { names, if_exists } => self.exec_drop_view(&names, if_exists),
2092            Statement::CreateMaterializedView(s) => self.exec_create_materialized_view(s),
2093            Statement::RefreshMaterializedView { name, with_data } => {
2094                self.exec_refresh_materialized_view(&name, with_data)
2095            }
2096            Statement::DropMaterializedView { names, if_exists } => {
2097                self.exec_drop_materialized_view(&names, if_exists)
2098            }
2099            Statement::CreateType(s) => self.exec_create_type(s),
2100            Statement::DropType { names, if_exists } => self.exec_drop_type(&names, if_exists),
2101            Statement::CreateDomain(s) => self.exec_create_domain(s),
2102            Statement::DropDomain { names, if_exists } => self.exec_drop_domain(&names, if_exists),
2103            Statement::CreateSchema {
2104                name,
2105                if_not_exists,
2106            } => self.exec_create_schema(name, if_not_exists),
2107            Statement::DropSchema { names, if_exists } => self.exec_drop_schema(&names, if_exists),
2108            Statement::ResetParameter(target) => {
2109                match target {
2110                    None => self.session_params.clear(),
2111                    Some(name) => {
2112                        self.session_params.remove(&name.to_ascii_lowercase());
2113                    }
2114                }
2115                Ok(QueryResult::CommandOk {
2116                    affected: 0,
2117                    modified_catalog: false,
2118                })
2119            }
2120        };
2121        self.enforce_row_limit(result)
2122    }
2123
2124    /// v6.1.2 — `CREATE PUBLICATION` runtime path. Duplicate names
2125    /// surface as `EngineError::Unsupported` so the existing PG-wire
2126    /// error mapping stays uniform; the message carries the name so
2127    /// operators can grep replication-log noise. Inside-transaction
2128    /// invocation is rejected (matches `CREATE USER` / `DROP USER`
2129    /// stance) — replication-catalog mutation is a connection-level
2130    /// administrative op, not a transactional one.
2131    fn exec_create_publication(
2132        &mut self,
2133        s: CreatePublicationStatement,
2134    ) -> Result<QueryResult, EngineError> {
2135        // v6.1.4 — the v6.1.2 "no DDL inside a transaction" guard
2136        // was over-cautious: it also blocked the auto-commit wrap
2137        // path (which begins an internal TX around every WAL-
2138        // logged statement). PG itself allows CREATE PUBLICATION
2139        // inside a transaction (it rolls back with the TX).
2140        self.publications
2141            .create(s.name, s.scope)
2142            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE PUBLICATION: {e:?}")))?;
2143        Ok(QueryResult::CommandOk {
2144            affected: 1,
2145            modified_catalog: true,
2146        })
2147    }
2148
2149    /// v6.1.2 — `DROP PUBLICATION` runtime path. PG-compatible silent
2150    /// no-op when the publication doesn't exist (returns `affected=0`
2151    /// in that case so the wire-level command tag distinguishes
2152    /// "dropped" from "no-op", though both succeed).
2153    fn exec_drop_publication(&mut self, name: &str) -> Result<QueryResult, EngineError> {
2154        let removed = self.publications.drop(name);
2155        Ok(QueryResult::CommandOk {
2156            affected: usize::from(removed),
2157            modified_catalog: removed,
2158        })
2159    }
2160
2161    /// v6.1.2 — read access to the publication catalog. Used by
2162    /// the v6.1.5 publisher-side WAL filter, by `SHOW PUBLICATIONS`
2163    /// (v6.1.3+), and by e2e tests that need to assert state without
2164    /// going through the wire.
2165    pub const fn publications(&self) -> &publications::Publications {
2166        &self.publications
2167    }
2168
2169    /// v6.1.4 — `CREATE SUBSCRIPTION` runtime path. Defaults
2170    /// `enabled = true` and `last_received_pos = 0` for a freshly-
2171    /// created subscription. The actual worker thread is spawned
2172    /// by spg-server once the engine returns success.
2173    fn exec_create_subscription(
2174        &mut self,
2175        s: CreateSubscriptionStatement,
2176    ) -> Result<QueryResult, EngineError> {
2177        // See exec_create_publication — the in_transaction gate
2178        // was over-cautious; the auto-commit wrap path holds an
2179        // internal TX that this check was incorrectly blocking.
2180        let sub = subscriptions::Subscription {
2181            conn_str: s.conn_str,
2182            publications: s.publications,
2183            enabled: true,
2184            last_received_pos: 0,
2185        };
2186        self.subscriptions
2187            .create(s.name, sub)
2188            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE SUBSCRIPTION: {e:?}")))?;
2189        Ok(QueryResult::CommandOk {
2190            affected: 1,
2191            modified_catalog: true,
2192        })
2193    }
2194
2195    /// v6.1.4 — `DROP SUBSCRIPTION`. Silent no-op when the name
2196    /// doesn't exist (PG-compatible). The associated worker is
2197    /// torn down by spg-server when it observes the catalog
2198    /// change at the next snapshot or via the engine's
2199    /// subscriptions accessor (the worker polls the catalog on
2200    /// reconnect; v6.1.5's filter-side will tighten this to an
2201    /// explicit signal).
2202    fn exec_drop_subscription(&mut self, name: &str) -> Result<QueryResult, EngineError> {
2203        let removed = self.subscriptions.drop(name);
2204        Ok(QueryResult::CommandOk {
2205            affected: usize::from(removed),
2206            modified_catalog: removed,
2207        })
2208    }
2209
2210    /// v6.1.4 — read access to the subscription catalog. Used by
2211    /// the subscription worker (read its own row to find its
2212    /// publications + last applied position), by SHOW SUBSCRIPTIONS,
2213    /// and by e2e tests asserting state directly.
2214    pub const fn subscriptions(&self) -> &subscriptions::Subscriptions {
2215        &self.subscriptions
2216    }
2217
2218    /// v6.1.4 — write access to `last_received_pos`. Worker
2219    /// calls this after each apply batch (under the engine's
2220    /// write-lock). Returns `false` when the subscription was
2221    /// dropped between when the worker received the record and
2222    /// when this call landed.
2223    pub fn subscription_advance(&mut self, name: &str, pos: u64) -> bool {
2224        self.subscriptions.update_last_received_pos(name, pos)
2225    }
2226
2227    /// v6.1.4 — `SHOW SUBSCRIPTIONS` row materialisation. Returns
2228    /// `(name, conn_str, publications, enabled, last_received_pos)`
2229    /// ordered by subscription name. The `publications` column is
2230    /// the comma-joined list ("p1, p2") for ergonomic SHOW output;
2231    /// callers wanting structured access read `Engine::subscriptions`.
2232    fn exec_show_subscriptions(&self) -> QueryResult {
2233        let columns = alloc::vec![
2234            ColumnSchema::new("name", DataType::Text, false),
2235            ColumnSchema::new("conn_str", DataType::Text, false),
2236            ColumnSchema::new("publications", DataType::Text, false),
2237            ColumnSchema::new("enabled", DataType::Bool, false),
2238            ColumnSchema::new("last_received_pos", DataType::BigInt, false),
2239        ];
2240        let rows: Vec<Row> = self
2241            .subscriptions
2242            .iter()
2243            .map(|(name, sub)| {
2244                Row::new(alloc::vec![
2245                    Value::Text(name.clone()),
2246                    Value::Text(sub.conn_str.clone()),
2247                    Value::Text(sub.publications.join(", ")),
2248                    Value::Bool(sub.enabled),
2249                    Value::BigInt(i64::try_from(sub.last_received_pos).unwrap_or(i64::MAX)),
2250                ])
2251            })
2252            .collect();
2253        QueryResult::Rows { columns, rows }
2254    }
2255
2256    /// v6.2.0 — materialise `spg_statistic` rows. One row per
2257    /// `(table, column)` pair tracked in `Statistics`, with
2258    /// `histogram_bounds` rendered as a `[v0, v1, ...]` string —
2259    /// the same canonical form vector literals use for round-trip.
2260    fn exec_spg_statistic(&self) -> QueryResult {
2261        let columns = alloc::vec![
2262            ColumnSchema::new("table_name", DataType::Text, false),
2263            ColumnSchema::new("column_name", DataType::Text, false),
2264            ColumnSchema::new("null_frac", DataType::Float, false),
2265            ColumnSchema::new("n_distinct", DataType::BigInt, false),
2266            ColumnSchema::new("histogram_bounds", DataType::Text, false),
2267            // v6.7.0 — appended column (v6.2.0 stability contract
2268            // allows APPEND to spg_statistic, not reorder/rename).
2269            // Reports the cached per-table cold-row count; same
2270            // value across every column row of the same table.
2271            ColumnSchema::new("cold_row_count", DataType::BigInt, false),
2272        ];
2273        let rows: Vec<Row> = self
2274            .statistics
2275            .iter()
2276            .map(|((t, c), s)| {
2277                let cold = self
2278                    .catalog
2279                    .get(t)
2280                    .map_or(0, |table| table.cold_row_count());
2281                Row::new(alloc::vec![
2282                    Value::Text(t.clone()),
2283                    Value::Text(c.clone()),
2284                    Value::Float(f64::from(s.null_frac)),
2285                    Value::BigInt(i64::try_from(s.n_distinct).unwrap_or(i64::MAX)),
2286                    Value::Text(render_histogram_bounds(&s.histogram_bounds)),
2287                    Value::BigInt(i64::try_from(cold).unwrap_or(i64::MAX)),
2288                ])
2289            })
2290            .collect();
2291        QueryResult::Rows { columns, rows }
2292    }
2293
2294    /// v6.5.0 — materialise `spg_stat_replication` rows. One row
2295    /// per subscription with `(name, conn_str, publications,
2296    /// last_received_pos, enabled)`. Surface mirrors
2297    /// `SHOW SUBSCRIPTIONS` but follows the virtual-table dispatch
2298    /// shape so it composes with SELECT clauses (WHERE, projection
2299    /// onto specific columns, etc).
2300    fn exec_spg_stat_replication(&self) -> QueryResult {
2301        let columns = alloc::vec![
2302            ColumnSchema::new("name", DataType::Text, false),
2303            ColumnSchema::new("conn_str", DataType::Text, false),
2304            ColumnSchema::new("publications", DataType::Text, false),
2305            ColumnSchema::new("last_received_pos", DataType::BigInt, false),
2306            ColumnSchema::new("enabled", DataType::Bool, false),
2307        ];
2308        let rows: Vec<Row> = self
2309            .subscriptions
2310            .iter()
2311            .map(|(name, sub)| {
2312                Row::new(alloc::vec![
2313                    Value::Text(name.clone()),
2314                    Value::Text(sub.conn_str.clone()),
2315                    Value::Text(sub.publications.join(",")),
2316                    Value::BigInt(i64::try_from(sub.last_received_pos).unwrap_or(i64::MAX)),
2317                    Value::Bool(sub.enabled),
2318                ])
2319            })
2320            .collect();
2321        QueryResult::Rows { columns, rows }
2322    }
2323
2324    /// v6.5.0 — materialise `spg_stat_segment` rows. One row per
2325    /// cold-tier segment with `(segment_id, num_rows, num_pages,
2326    /// total_bytes)`.
2327    ///
2328    /// v6.7.0 — appended `table_name` column resolves the v6.5.0
2329    /// carve-out. Walks every user table's BTree indices to find
2330    /// which table's Cold locators point at each segment. Empty
2331    /// string for orphan segments (loaded via SPG_PRELOAD_COLD_SEGMENT
2332    /// before any index registered a locator). The walk is
2333    /// O(tables × indices × keys); cached per call, not across
2334    /// calls — re-walked on every `SELECT * FROM spg_stat_segment`.
2335    fn exec_spg_stat_segment(&self) -> QueryResult {
2336        let columns = alloc::vec![
2337            ColumnSchema::new("segment_id", DataType::BigInt, false),
2338            ColumnSchema::new("table_name", DataType::Text, false),
2339            ColumnSchema::new("num_rows", DataType::BigInt, false),
2340            ColumnSchema::new("num_pages", DataType::BigInt, false),
2341            ColumnSchema::new("total_bytes", DataType::BigInt, false),
2342        ];
2343        // v6.7.0 — build a segment_id → table_name map by walking
2344        // every user table's BTree indices once. O(tables × indices
2345        // × keys) for the v6.5.0 carve-out resolution; acceptable
2346        // because spg_stat_segment is operator-facing (not on a
2347        // hot-loop path).
2348        let mut segment_owners: alloc::collections::BTreeMap<u32, String> = BTreeMap::new();
2349        for tname in self.catalog.table_names() {
2350            if is_internal_table_name(&tname) {
2351                continue;
2352            }
2353            let Some(t) = self.catalog.get(&tname) else {
2354                continue;
2355            };
2356            for idx in t.indices() {
2357                if let spg_storage::IndexKind::BTree(map) = &idx.kind {
2358                    for (_, locs) in map.iter() {
2359                        for loc in locs {
2360                            if let spg_storage::RowLocator::Cold { segment_id, .. } = loc {
2361                                segment_owners
2362                                    .entry(*segment_id)
2363                                    .or_insert_with(|| tname.clone());
2364                            }
2365                        }
2366                    }
2367                }
2368            }
2369        }
2370        let rows: Vec<Row> = self
2371            .catalog
2372            .cold_segment_ids_global()
2373            .iter()
2374            .filter_map(|&id| {
2375                let seg = self.catalog.cold_segment(id)?;
2376                let meta = seg.meta();
2377                let owner = segment_owners.get(&id).cloned().unwrap_or_default();
2378                Some(Row::new(alloc::vec![
2379                    Value::BigInt(i64::from(id)),
2380                    Value::Text(owner),
2381                    Value::BigInt(i64::try_from(meta.num_rows).unwrap_or(i64::MAX)),
2382                    Value::BigInt(i64::from(meta.num_pages)),
2383                    Value::BigInt(i64::try_from(meta.total_bytes).unwrap_or(i64::MAX)),
2384                ]))
2385            })
2386            .collect();
2387        QueryResult::Rows { columns, rows }
2388    }
2389
2390    /// v6.5.1 — materialise `spg_stat_query` rows. One row per
2391    /// distinct SQL text recorded since the engine booted, capped
2392    /// at `QUERY_STATS_MAX` (1024). Columns:
2393    ///   sql, exec_count, total_us, mean_us, max_us, last_seen_us
2394    /// mean_us = total_us / exec_count (saturating).
2395    fn exec_spg_stat_query(&self) -> QueryResult {
2396        let columns = alloc::vec![
2397            ColumnSchema::new("sql", DataType::Text, false),
2398            ColumnSchema::new("exec_count", DataType::BigInt, false),
2399            ColumnSchema::new("total_us", DataType::BigInt, false),
2400            ColumnSchema::new("mean_us", DataType::BigInt, false),
2401            ColumnSchema::new("max_us", DataType::BigInt, false),
2402            ColumnSchema::new("last_seen_us", DataType::BigInt, false),
2403        ];
2404        let rows: Vec<Row> = self
2405            .query_stats
2406            .snapshot()
2407            .into_iter()
2408            .map(|(sql, s)| {
2409                let mean = if s.exec_count == 0 {
2410                    0
2411                } else {
2412                    s.total_us / s.exec_count
2413                };
2414                Row::new(alloc::vec![
2415                    Value::Text(sql),
2416                    Value::BigInt(i64::try_from(s.exec_count).unwrap_or(i64::MAX)),
2417                    Value::BigInt(i64::try_from(s.total_us).unwrap_or(i64::MAX)),
2418                    Value::BigInt(i64::try_from(mean).unwrap_or(i64::MAX)),
2419                    Value::BigInt(i64::try_from(s.max_us).unwrap_or(i64::MAX)),
2420                    Value::BigInt(i64::try_from(s.last_seen_us).unwrap_or(i64::MAX)),
2421                ])
2422            })
2423            .collect();
2424        QueryResult::Rows { columns, rows }
2425    }
2426
2427    /// v6.5.2 — register a connection-state provider. spg-server
2428    /// calls this at startup with a function that snapshots its
2429    /// per-pgwire-connection registry. Engine reads through the
2430    /// callback on `SELECT * FROM spg_stat_activity`.
2431    #[must_use]
2432    pub const fn with_activity_provider(mut self, f: ActivityProvider) -> Self {
2433        self.activity_provider = Some(f);
2434        self
2435    }
2436
2437    /// v6.5.3 — register audit chain provider + verifier.
2438    #[must_use]
2439    pub const fn with_audit_providers(
2440        mut self,
2441        chain: AuditChainProvider,
2442        verify: AuditVerifier,
2443    ) -> Self {
2444        self.audit_chain_provider = Some(chain);
2445        self.audit_verifier = Some(verify);
2446        self
2447    }
2448
2449    /// v6.5.6 — register a slow-query log callback. `threshold_us`
2450    /// is the floor (in microseconds); only executes above the floor
2451    /// fire the callback. spg-server wires this from
2452    /// `SPG_SLOW_QUERY_THRESHOLD_MS` (default 100 ms).
2453    #[must_use]
2454    pub const fn with_slow_query_log(mut self, threshold_us: u64, logger: SlowQueryLogger) -> Self {
2455        self.slow_query_threshold_us = Some(threshold_us);
2456        self.slow_query_logger = Some(logger);
2457        self
2458    }
2459
2460    /// v6.5.6 — operator knob for plan cache cap. spg-server reads
2461    /// `SPG_PLAN_CACHE_MAX` env at startup; uses this to override
2462    /// the compile-time default of 256.
2463    pub fn set_plan_cache_max(&mut self, n: usize) {
2464        self.plan_cache.set_max_entries(n);
2465    }
2466
2467    /// v6.5.2 — materialise `spg_stat_activity` rows. Pulls a fresh
2468    /// snapshot from the registered `ActivityProvider`. Returns an
2469    /// empty result set when no provider is registered (the no_std
2470    /// embedded path with no pgwire layer).
2471    fn exec_spg_stat_activity(&self) -> QueryResult {
2472        let columns = alloc::vec![
2473            ColumnSchema::new("pid", DataType::Int, false),
2474            ColumnSchema::new("user", DataType::Text, false),
2475            ColumnSchema::new("started_at_us", DataType::BigInt, false),
2476            ColumnSchema::new("current_sql", DataType::Text, false),
2477            ColumnSchema::new("wait_event", DataType::Text, false),
2478            ColumnSchema::new("elapsed_us", DataType::BigInt, false),
2479            ColumnSchema::new("in_transaction", DataType::Bool, false),
2480            ColumnSchema::new("application_name", DataType::Text, false),
2481        ];
2482        let rows: Vec<Row> = self
2483            .activity_provider
2484            .map(|f| f())
2485            .unwrap_or_default()
2486            .into_iter()
2487            .map(|r| {
2488                Row::new(alloc::vec![
2489                    Value::Int(i32::try_from(r.pid).unwrap_or(i32::MAX)),
2490                    Value::Text(r.user),
2491                    Value::BigInt(r.started_at_us),
2492                    Value::Text(r.current_sql),
2493                    Value::Text(r.wait_event),
2494                    Value::BigInt(r.elapsed_us),
2495                    Value::Bool(r.in_transaction),
2496                    Value::Text(r.application_name),
2497                ])
2498            })
2499            .collect();
2500        QueryResult::Rows { columns, rows }
2501    }
2502
2503    /// v6.5.4 — materialise `spg_table_ddl` rows. One row per user
2504    /// table with `(table_name, ddl)`. Reconstructed from catalog
2505    /// state on demand.
2506    fn exec_spg_table_ddl(&self) -> QueryResult {
2507        let columns = alloc::vec![
2508            ColumnSchema::new("table_name", DataType::Text, false),
2509            ColumnSchema::new("ddl", DataType::Text, false),
2510        ];
2511        let rows: Vec<Row> = self
2512            .catalog
2513            .table_names()
2514            .into_iter()
2515            .filter(|n| !is_internal_table_name(n))
2516            .filter_map(|name| {
2517                let table = self.catalog.get(&name)?;
2518                let ddl = render_create_table(&name, &table.schema().columns);
2519                Some(Row::new(alloc::vec![Value::Text(name), Value::Text(ddl),]))
2520            })
2521            .collect();
2522        QueryResult::Rows { columns, rows }
2523    }
2524
2525    /// v6.5.4 — materialise `spg_role_ddl` rows. One row per user
2526    /// with `(role_name, ddl)`. Password is redacted (matches the
2527    /// `Statement::CreateUser` Display which prints `'<redacted>'`).
2528    fn exec_spg_role_ddl(&self) -> QueryResult {
2529        let columns = alloc::vec![
2530            ColumnSchema::new("role_name", DataType::Text, false),
2531            ColumnSchema::new("ddl", DataType::Text, false),
2532        ];
2533        let rows: Vec<Row> = self
2534            .users
2535            .iter()
2536            .map(|(name, rec)| {
2537                let ddl = alloc::format!(
2538                    "CREATE USER {name} WITH PASSWORD '<redacted>' ROLE '{}'",
2539                    rec.role.as_str(),
2540                );
2541                Row::new(alloc::vec![
2542                    Value::Text(String::from(name)),
2543                    Value::Text(ddl)
2544                ])
2545            })
2546            .collect();
2547        QueryResult::Rows { columns, rows }
2548    }
2549
2550    /// v6.5.4 — materialise `spg_database_ddl`: single row whose
2551    /// `ddl` column concatenates every user table's CREATE +
2552    /// every role's CREATE in deterministic catalog order. Suitable
2553    /// for piping back through `Engine::execute` to recreate a
2554    /// schema-equivalent database.
2555    fn exec_spg_database_ddl(&self) -> QueryResult {
2556        let columns = alloc::vec![ColumnSchema::new("ddl", DataType::Text, false)];
2557        let mut out = String::new();
2558        for (name, rec) in self.users.iter() {
2559            out.push_str(&alloc::format!(
2560                "CREATE USER {name} WITH PASSWORD '<redacted>' ROLE '{}';\n",
2561                rec.role.as_str(),
2562            ));
2563        }
2564        for name in self.catalog.table_names() {
2565            if is_internal_table_name(&name) {
2566                continue;
2567            }
2568            if let Some(table) = self.catalog.get(&name) {
2569                out.push_str(&render_create_table(&name, &table.schema().columns));
2570                out.push_str(";\n");
2571            }
2572        }
2573        QueryResult::Rows {
2574            columns,
2575            rows: alloc::vec![Row::new(alloc::vec![Value::Text(out)])],
2576        }
2577    }
2578
2579    /// v6.5.3 — materialise `spg_audit_chain` rows. Pulls a fresh
2580    /// snapshot from the registered provider; empty when no
2581    /// provider is set.
2582    fn exec_spg_audit_chain(&self) -> QueryResult {
2583        let columns = alloc::vec![
2584            ColumnSchema::new("seq", DataType::BigInt, false),
2585            ColumnSchema::new("ts_ms", DataType::BigInt, false),
2586            ColumnSchema::new("prev_hash", DataType::Text, false),
2587            ColumnSchema::new("entry_hash", DataType::Text, false),
2588            ColumnSchema::new("sql", DataType::Text, false),
2589        ];
2590        let rows: Vec<Row> = self
2591            .audit_chain_provider
2592            .map(|f| f())
2593            .unwrap_or_default()
2594            .into_iter()
2595            .map(|r| {
2596                Row::new(alloc::vec![
2597                    Value::BigInt(r.seq),
2598                    Value::BigInt(r.ts_ms),
2599                    Value::Text(r.prev_hash_hex),
2600                    Value::Text(r.entry_hash_hex),
2601                    Value::Text(r.sql),
2602                ])
2603            })
2604            .collect();
2605        QueryResult::Rows { columns, rows }
2606    }
2607
2608    /// v6.5.3 — materialise `spg_audit_verify` single-row result.
2609    /// `(verified_count, broken_at_seq)` — broken_at_seq is `-1`
2610    /// on a clean chain. Returns one row with both values 0 when
2611    /// no verifier is registered (no-data fallback for embedded
2612    /// callers).
2613    fn exec_spg_audit_verify(&self) -> QueryResult {
2614        let columns = alloc::vec![
2615            ColumnSchema::new("verified_count", DataType::BigInt, false),
2616            ColumnSchema::new("broken_at_seq", DataType::BigInt, false),
2617        ];
2618        let (verified, broken) = self.audit_verifier.map(|f| f()).unwrap_or((0, -1));
2619        let row = Row::new(alloc::vec![Value::BigInt(verified), Value::BigInt(broken),]);
2620        QueryResult::Rows {
2621            columns,
2622            rows: alloc::vec![row],
2623        }
2624    }
2625
2626    /// v6.5.1 — read-only accessor for tests + v6.5.6 ops resets.
2627    pub fn query_stats(&self) -> &query_stats::QueryStats {
2628        &self.query_stats
2629    }
2630
2631    /// v6.5.1 — mutable accessor (clear, etc).
2632    pub fn query_stats_mut(&mut self) -> &mut query_stats::QueryStats {
2633        &mut self.query_stats
2634    }
2635
2636    /// v6.2.0 — read access to the per-column statistics table.
2637    /// Used by the planner (v6.2.2 selectivity functions read this),
2638    /// by `SELECT * FROM spg_statistic`, and by e2e tests.
2639    pub const fn statistics(&self) -> &statistics::Statistics {
2640        &self.statistics
2641    }
2642
2643    /// v6.2.1 — return tables whose modified-row count crossed the
2644    /// auto-analyze threshold since the last ANALYZE on that table.
2645    /// The threshold is `0.1 × max(row_count, MIN_ROWS_FOR_AUTO_
2646    /// ANALYZE)` — combines PG-style fractional + absolute lower
2647    /// bound so a fresh / tiny table doesn't get hammered on every
2648    /// INSERT.
2649    ///
2650    /// Designed to be cheap: walks every user table's
2651    /// `Catalog::table_names()` + reads `statistics::modified_
2652    /// since_last_analyze()` (BTreeMap lookup). The background
2653    /// worker calls this under `engine.read()` then drops the lock
2654    /// before re-acquiring `engine.write()` for the actual ANALYZE.
2655    pub fn tables_needing_analyze(&self) -> Vec<String> {
2656        const MIN_ROWS: u64 = 100;
2657        let mut out = Vec::new();
2658        for name in self.catalog.table_names() {
2659            if is_internal_table_name(&name) {
2660                continue;
2661            }
2662            let Some(table) = self.catalog.get(&name) else {
2663                continue;
2664            };
2665            let row_count = table.rows().len() as u64;
2666            let modified = self.statistics.modified_since_last_analyze(&name);
2667            // Threshold: ceil(0.1 × max(row_count, MIN_ROWS)),
2668            // computed in integer arithmetic so spg-engine stays
2669            // no_std without pulling in libm. `(n + 9) / 10` is
2670            // `ceil(n / 10)` for non-negative `n`.
2671            let base = row_count.max(MIN_ROWS);
2672            let threshold = base.saturating_add(9) / 10;
2673            if modified >= threshold {
2674                out.push(name);
2675            }
2676        }
2677        out
2678    }
2679
2680    /// v6.2.0 — `ANALYZE [<table>]` runtime. Bare `ANALYZE` walks
2681    /// every user table; `ANALYZE <name>` re-stats one. For each
2682    /// target table, single-pass scan + per-column histogram +
2683    /// `null_frac` + `n_distinct`. Replaces the table's prior
2684    /// stats; resets the modified-row counter.
2685    ///
2686    /// v6.2.0 doesn't sample — it scans the full table. v6.2.x
2687    /// can add reservoir sampling at the > 100 K-row mark; not a
2688    /// scope blocker for the current commit since rows ≤ 100 K
2689    /// analyse in milliseconds.
2690    fn exec_analyze(&mut self, target: Option<&str>) -> Result<QueryResult, EngineError> {
2691        let names: Vec<String> = if let Some(name) = target {
2692            // Verify the table exists; surface a clear error if not.
2693            if self.catalog.get(name).is_none() {
2694                return Err(EngineError::Storage(StorageError::TableNotFound {
2695                    name: name.to_string(),
2696                }));
2697            }
2698            alloc::vec![name.to_string()]
2699        } else {
2700            self.catalog
2701                .table_names()
2702                .into_iter()
2703                .filter(|n| !is_internal_table_name(n))
2704                .collect()
2705        };
2706        let mut analysed = 0usize;
2707        for table_name in &names {
2708            self.analyze_one_table(table_name)?;
2709            analysed += 1;
2710        }
2711        // v6.3.1 — plan cache invalidation. Bump stats version so
2712        // future lookups see the new generation, and selectively
2713        // evict every plan whose `source_tables` overlap with the
2714        // ANALYZE target set. Bare ANALYZE (all tables) clears the
2715        // whole cache.
2716        if analysed > 0 {
2717            self.statistics.bump_version();
2718            if target.is_some() {
2719                for t in &names {
2720                    self.plan_cache.evict_referencing(t);
2721                }
2722            } else {
2723                self.plan_cache.clear();
2724            }
2725        }
2726        Ok(QueryResult::CommandOk {
2727            affected: analysed,
2728            modified_catalog: true,
2729        })
2730    }
2731
2732    /// v6.7.3 — `COMPACT COLD SEGMENTS` runtime path. Drives the
2733    /// engine-layer compaction shim with the default
2734    /// 4 MiB segment-size threshold. spg-server intercepts the
2735    /// SQL before it reaches the engine on a server build —
2736    /// it reads `SPG_COMPACTION_TARGET_SEGMENT_BYTES`, calls
2737    /// `Engine::compact_cold_segments_with_target` directly with
2738    /// the env value, and persists every merged segment to
2739    /// v7.12.1 — record a `SET <name> = <value>` parameter. Names
2740    /// are case-folded to lowercase to match PG; values keep their
2741    /// caller-supplied form so observability paths see what was
2742    /// requested. Only `default_text_search_config` is consulted by
2743    /// the engine today.
2744    fn set_session_param(&mut self, name: String, value: spg_sql::ast::SetValue) {
2745        let normalised = match value {
2746            spg_sql::ast::SetValue::String(s) => s,
2747            spg_sql::ast::SetValue::Ident(s) => s,
2748            spg_sql::ast::SetValue::Number(s) => s,
2749            spg_sql::ast::SetValue::Default => String::new(),
2750        };
2751        let key = name.to_ascii_lowercase();
2752        // v7.14.0 — mysqldump preamble emits
2753        // `SET FOREIGN_KEY_CHECKS=0` so it can CREATE TABLE in any
2754        // order despite cross-table FK references; the closing
2755        // section emits `SET FOREIGN_KEY_CHECKS=1` (or
2756        // `=@OLD_FOREIGN_KEY_CHECKS` which resolves to "ON" in our
2757        // session-variable-aware path). Match both shapes.
2758        // Also accept PG's `session_replication_role = 'replica'`
2759        // which suppresses trigger + FK enforcement during a
2760        // logical replication apply (pg_dump preserves this for
2761        // schema-only mode but it shows up in some restores).
2762        let value_off = matches!(
2763            normalised.to_ascii_lowercase().as_str(),
2764            "0" | "off" | "false"
2765        );
2766        let value_on = matches!(
2767            normalised.to_ascii_lowercase().as_str(),
2768            "1" | "on" | "true"
2769        );
2770        if key == "foreign_key_checks"
2771            || key == "session_replication_role" && normalised.eq_ignore_ascii_case("replica")
2772        {
2773            if value_off || key == "session_replication_role" {
2774                self.foreign_key_checks = false;
2775            } else if value_on
2776                || (key == "session_replication_role" && normalised.eq_ignore_ascii_case("origin"))
2777            {
2778                self.foreign_key_checks = true;
2779                // Drain pending FK queue against the now-complete
2780                // catalog. Errors here surface as the SET reply —
2781                // caller knows enabling checks revealed orphans.
2782                let _ = self.drain_pending_foreign_keys();
2783            }
2784        }
2785        // v7.22 (round-13 T3) — string-literal dialect signals.
2786        // `SET sql_mode = …` is something only MySQL clients and
2787        // mysqldump preambles emit → MySQL escape semantics.
2788        // `SET standard_conforming_strings = on|off` is PG's own
2789        // switch for exactly this behaviour (every pg_dump preamble
2790        // sets it to on). The same SQL text lexes differently per
2791        // dialect, so a flip invalidates the plan cache.
2792        let new_escapes = if key == "sql_mode" {
2793            Some(true)
2794        } else if key == "standard_conforming_strings" {
2795            Some(value_off)
2796        } else {
2797            None
2798        };
2799        if let Some(flag) = new_escapes
2800            && flag != self.backslash_escapes
2801        {
2802            self.backslash_escapes = flag;
2803            self.plan_cache.clear();
2804        }
2805        self.session_params.insert(key, normalised);
2806    }
2807
2808    /// v7.14.0 — resolve every queued FK whose installation was
2809    /// deferred (`SET FOREIGN_KEY_CHECKS=0` window). Called by
2810    /// `set_session_param` when checks flip back on and by the
2811    /// drop-import release gate. Each FK is resolved against the
2812    /// current catalog; remaining missing-parent errors propagate
2813    /// up so the caller knows the import was incomplete.
2814    fn drain_pending_foreign_keys(&mut self) -> Result<(), EngineError> {
2815        let pending = core::mem::take(&mut self.pending_foreign_keys);
2816        for (child, fk) in pending {
2817            // Resolve against the current catalog. Skip silently
2818            // when the child table itself was dropped between
2819            // queue + drain.
2820            let cols_snapshot = match self.active_catalog().get(&child) {
2821                Some(t) => t.schema().columns.clone(),
2822                None => continue,
2823            };
2824            let storage_fk =
2825                resolve_foreign_key(&child, &cols_snapshot, fk, self.active_catalog())?;
2826            let table = self
2827                .active_catalog_mut()
2828                .get_mut(&child)
2829                .expect("checked above");
2830            table.schema_mut().foreign_keys.push(storage_fk);
2831        }
2832        Ok(())
2833    }
2834
2835    /// v7.12.1 — read a session parameter set via `SET`. Used by
2836    /// the FTS function dispatcher to resolve the default config
2837    /// for `to_tsvector(text)` / `plainto_tsquery(text)` etc.
2838    #[must_use]
2839    pub fn session_param(&self, name: &str) -> Option<&str> {
2840        self.session_params
2841            .get(&name.to_ascii_lowercase())
2842            .map(String::as_str)
2843    }
2844
2845    /// v7.12.1 — build an `EvalContext` chained with the session's
2846    /// `default_text_search_config`. Engine-internal callers use
2847    /// this instead of `EvalContext::new` so the FTS function
2848    /// dispatcher sees the SET configuration.
2849    fn ev_ctx<'a>(
2850        &'a self,
2851        columns: &'a [ColumnSchema],
2852        alias: Option<&'a str>,
2853    ) -> EvalContext<'a> {
2854        EvalContext::new(columns, alias)
2855            .with_default_text_search_config(self.session_param("default_text_search_config"))
2856    }
2857
2858    /// `<db>.spg/segments/`. This arm only fires for engine-only
2859    /// callers (spg-embedded, lib tests); in that mode merged
2860    /// segments live in memory and are dropped at process exit.
2861    fn exec_compact_cold_segments(&mut self) -> Result<QueryResult, EngineError> {
2862        let target = COMPACTION_TARGET_DEFAULT_BYTES;
2863        let reports = self.compact_cold_segments_with_target(target)?;
2864        let columns = alloc::vec![
2865            ColumnSchema::new("table_name", DataType::Text, false),
2866            ColumnSchema::new("index_name", DataType::Text, false),
2867            ColumnSchema::new("sources_merged", DataType::BigInt, false),
2868            ColumnSchema::new("merged_segment_id", DataType::BigInt, false),
2869            ColumnSchema::new("merged_rows", DataType::BigInt, false),
2870            ColumnSchema::new("deleted_rows_pruned", DataType::BigInt, false),
2871            ColumnSchema::new("bytes_reclaimed_estimate", DataType::BigInt, false),
2872        ];
2873        let rows: Vec<Row> = reports
2874            .into_iter()
2875            .map(|(tname, iname, report)| {
2876                Row::new(alloc::vec![
2877                    Value::Text(tname),
2878                    Value::Text(iname),
2879                    Value::BigInt(i64::try_from(report.sources.len()).unwrap_or(i64::MAX)),
2880                    Value::BigInt(i64::from(report.merged_segment_id.unwrap_or(0))),
2881                    Value::BigInt(i64::try_from(report.merged_rows).unwrap_or(i64::MAX)),
2882                    Value::BigInt(i64::try_from(report.deleted_rows_pruned).unwrap_or(i64::MAX),),
2883                    Value::BigInt(
2884                        i64::try_from(report.bytes_reclaimed_estimate).unwrap_or(i64::MAX),
2885                    ),
2886                ])
2887            })
2888            .collect();
2889        Ok(QueryResult::Rows { columns, rows })
2890    }
2891
2892    /// Walk a single table's rows once and (re-)populate per-column
2893    /// stats. Drops the existing stats for `table` first so columns
2894    /// that have been DROP-ed between ANALYZEs don't leave stale
2895    /// rows.
2896    fn analyze_one_table(&mut self, table_name: &str) -> Result<(), EngineError> {
2897        let table = self.catalog.get(table_name).ok_or_else(|| {
2898            EngineError::Storage(StorageError::TableNotFound {
2899                name: table_name.to_string(),
2900            })
2901        })?;
2902        let schema = table.schema().clone();
2903        let row_count = table.rows().len();
2904        // For each column, collect (sorted) non-NULL textual values
2905        // + count NULLs; then ask `statistics::build_histogram` to
2906        // produce the 101 bounds and `estimate_n_distinct` the
2907        // distinct count.
2908        self.statistics.clear_table(table_name);
2909        for (col_pos, col_schema) in schema.columns.iter().enumerate() {
2910            // v6.2.0 skip: vector columns have their own stats
2911            // shape (HNSW graph topology). v6.2 deliberation #1.
2912            if matches!(col_schema.ty, DataType::Vector { .. }) {
2913                continue;
2914            }
2915            let mut non_null_values: Vec<Value> = Vec::with_capacity(row_count);
2916            let mut nulls: u64 = 0;
2917            for row in table.rows() {
2918                match row.values.get(col_pos) {
2919                    Some(Value::Null) | None => nulls += 1,
2920                    Some(v) => non_null_values.push(v.clone()),
2921                }
2922            }
2923            // Sort by type-aware ordering (Int as int, Text as
2924            // lex, etc.) so histogram bounds reflect the column's
2925            // natural order — not lexicographic on the string
2926            // representation, which would put "9" after "49".
2927            non_null_values.sort_by(|a, b| sort_values_for_histogram(a, b));
2928            let non_null: Vec<String> = non_null_values.iter().map(canonical_value_repr).collect();
2929            let null_frac = if row_count == 0 {
2930                0.0
2931            } else {
2932                #[allow(clippy::cast_precision_loss)]
2933                let f = nulls as f32 / row_count as f32;
2934                f
2935            };
2936            let n_distinct = statistics::estimate_n_distinct(&non_null);
2937            let histogram_bounds = statistics::build_histogram(&non_null);
2938            self.statistics.set(
2939                table_name.to_string(),
2940                col_schema.name.clone(),
2941                statistics::ColumnStats {
2942                    null_frac,
2943                    n_distinct,
2944                    histogram_bounds,
2945                },
2946            );
2947        }
2948        self.statistics.reset_modified(table_name);
2949        // v6.7.0 — refresh the per-table cold_rows cache. Walk the
2950        // BTree indices and count Cold locators (MAX across
2951        // indices); store the result on the table. Surfaced via
2952        // `spg_statistic.cold_row_count` (new column) and
2953        // `spg_stat_segment.table_name` (new column).
2954        let cold_count = {
2955            let table = self
2956                .active_catalog()
2957                .get(table_name)
2958                .expect("table still present");
2959            table.count_cold_locators()
2960        };
2961        let table_mut = self
2962            .active_catalog_mut()
2963            .get_mut(table_name)
2964            .expect("table still present");
2965        table_mut.set_cold_row_count(cold_count);
2966        Ok(())
2967    }
2968
2969    /// v6.1.3 — `SHOW PUBLICATIONS` row materialisation. Returns
2970    /// `(name, scope, table_count)` ordered by publication name.
2971    ///   - `scope` is the human-readable string:
2972    ///       `"FOR ALL TABLES"` /
2973    ///       `"FOR TABLE t1, t2"` /
2974    ///       `"FOR ALL TABLES EXCEPT t1, t2"`.
2975    ///   - `table_count` is NULL for `AllTables`, the list length
2976    ///     otherwise. NULLability lets clients distinguish "publish
2977    ///     everything" from "publish exactly 0 tables" (the v6.1.3
2978    ///     parser forbids the empty list, but the column shape is
2979    ///     ready for the v6.1.5 publisher-side semantics).
2980    fn exec_show_publications(&self) -> QueryResult {
2981        let columns = alloc::vec![
2982            ColumnSchema::new("name", DataType::Text, false),
2983            ColumnSchema::new("scope", DataType::Text, false),
2984            ColumnSchema::new("table_count", DataType::Int, true),
2985        ];
2986        let rows: Vec<Row> = self
2987            .publications
2988            .iter()
2989            .map(|(name, scope)| {
2990                let (scope_str, count_val) = match scope {
2991                    spg_sql::ast::PublicationScope::AllTables => {
2992                        ("FOR ALL TABLES".to_string(), Value::Null)
2993                    }
2994                    spg_sql::ast::PublicationScope::ForTables(ts) => (
2995                        alloc::format!("FOR TABLE {}", ts.join(", ")),
2996                        Value::Int(i32::try_from(ts.len()).unwrap_or(i32::MAX)),
2997                    ),
2998                    spg_sql::ast::PublicationScope::AllTablesExcept(ts) => (
2999                        alloc::format!("FOR ALL TABLES EXCEPT {}", ts.join(", ")),
3000                        Value::Int(i32::try_from(ts.len()).unwrap_or(i32::MAX)),
3001                    ),
3002                };
3003                Row::new(alloc::vec![
3004                    Value::Text(name.clone()),
3005                    Value::Text(scope_str),
3006                    count_val,
3007                ])
3008            })
3009            .collect();
3010        QueryResult::Rows { columns, rows }
3011    }
3012
3013    /// v4.1 `SHOW USERS` — `(name, role)` per row, ordered by name.
3014    fn exec_show_users(&self) -> QueryResult {
3015        let columns = alloc::vec![
3016            ColumnSchema::new("name", DataType::Text, false),
3017            ColumnSchema::new("role", DataType::Text, false),
3018        ];
3019        let rows: Vec<Row> = self
3020            .users
3021            .iter()
3022            .map(|(name, rec)| {
3023                Row::new(alloc::vec![
3024                    Value::Text(name.to_string()),
3025                    Value::Text(rec.role.as_str().to_string()),
3026                ])
3027            })
3028            .collect();
3029        QueryResult::Rows { columns, rows }
3030    }
3031
3032    fn exec_create_user(&mut self, s: &CreateUserStatement) -> Result<QueryResult, EngineError> {
3033        if self.in_transaction() {
3034            return Err(EngineError::Unsupported(
3035                "CREATE USER is not allowed inside a transaction".into(),
3036            ));
3037        }
3038        let role = users::Role::parse(&s.role).ok_or_else(|| {
3039            EngineError::Unsupported(alloc::format!("invalid role: {:?}", s.role))
3040        })?;
3041        // Prefer the host-injected RNG. Falls back to a deterministic
3042        // salt derived from the username only when no RNG is wired —
3043        // acceptable for tests; the server always installs one.
3044        let salt = self.salt_fn.map_or_else(
3045            || {
3046                let mut s_bytes = [0u8; 16];
3047                let digest = spg_crypto::hash(s.name.as_bytes());
3048                s_bytes.copy_from_slice(&digest[..16]);
3049                s_bytes
3050            },
3051            |f| f(),
3052        );
3053        self.users
3054            .create(&s.name, &s.password, role, salt)
3055            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE USER: {e}")))?;
3056        Ok(QueryResult::CommandOk {
3057            affected: 1,
3058            modified_catalog: true,
3059        })
3060    }
3061
3062    fn exec_drop_user(&mut self, name: &str) -> Result<QueryResult, EngineError> {
3063        if self.in_transaction() {
3064            return Err(EngineError::Unsupported(
3065                "DROP USER is not allowed inside a transaction".into(),
3066            ));
3067        }
3068        self.users
3069            .drop(name)
3070            .map_err(|e| EngineError::Unsupported(alloc::format!("DROP USER: {e}")))?;
3071        Ok(QueryResult::CommandOk {
3072            affected: 1,
3073            modified_catalog: true,
3074        })
3075    }
3076
3077    /// v7.12.4 — `CREATE [OR REPLACE] FUNCTION`. Stores the
3078    /// function metadata in the catalog. PL/pgSQL bodies are
3079    /// already parsed by the SQL parser; we re-canonicalise the
3080    /// body to source text for storage (the executor re-parses
3081    /// it at trigger fire time — see the trigger fire path).
3082    fn exec_create_function(
3083        &mut self,
3084        s: spg_sql::ast::CreateFunctionStatement,
3085    ) -> Result<QueryResult, EngineError> {
3086        let args_repr = render_function_args(&s.args);
3087        let returns = match &s.returns {
3088            spg_sql::ast::FunctionReturn::Trigger => alloc::string::String::from("TRIGGER"),
3089            spg_sql::ast::FunctionReturn::Void => alloc::string::String::from("VOID"),
3090            spg_sql::ast::FunctionReturn::Type(t) => alloc::format!("{t}"),
3091            spg_sql::ast::FunctionReturn::Other(s) => s.clone(),
3092        };
3093        let body_text = match &s.body {
3094            spg_sql::ast::FunctionBody::PlPgSql(b) => alloc::format!("{b}"),
3095            spg_sql::ast::FunctionBody::Raw(s) => s.clone(),
3096        };
3097        let def = spg_storage::FunctionDef {
3098            name: s.name.clone(),
3099            args_repr,
3100            returns,
3101            language: s.language.clone(),
3102            body: body_text,
3103        };
3104        self.active_catalog_mut()
3105            .create_function(def, s.or_replace)
3106            .map_err(EngineError::Storage)?;
3107        Ok(QueryResult::CommandOk {
3108            affected: 0,
3109            modified_catalog: true,
3110        })
3111    }
3112
3113    /// v7.12.4 — `CREATE [OR REPLACE] TRIGGER`. The referenced
3114    /// function must already exist in the catalog (forward
3115    /// references defer to a later release). Persists the
3116    /// trigger metadata for the row-write hooks below to consult.
3117    fn exec_create_trigger(
3118        &mut self,
3119        s: spg_sql::ast::CreateTriggerStatement,
3120    ) -> Result<QueryResult, EngineError> {
3121        let timing = match s.timing {
3122            spg_sql::ast::TriggerTiming::Before => "BEFORE",
3123            spg_sql::ast::TriggerTiming::After => "AFTER",
3124            spg_sql::ast::TriggerTiming::InsteadOf => "INSTEAD OF",
3125        };
3126        let events: Vec<alloc::string::String> = s
3127            .events
3128            .iter()
3129            .map(|e| match e {
3130                spg_sql::ast::TriggerEvent::Insert => alloc::string::String::from("INSERT"),
3131                spg_sql::ast::TriggerEvent::Update => alloc::string::String::from("UPDATE"),
3132                spg_sql::ast::TriggerEvent::Delete => alloc::string::String::from("DELETE"),
3133                spg_sql::ast::TriggerEvent::Truncate => alloc::string::String::from("TRUNCATE"),
3134            })
3135            .collect();
3136        let for_each = match s.for_each {
3137            spg_sql::ast::TriggerForEach::Row => "ROW",
3138            spg_sql::ast::TriggerForEach::Statement => "STATEMENT",
3139        };
3140        let def = spg_storage::TriggerDef {
3141            name: s.name.clone(),
3142            table: s.table.clone(),
3143            timing: alloc::string::String::from(timing),
3144            events,
3145            for_each: alloc::string::String::from(for_each),
3146            function: s.function.clone(),
3147            update_columns: s.update_columns.clone(),
3148            // v7.16.1 — every trigger is born enabled. Toggled
3149            // by ALTER TABLE … { ENABLE | DISABLE } TRIGGER.
3150            enabled: true,
3151        };
3152        self.active_catalog_mut()
3153            .create_trigger(def, s.or_replace)
3154            .map_err(EngineError::Storage)?;
3155        Ok(QueryResult::CommandOk {
3156            affected: 0,
3157            modified_catalog: true,
3158        })
3159    }
3160
3161    fn exec_drop_trigger(
3162        &mut self,
3163        name: &str,
3164        table: &str,
3165        if_exists: bool,
3166    ) -> Result<QueryResult, EngineError> {
3167        let removed = self.active_catalog_mut().drop_trigger(name, table);
3168        if !removed && !if_exists {
3169            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3170                alloc::format!("trigger {name:?} on {table:?} does not exist"),
3171            )));
3172        }
3173        Ok(QueryResult::CommandOk {
3174            affected: usize::from(removed),
3175            modified_catalog: removed,
3176        })
3177    }
3178
3179    fn exec_drop_function(
3180        &mut self,
3181        name: &str,
3182        if_exists: bool,
3183    ) -> Result<QueryResult, EngineError> {
3184        let removed = self.active_catalog_mut().drop_function(name);
3185        if !removed && !if_exists {
3186            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3187                alloc::format!("function {name:?} does not exist"),
3188            )));
3189        }
3190        Ok(QueryResult::CommandOk {
3191            affected: usize::from(removed),
3192            modified_catalog: removed,
3193        })
3194    }
3195
3196    /// v7.17.0 — `CREATE SEQUENCE` engine path. Resolves
3197    /// `min_value` / `max_value` / `start` against PG defaults
3198    /// when omitted, then installs the SequenceDef in the catalog.
3199    fn exec_create_sequence(
3200        &mut self,
3201        s: spg_sql::ast::CreateSequenceStatement,
3202    ) -> Result<QueryResult, EngineError> {
3203        use spg_sql::ast::{SeqBound, SequenceDataType as AstDt};
3204        use spg_storage::{SequenceDataType, SequenceDef};
3205        let dt = match s.data_type {
3206            None => SequenceDataType::BigInt,
3207            Some(AstDt::SmallInt) => SequenceDataType::SmallInt,
3208            Some(AstDt::Int) => SequenceDataType::Int,
3209            Some(AstDt::BigInt) => SequenceDataType::BigInt,
3210        };
3211        let increment = s.options.increment.unwrap_or(1);
3212        if increment == 0 {
3213            return Err(EngineError::Unsupported(
3214                "INCREMENT must not be zero".into(),
3215            ));
3216        }
3217        let (def_min, def_max) = dt.default_bounds(increment > 0);
3218        let min_value = match s.options.min_value {
3219            None | Some(SeqBound::NoBound) => def_min,
3220            Some(SeqBound::Value(n)) => n,
3221        };
3222        let max_value = match s.options.max_value {
3223            None | Some(SeqBound::NoBound) => def_max,
3224            Some(SeqBound::Value(n)) => n,
3225        };
3226        if min_value > max_value {
3227            return Err(EngineError::Unsupported(alloc::format!(
3228                "MINVALUE ({min_value}) must be <= MAXVALUE ({max_value})"
3229            )));
3230        }
3231        let start = s
3232            .options
3233            .start
3234            .unwrap_or(if increment > 0 { min_value } else { max_value });
3235        if start < min_value || start > max_value {
3236            return Err(EngineError::Unsupported(alloc::format!(
3237                "START WITH ({start}) is outside MINVALUE..MAXVALUE ({min_value}..{max_value})"
3238            )));
3239        }
3240        let cache = s.options.cache.unwrap_or(1);
3241        if cache < 1 {
3242            return Err(EngineError::Unsupported("CACHE must be >= 1".into()));
3243        }
3244        let cycle = s.options.cycle.unwrap_or(false);
3245        let owned_by = match s.options.owned_by {
3246            None | Some(spg_sql::ast::SequenceOwnedBy::None) => None,
3247            Some(spg_sql::ast::SequenceOwnedBy::Column { table, column }) => Some((table, column)),
3248        };
3249        let def = SequenceDef {
3250            name: s.name.clone(),
3251            data_type: dt,
3252            start,
3253            increment,
3254            min_value,
3255            max_value,
3256            cache,
3257            cycle,
3258            owned_by,
3259            last_value: start,
3260            is_called: false,
3261        };
3262        self.active_catalog_mut()
3263            .create_sequence(def, s.if_not_exists)
3264            .map_err(EngineError::Storage)?;
3265        Ok(QueryResult::CommandOk {
3266            affected: 0,
3267            modified_catalog: !self.in_transaction(),
3268        })
3269    }
3270
3271    /// v7.17.0 — `ALTER SEQUENCE` engine path. Re-uses the catalog
3272    /// `alter_sequence` merge helper.
3273    fn exec_alter_sequence(
3274        &mut self,
3275        s: spg_sql::ast::AlterSequenceStatement,
3276    ) -> Result<QueryResult, EngineError> {
3277        use spg_sql::ast::SeqBound;
3278        let cat = self.active_catalog_mut();
3279        if !cat.sequences().contains_key(&s.name) {
3280            if s.if_exists {
3281                return Ok(QueryResult::CommandOk {
3282                    affected: 0,
3283                    modified_catalog: false,
3284                });
3285            }
3286            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3287                alloc::format!("sequence {:?} does not exist", s.name),
3288            )));
3289        }
3290        let min_value = match s.options.min_value {
3291            None => None,
3292            Some(SeqBound::NoBound) => None, // NO MINVALUE → keep current
3293            Some(SeqBound::Value(n)) => Some(n),
3294        };
3295        let max_value = match s.options.max_value {
3296            None => None,
3297            Some(SeqBound::NoBound) => None,
3298            Some(SeqBound::Value(n)) => Some(n),
3299        };
3300        let owned_by = s.options.owned_by.map(|ob| match ob {
3301            spg_sql::ast::SequenceOwnedBy::None => None,
3302            spg_sql::ast::SequenceOwnedBy::Column { table, column } => Some((table, column)),
3303        });
3304        cat.alter_sequence(
3305            &s.name,
3306            s.options.increment,
3307            min_value,
3308            max_value,
3309            s.options.start,
3310            s.options.restart,
3311            s.options.cache,
3312            s.options.cycle,
3313            owned_by,
3314        )
3315        .map_err(EngineError::Storage)?;
3316        Ok(QueryResult::CommandOk {
3317            affected: 0,
3318            modified_catalog: !self.in_transaction(),
3319        })
3320    }
3321
3322    /// v7.17.0 Phase 1.1 — walk a Statement tree and pre-resolve
3323    /// any sequence FunctionCall nodes inside its Expr slots.
3324    /// Delegates per-statement-kind: SELECT projection +
3325    /// WHERE, INSERT VALUES, UPDATE SET, DELETE WHERE.
3326    fn pre_resolve_sequence_calls_in_statement(
3327        &mut self,
3328        stmt: &mut Statement,
3329    ) -> Result<(), EngineError> {
3330        match stmt {
3331            Statement::Select(s) => self.pre_resolve_sequence_calls_in_select(s),
3332            Statement::Insert(s) => {
3333                for tuple in &mut s.rows {
3334                    for cell in tuple.iter_mut() {
3335                        self.resolve_sequence_calls_in_expr(cell)?;
3336                    }
3337                }
3338                Ok(())
3339            }
3340            Statement::Update(s) => {
3341                for (_col, expr) in &mut s.assignments {
3342                    self.resolve_sequence_calls_in_expr(expr)?;
3343                }
3344                if let Some(w) = &mut s.where_ {
3345                    self.resolve_sequence_calls_in_expr(w)?;
3346                }
3347                Ok(())
3348            }
3349            Statement::Delete(s) => {
3350                if let Some(w) = &mut s.where_ {
3351                    self.resolve_sequence_calls_in_expr(w)?;
3352                }
3353                Ok(())
3354            }
3355            _ => Ok(()),
3356        }
3357    }
3358
3359    fn pre_resolve_sequence_calls_in_select(
3360        &mut self,
3361        s: &mut spg_sql::ast::SelectStatement,
3362    ) -> Result<(), EngineError> {
3363        for item in &mut s.items {
3364            match item {
3365                spg_sql::ast::SelectItem::Expr { expr, .. } => {
3366                    self.resolve_sequence_calls_in_expr(expr)?;
3367                }
3368                spg_sql::ast::SelectItem::Wildcard => {}
3369            }
3370        }
3371        if let Some(w) = &mut s.where_ {
3372            self.resolve_sequence_calls_in_expr(w)?;
3373        }
3374        Ok(())
3375    }
3376
3377    /// v7.17.0 Phase 1.1 — walk an Expr tree and pre-resolve any
3378    /// `nextval(name)` / `currval(name)` / `setval(name, value[,
3379    /// is_called])` FunctionCall nodes by calling the catalog and
3380    /// replacing the node with the resulting `Expr::Literal`.
3381    /// Used by INSERT VALUES / UPDATE SET / DEFAULT eval so the
3382    /// row-eval path sees pre-computed sequence values instead of
3383    /// needing mutable catalog access mid-eval.
3384    #[allow(clippy::too_many_lines)]
3385    fn resolve_sequence_calls_in_expr(&mut self, expr: &mut Expr) -> Result<(), EngineError> {
3386        match expr {
3387            Expr::Literal(_) | Expr::Column(_) | Expr::Placeholder(_) => Ok(()),
3388            Expr::FunctionCall { name, args } => {
3389                // Descend first so nested calls — e.g.
3390                // setval('seq', currval('other')) — resolve
3391                // innermost-first.
3392                for a in args.iter_mut() {
3393                    self.resolve_sequence_calls_in_expr(a)?;
3394                }
3395                let lc = name.to_ascii_lowercase();
3396                if lc == "nextval" || lc == "currval" || lc == "setval" {
3397                    let v = self.eval_sequence_call(&lc, args)?;
3398                    *expr = Expr::Literal(value_to_literal(v));
3399                }
3400                Ok(())
3401            }
3402            Expr::Binary { lhs, rhs, .. } => {
3403                self.resolve_sequence_calls_in_expr(lhs)?;
3404                self.resolve_sequence_calls_in_expr(rhs)
3405            }
3406            Expr::Unary { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3407            Expr::Cast { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3408            Expr::IsNull { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3409            Expr::Like { expr, pattern, .. } => {
3410                self.resolve_sequence_calls_in_expr(expr)?;
3411                self.resolve_sequence_calls_in_expr(pattern)
3412            }
3413            Expr::Extract { source, .. } => self.resolve_sequence_calls_in_expr(source),
3414            Expr::Array(items) => {
3415                for it in items.iter_mut() {
3416                    self.resolve_sequence_calls_in_expr(it)?;
3417                }
3418                Ok(())
3419            }
3420            // Window / subquery / etc — sequence calls inside these
3421            // are uncommon and require separate row-eval; leave
3422            // untouched for now and rely on the eval-time error
3423            // (no sequence_resolver attached).
3424            _ => Ok(()),
3425        }
3426    }
3427
3428    /// v7.17.0 Phase 1.1 — evaluate a single nextval/currval/
3429    /// setval call. `args` are already pre-resolved Expr nodes
3430    /// (literals) — we extract their constant values.
3431    fn eval_sequence_call(&mut self, op: &str, args: &[Expr]) -> Result<Value, EngineError> {
3432        if args.is_empty() {
3433            return Err(EngineError::Unsupported(alloc::format!(
3434                "{op}() takes at least one argument"
3435            )));
3436        }
3437        let seq_name = match &args[0] {
3438            Expr::Literal(spg_sql::ast::Literal::String(s)) => {
3439                // v7.17 dump-compat — pg_dump emits sequence
3440                // names schema-qualified (`'public.posts_id_seq'`).
3441                // SPG is single-schema; strip a leading
3442                // `public.` / `pg_catalog.` so the catalog lookup
3443                // matches the bare-name CREATE SEQUENCE used.
3444                let trimmed = s
3445                    .strip_prefix("public.")
3446                    .or_else(|| s.strip_prefix("pg_catalog."))
3447                    .unwrap_or(s);
3448                trimmed.to_string()
3449            }
3450            // v7.17 dump-compat — pg_dump also emits
3451            // `nextval('public.posts_id_seq'::regclass)`
3452            // where the cast wraps the literal. Peel the cast
3453            // and continue.
3454            Expr::Cast { expr, .. } => {
3455                if let Expr::Literal(spg_sql::ast::Literal::String(s)) = expr.as_ref() {
3456                    let trimmed = s
3457                        .strip_prefix("public.")
3458                        .or_else(|| s.strip_prefix("pg_catalog."))
3459                        .unwrap_or(s);
3460                    trimmed.to_string()
3461                } else {
3462                    return Err(EngineError::Unsupported(alloc::format!(
3463                        "{op}() first argument must be a literal sequence name"
3464                    )));
3465                }
3466            }
3467            other => {
3468                return Err(EngineError::Unsupported(alloc::format!(
3469                    "{op}() first argument must be a literal sequence name, got {other:?}"
3470                )));
3471            }
3472        };
3473        match op {
3474            "nextval" => {
3475                let v = self
3476                    .active_catalog_mut()
3477                    .sequence_next_value(&seq_name)
3478                    .map_err(EngineError::Storage)?;
3479                Ok(Value::BigInt(v))
3480            }
3481            "currval" => {
3482                let v = self
3483                    .active_catalog()
3484                    .sequence_current_value(&seq_name)
3485                    .map_err(EngineError::Storage)?;
3486                Ok(Value::BigInt(v))
3487            }
3488            "setval" => {
3489                if args.len() < 2 || args.len() > 3 {
3490                    return Err(EngineError::Unsupported(alloc::format!(
3491                        "setval() takes 2 or 3 arguments, got {}",
3492                        args.len()
3493                    )));
3494                }
3495                let value = match &args[1] {
3496                    Expr::Literal(spg_sql::ast::Literal::Integer(n)) => *n,
3497                    other => {
3498                        return Err(EngineError::Unsupported(alloc::format!(
3499                            "setval() value argument must be a literal integer, got {other:?}"
3500                        )));
3501                    }
3502                };
3503                let is_called = if args.len() == 3 {
3504                    match &args[2] {
3505                        Expr::Literal(spg_sql::ast::Literal::Bool(b)) => *b,
3506                        other => {
3507                            return Err(EngineError::Unsupported(alloc::format!(
3508                                "setval() is_called argument must be a literal BOOL, got {other:?}"
3509                            )));
3510                        }
3511                    }
3512                } else {
3513                    true
3514                };
3515                let v = self
3516                    .active_catalog_mut()
3517                    .sequence_set_value(&seq_name, value, is_called)
3518                    .map_err(EngineError::Storage)?;
3519                Ok(Value::BigInt(v))
3520            }
3521            other => Err(EngineError::Unsupported(alloc::format!(
3522                "unknown sequence op {other:?}"
3523            ))),
3524        }
3525    }
3526
3527    /// v7.17.0 Phase 1.2 — find every catalog VIEW referenced in
3528    /// the SELECT's FROM / JOIN graph, re-parse each view's body
3529    /// source, and prepend it as a synthetic CTE on the
3530    /// returned SelectStatement. Returns `None` when no view
3531    /// references are found (caller proceeds with the original
3532    /// statement); returns `Some(rewritten)` otherwise (caller
3533    /// re-runs exec_select_cancel on the rewritten form so the
3534    /// regular CTE materialiser handles it).
3535    fn expand_views_in_select(
3536        &self,
3537        stmt: &SelectStatement,
3538    ) -> Result<Option<SelectStatement>, EngineError> {
3539        let cat = self.active_catalog();
3540        let mut referenced: Vec<String> = Vec::new();
3541        if let Some(from) = &stmt.from {
3542            collect_view_refs(&from.primary, cat, &mut referenced);
3543            for j in &from.joins {
3544                collect_view_refs(&j.table, cat, &mut referenced);
3545            }
3546        }
3547        // Don't expand a view name that's already shadowed by a
3548        // CTE on the same SELECT — the CTE wins per PG.
3549        referenced.retain(|n| !stmt.ctes.iter().any(|c| c.name == *n));
3550        if referenced.is_empty() {
3551            return Ok(None);
3552        }
3553        let mut new_ctes: Vec<spg_sql::ast::Cte> = Vec::with_capacity(referenced.len());
3554        for name in &referenced {
3555            let view = cat.views().get(name).ok_or_else(|| {
3556                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3557                    "view {name:?} disappeared mid-expansion"
3558                )))
3559            })?;
3560            let parsed = spg_sql::parser::parse_statement(&view.body).map_err(|e| {
3561                EngineError::Unsupported(alloc::format!("view {name:?} body re-parse failed: {e}"))
3562            })?;
3563            let Statement::Select(body) = parsed else {
3564                return Err(EngineError::Unsupported(alloc::format!(
3565                    "view {name:?} body is not a SELECT (catalog corruption)"
3566                )));
3567            };
3568            new_ctes.push(spg_sql::ast::Cte {
3569                name: name.clone(),
3570                body,
3571                recursive: false,
3572                column_overrides: view.columns.clone(),
3573            });
3574        }
3575        let mut out = stmt.clone();
3576        // Prepend so view CTEs are visible to caller-supplied CTEs.
3577        new_ctes.extend(out.ctes);
3578        out.ctes = new_ctes;
3579        Ok(Some(out))
3580    }
3581
3582    /// v7.17.0 Phase 1.2 — `CREATE VIEW` engine path. Stores the
3583    /// Display-rendered body verbatim in the catalog; SELECT-from-
3584    /// view at exec time re-parses + prepends as a synthetic CTE.
3585    fn exec_create_view(
3586        &mut self,
3587        s: spg_sql::ast::CreateViewStatement,
3588    ) -> Result<QueryResult, EngineError> {
3589        // Render the SELECT body to canonical form so the catalog
3590        // round-trips a deterministic source (no whitespace /
3591        // comment surprises in the on-disk snapshot).
3592        let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body));
3593        let def = spg_storage::ViewDef {
3594            name: s.name.clone(),
3595            columns: s.columns,
3596            body: body_repr,
3597        };
3598        self.active_catalog_mut()
3599            .create_view(def, s.or_replace, s.if_not_exists)
3600            .map_err(EngineError::Storage)?;
3601        Ok(QueryResult::CommandOk {
3602            affected: 0,
3603            modified_catalog: !self.in_transaction(),
3604        })
3605    }
3606
3607    /// v7.17.0 Phase 1.4 — `CREATE TYPE name AS ENUM (…)` engine
3608    /// path. Registers the enum in the catalog with order-
3609    /// preserving labels. PG semantics: CREATE TYPE errors if the
3610    /// name is taken (no IF NOT EXISTS).
3611    fn exec_create_type(
3612        &mut self,
3613        s: spg_sql::ast::CreateTypeStatement,
3614    ) -> Result<QueryResult, EngineError> {
3615        // Name-collision check against tables / sequences / views /
3616        // materialized views.
3617        let cat = self.active_catalog();
3618        if cat.get(&s.name).is_some() {
3619            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3620                alloc::format!("type {:?} would shadow an existing table", s.name),
3621            )));
3622        }
3623        if cat.sequences().contains_key(&s.name) {
3624            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3625                alloc::format!("type {:?} would shadow an existing sequence", s.name),
3626            )));
3627        }
3628        if cat.views().contains_key(&s.name) {
3629            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3630                alloc::format!("type {:?} would shadow an existing view", s.name),
3631            )));
3632        }
3633        let def = match s.kind {
3634            spg_sql::ast::TypeKind::Enum { labels } => {
3635                if labels.is_empty() {
3636                    return Err(EngineError::Unsupported(
3637                        "CREATE TYPE … AS ENUM requires at least one label".into(),
3638                    ));
3639                }
3640                // Reject duplicate labels per PG.
3641                for i in 0..labels.len() {
3642                    for j in (i + 1)..labels.len() {
3643                        if labels[i] == labels[j] {
3644                            return Err(EngineError::Unsupported(alloc::format!(
3645                                "CREATE TYPE {:?}: duplicate ENUM label {:?}",
3646                                s.name,
3647                                labels[i]
3648                            )));
3649                        }
3650                    }
3651                }
3652                spg_storage::EnumDef {
3653                    name: s.name.clone(),
3654                    labels,
3655                }
3656            }
3657        };
3658        self.active_catalog_mut()
3659            .create_enum_type(def)
3660            .map_err(EngineError::Storage)?;
3661        Ok(QueryResult::CommandOk {
3662            affected: 0,
3663            modified_catalog: !self.in_transaction(),
3664        })
3665    }
3666
3667    /// v7.17.0 Phase 1.5 — `CREATE DOMAIN name AS base [DEFAULT
3668    /// expr] [NOT NULL] [CHECK (expr)]*` engine path. Stores the
3669    /// base type + Display-rendered CHECK / DEFAULT sources so
3670    /// INSERT/UPDATE on bound columns can re-eval the checks.
3671    fn exec_create_domain(
3672        &mut self,
3673        s: spg_sql::ast::CreateDomainStatement,
3674    ) -> Result<QueryResult, EngineError> {
3675        let cat = self.active_catalog();
3676        if cat.domain_types().contains_key(&s.name) {
3677            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3678                alloc::format!("domain {:?} already exists", s.name),
3679            )));
3680        }
3681        if cat.get(&s.name).is_some()
3682            || cat.sequences().contains_key(&s.name)
3683            || cat.views().contains_key(&s.name)
3684            || cat.enum_types().contains_key(&s.name)
3685        {
3686            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3687                alloc::format!("domain {:?} would shadow an existing object", s.name),
3688            )));
3689        }
3690        let base_type = column_type_to_data_type(s.base_type);
3691        let default = s.default.as_ref().map(|e| alloc::format!("{e}"));
3692        let checks = s
3693            .checks
3694            .iter()
3695            .map(|e| alloc::format!("{e}"))
3696            .collect::<Vec<_>>();
3697        let def = spg_storage::DomainDef {
3698            name: s.name.clone(),
3699            base_type,
3700            nullable: !s.not_null,
3701            default,
3702            checks,
3703        };
3704        self.active_catalog_mut()
3705            .create_domain_type(def)
3706            .map_err(EngineError::Storage)?;
3707        Ok(QueryResult::CommandOk {
3708            affected: 0,
3709            modified_catalog: !self.in_transaction(),
3710        })
3711    }
3712
3713    /// v7.17.0 Phase 1.5 — `DROP DOMAIN [IF EXISTS] names`.
3714    fn exec_drop_domain(
3715        &mut self,
3716        names: &[String],
3717        if_exists: bool,
3718    ) -> Result<QueryResult, EngineError> {
3719        let mut removed = 0usize;
3720        for name in names {
3721            let was_present = self.active_catalog_mut().drop_domain_type(name);
3722            if was_present {
3723                removed += 1;
3724            } else if !if_exists {
3725                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3726                    alloc::format!("domain {name:?} does not exist"),
3727                )));
3728            }
3729        }
3730        Ok(QueryResult::CommandOk {
3731            affected: removed,
3732            modified_catalog: removed > 0 && !self.in_transaction(),
3733        })
3734    }
3735
3736    /// v7.17.0 Phase 1.6 — `CREATE SCHEMA [IF NOT EXISTS] name`.
3737    /// Registers the schema in the catalog. Schema-qualified
3738    /// table references continue to strip the prefix at lookup
3739    /// time (prefix routing, not isolation — see project-next-
3740    /// docket for the v7.18+ real-isolation tracking).
3741    fn exec_create_schema(
3742        &mut self,
3743        name: String,
3744        if_not_exists: bool,
3745    ) -> Result<QueryResult, EngineError> {
3746        self.active_catalog_mut()
3747            .create_schema(name, if_not_exists)
3748            .map_err(EngineError::Storage)?;
3749        Ok(QueryResult::CommandOk {
3750            affected: 0,
3751            modified_catalog: !self.in_transaction(),
3752        })
3753    }
3754
3755    /// v7.17.0 Phase 1.6 — `DROP SCHEMA [IF EXISTS] names`.
3756    /// Built-in schemas always reject the drop with a clear
3757    /// error.
3758    fn exec_drop_schema(
3759        &mut self,
3760        names: &[String],
3761        if_exists: bool,
3762    ) -> Result<QueryResult, EngineError> {
3763        let mut removed = 0usize;
3764        for name in names {
3765            let was_present = self
3766                .active_catalog_mut()
3767                .drop_schema(name)
3768                .map_err(EngineError::Storage)?;
3769            if was_present {
3770                removed += 1;
3771            } else if !if_exists {
3772                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3773                    alloc::format!("schema {name:?} does not exist"),
3774                )));
3775            }
3776        }
3777        Ok(QueryResult::CommandOk {
3778            affected: removed,
3779            modified_catalog: removed > 0 && !self.in_transaction(),
3780        })
3781    }
3782
3783    /// v7.17.0 Phase 1.4 — `DROP TYPE [IF EXISTS] names`. Only
3784    /// ENUM types are catalogued today; other types silently
3785    /// no-op even outside IF EXISTS to mirror the prior
3786    /// "everything's text" lax stance.
3787    fn exec_drop_type(
3788        &mut self,
3789        names: &[String],
3790        if_exists: bool,
3791    ) -> Result<QueryResult, EngineError> {
3792        let mut removed = 0usize;
3793        for name in names {
3794            let was_present = self.active_catalog_mut().drop_enum_type(name);
3795            if was_present {
3796                removed += 1;
3797            } else if !if_exists {
3798                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3799                    alloc::format!("type {name:?} does not exist"),
3800                )));
3801            }
3802        }
3803        Ok(QueryResult::CommandOk {
3804            affected: removed,
3805            modified_catalog: removed > 0 && !self.in_transaction(),
3806        })
3807    }
3808
3809    /// v7.17.0 Phase 1.3 — `CREATE MATERIALIZED VIEW` engine path.
3810    /// Materialises the body at CREATE time (unless WITH NO DATA),
3811    /// stores the result as a regular `Table`, and registers the
3812    /// body source in the catalog so REFRESH can re-run it.
3813    fn exec_create_materialized_view(
3814        &mut self,
3815        s: spg_sql::ast::CreateMaterializedViewStatement,
3816    ) -> Result<QueryResult, EngineError> {
3817        // Name-collision check (table / view / sequence / mat-view).
3818        let cat = self.active_catalog();
3819        if cat.materialized_views().contains_key(&s.name) || cat.get(&s.name).is_some() {
3820            if s.if_not_exists {
3821                return Ok(QueryResult::CommandOk {
3822                    affected: 0,
3823                    modified_catalog: false,
3824                });
3825            }
3826            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3827                alloc::format!("materialized view {:?} already exists", s.name),
3828            )));
3829        }
3830        if cat.views().contains_key(&s.name) {
3831            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3832                alloc::format!(
3833                    "materialized view {:?} would shadow an existing view",
3834                    s.name
3835                ),
3836            )));
3837        }
3838        if cat.sequences().contains_key(&s.name) {
3839            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3840                alloc::format!(
3841                    "materialized view {:?} would shadow an existing sequence",
3842                    s.name
3843                ),
3844            )));
3845        }
3846        // Render the body to canonical form for the registry.
3847        let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body.clone()));
3848        // Execute the body to learn the columns. With WITH DATA we
3849        // also materialise the rows; with WITH NO DATA we only need
3850        // the schema, so re-use a LIMIT 0 wrap to keep the column
3851        // inference path uniform without paying for the rows.
3852        let result = self.exec_select_cancel(&s.body, CancelToken::none())?;
3853        let (mut cols, rows) = match result {
3854            QueryResult::Rows { columns, rows } => (columns, rows),
3855            other => {
3856                return Err(EngineError::Unsupported(alloc::format!(
3857                    "CREATE MATERIALIZED VIEW body did not return rows: {other:?}"
3858                )));
3859            }
3860        };
3861        // Apply the column-rename list per PG semantics.
3862        if !s.columns.is_empty() {
3863            if s.columns.len() != cols.len() {
3864                return Err(EngineError::Unsupported(alloc::format!(
3865                    "CREATE MATERIALIZED VIEW {:?}: column list has {} names but body returns {}",
3866                    s.name,
3867                    s.columns.len(),
3868                    cols.len()
3869                )));
3870            }
3871            for (c, name) in cols.iter_mut().zip(s.columns.iter()) {
3872                c.name.clone_from(name);
3873            }
3874        }
3875        // Promote any synthetic-Text projections to their actual
3876        // observed types so the backing table accepts the rows.
3877        cols = infer_column_types(&cols, &rows);
3878        let schema = spg_storage::TableSchema::new(s.name.clone(), cols);
3879        let cat = self.active_catalog_mut();
3880        cat.create_table(schema).map_err(EngineError::Storage)?;
3881        if s.with_data {
3882            let table = cat
3883                .get_mut(&s.name)
3884                .expect("just-created materialized-view backing table must exist");
3885            for row in rows {
3886                table.insert(row).map_err(EngineError::Storage)?;
3887            }
3888        }
3889        cat.register_materialized_view(s.name.clone(), body_repr);
3890        Ok(QueryResult::CommandOk {
3891            affected: 0,
3892            modified_catalog: !self.in_transaction(),
3893        })
3894    }
3895
3896    /// v7.17.0 Phase 1.3 — `REFRESH MATERIALIZED VIEW name [WITH
3897    /// [NO] DATA]`. Looks up the source, re-runs it, replaces the
3898    /// backing table's rows.
3899    fn exec_refresh_materialized_view(
3900        &mut self,
3901        name: &str,
3902        with_data: bool,
3903    ) -> Result<QueryResult, EngineError> {
3904        let source = self
3905            .active_catalog()
3906            .materialized_views()
3907            .get(name)
3908            .cloned()
3909            .ok_or_else(|| {
3910                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3911                    "materialized view {name:?} does not exist"
3912                )))
3913            })?;
3914        // Wipe the existing rows first (PG truncates the matview
3915        // and rebuilds; we approximate with an empty INSERT loop).
3916        {
3917            let cat = self.active_catalog_mut();
3918            let table = cat.get_mut(name).ok_or_else(|| {
3919                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3920                    "materialized view {name:?} backing table missing"
3921                )))
3922            })?;
3923            table.truncate();
3924        }
3925        if !with_data {
3926            return Ok(QueryResult::CommandOk {
3927                affected: 0,
3928                modified_catalog: !self.in_transaction(),
3929            });
3930        }
3931        let parsed = spg_sql::parser::parse_statement(&source).map_err(|e| {
3932            EngineError::Unsupported(alloc::format!(
3933                "materialized view {name:?} body re-parse failed: {e}"
3934            ))
3935        })?;
3936        let Statement::Select(body) = parsed else {
3937            return Err(EngineError::Unsupported(alloc::format!(
3938                "materialized view {name:?} body is not a SELECT (catalog corruption)"
3939            )));
3940        };
3941        let rows = match self.exec_select_cancel(&body, CancelToken::none())? {
3942            QueryResult::Rows { rows, .. } => rows,
3943            other => {
3944                return Err(EngineError::Unsupported(alloc::format!(
3945                    "REFRESH MATERIALIZED VIEW {name:?} body did not return rows: {other:?}"
3946                )));
3947            }
3948        };
3949        let cat = self.active_catalog_mut();
3950        let table = cat.get_mut(name).expect("backing table verified above");
3951        let affected = rows.len();
3952        for row in rows {
3953            table.insert(row).map_err(EngineError::Storage)?;
3954        }
3955        Ok(QueryResult::CommandOk {
3956            affected,
3957            modified_catalog: !self.in_transaction(),
3958        })
3959    }
3960
3961    /// v7.17.0 Phase 1.3 — `DROP MATERIALIZED VIEW [IF EXISTS]
3962    /// names`. Drops the backing table + unregisters the source.
3963    fn exec_drop_materialized_view(
3964        &mut self,
3965        names: &[String],
3966        if_exists: bool,
3967    ) -> Result<QueryResult, EngineError> {
3968        let mut removed = 0usize;
3969        for name in names {
3970            let was_present = self
3971                .active_catalog_mut()
3972                .drop_materialized_view_source(name);
3973            if was_present {
3974                // Drop the backing table too.
3975                self.active_catalog_mut().drop_table(name);
3976                removed += 1;
3977            } else if !if_exists {
3978                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3979                    alloc::format!("materialized view {name:?} does not exist"),
3980                )));
3981            }
3982        }
3983        Ok(QueryResult::CommandOk {
3984            affected: removed,
3985            modified_catalog: removed > 0 && !self.in_transaction(),
3986        })
3987    }
3988
3989    /// v7.17.0 Phase 1.2 — `DROP VIEW [IF EXISTS] name [, name…]`.
3990    fn exec_drop_view(
3991        &mut self,
3992        names: &[String],
3993        if_exists: bool,
3994    ) -> Result<QueryResult, EngineError> {
3995        let mut removed = 0usize;
3996        for name in names {
3997            let was_present = self.active_catalog_mut().drop_view(name);
3998            if !was_present && !if_exists {
3999                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
4000                    alloc::format!("view {name:?} does not exist"),
4001                )));
4002            }
4003            if was_present {
4004                removed += 1;
4005            }
4006        }
4007        Ok(QueryResult::CommandOk {
4008            affected: removed,
4009            modified_catalog: removed > 0 && !self.in_transaction(),
4010        })
4011    }
4012
4013    /// v7.17.0 — `DROP SEQUENCE [IF EXISTS] name [, name…]`.
4014    fn exec_drop_sequence(
4015        &mut self,
4016        names: &[String],
4017        if_exists: bool,
4018    ) -> Result<QueryResult, EngineError> {
4019        let mut removed = 0usize;
4020        for name in names {
4021            let was_present = self.active_catalog_mut().drop_sequence(name);
4022            if !was_present && !if_exists {
4023                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
4024                    alloc::format!("sequence {name:?} does not exist"),
4025                )));
4026            }
4027            if was_present {
4028                removed += 1;
4029            }
4030        }
4031        Ok(QueryResult::CommandOk {
4032            affected: removed,
4033            modified_catalog: removed > 0 && !self.in_transaction(),
4034        })
4035    }
4036
4037    /// v4.4 `UPDATE <table> SET col = expr [, ...] [WHERE cond]`.
4038    /// Filter pass uses the same WHERE eval as `exec_select`. Per
4039    /// matched row, evaluate each RHS expression against the *old*
4040    /// row, then call `Table::update_row` which rebuilds indices.
4041    /// Indexed columns are correctly reflected because rebuild
4042    /// happens after the cell rewrite.
4043    fn exec_update_cancel(
4044        &mut self,
4045        stmt: &spg_sql::ast::UpdateStatement,
4046        cancel: CancelToken<'_>,
4047    ) -> Result<QueryResult, EngineError> {
4048        // v7.12.5 — snapshot BEFORE/AFTER UPDATE row triggers + the
4049        // session FTS config before the table mut-borrow opens (the
4050        // INSERT path uses the same pattern). Empty vecs are the
4051        // common "no triggers on this table" fast path.
4052        // v7.13.0 — UPDATE triggers carry an optional `UPDATE OF
4053        // cols` filter. The filter is paired with each function so
4054        // the per-row fire loop can skip when no listed column
4055        // actually differs between OLD and NEW.
4056        let before_update_triggers = self.snapshot_update_row_triggers(&stmt.table, "BEFORE");
4057        let after_update_triggers = self.snapshot_update_row_triggers(&stmt.table, "AFTER");
4058        let trigger_session_cfg: Option<String> = self
4059            .session_params
4060            .get("default_text_search_config")
4061            .cloned();
4062        // v5.2.3: if the WHERE is a PK equality and matches a cold-
4063        // tier row, promote it back to the hot tier *before* the
4064        // hot-row walk. The promote pushes the row to the end of
4065        // `table.rows`, where the upcoming SET-evaluation loop will
4066        // pick it up and apply the assignments. Lookups for the key
4067        // never observe a gap because `promote_cold_row` inserts the
4068        // hot row before retiring the cold locator.
4069        if let Some(w) = &stmt.where_ {
4070            let schema_cols = self
4071                .active_catalog()
4072                .get(&stmt.table)
4073                .ok_or_else(|| {
4074                    EngineError::Storage(StorageError::TableNotFound {
4075                        name: stmt.table.clone(),
4076                    })
4077                })?
4078                .schema()
4079                .columns
4080                .clone();
4081            if let Some((col_pos, key)) = try_pk_predicate(w, &schema_cols, stmt.table.as_str())
4082                && let Some(idx_name) = self
4083                    .active_catalog()
4084                    .get(&stmt.table)
4085                    .and_then(|t| t.index_on(col_pos).map(|i| i.name.clone()))
4086            {
4087                // Promote may be a no-op (key is hot-only or absent);
4088                // we don't care about the return value here — the
4089                // subsequent hot walk will either match or not.
4090                let _ = self
4091                    .active_catalog_mut()
4092                    .promote_cold_row(&stmt.table, &idx_name, &key);
4093            }
4094        }
4095
4096        // v7.12.1 — cache session FTS config before the table
4097        // mut-borrow (same reason as exec_delete).
4098        let ts_cfg: Option<String> = self
4099            .session_param("default_text_search_config")
4100            .map(String::from);
4101        // v7.17.0 Phase 2.1 — snapshot the clock pointer before
4102        // we hold the catalog mutably so ON UPDATE runtime
4103        // overrides see the engine wall clock.
4104        let clock_for_on_update = self.clock;
4105        let table = self
4106            .active_catalog_mut()
4107            .get_mut(&stmt.table)
4108            .ok_or_else(|| {
4109                EngineError::Storage(StorageError::TableNotFound {
4110                    name: stmt.table.clone(),
4111                })
4112            })?;
4113        let schema_cols: Vec<ColumnSchema> = table.schema().columns.clone();
4114        // Resolve each SET target to a column position once, validate
4115        // up front so a typo'd column doesn't leave a partial mutation
4116        // behind.
4117        let mut targets: Vec<(usize, &Expr)> = Vec::with_capacity(stmt.assignments.len());
4118        for (col, expr) in &stmt.assignments {
4119            let pos = schema_cols
4120                .iter()
4121                .position(|c| c.name == *col)
4122                .ok_or_else(|| {
4123                    EngineError::Eval(EvalError::ColumnNotFound { name: col.clone() })
4124                })?;
4125            targets.push((pos, expr));
4126        }
4127        // v7.17.0 Phase 2.1 — for every column with an
4128        // `ON UPDATE CURRENT_TIMESTAMP` binding that the caller
4129        // did NOT explicitly set, schedule an automatic override.
4130        // Reuses `eval_runtime_default_free` so the same
4131        // canonical runtime-expression whitelist (now /
4132        // current_timestamp / current_date / …) governs both
4133        // DEFAULT and ON UPDATE.
4134        let mut on_update_overrides: Vec<(usize, String)> = Vec::new();
4135        for (i, col) in schema_cols.iter().enumerate() {
4136            if targets.iter().any(|(p, _)| *p == i) {
4137                continue;
4138            }
4139            if let Some(src) = &col.on_update_runtime {
4140                on_update_overrides.push((i, src.clone()));
4141            }
4142        }
4143        let ctx = EvalContext::new(&schema_cols, Some(stmt.table.as_str()))
4144            .with_default_text_search_config(ts_cfg.as_deref());
4145        // Walk candidate rows, evaluate WHERE then SET
4146        // expressions. We gather (position, new_values) tuples
4147        // first and apply them afterwards so the WHERE/RHS
4148        // evaluation reads the original row state — matches PG
4149        // semantics (UPDATE doesn't see its own writes).
4150        //
4151        // v7.20 P4 — index seek: a single-column equality WHERE
4152        // on an indexed column narrows the walk from
4153        // O(table.rows()) to O(matches). The full WHERE still
4154        // re-evaluates per candidate (the seek may be an
4155        // over-approximation under AND-composites), so semantics
4156        // are unchanged. profile: the bench's `UPDATE … WHERE
4157        // id = $1` on a 5 000-row table was a ~1.3 ms full scan
4158        // per statement; with the seek it's ~2 µs.
4159        let seek_positions: Option<Vec<usize>> = stmt
4160            .where_
4161            .as_ref()
4162            .and_then(|w| try_index_seek_positions(w, &schema_cols, table, stmt.table.as_str()));
4163        let mut planned: Vec<(usize, Vec<Value>)> = Vec::new();
4164        let candidate_positions: Vec<usize> = match &seek_positions {
4165            Some(list) => list.clone(),
4166            None => (0..table.row_count()).collect(),
4167        };
4168        for (loop_n, &i) in candidate_positions.iter().enumerate() {
4169            // v4.5: cooperative cancel checkpoint every 256 rows so
4170            // a runaway UPDATE without WHERE doesn't drag past the
4171            // server's query-timeout watchdog.
4172            if loop_n.is_multiple_of(256) {
4173                cancel.check()?;
4174            }
4175            let Some(row) = table.rows().get(i) else {
4176                continue;
4177            };
4178            if let Some(w) = &stmt.where_ {
4179                let cond = eval::eval_expr(w, row, &ctx)?;
4180                if !matches!(cond, Value::Bool(true)) {
4181                    continue;
4182                }
4183            }
4184            let mut new_vals = row.values.clone();
4185            for (pos, expr) in &targets {
4186                let v = eval::eval_expr(expr, row, &ctx)?;
4187                let coerced = coerce_value(v, schema_cols[*pos].ty, &schema_cols[*pos].name, *pos)?;
4188                check_unsigned_range(&coerced, &schema_cols[*pos], *pos)?;
4189                new_vals[*pos] = coerced;
4190            }
4191            // v7.17.0 Phase 2.1 — apply ON UPDATE overrides for
4192            // any column the SET clause didn't touch.
4193            for (pos, src) in &on_update_overrides {
4194                let v = eval_runtime_default_free(src, schema_cols[*pos].ty, clock_for_on_update)?;
4195                new_vals[*pos] = v;
4196            }
4197            planned.push((i, new_vals));
4198        }
4199        // planned must stay position-sorted: downstream passes
4200        // (FK pairing, trigger walks, the apply loop) iterate it
4201        // assuming ascending row order, which the full-scan path
4202        // guaranteed implicitly.
4203        planned.sort_by_key(|(i, _)| *i);
4204        // v7.6.6 — capture pre-update row values for the FK
4205        // enforcement passes below. `planned` carries new values
4206        // only; pair them with the old row.
4207        let plan_with_old: Vec<(usize, Vec<Value>, Vec<Value>)> = planned
4208            .iter()
4209            .map(|(pos, new_vals)| (*pos, table.rows()[*pos].values.clone(), new_vals.clone()))
4210            .collect();
4211        let self_fks = table.schema().foreign_keys.clone();
4212        // v7.12.5 — `affected` is computed post-BEFORE-trigger
4213        // below (triggers may RETURN NULL to skip individual
4214        // rows). The pre-trigger len shape is no longer accurate.
4215        // Release mutable borrow on `table` for the FK passes.
4216        let _ = table;
4217        // v7.6.6 — Stage 2a: outbound FK check. For every row whose
4218        // local FK columns changed, the new value must exist in the
4219        // parent.
4220        if !self_fks.is_empty() {
4221            let new_rows: Vec<Vec<Value>> = planned
4222                .iter()
4223                .map(|(_pos, new_vals)| new_vals.clone())
4224                .collect();
4225            enforce_fk_inserts(self.active_catalog(), &stmt.table, &self_fks, &new_rows)?;
4226        }
4227        // v7.13.0 — CHECK constraint enforcement on UPDATE
4228        // (mailrs round-5 G3). Predicates evaluated against the
4229        // candidate post-UPDATE row; false rejects the UPDATE.
4230        {
4231            let new_rows: Vec<Vec<Value>> = planned
4232                .iter()
4233                .map(|(_pos, new_vals)| new_vals.clone())
4234                .collect();
4235            enforce_check_constraints(self.active_catalog(), &stmt.table, &new_rows)?;
4236        }
4237        // v7.6.6 — Stage 2b: inbound FK check. For every row that
4238        // changed value in a column that *some other table* uses as
4239        // a FK parent column, react per `on_update` action.
4240        let child_plan =
4241            plan_fk_parent_updates(self.active_catalog(), &stmt.table, &plan_with_old)?;
4242        // Stage 3a — apply each child-side action.
4243        for step in &child_plan {
4244            apply_fk_child_step(self.active_catalog_mut(), step)?;
4245        }
4246        // Stage 3b — apply the original UPDATE.
4247        let table = self
4248            .active_catalog_mut()
4249            .get_mut(&stmt.table)
4250            .ok_or_else(|| {
4251                EngineError::Storage(StorageError::TableNotFound {
4252                    name: stmt.table.clone(),
4253                })
4254            })?;
4255        // v7.12.5 — fire BEFORE/AFTER UPDATE row-level triggers
4256        // around the apply loop. BEFORE sees NEW=candidate +
4257        // OLD=current; may rewrite NEW or RETURN NULL to skip.
4258        // AFTER sees NEW=post-write + OLD=pre-write (both read-
4259        // only).
4260        //
4261        // Filter `planned` through the BEFORE pass first so the
4262        // RETURNING snapshot reflects what actually got written
4263        // (triggers may rewrite cells, including a cancellation).
4264        let mut applied_after_before: Vec<(usize, Row, Row)> = Vec::with_capacity(planned.len());
4265        // v7.12.7 — embedded SQL queue.
4266        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
4267        for (pos, new_vals) in &planned {
4268            let old_row = table.rows()[*pos].clone();
4269            let mut new_row = Row::new(new_vals.clone());
4270            let mut skip = false;
4271            for (fd, filter) in &before_update_triggers {
4272                // v7.13.0 — `UPDATE OF cols` filter (mailrs round-5
4273                // G7). Skip this trigger when the filter is set and
4274                // no listed column actually differs between OLD and
4275                // NEW for this row.
4276                if !filter.is_empty()
4277                    && !any_column_changed(filter, &schema_cols, &old_row, &new_row)
4278                {
4279                    continue;
4280                }
4281                let (outcome, deferred) = triggers::fire_row_trigger(
4282                    fd,
4283                    Some(new_row.clone()),
4284                    Some(&old_row),
4285                    &stmt.table,
4286                    &schema_cols,
4287                    &[],
4288                    trigger_session_cfg.as_deref(),
4289                    false,
4290                )
4291                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
4292                deferred_embedded.extend(deferred);
4293                match outcome {
4294                    triggers::TriggerOutcome::Row(r) => new_row = r,
4295                    triggers::TriggerOutcome::Skip => {
4296                        skip = true;
4297                        break;
4298                    }
4299                }
4300            }
4301            if !skip {
4302                applied_after_before.push((*pos, new_row, old_row));
4303            }
4304        }
4305        // v7.9.4 — snapshot post-update values for RETURNING (post-
4306        // BEFORE-trigger because triggers can rewrite cells).
4307        let updated_for_returning: Vec<Vec<Value>> = if stmt.returning.is_some() {
4308            applied_after_before
4309                .iter()
4310                .map(|(_pos, new_row, _old)| new_row.values.clone())
4311                .collect()
4312        } else {
4313            Vec::new()
4314        };
4315        let affected = applied_after_before.len();
4316        // Apply, then fire AFTER triggers per row. AFTER runs read-
4317        // only against the freshly-written row; v7.12.4-shape
4318        // assignment errors with a clear message.
4319        for (pos, new_row, old_row) in applied_after_before {
4320            table.update_row(pos, new_row.values.clone())?;
4321            for (fd, filter) in &after_update_triggers {
4322                if !filter.is_empty()
4323                    && !any_column_changed(filter, &schema_cols, &old_row, &new_row)
4324                {
4325                    continue;
4326                }
4327                let (_outcome, deferred) = triggers::fire_row_trigger(
4328                    fd,
4329                    Some(new_row.clone()),
4330                    Some(&old_row),
4331                    &stmt.table,
4332                    &schema_cols,
4333                    &[],
4334                    trigger_session_cfg.as_deref(),
4335                    true,
4336                )
4337                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
4338                deferred_embedded.extend(deferred);
4339            }
4340        }
4341        let _ = table;
4342        // v7.12.7 — drain trigger-emitted embedded SQL for this UPDATE.
4343        self.execute_deferred_trigger_stmts(deferred_embedded, cancel)?;
4344        // v6.2.1 — auto-analyze modified-row tracking for UPDATE.
4345        if !self.in_transaction() && affected > 0 {
4346            self.statistics
4347                .record_modifications(&stmt.table, affected as u64);
4348        }
4349        // v7.9.4 — RETURNING projection.
4350        if let Some(items) = &stmt.returning {
4351            return self.build_returning_rows(&stmt.table, items, updated_for_returning);
4352        }
4353        Ok(QueryResult::CommandOk {
4354            affected,
4355            modified_catalog: !self.in_transaction(),
4356        })
4357    }
4358
4359    /// v4.4 `DELETE FROM <table> [WHERE cond]`. Collects matching
4360    /// positions then delegates to `Table::delete_rows` (single index
4361    /// rebuild for the batch).
4362    /// v7.17.0 Phase 3.P0-42 — SQL:2003 / PG 15+ `MERGE` execution.
4363    ///
4364    /// Semantics:
4365    ///   * Resolve `target` and `source` tables (catalog reads).
4366    ///   * Build a combined `(target_alias.col, source_alias.col)`
4367    ///     schema so the ON / WHEN AND / SET / VALUES expressions
4368    ///     resolve through the standard qualifier-aware resolver.
4369    ///   * Pass 1: walk every source row × every target hot row,
4370    ///     evaluate ON, then pick the first WHEN clause that fits
4371    ///     (`Matched` if any target row matched, `NotMatched`
4372    ///     otherwise; AND-condition must hold). Collect the action
4373    ///     plan as `(deletes, updates, inserts)` so the apply pass
4374    ///     reads the original target row state.
4375    ///   * Pass 2: apply the plan against the target's mutable row
4376    ///     vector. Deletes execute by index in descending order so
4377    ///     earlier indices remain stable; updates next; inserts
4378    ///     last (matching PG's "INSERT branch sees the post-delete
4379    ///     state" behaviour for the common upsert shape).
4380    ///
4381    /// v7.17 simplifications (documented limitations):
4382    ///   * No triggers / WAL plumbing (MVP); MERGE rows don't fire
4383    ///     INSERT / UPDATE / DELETE row triggers in v7.17.
4384    ///   * No cardinality check (PG-canonical: "MERGE command
4385    ///     cannot affect row a second time" — SPG silently applies
4386    ///     the last action for a target row covered twice).
4387    ///   * Source must be a catalog-resolvable table (no subquery
4388    ///     source); RETURNING / BY SOURCE / BY TARGET unsupported.
4389    fn exec_merge_cancel(
4390        &mut self,
4391        stmt: &spg_sql::ast::MergeStatement,
4392        cancel: CancelToken<'_>,
4393    ) -> Result<QueryResult, EngineError> {
4394        let target_alias = stmt
4395            .target_alias
4396            .clone()
4397            .unwrap_or_else(|| stmt.target.clone());
4398        let source_alias = stmt
4399            .source_alias
4400            .clone()
4401            .unwrap_or_else(|| stmt.source.clone());
4402        let (target_cols, target_rows_snapshot) = {
4403            let t = self.active_catalog().get(&stmt.target).ok_or_else(|| {
4404                EngineError::Storage(StorageError::TableNotFound {
4405                    name: stmt.target.clone(),
4406                })
4407            })?;
4408            (
4409                t.schema().columns.clone(),
4410                t.rows().iter().cloned().collect::<Vec<Row>>(),
4411            )
4412        };
4413        let (source_cols, source_rows) = {
4414            let s = self.active_catalog().get(&stmt.source).ok_or_else(|| {
4415                EngineError::Storage(StorageError::TableNotFound {
4416                    name: stmt.source.clone(),
4417                })
4418            })?;
4419            (
4420                s.schema().columns.clone(),
4421                s.rows().iter().cloned().collect::<Vec<Row>>(),
4422            )
4423        };
4424        // Composite schema: target_alias.col ... source_alias.col ...
4425        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
4426        for col in &target_cols {
4427            combined_schema.push(ColumnSchema::new(
4428                alloc::format!("{target_alias}.{}", col.name),
4429                col.ty,
4430                col.nullable,
4431            ));
4432        }
4433        for col in &source_cols {
4434            combined_schema.push(ColumnSchema::new(
4435                alloc::format!("{source_alias}.{}", col.name),
4436                col.ty,
4437                col.nullable,
4438            ));
4439        }
4440        let combined_ctx = EvalContext::new(&combined_schema, None);
4441        // Source-only context for WHEN NOT MATCHED actions (no
4442        // matched target row exists — the source-side qualified
4443        // columns must still resolve).
4444        let mut source_only_schema: Vec<ColumnSchema> = Vec::new();
4445        for col in &target_cols {
4446            source_only_schema.push(ColumnSchema::new(
4447                alloc::format!("{target_alias}.{}", col.name),
4448                col.ty,
4449                col.nullable,
4450            ));
4451        }
4452        for col in &source_cols {
4453            source_only_schema.push(ColumnSchema::new(
4454                alloc::format!("{source_alias}.{}", col.name),
4455                col.ty,
4456                col.nullable,
4457            ));
4458        }
4459        let source_only_ctx = EvalContext::new(&source_only_schema, None);
4460        let target_arity = target_cols.len();
4461        let source_arity = source_cols.len();
4462
4463        // Resolve INSERT column positions once (validate names).
4464        // For each clause that's an INSERT, map column names → target positions.
4465        let mut delete_indices: Vec<usize> = Vec::new();
4466        let mut updates: Vec<(usize, Vec<Value>)> = Vec::new();
4467        let mut inserts: Vec<Vec<Value>> = Vec::new();
4468        let mut affected: usize = 0;
4469
4470        for (src_idx, src_row) in source_rows.iter().enumerate() {
4471            if src_idx.is_multiple_of(256) {
4472                cancel.check()?;
4473            }
4474            // Find every matched target index (per the ON predicate).
4475            let mut matched_targets: Vec<usize> = Vec::new();
4476            for (t_idx, t_row) in target_rows_snapshot.iter().enumerate() {
4477                let mut combined_vals = t_row.values.clone();
4478                combined_vals.extend(src_row.values.iter().cloned());
4479                let combined_row = Row::new(combined_vals);
4480                let cond = eval::eval_expr(&stmt.on, &combined_row, &combined_ctx)?;
4481                if matches!(cond, Value::Bool(true)) {
4482                    matched_targets.push(t_idx);
4483                }
4484            }
4485            let is_matched = !matched_targets.is_empty();
4486            // Pick the first WHEN clause whose kind agrees with
4487            // `is_matched` and whose AND condition (if any) holds.
4488            // AND condition for MATCHED: evaluated against the
4489            // first matched target row × source. For NOT MATCHED:
4490            // evaluated with target side NULL-padded.
4491            let fired_clause = stmt.clauses.iter().find(|c| {
4492                let kind_ok = match c.matched {
4493                    spg_sql::ast::MergeMatched::Matched => is_matched,
4494                    spg_sql::ast::MergeMatched::NotMatched => !is_matched,
4495                };
4496                if !kind_ok {
4497                    return false;
4498                }
4499                let Some(cond_expr) = &c.condition else {
4500                    return true;
4501                };
4502                let row = if is_matched {
4503                    let t = &target_rows_snapshot[matched_targets[0]];
4504                    let mut vals = t.values.clone();
4505                    vals.extend(src_row.values.iter().cloned());
4506                    Row::new(vals)
4507                } else {
4508                    let mut vals: Vec<Value> = (0..target_arity).map(|_| Value::Null).collect();
4509                    vals.extend(src_row.values.iter().cloned());
4510                    Row::new(vals)
4511                };
4512                let ctx_ref = if is_matched {
4513                    &combined_ctx
4514                } else {
4515                    &source_only_ctx
4516                };
4517                matches!(
4518                    eval::eval_expr(cond_expr, &row, ctx_ref),
4519                    Ok(Value::Bool(true))
4520                )
4521            });
4522            let Some(clause) = fired_clause else { continue };
4523            match &clause.action {
4524                spg_sql::ast::MergeAction::DoNothing => {}
4525                spg_sql::ast::MergeAction::Delete => {
4526                    for &t_idx in &matched_targets {
4527                        if !delete_indices.contains(&t_idx) {
4528                            delete_indices.push(t_idx);
4529                            affected += 1;
4530                        }
4531                    }
4532                }
4533                spg_sql::ast::MergeAction::Update { assignments } => {
4534                    // Pre-resolve SET targets to target column positions.
4535                    let mut planned_sets: Vec<(usize, &Expr)> =
4536                        Vec::with_capacity(assignments.len());
4537                    for (col, expr) in assignments {
4538                        let pos =
4539                            target_cols
4540                                .iter()
4541                                .position(|c| c.name == *col)
4542                                .ok_or_else(|| {
4543                                    EngineError::Eval(EvalError::ColumnNotFound {
4544                                        name: col.clone(),
4545                                    })
4546                                })?;
4547                        planned_sets.push((pos, expr));
4548                    }
4549                    for &t_idx in &matched_targets {
4550                        let t_row = &target_rows_snapshot[t_idx];
4551                        let mut new_values = t_row.values.clone();
4552                        let mut combined_vals = t_row.values.clone();
4553                        combined_vals.extend(src_row.values.iter().cloned());
4554                        let combined_row = Row::new(combined_vals);
4555                        for (pos, expr) in &planned_sets {
4556                            let raw = eval::eval_expr(expr, &combined_row, &combined_ctx)?;
4557                            let coerced = coerce_value(
4558                                raw,
4559                                target_cols[*pos].ty,
4560                                &target_cols[*pos].name,
4561                                *pos,
4562                            )?;
4563                            new_values[*pos] = coerced;
4564                        }
4565                        updates.push((t_idx, new_values));
4566                        affected += 1;
4567                    }
4568                }
4569                spg_sql::ast::MergeAction::Insert { columns, values } => {
4570                    // For INSERT NOT MATCHED, target side is NULL-padded.
4571                    let mut vals: Vec<Value> = (0..target_arity).map(|_| Value::Null).collect();
4572                    vals.extend(src_row.values.iter().cloned());
4573                    let synth_row = Row::new(vals);
4574                    let mut new_row_values: Vec<Value> =
4575                        (0..target_arity).map(|_| Value::Null).collect();
4576                    for (col, expr) in columns.iter().zip(values.iter()) {
4577                        let pos =
4578                            target_cols
4579                                .iter()
4580                                .position(|c| c.name == *col)
4581                                .ok_or_else(|| {
4582                                    EngineError::Eval(EvalError::ColumnNotFound {
4583                                        name: col.clone(),
4584                                    })
4585                                })?;
4586                        let raw = eval::eval_expr(expr, &synth_row, &source_only_ctx)?;
4587                        let coerced =
4588                            coerce_value(raw, target_cols[pos].ty, &target_cols[pos].name, pos)?;
4589                        new_row_values[pos] = coerced;
4590                    }
4591                    inserts.push(new_row_values);
4592                    affected += 1;
4593                }
4594            }
4595        }
4596        let _ = source_arity; // captured for symmetry; cancellation cost negligible.
4597
4598        // Apply the plan to the target table.
4599        let table = self
4600            .active_catalog_mut()
4601            .get_mut(&stmt.target)
4602            .ok_or_else(|| {
4603                EngineError::Storage(StorageError::TableNotFound {
4604                    name: stmt.target.clone(),
4605                })
4606            })?;
4607        // Apply updates first (in-place), then deletes (one batch),
4608        // then inserts. The storage API uses `update_row(pos,
4609        // new_values)`, `delete_rows(&[positions])`, and `insert(row)`.
4610        for (idx, new_vals) in &updates {
4611            table
4612                .update_row(*idx, new_vals.clone())
4613                .map_err(EngineError::Storage)?;
4614        }
4615        if !delete_indices.is_empty() {
4616            table.delete_rows(&delete_indices);
4617        }
4618        for vals in inserts {
4619            table.insert(Row::new(vals)).map_err(EngineError::Storage)?;
4620        }
4621        Ok(QueryResult::CommandOk {
4622            affected,
4623            modified_catalog: affected > 0,
4624        })
4625    }
4626
4627    fn exec_delete_cancel(
4628        &mut self,
4629        stmt: &spg_sql::ast::DeleteStatement,
4630        cancel: CancelToken<'_>,
4631    ) -> Result<QueryResult, EngineError> {
4632        // v7.12.5 — snapshot BEFORE/AFTER DELETE row triggers + the
4633        // session FTS config before the mut borrow (same shape as
4634        // INSERT / UPDATE).
4635        let before_delete_triggers = self.snapshot_row_triggers(&stmt.table, "DELETE", "BEFORE");
4636        let after_delete_triggers = self.snapshot_row_triggers(&stmt.table, "DELETE", "AFTER");
4637        let trigger_session_cfg: Option<String> = self
4638            .session_params
4639            .get("default_text_search_config")
4640            .cloned();
4641        // v5.2.3: PK-targeted DELETE → first retire any cold-tier
4642        // locator for the key. The cold row body stays in the
4643        // segment (becoming shadowed garbage that a future
4644        // compaction pass reclaims) but the index no longer
4645        // resolves it. The shadow count contributes to the
4646        // affected total; the subsequent hot walk handles any hot
4647        // rows for the same key.
4648        let mut cold_shadow_count: usize = 0;
4649        if let Some(w) = &stmt.where_ {
4650            let schema_cols = self
4651                .active_catalog()
4652                .get(&stmt.table)
4653                .ok_or_else(|| {
4654                    EngineError::Storage(StorageError::TableNotFound {
4655                        name: stmt.table.clone(),
4656                    })
4657                })?
4658                .schema()
4659                .columns
4660                .clone();
4661            if let Some((col_pos, key)) = try_pk_predicate(w, &schema_cols, stmt.table.as_str())
4662                && let Some(idx_name) = self
4663                    .active_catalog()
4664                    .get(&stmt.table)
4665                    .and_then(|t| t.index_on(col_pos).map(|i| i.name.clone()))
4666            {
4667                cold_shadow_count = self
4668                    .active_catalog_mut()
4669                    .shadow_cold_row(&stmt.table, &idx_name, &key)
4670                    .unwrap_or(0);
4671            }
4672        }
4673
4674        // v7.12.1 — cache the session FTS config as an owned
4675        // String before the mutable table borrow below; the
4676        // ctx-builder then references it via `as_deref` so the
4677        // immutable read of `session_params` doesn't conflict
4678        // with the mut borrow chain.
4679        let ts_cfg: Option<String> = self
4680            .session_param("default_text_search_config")
4681            .map(String::from);
4682        let table = self
4683            .active_catalog_mut()
4684            .get_mut(&stmt.table)
4685            .ok_or_else(|| {
4686                EngineError::Storage(StorageError::TableNotFound {
4687                    name: stmt.table.clone(),
4688                })
4689            })?;
4690        let schema_cols: Vec<ColumnSchema> = table.schema().columns.clone();
4691        let ctx = EvalContext::new(&schema_cols, Some(stmt.table.as_str()))
4692            .with_default_text_search_config(ts_cfg.as_deref());
4693        let mut positions: Vec<usize> = Vec::new();
4694        // v7.6.3 — collect every to-delete row's full Value tuple
4695        // alongside its position, so the FK enforcement pass can
4696        // run after the mut borrow drops.
4697        let mut to_delete_rows: Vec<Vec<Value>> = Vec::new();
4698        // v7.20 P4 — index seek (same shape as exec_update_cancel):
4699        // an equality WHERE on an indexed column narrows the walk
4700        // to the matching hot positions; the full WHERE still
4701        // re-evaluates per candidate. Downstream passes assume
4702        // ascending position order, so the seek result is sorted.
4703        let seek_positions: Option<Vec<usize>> = stmt
4704            .where_
4705            .as_ref()
4706            .and_then(|w| try_index_seek_positions(w, &schema_cols, table, stmt.table.as_str()));
4707        let candidate_positions: Vec<usize> = match seek_positions {
4708            Some(mut list) => {
4709                list.sort_unstable();
4710                list
4711            }
4712            None => (0..table.row_count()).collect(),
4713        };
4714        for (loop_n, &i) in candidate_positions.iter().enumerate() {
4715            if loop_n.is_multiple_of(256) {
4716                cancel.check()?;
4717            }
4718            let Some(row) = table.rows().get(i) else {
4719                continue;
4720            };
4721            let keep = if let Some(w) = &stmt.where_ {
4722                let cond = eval::eval_expr(w, row, &ctx)?;
4723                !matches!(cond, Value::Bool(true))
4724            } else {
4725                false
4726            };
4727            if !keep {
4728                positions.push(i);
4729                to_delete_rows.push(row.values.clone());
4730            }
4731        }
4732        // v7.6.3 / v7.6.4 — Stage 2: FK enforcement on the immutable
4733        // catalog. Release the mut borrow and run reverse-scan
4734        // against every child table whose FK targets this table.
4735        // RESTRICT / NoAction raise an error; CASCADE returns a
4736        // cascade plan that stage 3 applies after the primary delete.
4737        // SET NULL / SET DEFAULT remain Unsupported until v7.6.5.
4738        let _ = table;
4739        // v7.12.5 — BEFORE DELETE row-level triggers. Each fires
4740        // with NEW=None / OLD=pre-delete row; RETURN OLD (or NEW)
4741        // = proceed, RETURN NULL = skip the row entirely. The
4742        // filter must run BEFORE the FK cascade plan so cascaded
4743        // child rows track the trigger's skip-decision on the
4744        // parent.
4745        // v7.12.7 — embedded SQL queue.
4746        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
4747        if !before_delete_triggers.is_empty() {
4748            let mut filtered_positions: Vec<usize> = Vec::with_capacity(positions.len());
4749            let mut filtered_old_rows: Vec<Vec<Value>> = Vec::with_capacity(to_delete_rows.len());
4750            for (pos, old_vals) in positions.iter().zip(to_delete_rows.iter()) {
4751                let old_row = Row::new(old_vals.clone());
4752                let mut cancel_this = false;
4753                for fd in &before_delete_triggers {
4754                    let (outcome, deferred) = triggers::fire_row_trigger(
4755                        fd,
4756                        None,
4757                        Some(&old_row),
4758                        &stmt.table,
4759                        &schema_cols,
4760                        &[],
4761                        trigger_session_cfg.as_deref(),
4762                        false,
4763                    )
4764                    .map_err(|e| {
4765                        EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}")))
4766                    })?;
4767                    deferred_embedded.extend(deferred);
4768                    if matches!(outcome, triggers::TriggerOutcome::Skip) {
4769                        cancel_this = true;
4770                        break;
4771                    }
4772                }
4773                if !cancel_this {
4774                    filtered_positions.push(*pos);
4775                    filtered_old_rows.push(old_vals.clone());
4776                }
4777            }
4778            positions = filtered_positions;
4779            to_delete_rows = filtered_old_rows;
4780        }
4781        let cascade_plan = plan_fk_parent_deletions(
4782            self.active_catalog(),
4783            &stmt.table,
4784            &positions,
4785            &to_delete_rows,
4786        )?;
4787        // Stage 3a — apply each FK child step (SET NULL / SET
4788        // DEFAULT / CASCADE delete) before deleting the parent.
4789        // The plan is already ordered: nulls/defaults first, then
4790        // cascade deletes (so a row mutated and later deleted
4791        // surfaces as deleted — though v7.6.5 doesn't produce
4792        // that overlap today).
4793        for step in &cascade_plan {
4794            apply_fk_child_step(self.active_catalog_mut(), step)?;
4795        }
4796        // Stage 3b — actually delete the original target rows.
4797        let table = self
4798            .active_catalog_mut()
4799            .get_mut(&stmt.table)
4800            .ok_or_else(|| {
4801                EngineError::Storage(StorageError::TableNotFound {
4802                    name: stmt.table.clone(),
4803                })
4804            })?;
4805        let affected = table.delete_rows(&positions) + cold_shadow_count;
4806        let _ = table;
4807        // v7.12.5 — AFTER DELETE row-level triggers fire post-write
4808        // with NEW=None / OLD=pre-delete row (each from the
4809        // already-snapshotted to_delete_rows). Return value is
4810        // ignored (matches PG AFTER semantics).
4811        if !after_delete_triggers.is_empty() {
4812            for old_vals in &to_delete_rows {
4813                let old_row = Row::new(old_vals.clone());
4814                for fd in &after_delete_triggers {
4815                    let (_outcome, deferred) = triggers::fire_row_trigger(
4816                        fd,
4817                        None,
4818                        Some(&old_row),
4819                        &stmt.table,
4820                        &schema_cols,
4821                        &[],
4822                        trigger_session_cfg.as_deref(),
4823                        true,
4824                    )
4825                    .map_err(|e| {
4826                        EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}")))
4827                    })?;
4828                    deferred_embedded.extend(deferred);
4829                }
4830            }
4831        }
4832        // v7.12.7 — drain trigger-emitted embedded SQL for this DELETE.
4833        self.execute_deferred_trigger_stmts(deferred_embedded, cancel)?;
4834        // v6.2.1 — auto-analyze modified-row tracking for DELETE.
4835        if !self.in_transaction() && affected > 0 {
4836            self.statistics
4837                .record_modifications(&stmt.table, affected as u64);
4838        }
4839        // v7.9.4 — RETURNING projection over the soon-to-be-gone
4840        // rows. `to_delete_rows` was snapshotted in stage 1 before
4841        // mutation, so the projection sees the pre-delete state
4842        // (matches PG semantics: DELETE RETURNING returns the row
4843        // as it was just before removal).
4844        if let Some(items) = &stmt.returning {
4845            return self.build_returning_rows(&stmt.table, items, to_delete_rows);
4846        }
4847        Ok(QueryResult::CommandOk {
4848            affected,
4849            modified_catalog: !self.in_transaction(),
4850        })
4851    }
4852
4853    /// `SHOW TABLES` — one row per table in the active catalog.
4854    /// Column name is `name` so result-set consumers can downstream
4855    /// `SELECT name FROM ...` style logic if needed.
4856    /// v4.26: `EXPLAIN [ANALYZE] <select>`. Returns a single-column
4857    /// `QUERY PLAN` text table — first line names the top operator
4858    /// (Scan / Aggregate / Window / etc.), indented children list
4859    /// FROM joins, WHERE filters, ORDER BY / LIMIT, projection
4860    /// shape, and any active index hits. `ANALYZE` execs the inner
4861    /// SELECT and appends actual-row + elapsed-micros annotations.
4862    #[allow(clippy::format_push_string)]
4863    fn exec_explain(
4864        &self,
4865        e: &spg_sql::ast::ExplainStatement,
4866        cancel: CancelToken<'_>,
4867    ) -> Result<QueryResult, EngineError> {
4868        let mut lines = Vec::<String>::new();
4869        explain_select(&e.inner, self, 0, &mut lines);
4870        if e.suggest {
4871            // v6.8.3 — index advisor. Walks the SELECT's FROM
4872            // tables + WHERE column refs; for each (table, column)
4873            // pair that lacks an index, append a SUGGEST line with
4874            // a copy-pastable `CREATE INDEX` statement. This is a
4875            // pure-syntax heuristic — no cardinality estimation —
4876            // matching the v6.8.3 design intent of "tell the
4877            // operator where indexes are missing", not "give the
4878            // mathematically optimal index set".
4879            let suggestions = build_index_suggestions(&e.inner, self);
4880            for s in suggestions {
4881                lines.push(s);
4882            }
4883        } else if e.analyze {
4884            // v6.2.4 — EXPLAIN ANALYZE annotates each operator line
4885            // with `(rows=N)` where the row count is computable
4886            // without re-executing the full query:
4887            //   - Top-level operator (first non-indented line):
4888            //     rows = final result.len()
4889            //   - "From: <table> [full scan]" lines: rows =
4890            //     table.rows().len() (catalog read; no execution)
4891            //   - "From: <table> [index seek]": indeterminate —
4892            //     the index step would need re-execution; v6.2.5
4893            //     adds per-operator wall-clock + hot/cold rows
4894            //     instrumentation that makes this concrete.
4895            //   - Everything else: marked `(—)` so the surface
4896            //     stays well-defined without silently dropping
4897            //     stats. v6.2.5 fills in via inline executor
4898            //     instrumentation.
4899            // Total elapsed lands on a trailing `Total: …` line.
4900            let started = self.clock.map(|f| f());
4901            let exec = self.exec_select_cancel(&e.inner, cancel)?;
4902            let elapsed_micros = match (self.clock, started) {
4903                (Some(f), Some(s)) => Some(f().saturating_sub(s)),
4904                _ => None,
4905            };
4906            let row_count = if let QueryResult::Rows { rows, .. } = &exec {
4907                rows.len()
4908            } else {
4909                0
4910            };
4911            annotate_explain_lines(&mut lines, row_count, self);
4912            let mut total = alloc::format!("Total: rows={row_count}");
4913            if let Some(us) = elapsed_micros {
4914                total.push_str(&alloc::format!(" elapsed={us}us"));
4915            }
4916            lines.push(total);
4917        }
4918        let columns = alloc::vec![ColumnSchema::new("QUERY PLAN", DataType::Text, false)];
4919        let rows: Vec<Row> = lines
4920            .into_iter()
4921            .map(|l| Row::new(alloc::vec![Value::Text(l)]))
4922            .collect();
4923        Ok(QueryResult::Rows { columns, rows })
4924    }
4925
4926    fn exec_show_tables(&self) -> QueryResult {
4927        let columns = alloc::vec![ColumnSchema::new("name", DataType::Text, false)];
4928        let rows: Vec<Row> = self
4929            .active_catalog()
4930            .table_names()
4931            .into_iter()
4932            .map(|n| Row::new(alloc::vec![Value::Text(n)]))
4933            .collect();
4934        QueryResult::Rows { columns, rows }
4935    }
4936
4937    /// v7.17.0 Phase 3.P0-59 — `SHOW CREATE TABLE <t>`. Synthesise
4938    /// a minimal MySQL-flavoured CREATE TABLE DDL from the
4939    /// catalog's TableSchema so mysqldump round-trips load against
4940    /// SPG without splitting init scripts.
4941    fn exec_show_create_table(&self, name: &str) -> Result<QueryResult, EngineError> {
4942        let t = self.active_catalog().get(name).ok_or_else(|| {
4943            EngineError::Storage(StorageError::TableNotFound { name: name.into() })
4944        })?;
4945        let cols: Vec<String> = t
4946            .schema()
4947            .columns
4948            .iter()
4949            .map(|c| {
4950                let ty = render_data_type(c.ty);
4951                let nullable = if c.nullable { "" } else { " NOT NULL" };
4952                alloc::format!("  `{}` {}{}", c.name, ty, nullable)
4953            })
4954            .collect();
4955        let mut body = cols.join(",\n");
4956        // Append UNIQUE / PRIMARY KEY clauses.
4957        for uc in &t.schema().uniqueness_constraints {
4958            let col_names: Vec<String> = uc
4959                .columns
4960                .iter()
4961                .map(|&p| {
4962                    t.schema().columns.get(p).map_or_else(
4963                        || alloc::format!("col{p}"),
4964                        |c| alloc::format!("`{}`", c.name),
4965                    )
4966                })
4967                .collect();
4968            let kw = if uc.is_primary_key {
4969                "PRIMARY KEY"
4970            } else {
4971                "UNIQUE KEY"
4972            };
4973            body.push_str(",\n  ");
4974            body.push_str(&alloc::format!("{kw} ({})", col_names.join(", ")));
4975        }
4976        // Foreign keys.
4977        for fk in &t.schema().foreign_keys {
4978            let local: Vec<String> = fk
4979                .local_columns
4980                .iter()
4981                .map(|&p| {
4982                    t.schema().columns.get(p).map_or_else(
4983                        || alloc::format!("col{p}"),
4984                        |c| alloc::format!("`{}`", c.name),
4985                    )
4986                })
4987                .collect();
4988            let parent_cols: Vec<String> =
4989                if let Some(parent) = self.active_catalog().get(&fk.parent_table) {
4990                    fk.parent_columns
4991                        .iter()
4992                        .map(|&p| {
4993                            parent.schema().columns.get(p).map_or_else(
4994                                || alloc::format!("col{p}"),
4995                                |c| alloc::format!("`{}`", c.name),
4996                            )
4997                        })
4998                        .collect()
4999                } else {
5000                    fk.parent_columns
5001                        .iter()
5002                        .map(|p| alloc::format!("col{p}"))
5003                        .collect()
5004                };
5005            body.push_str(",\n  ");
5006            body.push_str(&alloc::format!(
5007                "FOREIGN KEY ({}) REFERENCES `{}` ({})",
5008                local.join(", "),
5009                fk.parent_table,
5010                parent_cols.join(", ")
5011            ));
5012        }
5013        let ddl = alloc::format!(
5014            "CREATE TABLE `{}` (\n{}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
5015            name,
5016            body
5017        );
5018        let columns = alloc::vec![
5019            ColumnSchema::new("Table", DataType::Text, false),
5020            ColumnSchema::new("Create Table", DataType::Text, false),
5021        ];
5022        let rows = alloc::vec![Row::new(alloc::vec![
5023            Value::Text(name.into()),
5024            Value::Text(ddl),
5025        ])];
5026        Ok(QueryResult::Rows { columns, rows })
5027    }
5028
5029    /// v7.17.0 Phase 3.P0-60 — `SHOW INDEXES FROM <t>`. MySQL
5030    /// surface returns one row per (index × column) with 14
5031    /// columns; v7.17 ships the columns admin probes actually
5032    /// filter on: Table, Non_unique, Key_name, Seq_in_index,
5033    /// Column_name, Null, Index_type.
5034    fn exec_show_indexes(&self, name: &str) -> Result<QueryResult, EngineError> {
5035        let t = self.active_catalog().get(name).ok_or_else(|| {
5036            EngineError::Storage(StorageError::TableNotFound { name: name.into() })
5037        })?;
5038        let columns = alloc::vec![
5039            ColumnSchema::new("Table", DataType::Text, false),
5040            ColumnSchema::new("Non_unique", DataType::Int, false),
5041            ColumnSchema::new("Key_name", DataType::Text, false),
5042            ColumnSchema::new("Seq_in_index", DataType::Int, false),
5043            ColumnSchema::new("Column_name", DataType::Text, false),
5044            ColumnSchema::new("Null", DataType::Text, false),
5045            ColumnSchema::new("Index_type", DataType::Text, false),
5046        ];
5047        let mut rows: Vec<Row> = Vec::new();
5048        for idx in t.indices() {
5049            let col = t
5050                .schema()
5051                .columns
5052                .get(idx.column_position)
5053                .map_or("?".into(), |c| c.name.clone());
5054            let nullable = t
5055                .schema()
5056                .columns
5057                .get(idx.column_position)
5058                .map_or(true, |c| c.nullable);
5059            rows.push(Row::new(alloc::vec![
5060                Value::Text(name.into()),
5061                Value::Int(i32::from(!idx.is_unique)),
5062                Value::Text(idx.name.clone()),
5063                Value::Int(1),
5064                Value::Text(col),
5065                Value::Text(if nullable {
5066                    "YES".into()
5067                } else {
5068                    String::new()
5069                }),
5070                Value::Text("BTREE".into()),
5071            ]));
5072        }
5073        Ok(QueryResult::Rows { columns, rows })
5074    }
5075
5076    /// v7.17.0 Phase 3.P0-61 — `SHOW STATUS`. Returns canonical
5077    /// MySQL server-status counters (2-column `(Variable_name,
5078    /// Value)`).
5079    fn exec_show_status(&self) -> QueryResult {
5080        let columns = alloc::vec![
5081            ColumnSchema::new("Variable_name", DataType::Text, false),
5082            ColumnSchema::new("Value", DataType::Text, false),
5083        ];
5084        let pairs: &[(&str, &str)] = &[
5085            ("Uptime", "0"),
5086            ("Threads_connected", "1"),
5087            ("Threads_running", "1"),
5088            ("Questions", "0"),
5089            ("Slow_queries", "0"),
5090            ("Opened_tables", "0"),
5091            ("Innodb_buffer_pool_pages_total", "0"),
5092        ];
5093        let rows: Vec<Row> = pairs
5094            .iter()
5095            .map(|(k, v)| {
5096                Row::new(alloc::vec![
5097                    Value::Text((*k).into()),
5098                    Value::Text((*v).into())
5099                ])
5100            })
5101            .collect();
5102        QueryResult::Rows { columns, rows }
5103    }
5104
5105    /// v7.17.0 Phase 3.P0-61 — `SHOW VARIABLES`. Returns server-side
5106    /// variables MySQL/MariaDB clients probe at connect time.
5107    fn exec_show_variables(&self) -> QueryResult {
5108        let columns = alloc::vec![
5109            ColumnSchema::new("Variable_name", DataType::Text, false),
5110            ColumnSchema::new("Value", DataType::Text, false),
5111        ];
5112        let mut rows: Vec<Row> = Vec::new();
5113        let canonical: &[(&str, &str)] = &[
5114            ("version", "8.0.35-spg"),
5115            ("version_comment", "SPG dual-stack engine"),
5116            ("character_set_server", "utf8mb4"),
5117            ("collation_server", "utf8mb4_0900_ai_ci"),
5118            ("max_allowed_packet", "67108864"),
5119            ("autocommit", "ON"),
5120            ("sql_mode", "STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION"),
5121            ("time_zone", "SYSTEM"),
5122            ("transaction_isolation", "REPEATABLE-READ"),
5123        ];
5124        for &(k, v) in canonical {
5125            rows.push(Row::new(alloc::vec![
5126                Value::Text(k.into()),
5127                Value::Text(v.into()),
5128            ]));
5129        }
5130        // Session-set parameters surface here too.
5131        for (k, v) in &self.session_params {
5132            if !canonical.iter().any(|(n, _)| (*n).eq_ignore_ascii_case(k)) {
5133                rows.push(Row::new(alloc::vec![
5134                    Value::Text(k.clone()),
5135                    Value::Text(v.clone()),
5136                ]));
5137            }
5138        }
5139        QueryResult::Rows { columns, rows }
5140    }
5141
5142    /// v7.17.0 Phase 3.P0-62 — `SHOW PROCESSLIST`. SPG is
5143    /// single-process so the surface returns one synthetic row
5144    /// describing the current connection (Id, User, Host, db,
5145    /// Command, Time, State, Info).
5146    fn exec_show_processlist(&self) -> QueryResult {
5147        let columns = alloc::vec![
5148            ColumnSchema::new("Id", DataType::Int, false),
5149            ColumnSchema::new("User", DataType::Text, false),
5150            ColumnSchema::new("Host", DataType::Text, false),
5151            ColumnSchema::new("db", DataType::Text, true),
5152            ColumnSchema::new("Command", DataType::Text, false),
5153            ColumnSchema::new("Time", DataType::Int, false),
5154            ColumnSchema::new("State", DataType::Text, true),
5155            ColumnSchema::new("Info", DataType::Text, true),
5156        ];
5157        let rows = alloc::vec![Row::new(alloc::vec![
5158            Value::Int(1),
5159            Value::Text("postgres".into()),
5160            Value::Text("localhost".into()),
5161            Value::Text("postgres".into()),
5162            Value::Text("Query".into()),
5163            Value::Int(0),
5164            Value::Text("executing".into()),
5165            Value::Text("SHOW PROCESSLIST".into()),
5166        ])];
5167        QueryResult::Rows { columns, rows }
5168    }
5169
5170    /// v7.17.0 Phase 3.P0-58 — `SHOW DATABASES` / `SHOW SCHEMAS`.
5171    /// SPG is single-database so the result is the canonical MySQL
5172    /// set every mysql/MariaDB client expects at connect time:
5173    /// `information_schema`, `mysql`, `performance_schema`, `sys`,
5174    /// plus a `postgres` slot so dual-stack callers find their
5175    /// PG-compatible database too.
5176    fn exec_show_databases(&self) -> QueryResult {
5177        let columns = alloc::vec![ColumnSchema::new("Database", DataType::Text, false)];
5178        let names = [
5179            "information_schema",
5180            "mysql",
5181            "performance_schema",
5182            "sys",
5183            "postgres",
5184        ];
5185        let rows: Vec<Row> = names
5186            .iter()
5187            .map(|n| Row::new(alloc::vec![Value::Text((*n).into())]))
5188            .collect();
5189        QueryResult::Rows { columns, rows }
5190    }
5191
5192    /// `SHOW COLUMNS FROM <table>` — one row per column with the
5193    /// declared name, SQL type rendering, and nullability flag.
5194    fn exec_show_columns(&self, table_name: &str) -> Result<QueryResult, EngineError> {
5195        let table =
5196            self.active_catalog()
5197                .get(table_name)
5198                .ok_or_else(|| StorageError::TableNotFound {
5199                    name: table_name.into(),
5200                })?;
5201        let columns = alloc::vec![
5202            ColumnSchema::new("name", DataType::Text, false),
5203            ColumnSchema::new("type", DataType::Text, false),
5204            ColumnSchema::new("nullable", DataType::Bool, false),
5205        ];
5206        let rows: Vec<Row> = table
5207            .schema()
5208            .columns
5209            .iter()
5210            .map(|c| {
5211                Row::new(alloc::vec![
5212                    Value::Text(c.name.clone()),
5213                    Value::Text(alloc::format!("{}", c.ty)),
5214                    Value::Bool(c.nullable),
5215                ])
5216            })
5217            .collect();
5218        Ok(QueryResult::Rows { columns, rows })
5219    }
5220
5221    fn exec_begin(&mut self) -> Result<QueryResult, EngineError> {
5222        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5223        if self.tx_catalogs.contains_key(&tx_id) {
5224            return Err(EngineError::TransactionAlreadyOpen);
5225        }
5226        self.tx_catalogs.insert(
5227            tx_id,
5228            TxState {
5229                catalog: self.catalog.clone(),
5230                savepoints: Vec::new(),
5231            },
5232        );
5233        Ok(QueryResult::CommandOk {
5234            affected: 0,
5235            modified_catalog: false,
5236        })
5237    }
5238
5239    fn exec_commit(&mut self) -> Result<QueryResult, EngineError> {
5240        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5241        let state = self
5242            .tx_catalogs
5243            .remove(&tx_id)
5244            .ok_or(EngineError::NoActiveTransaction)?;
5245        self.catalog = state.catalog;
5246        // All savepoints become permanent at COMMIT and the stack
5247        // resets for the next TX (`state.savepoints` is discarded with
5248        // `state`).
5249        Ok(QueryResult::CommandOk {
5250            affected: 0,
5251            modified_catalog: true,
5252        })
5253    }
5254
5255    fn exec_rollback(&mut self) -> Result<QueryResult, EngineError> {
5256        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5257        if self.tx_catalogs.remove(&tx_id).is_none() {
5258            return Err(EngineError::NoActiveTransaction);
5259        }
5260        // savepoints discarded with the TxState
5261        Ok(QueryResult::CommandOk {
5262            affected: 0,
5263            modified_catalog: false,
5264        })
5265    }
5266
5267    fn exec_savepoint(&mut self, name: String) -> Result<QueryResult, EngineError> {
5268        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5269        let state = self
5270            .tx_catalogs
5271            .get_mut(&tx_id)
5272            .ok_or(EngineError::NoActiveTransaction)?;
5273        // PG re-uses an existing savepoint name by dropping the older
5274        // entry and pushing a fresh one — match that behaviour so
5275        // application code can `SAVEPOINT sp; ...; SAVEPOINT sp` freely.
5276        state.savepoints.retain(|(n, _)| n != &name);
5277        let snapshot = state.catalog.clone();
5278        state.savepoints.push((name, snapshot));
5279        Ok(QueryResult::CommandOk {
5280            affected: 0,
5281            modified_catalog: false,
5282        })
5283    }
5284
5285    fn exec_rollback_to_savepoint(&mut self, name: &str) -> Result<QueryResult, EngineError> {
5286        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5287        let state = self
5288            .tx_catalogs
5289            .get_mut(&tx_id)
5290            .ok_or(EngineError::NoActiveTransaction)?;
5291        let pos = state
5292            .savepoints
5293            .iter()
5294            .rposition(|(n, _)| n == name)
5295            .ok_or_else(|| {
5296                EngineError::Unsupported(alloc::format!("savepoint not found: {name}"))
5297            })?;
5298        // The savepoint stays on the stack (PG semantics): a later
5299        // `RELEASE` or further `ROLLBACK TO` is still allowed. Everything
5300        // after it is discarded.
5301        let snapshot = state.savepoints[pos].1.clone();
5302        state.savepoints.truncate(pos + 1);
5303        state.catalog = snapshot;
5304        Ok(QueryResult::CommandOk {
5305            affected: 0,
5306            modified_catalog: false,
5307        })
5308    }
5309
5310    fn exec_release_savepoint(&mut self, name: &str) -> Result<QueryResult, EngineError> {
5311        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5312        let state = self
5313            .tx_catalogs
5314            .get_mut(&tx_id)
5315            .ok_or(EngineError::NoActiveTransaction)?;
5316        let pos = state
5317            .savepoints
5318            .iter()
5319            .rposition(|(n, _)| n == name)
5320            .ok_or_else(|| {
5321                EngineError::Unsupported(alloc::format!("savepoint not found: {name}"))
5322            })?;
5323        // RELEASE keeps the work since the savepoint, just discards the
5324        // bookmark plus everything nested under it.
5325        state.savepoints.truncate(pos);
5326        Ok(QueryResult::CommandOk {
5327            affected: 0,
5328            modified_catalog: false,
5329        })
5330    }
5331
5332    /// v6.0.4 — synchronous `ALTER INDEX <name> REBUILD [WITH
5333    /// (encoding = …)]`. Walks every table in the active catalog
5334    /// looking for an index matching `stmt.name`, then delegates the
5335    /// rebuild (including any encoding switch) to
5336    /// `Table::rebuild_nsw_index`. The "live" non-blocking
5337    /// optimisation is v6.0.4.1 / v6.1.x territory.
5338    /// v6.7.2 — `ALTER TABLE t SET hot_tier_bytes = X`. Dispatch
5339    /// arm. Currently the only setting is `hot_tier_bytes`; later
5340    /// v6.7.x can extend `AlterTableTarget` without touching this
5341    /// arm structure.
5342    fn exec_alter_table(
5343        &mut self,
5344        s: spg_sql::ast::AlterTableStatement,
5345    ) -> Result<QueryResult, EngineError> {
5346        // v7.13.2 — mailrs round-6 S1: apply each subaction in order.
5347        // On first error the statement aborts; subactions already
5348        // applied stay (no transactional rollback in v7.13 — wrap in
5349        // BEGIN/COMMIT if atomicity matters).
5350        let table_name = s.name.clone();
5351        for target in s.targets {
5352            self.exec_alter_table_subaction(&table_name, target)?;
5353        }
5354        Ok(QueryResult::CommandOk {
5355            affected: 0,
5356            modified_catalog: !self.in_transaction(),
5357        })
5358    }
5359
5360    fn exec_alter_table_subaction(
5361        &mut self,
5362        table_name_outer: &str,
5363        target: spg_sql::ast::AlterTableTarget,
5364    ) -> Result<(), EngineError> {
5365        // Inner helper retains the s.name closure shape; alias to `s`
5366        // for minimal diff against the v7.13.0 body.
5367        struct S<'a> {
5368            name: &'a str,
5369        }
5370        let s = S {
5371            name: table_name_outer,
5372        };
5373        match target {
5374            spg_sql::ast::AlterTableTarget::SetHotTierBytes(n) => {
5375                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5376                    EngineError::Storage(StorageError::TableNotFound {
5377                        name: s.name.into(),
5378                    })
5379                })?;
5380                table.schema_mut().hot_tier_bytes = Some(n);
5381            }
5382            spg_sql::ast::AlterTableTarget::AddForeignKey(fk) => {
5383                // v7.6.8 — resolve FK against the live catalog first
5384                // (validates parent table, columns, indices). Then
5385                // verify every existing row in the child table
5386                // satisfies the new constraint. Then install it.
5387                let cols_snapshot = self
5388                    .active_catalog()
5389                    .get(s.name)
5390                    .ok_or_else(|| {
5391                        EngineError::Storage(StorageError::TableNotFound {
5392                            name: s.name.into(),
5393                        })
5394                    })?
5395                    .schema()
5396                    .columns
5397                    .clone();
5398                let storage_fk =
5399                    resolve_foreign_key(s.name, &cols_snapshot, fk, self.active_catalog())?;
5400                // Verify existing rows. Treat them as a virtual
5401                // INSERT batch — reusing the v7.6.2 enforce helper.
5402                let existing_rows: Vec<Vec<Value>> = self
5403                    .active_catalog()
5404                    .get(s.name)
5405                    .expect("checked above")
5406                    .rows()
5407                    .iter()
5408                    .map(|r| r.values.clone())
5409                    .collect();
5410                enforce_fk_inserts(
5411                    self.active_catalog(),
5412                    s.name,
5413                    core::slice::from_ref(&storage_fk),
5414                    &existing_rows,
5415                )?;
5416                // Reject duplicate constraint name.
5417                let table = self
5418                    .active_catalog_mut()
5419                    .get_mut(s.name)
5420                    .expect("checked above");
5421                if let Some(name) = &storage_fk.name
5422                    && table
5423                        .schema()
5424                        .foreign_keys
5425                        .iter()
5426                        .any(|f| f.name.as_ref() == Some(name))
5427                {
5428                    return Err(EngineError::Unsupported(alloc::format!(
5429                        "ALTER TABLE ADD CONSTRAINT: a constraint named {name:?} already exists"
5430                    )));
5431                }
5432                table.schema_mut().foreign_keys.push(storage_fk);
5433            }
5434            spg_sql::ast::AlterTableTarget::DropForeignKey { name, if_exists } => {
5435                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5436                    EngineError::Storage(StorageError::TableNotFound {
5437                        name: s.name.into(),
5438                    })
5439                })?;
5440                let fks = &mut table.schema_mut().foreign_keys;
5441                let before = fks.len();
5442                fks.retain(|f| f.name.as_ref() != Some(&name));
5443                if fks.len() == before && !if_exists {
5444                    return Err(EngineError::Unsupported(alloc::format!(
5445                        "ALTER TABLE DROP CONSTRAINT: no FK named {name:?} on {:?}",
5446                        s.name
5447                    )));
5448                }
5449                // v7.13.2 mailrs round-6 S7: IF EXISTS silences the miss.
5450            }
5451            spg_sql::ast::AlterTableTarget::AddColumn {
5452                column,
5453                if_not_exists,
5454            } => {
5455                // v7.13.0 — mailrs round-5 G1. Append-only column add
5456                // with back-fill of the DEFAULT (or NULL) into every
5457                // existing row. Column positions don't shift, so we
5458                // skip index rebuild.
5459                let clock = self.clock;
5460                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5461                    EngineError::Storage(StorageError::TableNotFound {
5462                        name: s.name.into(),
5463                    })
5464                })?;
5465                if table
5466                    .schema()
5467                    .columns
5468                    .iter()
5469                    .any(|c| c.name.eq_ignore_ascii_case(&column.name))
5470                {
5471                    if if_not_exists {
5472                        return Ok(());
5473                    }
5474                    return Err(EngineError::Unsupported(alloc::format!(
5475                        "ALTER TABLE ADD COLUMN: column {:?} already exists on {:?}",
5476                        column.name,
5477                        s.name
5478                    )));
5479                }
5480                let col_name = column.name.clone();
5481                let nullable = column.nullable;
5482                let has_default = column.default.is_some() || column.auto_increment;
5483                let col_schema = column_def_to_schema(column)?;
5484                let row_count = table.row_count();
5485                // Compute the back-fill value. Literal / runtime DEFAULT
5486                // funnels through the same resolver that INSERT uses
5487                // (v7.9.21 `resolve_column_default_free`). NULL when
5488                // the column is nullable and has no DEFAULT. NOT NULL
5489                // without DEFAULT errors when the table has existing
5490                // rows — same as PG.
5491                let fill_value: Value = if has_default || col_schema.runtime_default.is_some() {
5492                    resolve_column_default_free(&col_schema, clock)?
5493                } else if nullable || row_count == 0 {
5494                    Value::Null
5495                } else {
5496                    return Err(EngineError::Unsupported(alloc::format!(
5497                        "ALTER TABLE ADD COLUMN {col_name:?}: NOT NULL column requires DEFAULT \
5498                         when the table has existing rows"
5499                    )));
5500                };
5501                table.add_column(col_schema, fill_value);
5502            }
5503            spg_sql::ast::AlterTableTarget::AlterColumnType {
5504                column,
5505                new_type,
5506                using,
5507            } => {
5508                // v7.13.0 — mailrs round-5 G8. Re-evaluate each
5509                // row's column value (either through the USING
5510                // expression if supplied, or as a direct CAST of
5511                // the existing value) and re-coerce to the new
5512                // type. Indices on the column get rebuilt.
5513                let new_data_type = column_type_to_data_type(new_type);
5514                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5515                    EngineError::Storage(StorageError::TableNotFound {
5516                        name: s.name.into(),
5517                    })
5518                })?;
5519                let col_pos = table
5520                    .schema()
5521                    .columns
5522                    .iter()
5523                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5524                    .ok_or_else(|| {
5525                        EngineError::Unsupported(alloc::format!(
5526                            "ALTER COLUMN TYPE: column {column:?} not found on {:?}",
5527                            s.name
5528                        ))
5529                    })?;
5530                let schema_cols = table.schema().columns.clone();
5531                let ctx = eval::EvalContext::new(&schema_cols, None);
5532                let mut new_values: alloc::vec::Vec<Value> =
5533                    alloc::vec::Vec::with_capacity(table.row_count());
5534                for row in table.rows().iter() {
5535                    let raw = match &using {
5536                        Some(expr) => eval::eval_expr(expr, row, &ctx).map_err(|e| {
5537                            EngineError::Unsupported(alloc::format!(
5538                                "ALTER COLUMN TYPE: USING expression failed: {e:?}"
5539                            ))
5540                        })?,
5541                        None => row.values.get(col_pos).cloned().unwrap_or(Value::Null),
5542                    };
5543                    let coerced = coerce_value(raw, new_data_type, &column, col_pos)?;
5544                    new_values.push(coerced);
5545                }
5546                table.schema_mut().columns[col_pos].ty = new_data_type;
5547                for (i, v) in new_values.into_iter().enumerate() {
5548                    let mut row_values = table
5549                        .rows()
5550                        .get(i)
5551                        .expect("bounds-checked above")
5552                        .values
5553                        .clone();
5554                    row_values[col_pos] = v;
5555                    table.update_row(i, row_values)?;
5556                }
5557            }
5558            spg_sql::ast::AlterTableTarget::AddTableConstraint(tc) => {
5559                // v7.14.0 — pg_dump emits PKs as a separate
5560                // ALTER TABLE ADD CONSTRAINT post-CREATE-TABLE.
5561                // For PRIMARY KEY / UNIQUE, install a UC entry
5562                // and the implicit BTree index on the leading
5563                // column. CHECK: append predicate to schema.
5564                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5565                    EngineError::Storage(StorageError::TableNotFound {
5566                        name: s.name.into(),
5567                    })
5568                })?;
5569                let is_pk = matches!(tc, spg_sql::ast::TableConstraint::PrimaryKey { .. });
5570                // v7.22 (mailrs round-13 gap 6) — carry the parsed
5571                // NULLS NOT DISTINCT flag through the ALTER path;
5572                // it was hardcoded false here while the CREATE
5573                // TABLE path honoured it since v7.13.
5574                let nnd = matches!(
5575                    tc,
5576                    spg_sql::ast::TableConstraint::Unique {
5577                        nulls_not_distinct: true,
5578                        ..
5579                    }
5580                );
5581                match tc {
5582                    spg_sql::ast::TableConstraint::PrimaryKey { columns, .. }
5583                    | spg_sql::ast::TableConstraint::Unique { columns, .. } => {
5584                        let positions: Vec<usize> = columns
5585                            .iter()
5586                            .map(|c| {
5587                                table
5588                                    .schema()
5589                                    .columns
5590                                    .iter()
5591                                    .position(|sc| sc.name.eq_ignore_ascii_case(c))
5592                                    .ok_or_else(|| {
5593                                        EngineError::Unsupported(alloc::format!(
5594                                            "ALTER TABLE ADD CONSTRAINT: column {c:?} not found on {:?}",
5595                                            s.name
5596                                        ))
5597                                    })
5598                            })
5599                            .collect::<Result<Vec<_>, _>>()?;
5600                        // Skip if an equivalent UC is already there
5601                        // (idempotent — pg_dump's PK + a prior inline
5602                        // PK shouldn't double-install).
5603                        let already = table
5604                            .schema()
5605                            .uniqueness_constraints
5606                            .iter()
5607                            .any(|u| u.columns == positions);
5608                        if !already {
5609                            table.schema_mut().uniqueness_constraints.push(
5610                                spg_storage::UniquenessConstraint {
5611                                    is_primary_key: is_pk,
5612                                    columns: positions.clone(),
5613                                    nulls_not_distinct: nnd,
5614                                },
5615                            );
5616                            // PK implies NOT NULL on referenced cols.
5617                            if is_pk {
5618                                for p in &positions {
5619                                    if let Some(c) = table.schema_mut().columns.get_mut(*p) {
5620                                        c.nullable = false;
5621                                    }
5622                                }
5623                            }
5624                            // Add a BTree index on the leading
5625                            // column for INSERT-side enforcement.
5626                            let leading = &columns[0];
5627                            let already_idx = table.indices().iter().any(|idx| {
5628                                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
5629                                    && table.schema().columns[idx.column_position].name == *leading
5630                            });
5631                            if !already_idx {
5632                                let suffix = if is_pk { "pkey" } else { "key" };
5633                                let idx_name = alloc::format!("{}_{leading}_{suffix}", s.name);
5634                                let _ = table.add_index(idx_name, leading);
5635                            }
5636                        }
5637                    }
5638                    spg_sql::ast::TableConstraint::Check { expr, .. } => {
5639                        table.schema_mut().checks.push(alloc::format!("{expr}"));
5640                    }
5641                    spg_sql::ast::TableConstraint::Index { name, columns } => {
5642                        // v7.15.0 — ALTER TABLE ADD KEY (cols).
5643                        // mysqldump occasionally emits this
5644                        // post-CREATE-TABLE shape; build a BTree
5645                        // on the leading column using the
5646                        // user-supplied or synthesised name.
5647                        let leading = &columns[0];
5648                        let already_idx = table.indices().iter().any(|idx| {
5649                            matches!(idx.kind, spg_storage::IndexKind::BTree(_))
5650                                && table.schema().columns[idx.column_position].name == *leading
5651                        });
5652                        if !already_idx {
5653                            let idx_name = name
5654                                .clone()
5655                                .unwrap_or_else(|| alloc::format!("{}_{leading}_idx", s.name));
5656                            let _ = table.add_index(idx_name, leading);
5657                        }
5658                    }
5659                    spg_sql::ast::TableConstraint::FulltextIndex { name, columns } => {
5660                        // v7.17.0 Phase 2.2 — ALTER TABLE ADD
5661                        // FULLTEXT KEY (cols). Builds one
5662                        // fulltext-GIN per named column so MATCH
5663                        // AGAINST gets a real inverted index.
5664                        // Multi-column declarations expand to
5665                        // per-column GINs (the leading column
5666                        // drives MATCH AGAINST planning).
5667                        for (k, col) in columns.iter().enumerate() {
5668                            let already_idx = table.indices().iter().any(|idx| {
5669                                matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
5670                                    && table.schema().columns[idx.column_position].name == *col
5671                            });
5672                            if already_idx {
5673                                continue;
5674                            }
5675                            let idx_name = match (&name, columns.len(), k) {
5676                                (Some(n), 1, _) => n.clone(),
5677                                (Some(n), _, k) => alloc::format!("{n}_{k}"),
5678                                (None, _, _) => {
5679                                    alloc::format!("{}_{col}_ftidx", s.name)
5680                                }
5681                            };
5682                            let _ = table.add_gin_fulltext_index(idx_name, col);
5683                        }
5684                    }
5685                }
5686            }
5687            spg_sql::ast::AlterTableTarget::DropColumn {
5688                column,
5689                if_exists,
5690                cascade,
5691            } => {
5692                // v7.13.3 — mailrs round-7 S8. Remove the column +
5693                // every row's value at that position; drop any index
5694                // on the column. RESTRICT (default) rejects when an
5695                // FK on this table or partial-index predicate
5696                // references the column; CASCADE removes those
5697                // dependents first.
5698                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5699                    EngineError::Storage(StorageError::TableNotFound {
5700                        name: s.name.into(),
5701                    })
5702                })?;
5703                let col_pos = match table
5704                    .schema()
5705                    .columns
5706                    .iter()
5707                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5708                {
5709                    Some(p) => p,
5710                    None => {
5711                        if if_exists {
5712                            return Ok(());
5713                        }
5714                        return Err(EngineError::Unsupported(alloc::format!(
5715                            "ALTER TABLE DROP COLUMN: column {column:?} not found on {:?}",
5716                            s.name
5717                        )));
5718                    }
5719                };
5720                // Dependent check: FKs whose local columns include
5721                // col_pos. CASCADE drops them; otherwise reject.
5722                let dependent_fks: Vec<usize> = table
5723                    .schema()
5724                    .foreign_keys
5725                    .iter()
5726                    .enumerate()
5727                    .filter_map(|(i, fk)| {
5728                        if fk.local_columns.contains(&col_pos) {
5729                            Some(i)
5730                        } else {
5731                            None
5732                        }
5733                    })
5734                    .collect();
5735                if !dependent_fks.is_empty() && !cascade {
5736                    return Err(EngineError::Unsupported(alloc::format!(
5737                        "ALTER TABLE DROP COLUMN {column:?}: column has FK dependents; \
5738                         use DROP COLUMN ... CASCADE to remove them"
5739                    )));
5740                }
5741                // CASCADE the FK removals first.
5742                if cascade {
5743                    // Drop in reverse so indices stay valid.
5744                    let mut sorted = dependent_fks.clone();
5745                    sorted.sort();
5746                    sorted.reverse();
5747                    let fks = &mut table.schema_mut().foreign_keys;
5748                    for i in sorted {
5749                        fks.remove(i);
5750                    }
5751                }
5752                // Drop the column. New helper on Table does the
5753                // row + schema + index shift atomically.
5754                table.drop_column(col_pos);
5755            }
5756            spg_sql::ast::AlterTableTarget::SetTriggerEnabled { which, enabled } => {
5757                // v7.16.1 — mailrs round-9 A.2.b. pg_dump
5758                // --disable-triggers wraps each table's data
5759                // block with `ALTER TABLE … DISABLE TRIGGER ALL`
5760                // / `… ENABLE TRIGGER ALL`. Toggle the enabled
5761                // flag on every matching trigger so the row-
5762                // write paths skip them; the catalog snapshot
5763                // persists the new state across restarts.
5764                let table_name = s.name.to_string();
5765                let trigs = self.active_catalog_mut().triggers_mut();
5766                let mut touched = false;
5767                for t in trigs.iter_mut() {
5768                    if !t.table.eq_ignore_ascii_case(&table_name) {
5769                        continue;
5770                    }
5771                    match &which {
5772                        spg_sql::ast::TriggerSelector::All => {
5773                            t.enabled = enabled;
5774                            touched = true;
5775                        }
5776                        spg_sql::ast::TriggerSelector::Named(name) => {
5777                            if t.name.eq_ignore_ascii_case(name) {
5778                                t.enabled = enabled;
5779                                touched = true;
5780                            }
5781                        }
5782                    }
5783                }
5784                // PG semantics: `ALL` on a table with no
5785                // triggers is a no-op (no error). A `Named`
5786                // form pointing at a non-existent trigger
5787                // raises in PG; v7.16.1 also raises so we
5788                // don't silently lose state.
5789                if !touched {
5790                    if let spg_sql::ast::TriggerSelector::Named(name) = &which {
5791                        return Err(EngineError::Unsupported(alloc::format!(
5792                            "ALTER TABLE {table_name:?} {} TRIGGER {name:?}: no such trigger on table",
5793                            if enabled { "ENABLE" } else { "DISABLE" },
5794                        )));
5795                    }
5796                }
5797            }
5798            spg_sql::ast::AlterTableTarget::SetColumnAutoIncrement { column, seq_name } => {
5799                // pg_dump's identity form names an IMPLICIT sequence
5800                // (`… AS IDENTITY ( SEQUENCE NAME s … )`) that never
5801                // gets its own CREATE SEQUENCE statement, while the
5802                // data section still calls `setval(s, …)`. Make the
5803                // sequence exist (idempotent) so those calls land.
5804                if let Some(seq) = seq_name {
5805                    let _ = self.exec_create_sequence(spg_sql::ast::CreateSequenceStatement {
5806                        name: seq,
5807                        if_not_exists: true,
5808                        temporary: false,
5809                        data_type: None,
5810                        options: spg_sql::ast::SequenceOptions::default(),
5811                    })?;
5812                }
5813                // v7.22 (round-13 T2) — pg_dump's serial/identity
5814                // spellings (`SET DEFAULT nextval(…)` / `ADD
5815                // GENERATED … AS IDENTITY`) lower here: flip the
5816                // column's auto-increment flag so post-import
5817                // INSERTs without an explicit value keep numbering
5818                // (max+1 semantics; the dump's setval() calls are
5819                // no-ops by construction).
5820                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5821                    EngineError::Storage(StorageError::TableNotFound {
5822                        name: s.name.into(),
5823                    })
5824                })?;
5825                let pos = table
5826                    .schema()
5827                    .columns
5828                    .iter()
5829                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5830                    .ok_or_else(|| {
5831                        EngineError::Unsupported(alloc::format!(
5832                            "ALTER COLUMN {column:?}: no such column on {:?}",
5833                            s.name
5834                        ))
5835                    })?;
5836                let col = &table.schema().columns[pos];
5837                if !matches!(
5838                    col.ty,
5839                    spg_storage::DataType::SmallInt
5840                        | spg_storage::DataType::Int
5841                        | spg_storage::DataType::BigInt
5842                ) {
5843                    return Err(EngineError::Unsupported(alloc::format!(
5844                        "auto-increment applies to integer columns only ({column:?} is {:?})",
5845                        col.ty
5846                    )));
5847                }
5848                table.schema_mut().columns[pos].auto_increment = true;
5849            }
5850            spg_sql::ast::AlterTableTarget::RenameTable { new } => {
5851                // v7.16.2 — table-level rename (mailrs round-10
5852                // A.5 — used by migrate-042's `ALTER TABLE
5853                // contacts RENAME TO email_contacts`). Storage
5854                // helper updates the schema + by_name index +
5855                // dangling FK / trigger references in one
5856                // atomic step.
5857                let old = s.name.to_string();
5858                self.active_catalog_mut()
5859                    .rename_table(&old, &new)
5860                    .map_err(EngineError::Storage)?;
5861            }
5862            spg_sql::ast::AlterTableTarget::RenameColumn { old, new } => {
5863                // v7.15.0 — `ALTER TABLE t RENAME [COLUMN] old TO
5864                // new`. Rename the column in the schema; rewrite
5865                // every stored source string on this table that
5866                // references it as a (potentially-qualified)
5867                // column identifier: CHECK predicates, partial-
5868                // index predicates, runtime DEFAULT expressions.
5869                // Then walk catalog triggers on this table and
5870                // patch any `UPDATE OF` column list. Function and
5871                // trigger bodies are NOT auto-rewritten — that
5872                // surface is dynamic SQL territory; users update
5873                // those separately (matches PG plpgsql behavior:
5874                // a column rename invalidates name-referencing
5875                // plpgsql at call time, not rename time).
5876                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5877                    EngineError::Storage(StorageError::TableNotFound {
5878                        name: s.name.into(),
5879                    })
5880                })?;
5881                let col_pos = table
5882                    .schema()
5883                    .columns
5884                    .iter()
5885                    .position(|c| c.name.eq_ignore_ascii_case(&old))
5886                    .ok_or_else(|| {
5887                        EngineError::Unsupported(alloc::format!(
5888                            "ALTER TABLE RENAME COLUMN: column {old:?} not found on {:?}",
5889                            s.name
5890                        ))
5891                    })?;
5892                // Reject same-name (case-insensitive) collision.
5893                if table
5894                    .schema()
5895                    .columns
5896                    .iter()
5897                    .enumerate()
5898                    .any(|(i, c)| i != col_pos && c.name.eq_ignore_ascii_case(&new))
5899                {
5900                    return Err(EngineError::Unsupported(alloc::format!(
5901                        "ALTER TABLE RENAME COLUMN: column {new:?} already exists on {:?}",
5902                        s.name
5903                    )));
5904                }
5905                // Schema rename first — even idempotent same-name
5906                // rename (`ALTER TABLE t RENAME a TO a`) needs to
5907                // be a no-op, not an error.
5908                if old.eq_ignore_ascii_case(&new) {
5909                    return Ok(());
5910                }
5911                table.rename_column(col_pos, &new);
5912                // Rewrite per-column runtime_default sources on
5913                // every column of this table — a DEFAULT expression
5914                // on column X may reference column Y by name (rare,
5915                // but legal in PG when the value is supplied via a
5916                // function that takes the row).
5917                let n_cols = table.schema().columns.len();
5918                for i in 0..n_cols {
5919                    let rt = table.schema().columns[i].runtime_default.clone();
5920                    if let Some(src) = rt {
5921                        let rewritten = rewrite_column_in_source(&src, &old, &new)?;
5922                        table.schema_mut().columns[i].runtime_default = Some(rewritten);
5923                    }
5924                }
5925                // Rewrite table-level CHECK predicates.
5926                let checks = table.schema().checks.clone();
5927                let mut new_checks = Vec::with_capacity(checks.len());
5928                for chk in checks {
5929                    new_checks.push(rewrite_column_in_source(&chk, &old, &new)?);
5930                }
5931                table.schema_mut().checks = new_checks;
5932                // Rewrite per-index partial_predicate sources.
5933                let n_idx = table.indices().len();
5934                for i in 0..n_idx {
5935                    let pred = table.indices()[i].partial_predicate.clone();
5936                    if let Some(src) = pred {
5937                        let rewritten = rewrite_column_in_source(&src, &old, &new)?;
5938                        // SAFETY: indices_mut would be cleanest, but
5939                        // partial_predicate is the only mutable field
5940                        // here; reach in via the public mut accessor.
5941                        table.set_partial_predicate(i, Some(rewritten));
5942                    }
5943                }
5944                // Walk catalog triggers; patch `update_columns` on
5945                // triggers attached to this table.
5946                let table_name = s.name.to_string();
5947                for trig in self.active_catalog_mut().triggers_mut() {
5948                    if !trig.table.eq_ignore_ascii_case(&table_name) {
5949                        continue;
5950                    }
5951                    for c in &mut trig.update_columns {
5952                        if c.eq_ignore_ascii_case(&old) {
5953                            *c = new.clone();
5954                        }
5955                    }
5956                }
5957            }
5958        }
5959        Ok(())
5960    }
5961
5962    fn exec_alter_index(
5963        &mut self,
5964        stmt: spg_sql::ast::AlterIndexStatement,
5965    ) -> Result<QueryResult, EngineError> {
5966        // Translate the optional SQL-side encoding choice into the
5967        // storage-side enum; the same SqlVecEncoding -> VecEncoding
5968        // bridge `column_type_to_data_type` uses.
5969        let spg_sql::ast::AlterIndexStatement {
5970            name: idx_name,
5971            target,
5972        } = stmt;
5973        // v7.16.2 — RENAME TO branch (mailrs round-10 migrate-042).
5974        // IF EXISTS makes a missing index a no-op rather than an
5975        // error, mirroring PG semantics.
5976        if let spg_sql::ast::AlterIndexTarget::Rename { new, if_exists } = target {
5977            let renamed = self.active_catalog_mut().rename_index(&idx_name, &new);
5978            return match renamed {
5979                Ok(()) => Ok(QueryResult::CommandOk {
5980                    affected: 0,
5981                    modified_catalog: !self.in_transaction(),
5982                }),
5983                Err(StorageError::IndexNotFound { .. }) if if_exists => {
5984                    Ok(QueryResult::CommandOk {
5985                        affected: 0,
5986                        modified_catalog: false,
5987                    })
5988                }
5989                Err(e) => Err(EngineError::Storage(e)),
5990            };
5991        }
5992        let spg_sql::ast::AlterIndexTarget::Rebuild { encoding } = target else {
5993            unreachable!("Rename branch returned above");
5994        };
5995        let target = encoding.map(|e| match e {
5996            SqlVecEncoding::F32 => VecEncoding::F32,
5997            SqlVecEncoding::Sq8 => VecEncoding::Sq8,
5998            SqlVecEncoding::F16 => VecEncoding::F16,
5999        });
6000        // Linear scan: index names are globally unique within a
6001        // catalog (enforced by add_nsw_index_inner) so the first
6002        // match is the only one. Save the table name to avoid
6003        // borrowing while we then take a mut borrow.
6004        let table_name = {
6005            let cat = self.active_catalog();
6006            let mut found: Option<String> = None;
6007            for tname in cat.table_names() {
6008                if let Some(t) = cat.get(&tname)
6009                    && t.indices().iter().any(|i| i.name == idx_name)
6010                {
6011                    found = Some(tname);
6012                    break;
6013                }
6014            }
6015            found.ok_or_else(|| {
6016                EngineError::Storage(StorageError::IndexNotFound {
6017                    name: idx_name.clone(),
6018                })
6019            })?
6020        };
6021        let table = self
6022            .active_catalog_mut()
6023            .get_mut(&table_name)
6024            .expect("table found above");
6025        table.rebuild_nsw_index(&idx_name, target)?;
6026        // v6.3.1 — ALTER INDEX REBUILD potentially with new encoding
6027        // changes cost characteristics; evict any cached plans.
6028        self.plan_cache.evict_referencing(&table_name);
6029        Ok(QueryResult::CommandOk {
6030            affected: 0,
6031            modified_catalog: !self.in_transaction(),
6032        })
6033    }
6034
6035    fn exec_create_index(
6036        &mut self,
6037        stmt: CreateIndexStatement,
6038    ) -> Result<QueryResult, EngineError> {
6039        let table = self
6040            .active_catalog_mut()
6041            .get_mut(&stmt.table)
6042            .ok_or_else(|| {
6043                EngineError::Storage(StorageError::TableNotFound {
6044                    name: stmt.table.clone(),
6045                })
6046            })?;
6047        // `IF NOT EXISTS` reduces DuplicateIndex to a no-op CommandOk.
6048        if stmt.if_not_exists && table.indices().iter().any(|i| i.name == stmt.name) {
6049            return Ok(QueryResult::CommandOk {
6050                affected: 0,
6051                modified_catalog: false,
6052            });
6053        }
6054        // v7.9.14 — multi-column index parses through; engine
6055        // builds a single-column BTree on the leading column only.
6056        // The extras live on the AST so spg-server's dispatcher
6057        // can emit a PG-wire NoticeResponse / log line. Composite
6058        // BTree keys land in v7.10.
6059        let _ = &stmt.extra_columns; // intentional drop on engine side
6060        let table_name = stmt.table.clone();
6061        // v6.8.0 — resolve INCLUDE column names to positions. Done
6062        // before `add_index` so a typo error surfaces before any
6063        // catalog mutation lands.
6064        let included_positions: Vec<usize> = if stmt.included_columns.is_empty() {
6065            Vec::new()
6066        } else {
6067            let schema = table.schema();
6068            stmt.included_columns
6069                .iter()
6070                .map(|c| {
6071                    schema.column_position(c).ok_or_else(|| {
6072                        EngineError::Storage(StorageError::ColumnNotFound { column: c.clone() })
6073                    })
6074                })
6075                .collect::<Result<Vec<_>, _>>()?
6076        };
6077        match stmt.method {
6078            IndexMethod::BTree => table.add_index(stmt.name.clone(), &stmt.column)?,
6079            IndexMethod::Hnsw => {
6080                if !included_positions.is_empty() {
6081                    return Err(EngineError::Unsupported(
6082                        "INCLUDE columns are not supported on HNSW indexes".into(),
6083                    ));
6084                }
6085                table.add_nsw_index(stmt.name.clone(), &stmt.column, spg_storage::NSW_DEFAULT_M)?;
6086            }
6087            // v6.7.1 — BRIN. Pure metadata; no in-memory data.
6088            IndexMethod::Brin => {
6089                if !included_positions.is_empty() {
6090                    return Err(EngineError::Unsupported(
6091                        "INCLUDE columns are not supported on BRIN indexes".into(),
6092                    ));
6093                }
6094                table.add_brin_index(stmt.name.clone(), &stmt.column)?;
6095            }
6096            // v7.12.3 — GIN inverted index. Real posting-list-backed
6097            // GIN when the indexed column is `tsvector`; falls back
6098            // to a BTree on the leading column for any other column
6099            // type so v7.9.26b's `pg_dump` compatibility (GIN on
6100            // JSONB etc. silently loading as BTree) is preserved.
6101            // Operators see the real GIN only where it matters; old
6102            // schemas keep loading.
6103            IndexMethod::Gin => {
6104                if !included_positions.is_empty() {
6105                    return Err(EngineError::Unsupported(
6106                        "INCLUDE columns are not supported on GIN indexes".into(),
6107                    ));
6108                }
6109                let col_pos = table
6110                    .schema()
6111                    .column_position(&stmt.column)
6112                    .ok_or_else(|| {
6113                        EngineError::Storage(StorageError::ColumnNotFound {
6114                            column: stmt.column.clone(),
6115                        })
6116                    })?;
6117                let col_ty = table.schema().columns[col_pos].ty;
6118                // v7.15.0 — `gin_trgm_ops` on a TEXT/VARCHAR
6119                // column dispatches to the real trigram-shingle
6120                // GIN build (LIKE / similarity acceleration).
6121                // Other GIN opclasses fall through to the regular
6122                // tsvector-vs-BTree split below.
6123                let is_trgm = stmt
6124                    .opclass
6125                    .as_deref()
6126                    .is_some_and(|op| op.eq_ignore_ascii_case("gin_trgm_ops"));
6127                if is_trgm
6128                    && matches!(
6129                        col_ty,
6130                        spg_storage::DataType::Text | spg_storage::DataType::Varchar(_)
6131                    )
6132                {
6133                    table
6134                        .add_gin_trgm_index(stmt.name.clone(), &stmt.column)
6135                        .map_err(EngineError::Storage)?;
6136                } else if col_ty == spg_storage::DataType::TsVector {
6137                    table
6138                        .add_gin_index(stmt.name.clone(), &stmt.column)
6139                        .map_err(EngineError::Storage)?;
6140                } else {
6141                    // v7.9.26b BTree fallback — the catalog still
6142                    // gets an index entry on the leading column so
6143                    // pg_dump scripts that name GIN on JSONB / etc.
6144                    // load clean; query-time gain stays opt-in for
6145                    // tsvector callers.
6146                    table.add_index(stmt.name.clone(), &stmt.column)?;
6147                }
6148            }
6149        }
6150        if !included_positions.is_empty()
6151            && let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name)
6152        {
6153            idx.included_columns = included_positions;
6154        }
6155        // v6.8.1 — persist partial-index predicate. Stored as the
6156        // expression's Display form so the catalog snapshot stays
6157        // pure (storage has no spg-sql dependency). The runtime
6158        // maintenance path treats partial indexes identically to
6159        // full indexes for v6.8.1 (over-maintenance is safe; the
6160        // planner-side "use partial when query WHERE implies the
6161        // predicate" pass is STABILITY carve-out).
6162        if let Some(pred_expr) = &stmt.partial_predicate {
6163            let canonical = pred_expr.to_string();
6164            // v7.13.2 — mailrs round-6 S2. PG's `pg_trgm` uses
6165            // `CREATE INDEX … USING gin(col gin_trgm_ops) WHERE …`
6166            // routinely to slim trigram indexes. SPG now persists
6167            // the predicate for GIN / BRIN / HNSW the same way it
6168            // already does for BTree — same v6.8.1 "over-maintain
6169            // is safe; planner-side partial routing is STABILITY
6170            // carve-out" semantics. HNSW carries an additional
6171            // caveat: the predicate isn't applied at index build
6172            // time (would require per-row eval inside the NSW
6173            // construction loop), so the index oversamples; query
6174            // time the WHERE clause still filters correctly.
6175            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6176                idx.partial_predicate = Some(canonical);
6177            }
6178        }
6179        // v6.8.2 — persist expression index key. Same Display-form
6180        // storage; the runtime maintenance pass evaluates each
6181        // row's expression to derive the index key, but for v6.8.2
6182        // the engine falls through to the bare-column-reference
6183        // path and the expression is preserved for format-layer
6184        // round-trip + future planner work. Carved-out in
6185        // STABILITY § "Out of v6.8".
6186        if let Some(key_expr) = &stmt.expression {
6187            if matches!(
6188                stmt.method,
6189                IndexMethod::Hnsw | IndexMethod::Brin | IndexMethod::Gin
6190            ) {
6191                return Err(EngineError::Unsupported(
6192                    "Expression keys are not supported on HNSW or BRIN indexes".into(),
6193                ));
6194            }
6195            let canonical = key_expr.to_string();
6196            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6197                idx.expression = Some(canonical);
6198            }
6199        }
6200        // v7.9.29 — persist `is_unique` flag on the storage Index.
6201        // Combined with `partial_predicate`, INSERT enforcement
6202        // checks that no other row whose predicate evaluates true
6203        // shares the same indexed key. Parser already rejected
6204        // `UNIQUE` on HNSW / BRIN, so plain BTree here.
6205        // For multi-column UNIQUE INDEX the extras matter (the
6206        // full tuple is the uniqueness key), so resolve them to
6207        // column positions and persist on the index too.
6208        if stmt.is_unique {
6209            let mut extra_positions: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
6210            for col_name in &stmt.extra_columns {
6211                let pos = table
6212                    .schema()
6213                    .columns
6214                    .iter()
6215                    .position(|c| c.name.eq_ignore_ascii_case(col_name))
6216                    .ok_or_else(|| {
6217                        EngineError::Unsupported(alloc::format!(
6218                            "UNIQUE INDEX {:?}: extra column {col_name:?} not in table {:?}",
6219                            stmt.name,
6220                            stmt.table
6221                        ))
6222                    })?;
6223                extra_positions.push(pos);
6224            }
6225            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6226                idx.is_unique = true;
6227                idx.extra_column_positions = extra_positions;
6228            }
6229            // At index-creation time, check the existing rows for
6230            // pre-existing duplicates that would have violated the
6231            // new constraint — otherwise CREATE UNIQUE INDEX would
6232            // silently leave duplicates in place.
6233            let snapshot_indices = table.indices().to_vec();
6234            let snapshot_rows: alloc::vec::Vec<spg_storage::Row> =
6235                table.rows().iter().cloned().collect();
6236            let snapshot_schema = table.schema().clone();
6237            let idx_ref = snapshot_indices
6238                .iter()
6239                .find(|i| i.name == stmt.name)
6240                .expect("just-added index");
6241            check_existing_unique_violation(idx_ref, &snapshot_schema, &snapshot_rows)?;
6242        }
6243        // v6.3.1 — adding an index can change the optimal plan for
6244        // any cached query that references this table.
6245        self.plan_cache.evict_referencing(&table_name);
6246        Ok(QueryResult::CommandOk {
6247            affected: 0,
6248            modified_catalog: !self.in_transaction(),
6249        })
6250    }
6251
6252    /// v7.13.3 — mailrs round-7 S9. SPG-specific reconciliation
6253    /// for `CREATE TABLE IF NOT EXISTS` when the table already
6254    /// exists. Adds missing columns + inline FKs from the new
6255    /// definition; existing columns / constraints stay untouched.
6256    /// New columns with a `NOT NULL` declaration without a
6257    /// `DEFAULT` are reported as a clear error rather than
6258    /// silently dropped — this is the "fail loud on real
6259    /// incompatibility, fail silent on schema-superset" tradeoff.
6260    fn reconcile_table_if_not_exists(
6261        &mut self,
6262        stmt: CreateTableStatement,
6263    ) -> Result<QueryResult, EngineError> {
6264        let table_name = stmt.name.clone();
6265        let clock = self.clock;
6266        let existing_col_names: alloc::collections::BTreeSet<String> = self
6267            .active_catalog()
6268            .get(&table_name)
6269            .expect("checked above")
6270            .schema()
6271            .columns
6272            .iter()
6273            .map(|c| c.name.to_ascii_lowercase())
6274            .collect();
6275        let row_count = self
6276            .active_catalog()
6277            .get(&table_name)
6278            .expect("checked above")
6279            .row_count();
6280        // Collect missing column defs in source order.
6281        let new_columns: alloc::vec::Vec<spg_sql::ast::ColumnDef> = stmt
6282            .columns
6283            .iter()
6284            .filter(|c| !existing_col_names.contains(&c.name.to_ascii_lowercase()))
6285            .cloned()
6286            .collect();
6287        for col_def in new_columns {
6288            let col_name = col_def.name.clone();
6289            let nullable = col_def.nullable;
6290            let has_default = col_def.default.is_some() || col_def.auto_increment;
6291            let col_schema = column_def_to_schema(col_def)?;
6292            let fill_value: Value = if has_default || col_schema.runtime_default.is_some() {
6293                resolve_column_default_free(&col_schema, clock)?
6294            } else if nullable || row_count == 0 {
6295                Value::Null
6296            } else {
6297                return Err(EngineError::Unsupported(alloc::format!(
6298                    "CREATE TABLE IF NOT EXISTS {table_name:?}: reconciling \
6299                     column {col_name:?} requires DEFAULT (existing rows would violate NOT NULL)"
6300                )));
6301            };
6302            let table = self
6303                .active_catalog_mut()
6304                .get_mut(&table_name)
6305                .expect("checked above");
6306            table.add_column(col_schema, fill_value);
6307        }
6308        // Resolve any newly-added inline FKs (column-level
6309        // REFERENCES forms) and install. Skip FKs whose local
6310        // columns we didn't have in the existing table.
6311        let table_cols_now = self
6312            .active_catalog()
6313            .get(&table_name)
6314            .expect("checked above")
6315            .schema()
6316            .columns
6317            .clone();
6318        for fk in stmt.foreign_keys {
6319            // Only install FKs whose every local column resolves
6320            // — older catalogs may have a column the new FK
6321            // references but not the column the new FK declares.
6322            let all_resolved = fk.columns.iter().all(|c| {
6323                table_cols_now
6324                    .iter()
6325                    .any(|sc| sc.name.eq_ignore_ascii_case(c))
6326            });
6327            if !all_resolved {
6328                continue;
6329            }
6330            let already_present = {
6331                let table = self
6332                    .active_catalog()
6333                    .get(&table_name)
6334                    .expect("checked above");
6335                table.schema().foreign_keys.iter().any(|f| {
6336                    f.parent_table.eq_ignore_ascii_case(&fk.parent_table)
6337                        && f.local_columns.len() == fk.columns.len()
6338                })
6339            };
6340            if already_present {
6341                continue;
6342            }
6343            let storage_fk =
6344                resolve_foreign_key(&table_name, &table_cols_now, fk, self.active_catalog())?;
6345            let table = self
6346                .active_catalog_mut()
6347                .get_mut(&table_name)
6348                .expect("checked above");
6349            table.schema_mut().foreign_keys.push(storage_fk);
6350        }
6351        Ok(QueryResult::CommandOk {
6352            affected: 0,
6353            modified_catalog: !self.in_transaction(),
6354        })
6355    }
6356
6357    /// v7.14.0 — DROP TABLE handler (pg_dump / mysqldump preamble).
6358    fn exec_drop_table(
6359        &mut self,
6360        names: Vec<String>,
6361        if_exists: bool,
6362    ) -> Result<QueryResult, EngineError> {
6363        for name in names {
6364            let dropped = self.active_catalog_mut().drop_table(&name);
6365            if !dropped && !if_exists {
6366                return Err(EngineError::Storage(StorageError::TableNotFound { name }));
6367            }
6368        }
6369        Ok(QueryResult::CommandOk {
6370            affected: 0,
6371            modified_catalog: !self.in_transaction(),
6372        })
6373    }
6374
6375    /// v7.14.0 — DROP INDEX handler.
6376    fn exec_drop_index(
6377        &mut self,
6378        name: String,
6379        if_exists: bool,
6380    ) -> Result<QueryResult, EngineError> {
6381        let dropped = self.active_catalog_mut().drop_named_index(&name);
6382        if !dropped && !if_exists {
6383            return Err(EngineError::Storage(StorageError::IndexNotFound { name }));
6384        }
6385        Ok(QueryResult::CommandOk {
6386            affected: 0,
6387            modified_catalog: !self.in_transaction(),
6388        })
6389    }
6390
6391    fn exec_create_table(
6392        &mut self,
6393        stmt: CreateTableStatement,
6394    ) -> Result<QueryResult, EngineError> {
6395        if stmt.if_not_exists && self.active_catalog().get(&stmt.name).is_some() {
6396            // v7.16.2 — PG-strict silent no-op (mailrs round-10
6397            // surfaced this). v7.13.3's "reconcile by adding
6398            // missing columns" was friendly for mailrs round-7
6399            // where init-schema's `contacts` and migrate-023's
6400            // CardDAV `contacts` collided; but it ALSO silently
6401            // added columns to existing tables when later
6402            // migrations had a duplicate `CREATE TABLE IF NOT
6403            // EXISTS <t> (different-shape-cols)` shape. mailrs's
6404            // migrate-030 has exactly that — re-declares
6405            // system_config with `key` even though init-schema
6406            // already created it with `config_key`. PG's silent
6407            // no-op leaves system_config at `config_key`;
6408            // v7.13.3 added a phantom `key` column that then
6409            // tripped migrate-040's idempotent rename guard.
6410            // mailrs v1.7.106 ships the proper PG-style
6411            // contacts rename via DO + IF EXISTS, so SPG can
6412            // revert to PG-strict here without re-breaking the
6413            // round-7 case.
6414            return Ok(QueryResult::CommandOk {
6415                affected: 0,
6416                modified_catalog: false,
6417            });
6418        }
6419        let table_name = stmt.name.clone();
6420        // v7.9.13 — pluck the names of any columns marked
6421        // `PRIMARY KEY` inline so the post-create-table pass can
6422        // build an implicit BTree index. mailrs F1.
6423        let inline_pk_columns: Vec<String> = stmt
6424            .columns
6425            .iter()
6426            .filter(|c| c.is_primary_key)
6427            .map(|c| c.name.clone())
6428            .collect();
6429        // v7.9.19 — table-level constraints: PRIMARY KEY (a, b, ...)
6430        // and UNIQUE (a, b, ...). Each builds a BTree index on the
6431        // leading column (the existing single-column storage tier)
6432        // and registers a UniquenessConstraint on the schema for
6433        // INSERT-time enforcement of the full tuple. mailrs G1/G6.
6434        let cols = stmt
6435            .columns
6436            .into_iter()
6437            .map(column_def_to_schema)
6438            .collect::<Result<Vec<_>, _>>()?;
6439        // v7.17.0 Phase 1.4 + 1.5 — classify every raw
6440        // user_type_ref (parked as user_enum_type by
6441        // column_def_to_schema) into either an enum binding or a
6442        // domain binding. For domains, also rewrite the column's
6443        // base DataType from the placeholder Text to the domain's
6444        // declared base. Unknown idents are still a hard error
6445        // here (same as Phase 1.4) so silent acceptance never
6446        // happens.
6447        let mut cols = cols;
6448        for col in cols.iter_mut() {
6449            let Some(name) = col.user_enum_type.take() else {
6450                continue;
6451            };
6452            let cat = self.active_catalog();
6453            if cat.enum_types().contains_key(&name) {
6454                col.user_enum_type = Some(name);
6455                continue;
6456            }
6457            if let Some(dom) = cat.domain_types().get(&name) {
6458                col.ty = dom.base_type;
6459                col.user_domain_type = Some(name);
6460                if !dom.nullable {
6461                    col.nullable = false;
6462                }
6463                continue;
6464            }
6465            return Err(EngineError::Unsupported(alloc::format!(
6466                "column {:?}: unknown column type {:?} (not a built-in, ENUM, or DOMAIN)",
6467                col.name,
6468                name
6469            )));
6470        }
6471        for tc in &stmt.table_constraints {
6472            if let spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } = tc {
6473                for col_name in columns {
6474                    if let Some(col) = cols.iter_mut().find(|c| c.name == *col_name) {
6475                        col.nullable = false;
6476                    }
6477                }
6478            }
6479        }
6480        // v7.6.1 — resolve every FK in the statement against the
6481        // already-known catalog. Validates: parent table exists,
6482        // parent column names exist, arity matches, parent columns
6483        // have a PK / UNIQUE index. Self-referencing FKs (parent
6484        // table == this table) resolve against the column list we
6485        // just built — they don't need the catalog yet.
6486        let mut fks: Vec<spg_storage::ForeignKeyConstraint> =
6487            Vec::with_capacity(stmt.foreign_keys.len());
6488        for fk in stmt.foreign_keys {
6489            // v7.14.0 — when SET FOREIGN_KEY_CHECKS=0 is in effect
6490            // (mysqldump preamble + bulk imports), defer FK
6491            // resolution if the parent table isn't in the catalog
6492            // yet. The FK is queued and resolved when checks flip
6493            // back on. Self-references stay in-band (the parent is
6494            // the same as the child we're building).
6495            let needs_parent = !fk.parent_table.eq_ignore_ascii_case(&table_name);
6496            if !self.foreign_key_checks
6497                && needs_parent
6498                && self.active_catalog().get(&fk.parent_table).is_none()
6499            {
6500                self.pending_foreign_keys.push((table_name.clone(), fk));
6501                continue;
6502            }
6503            fks.push(resolve_foreign_key(
6504                &table_name,
6505                &cols,
6506                fk,
6507                self.active_catalog(),
6508            )?);
6509        }
6510        let mut schema = TableSchema::new(table_name.clone(), cols);
6511        schema.foreign_keys = fks;
6512        // v7.9.19 — translate AST table_constraints to storage
6513        // UniquenessConstraints (column name → position) so the
6514        // INSERT enforcement helper sees positions directly.
6515        let mut uc_storage: Vec<spg_storage::UniquenessConstraint> = Vec::new();
6516        let mut check_exprs: Vec<String> = Vec::new();
6517        for tc in &stmt.table_constraints {
6518            let (is_pk, names, nnd) = match tc {
6519                spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
6520                    (true, columns.clone(), false)
6521                }
6522                spg_sql::ast::TableConstraint::Unique {
6523                    columns,
6524                    nulls_not_distinct,
6525                    ..
6526                } => (false, columns.clone(), *nulls_not_distinct),
6527                spg_sql::ast::TableConstraint::Check { expr, .. } => {
6528                    // v7.13.0 — collect CHECK predicate sources;
6529                    // they get attached to the schema below.
6530                    check_exprs.push(alloc::format!("{expr}"));
6531                    continue;
6532                }
6533                // v7.15.0 — plain `KEY (cols)` from MySQL inline
6534                // is NOT a uniqueness constraint; skip the UC
6535                // build path entirely. The BTree index lands in
6536                // the post-create loop below alongside the PK/UQ
6537                // implicit indexes.
6538                spg_sql::ast::TableConstraint::Index { .. } => continue,
6539                // v7.17.0 Phase 2.2 — MySQL FULLTEXT KEY is not
6540                // a uniqueness constraint either; its GIN gets
6541                // built in the post-create loop below.
6542                spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
6543            };
6544            let mut positions = Vec::with_capacity(names.len());
6545            for n in &names {
6546                let pos = schema
6547                    .columns
6548                    .iter()
6549                    .position(|c| c.name == *n)
6550                    .ok_or_else(|| {
6551                        EngineError::Unsupported(alloc::format!(
6552                            "table constraint references unknown column {n:?}"
6553                        ))
6554                    })?;
6555                positions.push(pos);
6556            }
6557            uc_storage.push(spg_storage::UniquenessConstraint {
6558                is_primary_key: is_pk,
6559                columns: positions,
6560                nulls_not_distinct: nnd,
6561            });
6562        }
6563        // v7.24 (round-16 collateral) — inline `PRIMARY KEY` column
6564        // constraints used to build only the implicit BTree index;
6565        // uniqueness was NEVER registered, so duplicate keys were
6566        // silently accepted (table-level PRIMARY KEY did enforce).
6567        // Register the same UniquenessConstraint the table-level
6568        // form gets, unless one already covers the column set.
6569        if !inline_pk_columns.is_empty() {
6570            let mut positions = Vec::with_capacity(inline_pk_columns.len());
6571            for n in &inline_pk_columns {
6572                if let Some(pos) = schema.columns.iter().position(|c| c.name == *n) {
6573                    positions.push(pos);
6574                }
6575            }
6576            if !uc_storage
6577                .iter()
6578                .any(|uc| uc.is_primary_key || uc.columns == positions)
6579            {
6580                uc_storage.push(spg_storage::UniquenessConstraint {
6581                    is_primary_key: true,
6582                    columns: positions,
6583                    nulls_not_distinct: false,
6584                });
6585            }
6586        }
6587        schema.uniqueness_constraints = uc_storage.clone();
6588        schema.checks = check_exprs;
6589        self.active_catalog_mut().create_table(schema)?;
6590        // v7.9.13 — implicit BTree per inline PK column +
6591        // v7.9.19 — implicit BTree on the leading column of every
6592        // table-level PRIMARY KEY / UNIQUE constraint.
6593        let table = self
6594            .active_catalog_mut()
6595            .get_mut(&table_name)
6596            .expect("just created");
6597        for (i, col_name) in inline_pk_columns.iter().enumerate() {
6598            let idx_name = if inline_pk_columns.len() == 1 {
6599                alloc::format!("{table_name}_pkey")
6600            } else {
6601                alloc::format!("{table_name}_pkey_{i}")
6602            };
6603            if let Err(e) = table.add_index(idx_name, col_name) {
6604                return Err(EngineError::Storage(e));
6605            }
6606        }
6607        for (i, tc) in stmt.table_constraints.iter().enumerate() {
6608            // v7.17.0 Phase 2.2 — FULLTEXT KEY lands a real
6609            // tsvector-GIN per declared column instead of the
6610            // BTree the PK / UQ / KEY paths build. Branch early
6611            // so the BTree loop never sees the FULLTEXT shape.
6612            if let spg_sql::ast::TableConstraint::FulltextIndex { name, columns } = tc {
6613                for (k, col) in columns.iter().enumerate() {
6614                    let already = table.indices().iter().any(|idx| {
6615                        matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
6616                            && table.schema().columns[idx.column_position].name == *col
6617                    });
6618                    if already {
6619                        continue;
6620                    }
6621                    let idx_name = match (name.as_ref(), columns.len(), k) {
6622                        (Some(n), 1, _) => n.clone(),
6623                        (Some(n), _, k) => alloc::format!("{n}_{k}"),
6624                        (None, _, _) => {
6625                            alloc::format!("{table_name}_{col}_ftidx")
6626                        }
6627                    };
6628                    if let Err(e) = table.add_gin_fulltext_index(idx_name, col) {
6629                        return Err(EngineError::Storage(e));
6630                    }
6631                }
6632                continue;
6633            }
6634            // v7.15.0 — plain KEY/INDEX rides this same loop so
6635            // the implicit BTree gets built. It carries its own
6636            // user-supplied name; PK/UQ still synthesise.
6637            let (suffix, names, explicit_name): (&str, &Vec<String>, Option<&String>) = match tc {
6638                spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
6639                    ("pkey", columns, None)
6640                }
6641                spg_sql::ast::TableConstraint::Unique { columns, .. } => ("key", columns, None),
6642                spg_sql::ast::TableConstraint::Index { name, columns } => {
6643                    ("idx", columns, name.as_ref())
6644                }
6645                spg_sql::ast::TableConstraint::Check { .. } => continue,
6646                // Handled by the early-branch above.
6647                spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
6648            };
6649            let leading = &names[0];
6650            // Skip if a same-column BTree already exists (e.g.
6651            // inline PK on the leading column).
6652            let already = table.indices().iter().any(|idx| {
6653                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
6654                    && table.schema().columns[idx.column_position].name == *leading
6655            });
6656            if already {
6657                continue;
6658            }
6659            let idx_name = if let Some(n) = explicit_name {
6660                n.clone()
6661            } else if names.len() == 1 {
6662                alloc::format!("{table_name}_{leading}_{suffix}")
6663            } else {
6664                alloc::format!("{table_name}_{leading}_{suffix}_{i}")
6665            };
6666            if let Err(e) = table.add_index(idx_name, leading) {
6667                return Err(EngineError::Storage(e));
6668            }
6669        }
6670        Ok(QueryResult::CommandOk {
6671            affected: 0,
6672            modified_catalog: !self.in_transaction(),
6673        })
6674    }
6675
6676    fn exec_insert(&mut self, mut stmt: InsertStatement) -> Result<QueryResult, EngineError> {
6677        // v7.17.0 Phase 1.1 — pre-resolve any nextval / currval /
6678        // setval calls against the catalog before the row loop. We
6679        // walk each tuple expression and replace matching
6680        // FunctionCall nodes with their concrete Literal. This
6681        // keeps `literal_expr_to_value` free of `&mut self` and
6682        // lets multi-row INSERT VALUES (… nextval('seq') …)
6683        // mint a separate sequence value per row.
6684        for tuple in &mut stmt.rows {
6685            for cell in tuple.iter_mut() {
6686                self.resolve_sequence_calls_in_expr(cell)?;
6687            }
6688        }
6689        // v7.13.0 — `INSERT INTO t [(cols)] SELECT …` (mailrs
6690        // round-5 G4). Execute the inner SELECT first, then route
6691        // back through the regular VALUES code path with the
6692        // materialised rows.
6693        if let Some(select) = stmt.select_source.clone() {
6694            let select_result = self.exec_select_cancel(&select, CancelToken::none())?;
6695            let rows = match select_result {
6696                QueryResult::Rows { rows, .. } => rows,
6697                other => {
6698                    return Err(EngineError::Unsupported(alloc::format!(
6699                        "INSERT … SELECT: inner statement produced {other:?} instead of a row set"
6700                    )));
6701                }
6702            };
6703            let mut materialised: Vec<Vec<Expr>> = Vec::with_capacity(rows.len());
6704            for row in rows {
6705                let mut tuple: Vec<Expr> = Vec::with_capacity(row.values.len());
6706                for v in row.values {
6707                    tuple.push(value_to_literal_expr_permissive(v)?);
6708                }
6709                materialised.push(tuple);
6710            }
6711            let recurse = InsertStatement {
6712                table: stmt.table,
6713                columns: stmt.columns,
6714                rows: materialised,
6715                select_source: None,
6716                on_conflict: stmt.on_conflict,
6717                returning: stmt.returning,
6718            };
6719            return self.exec_insert(recurse);
6720        }
6721        // v7.9.21 — snapshot the clock fn pointer before the mut
6722        // borrow on the catalog opens; runtime DEFAULT eval needs
6723        // it inside the row hot loop.
6724        let clock = self.clock;
6725        // v7.12.4 — snapshot row-level triggers + their referenced
6726        // functions before the mut borrow on the catalog opens.
6727        // Cloned out so the row hot loop can fire them without
6728        // re-borrowing the catalog (which would conflict with
6729        // table.insert's mutable borrow).
6730        let before_insert_triggers = self.snapshot_row_triggers(&stmt.table, "INSERT", "BEFORE");
6731        let after_insert_triggers = self.snapshot_row_triggers(&stmt.table, "INSERT", "AFTER");
6732        let trigger_session_cfg: Option<alloc::string::String> = self
6733            .session_params
6734            .get("default_text_search_config")
6735            .cloned();
6736        // v7.17.0 Phase 1.4 — snapshot the enum label lookup BEFORE
6737        // opening the mutable borrow on the table below. We need
6738        // catalog-level read access (enum_types lives at the
6739        // catalog level, not the table) and the upcoming mutable
6740        // borrow shadows it.
6741        let pre_borrow_column_meta: Vec<ColumnSchema> = {
6742            let preview_table = self.active_catalog().get(&stmt.table).ok_or_else(|| {
6743                EngineError::Storage(StorageError::TableNotFound {
6744                    name: stmt.table.clone(),
6745                })
6746            })?;
6747            preview_table.schema().columns.clone()
6748        };
6749        let enum_label_lookup: alloc::collections::BTreeMap<usize, Vec<String>> =
6750            pre_borrow_column_meta
6751                .iter()
6752                .enumerate()
6753                .filter_map(|(i, col)| {
6754                    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM
6755                    // variant lists take priority over the PG
6756                    // catalog enum_types lookup (they're
6757                    // column-local and authoritative when set).
6758                    if let Some(inline) = &col.inline_enum_variants {
6759                        return Some((i, inline.clone()));
6760                    }
6761                    col.user_enum_type.as_ref().and_then(|ename| {
6762                        self.active_catalog()
6763                            .enum_types()
6764                            .get(ename)
6765                            .map(|e| (i, e.labels.clone()))
6766                    })
6767                })
6768                .collect();
6769        // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant lists.
6770        // Distinct from enum_label_lookup: SET validates that
6771        // every comma-separated token is in the variant list, and
6772        // canonicalises the cell to definition-order de-duped text.
6773        let set_variant_lookup: alloc::collections::BTreeMap<usize, Vec<String>> =
6774            pre_borrow_column_meta
6775                .iter()
6776                .enumerate()
6777                .filter_map(|(i, col)| col.inline_set_variants.as_ref().map(|vs| (i, vs.clone())))
6778                .collect();
6779        let table = self
6780            .active_catalog_mut()
6781            .get_mut(&stmt.table)
6782            .ok_or_else(|| {
6783                EngineError::Storage(StorageError::TableNotFound {
6784                    name: stmt.table.clone(),
6785                })
6786            })?;
6787        // v3.1.5: clone the columns vector only (not the whole
6788        // TableSchema — saves one String alloc for the table name).
6789        // We need an owned snapshot because we'll call `table.insert`
6790        // (mutable borrow on `table`) inside the row loop while
6791        // reading schema fields.
6792        let column_meta: Vec<ColumnSchema> = table.schema().columns.clone();
6793        let schema_cols_len = column_meta.len();
6794        // Build a permutation `tuple_pos[c] = Some(j)` meaning schema
6795        // column `c` is filled from the `j`-th tuple slot; `None` means
6796        // "fill with NULL". Validated once and reused for every row.
6797        let tuple_pos: Option<Vec<Option<usize>>> = match &stmt.columns {
6798            None => None, // 1-1 mapping, fast path
6799            Some(cols) => {
6800                let mut map = alloc::vec![None; schema_cols_len];
6801                for (j, name) in cols.iter().enumerate() {
6802                    let idx = column_meta
6803                        .iter()
6804                        .position(|c| c.name == *name)
6805                        .ok_or_else(|| {
6806                            EngineError::Eval(EvalError::ColumnNotFound { name: name.clone() })
6807                        })?;
6808                    if map[idx].is_some() {
6809                        return Err(EngineError::Storage(StorageError::ArityMismatch {
6810                            expected: schema_cols_len,
6811                            actual: cols.len(),
6812                        }));
6813                    }
6814                    map[idx] = Some(j);
6815                }
6816                // Omitted columns must either be nullable, carry a
6817                // DEFAULT, or be AUTO_INCREMENT. Catch NOT NULL
6818                // omissions up front so the WAL stays clean.
6819                for (i, col) in column_meta.iter().enumerate() {
6820                    if map[i].is_none()
6821                        && !col.nullable
6822                        && col.default.is_none()
6823                        && col.runtime_default.is_none()
6824                        && !col.auto_increment
6825                    {
6826                        return Err(EngineError::Storage(StorageError::NullInNotNull {
6827                            column: col.name.clone(),
6828                        }));
6829                    }
6830                }
6831                Some(map)
6832            }
6833        };
6834        let expected_tuple_len = stmt.columns.as_ref().map_or(schema_cols_len, Vec::len);
6835        // v7.6.2 — snapshot this table's FK list before the
6836        // mutable-borrow window so we can run parent lookups
6837        // against the immutable catalog after parsing. Empty vec is
6838        // the no-FK fast path; clone cost is O(fks * arity) which
6839        // is < 100 ns for typical schemas.
6840        let fks = table.schema().foreign_keys.clone();
6841        let mut affected = 0usize;
6842        // Stage 1 — parse + AUTO_INC + coerce all rows under the
6843        // single mutable borrow.
6844        let mut all_values: Vec<Vec<Value>> = Vec::with_capacity(stmt.rows.len());
6845        // v7.24 (round-16 collateral) — statement-scoped serial
6846        // cursors. next_auto_value() is a max+1 scan over COMMITTED
6847        // rows; multi-row `INSERT … VALUES (…),(…)` computed it per
6848        // tuple BEFORE any insertion, so every row drew the SAME id
6849        // (then sailed through, compounding with the inline-PK
6850        // enforcement gap). First use per column seeds from the
6851        // table; subsequent rows increment.
6852        let mut auto_cursors: alloc::collections::BTreeMap<usize, i64> =
6853            alloc::collections::BTreeMap::new();
6854        for tuple in stmt.rows {
6855            if tuple.len() != expected_tuple_len {
6856                return Err(EngineError::Storage(StorageError::ArityMismatch {
6857                    expected: expected_tuple_len,
6858                    actual: tuple.len(),
6859                }));
6860            }
6861            // Fast path: no column-list permutation → tuple slot j
6862            // maps to schema column j. We can zip schema with tuple
6863            // and skip the `raw_tuple` staging allocation entirely.
6864            let values: Vec<Value> = if let Some(map) = &tuple_pos {
6865                // Permuted path: still need raw_tuple to index by `map[i]`.
6866                let raw_tuple: Vec<Value> = tuple
6867                    .into_iter()
6868                    .map(literal_expr_to_value)
6869                    .collect::<Result<_, _>>()?;
6870                let mut out = Vec::with_capacity(schema_cols_len);
6871                for (i, col) in column_meta.iter().enumerate() {
6872                    let mut raw = match map[i] {
6873                        Some(j) => raw_tuple[j].clone(),
6874                        None => resolve_column_default_free(col, clock)?,
6875                    };
6876                    if col.auto_increment && raw.is_null() {
6877                        let next = match auto_cursors.get(&i) {
6878                            Some(n) => *n,
6879                            None => table.next_auto_value(i).ok_or_else(|| {
6880                                EngineError::Unsupported(alloc::format!(
6881                                    "AUTO_INCREMENT applies to integer columns only (column `{}`)",
6882                                    col.name
6883                                ))
6884                            })?,
6885                        };
6886                        auto_cursors.insert(i, next + 1);
6887                        raw = Value::BigInt(next);
6888                    }
6889                    let coerced = coerce_value(raw, col.ty, &col.name, i)?;
6890                    enforce_enum_label(&enum_label_lookup, i, &col.name, &coerced)?;
6891                    let coerced =
6892                        canonicalize_set_value(&set_variant_lookup, i, &col.name, coerced)?;
6893                    check_unsigned_range(&coerced, col, i)?;
6894                    out.push(coerced);
6895                }
6896                out
6897            } else {
6898                // 1-1 mapping fast path: single Vec alloc, no raw_tuple.
6899                let mut out = Vec::with_capacity(schema_cols_len);
6900                for (i, (col, expr)) in column_meta.iter().zip(tuple).enumerate() {
6901                    let mut raw = literal_expr_to_value(expr)?;
6902                    if col.auto_increment && raw.is_null() {
6903                        let next = match auto_cursors.get(&i) {
6904                            Some(n) => *n,
6905                            None => table.next_auto_value(i).ok_or_else(|| {
6906                                EngineError::Unsupported(alloc::format!(
6907                                    "AUTO_INCREMENT applies to integer columns only (column `{}`)",
6908                                    col.name
6909                                ))
6910                            })?,
6911                        };
6912                        auto_cursors.insert(i, next + 1);
6913                        raw = Value::BigInt(next);
6914                    }
6915                    let coerced = coerce_value(raw, col.ty, &col.name, i)?;
6916                    enforce_enum_label(&enum_label_lookup, i, &col.name, &coerced)?;
6917                    let coerced =
6918                        canonicalize_set_value(&set_variant_lookup, i, &col.name, coerced)?;
6919                    check_unsigned_range(&coerced, col, i)?;
6920                    out.push(coerced);
6921                }
6922                out
6923            };
6924            all_values.push(values);
6925        }
6926        // Stage 2 — FK enforcement on the immutable catalog.
6927        // Non-lexical lifetimes release the mutable borrow on
6928        // `table` here since stage 1 was the last use. The
6929        // parent-table lookup runs before any row is committed.
6930        let uniqueness = table.schema().uniqueness_constraints.clone();
6931        let _ = table;
6932        if !fks.is_empty() {
6933            enforce_fk_inserts(self.active_catalog(), &stmt.table, &fks, &all_values)?;
6934        }
6935        // v7.13.0 — CHECK constraint enforcement (mailrs round-5 G3).
6936        enforce_check_constraints(self.active_catalog(), &stmt.table, &all_values)?;
6937        // NOTE (mailrs embed round-12): UNIQUE / PRIMARY KEY and
6938        // UNIQUE INDEX enforcement moved BELOW the ON CONFLICT
6939        // resolution pass. Running them first made every
6940        // `ON CONFLICT … DO UPDATE` upsert fail with a uniqueness
6941        // violation before the conflict handler could route the row
6942        // to an UPDATE — PG resolves the conflict action first and
6943        // only errors on rows no arbiter matched.
6944        // v7.9.8 / v7.9.9 — ON CONFLICT handling.
6945        //   - `DO NOTHING` filters `all_values` to non-conflicting
6946        //     rows + drops within-batch duplicates.
6947        //   - `DO UPDATE SET …` ALSO filters, but for each
6948        //     conflicting row it queues an UPDATE on the existing
6949        //     row using the incoming row's values as `EXCLUDED.*`.
6950        let mut pending_updates: Vec<(usize, Vec<Value>)> = Vec::new();
6951        let mut skipped_count = 0usize;
6952        if let Some(clause) = &stmt.on_conflict {
6953            let conflict_cols = resolve_on_conflict_columns(
6954                self.active_catalog(),
6955                &stmt.table,
6956                clause.target_columns.as_slice(),
6957            )?;
6958            let mut kept: Vec<Vec<Value>> = Vec::with_capacity(all_values.len());
6959            let mut seen_keys: Vec<Vec<Value>> = Vec::new();
6960            for values in all_values {
6961                let key_tuple: Vec<&Value> = conflict_cols.iter().map(|&c| &values[c]).collect();
6962                // SQL spec: NULL in any conflict column means "no
6963                // conflict possible" (NULL ≠ NULL for uniqueness).
6964                let has_null_key = key_tuple.iter().any(|v| matches!(v, Value::Null));
6965                let collides_with_table = !has_null_key
6966                    && on_conflict_keys_exist(
6967                        self.active_catalog(),
6968                        &stmt.table,
6969                        &conflict_cols,
6970                        &key_tuple,
6971                    );
6972                let key_tuple_owned: Vec<Value> = key_tuple.iter().map(|v| (*v).clone()).collect();
6973                let collides_with_batch =
6974                    !has_null_key && seen_keys.iter().any(|k| k == &key_tuple_owned);
6975                let collides = collides_with_table || collides_with_batch;
6976                match (&clause.action, collides) {
6977                    (_, false) => {
6978                        seen_keys.push(key_tuple_owned);
6979                        kept.push(values);
6980                    }
6981                    (spg_sql::ast::OnConflictAction::Nothing, true) => {
6982                        skipped_count += 1;
6983                    }
6984                    (
6985                        spg_sql::ast::OnConflictAction::Update {
6986                            assignments,
6987                            where_,
6988                        },
6989                        true,
6990                    ) => {
6991                        if !collides_with_table {
6992                            skipped_count += 1;
6993                            continue;
6994                        }
6995                        let target_pos = lookup_row_position_by_keys(
6996                            self.active_catalog(),
6997                            &stmt.table,
6998                            &conflict_cols,
6999                            &key_tuple,
7000                        )
7001                        .ok_or_else(|| {
7002                            EngineError::Unsupported(
7003                                "ON CONFLICT DO UPDATE: conflict detected but row \
7004                                 position could not be resolved (cold-tier row?)"
7005                                    .into(),
7006                            )
7007                        })?;
7008                        let updated = apply_on_conflict_assignments(
7009                            self.active_catalog(),
7010                            &stmt.table,
7011                            target_pos,
7012                            &values,
7013                            assignments,
7014                            where_.as_ref(),
7015                        )?;
7016                        if let Some(new_row) = updated {
7017                            pending_updates.push((target_pos, new_row));
7018                        } else {
7019                            skipped_count += 1;
7020                        }
7021                    }
7022                }
7023            }
7024            all_values = kept;
7025        }
7026        // v7.9.19 — composite UNIQUE / PRIMARY KEY enforcement.
7027        // v7.9.29 — CREATE UNIQUE INDEX [WHERE pred] enforcement.
7028        // Both run on the post-ON-CONFLICT row set: conflicting rows
7029        // already left `all_values` (DO NOTHING drop / DO UPDATE
7030        // reroute), so what remains must be genuinely unique.
7031        enforce_uniqueness_inserts(self.active_catalog(), &stmt.table, &uniqueness, &all_values)?;
7032        enforce_unique_index_inserts(self.active_catalog(), &stmt.table, &all_values)?;
7033        // Stage 3 — insert all rows under a fresh mutable borrow.
7034        let table = self
7035            .active_catalog_mut()
7036            .get_mut(&stmt.table)
7037            .ok_or_else(|| {
7038                EngineError::Storage(StorageError::TableNotFound {
7039                    name: stmt.table.clone(),
7040                })
7041            })?;
7042        // v7.9.4 — keep RETURNING projection rows separate per
7043        // INSERT and per UPDATE branch so DO UPDATE pushes the new
7044        // post-update state, not the incoming-only values.
7045        let mut returning_rows: Vec<Vec<Value>> = Vec::new();
7046        // v7.12.7 — collect embedded SQL emitted by any trigger
7047        // fire across the row loop; engine drains the queue after
7048        // the table mut borrow drops.
7049        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
7050        'rowloop: for values in all_values {
7051            let mut row = Row::new(values);
7052            // v7.12.4 — BEFORE INSERT row-level triggers. Each
7053            // trigger may rewrite NEW cells (e.g. populate
7054            // `search_vector := to_tsvector(...)`) and may return
7055            // NULL to skip the row entirely.
7056            for fd in &before_insert_triggers {
7057                let (outcome, deferred) = triggers::fire_row_trigger(
7058                    fd,
7059                    Some(row.clone()),
7060                    None,
7061                    &stmt.table,
7062                    &column_meta,
7063                    &[],
7064                    trigger_session_cfg.as_deref(),
7065                    false,
7066                )
7067                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
7068                deferred_embedded.extend(deferred);
7069                match outcome {
7070                    triggers::TriggerOutcome::Row(r) => row = r,
7071                    triggers::TriggerOutcome::Skip => continue 'rowloop,
7072                }
7073            }
7074            if stmt.returning.is_some() {
7075                returning_rows.push(row.values.clone());
7076            }
7077            // v7.12.4 — clone for the AFTER trigger view; insert
7078            // moves the row into the table.
7079            let inserted = row.clone();
7080            table.insert(row)?;
7081            affected += 1;
7082            // v7.12.4 — AFTER INSERT row-level triggers fire post-
7083            // write. Return value is ignored (PG semantics); we
7084            // surface any error from the body up to the caller.
7085            for fd in &after_insert_triggers {
7086                let (_outcome, deferred) = triggers::fire_row_trigger(
7087                    fd,
7088                    Some(inserted.clone()),
7089                    None,
7090                    &stmt.table,
7091                    &column_meta,
7092                    &[],
7093                    trigger_session_cfg.as_deref(),
7094                    true,
7095                )
7096                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
7097                deferred_embedded.extend(deferred);
7098            }
7099        }
7100        // v7.9.9 — apply ON CONFLICT DO UPDATE rewrites collected
7101        // in the conflict-resolution pass. update_row handles
7102        // index maintenance + body re-encoding.
7103        for (pos, new_row) in pending_updates {
7104            if stmt.returning.is_some() {
7105                returning_rows.push(new_row.clone());
7106            }
7107            table.update_row(pos, new_row)?;
7108            affected += 1;
7109        }
7110        let _ = skipped_count;
7111        // v7.12.7 — drop the table mut borrow and drain any
7112        // trigger-emitted embedded SQL queued during this INSERT.
7113        // The borrow has to release first because each deferred
7114        // stmt may UPDATE / INSERT / DELETE the same (or another)
7115        // table — including, in principle, this one.
7116        let _ = table;
7117        self.execute_deferred_trigger_stmts(deferred_embedded, CancelToken::none())?;
7118        // v7.9.4/v7.9.9 — RETURNING streams the rows that ended
7119        // up in the table after this statement (insert or
7120        // post-update on conflict).
7121        if let Some(items) = &stmt.returning {
7122            return self.build_returning_rows(&stmt.table, items, returning_rows);
7123        }
7124        // v6.2.1 — auto-analyze: track per-table modified-row
7125        // counter so the background sweep can decide when to
7126        // re-ANALYZE. Cheap path on the autocommit-wrap hot loop
7127        // — one BTreeMap entry update per INSERT batch.
7128        if !self.in_transaction() && affected > 0 {
7129            self.statistics
7130                .record_modifications(&stmt.table, affected as u64);
7131        }
7132        Ok(QueryResult::CommandOk {
7133            affected,
7134            modified_catalog: !self.in_transaction(),
7135        })
7136    }
7137
7138    /// v4.5: SELECT with cooperative cancellation. The token is
7139    /// honoured between UNION peers and inside the bare-SELECT row
7140    /// loop; HNSW kNN graph walks and the aggregate executor don't
7141    /// honour it yet (deferred — those paths bound their work
7142    /// internally by `LIMIT k` and `GROUP BY` cardinality).
7143    /// v6.10.2 — cold-tier time-travel scan. Resolves the segment
7144    /// by id, decodes each row body against the table's current
7145    /// schema, applies the SELECT's projection + optional WHERE +
7146    /// optional LIMIT, returns a `Rows` result. JOINs / aggregates
7147    /// / ORDER BY are unsupported on this path (STABILITY carve-
7148    /// out); operators wanting them should restore the segment
7149    /// into a regular table first.
7150    fn exec_select_as_of_segment(
7151        &self,
7152        stmt: &SelectStatement,
7153        from: &spg_sql::ast::FromClause,
7154        segment_id: u32,
7155    ) -> Result<QueryResult, EngineError> {
7156        // v6.10.2 scope: no joins, no aggregates, no ORDER BY,
7157        // no GROUP BY / HAVING / UNION / OFFSET / DISTINCT.
7158        if !from.joins.is_empty()
7159            || stmt.group_by.is_some()
7160            || stmt.having.is_some()
7161            || !stmt.unions.is_empty()
7162            || !stmt.order_by.is_empty()
7163            || stmt.offset.is_some()
7164            || stmt.distinct
7165            || aggregate::uses_aggregate(stmt)
7166        {
7167            return Err(EngineError::Unsupported(
7168                "AS OF SEGMENT supports SELECT projection + WHERE + LIMIT only \
7169                 (joins / aggregates / ORDER BY are STABILITY § \"Out of v6.10\")"
7170                    .into(),
7171            ));
7172        }
7173        let table = self
7174            .active_catalog()
7175            .get(&from.primary.name)
7176            .ok_or_else(|| StorageError::TableNotFound {
7177                name: from.primary.name.clone(),
7178            })?;
7179        let schema = table.schema().clone();
7180        let schema_cols = &schema.columns;
7181        let alias = from
7182            .primary
7183            .alias
7184            .as_deref()
7185            .unwrap_or(from.primary.name.as_str());
7186        let ctx = EvalContext::new(schema_cols, Some(alias));
7187        let seg = self
7188            .active_catalog()
7189            .cold_segment(segment_id)
7190            .ok_or_else(|| {
7191                EngineError::Unsupported(alloc::format!(
7192                    "AS OF SEGMENT: cold segment {segment_id} not registered"
7193                ))
7194            })?;
7195        let mut out_rows: Vec<Row> = Vec::new();
7196        let mut limit_remaining: Option<usize> =
7197            stmt.limit_literal().and_then(|n| usize::try_from(n).ok());
7198        for (_key, body) in seg.scan() {
7199            let (row, _consumed) =
7200                spg_storage::decode_row_body_dense(&body, &schema, seg.long_strings())
7201                    .map_err(EngineError::Storage)?;
7202            if let Some(where_expr) = &stmt.where_ {
7203                let cond = self.eval_expr_simple(where_expr, &row, &ctx)?;
7204                if !matches!(cond, Value::Bool(true)) {
7205                    continue;
7206                }
7207            }
7208            // Projection.
7209            let projected = self.project_row_simple(&row, &stmt.items, schema_cols, alias)?;
7210            out_rows.push(projected);
7211            if let Some(rem) = limit_remaining.as_mut() {
7212                if *rem == 0 {
7213                    out_rows.pop();
7214                    break;
7215                }
7216                *rem -= 1;
7217            }
7218        }
7219        // Output column schema: derive from SELECT items.
7220        let columns = self.derive_output_columns(&stmt.items, schema_cols, alias);
7221        Ok(QueryResult::Rows {
7222            columns,
7223            rows: out_rows,
7224        })
7225    }
7226
7227    /// v6.10.2 — simple-path WHERE eval that doesn't go through
7228    /// the correlated-subquery / Memoize machinery. AS OF SEGMENT
7229    /// scan paths predicate against a snapshot frozen segment, no
7230    /// cross-row state.
7231    fn eval_expr_simple(
7232        &self,
7233        expr: &Expr,
7234        row: &Row,
7235        ctx: &EvalContext,
7236    ) -> Result<Value, EngineError> {
7237        let cancel = CancelToken::none();
7238        self.eval_expr_with_correlated(expr, row, ctx, cancel, None)
7239    }
7240
7241    /// v7.9.4 — INSERT / UPDATE / DELETE RETURNING projector.
7242    /// Given the table name, the user-supplied projection items,
7243    /// and the mutated rows (post-insert / post-update values, or
7244    /// pre-delete snapshot), build a `QueryResult::Rows` whose
7245    /// schema describes the projected columns. Mailrs migration
7246    /// blocker #1.
7247    fn build_returning_rows(
7248        &self,
7249        table_name: &str,
7250        items: &[SelectItem],
7251        mutated_rows: Vec<Vec<Value>>,
7252    ) -> Result<QueryResult, EngineError> {
7253        let table = self.active_catalog().get(table_name).ok_or_else(|| {
7254            EngineError::Storage(StorageError::TableNotFound {
7255                name: table_name.into(),
7256            })
7257        })?;
7258        let schema_cols = table.schema().columns.clone();
7259        let columns = self.derive_output_columns(items, &schema_cols, table_name);
7260        let mut out_rows: Vec<Row> = Vec::with_capacity(mutated_rows.len());
7261        for values in mutated_rows {
7262            let row = Row::new(values);
7263            let projected = self.project_row_simple(&row, items, &schema_cols, table_name)?;
7264            out_rows.push(projected);
7265        }
7266        Ok(QueryResult::Rows {
7267            columns,
7268            rows: out_rows,
7269        })
7270    }
7271
7272    /// v6.10.2 — projection for AS OF SEGMENT. Resolves
7273    /// `SelectItem::Wildcard` to all schema columns and
7274    /// `SelectItem::Expr` via the regular eval path.
7275    fn project_row_simple(
7276        &self,
7277        row: &Row,
7278        items: &[SelectItem],
7279        schema_cols: &[ColumnSchema],
7280        alias: &str,
7281    ) -> Result<Row, EngineError> {
7282        let ctx = EvalContext::new(schema_cols, Some(alias));
7283        let cancel = CancelToken::none();
7284        let mut out_vals = Vec::new();
7285        for item in items {
7286            match item {
7287                SelectItem::Wildcard => {
7288                    out_vals.extend(row.values.iter().cloned());
7289                }
7290                SelectItem::Expr { expr, .. } => {
7291                    let v = self.eval_expr_with_correlated(expr, row, &ctx, cancel, None)?;
7292                    out_vals.push(v);
7293                }
7294            }
7295        }
7296        Ok(Row::new(out_vals))
7297    }
7298
7299    /// v6.10.2 — derive the output `ColumnSchema` list for an
7300    /// AS OF SEGMENT projection. Wildcards take the full schema;
7301    /// expressions take the alias if present or a synthetic
7302    /// `?column?` (PG convention) otherwise.
7303    fn derive_output_columns(
7304        &self,
7305        items: &[SelectItem],
7306        schema_cols: &[ColumnSchema],
7307        _alias: &str,
7308    ) -> Vec<ColumnSchema> {
7309        let mut out = Vec::new();
7310        for item in items {
7311            match item {
7312                SelectItem::Wildcard => {
7313                    out.extend(schema_cols.iter().cloned());
7314                }
7315                SelectItem::Expr { expr, alias } => {
7316                    // Bare column references inherit the schema
7317                    // column's name + type — PG names `RETURNING id`
7318                    // "id" and types it BIGINT, and the sqlx embed
7319                    // path type-checks RowDescription against the
7320                    // Rust target (mailrs embed round-12).
7321                    if let Expr::Column(col) = expr
7322                        && let Some(sc) = schema_cols.iter().find(|c| c.name == col.name)
7323                    {
7324                        let name = alias.clone().unwrap_or_else(|| sc.name.clone());
7325                        out.push(ColumnSchema::new(name, sc.ty, sc.nullable));
7326                        continue;
7327                    }
7328                    let name = alias.clone().unwrap_or_else(|| "?column?".to_string());
7329                    // Default to Text; the caller's row values
7330                    // carry the actual type. v6.10.2 scope.
7331                    out.push(ColumnSchema::new(name, DataType::Text, true));
7332                }
7333            }
7334        }
7335        out
7336    }
7337
7338    fn exec_select_cancel(
7339        &self,
7340        stmt: &SelectStatement,
7341        cancel: CancelToken<'_>,
7342    ) -> Result<QueryResult, EngineError> {
7343        cancel.check()?;
7344        // v7.17.0 Phase 1.2 — user-defined VIEW expansion. If the
7345        // FROM / JOIN graph references any catalogued view name,
7346        // re-parse the view body and prepend it as a synthetic
7347        // CTE. Recurses on views-in-views via the regular CTE
7348        // dispatch below. Fast-path: skip the walker entirely when
7349        // the catalog has no views (the typical OLTP load).
7350        if !self.active_catalog().views().is_empty() {
7351            if let Some(rewritten) = self.expand_views_in_select(stmt)? {
7352                return self.exec_select_cancel(&rewritten, cancel);
7353            }
7354        }
7355        // v7.16.2 — information_schema / pg_catalog virtual
7356        // views (mailrs round-10 A.3). If the SELECT touches a
7357        // synthetic meta-table name (`__spg_info_*` /
7358        // `__spg_pg_*` — produced by the parser for
7359        // `information_schema.X` / `pg_catalog.X`), clone the
7360        // catalog, materialise the requested view as a real
7361        // temporary table, and re-execute against an enriched
7362        // engine. Same pattern as `exec_with_ctes` for CTEs.
7363        if !self.meta_views_materialised && select_references_meta_view(stmt) {
7364            return self.exec_select_with_meta_views(stmt, cancel);
7365        }
7366        // v6.10.2 — cold-tier time-travel short-circuit. When the
7367        // primary TableRef carries `AS OF SEGMENT '<id>'`, run a
7368        // dedicated cold-segment scan instead of the regular
7369        // hot+index path. The scope is intentionally narrow for
7370        // v6.10.2 — bare `SELECT * FROM <t> AS OF SEGMENT 'id'`,
7371        // optionally with a single-column-equality WHERE. JOINs /
7372        // aggregates / ORDER BY / subqueries on top of a time-
7373        // travelled scan are STABILITY § "Out of v6.10".
7374        if let Some(from) = &stmt.from
7375            && let Some(seg_id) = from.primary.as_of_segment
7376        {
7377            return self.exec_select_as_of_segment(stmt, from, seg_id);
7378        }
7379        // v6.2.0 / v6.5.0 — virtual-table short-circuits. Detected
7380        // pre-CTE because they don't read from the catalog and
7381        // shouldn't participate in regular FROM resolution.
7382        if let Some(from) = &stmt.from
7383            && from.joins.is_empty()
7384            && stmt.where_.is_none()
7385            && stmt.group_by.is_none()
7386            && stmt.having.is_none()
7387            && stmt.unions.is_empty()
7388            && stmt.order_by.is_empty()
7389            && stmt.limit.is_none()
7390            && stmt.offset.is_none()
7391            && !stmt.distinct
7392            && stmt.items.iter().all(|i| matches!(i, SelectItem::Wildcard))
7393        {
7394            let lower = from.primary.name.to_ascii_lowercase();
7395            match lower.as_str() {
7396                "spg_statistic" => return Ok(self.exec_spg_statistic()),
7397                // v6.5.0 — observability v2 virtual tables.
7398                "spg_stat_replication" => return Ok(self.exec_spg_stat_replication()),
7399                "spg_stat_segment" => return Ok(self.exec_spg_stat_segment()),
7400                "spg_stat_query" => return Ok(self.exec_spg_stat_query()),
7401                "spg_stat_activity" => return Ok(self.exec_spg_stat_activity()),
7402                "spg_audit_chain" => return Ok(self.exec_spg_audit_chain()),
7403                "spg_audit_verify" => return Ok(self.exec_spg_audit_verify()),
7404                "spg_table_ddl" => return Ok(self.exec_spg_table_ddl()),
7405                "spg_role_ddl" => return Ok(self.exec_spg_role_ddl()),
7406                "spg_database_ddl" => return Ok(self.exec_spg_database_ddl()),
7407                _ => {}
7408            }
7409        }
7410        // v4.11: CTEs materialise into a temporary enriched catalog
7411        // *before* anything else — the body SELECT can then refer
7412        // to CTE names via the regular FROM-clause resolution.
7413        // Uncorrelated only: each CTE body runs once against the
7414        // current catalog, not against later CTEs' results (left-
7415        // to-right materialisation would relax this, but we keep
7416        // it simple for v4.11 MVP).
7417        if !stmt.ctes.is_empty() {
7418            return self.exec_with_ctes(stmt, cancel);
7419        }
7420        // v4.10: subqueries (uncorrelated) are resolved here, before
7421        // the executor sees the row loop. We clone the statement so
7422        // we can mutate without disturbing the caller's AST — most
7423        // queries pass through with no subquery nodes and the clone
7424        // is cheap; with subqueries the materialisation cost
7425        // dominates anyway.
7426        let mut stmt_owned;
7427        let stmt_ref: &SelectStatement = if expr_tree_has_subquery(stmt) {
7428            stmt_owned = stmt.clone();
7429            self.resolve_select_subqueries(&mut stmt_owned, cancel)?;
7430            &stmt_owned
7431        } else {
7432            stmt
7433        };
7434        if stmt_ref.unions.is_empty() {
7435            return self.exec_bare_select_cancel(stmt_ref, cancel);
7436        }
7437        // UNION path: clone-strip the head into a bare block (its own
7438        // DISTINCT and any inner ORDER BY are dropped by parser rule —
7439        // the wrapper SelectStatement carries them), execute, then chain
7440        // peers with left-associative dedup semantics.
7441        let mut head = stmt_ref.clone();
7442        head.unions = Vec::new();
7443        head.order_by = Vec::new();
7444        head.limit = None;
7445        let QueryResult::Rows { columns, mut rows } =
7446            self.exec_bare_select_cancel(&head, cancel)?
7447        else {
7448            unreachable!("bare SELECT cannot return CommandOk")
7449        };
7450        for (kind, peer) in &stmt_ref.unions {
7451            let QueryResult::Rows {
7452                columns: peer_cols,
7453                rows: peer_rows,
7454            } = self.exec_bare_select_cancel(peer, cancel)?
7455            else {
7456                unreachable!("bare SELECT cannot return CommandOk")
7457            };
7458            if peer_cols.len() != columns.len() {
7459                return Err(EngineError::Unsupported(alloc::format!(
7460                    "UNION arity mismatch: head has {} columns, peer has {}",
7461                    columns.len(),
7462                    peer_cols.len()
7463                )));
7464            }
7465            rows.extend(peer_rows);
7466            if matches!(kind, UnionKind::Distinct) {
7467                rows = dedup_rows(rows);
7468            }
7469        }
7470        // ORDER BY at the top of a UNION applies to the combined result.
7471        // Eval against the projected schema (NOT the source table).
7472        if !stmt.order_by.is_empty() {
7473            let synth_ctx = EvalContext::new(&columns, None);
7474            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
7475            let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(rows.len());
7476            for r in rows {
7477                let keys = build_order_keys(&stmt.order_by, &r, &synth_ctx)?;
7478                tagged.push((keys, r));
7479            }
7480            sort_by_keys(&mut tagged, &descs);
7481            rows = tagged.into_iter().map(|(_, r)| r).collect();
7482        }
7483        apply_offset_and_limit(&mut rows, stmt.offset_literal(), stmt.limit_literal());
7484        Ok(QueryResult::Rows { columns, rows })
7485    }
7486
7487    #[allow(clippy::too_many_lines)]
7488    #[allow(clippy::too_many_lines)] // huge match — splitting fragments the planner
7489    /// v7.11.7 — execute `SELECT … FROM unnest(expr) [AS] alias …`.
7490    /// Synthesises a single-column virtual table whose column type
7491    /// is TEXT and whose rows are the array elements. Routes
7492    /// through the regular projection / WHERE / ORDER BY / LIMIT
7493    /// machinery so set-returning UNNEST composes naturally with
7494    /// the rest of the SELECT surface.
7495    fn exec_select_unnest(
7496        &self,
7497        stmt: &SelectStatement,
7498        primary: &TableRef,
7499        cancel: CancelToken<'_>,
7500    ) -> Result<QueryResult, EngineError> {
7501        let expr = primary
7502            .unnest_expr
7503            .as_deref()
7504            .expect("caller guards unnest_expr.is_some()");
7505        // Evaluate the array expression once. Empty schema / empty
7506        // row — uncorrelated UNNEST cannot reference outer columns.
7507        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7508        let ctx = EvalContext::new(&empty_schema, None);
7509        let dummy_row = Row::new(alloc::vec::Vec::new());
7510        // v7.11.13 — unnest dispatches per array element type so
7511        // INT[] / BIGINT[] surface their PG types in projection.
7512        let (elem_dtype, rows): (DataType, alloc::vec::Vec<Row>) =
7513            match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
7514                Value::Null => (DataType::Text, alloc::vec::Vec::new()),
7515                Value::TextArray(items) => {
7516                    let rows = items
7517                        .into_iter()
7518                        .map(|item| {
7519                            Row::new(alloc::vec![match item {
7520                                Some(s) => Value::Text(s),
7521                                None => Value::Null,
7522                            }])
7523                        })
7524                        .collect();
7525                    (DataType::Text, rows)
7526                }
7527                Value::IntArray(items) => {
7528                    let rows = items
7529                        .into_iter()
7530                        .map(|item| {
7531                            Row::new(alloc::vec![match item {
7532                                Some(n) => Value::Int(n),
7533                                None => Value::Null,
7534                            }])
7535                        })
7536                        .collect();
7537                    (DataType::Int, rows)
7538                }
7539                Value::BigIntArray(items) => {
7540                    let rows = items
7541                        .into_iter()
7542                        .map(|item| {
7543                            Row::new(alloc::vec![match item {
7544                                Some(n) => Value::BigInt(n),
7545                                None => Value::Null,
7546                            }])
7547                        })
7548                        .collect();
7549                    (DataType::BigInt, rows)
7550                }
7551                other => {
7552                    return Err(EngineError::Unsupported(alloc::format!(
7553                        "unnest() expects an array argument, got {:?}",
7554                        other.data_type()
7555                    )));
7556                }
7557            };
7558        let alias = primary
7559            .alias
7560            .clone()
7561            .unwrap_or_else(|| "unnest".to_string());
7562        // v7.13.2 — mailrs round-6 S5. Honour PG-standard
7563        // `UNNEST(arr) AS p(col_name)` column-list aliasing: the
7564        // first entry overrides the projected column's name.
7565        // Without the column list, fall back to the table alias
7566        // (pre-v7.13.2 behaviour).
7567        let col_name = primary
7568            .unnest_column_aliases
7569            .first()
7570            .cloned()
7571            .unwrap_or_else(|| alias.clone());
7572        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7573        let schema_cols = alloc::vec![col_schema.clone()];
7574        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7575        // Apply WHERE.
7576        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7577            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7578            for row in rows {
7579                cancel.check()?;
7580                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7581                if matches!(v, Value::Bool(true)) {
7582                    out.push(row);
7583                }
7584            }
7585            out
7586        } else {
7587            rows
7588        };
7589        // v7.17.0 Phase 3.P0-48 — aggregate dispatch over the
7590        // unnest source. Same routing the relational scan path
7591        // already takes — without it `SELECT COUNT(*) FROM
7592        // unnest(ARRAY[…])` either errored at projection time or
7593        // returned the wrong shape.
7594        if aggregate::uses_aggregate(stmt) {
7595            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7596            let mut agg = aggregate::run(stmt, &filtered_refs, &schema_cols, Some(&alias))?;
7597            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7598            return Ok(QueryResult::Rows {
7599                columns: agg.columns,
7600                rows: agg.rows,
7601            });
7602        }
7603        // Projection.
7604        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7605        let mut projected_rows: alloc::vec::Vec<Row> =
7606            alloc::vec::Vec::with_capacity(filtered.len());
7607        // v7.19 P5 — Set-Returning-Function in projection
7608        // position (PG `SELECT unnest(arr) FROM t` shape). When a
7609        // SELECT item evaluates to a top-level unnest(arr) call,
7610        // expand it: for each input row, evaluate the array, emit
7611        // one output row per element, broadcasting non-SRF
7612        // projections from the same input row. Multi-SRF + LCM
7613        // padding stays a documented carve-out; mailrs uses
7614        // single-SRF for redirect_uris.
7615        let srf_position = projection.iter().position(|p| is_top_level_unnest(&p.expr));
7616        if let Some(srf_idx) = srf_position {
7617            let srf_arg = top_level_unnest_arg(&projection[srf_idx].expr)
7618                .expect("checked by is_top_level_unnest above");
7619            for row in &filtered {
7620                let arr_val =
7621                    eval::eval_expr(srf_arg, row, &scan_ctx).map_err(EngineError::Eval)?;
7622                let elements = array_value_to_elements(&arr_val)?;
7623                // Empty array → zero rows for this input row (PG
7624                // semantics: `SELECT unnest('{}'::int[])` returns
7625                // 0 rows, not a single NULL row).
7626                for elem in elements {
7627                    let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7628                    for (i, p) in projection.iter().enumerate() {
7629                        if i == srf_idx {
7630                            vals.push(elem.clone());
7631                        } else {
7632                            vals.push(
7633                                eval::eval_expr(&p.expr, row, &scan_ctx)
7634                                    .map_err(EngineError::Eval)?,
7635                            );
7636                        }
7637                    }
7638                    projected_rows.push(Row::new(vals));
7639                }
7640            }
7641        } else {
7642            // v7.24 (round-16 B) — select-list subqueries resolve
7643            // per row (correlated-aware; plain exprs take the fast
7644            // path inside).
7645            let mut proj_memo = memoize::MemoizeCache::default();
7646            for row in &filtered {
7647                let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7648                for p in &projection {
7649                    vals.push(self.eval_expr_with_correlated(
7650                        &p.expr,
7651                        row,
7652                        &scan_ctx,
7653                        cancel,
7654                        Some(&mut proj_memo),
7655                    )?);
7656                }
7657                projected_rows.push(Row::new(vals));
7658            }
7659        }
7660        // ORDER BY / LIMIT — apply on the projected rows (cheap;
7661        // unnest result sets are small by design).
7662        let columns: alloc::vec::Vec<ColumnSchema> = projection
7663            .iter()
7664            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7665            .collect();
7666        // Re-evaluate ORDER BY against the source schema (pre-projection
7667        // so col refs by name still resolve through `scan_ctx`).
7668        if !stmt.order_by.is_empty() {
7669            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7670                .iter()
7671                .enumerate()
7672                .map(|(i, r)| -> Result<_, EngineError> {
7673                    let keys: Result<Vec<Value>, EngineError> = stmt
7674                        .order_by
7675                        .iter()
7676                        .map(|ob| {
7677                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7678                        })
7679                        .collect();
7680                    Ok((i, keys?))
7681                })
7682                .collect::<Result<_, _>>()?;
7683            indexed.sort_by(|a, b| {
7684                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7685                    let o = &stmt.order_by[idx];
7686                    let cmp = order_by_value_cmp(o.desc, o.nulls_first, ka, kb);
7687                    if cmp != core::cmp::Ordering::Equal {
7688                        return cmp;
7689                    }
7690                }
7691                core::cmp::Ordering::Equal
7692            });
7693            projected_rows = indexed
7694                .into_iter()
7695                .map(|(i, _)| projected_rows[i].clone())
7696                .collect();
7697        }
7698        // LIMIT / OFFSET — apply at the tail.
7699        if let Some(offset) = stmt.offset_literal() {
7700            let off = (offset as usize).min(projected_rows.len());
7701            projected_rows.drain(..off);
7702        }
7703        if let Some(limit) = stmt.limit_literal() {
7704            projected_rows.truncate(limit as usize);
7705        }
7706        Ok(QueryResult::Rows {
7707            columns,
7708            rows: projected_rows,
7709        })
7710    }
7711
7712    /// v7.17.0 Phase 3.10 — `FROM generate_series(start, stop [,
7713    /// step])` set-returning source. Mirrors `exec_select_unnest`'s
7714    /// shape: evaluate the arg list once against an empty row,
7715    /// materialise the row stream by stepping start → stop, then
7716    /// route through the standard WHERE / projection / ORDER BY /
7717    /// LIMIT pipeline. Two arg-type combos in v7.17:
7718    ///   * integer / integer [/ integer] — SmallInt, Int, BigInt
7719    ///     (widened to BigInt internally; step defaults to 1)
7720    ///   * timestamp / timestamp / interval — date-range
7721    ///     iteration (mailrs's daily-report pattern)
7722    fn exec_select_generate_series(
7723        &self,
7724        stmt: &SelectStatement,
7725        primary: &TableRef,
7726        cancel: CancelToken<'_>,
7727    ) -> Result<QueryResult, EngineError> {
7728        let args = primary
7729            .generate_series_args
7730            .as_ref()
7731            .expect("caller guards generate_series_args.is_some()");
7732        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7733        let ctx = EvalContext::new(&empty_schema, None);
7734        let dummy_row = Row::new(alloc::vec::Vec::new());
7735        let mut arg_values: alloc::vec::Vec<Value> = alloc::vec::Vec::with_capacity(args.len());
7736        for a in args {
7737            arg_values.push(eval::eval_expr(a, &dummy_row, &ctx).map_err(EngineError::Eval)?);
7738        }
7739        // Dispatch on the start value's shape. Reject mixed-shape
7740        // calls early (e.g. start = timestamp, stop = integer) so
7741        // the caller gets a clean error rather than a panic.
7742        let (elem_dtype, rows) = match arg_values.as_slice() {
7743            [Value::Timestamp(start), Value::Timestamp(stop), step] => {
7744                let interval_step = match step {
7745                    Value::Interval { .. } => step.clone(),
7746                    other => {
7747                        return Err(EngineError::Unsupported(alloc::format!(
7748                            "generate_series(timestamp, timestamp, …): \
7749                             step must be INTERVAL, got {:?}",
7750                            other.data_type()
7751                        )));
7752                    }
7753                };
7754                let rows = generate_series_timestamps(*start, *stop, interval_step, &cancel)?;
7755                (DataType::Timestamp, rows)
7756            }
7757            [start, stop, step]
7758                if value_is_integer(start) && value_is_integer(stop) && value_is_integer(step) =>
7759            {
7760                let s = value_to_i64(start);
7761                let e = value_to_i64(stop);
7762                let st = value_to_i64(step);
7763                let rows = generate_series_integers(s, e, st, &cancel)?;
7764                (DataType::BigInt, rows)
7765            }
7766            [start, stop] if value_is_integer(start) && value_is_integer(stop) => {
7767                let s = value_to_i64(start);
7768                let e = value_to_i64(stop);
7769                let rows = generate_series_integers(s, e, 1, &cancel)?;
7770                (DataType::BigInt, rows)
7771            }
7772            _ => {
7773                return Err(EngineError::Unsupported(alloc::format!(
7774                    "generate_series(): v7.17 supports integer or (timestamp, timestamp, interval) \
7775                     argument shapes; got {:?}",
7776                    arg_values
7777                        .iter()
7778                        .map(|v| v.data_type())
7779                        .collect::<alloc::vec::Vec<_>>()
7780                )));
7781            }
7782        };
7783        let alias = primary
7784            .alias
7785            .clone()
7786            .unwrap_or_else(|| "generate_series".to_string());
7787        let col_name = alias.clone();
7788        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7789        let schema_cols = alloc::vec![col_schema.clone()];
7790        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7791        // WHERE.
7792        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7793            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7794            for row in rows {
7795                cancel.check()?;
7796                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7797                if matches!(v, Value::Bool(true)) {
7798                    out.push(row);
7799                }
7800            }
7801            out
7802        } else {
7803            rows
7804        };
7805        // v7.17.0 Phase 3.P0-48 — aggregate dispatch for set-
7806        // returning sources. When the SELECT projection contains
7807        // aggregate functions (COUNT/SUM/MIN/MAX/AVG/string_agg/
7808        // …) we route the filtered row stream through the same
7809        // aggregate executor the relational scan path uses, so
7810        // `SELECT COUNT(*) FROM generate_series(1, 100)` returns
7811        // a single 100 row instead of erroring at projection
7812        // time. GROUP BY / HAVING / ORDER BY over the aggregate
7813        // output all ride through `aggregate::run`.
7814        if aggregate::uses_aggregate(stmt) {
7815            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7816            let mut agg = aggregate::run(stmt, &filtered_refs, &schema_cols, Some(&alias))?;
7817            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7818            return Ok(QueryResult::Rows {
7819                columns: agg.columns,
7820                rows: agg.rows,
7821            });
7822        }
7823        // Projection.
7824        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7825        let mut projected_rows: alloc::vec::Vec<Row> =
7826            alloc::vec::Vec::with_capacity(filtered.len());
7827        let mut proj_memo = memoize::MemoizeCache::default();
7828        for row in &filtered {
7829            let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7830            for p in &projection {
7831                // v7.24 (round-16 B) — correlated-aware.
7832                vals.push(self.eval_expr_with_correlated(
7833                    &p.expr,
7834                    row,
7835                    &scan_ctx,
7836                    cancel,
7837                    Some(&mut proj_memo),
7838                )?);
7839            }
7840            projected_rows.push(Row::new(vals));
7841        }
7842        let columns: alloc::vec::Vec<ColumnSchema> = projection
7843            .iter()
7844            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7845            .collect();
7846        // ORDER BY against the source schema.
7847        if !stmt.order_by.is_empty() {
7848            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7849                .iter()
7850                .enumerate()
7851                .map(|(i, r)| -> Result<_, EngineError> {
7852                    let keys: Result<Vec<Value>, EngineError> = stmt
7853                        .order_by
7854                        .iter()
7855                        .map(|ob| {
7856                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7857                        })
7858                        .collect();
7859                    Ok((i, keys?))
7860                })
7861                .collect::<Result<_, _>>()?;
7862            indexed.sort_by(|a, b| {
7863                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7864                    let o = &stmt.order_by[idx];
7865                    let cmp = order_by_value_cmp(o.desc, o.nulls_first, ka, kb);
7866                    if cmp != core::cmp::Ordering::Equal {
7867                        return cmp;
7868                    }
7869                }
7870                core::cmp::Ordering::Equal
7871            });
7872            projected_rows = indexed
7873                .into_iter()
7874                .map(|(i, _)| projected_rows[i].clone())
7875                .collect();
7876        }
7877        if let Some(offset) = stmt.offset_literal() {
7878            let off = (offset as usize).min(projected_rows.len());
7879            projected_rows.drain(..off);
7880        }
7881        if let Some(limit) = stmt.limit_literal() {
7882            projected_rows.truncate(limit as usize);
7883        }
7884        Ok(QueryResult::Rows {
7885            columns,
7886            rows: projected_rows,
7887        })
7888    }
7889
7890    fn exec_bare_select_cancel(
7891        &self,
7892        stmt: &SelectStatement,
7893        cancel: CancelToken<'_>,
7894    ) -> Result<QueryResult, EngineError> {
7895        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST N ROWS WITH TIES`
7896        // is meaningless without an ORDER BY; PG raises a hard
7897        // error and SPG mirrors the surface so the same DDL/app
7898        // path behaves identically on cutover.
7899        check_with_ties_requires_order_by(stmt)?;
7900        // v7.16.2 — same meta-view dispatch as
7901        // `exec_select_cancel`, applied here too because
7902        // `subquery_replacement` enters this function directly
7903        // for Exists / ScalarSubquery / InSubquery resolution
7904        // (bypassing the top-level entry to avoid double
7905        // subquery walking). Without this dispatch the subquery
7906        // hits `__spg_info_columns` and reports TableNotFound.
7907        if !self.meta_views_materialised && select_references_meta_view(stmt) {
7908            return self.exec_select_with_meta_views(stmt, cancel);
7909        }
7910        // v4.12: window-function path. When the projection contains
7911        // any `name(args) OVER (...)` we route to the dedicated
7912        // executor — partition + sort + per-row window value before
7913        // the regular projection.
7914        if select_has_window(stmt) {
7915            return self.exec_select_with_window(stmt, cancel);
7916        }
7917        // Constant SELECT (no FROM) — evaluate each item once against an
7918        // empty dummy row. Useful for `SELECT 1`, `SELECT coalesce(...)`,
7919        // `SELECT '7'::INT`. Column references will surface as
7920        // ColumnNotFound on eval since the schema is empty.
7921        let Some(from) = &stmt.from else {
7922            let empty_schema: Vec<ColumnSchema> = Vec::new();
7923            let ctx = self.ev_ctx(&empty_schema, None);
7924            let projection = build_projection(&stmt.items, &empty_schema, "")?;
7925            let dummy_row = Row::new(Vec::new());
7926            let mut values = Vec::with_capacity(projection.len());
7927            for p in &projection {
7928                values.push(eval::eval_expr(&p.expr, &dummy_row, &ctx)?);
7929            }
7930            let columns: Vec<ColumnSchema> = projection
7931                .into_iter()
7932                .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
7933                .collect();
7934            return Ok(QueryResult::Rows {
7935                columns,
7936                rows: alloc::vec![Row::new(values)],
7937            });
7938        };
7939        // Multi-table FROM (one or more joined peers) goes through the
7940        // nested-loop join executor. Single-table FROM stays on the
7941        // existing scan + index-seek path.
7942        if !from.joins.is_empty() {
7943            return self.exec_joined_select(stmt, from, cancel);
7944        }
7945        // v7.11.7 — `FROM unnest(<expr>) [AS] <alias>`. Synthesise a
7946        // single-column table at SELECT entry by evaluating the
7947        // expression once against the empty row (UNNEST is
7948        // uncorrelated in v7.11; correlated / LATERAL unnest is a
7949        // v7.12 carve-out). Build a virtual `Table` in a heap-only
7950        // catalog, then route to the regular scan path.
7951        if from.primary.unnest_expr.is_some() {
7952            return self.exec_select_unnest(stmt, &from.primary, cancel);
7953        }
7954        // v7.17.0 Phase 3.10 — `FROM generate_series(start, stop
7955        // [, step])` set-returning source. Dispatch mirrors UNNEST:
7956        // materialise the row stream from a single eval pass, then
7957        // run the regular projection / WHERE / ORDER BY / LIMIT
7958        // pipeline over the synthetic single-column table.
7959        if from.primary.generate_series_args.is_some() {
7960            return self.exec_select_generate_series(stmt, &from.primary, cancel);
7961        }
7962        let primary = &from.primary;
7963        let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
7964            StorageError::TableNotFound {
7965                name: primary.name.clone(),
7966            }
7967        })?;
7968        let schema_cols = &table.schema().columns;
7969        // The qualifier accepted on column refs is the alias (if any) else the
7970        // bare table name.
7971        let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
7972        let ctx = self.ev_ctx(schema_cols, Some(alias));
7973
7974        // NSW kNN planner: `ORDER BY col <-> literal LIMIT k` with no
7975        // WHERE and an NSW index on `col` skips the full scan. The
7976        // walk returns rows already in ascending-distance order, so
7977        // ORDER BY / LIMIT are honoured implicitly.
7978        if let Some(nsw_rows) = try_nsw_knn(stmt, table, schema_cols, alias) {
7979            return materialise_in_order(stmt, table, schema_cols, alias, &nsw_rows);
7980        }
7981
7982        // Index seek: if WHERE is `col = literal` (or commuted) and the
7983        // referenced column has an index, dispatch each locator through
7984        // the catalog (hot tier → borrow, cold tier → page-read +
7985        // decode) and iterate just those rows. Otherwise fall back to a
7986        // full scan over the hot tier (cold-tier rows are only reached
7987        // via index seek in v5.1 — full table scans against cold-tier
7988        // data ship in v5.2 with the freezer's per-segment scan API).
7989        let indexed_rows: Option<Vec<Cow<'_, Row>>> = stmt.where_.as_ref().and_then(|w| {
7990            // BTree / col=literal seek first — covers the v7.11.3 multi-
7991            // column AND case and the leading-column equality lookup.
7992            try_index_seek(w, schema_cols, self.active_catalog(), table, alias)
7993                .or_else(|| {
7994                    // v7.12.3 — GIN-accelerated `WHERE col @@
7995                    // tsquery` when the column has a `USING gin`
7996                    // index. Returns an over-approximate candidate
7997                    // set; the WHERE re-eval loop below verifies
7998                    // the full `@@` predicate per row.
7999                    try_gin_seek(w, schema_cols, self.active_catalog(), table, alias, &ctx)
8000                })
8001                .or_else(|| {
8002                    // v7.15.0 — trigram-GIN-accelerated
8003                    // `WHERE col LIKE / ILIKE '<pat>'` when the
8004                    // column has a `gin_trgm_ops` GIN index.
8005                    // Over-approximate candidate set; the WHERE
8006                    // re-eval verifies the LIKE per row.
8007                    try_trgm_seek(w, schema_cols, table, alias)
8008                })
8009        });
8010
8011        // Aggregate path: filter rows first, then hand off to the
8012        // aggregate executor which does its own projection + ORDER BY.
8013        if aggregate::uses_aggregate(stmt) {
8014            let mut filtered: Vec<&Row> = Vec::new();
8015            // v6.2.6 — Memoize: per-query LRU cache for correlated
8016            // scalar subqueries. Fresh per row-loop entry so each
8017            // SELECT execution gets an isolated cache.
8018            let mut memo = memoize::MemoizeCache::new();
8019            if let Some(rows) = &indexed_rows {
8020                for cow in rows {
8021                    let row = cow.as_ref();
8022                    if let Some(where_expr) = &stmt.where_ {
8023                        let cond = self.eval_expr_with_correlated(
8024                            where_expr,
8025                            row,
8026                            &ctx,
8027                            cancel,
8028                            Some(&mut memo),
8029                        )?;
8030                        if !matches!(cond, Value::Bool(true)) {
8031                            continue;
8032                        }
8033                    }
8034                    filtered.push(row);
8035                }
8036            } else {
8037                for i in 0..table.row_count() {
8038                    let row = &table.rows()[i];
8039                    if let Some(where_expr) = &stmt.where_ {
8040                        let cond = self.eval_expr_with_correlated(
8041                            where_expr,
8042                            row,
8043                            &ctx,
8044                            cancel,
8045                            Some(&mut memo),
8046                        )?;
8047                        if !matches!(cond, Value::Bool(true)) {
8048                            continue;
8049                        }
8050                    }
8051                    filtered.push(row);
8052                }
8053            }
8054            let mut agg = aggregate::run(stmt, &filtered, schema_cols, Some(alias))?;
8055            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
8056            return Ok(QueryResult::Rows {
8057                columns: agg.columns,
8058                rows: agg.rows,
8059            });
8060        }
8061
8062        let projection = build_projection(&stmt.items, schema_cols, alias)?;
8063        // v7.19 P5 — single-table SELECT path for SRF
8064        // `SELECT unnest(arr) FROM t` shape. Detect a top-level
8065        // unnest in the projection list. When present, the
8066        // per-row processor emits one output row per array
8067        // element (broadcasting non-SRF projections from the
8068        // same input row). Empty / NULL arrays emit zero rows
8069        // for that input — PG semantics.
8070        let srf_position = projection.iter().position(|p| is_top_level_unnest(&p.expr));
8071
8072        // Materialise the filter pass into `(order_key, projected_row)`
8073        // tuples. The order key is `None` when there's no ORDER BY clause.
8074        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
8075        // v6.2.6 — Memoize per-row WHERE eval shares one cache.
8076        let mut memo = memoize::MemoizeCache::new();
8077        // Inline the per-row work in a closure so the indexed and full-
8078        // scan branches share the body.
8079        let mut process_row = |row: &Row, loop_idx: usize| -> Result<(), EngineError> {
8080            if loop_idx.is_multiple_of(256) {
8081                cancel.check()?;
8082            }
8083            if let Some(where_expr) = &stmt.where_ {
8084                let cond =
8085                    self.eval_expr_with_correlated(where_expr, row, &ctx, cancel, Some(&mut memo))?;
8086                if !matches!(cond, Value::Bool(true)) {
8087                    return Ok(());
8088                }
8089            }
8090            let order_keys = if stmt.order_by.is_empty() {
8091                Vec::new()
8092            } else {
8093                build_order_keys(&stmt.order_by, row, &ctx)?
8094            };
8095            if let Some(srf_idx) = srf_position {
8096                let srf_arg = top_level_unnest_arg(&projection[srf_idx].expr)
8097                    .expect("checked by is_top_level_unnest above");
8098                let arr_val = eval::eval_expr(srf_arg, row, &ctx)?;
8099                let elements = array_value_to_elements(&arr_val)?;
8100                for elem in elements {
8101                    let mut values = Vec::with_capacity(projection.len());
8102                    for (i, p) in projection.iter().enumerate() {
8103                        if i == srf_idx {
8104                            values.push(elem.clone());
8105                        } else {
8106                            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8107                        }
8108                    }
8109                    tagged.push((order_keys.clone(), Row::new(values)));
8110                }
8111            } else {
8112                let mut values = Vec::with_capacity(projection.len());
8113                for p in &projection {
8114                    // v7.24 (round-16 B) — correlated-aware.
8115                    values.push(self.eval_expr_with_correlated(&p.expr, row, &ctx, cancel, None)?);
8116                }
8117                tagged.push((order_keys, Row::new(values)));
8118            }
8119            Ok(())
8120        };
8121        if let Some(rows) = &indexed_rows {
8122            for (loop_idx, cow) in rows.iter().enumerate() {
8123                process_row(cow.as_ref(), loop_idx)?;
8124            }
8125        } else {
8126            for i in 0..table.row_count() {
8127                process_row(&table.rows()[i], i)?;
8128            }
8129        }
8130
8131        if !stmt.order_by.is_empty() {
8132            // Partial-sort fast path: when LIMIT is small relative to
8133            // the row count, select_nth_unstable + sort just the
8134            // prefix is O(n + k log k) instead of O(n log n). DISTINCT
8135            // requires the full sort because de-dup happens after.
8136            // WITH TIES likewise needs the full sort so the tie
8137            // extension can scan past `limit` to find rows that
8138            // share the last-kept row's key.
8139            let keep = if stmt.distinct || stmt.limit_with_ties {
8140                None
8141            } else {
8142                stmt.limit_literal()
8143                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
8144            };
8145            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
8146            partial_sort_tagged(&mut tagged, keep, &descs);
8147        }
8148
8149        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST … WITH TIES` extends
8150        // past the truncated tail through every row that shares the
8151        // last-kept row's ORDER BY key. The tie check uses the
8152        // already-computed `(order_keys, row)` pairs so it matches
8153        // the sort comparator exactly. DISTINCT + WITH TIES falls
8154        // through to the no-ties path (PG also disallows their
8155        // combination; SPG silently drops the tie extension here so
8156        // the customer doesn't see a hard error mid-query — the
8157        // user-visible result is still correct, just narrower).
8158        let output_rows: Vec<Row> = if stmt.limit_with_ties && !stmt.distinct {
8159            apply_offset_and_limit_tagged(
8160                &mut tagged,
8161                stmt.offset_literal(),
8162                stmt.limit_literal(),
8163                true,
8164            );
8165            tagged.into_iter().map(|(_, r)| r).collect()
8166        } else {
8167            let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
8168            if stmt.distinct {
8169                output_rows = dedup_rows(output_rows);
8170            }
8171            apply_offset_and_limit(
8172                &mut output_rows,
8173                stmt.offset_literal(),
8174                stmt.limit_literal(),
8175            );
8176            output_rows
8177        };
8178
8179        let columns: Vec<ColumnSchema> = projection
8180            .into_iter()
8181            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8182            .collect();
8183
8184        Ok(QueryResult::Rows {
8185            columns,
8186            rows: output_rows,
8187        })
8188    }
8189
8190    /// Multi-table SELECT executor (one or more JOIN peers).
8191    ///
8192    /// v1.10 builds the joined row set up-front via nested-loop joins,
8193    /// then runs WHERE + projection + ORDER BY against the combined
8194    /// rows. No index seek. Aggregates and DISTINCT still work because
8195    /// the executor delegates projection through the same shared paths.
8196    #[allow(clippy::too_many_lines)]
8197    /// v7.13.2 — mailrs round-6 S5. Resolve a TableRef into an
8198    /// owned (rows, schema) pair. Catalog tables clone their hot
8199    /// rows + schema; UNNEST table refs evaluate their array
8200    /// expression once and synthesise a single-column row set
8201    /// using the same dispatch as `exec_select_unnest`. Used by
8202    /// the joined-select path so UNNEST can appear in any FROM
8203    /// position, not just as the primary.
8204    fn materialise_table_ref(
8205        &self,
8206        tref: &TableRef,
8207    ) -> Result<(Vec<Row>, Vec<ColumnSchema>), EngineError> {
8208        if let Some(expr) = tref.unnest_expr.as_deref() {
8209            let empty_schema: Vec<ColumnSchema> = Vec::new();
8210            let ctx = EvalContext::new(&empty_schema, None);
8211            let dummy_row = Row::new(Vec::new());
8212            let (elem_dtype, rows) =
8213                match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
8214                    Value::Null => (DataType::Text, Vec::new()),
8215                    Value::TextArray(items) => (
8216                        DataType::Text,
8217                        items
8218                            .into_iter()
8219                            .map(|item| {
8220                                Row::new(alloc::vec![match item {
8221                                    Some(s) => Value::Text(s),
8222                                    None => Value::Null,
8223                                }])
8224                            })
8225                            .collect(),
8226                    ),
8227                    Value::IntArray(items) => (
8228                        DataType::Int,
8229                        items
8230                            .into_iter()
8231                            .map(|item| {
8232                                Row::new(alloc::vec![match item {
8233                                    Some(n) => Value::Int(n),
8234                                    None => Value::Null,
8235                                }])
8236                            })
8237                            .collect(),
8238                    ),
8239                    Value::BigIntArray(items) => (
8240                        DataType::BigInt,
8241                        items
8242                            .into_iter()
8243                            .map(|item| {
8244                                Row::new(alloc::vec![match item {
8245                                    Some(n) => Value::BigInt(n),
8246                                    None => Value::Null,
8247                                }])
8248                            })
8249                            .collect(),
8250                    ),
8251                    other => {
8252                        return Err(EngineError::Unsupported(alloc::format!(
8253                            "unnest() expects an array argument, got {:?}",
8254                            other.data_type()
8255                        )));
8256                    }
8257                };
8258            let alias = tref.alias.clone().unwrap_or_else(|| "unnest".to_string());
8259            let col_name = tref.unnest_column_aliases.first().cloned().unwrap_or(alias);
8260            return Ok((
8261                rows,
8262                alloc::vec![ColumnSchema::new(col_name, elem_dtype, true)],
8263            ));
8264        }
8265        let table =
8266            self.active_catalog()
8267                .get(&tref.name)
8268                .ok_or_else(|| StorageError::TableNotFound {
8269                    name: tref.name.clone(),
8270                })?;
8271        let rows: Vec<Row> = table.rows().iter().cloned().collect();
8272        let cols = table.schema().columns.clone();
8273        Ok((rows, cols))
8274    }
8275
8276    /// v7.17.0 Phase 3.P0-43 — materialise a `FROM` with one or more
8277    /// JOINs into `(combined_schema, filtered_rows)`. The combined
8278    /// schema uses composite `alias.col` column names so the
8279    /// qualifier-aware column resolver finds every join peer by
8280    /// exact match; the filtered rows are the join cross-product
8281    /// after the optional WHERE clause is applied.
8282    ///
8283    /// Shared by `exec_joined_select` and the JOIN branch of
8284    /// `exec_select_with_window`; both paths used to inline the
8285    /// same nested-loop logic and the window path rejected JOIN
8286    /// outright.
8287    fn build_joined_filtered_rows(
8288        &self,
8289        from: &FromClause,
8290        where_: Option<&Expr>,
8291        cancel: CancelToken<'_>,
8292    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
8293        let (primary_rows, primary_cols) = self.materialise_table_ref(&from.primary)?;
8294        let primary_alias = from
8295            .primary
8296            .alias
8297            .as_deref()
8298            .unwrap_or(from.primary.name.as_str())
8299            .to_string();
8300        // v7.17.0 Phase 3.P0-41 — LATERAL peers can't be
8301        // pre-materialised because their rows depend on outer
8302        // columns. For each peer, build either an eager
8303        // (rows, schema) pair or a "lateral" sentinel carrying
8304        // just the schema and the inner SELECT to re-run per
8305        // outer row.
8306        #[allow(clippy::type_complexity)]
8307        let mut joined: Vec<JoinedPeer<'_>> = Vec::new();
8308        for j in &from.joins {
8309            let a = j
8310                .table
8311                .alias
8312                .as_deref()
8313                .unwrap_or(j.table.name.as_str())
8314                .to_string();
8315            if let Some(inner_box) = &j.table.lateral_subquery {
8316                // Probe schema by running the inner SELECT against a
8317                // NULL-padded outer context. The probe gives us the
8318                // projection's column shape; rows materialise per
8319                // left-row below.
8320                let schema = self.lateral_probe_schema(inner_box)?;
8321                joined.push(JoinedPeer {
8322                    eager_rows: None,
8323                    cols: schema,
8324                    alias: a,
8325                    kind: j.kind,
8326                    on: j.on.as_ref(),
8327                    lateral: Some(inner_box.as_ref()),
8328                });
8329            } else {
8330                let (rows, cols) = self.materialise_table_ref(&j.table)?;
8331                joined.push(JoinedPeer {
8332                    eager_rows: Some(rows),
8333                    cols,
8334                    alias: a,
8335                    kind: j.kind,
8336                    on: j.on.as_ref(),
8337                    lateral: None,
8338                });
8339            }
8340        }
8341        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
8342        for col in &primary_cols {
8343            combined_schema.push(ColumnSchema::new(
8344                alloc::format!("{primary_alias}.{}", col.name),
8345                col.ty,
8346                col.nullable,
8347            ));
8348        }
8349        for peer in &joined {
8350            for col in &peer.cols {
8351                combined_schema.push(ColumnSchema::new(
8352                    alloc::format!("{}.{}", peer.alias, col.name),
8353                    col.ty,
8354                    col.nullable,
8355                ));
8356            }
8357        }
8358        let ctx = EvalContext::new(&combined_schema, None);
8359        let mut working: Vec<Row> = primary_rows;
8360        // Track the per-row width consumed by the outer left side so
8361        // each lateral evaluation sees the correct schema slice.
8362        let mut consumed_cols = primary_cols.len();
8363        for peer in &joined {
8364            let right_arity = peer.cols.len();
8365            let mut next: Vec<Row> = Vec::new();
8366            for left in &working {
8367                let mut left_matched = false;
8368                let per_left_rrows: alloc::borrow::Cow<'_, [Row]> = match peer.lateral {
8369                    Some(inner) => {
8370                        // Substitute outer columns and run the inner
8371                        // SELECT against the current left row's slice
8372                        // of the combined schema.
8373                        let outer_schema = &combined_schema[..consumed_cols];
8374                        let rows = self.materialise_lateral_for_outer(inner, outer_schema, left)?;
8375                        alloc::borrow::Cow::Owned(rows)
8376                    }
8377                    None => {
8378                        let r = peer.eager_rows.as_ref().expect("non-lateral peer eager");
8379                        alloc::borrow::Cow::Borrowed(r.as_slice())
8380                    }
8381                };
8382                for right in per_left_rrows.as_ref() {
8383                    let mut combined_vals = left.values.clone();
8384                    combined_vals.extend(right.values.iter().cloned());
8385                    let combined = Row::new(combined_vals);
8386                    let keep = if let Some(on_expr) = peer.on {
8387                        // v7.24.1 — correlated-aware (subqueries in
8388                        // ON referencing earlier join columns).
8389                        let cond =
8390                            self.eval_expr_with_correlated(on_expr, &combined, &ctx, cancel, None)?;
8391                        matches!(cond, Value::Bool(true))
8392                    } else {
8393                        true
8394                    };
8395                    if keep {
8396                        next.push(combined);
8397                        left_matched = true;
8398                    }
8399                }
8400                if !left_matched && matches!(peer.kind, JoinKind::Left) {
8401                    let mut combined_vals = left.values.clone();
8402                    for _ in 0..right_arity {
8403                        combined_vals.push(Value::Null);
8404                    }
8405                    next.push(Row::new(combined_vals));
8406                }
8407            }
8408            working = next;
8409            consumed_cols += right_arity;
8410            debug_assert!(consumed_cols <= combined_schema.len());
8411        }
8412        let mut filtered: Vec<Row> = Vec::new();
8413        // v7.24 (round-16 B) — the joined WHERE filter ran the plain
8414        // row evaluator, so a correlated EXISTS/IN/scalar subquery
8415        // under a JOIN hit "subquery reached row eval". Route through
8416        // the correlated-aware evaluator (memoized, same as the
8417        // single-table path).
8418        let mut memo = memoize::MemoizeCache::default();
8419        for row in working {
8420            if let Some(where_expr) = where_ {
8421                let cond = self.eval_expr_with_correlated(
8422                    where_expr,
8423                    &row,
8424                    &ctx,
8425                    cancel,
8426                    Some(&mut memo),
8427                )?;
8428                if !matches!(cond, Value::Bool(true)) {
8429                    continue;
8430                }
8431            }
8432            filtered.push(row);
8433        }
8434        Ok((combined_schema, filtered))
8435    }
8436
8437    /// v7.17.0 Phase 3.P0-41 — probe a LATERAL subquery's projection
8438    /// schema by running it once with a NULL-padded outer context.
8439    /// The probe never materialises real outer rows; it just executes
8440    /// the inner SELECT with `outer_alias.col` references substituted
8441    /// to NULL so the projection's type inference is exercised.
8442    fn lateral_probe_schema(
8443        &self,
8444        inner: &SelectStatement,
8445    ) -> Result<Vec<ColumnSchema>, EngineError> {
8446        // Substitute every qualified column reference whose qualifier
8447        // does NOT match an in-subquery FROM alias with NULL. The
8448        // safest probe is to walk the inner SELECT and replace any
8449        // `<qual>.<col>` whose qual isn't bound inside the subquery
8450        // with a Null literal. For the v7.17 probe we just run the
8451        // unmodified subquery and surface the columns; if it fails
8452        // (e.g. references an outer column the probe can't resolve),
8453        // we synthesise a best-effort schema from the SELECT items
8454        // by inferring a single Text-typed column per projection.
8455        match self.execute_readonly_select_for_lateral_probe(inner) {
8456            Ok(QueryResult::Rows { columns, .. }) => Ok(columns),
8457            // Best-effort fallback: each SELECT item becomes a TEXT
8458            // column. Real schemas only differ when the inner SELECT
8459            // references outer columns at projection-time; those
8460            // queries surface via the substitution path during
8461            // per-row execution and still return the right values.
8462            _ => {
8463                let mut out: Vec<ColumnSchema> = Vec::new();
8464                for (i, item) in inner.items.iter().enumerate() {
8465                    let name = match item {
8466                        SelectItem::Expr { alias: Some(a), .. } => a.clone(),
8467                        SelectItem::Expr { expr, .. } => synth_lateral_col_name(expr, i),
8468                        SelectItem::Wildcard => alloc::format!("col{i}"),
8469                    };
8470                    out.push(ColumnSchema::new(name, DataType::Text, true));
8471                }
8472                Ok(out)
8473            }
8474        }
8475    }
8476
8477    /// v7.17.0 Phase 3.P0-41 — try the inner LATERAL subquery against
8478    /// the engine in read-only mode for schema-probe purposes. Failure
8479    /// is expected when the subquery references an outer column the
8480    /// probe can't resolve; the caller falls back to a best-effort
8481    /// schema based on the SELECT items.
8482    fn execute_readonly_select_for_lateral_probe(
8483        &self,
8484        inner: &SelectStatement,
8485    ) -> Result<QueryResult, EngineError> {
8486        self.exec_bare_select_cancel(inner, CancelToken::none())
8487    }
8488
8489    /// v7.17.0 Phase 3.P0-41 — materialise a LATERAL subquery's rows
8490    /// for one outer-row context. Walks the inner SELECT, replaces
8491    /// every `<outer_alias>.<col>` reference whose alias appears in
8492    /// the outer schema with the literal value from the outer row,
8493    /// then runs the rewritten SELECT against the engine.
8494    fn materialise_lateral_for_outer(
8495        &self,
8496        inner: &SelectStatement,
8497        outer_schema: &[ColumnSchema],
8498        outer_row: &Row,
8499    ) -> Result<Vec<Row>, EngineError> {
8500        let mut substituted = inner.clone();
8501        substitute_outer_columns_multi(&mut substituted, outer_row, outer_schema);
8502        let result = self.exec_bare_select_cancel(&substituted, CancelToken::none())?;
8503        match result {
8504            QueryResult::Rows { rows, .. } => Ok(rows),
8505            _ => Err(EngineError::Unsupported(
8506                "LATERAL subquery must be a SELECT (cannot be a write statement)".into(),
8507            )),
8508        }
8509    }
8510
8511    fn exec_joined_select(
8512        &self,
8513        stmt: &SelectStatement,
8514        from: &FromClause,
8515        cancel: CancelToken<'_>,
8516    ) -> Result<QueryResult, EngineError> {
8517        // v7.17.0 Phase 3.P0-43 + P0-41 — delegate the join +
8518        // WHERE materialisation to the shared helper so the LATERAL
8519        // / UNNEST / regular-catalog paths route through one place.
8520        // (`build_joined_filtered_rows` carries LATERAL support as
8521        // of Phase 3.P0-41.) Downstream we still handle aggregate /
8522        // projection / ORDER BY / DISTINCT / LIMIT inline because
8523        // those depend on the SelectStatement's items list.
8524        let (combined_schema, filtered) =
8525            self.build_joined_filtered_rows(from, stmt.where_.as_ref(), cancel)?;
8526        let ctx = EvalContext::new(&combined_schema, None);
8527        // Aggregate path: handle GROUP BY / aggregate calls over the
8528        // joined+filtered rows.
8529        if aggregate::uses_aggregate(stmt) {
8530            let refs: Vec<&Row> = filtered.iter().collect();
8531            let mut agg = aggregate::run(stmt, &refs, &combined_schema, None)?;
8532            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
8533            return Ok(QueryResult::Rows {
8534                columns: agg.columns,
8535                rows: agg.rows,
8536            });
8537        }
8538
8539        let projection = build_projection(&stmt.items, &combined_schema, "")?;
8540        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
8541        let mut proj_memo = memoize::MemoizeCache::default();
8542        for row in &filtered {
8543            let mut values = Vec::with_capacity(projection.len());
8544            for p in &projection {
8545                // v7.24 (round-16 B) — select-list subqueries under a
8546                // JOIN go through the correlated-aware evaluator too.
8547                values.push(self.eval_expr_with_correlated(
8548                    &p.expr,
8549                    row,
8550                    &ctx,
8551                    cancel,
8552                    Some(&mut proj_memo),
8553                )?);
8554            }
8555            let order_keys = if stmt.order_by.is_empty() {
8556                Vec::new()
8557            } else {
8558                build_order_keys(&stmt.order_by, row, &ctx)?
8559            };
8560            tagged.push((order_keys, Row::new(values)));
8561        }
8562        if !stmt.order_by.is_empty() {
8563            let keep = if stmt.distinct {
8564                None
8565            } else {
8566                stmt.limit_literal()
8567                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
8568            };
8569            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
8570            partial_sort_tagged(&mut tagged, keep, &descs);
8571        }
8572        let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
8573        if stmt.distinct {
8574            output_rows = dedup_rows(output_rows);
8575        }
8576        apply_offset_and_limit(
8577            &mut output_rows,
8578            stmt.offset_literal(),
8579            stmt.limit_literal(),
8580        );
8581        let columns: Vec<ColumnSchema> = projection
8582            .into_iter()
8583            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8584            .collect();
8585        Ok(QueryResult::Rows {
8586            columns,
8587            rows: output_rows,
8588        })
8589    }
8590}
8591
8592/// One row-producing projection: an expression to evaluate, the resulting
8593/// column's user-visible name, its inferred type, and nullability.
8594#[derive(Debug, Clone)]
8595struct ProjectedItem {
8596    expr: Expr,
8597    output_name: String,
8598    ty: DataType,
8599    nullable: bool,
8600}
8601
8602/// Dedupe a row set, preserving first-seen order. `Row`'s `PartialEq` is
8603/// structural (`Vec<Value>` ⇒ pairwise `Value` equality), which gives SQL
8604/// `NULL = NULL → TRUE` and `NaN = NaN → FALSE`. The first agrees with
8605/// the spec's "two NULLs are not distinct"; the second is a tolerated
8606/// quirk for v1 (no NaN literals are reachable from the SQL surface).
8607fn dedup_rows(rows: Vec<Row>) -> Vec<Row> {
8608    let mut out: Vec<Row> = Vec::with_capacity(rows.len());
8609    for r in rows {
8610        if !out.iter().any(|seen| seen == &r) {
8611            out.push(r);
8612        }
8613    }
8614    out
8615}
8616
8617/// Coerce a `Value` to an `f64` sort key for ORDER BY. Numbers map directly;
8618/// NULL sorts last (treated as `+∞`); booleans are 0.0 / 1.0; text uses lex
8619/// order via the byte values; vectors are not sortable.
8620fn value_to_order_key(v: &Value) -> Result<f64, EngineError> {
8621    match v {
8622        Value::Null => Ok(f64::INFINITY),
8623        Value::SmallInt(n) => Ok(f64::from(*n)),
8624        Value::Int(n) => Ok(f64::from(*n)),
8625        Value::Date(d) => Ok(f64::from(*d)),
8626        #[allow(clippy::cast_precision_loss)]
8627        Value::Timestamp(t) => Ok(*t as f64),
8628        // v7.17.0 Phase 3.P0-32 — PG TIME ordered by underlying
8629        // i64 microseconds (matches wall-clock ordering).
8630        #[allow(clippy::cast_precision_loss)]
8631        Value::Time(us) => Ok(*us as f64),
8632        // v7.17.0 Phase 3.P0-33 — MySQL YEAR ordered by underlying
8633        // u16 (matches calendar ordering; zero-year sentinel
8634        // sorts before 1901).
8635        Value::Year(y) => Ok(f64::from(*y)),
8636        // v7.17.0 Phase 3.P0-34 — PG TIMETZ ordered by the
8637        // UTC-equivalent microseconds (local wall - offset). Two
8638        // values for the same physical instant in different zones
8639        // sort equal — matches PG TIMETZ index behaviour.
8640        #[allow(clippy::cast_precision_loss)]
8641        Value::TimeTz { us, offset_secs } => Ok((us - i64::from(*offset_secs) * 1_000_000) as f64),
8642        // v7.17.0 Phase 3.P0-35 — PG MONEY ordered by i64 cents.
8643        #[allow(clippy::cast_precision_loss)]
8644        Value::Money(c) => Ok(*c as f64),
8645        // v7.17.0 Phase 3.P0-38 — range ordering is not supported
8646        // in v7.17.0 (needs lex-then-inclusivity tiebreak).
8647        Value::Range { .. } => Err(EngineError::Unsupported(
8648            "ORDER BY of a range value is not supported in v7.17.0".into(),
8649        )),
8650        // v7.17.0 Phase 3.P0-39 — hstore is not orderable.
8651        Value::Hstore(_) => Err(EngineError::Unsupported(
8652            "ORDER BY of a hstore value is not supported".into(),
8653        )),
8654        // v7.17.0 Phase 3.P0-40 — 2D arrays not orderable.
8655        Value::IntArray2D(_) | Value::BigIntArray2D(_) | Value::TextArray2D(_) => Err(
8656            EngineError::Unsupported("ORDER BY of a 2D array is not supported in v7.17.0".into()),
8657        ),
8658        #[allow(clippy::cast_precision_loss)]
8659        Value::Numeric { scaled, scale } => {
8660            // Scaled integer / 10^scale, computed via f64 for sort
8661            // ordering only. Precision losses here only matter for
8662            // ORDER BY tie-breaks well past 15 significant digits.
8663            // `f64::powi` lives in std; we hand-roll the loop so the
8664            // no_std engine crate doesn't need it.
8665            let mut divisor = 1.0_f64;
8666            for _ in 0..*scale {
8667                divisor *= 10.0;
8668            }
8669            Ok((*scaled as f64) / divisor)
8670        }
8671        #[allow(clippy::cast_precision_loss)]
8672        Value::BigInt(n) => Ok(*n as f64),
8673        Value::Float(x) => Ok(*x),
8674        Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
8675        Value::Text(s) => {
8676            // Lex order by codepoints — good enough for ORDER BY name.
8677            // Map first 8 bytes packed into u64 as a coarse key; ties fall to
8678            // partial_cmp Equal. v1.x can swap in a real string comparator.
8679            let mut key: u64 = 0;
8680            for &b in s.as_bytes().iter().take(8) {
8681                key = (key << 8) | u64::from(b);
8682            }
8683            #[allow(clippy::cast_precision_loss)]
8684            Ok(key as f64)
8685        }
8686        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
8687            Err(EngineError::Unsupported(
8688                "ORDER BY of a raw vector column is not meaningful — use `<->`".into(),
8689            ))
8690        }
8691        Value::Interval { .. } => Err(EngineError::Unsupported(
8692            "ORDER BY of an INTERVAL is not supported in v2.11 \
8693             (months vs micros has no single canonical ordering)"
8694                .into(),
8695        )),
8696        Value::Json(_) => Err(EngineError::Unsupported(
8697            "ORDER BY of a JSON value is not supported — cast the document to text first".into(),
8698        )),
8699        // v7.5.0 — Value is #[non_exhaustive]; future variants need
8700        // an explicit ORDER BY mapping. Surface as Unsupported until
8701        // engine support is added.
8702        _ => Err(EngineError::Unsupported(
8703            "ORDER BY of this value type is not supported".into(),
8704        )),
8705    }
8706}
8707
8708/// Try to plan a WHERE clause as an equality lookup against an existing
8709/// index. Returns the candidate row indices on success; `None` means the
8710/// caller should fall back to a full scan.
8711///
8712/// v0.8 recognises a single top-level `col = literal` (in either operand
8713/// order). AND chains and range scans land in later milestones.
8714/// Look for `ORDER BY col <dist-op> literal LIMIT k` against an
8715/// NSW-indexed vector column. Recognised distance ops: `<->` (L2),
8716/// `<#>` (inner product), `<=>` (cosine). When a WHERE clause is
8717/// present, the planner does an "over-fetch and filter" pass — it
8718/// asks the graph for `k * over_fetch` candidates, evaluates WHERE
8719/// against each, and trims back to `k`. Returns the row indices in
8720/// ascending-distance order when the plan applies.
8721fn try_nsw_knn(
8722    stmt: &SelectStatement,
8723    table: &Table,
8724    schema_cols: &[ColumnSchema],
8725    table_alias: &str,
8726) -> Option<Vec<usize>> {
8727    if stmt.distinct {
8728        return None;
8729    }
8730    let limit = usize::try_from(stmt.limit_literal()?).ok()?;
8731    if limit == 0 {
8732        return None;
8733    }
8734    // v6.4.0 — NSW kNN dispatch needs a single ORDER BY key on the
8735    // distance metric. Multi-key ORDER BY falls through to the
8736    // generic sort path.
8737    if stmt.order_by.len() != 1 {
8738        return None;
8739    }
8740    let order = &stmt.order_by[0];
8741    // NSW kNN returns rows ascending by distance — DESC inverts the
8742    // natural order, so the planner can't handle it without a sort
8743    // pass. Fall back to the generic ORDER BY path.
8744    if order.desc {
8745        return None;
8746    }
8747    let Expr::Binary { lhs, op, rhs } = &order.expr else {
8748        return None;
8749    };
8750    let metric = match op {
8751        BinOp::L2Distance => spg_storage::NswMetric::L2,
8752        BinOp::InnerProduct => spg_storage::NswMetric::InnerProduct,
8753        BinOp::CosineDistance => spg_storage::NswMetric::Cosine,
8754        _ => return None,
8755    };
8756    // Accept both `col <op> literal` and `literal <op> col`.
8757    let ((Expr::Column(col), literal) | (literal, Expr::Column(col))) =
8758        (lhs.as_ref(), rhs.as_ref())
8759    else {
8760        return None;
8761    };
8762    if let Some(q) = &col.qualifier
8763        && q != table_alias
8764    {
8765        return None;
8766    }
8767    let col_pos = schema_cols.iter().position(|s| s.name == col.name)?;
8768    let query = literal_to_vector(literal)?;
8769    let idx = spg_storage::nsw_index_on(table, col_pos)?;
8770    if let Some(where_expr) = &stmt.where_ {
8771        // Over-fetch and filter. The factor (10×) is a heuristic that
8772        // covers typical selectivity for the corpus tests; v2.x will
8773        // make it configurable.
8774        let over_fetch = limit.saturating_mul(10).max(NSW_OVER_FETCH_FLOOR);
8775        let candidates = spg_storage::nsw_query(table, &idx.name, &query, over_fetch, metric);
8776        let ctx = EvalContext::new(schema_cols, Some(table_alias));
8777        let mut kept: Vec<usize> = Vec::with_capacity(limit);
8778        for i in candidates {
8779            let row = &table.rows()[i];
8780            let cond = eval::eval_expr(where_expr, row, &ctx).ok()?;
8781            if matches!(cond, Value::Bool(true)) {
8782                kept.push(i);
8783                if kept.len() >= limit {
8784                    break;
8785                }
8786            }
8787        }
8788        Some(kept)
8789    } else {
8790        Some(spg_storage::nsw_query(
8791            table, &idx.name, &query, limit, metric,
8792        ))
8793    }
8794}
8795
8796/// Lower bound on the over-fetch pool when WHERE is present — even
8797/// for tiny `LIMIT 1` queries we keep enough candidates to absorb a
8798/// few WHERE rejections.
8799const NSW_OVER_FETCH_FLOOR: usize = 32;
8800
8801/// Pull a `Vec<f32>` out of a literal-or-cast expression. Returns
8802/// `None` for anything we can't fold at plan time.
8803fn literal_to_vector(e: &Expr) -> Option<Vec<f32>> {
8804    match e {
8805        Expr::Literal(Literal::Vector(v)) => Some(v.clone()),
8806        Expr::Cast { expr, .. } => literal_to_vector(expr),
8807        _ => None,
8808    }
8809}
8810
8811/// Materialise rows in a planner-supplied order (used by the NSW path)
8812/// without re-running ORDER BY. The projection + LIMIT slot mirror the
8813/// equivalent block in `exec_bare_select`.
8814fn materialise_in_order(
8815    stmt: &SelectStatement,
8816    table: &Table,
8817    schema_cols: &[ColumnSchema],
8818    table_alias: &str,
8819    ordered_rows: &[usize],
8820) -> Result<QueryResult, EngineError> {
8821    let ctx = EvalContext::new(schema_cols, Some(table_alias));
8822    let projection = build_projection(&stmt.items, schema_cols, table_alias)?;
8823    let mut output_rows: Vec<Row> = Vec::with_capacity(ordered_rows.len());
8824    for &i in ordered_rows {
8825        let row = &table.rows()[i];
8826        let mut values = Vec::with_capacity(projection.len());
8827        for p in &projection {
8828            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8829        }
8830        output_rows.push(Row::new(values));
8831    }
8832    apply_offset_and_limit(
8833        &mut output_rows,
8834        stmt.offset_literal(),
8835        stmt.limit_literal(),
8836    );
8837    let columns: Vec<ColumnSchema> = projection
8838        .into_iter()
8839        .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8840        .collect();
8841    Ok(QueryResult::Rows {
8842        columns,
8843        rows: output_rows,
8844    })
8845}
8846
8847/// v7.20 P4 — hot-row POSITION seek for the mutation paths
8848/// (UPDATE / DELETE index their planned writes by position in
8849/// `table.rows()`, so the Cow-row shape `try_index_seek`
8850/// returns doesn't fit). Same top-level-AND recursion and
8851/// col=literal resolution; the caller re-applies the full WHERE
8852/// to every returned row so the index only narrows candidates.
8853///
8854/// Returns `None` (→ caller full-scans) when no equality leaf
8855/// hits an index OR any matching locator lives in the cold tier
8856/// — the mutation paths operate on hot rows, and the PK
8857/// promote-then-walk upstream already handles the
8858/// cold-single-row case.
8859fn try_index_seek_positions(
8860    where_expr: &Expr,
8861    schema_cols: &[ColumnSchema],
8862    table: &Table,
8863    table_alias: &str,
8864) -> Option<Vec<usize>> {
8865    if let Expr::Binary {
8866        lhs,
8867        op: BinOp::And,
8868        rhs,
8869    } = where_expr
8870    {
8871        if let Some(p) = try_index_seek_positions(lhs, schema_cols, table, table_alias) {
8872            return Some(p);
8873        }
8874        return try_index_seek_positions(rhs, schema_cols, table, table_alias);
8875    }
8876    let Expr::Binary {
8877        lhs,
8878        op: BinOp::Eq,
8879        rhs,
8880    } = where_expr
8881    else {
8882        return None;
8883    };
8884    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
8885        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
8886    let idx = table.index_on(col_pos)?;
8887    let key = IndexKey::from_value(&value)?;
8888    let locators = idx.lookup_eq(&key);
8889    let mut out = Vec::with_capacity(locators.len());
8890    for loc in locators {
8891        match *loc {
8892            spg_storage::RowLocator::Hot(i) => out.push(i),
8893            spg_storage::RowLocator::Cold { .. } => return None,
8894        }
8895    }
8896    Some(out)
8897}
8898
8899fn try_index_seek<'a>(
8900    where_expr: &Expr,
8901    schema_cols: &[ColumnSchema],
8902    catalog: &'a Catalog,
8903    table: &'a Table,
8904    table_alias: &str,
8905) -> Option<Vec<Cow<'a, Row>>> {
8906    // v7.11.3 — recurse through top-level `AND` so a PG-style
8907    // composite predicate like `WHERE id = 1 AND created_at > $1`
8908    // still hits the index on `id`. The caller re-applies the
8909    // full WHERE expression to each returned row, so dropping the
8910    // residual conjuncts here is correct — the index just narrows
8911    // the candidate set.
8912    if let Expr::Binary {
8913        lhs,
8914        op: BinOp::And,
8915        rhs,
8916    } = where_expr
8917    {
8918        // Try LHS first (typical convention: leading equality on
8919        // the indexed column comes first in user-written SQL).
8920        if let Some(rows) = try_index_seek(lhs, schema_cols, catalog, table, table_alias) {
8921            return Some(rows);
8922        }
8923        return try_index_seek(rhs, schema_cols, catalog, table, table_alias);
8924    }
8925    let Expr::Binary {
8926        lhs,
8927        op: BinOp::Eq,
8928        rhs,
8929    } = where_expr
8930    else {
8931        return None;
8932    };
8933    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
8934        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
8935    let idx = table.index_on(col_pos)?;
8936    let key = IndexKey::from_value(&value)?;
8937    let locators = idx.lookup_eq(&key);
8938    let table_name = table.schema().name.as_str();
8939    // v5.1: each locator dispatches to either the hot tier (zero-
8940    // copy borrow of `table.rows()[i]`) or a cold-tier segment
8941    // (one page read + dense row decode, ~µs scale). Cold rows are
8942    // returned as `Cow::Owned` so the caller's `&Row` iteration
8943    // doesn't see a tier distinction; pre-freezer (no cold
8944    // segments loaded) every locator is `Hot` and every entry is
8945    // `Cow::Borrowed` — identical cost to the pre-v5.1 path.
8946    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(locators.len());
8947    for loc in locators {
8948        match *loc {
8949            spg_storage::RowLocator::Hot(i) => {
8950                if let Some(row) = table.rows().get(i) {
8951                    out.push(Cow::Borrowed(row));
8952                }
8953            }
8954            spg_storage::RowLocator::Cold { segment_id, .. } => {
8955                if let Some(row) = catalog.resolve_cold_locator(table_name, segment_id, &key) {
8956                    out.push(Cow::Owned(row));
8957                }
8958            }
8959        }
8960    }
8961    Some(out)
8962}
8963
8964/// v7.12.3 — GIN-accelerated candidate seek for `WHERE col @@ <ts_query>`.
8965///
8966/// Recurses through top-level `AND` like [`try_index_seek`] so a
8967/// composite predicate `WHERE search_vector @@ q AND id > $1` still
8968/// hits the GIN index on `search_vector` — the caller re-applies the
8969/// full WHERE expression to each returned candidate, so dropping the
8970/// `id > $1` residual here stays semantically correct.
8971///
8972/// Returns `None` when:
8973///   - no leaf is a `col @@ <rhs>` shape on a GIN-indexed column;
8974///   - the RHS can't be const-evaluated to a `Value::TsQuery`
8975///     (typically because it references row columns);
8976///   - the resolved `TsQuery` uses query shapes the MVP doesn't
8977///     accelerate (`Not`, `Phrase` — those fall through to full scan).
8978///
8979/// On `Some(rows)` the caller iterates only `rows` and re-evaluates
8980/// the full `@@` predicate per row, so an over-approximate candidate
8981/// set is safe.
8982fn try_gin_seek<'a>(
8983    where_expr: &Expr,
8984    schema_cols: &[ColumnSchema],
8985    catalog: &'a Catalog,
8986    table: &'a Table,
8987    table_alias: &str,
8988    ctx: &eval::EvalContext<'_>,
8989) -> Option<Vec<Cow<'a, Row>>> {
8990    if let Expr::Binary {
8991        lhs,
8992        op: BinOp::And,
8993        rhs,
8994    } = where_expr
8995    {
8996        if let Some(rows) = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx) {
8997            return Some(rows);
8998        }
8999        return try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx);
9000    }
9001    // v7.17.0 Phase 3.P0-44 — MySQL `MATCH(col1, col2) AGAINST (...)`
9002    // desugars into `(to_tsvector(col1) @@ q) OR (to_tsvector(col2) @@ q)`
9003    // in the parser. To accelerate the multi-column case, walk OR the same
9004    // way we walk AND: only emit a candidate set if BOTH sides can seek
9005    // (otherwise the OR result is unbounded and we must fall through to
9006    // the full scan). Candidates are union'd; the caller's WHERE re-eval
9007    // verifies the full predicate per row, so duplicates / supersets stay
9008    // semantically safe.
9009    if let Expr::Binary {
9010        lhs,
9011        op: BinOp::Or,
9012        rhs,
9013    } = where_expr
9014    {
9015        let left = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx)?;
9016        let right = try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx)?;
9017        let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(left.len() + right.len());
9018        out.extend(left);
9019        out.extend(right);
9020        return Some(out);
9021    }
9022    let Expr::Binary {
9023        lhs,
9024        op: BinOp::TsMatch,
9025        rhs,
9026    } = where_expr
9027    else {
9028        return None;
9029    };
9030    // Either side can be the column; pgvector idiom (`vec @@ q`)
9031    // hits the first arm, FROM-clause-derived (`plainto_tsquery($1)
9032    // q ... WHERE search_vector @@ q`) the same. CROSS JOIN derived
9033    // tables resolve `q` to a Column too.
9034    let (col_pos, query) = resolve_gin_col_query(lhs, rhs, schema_cols, table_alias, ctx)
9035        .or_else(|| resolve_gin_col_query(rhs, lhs, schema_cols, table_alias, ctx))?;
9036    // v7.17.0 Phase 3.P0-44 — MySQL `FULLTEXT KEY` builds a
9037    // `IndexKind::GinFulltext` posting list (Phase 2.2). It shares
9038    // the same `gin_lookup_word` shape as the tsvector-typed GIN,
9039    // so the MATCH-AGAINST `@@` predicate (desugared by the parser
9040    // into `to_tsvector(col) @@ plainto_tsquery('term')`) routes
9041    // through the same candidate-set seek.
9042    let idx = table
9043        .indices()
9044        .iter()
9045        .find(|i| i.column_position == col_pos && (i.is_gin() || i.is_gin_fulltext()))?;
9046    let candidates = gin_query_candidates(idx, &query)?;
9047    let _ = catalog; // cold-tier row resolution unused in MVP; see below.
9048    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(candidates.len());
9049    for loc in candidates {
9050        match loc {
9051            spg_storage::RowLocator::Hot(i) => {
9052                if let Some(row) = table.rows().get(i) {
9053                    out.push(Cow::Borrowed(row));
9054                }
9055            }
9056            // GIN cold-tier rows in the MVP: skipped, matching the
9057            // full-scan `@@` path which itself only iterates
9058            // `table.rows()` (hot tier). When v7.13+ adds cold-tier
9059            // scan-time materialisation for `@@`, the parallel
9060            // resolution lands here; until then both paths see the
9061            // same hot-only candidate set so correctness is preserved.
9062            spg_storage::RowLocator::Cold { .. } => {}
9063        }
9064    }
9065    Some(out)
9066}
9067
9068/// v7.15.0 — trigram-GIN-accelerated candidate seek for
9069/// `WHERE col LIKE '<pat>'` and `WHERE col ILIKE '<pat>'` when
9070/// the column has a `gin_trgm_ops` GIN index.
9071///
9072/// Walks top-level `AND` so multi-predicate WHEREs (`col LIKE
9073/// 'foo%' AND id > 1`) still hit the trigram index; the caller
9074/// re-evaluates the full WHERE per candidate row, so dropping
9075/// non-LIKE conjuncts here stays semantically correct.
9076///
9077/// Returns `None` when:
9078///   - no leaf is `col LIKE/ILIKE <literal>` on a trigram-GIN-
9079///     indexed column;
9080///   - the pattern's literal runs are too short to constrain
9081///     (pattern decomposes into `< 3`-char runs, e.g. `%ab%`);
9082///   - the pattern doesn't const-evaluate to a TEXT.
9083fn try_trgm_seek<'a>(
9084    where_expr: &Expr,
9085    schema_cols: &[ColumnSchema],
9086    table: &'a Table,
9087    table_alias: &str,
9088) -> Option<Vec<Cow<'a, Row>>> {
9089    if let Expr::Binary {
9090        lhs,
9091        op: BinOp::And,
9092        rhs,
9093    } = where_expr
9094    {
9095        if let Some(rows) = try_trgm_seek(lhs, schema_cols, table, table_alias) {
9096            return Some(rows);
9097        }
9098        return try_trgm_seek(rhs, schema_cols, table, table_alias);
9099    }
9100    // LIKE node is what carries the column reference + pattern.
9101    // ILIKE is the same AST node — PG's LIKE/ILIKE both lower
9102    // through `Expr::Like { expr, pattern, negated }`. The trigram
9103    // index posting-list keys are already lower-cased and
9104    // case-folded, so we only need the pattern's literal runs.
9105    let Expr::Like { expr, pattern, .. } = where_expr else {
9106        return None;
9107    };
9108    // Column side.
9109    let Expr::Column(c) = expr.as_ref() else {
9110        return None;
9111    };
9112    if let Some(q) = &c.qualifier
9113        && q != table_alias
9114    {
9115        return None;
9116    }
9117    let col_pos = schema_cols
9118        .iter()
9119        .position(|s| s.name.eq_ignore_ascii_case(&c.name))?;
9120    // Index must exist on that column AND be a trigram-GIN.
9121    let idx = table
9122        .indices()
9123        .iter()
9124        .find(|i| i.column_position == col_pos && i.is_gin_trgm())?;
9125    // Pattern side must be a literal TEXT — anything else (column
9126    // ref, function call, parameter that hasn't been bound yet)
9127    // falls through to full scan.
9128    let Expr::Literal(spg_sql::ast::Literal::String(pat)) = pattern.as_ref() else {
9129        return None;
9130    };
9131    let trigrams = spg_storage::trgm::trigrams_from_like_pattern(pat)?;
9132    // Intersect every trigram's posting list. Empty intersection
9133    // → empty candidate set (caller short-circuits its row loop).
9134    let mut iter = trigrams.iter();
9135    let first = iter.next()?;
9136    let mut acc: Vec<spg_storage::RowLocator> = {
9137        let mut v = idx.gin_trgm_lookup(first).to_vec();
9138        v.sort_by_key(locator_sort_key);
9139        v.dedup_by_key(|l| locator_sort_key(l));
9140        v
9141    };
9142    for tri in iter {
9143        let mut next: Vec<spg_storage::RowLocator> = idx.gin_trgm_lookup(tri).to_vec();
9144        next.sort_by_key(locator_sort_key);
9145        next.dedup_by_key(|l| locator_sort_key(l));
9146        // Sorted-merge intersection.
9147        let mut merged: Vec<spg_storage::RowLocator> =
9148            Vec::with_capacity(acc.len().min(next.len()));
9149        let (mut i, mut j) = (0usize, 0usize);
9150        while i < acc.len() && j < next.len() {
9151            let lk = locator_sort_key(&acc[i]);
9152            let rk = locator_sort_key(&next[j]);
9153            match lk.cmp(&rk) {
9154                core::cmp::Ordering::Less => i += 1,
9155                core::cmp::Ordering::Greater => j += 1,
9156                core::cmp::Ordering::Equal => {
9157                    merged.push(acc[i]);
9158                    i += 1;
9159                    j += 1;
9160                }
9161            }
9162        }
9163        acc = merged;
9164        if acc.is_empty() {
9165            break;
9166        }
9167    }
9168    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(acc.len());
9169    for loc in acc {
9170        if let spg_storage::RowLocator::Hot(i) = loc
9171            && let Some(row) = table.rows().get(i)
9172        {
9173            out.push(Cow::Borrowed(row));
9174        }
9175        // Cold-tier rows: skipped in MVP (same as try_gin_seek).
9176    }
9177    Some(out)
9178}
9179
9180/// v7.12.3 — extract `(column_position, TsQueryAst)` when one side of
9181/// the binary is a column reference to a GIN-indexed tsvector column
9182/// and the other side const-evaluates to a `Value::TsQuery`. Returns
9183/// `None` if the column reference is for the wrong table alias, or if
9184/// the RHS expression depends on row data.
9185fn resolve_gin_col_query(
9186    col_side: &Expr,
9187    query_side: &Expr,
9188    schema_cols: &[ColumnSchema],
9189    table_alias: &str,
9190    ctx: &eval::EvalContext<'_>,
9191) -> Option<(usize, spg_storage::TsQueryAst)> {
9192    // v7.17.0 Phase 3.P0-44 — the MATCH AGAINST desugar wraps the
9193    // column in `to_tsvector('simple', col)`, so we peel that wrapper
9194    // before the column lookup. Direct `col @@ tsquery` paths (the
9195    // tsvector-typed v7.12 surface) skip the wrapper entirely.
9196    let column = match col_side {
9197        Expr::Column(c) => c,
9198        Expr::FunctionCall { name, args }
9199            if name.eq_ignore_ascii_case("to_tsvector") && !args.is_empty() =>
9200        {
9201            // PG `to_tsvector` accepts either `to_tsvector(col)` or
9202            // `to_tsvector(config, col)`. In both shapes the column
9203            // we care about is the final argument.
9204            if let Expr::Column(c) = args.last().unwrap() {
9205                c
9206            } else {
9207                return None;
9208            }
9209        }
9210        _ => return None,
9211    };
9212    let c = column;
9213    if let Some(q) = &c.qualifier
9214        && q != table_alias
9215    {
9216        return None;
9217    }
9218    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
9219    // Const-evaluate the query side with an empty row — fails fast
9220    // (with a `ColumnNotFound` / similar) if the expression actually
9221    // depends on row data, which is exactly the bail signal we want.
9222    let empty_row = Row::new(Vec::new());
9223    let v = eval::eval_expr(query_side, &empty_row, ctx).ok()?;
9224    let Value::TsQuery(q) = v else { return None };
9225    Some((pos, q))
9226}
9227
9228/// v7.12.3 — walk a `TsQueryAst` against an [`IndexKind::Gin`] index
9229/// to produce a candidate row-locator set. Returns `None` for query
9230/// shapes the MVP doesn't accelerate (`Not` / `Phrase` — both bail to
9231/// full scan since their semantics need either complementation across
9232/// the whole row set or positional verification beyond what the
9233/// posting list carries).
9234///
9235/// Candidate sets are over-approximate — the caller re-applies the
9236/// full `@@` predicate per row, so reporting "row was in some
9237/// posting list" without verifying positions / weights stays correct.
9238fn gin_query_candidates(
9239    idx: &spg_storage::Index,
9240    query: &spg_storage::TsQueryAst,
9241) -> Option<Vec<spg_storage::RowLocator>> {
9242    use spg_storage::TsQueryAst;
9243    match query {
9244        TsQueryAst::Term { word, .. } => {
9245            let mut v: Vec<spg_storage::RowLocator> = idx.gin_lookup_word(word).to_vec();
9246            v.sort_by_key(locator_sort_key);
9247            v.dedup_by_key(|l| locator_sort_key(l));
9248            Some(v)
9249        }
9250        TsQueryAst::And(l, r) => {
9251            let mut left = gin_query_candidates(idx, l)?;
9252            let mut right = gin_query_candidates(idx, r)?;
9253            left.sort_by_key(locator_sort_key);
9254            right.sort_by_key(locator_sort_key);
9255            // Sorted-merge intersection.
9256            let mut out: Vec<spg_storage::RowLocator> = Vec::new();
9257            let (mut i, mut j) = (0usize, 0usize);
9258            while i < left.len() && j < right.len() {
9259                let lk = locator_sort_key(&left[i]);
9260                let rk = locator_sort_key(&right[j]);
9261                match lk.cmp(&rk) {
9262                    core::cmp::Ordering::Less => i += 1,
9263                    core::cmp::Ordering::Greater => j += 1,
9264                    core::cmp::Ordering::Equal => {
9265                        out.push(left[i]);
9266                        i += 1;
9267                        j += 1;
9268                    }
9269                }
9270            }
9271            Some(out)
9272        }
9273        TsQueryAst::Or(l, r) => {
9274            let mut out = gin_query_candidates(idx, l)?;
9275            out.extend(gin_query_candidates(idx, r)?);
9276            out.sort_by_key(locator_sort_key);
9277            out.dedup_by_key(|l| locator_sort_key(l));
9278            Some(out)
9279        }
9280        // Not / Phrase bail to full scan in the MVP. Not needs
9281        // complementation against the whole row set (not represented
9282        // in the posting-list view); Phrase needs positional
9283        // verification beyond what `word → rows` carries.
9284        TsQueryAst::Not(_) | TsQueryAst::Phrase { .. } => None,
9285    }
9286}
9287
9288/// v7.12.3 — total ordering on `RowLocator` for sort/dedup purposes
9289/// inside the GIN intersection / union loops. Hot rows order by their
9290/// row index; Cold rows order after all Hot rows, then by
9291/// `(segment_id, the cold sub-key)`.
9292fn locator_sort_key(l: &spg_storage::RowLocator) -> (u8, u64, u64) {
9293    match *l {
9294        spg_storage::RowLocator::Hot(i) => (0, i as u64, 0),
9295        spg_storage::RowLocator::Cold {
9296            segment_id,
9297            page_offset,
9298        } => (1, u64::from(segment_id), u64::from(page_offset)),
9299    }
9300}
9301
9302/// v5.2.3: extract `(column_position, IndexKey)` when `where_expr`
9303/// is a simple `col = literal` predicate suitable for a `BTree` index
9304/// seek. Used by `exec_update_cancel` / `exec_delete_cancel` to
9305/// decide whether a write touches a cold-tier row (which requires
9306/// promote-on-write / shadow-on-delete) before falling through to
9307/// the hot-tier row walk.
9308///
9309/// Returns `None` for any predicate shape the planner can't push
9310/// down to an index seek — complex WHERE clauses always take the
9311/// hot-only path (cold rows are immutable to non-indexed writes
9312/// until a future scan-fanout sub-version).
9313fn try_pk_predicate(
9314    where_expr: &Expr,
9315    schema_cols: &[ColumnSchema],
9316    table_alias: &str,
9317) -> Option<(usize, IndexKey)> {
9318    let Expr::Binary {
9319        lhs,
9320        op: BinOp::Eq,
9321        rhs,
9322    } = where_expr
9323    else {
9324        return None;
9325    };
9326    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
9327        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
9328    let key = IndexKey::from_value(&value)?;
9329    Some((col_pos, key))
9330}
9331
9332fn resolve_col_literal_pair(
9333    col_side: &Expr,
9334    lit_side: &Expr,
9335    schema_cols: &[ColumnSchema],
9336    table_alias: &str,
9337) -> Option<(usize, Value)> {
9338    let Expr::Column(c) = col_side else {
9339        return None;
9340    };
9341    if let Some(q) = &c.qualifier
9342        && q != table_alias
9343    {
9344        return None;
9345    }
9346    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
9347    let Expr::Literal(l) = lit_side else {
9348        return None;
9349    };
9350    let v = match l {
9351        Literal::Integer(n) => {
9352            if let Ok(small) = i32::try_from(*n) {
9353                Value::Int(small)
9354            } else {
9355                Value::BigInt(*n)
9356            }
9357        }
9358        Literal::Float(x) => Value::Float(*x),
9359        Literal::String(s) => Value::Text(s.clone()),
9360        Literal::Bool(b) => Value::Bool(*b),
9361        Literal::Null => Value::Null,
9362        // Vector, array and Interval literals can't be used as B-tree
9363        // index keys. Tell the planner to fall back to full-scan.
9364        Literal::Vector(_)
9365        | Literal::Interval { .. }
9366        | Literal::TextArray(_)
9367        | Literal::IntArray(_)
9368        | Literal::BigIntArray(_) => return None,
9369    };
9370    Some((pos, v))
9371}
9372
9373/// Find the schema entry that a SELECT-list `Expr::Column` refers to.
9374/// Mirrors `resolve_column` in `eval.rs`, but returns a proper
9375/// `EngineError` so the projection-build path keeps `UnknownQualifier`
9376/// vs `ColumnNotFound` distinct.
9377fn resolve_projection_column<'a>(
9378    c: &ColumnName,
9379    schema_cols: &'a [ColumnSchema],
9380    table_alias: &str,
9381) -> Result<&'a ColumnSchema, EngineError> {
9382    if let Some(q) = &c.qualifier {
9383        let composite = alloc::format!("{q}.{name}", name = c.name);
9384        if let Some(s) = schema_cols.iter().find(|s| s.name == composite) {
9385            return Ok(s);
9386        }
9387        // Single-table case: the qualifier may equal the active alias —
9388        // then look for the bare column name.
9389        if q == table_alias
9390            && let Some(s) = schema_cols.iter().find(|s| s.name == c.name)
9391        {
9392            return Ok(s);
9393        }
9394        // For multi-table schemas the qualifier is unknown only if no
9395        // column bears the "<q>." prefix. For single-table, the alias
9396        // mismatch alone is enough.
9397        let prefix = alloc::format!("{q}.");
9398        let qualifier_known =
9399            q == table_alias || schema_cols.iter().any(|s| s.name.starts_with(&prefix));
9400        if !qualifier_known {
9401            return Err(EngineError::Eval(EvalError::UnknownQualifier {
9402                qualifier: q.clone(),
9403            }));
9404        }
9405        return Err(EngineError::Eval(EvalError::ColumnNotFound {
9406            name: c.name.clone(),
9407        }));
9408    }
9409    if let Some(s) = schema_cols.iter().find(|s| s.name == c.name) {
9410        return Ok(s);
9411    }
9412    let suffix = alloc::format!(".{name}", name = c.name);
9413    let mut matches = schema_cols.iter().filter(|s| s.name.ends_with(&suffix));
9414    let first = matches.next();
9415    let extra = matches.next();
9416    match (first, extra) {
9417        (Some(s), None) => Ok(s),
9418        (Some(_), Some(_)) => Err(EngineError::Eval(EvalError::TypeMismatch {
9419            detail: alloc::format!("ambiguous column reference: {}", c.name),
9420        })),
9421        _ => Err(EngineError::Eval(EvalError::ColumnNotFound {
9422            name: c.name.clone(),
9423        })),
9424    }
9425}
9426
9427fn build_projection(
9428    items: &[SelectItem],
9429    schema_cols: &[ColumnSchema],
9430    table_alias: &str,
9431) -> Result<Vec<ProjectedItem>, EngineError> {
9432    let mut out = Vec::new();
9433    for item in items {
9434        match item {
9435            SelectItem::Wildcard => {
9436                for col in schema_cols {
9437                    out.push(ProjectedItem {
9438                        expr: Expr::Column(ColumnName {
9439                            qualifier: None,
9440                            name: col.name.clone(),
9441                        }),
9442                        output_name: col.name.clone(),
9443                        ty: col.ty,
9444                        nullable: col.nullable,
9445                    });
9446                }
9447            }
9448            SelectItem::Expr { expr, alias } => {
9449                // Plain column ref keeps full schema info (real type +
9450                // nullability). For compound expressions try the
9451                // describe-side function-return-type table first
9452                // (e.g. `SELECT now()` → Timestamptz, `SELECT
9453                // concat(…)` → Text). Falls back to nullable Text
9454                // for shapes the describe path can't resolve.
9455                if let Expr::Column(c) = expr {
9456                    let sch = resolve_projection_column(c, schema_cols, table_alias)?;
9457                    let output_name = alias.clone().unwrap_or_else(|| c.name.clone());
9458                    out.push(ProjectedItem {
9459                        expr: expr.clone(),
9460                        output_name,
9461                        ty: sch.ty,
9462                        nullable: sch.nullable,
9463                    });
9464                } else if let Some(shape) = describe::describe_expr(expr, schema_cols) {
9465                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
9466                    out.push(ProjectedItem {
9467                        expr: expr.clone(),
9468                        output_name,
9469                        ty: shape.ty,
9470                        nullable: shape.nullable,
9471                    });
9472                } else {
9473                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
9474                    out.push(ProjectedItem {
9475                        expr: expr.clone(),
9476                        output_name,
9477                        ty: DataType::Text,
9478                        nullable: true,
9479                    });
9480                }
9481            }
9482        }
9483    }
9484    Ok(out)
9485}
9486
9487/// Promote an integer to a NUMERIC value at the requested scale.
9488/// Rejects values that, after scaling, would overflow the column's
9489/// precision budget.
9490fn numeric_from_integer(
9491    n: i128,
9492    precision: u8,
9493    scale: u8,
9494    col_name: &str,
9495) -> Result<Value, EngineError> {
9496    let factor = pow10_i128(scale);
9497    let scaled = n.checked_mul(factor).ok_or_else(|| {
9498        EngineError::Unsupported(alloc::format!(
9499            "integer overflow scaling value for column `{col_name}` to scale {scale}"
9500        ))
9501    })?;
9502    check_precision(scaled, precision, col_name)?;
9503    Ok(Value::Numeric { scaled, scale })
9504}
9505
9506/// Float → NUMERIC. Uses round-half-away-from-zero on `x * 10^scale`,
9507/// then verifies the result fits the column's precision.
9508#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
9509fn numeric_from_float(
9510    x: f64,
9511    precision: u8,
9512    scale: u8,
9513    col_name: &str,
9514) -> Result<Value, EngineError> {
9515    if !x.is_finite() {
9516        return Err(EngineError::Unsupported(alloc::format!(
9517            "cannot store non-finite float in NUMERIC column `{col_name}`"
9518        )));
9519    }
9520    let mut factor = 1.0_f64;
9521    for _ in 0..scale {
9522        factor *= 10.0;
9523    }
9524    // Round half-away-from-zero by biasing then casting (`as i128`
9525    // truncates toward zero, so the bias + truncation gives the
9526    // desired rounding). `f64::floor` / `ceil` live in std; we don't
9527    // need them — the cast handles the truncation step.
9528    let shifted = x * factor;
9529    let biased = if shifted >= 0.0 {
9530        shifted + 0.5
9531    } else {
9532        shifted - 0.5
9533    };
9534    // Range-check before casting back to i128 — the cast itself is
9535    // saturating in Rust, which would silently truncate huge inputs.
9536    if !(-1e38..=1e38).contains(&biased) {
9537        return Err(EngineError::Unsupported(alloc::format!(
9538            "value {x} overflows NUMERIC range for column `{col_name}`"
9539        )));
9540    }
9541    let scaled = biased as i128;
9542    check_precision(scaled, precision, col_name)?;
9543    Ok(Value::Numeric { scaled, scale })
9544}
9545
9546/// v7.17.0 Phase 3.P0-67 — parse PG-canonical decimal text into
9547/// `(mantissa: i128, source_scale: u8)`. Accepts optional sign,
9548/// optional integer part, optional fractional part. Rejects
9549/// scientific notation, embedded spaces, locale-specific
9550/// thousand separators. Returns None on bad input — coerce_value
9551/// turns that into a TypeMismatch error.
9552fn parse_numeric_text(s: &str) -> Option<(i128, u8)> {
9553    let s = s.trim();
9554    if s.is_empty() {
9555        return None;
9556    }
9557    let (negative, rest) = match s.as_bytes()[0] {
9558        b'-' => (true, &s[1..]),
9559        b'+' => (false, &s[1..]),
9560        _ => (false, s),
9561    };
9562    if rest.is_empty() {
9563        return None;
9564    }
9565    // Reject scientific notation — bigdecimal collapses it before
9566    // hitting the wire, and we want a clear error if a stray `e`
9567    // sneaks in.
9568    if rest.bytes().any(|b| b == b'e' || b == b'E') {
9569        return None;
9570    }
9571    let (int_part, frac_part) = match rest.find('.') {
9572        Some(idx) => (&rest[..idx], &rest[idx + 1..]),
9573        None => (rest, ""),
9574    };
9575    if int_part.is_empty() && frac_part.is_empty() {
9576        return None;
9577    }
9578    if int_part.bytes().any(|b| !b.is_ascii_digit()) {
9579        return None;
9580    }
9581    if frac_part.bytes().any(|b| !b.is_ascii_digit()) {
9582        return None;
9583    }
9584    let scale_u32 = u32::try_from(frac_part.len()).ok()?;
9585    if scale_u32 > u32::from(u8::MAX) {
9586        return None;
9587    }
9588    let scale = scale_u32 as u8;
9589    let mut digits = alloc::string::String::with_capacity(int_part.len() + frac_part.len() + 1);
9590    if negative {
9591        digits.push('-');
9592    }
9593    digits.push_str(int_part);
9594    digits.push_str(frac_part);
9595    // Strip a leading "+0..0" so parse doesn't choke on "00" etc.
9596    let digits = if digits == "-" {
9597        return None;
9598    } else if digits.is_empty() {
9599        "0"
9600    } else {
9601        digits.as_str()
9602    };
9603    let mantissa: i128 = digits.parse().ok()?;
9604    Some((mantissa, scale))
9605}
9606
9607/// Move a Numeric value from `src_scale` to `dst_scale`. Going up
9608/// multiplies by 10; going down rounds half-away-from-zero.
9609fn numeric_rescale(
9610    scaled: i128,
9611    src_scale: u8,
9612    precision: u8,
9613    dst_scale: u8,
9614    col_name: &str,
9615) -> Result<Value, EngineError> {
9616    let new_scaled = if dst_scale >= src_scale {
9617        let bump = pow10_i128(dst_scale - src_scale);
9618        scaled.checked_mul(bump).ok_or_else(|| {
9619            EngineError::Unsupported(alloc::format!(
9620                "overflow rescaling NUMERIC for column `{col_name}`"
9621            ))
9622        })?
9623    } else {
9624        let drop = pow10_i128(src_scale - dst_scale);
9625        let half = drop / 2;
9626        if scaled >= 0 {
9627            (scaled + half) / drop
9628        } else {
9629            (scaled - half) / drop
9630        }
9631    };
9632    check_precision(new_scaled, precision, col_name)?;
9633    Ok(Value::Numeric {
9634        scaled: new_scaled,
9635        scale: dst_scale,
9636    })
9637}
9638
9639/// Drop the fractional part of a scaled integer, returning the integer
9640/// portion (toward zero). Used for NUMERIC → INT casts.
9641const fn numeric_truncate_to_integer(scaled: i128, scale: u8) -> i128 {
9642    if scale == 0 {
9643        return scaled;
9644    }
9645    let factor = pow10_i128_const(scale);
9646    scaled / factor
9647}
9648
9649/// Verify a scaled NUMERIC value fits the column's declared precision.
9650/// `precision == 0` is the "unconstrained" form (bare `NUMERIC`); we
9651/// skip the check there.
9652fn check_precision(scaled: i128, precision: u8, col_name: &str) -> Result<(), EngineError> {
9653    if precision == 0 {
9654        return Ok(());
9655    }
9656    let limit = pow10_i128(precision);
9657    if scaled.unsigned_abs() >= limit.unsigned_abs() {
9658        return Err(EngineError::Unsupported(alloc::format!(
9659            "NUMERIC value exceeds precision {precision} for column `{col_name}`"
9660        )));
9661    }
9662    Ok(())
9663}
9664
9665const fn pow10_i128_const(p: u8) -> i128 {
9666    let mut acc: i128 = 1;
9667    let mut i = 0;
9668    while i < p {
9669        acc *= 10;
9670        i += 1;
9671    }
9672    acc
9673}
9674
9675fn pow10_i128(p: u8) -> i128 {
9676    pow10_i128_const(p)
9677}
9678
9679/// Walk a parsed `Statement`, swapping any `NOW()` /
9680/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()` function calls for a
9681/// literal cast that wraps the engine's per-statement clock reading.
9682/// When `now_micros` is `None`, calls stay as-is and surface as
9683/// `unknown function` at eval time — keeps the error path explicit.
9684/// v4.10: pre-walk the WHERE / projection / etc. of a SELECT and
9685/// replace every subquery node with a materialised literal. SPG
9686/// only supports uncorrelated subqueries — the inner SELECT does
9687/// not see outer-row columns, so the result is the same for every
9688/// outer row and can be evaluated once.
9689///
9690/// Returns the rewritten statement; the caller passes this to the
9691/// regular row-loop executor which no longer sees Subquery nodes
9692/// in its tree.
9693impl Engine {
9694    /// v4.12 window executor. Implements `ROW_NUMBER` / `RANK` /
9695    /// `DENSE_RANK` and the partition-aware aggregates `SUM` /
9696    /// `AVG` / `COUNT` / `MIN` / `MAX`. The plan is:
9697    /// 1. Apply the WHERE filter.
9698    /// 2. For each unique `WindowFunction` node in the projection,
9699    ///    partition + sort, compute the per-row value.
9700    /// 3. Append the window values as synthetic columns (`__win_N`)
9701    ///    to the row schema.
9702    /// 4. Rewrite the projection to read those columns.
9703    /// 5. Hand off to the regular project / ORDER BY / LIMIT pipe.
9704    #[allow(
9705        clippy::too_many_lines,
9706        clippy::type_complexity,
9707        clippy::needless_range_loop
9708    )] // window-eval is one cohesive pipe; splitting fragments
9709    fn exec_select_with_window(
9710        &self,
9711        stmt: &SelectStatement,
9712        cancel: CancelToken<'_>,
9713    ) -> Result<QueryResult, EngineError> {
9714        let from = stmt.from.as_ref().ok_or_else(|| {
9715            EngineError::Unsupported("window functions require a FROM clause".into())
9716        })?;
9717        // v7.17.0 Phase 3.P0-43 — JOIN + window functions. Phase
9718        // 3.6 rejected this combination outright ("queued for
9719        // v5.x"); P0-43 materialises the join + WHERE through the
9720        // existing nested-loop helper and runs the window pipeline
9721        // on the joined row set with the combined `alias.col`
9722        // schema. The window expressions resolve through the
9723        // qualifier-aware column resolver same as the aggregate /
9724        // projection paths on JOIN.
9725        let (schema_cols_owned, alias_opt): (Vec<ColumnSchema>, Option<&str>);
9726        let filtered: Vec<Row>;
9727        if from.joins.is_empty() {
9728            let primary = &from.primary;
9729            let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
9730                StorageError::TableNotFound {
9731                    name: primary.name.clone(),
9732                }
9733            })?;
9734            let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
9735            schema_cols_owned = table.schema().columns.clone();
9736            alias_opt = Some(alias);
9737            // Materialise WHERE-filtered rows owned so the JOIN
9738            // and single-table paths share a single downstream
9739            // shape. The clone is cheap relative to the window
9740            // computation that follows.
9741            let ctx = self.ev_ctx(&schema_cols_owned, alias_opt);
9742            let mut owned: Vec<Row> = Vec::new();
9743            for (i, row) in table.rows().iter().enumerate() {
9744                if i.is_multiple_of(256) {
9745                    cancel.check()?;
9746                }
9747                if let Some(w) = &stmt.where_ {
9748                    let cond = eval::eval_expr(w, row, &ctx)?;
9749                    if !matches!(cond, Value::Bool(true)) {
9750                        continue;
9751                    }
9752                }
9753                owned.push(row.clone());
9754            }
9755            filtered = owned;
9756        } else {
9757            let (combined_schema, rows) =
9758                self.build_joined_filtered_rows(from, stmt.where_.as_ref(), cancel)?;
9759            schema_cols_owned = combined_schema;
9760            alias_opt = None;
9761            filtered = rows;
9762        }
9763        let schema_cols = &schema_cols_owned;
9764        let ctx = self.ev_ctx(schema_cols, alias_opt);
9765        let alias = alias_opt.unwrap_or("");
9766        let n_rows = filtered.len();
9767        // Borrow refs into the owned row vec once so the downstream
9768        // `compute_window_partition` call (which takes `&[&Row]`) and
9769        // the per-row eval loops share a single backing buffer.
9770        let filtered_refs: Vec<&Row> = filtered.iter().collect();
9771
9772        // 2) Collect unique window function nodes from projection.
9773        let mut window_nodes: Vec<Expr> = Vec::new();
9774        for item in &stmt.items {
9775            if let SelectItem::Expr { expr, .. } = item {
9776                collect_window_nodes(expr, &mut window_nodes);
9777            }
9778        }
9779
9780        // 3) For each window, compute per-row value.
9781        // Index: same order as window_nodes; for row i, win_vals[w][i].
9782        let mut win_vals: Vec<Vec<Value>> = Vec::with_capacity(window_nodes.len());
9783        for wnode in &window_nodes {
9784            let Expr::WindowFunction {
9785                name,
9786                args,
9787                partition_by,
9788                order_by,
9789                frame,
9790                null_treatment,
9791            } = wnode
9792            else {
9793                unreachable!("collect_window_nodes pushes only WindowFunction");
9794            };
9795            // Compute (partition_key, order_key, original_index) for each row.
9796            let mut indexed: Vec<(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)> =
9797                Vec::with_capacity(n_rows);
9798            for (i, row) in filtered.iter().enumerate() {
9799                let pkey: Vec<Value> = partition_by
9800                    .iter()
9801                    .map(|p| eval::eval_expr(p, row, &ctx))
9802                    .collect::<Result<_, _>>()?;
9803                let okey: Vec<(Value, bool, Option<bool>)> = order_by
9804                    .iter()
9805                    .map(|(e, desc, nf)| eval::eval_expr(e, row, &ctx).map(|v| (v, *desc, *nf)))
9806                    .collect::<Result<_, _>>()?;
9807                indexed.push((pkey, okey, i));
9808            }
9809            // Sort by (partition_key, order_key). Partition key uses
9810            // a stable encoded form; order key respects ASC/DESC.
9811            indexed.sort_by(|a, b| {
9812                let p_cmp = partition_key_cmp(&a.0, &b.0);
9813                if p_cmp != core::cmp::Ordering::Equal {
9814                    return p_cmp;
9815                }
9816                order_key_cmp(&a.1, &b.1)
9817            });
9818            // Per-partition compute.
9819            let mut out_vals: Vec<Value> = alloc::vec![Value::Null; n_rows];
9820            let mut p_start = 0;
9821            while p_start < indexed.len() {
9822                let mut p_end = p_start + 1;
9823                while p_end < indexed.len()
9824                    && partition_key_cmp(&indexed[p_start].0, &indexed[p_end].0)
9825                        == core::cmp::Ordering::Equal
9826                {
9827                    p_end += 1;
9828                }
9829                // Compute the function within this partition slice.
9830                compute_window_partition(
9831                    name,
9832                    args,
9833                    !order_by.is_empty(),
9834                    frame.as_ref(),
9835                    *null_treatment,
9836                    &indexed[p_start..p_end],
9837                    &filtered_refs,
9838                    &ctx,
9839                    &mut out_vals,
9840                )?;
9841                p_start = p_end;
9842            }
9843            win_vals.push(out_vals);
9844        }
9845
9846        // 4) Build extended schema: original columns + synthetic.
9847        let mut ext_cols = schema_cols.clone();
9848        for i in 0..window_nodes.len() {
9849            ext_cols.push(ColumnSchema::new(
9850                alloc::format!("__win_{i}"),
9851                DataType::Text, // type doesn't matter for projection eval
9852                true,
9853            ));
9854        }
9855        // 5) Build extended rows: each row gets its window values appended.
9856        let mut ext_rows: Vec<Row> = Vec::with_capacity(n_rows);
9857        for i in 0..n_rows {
9858            let mut values = filtered[i].values.clone();
9859            for w in 0..window_nodes.len() {
9860                values.push(win_vals[w][i].clone());
9861            }
9862            ext_rows.push(Row::new(values));
9863        }
9864        // 6) Rewrite the projection: WindowFunction nodes → Column(__win_N).
9865        let mut rewritten_items: Vec<SelectItem> = Vec::with_capacity(stmt.items.len());
9866        for item in &stmt.items {
9867            let new_item = match item {
9868                SelectItem::Wildcard => SelectItem::Wildcard,
9869                SelectItem::Expr { expr, alias } => {
9870                    let mut e = expr.clone();
9871                    rewrite_window_to_columns(&mut e, &window_nodes);
9872                    SelectItem::Expr {
9873                        expr: e,
9874                        alias: alias.clone(),
9875                    }
9876                }
9877            };
9878            rewritten_items.push(new_item);
9879        }
9880
9881        // 7) Project into final rows. JOIN case uses None so the
9882        // qualifier check in `resolve_column` falls through to the
9883        // composite `alias.col` schema lookup; single-table case
9884        // keeps the bare alias so `bare_col` resolution still
9885        // works for the projection's per-row column references.
9886        let ext_ctx = EvalContext::new(&ext_cols, alias_opt);
9887        let projection = build_projection(&rewritten_items, &ext_cols, alias)?;
9888        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(n_rows);
9889        for (i, row) in ext_rows.iter().enumerate() {
9890            if i.is_multiple_of(256) {
9891                cancel.check()?;
9892            }
9893            let mut values = Vec::with_capacity(projection.len());
9894            for p in &projection {
9895                values.push(eval::eval_expr(&p.expr, row, &ext_ctx)?);
9896            }
9897            let order_keys = if stmt.order_by.is_empty() {
9898                Vec::new()
9899            } else {
9900                let mut keys = Vec::with_capacity(stmt.order_by.len());
9901                for o in &stmt.order_by {
9902                    let mut e = o.expr.clone();
9903                    rewrite_window_to_columns(&mut e, &window_nodes);
9904                    let key = eval::eval_expr(&e, row, &ext_ctx)?;
9905                    keys.push(value_to_order_key(&key)?);
9906                }
9907                keys
9908            };
9909            tagged.push((order_keys, Row::new(values)));
9910        }
9911        // ORDER BY + LIMIT/OFFSET on the projected rows.
9912        if !stmt.order_by.is_empty() {
9913            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
9914            sort_by_keys(&mut tagged, &descs);
9915        }
9916        let mut out_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
9917        apply_offset_and_limit(&mut out_rows, stmt.offset_literal(), stmt.limit_literal());
9918        let final_cols: Vec<ColumnSchema> = projection
9919            .into_iter()
9920            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
9921            .collect();
9922        Ok(QueryResult::Rows {
9923            columns: final_cols,
9924            rows: out_rows,
9925        })
9926    }
9927
9928    /// v4.11: materialise each CTE into a temp table inside a
9929    /// cloned catalog, then run the body SELECT against a fresh
9930    /// engine instance that owns the enriched catalog. The clone
9931    /// is moderately expensive — only paid by CTE-bearing queries.
9932    /// Subqueries inside CTE bodies / the main body resolve as
9933    /// usual; `clock_fn` is propagated so `NOW()` lines up.
9934    /// v7.16.2 — mailrs round-10 A.3. Materialise the
9935    /// `information_schema.*` / `pg_catalog.*` virtual views
9936    /// the SELECT references, then re-execute the SELECT
9937    /// against an enriched catalog where those views are real
9938    /// tables. Same pattern as `exec_with_ctes`. The temp
9939    /// engine carries `meta_views_materialised = true` so its
9940    /// own meta-dispatch short-circuits — without that we'd
9941    /// infinite-recurse since the temp catalog's view name
9942    /// still starts with `__spg_info_` and re-triggers the
9943    /// check.
9944    fn exec_select_with_meta_views(
9945        &self,
9946        stmt: &SelectStatement,
9947        cancel: CancelToken<'_>,
9948    ) -> Result<QueryResult, EngineError> {
9949        let mut needed: alloc::collections::BTreeSet<String> = alloc::collections::BTreeSet::new();
9950        collect_meta_view_names(stmt, &mut needed);
9951        let mut catalog = self.active_catalog().clone();
9952        for view in &needed {
9953            if catalog.get(view).is_some() {
9954                continue;
9955            }
9956            match view.as_str() {
9957                "__spg_info_columns" => {
9958                    let (schema, rows) = synth_information_schema_columns(self.active_catalog());
9959                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9960                }
9961                "__spg_info_tables" => {
9962                    let (schema, rows) = synth_information_schema_tables(self.active_catalog());
9963                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9964                }
9965                "__spg_pg_class" => {
9966                    let (schema, rows) = synth_pg_class(self.active_catalog());
9967                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9968                }
9969                "__spg_pg_attribute" => {
9970                    let (schema, rows) = synth_pg_attribute(self.active_catalog());
9971                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9972                }
9973                // v7.17.0 Phase 3.P0-50 — pg_catalog.pg_type for
9974                // sqlx / SQLAlchemy / Diesel / pgAdmin lookups.
9975                "__spg_pg_type" => {
9976                    let (schema, rows) = synth_pg_type(self.active_catalog());
9977                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9978                }
9979                // v7.17.0 Phase 3.P0-51 — pg_catalog.pg_proc for
9980                // function-name introspection (ORM / pgAdmin).
9981                "__spg_pg_proc" => {
9982                    let (schema, rows) = synth_pg_proc(self.active_catalog());
9983                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9984                }
9985                // v7.24 (round-16 D) — pg_catalog.pg_trigger. The
9986                // round-16 "why doesn't prod fire the trigger"
9987                // question was unanswerable because triggers had NO
9988                // introspection surface; tgname/tgenabled plus the
9989                // pragmatic relname/timing/events/function columns
9990                // make "is it registered and enabled" a one-liner.
9991                "__spg_pg_trigger" => {
9992                    let (schema, rows) = synth_pg_trigger(self.active_catalog());
9993                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9994                }
9995                // v7.17.0 Phase 3.P0-52 — pg_catalog.pg_namespace
9996                // (schema list for admin tools' tree views).
9997                "__spg_pg_namespace" => {
9998                    let (schema, rows) = synth_pg_namespace(self.active_catalog());
9999                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10000                }
10001                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_indexes view
10002                // for pgAdmin / DataGrip "indexes per table" listings.
10003                "__spg_pg_indexes" => {
10004                    let (schema, rows) = synth_pg_indexes(self.active_catalog());
10005                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10006                }
10007                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_index (raw)
10008                // for index introspection by ORM compilers.
10009                "__spg_pg_index" => {
10010                    let (schema, rows) = synth_pg_index_raw(self.active_catalog());
10011                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10012                }
10013                // v7.17.0 Phase 3.P0-54 — pg_catalog.pg_constraint
10014                // for FK / UNIQUE / PK / CHECK introspection.
10015                "__spg_pg_constraint" => {
10016                    let (schema, rows) = synth_pg_constraint(self.active_catalog());
10017                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10018                }
10019                // v7.17.0 Phase 3.P0-55 — pg_catalog.pg_database /
10020                // pg_roles / pg_user. SPG is single-database so
10021                // pg_database surfaces just `postgres`; pg_roles
10022                // / pg_user walk the engine's UserStore.
10023                "__spg_pg_database" => {
10024                    let (schema, rows) = synth_pg_database(self.active_catalog());
10025                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10026                }
10027                "__spg_pg_roles" | "__spg_pg_user" => {
10028                    let (schema, rows) = synth_pg_roles(self);
10029                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10030                }
10031                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_views. PG's
10032                // pg_views surfaces every CREATE VIEW result; SPG
10033                // ships one row per declared view from the catalog.
10034                "__spg_pg_views" => {
10035                    let (schema, rows) = synth_pg_views(self.active_catalog());
10036                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10037                }
10038                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_matviews.
10039                // SPG has no materialised view surface yet so the
10040                // table shares pg_views's schema but stays empty.
10041                "__spg_pg_matviews" => {
10042                    let (schema, _) = synth_pg_views(self.active_catalog());
10043                    materialise_meta_view(&mut catalog, view, schema, Vec::new())?;
10044                }
10045                // pg_catalog.pg_extension — native capability list
10046                // (mailrs embed round-12).
10047                "__spg_pg_extension" => {
10048                    let (schema, rows) = synth_pg_extension();
10049                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10050                }
10051                // v7.17.0 Phase 3.P0-57 — pg_catalog.pg_settings.
10052                "__spg_pg_settings" => {
10053                    let (schema, rows) = synth_pg_settings(self);
10054                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10055                }
10056                // v7.17.0 Phase 3.P0-63 — information_schema.KEY_COLUMN_USAGE.
10057                "__spg_info_key_column_usage" => {
10058                    let (schema, rows) = synth_info_key_column_usage(self.active_catalog());
10059                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10060                }
10061                // v7.17.0 Phase 3.P0-64 — information_schema.REFERENTIAL_CONSTRAINTS.
10062                "__spg_info_referential_constraints" => {
10063                    let (schema, rows) = synth_info_referential_constraints(self.active_catalog());
10064                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10065                }
10066                // v7.17.0 Phase 3.P0-64 — information_schema.STATISTICS.
10067                "__spg_info_statistics" => {
10068                    let (schema, rows) = synth_info_statistics(self.active_catalog());
10069                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10070                }
10071                // v7.17.0 Phase 3.P0-64 — information_schema.ROUTINES.
10072                "__spg_info_routines" => {
10073                    let (schema, rows) = synth_info_routines();
10074                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10075                }
10076                // v7.17.0 Phase 3.P0-65 — mysql.user / mysql.db.
10077                "__spg_mysql_user" => {
10078                    let (schema, rows) = synth_mysql_user(self);
10079                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10080                }
10081                "__spg_mysql_db" => {
10082                    let (schema, rows) = synth_mysql_db();
10083                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10084                }
10085                _ => {
10086                    return Err(EngineError::Unsupported(alloc::format!(
10087                        "meta view {view:?} is not yet materialisable; \
10088                         v7.16.2 covers information_schema.columns / .tables \
10089                         and pg_catalog.pg_class / pg_attribute; \
10090                         v7.17.0 P0-50..P0-57 add pg_type / pg_proc / pg_namespace / \
10091                         pg_indexes / pg_index / pg_constraint / pg_database / pg_roles / \
10092                         pg_user / pg_views / pg_matviews / pg_settings"
10093                    )));
10094                }
10095            }
10096        }
10097        let mut temp = Engine::restore(catalog);
10098        if let Some(c) = self.clock {
10099            temp = temp.with_clock(c);
10100        }
10101        if let Some(f) = self.salt_fn {
10102            temp = temp.with_salt_fn(f);
10103        }
10104        temp.meta_views_materialised = true;
10105        temp.exec_select_cancel(stmt, cancel)
10106    }
10107
10108    fn exec_with_ctes(
10109        &self,
10110        stmt: &SelectStatement,
10111        cancel: CancelToken<'_>,
10112    ) -> Result<QueryResult, EngineError> {
10113        cancel.check()?;
10114        let mut catalog = self.active_catalog().clone();
10115        for cte in &stmt.ctes {
10116            if catalog.get(&cte.name).is_some() {
10117                return Err(EngineError::Unsupported(alloc::format!(
10118                    "CTE name {:?} shadows an existing table; rename the CTE",
10119                    cte.name
10120                )));
10121            }
10122            let (columns, rows) = if cte.recursive {
10123                self.materialise_recursive_cte(cte, &catalog, cancel)?
10124            } else {
10125                let body_result = self.exec_select_cancel(&cte.body, cancel)?;
10126                let QueryResult::Rows { columns, rows } = body_result else {
10127                    return Err(EngineError::Unsupported(alloc::format!(
10128                        "CTE {:?} body did not return rows",
10129                        cte.name
10130                    )));
10131                };
10132                (columns, rows)
10133            };
10134            // v4.22: the projection builder labels any non-column
10135            // expression as Text — including literal SELECT 1.
10136            // Promote each column's type to whatever the rows
10137            // actually carry so the CTE storage table accepts them.
10138            let inferred = infer_column_types(&columns, &rows);
10139            let mut columns = inferred;
10140            // v4.22: apply optional `WITH name(a, b, c)` overrides.
10141            if !cte.column_overrides.is_empty() {
10142                if cte.column_overrides.len() != columns.len() {
10143                    return Err(EngineError::Unsupported(alloc::format!(
10144                        "CTE {:?} column list has {} names but body returns {} columns",
10145                        cte.name,
10146                        cte.column_overrides.len(),
10147                        columns.len()
10148                    )));
10149                }
10150                for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
10151                    col.name.clone_from(name);
10152                }
10153            }
10154            let schema = TableSchema::new(cte.name.clone(), columns);
10155            catalog.create_table(schema).map_err(EngineError::Storage)?;
10156            let table = catalog
10157                .get_mut(&cte.name)
10158                .expect("just-created CTE table must exist");
10159            for row in rows {
10160                table.insert(row).map_err(EngineError::Storage)?;
10161            }
10162        }
10163        // Strip CTEs from the body before running on the temp engine
10164        // so we don't recurse forever.
10165        let mut body = stmt.clone();
10166        body.ctes = Vec::new();
10167        let mut temp = Engine::restore(catalog);
10168        if let Some(c) = self.clock {
10169            temp = temp.with_clock(c);
10170        }
10171        if let Some(f) = self.salt_fn {
10172            temp = temp.with_salt_fn(f);
10173        }
10174        temp.exec_select_cancel(&body, cancel)
10175    }
10176
10177    /// v4.22: materialise a WITH RECURSIVE CTE. The body must be a
10178    /// UNION (or UNION ALL) of an anchor that does not reference
10179    /// the CTE name, and one or more recursive terms that do. The
10180    /// anchor runs first; each subsequent iteration runs the
10181    /// recursive term against a temp catalog where the CTE name is
10182    /// bound to the *previous* iteration's output. Iteration stops
10183    /// when the recursive term yields no rows; UNION (DISTINCT)
10184    /// deduplicates against the accumulated result, UNION ALL does
10185    /// not. A hard cap on total rows prevents runaway queries.
10186    #[allow(clippy::too_many_lines)]
10187    fn materialise_recursive_cte(
10188        &self,
10189        cte: &spg_sql::ast::Cte,
10190        base_catalog: &Catalog,
10191        cancel: CancelToken<'_>,
10192    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
10193        const MAX_TOTAL_ROWS: usize = 1_000_000;
10194        const MAX_ITERATIONS: usize = 100_000;
10195        cancel.check()?;
10196        if cte.body.unions.is_empty() {
10197            return Err(EngineError::Unsupported(alloc::format!(
10198                "WITH RECURSIVE {:?} body must be a UNION of an anchor and a recursive term",
10199                cte.name
10200            )));
10201        }
10202        // Anchor: the body's leading SELECT, with unions stripped.
10203        let mut anchor = cte.body.clone();
10204        let union_terms = core::mem::take(&mut anchor.unions);
10205        anchor.ctes = Vec::new();
10206        // Anchor must not reference the CTE name.
10207        if select_refers_to(&anchor, &cte.name) {
10208            return Err(EngineError::Unsupported(alloc::format!(
10209                "WITH RECURSIVE {:?}: the anchor must not reference the CTE itself",
10210                cte.name
10211            )));
10212        }
10213        let anchor_result = self.exec_select_cancel(&anchor, cancel)?;
10214        let QueryResult::Rows {
10215            columns: anchor_cols,
10216            rows: anchor_rows,
10217        } = anchor_result
10218        else {
10219            return Err(EngineError::Unsupported(alloc::format!(
10220                "WITH RECURSIVE {:?}: anchor did not return rows",
10221                cte.name
10222            )));
10223        };
10224        // The projection builder labels non-column expressions Text;
10225        // refine column types from the anchor's actual values so the
10226        // intermediate iter-catalog tables accept them.
10227        let mut columns = infer_column_types(&anchor_cols, &anchor_rows);
10228        if !cte.column_overrides.is_empty() {
10229            if cte.column_overrides.len() != columns.len() {
10230                return Err(EngineError::Unsupported(alloc::format!(
10231                    "CTE {:?} column list has {} names but anchor returns {} columns",
10232                    cte.name,
10233                    cte.column_overrides.len(),
10234                    columns.len()
10235                )));
10236            }
10237            for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
10238                col.name.clone_from(name);
10239            }
10240        }
10241        let mut all_rows: Vec<Row> = anchor_rows.clone();
10242        let mut working_set: Vec<Row> = anchor_rows;
10243        let mut seen: alloc::collections::BTreeSet<Vec<u8>> = alloc::collections::BTreeSet::new();
10244        // Track at least one "all UNION ALL" flag — if every union
10245        // kind is ALL we skip the dedup step (faster + matches PG).
10246        let all_union_all = union_terms.iter().all(|(k, _)| matches!(k, UnionKind::All));
10247        if !all_union_all {
10248            for r in &all_rows {
10249                seen.insert(encode_row_key(r));
10250            }
10251        }
10252        for iter in 0..MAX_ITERATIONS {
10253            cancel.check()?;
10254            if working_set.is_empty() {
10255                break;
10256            }
10257            // Build a fresh catalog: base + CTE bound to working_set.
10258            let mut iter_catalog = base_catalog.clone();
10259            let schema = TableSchema::new(cte.name.clone(), columns.clone());
10260            iter_catalog
10261                .create_table(schema)
10262                .map_err(EngineError::Storage)?;
10263            {
10264                let table = iter_catalog.get_mut(&cte.name).expect("just-created");
10265                for row in &working_set {
10266                    table.insert(row.clone()).map_err(EngineError::Storage)?;
10267                }
10268            }
10269            let mut iter_engine = Engine::restore(iter_catalog);
10270            if let Some(c) = self.clock {
10271                iter_engine = iter_engine.with_clock(c);
10272            }
10273            if let Some(f) = self.salt_fn {
10274                iter_engine = iter_engine.with_salt_fn(f);
10275            }
10276            // Run each recursive term in sequence and collect new rows.
10277            let mut next_set: Vec<Row> = Vec::new();
10278            for (_, term) in &union_terms {
10279                let mut term = term.clone();
10280                term.ctes = Vec::new();
10281                let r = iter_engine.exec_select_cancel(&term, cancel)?;
10282                let QueryResult::Rows {
10283                    columns: rc,
10284                    rows: rs,
10285                } = r
10286                else {
10287                    return Err(EngineError::Unsupported(alloc::format!(
10288                        "WITH RECURSIVE {:?}: recursive term did not return rows",
10289                        cte.name
10290                    )));
10291                };
10292                if rc.len() != columns.len() {
10293                    return Err(EngineError::Unsupported(alloc::format!(
10294                        "WITH RECURSIVE {:?}: column count of recursive term ({}) does not match anchor ({})",
10295                        cte.name,
10296                        rc.len(),
10297                        columns.len()
10298                    )));
10299                }
10300                for row in rs {
10301                    if !all_union_all {
10302                        let key = encode_row_key(&row);
10303                        if !seen.insert(key) {
10304                            continue;
10305                        }
10306                    }
10307                    next_set.push(row);
10308                }
10309            }
10310            if next_set.is_empty() {
10311                break;
10312            }
10313            all_rows.extend(next_set.iter().cloned());
10314            working_set = next_set;
10315            if all_rows.len() > MAX_TOTAL_ROWS {
10316                return Err(EngineError::Unsupported(alloc::format!(
10317                    "WITH RECURSIVE {:?}: produced more than {MAX_TOTAL_ROWS} rows — likely runaway recursion",
10318                    cte.name
10319                )));
10320            }
10321            if iter + 1 == MAX_ITERATIONS {
10322                return Err(EngineError::Unsupported(alloc::format!(
10323                    "WITH RECURSIVE {:?}: exceeded {MAX_ITERATIONS} iterations",
10324                    cte.name
10325                )));
10326            }
10327        }
10328        Ok((columns, all_rows))
10329    }
10330
10331    fn resolve_select_subqueries(
10332        &self,
10333        stmt: &mut SelectStatement,
10334        cancel: CancelToken<'_>,
10335    ) -> Result<(), EngineError> {
10336        for item in &mut stmt.items {
10337            if let SelectItem::Expr { expr, .. } = item {
10338                self.resolve_expr_subqueries(expr, cancel)?;
10339            }
10340        }
10341        if let Some(w) = &mut stmt.where_ {
10342            self.resolve_expr_subqueries(w, cancel)?;
10343        }
10344        // v7.24.1 — JOIN ON conditions can carry subqueries too;
10345        // they were never walked, so even an UNCORRELATED subquery
10346        // in ON hit "subquery reached row eval".
10347        if let Some(from) = &mut stmt.from {
10348            for j in &mut from.joins {
10349                if let Some(on) = &mut j.on {
10350                    self.resolve_expr_subqueries(on, cancel)?;
10351                }
10352            }
10353        }
10354        if let Some(gs) = &mut stmt.group_by {
10355            for g in gs {
10356                self.resolve_expr_subqueries(g, cancel)?;
10357            }
10358        }
10359        if let Some(h) = &mut stmt.having {
10360            self.resolve_expr_subqueries(h, cancel)?;
10361        }
10362        for o in &mut stmt.order_by {
10363            self.resolve_expr_subqueries(&mut o.expr, cancel)?;
10364        }
10365        for (_, peer) in &mut stmt.unions {
10366            self.resolve_select_subqueries(peer, cancel)?;
10367        }
10368        Ok(())
10369    }
10370
10371    #[allow(clippy::only_used_in_recursion)] // engine handle reads aren't really pure
10372    fn resolve_expr_subqueries(
10373        &self,
10374        e: &mut Expr,
10375        cancel: CancelToken<'_>,
10376    ) -> Result<(), EngineError> {
10377        // Replace-on-this-node cases first.
10378        if let Some(replacement) = self.subquery_replacement(e, cancel)? {
10379            *e = replacement;
10380            return Ok(());
10381        }
10382        match e {
10383            Expr::AggregateOrdered { call, order_by } => {
10384                self.resolve_expr_subqueries(call, cancel)?;
10385                for o in order_by.iter_mut() {
10386                    self.resolve_expr_subqueries(&mut o.expr, cancel)?;
10387                }
10388            }
10389            Expr::Binary { lhs, rhs, .. } => {
10390                self.resolve_expr_subqueries(lhs, cancel)?;
10391                self.resolve_expr_subqueries(rhs, cancel)?;
10392            }
10393            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10394                self.resolve_expr_subqueries(expr, cancel)?;
10395            }
10396            Expr::FunctionCall { args, .. } => {
10397                for a in args {
10398                    self.resolve_expr_subqueries(a, cancel)?;
10399                }
10400            }
10401            Expr::Like { expr, pattern, .. } => {
10402                self.resolve_expr_subqueries(expr, cancel)?;
10403                self.resolve_expr_subqueries(pattern, cancel)?;
10404            }
10405            Expr::Extract { source, .. } => self.resolve_expr_subqueries(source, cancel)?,
10406            // v4.12 window functions — recurse into args + ORDER BY
10407            // + PARTITION BY in case they carry inner subqueries.
10408            Expr::WindowFunction {
10409                args,
10410                partition_by,
10411                order_by,
10412                ..
10413            } => {
10414                for a in args {
10415                    self.resolve_expr_subqueries(a, cancel)?;
10416                }
10417                for p in partition_by {
10418                    self.resolve_expr_subqueries(p, cancel)?;
10419                }
10420                for (e, _, _) in order_by {
10421                    self.resolve_expr_subqueries(e, cancel)?;
10422                }
10423            }
10424            // Subquery nodes are handled in subquery_replacement
10425            // (which returned None — defensive no-op); Literal /
10426            // Column are leaves.
10427            Expr::ScalarSubquery(_)
10428            | Expr::Exists { .. }
10429            | Expr::InSubquery { .. }
10430            | Expr::Literal(_)
10431            | Expr::Placeholder(_)
10432            | Expr::Column(_) => {}
10433            // v7.10.10 — recurse children.
10434            Expr::Array(items) => {
10435                for elem in items {
10436                    self.resolve_expr_subqueries(elem, cancel)?;
10437                }
10438            }
10439            Expr::ArraySubscript { target, index } => {
10440                self.resolve_expr_subqueries(target, cancel)?;
10441                self.resolve_expr_subqueries(index, cancel)?;
10442            }
10443            Expr::AnyAll { expr, array, .. } => {
10444                self.resolve_expr_subqueries(expr, cancel)?;
10445                self.resolve_expr_subqueries(array, cancel)?;
10446            }
10447            Expr::Case {
10448                operand,
10449                branches,
10450                else_branch,
10451            } => {
10452                if let Some(o) = operand {
10453                    self.resolve_expr_subqueries(o, cancel)?;
10454                }
10455                for (w, t) in branches {
10456                    self.resolve_expr_subqueries(w, cancel)?;
10457                    self.resolve_expr_subqueries(t, cancel)?;
10458                }
10459                if let Some(e) = else_branch {
10460                    self.resolve_expr_subqueries(e, cancel)?;
10461                }
10462            }
10463        }
10464        Ok(())
10465    }
10466
10467    /// v4.23: per-row eval that handles correlated subqueries.
10468    /// Equivalent to `eval::eval_expr` when the expression has no
10469    /// subqueries; otherwise clones the expression, substitutes
10470    /// outer-row columns into each surviving subquery node, runs
10471    /// the inner SELECT, and replaces the node with the literal
10472    /// result. Only the WHERE-filter call sites use this path so
10473    /// the uncorrelated fast path is preserved everywhere else.
10474    fn eval_expr_with_correlated(
10475        &self,
10476        expr: &Expr,
10477        row: &Row,
10478        ctx: &EvalContext<'_>,
10479        cancel: CancelToken<'_>,
10480        memo: Option<&mut memoize::MemoizeCache>,
10481    ) -> Result<Value, EngineError> {
10482        if !expr_has_subquery(expr) {
10483            return eval::eval_expr(expr, row, ctx).map_err(EngineError::Eval);
10484        }
10485        let mut e = expr.clone();
10486        self.resolve_correlated_in_expr(&mut e, row, ctx, cancel, memo)?;
10487        eval::eval_expr(&e, row, ctx).map_err(EngineError::Eval)
10488    }
10489
10490    fn resolve_correlated_in_expr(
10491        &self,
10492        e: &mut Expr,
10493        row: &Row,
10494        ctx: &EvalContext<'_>,
10495        cancel: CancelToken<'_>,
10496        mut memo: Option<&mut memoize::MemoizeCache>,
10497    ) -> Result<(), EngineError> {
10498        match e {
10499            Expr::AggregateOrdered { call, order_by } => {
10500                self.resolve_correlated_in_expr(call, row, ctx, cancel, memo.as_deref_mut())?;
10501                for o in order_by.iter_mut() {
10502                    self.resolve_correlated_in_expr(
10503                        &mut o.expr,
10504                        row,
10505                        ctx,
10506                        cancel,
10507                        memo.as_deref_mut(),
10508                    )?;
10509                }
10510            }
10511            Expr::ScalarSubquery(inner) => {
10512                // v6.2.6 — Memoize: build the cache key from the
10513                // pre-substitution subquery repr + the outer row's
10514                // values. Two outer rows with identical correlated
10515                // values hit the same entry.
10516                let cache_key = memo.as_ref().map(|_| memoize::CacheKey {
10517                    subquery_repr: alloc::format!("{}", **inner),
10518                    outer_values: row.values.clone(),
10519                });
10520                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key.as_ref())
10521                    && let Some(cached) = cache.get(k)
10522                {
10523                    *e = value_to_literal_expr(cached)?;
10524                    return Ok(());
10525                }
10526                let mut s = (**inner).clone();
10527                substitute_outer_columns(&mut s, row, ctx);
10528                let r = self.exec_select_cancel(&s, cancel)?;
10529                let QueryResult::Rows { rows, .. } = r else {
10530                    return Err(EngineError::Unsupported(
10531                        "scalar subquery: inner did not return rows".into(),
10532                    ));
10533                };
10534                let value = match rows.as_slice() {
10535                    [] => Value::Null,
10536                    [r0] => r0.values.first().cloned().unwrap_or(Value::Null),
10537                    _ => {
10538                        return Err(EngineError::Unsupported(alloc::format!(
10539                            "scalar subquery returned {} rows; expected 0 or 1",
10540                            rows.len()
10541                        )));
10542                    }
10543                };
10544                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key) {
10545                    cache.insert(k, value.clone());
10546                }
10547                *e = value_to_literal_expr(value)?;
10548            }
10549            Expr::Exists { subquery, negated } => {
10550                let mut s = (**subquery).clone();
10551                substitute_outer_columns(&mut s, row, ctx);
10552                let r = self.exec_select_cancel(&s, cancel)?;
10553                let exists = matches!(r, QueryResult::Rows { rows, .. } if !rows.is_empty());
10554                let bit = if *negated { !exists } else { exists };
10555                *e = Expr::Literal(Literal::Bool(bit));
10556            }
10557            Expr::InSubquery {
10558                expr: lhs,
10559                subquery,
10560                negated,
10561            } => {
10562                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
10563                let lhs_val = eval::eval_expr(lhs, row, ctx).map_err(EngineError::Eval)?;
10564                let mut s = (**subquery).clone();
10565                substitute_outer_columns(&mut s, row, ctx);
10566                let r = self.exec_select_cancel(&s, cancel)?;
10567                let QueryResult::Rows { columns, rows, .. } = r else {
10568                    return Err(EngineError::Unsupported(
10569                        "IN-subquery: inner did not return rows".into(),
10570                    ));
10571                };
10572                if columns.len() != 1 {
10573                    return Err(EngineError::Unsupported(alloc::format!(
10574                        "IN-subquery must project exactly one column; got {}",
10575                        columns.len()
10576                    )));
10577                }
10578                let mut found = false;
10579                let mut any_null = false;
10580                for r0 in rows {
10581                    let v = r0.values.into_iter().next().unwrap_or(Value::Null);
10582                    if v.is_null() {
10583                        any_null = true;
10584                        continue;
10585                    }
10586                    if value_cmp(&v, &lhs_val) == core::cmp::Ordering::Equal {
10587                        found = true;
10588                        break;
10589                    }
10590                }
10591                let bit = if found {
10592                    !*negated
10593                } else if any_null {
10594                    return Err(EngineError::Unsupported(
10595                        "IN-subquery with NULL in result and no match: NULL semantics not yet implemented".into(),
10596                    ));
10597                } else {
10598                    *negated
10599                };
10600                *e = Expr::Literal(Literal::Bool(bit));
10601            }
10602            Expr::Binary { lhs, rhs, .. } => {
10603                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
10604                self.resolve_correlated_in_expr(rhs, row, ctx, cancel, memo.as_deref_mut())?;
10605            }
10606            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10607                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10608            }
10609            Expr::Like { expr, pattern, .. } => {
10610                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10611                self.resolve_correlated_in_expr(pattern, row, ctx, cancel, memo.as_deref_mut())?;
10612            }
10613            Expr::FunctionCall { args, .. } => {
10614                for a in args {
10615                    self.resolve_correlated_in_expr(a, row, ctx, cancel, memo.as_deref_mut())?;
10616                }
10617            }
10618            Expr::Extract { source, .. } => {
10619                self.resolve_correlated_in_expr(source, row, ctx, cancel, memo.as_deref_mut())?;
10620            }
10621            Expr::WindowFunction { .. }
10622            | Expr::Literal(_)
10623            | Expr::Placeholder(_)
10624            | Expr::Column(_) => {}
10625            // v7.10.10 — recurse children.
10626            Expr::Array(items) => {
10627                for elem in items {
10628                    self.resolve_correlated_in_expr(elem, row, ctx, cancel, memo.as_deref_mut())?;
10629                }
10630            }
10631            Expr::ArraySubscript { target, index } => {
10632                self.resolve_correlated_in_expr(target, row, ctx, cancel, memo.as_deref_mut())?;
10633                self.resolve_correlated_in_expr(index, row, ctx, cancel, memo.as_deref_mut())?;
10634            }
10635            Expr::AnyAll { expr, array, .. } => {
10636                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10637                self.resolve_correlated_in_expr(array, row, ctx, cancel, memo.as_deref_mut())?;
10638            }
10639            Expr::Case {
10640                operand,
10641                branches,
10642                else_branch,
10643            } => {
10644                if let Some(o) = operand {
10645                    self.resolve_correlated_in_expr(o, row, ctx, cancel, memo.as_deref_mut())?;
10646                }
10647                for (w, t) in branches {
10648                    self.resolve_correlated_in_expr(w, row, ctx, cancel, memo.as_deref_mut())?;
10649                    self.resolve_correlated_in_expr(t, row, ctx, cancel, memo.as_deref_mut())?;
10650                }
10651                if let Some(e) = else_branch {
10652                    self.resolve_correlated_in_expr(e, row, ctx, cancel, memo.as_deref_mut())?;
10653                }
10654            }
10655        }
10656        Ok(())
10657    }
10658
10659    fn subquery_replacement(
10660        &self,
10661        e: &Expr,
10662        cancel: CancelToken<'_>,
10663    ) -> Result<Option<Expr>, EngineError> {
10664        match e {
10665            Expr::ScalarSubquery(inner) => {
10666                let mut s = (**inner).clone();
10667                // Recurse into the inner SELECT first so nested
10668                // subqueries materialise bottom-up.
10669                self.resolve_select_subqueries(&mut s, cancel)?;
10670                let r = match self.exec_bare_select_cancel(&s, cancel) {
10671                    Ok(r) => r,
10672                    Err(e) if is_correlation_error(&e) => return Ok(None),
10673                    Err(e) => return Err(e),
10674                };
10675                let QueryResult::Rows { rows, .. } = r else {
10676                    return Err(EngineError::Unsupported(
10677                        "scalar subquery: inner statement did not return rows".into(),
10678                    ));
10679                };
10680                let value = match rows.as_slice() {
10681                    [] => Value::Null,
10682                    [row] => row.values.first().cloned().unwrap_or(Value::Null),
10683                    _ => {
10684                        return Err(EngineError::Unsupported(alloc::format!(
10685                            "scalar subquery returned {} rows; expected 0 or 1",
10686                            rows.len()
10687                        )));
10688                    }
10689                };
10690                Ok(Some(value_to_literal_expr(value)?))
10691            }
10692            Expr::Exists { subquery, negated } => {
10693                let mut s = (**subquery).clone();
10694                self.resolve_select_subqueries(&mut s, cancel)?;
10695                let r = match self.exec_bare_select_cancel(&s, cancel) {
10696                    Ok(r) => r,
10697                    Err(e) if is_correlation_error(&e) => return Ok(None),
10698                    Err(e) => return Err(e),
10699                };
10700                let exists = match r {
10701                    QueryResult::Rows { rows, .. } => !rows.is_empty(),
10702                    QueryResult::CommandOk { .. } => false,
10703                };
10704                let bit = if *negated { !exists } else { exists };
10705                Ok(Some(Expr::Literal(Literal::Bool(bit))))
10706            }
10707            Expr::InSubquery {
10708                expr,
10709                subquery,
10710                negated,
10711            } => {
10712                let mut s = (**subquery).clone();
10713                self.resolve_select_subqueries(&mut s, cancel)?;
10714                let r = match self.exec_bare_select_cancel(&s, cancel) {
10715                    Ok(r) => r,
10716                    Err(e) if is_correlation_error(&e) => return Ok(None),
10717                    Err(e) => return Err(e),
10718                };
10719                let QueryResult::Rows { columns, rows, .. } = r else {
10720                    return Err(EngineError::Unsupported(
10721                        "IN-subquery: inner statement did not return rows".into(),
10722                    ));
10723                };
10724                if columns.len() != 1 {
10725                    return Err(EngineError::Unsupported(alloc::format!(
10726                        "IN-subquery must project exactly one column; got {}",
10727                        columns.len()
10728                    )));
10729                }
10730                // Build the same OR-Eq chain the parse-time literal-list
10731                // path constructs, with each value lifted into a Literal.
10732                let mut acc: Option<Expr> = None;
10733                for row in rows {
10734                    let v = row.values.into_iter().next().unwrap_or(Value::Null);
10735                    let lit = value_to_literal_expr(v)?;
10736                    let cmp = Expr::Binary {
10737                        lhs: expr.clone(),
10738                        op: BinOp::Eq,
10739                        rhs: Box::new(lit),
10740                    };
10741                    acc = Some(match acc {
10742                        None => cmp,
10743                        Some(prev) => Expr::Binary {
10744                            lhs: Box::new(prev),
10745                            op: BinOp::Or,
10746                            rhs: Box::new(cmp),
10747                        },
10748                    });
10749                }
10750                let combined = acc.unwrap_or(Expr::Literal(Literal::Bool(false)));
10751                let final_expr = if *negated {
10752                    Expr::Unary {
10753                        op: UnOp::Not,
10754                        expr: Box::new(combined),
10755                    }
10756                } else {
10757                    combined
10758                };
10759                Ok(Some(final_expr))
10760            }
10761            _ => Ok(None),
10762        }
10763    }
10764}
10765
10766// ---- v4.12 window-function helpers ----
10767// The (partition-key, order-key, original-index) tuple shape used
10768// across these helpers is intrinsic to the planner. Factoring it
10769// into a typedef adds indirection without making the code clearer,
10770// so several lints are allowed inline on the affected functions
10771// rather than module-wide.
10772
10773/// v4.22: cheap structural scan for `FROM <name>` (qualified or
10774/// not) inside a SELECT — used to verify the anchor of a WITH
10775/// RECURSIVE CTE doesn't recurse into itself. Conservative: walks
10776/// FROM joins, subqueries, and unions.
10777fn select_refers_to(stmt: &SelectStatement, target: &str) -> bool {
10778    if let Some(from) = &stmt.from
10779        && from_refers_to(from, target)
10780    {
10781        return true;
10782    }
10783    for (_, peer) in &stmt.unions {
10784        if select_refers_to(peer, target) {
10785            return true;
10786        }
10787    }
10788    for item in &stmt.items {
10789        if let SelectItem::Expr { expr, .. } = item
10790            && expr_refers_to(expr, target)
10791        {
10792            return true;
10793        }
10794    }
10795    if let Some(w) = &stmt.where_
10796        && expr_refers_to(w, target)
10797    {
10798        return true;
10799    }
10800    false
10801}
10802
10803fn from_refers_to(from: &FromClause, target: &str) -> bool {
10804    if from.primary.name.eq_ignore_ascii_case(target) {
10805        return true;
10806    }
10807    from.joins
10808        .iter()
10809        .any(|j| j.table.name.eq_ignore_ascii_case(target))
10810}
10811
10812fn expr_refers_to(e: &Expr, target: &str) -> bool {
10813    match e {
10814        Expr::AggregateOrdered { call, order_by } => {
10815            expr_refers_to(call, target) || order_by.iter().any(|o| expr_refers_to(&o.expr, target))
10816        }
10817        Expr::ScalarSubquery(s) => select_refers_to(s, target),
10818        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
10819            select_refers_to(subquery, target)
10820        }
10821        Expr::Binary { lhs, rhs, .. } => expr_refers_to(lhs, target) || expr_refers_to(rhs, target),
10822        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10823            expr_refers_to(expr, target)
10824        }
10825        Expr::Like { expr, pattern, .. } => {
10826            expr_refers_to(expr, target) || expr_refers_to(pattern, target)
10827        }
10828        Expr::FunctionCall { args, .. } => args.iter().any(|a| expr_refers_to(a, target)),
10829        Expr::Extract { source, .. } => expr_refers_to(source, target),
10830        Expr::WindowFunction {
10831            args,
10832            partition_by,
10833            order_by,
10834            ..
10835        } => {
10836            args.iter().any(|a| expr_refers_to(a, target))
10837                || partition_by.iter().any(|p| expr_refers_to(p, target))
10838                || order_by.iter().any(|(o, _, _)| expr_refers_to(o, target))
10839        }
10840        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
10841        Expr::Array(items) => items.iter().any(|e| expr_refers_to(e, target)),
10842        Expr::ArraySubscript { target: t, index } => {
10843            expr_refers_to(t, target) || expr_refers_to(index, target)
10844        }
10845        Expr::AnyAll { expr, array, .. } => {
10846            expr_refers_to(expr, target) || expr_refers_to(array, target)
10847        }
10848        Expr::Case {
10849            operand,
10850            branches,
10851            else_branch,
10852        } => {
10853            operand
10854                .as_deref()
10855                .is_some_and(|o| expr_refers_to(o, target))
10856                || branches
10857                    .iter()
10858                    .any(|(w, t)| expr_refers_to(w, target) || expr_refers_to(t, target))
10859                || else_branch
10860                    .as_deref()
10861                    .is_some_and(|e| expr_refers_to(e, target))
10862        }
10863    }
10864}
10865
10866/// v4.22: pick more specific column types from observed rows when
10867/// the projection builder defaulted to Text (the v1.x behavior for
10868/// non-column expressions). Lets `WITH t(n) AS (SELECT 1 ...)`
10869/// land an Int column in the CTE storage table rather than failing
10870/// the insert with "expected TEXT, got INT".
10871/// v7.16.2 — map an SPG [`DataType`] to the PG-canonical
10872/// `information_schema.columns.data_type` text. Covers the
10873/// values mailrs's migrations probe (`'ARRAY'`, `'integer'`,
10874/// `'text'`, …). Unknown variants fall back to the SPG name
10875/// downcased — better than panicking on a future DataType.
10876fn pg_data_type_text(ty: DataType) -> alloc::string::String {
10877    let s = match ty {
10878        DataType::Int => "integer",
10879        DataType::BigInt => "bigint",
10880        DataType::SmallInt => "smallint",
10881        DataType::Float => "double precision",
10882        DataType::Bool => "boolean",
10883        DataType::Text => "text",
10884        DataType::Varchar(_) => "character varying",
10885        DataType::Date => "date",
10886        DataType::Timestamp => "timestamp without time zone",
10887        DataType::Timestamptz => "timestamp with time zone",
10888        DataType::Json => "jsonb",
10889        DataType::Bytes => "bytea",
10890        DataType::TextArray | DataType::IntArray | DataType::BigIntArray => "ARRAY",
10891        DataType::TsVector => "tsvector",
10892        DataType::TsQuery => "tsquery",
10893        DataType::Vector { .. } => "USER-DEFINED",
10894        // Non-exhaustive — fall back to "USER-DEFINED" the way
10895        // PG labels any pg_type it doesn't recognise.
10896        _ => "USER-DEFINED",
10897    };
10898    alloc::string::String::from(s)
10899}
10900
10901/// v7.16.2 — synthesise `information_schema.columns`. mailrs
10902/// queries are of shape `SELECT 1 FROM information_schema.columns
10903/// WHERE table_name = … AND column_name = … AND data_type = …` —
10904/// the v7.16.2 view returns the columns mailrs probes; broader
10905/// PG-spec parity (ordinal_position, is_nullable, character_
10906/// maximum_length, udt_name, …) lands as needed.
10907fn synth_information_schema_columns(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10908    let schema = alloc::vec![
10909        ColumnSchema::new("table_catalog", DataType::Text, false),
10910        ColumnSchema::new("table_schema", DataType::Text, false),
10911        ColumnSchema::new("table_name", DataType::Text, false),
10912        ColumnSchema::new("column_name", DataType::Text, false),
10913        ColumnSchema::new("ordinal_position", DataType::Int, false),
10914        ColumnSchema::new("is_nullable", DataType::Text, false),
10915        ColumnSchema::new("data_type", DataType::Text, false),
10916    ];
10917    let mut rows: Vec<Row> = Vec::new();
10918    for tname in cat.table_names() {
10919        let Some(t) = cat.get(&tname) else { continue };
10920        for (i, col) in t.schema().columns.iter().enumerate() {
10921            #[allow(clippy::cast_possible_wrap)]
10922            let ordinal = (i + 1) as i32;
10923            rows.push(Row::new(alloc::vec![
10924                Value::Text("spg".into()),
10925                Value::Text("public".into()),
10926                Value::Text(tname.clone()),
10927                Value::Text(col.name.clone()),
10928                Value::Int(ordinal),
10929                Value::Text(if col.nullable {
10930                    "YES".into()
10931                } else {
10932                    "NO".into()
10933                }),
10934                Value::Text(pg_data_type_text(col.ty)),
10935            ]));
10936        }
10937    }
10938    (schema, rows)
10939}
10940
10941/// v7.16.2 — synthesise `information_schema.tables`.
10942fn synth_information_schema_tables(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10943    let schema = alloc::vec![
10944        ColumnSchema::new("table_catalog", DataType::Text, false),
10945        ColumnSchema::new("table_schema", DataType::Text, false),
10946        ColumnSchema::new("table_name", DataType::Text, false),
10947        ColumnSchema::new("table_type", DataType::Text, false),
10948    ];
10949    let mut rows: Vec<Row> = Vec::new();
10950    for tname in cat.table_names() {
10951        rows.push(Row::new(alloc::vec![
10952            Value::Text("spg".into()),
10953            Value::Text("public".into()),
10954            Value::Text(tname.clone()),
10955            Value::Text("BASE TABLE".into()),
10956        ]));
10957    }
10958    (schema, rows)
10959}
10960
10961/// v7.16.2 — synthesise `pg_catalog.pg_class`. Minimum shape
10962/// for psql `\d` / ORM probes: `relname` + `relkind`. Each
10963/// user table emits one row.
10964fn synth_pg_class(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10965    let schema = alloc::vec![
10966        ColumnSchema::new("relname", DataType::Text, false),
10967        ColumnSchema::new("relkind", DataType::Text, false),
10968        ColumnSchema::new("relnamespace", DataType::BigInt, false),
10969    ];
10970    let mut rows: Vec<Row> = Vec::new();
10971    for tname in cat.table_names() {
10972        rows.push(Row::new(alloc::vec![
10973            Value::Text(tname.clone()),
10974            Value::Text("r".into()),
10975            Value::BigInt(2200), // PG's `public` namespace OID
10976        ]));
10977    }
10978    (schema, rows)
10979}
10980
10981/// v7.16.2 — synthesise `pg_catalog.pg_attribute`. Minimum
10982/// shape: `attrelid` (text — SPG has no OID), `attname`,
10983/// `attnum`, `atttypid` (text), `attnotnull`.
10984fn synth_pg_attribute(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10985    let schema = alloc::vec![
10986        ColumnSchema::new("attrelid", DataType::Text, false),
10987        ColumnSchema::new("attname", DataType::Text, false),
10988        ColumnSchema::new("attnum", DataType::Int, false),
10989        ColumnSchema::new("atttypid", DataType::Text, false),
10990        ColumnSchema::new("attnotnull", DataType::Bool, false),
10991    ];
10992    let mut rows: Vec<Row> = Vec::new();
10993    for tname in cat.table_names() {
10994        let Some(t) = cat.get(&tname) else { continue };
10995        for (i, col) in t.schema().columns.iter().enumerate() {
10996            #[allow(clippy::cast_possible_wrap)]
10997            let ordinal = (i + 1) as i32;
10998            rows.push(Row::new(alloc::vec![
10999                Value::Text(tname.clone()),
11000                Value::Text(col.name.clone()),
11001                Value::Int(ordinal),
11002                Value::Text(pg_data_type_text(col.ty)),
11003                Value::Bool(!col.nullable),
11004            ]));
11005        }
11006    }
11007    (schema, rows)
11008}
11009
11010/// v7.17.0 Phase 3.P0-50 — synthesise `pg_catalog.pg_type`. The
11011/// returned rows cover every built-in scalar / array type sqlx,
11012/// SQLAlchemy, Diesel and pgAdmin look up at compile / connect
11013/// time. PG-canonical schema columns we expose:
11014///   * oid           — type OID (the lookup key sqlx uses)
11015///   * typname       — canonical type name (`int4`, `text`, …)
11016///   * typlen        — width in bytes (-1 for var-length)
11017///   * typtype       — `b`ase / `c`omposite / `e`num / etc.
11018///   * typcategory   — PG type category single-char
11019///   * typelem       — element OID for arrays (0 otherwise)
11020///   * typarray      — array-type OID (0 if no array type)
11021///   * typnamespace  — schema OID (always `public` = 2200)
11022///
11023/// Other pg_type columns (typowner, typinput/typoutput, etc.)
11024/// land in follow-up work — sqlx encoders don't query them at
11025/// connect time.
11026fn synth_pg_type(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11027    let schema = alloc::vec![
11028        ColumnSchema::new("oid", DataType::BigInt, false),
11029        ColumnSchema::new("typname", DataType::Text, false),
11030        ColumnSchema::new("typlen", DataType::SmallInt, false),
11031        ColumnSchema::new("typtype", DataType::Text, false),
11032        ColumnSchema::new("typcategory", DataType::Text, false),
11033        ColumnSchema::new("typelem", DataType::BigInt, false),
11034        ColumnSchema::new("typarray", DataType::BigInt, false),
11035        ColumnSchema::new("typnamespace", DataType::BigInt, false),
11036    ];
11037    // (oid, name, len, type, cat, elem, array_oid). PG OID
11038    // numbers come straight from `pg_type.dat`.
11039    let scalars: &[(i64, &str, i16, &str, &str, i64, i64)] = &[
11040        // bool
11041        (16, "bool", 1, "b", "B", 0, 1000),
11042        (17, "bytea", -1, "b", "U", 0, 1001),
11043        (18, "char", 1, "b", "S", 0, 1002),
11044        (19, "name", 64, "b", "S", 0, 1003),
11045        (20, "int8", 8, "b", "N", 0, 1016),
11046        (21, "int2", 2, "b", "N", 0, 1005),
11047        (23, "int4", 4, "b", "N", 0, 1007),
11048        (24, "regproc", 4, "b", "N", 0, 1008),
11049        (25, "text", -1, "b", "S", 0, 1009),
11050        (26, "oid", 4, "b", "N", 0, 1028),
11051        (114, "json", -1, "b", "U", 0, 199),
11052        (142, "xml", -1, "b", "U", 0, 143),
11053        (700, "float4", 4, "b", "N", 0, 1021),
11054        (701, "float8", 8, "b", "N", 0, 1022),
11055        (650, "cidr", -1, "b", "I", 0, 651),
11056        (869, "inet", -1, "b", "I", 0, 1041),
11057        (829, "macaddr", 6, "b", "U", 0, 1040),
11058        (1042, "bpchar", -1, "b", "S", 0, 1014),
11059        (1043, "varchar", -1, "b", "S", 0, 1015),
11060        (1082, "date", 4, "b", "D", 0, 1182),
11061        (1083, "time", 8, "b", "D", 0, 1183),
11062        (1114, "timestamp", 8, "b", "D", 0, 1115),
11063        (1184, "timestamptz", 8, "b", "D", 0, 1185),
11064        (1186, "interval", 16, "b", "T", 0, 1187),
11065        (1266, "timetz", 12, "b", "D", 0, 1270),
11066        (1700, "numeric", -1, "b", "N", 0, 1231),
11067        (790, "money", 8, "b", "N", 0, 791),
11068        (2950, "uuid", 16, "b", "U", 0, 2951),
11069        (3802, "jsonb", -1, "b", "U", 0, 3807),
11070        (3614, "tsvector", -1, "b", "U", 0, 3643),
11071        (3615, "tsquery", -1, "b", "U", 0, 3645),
11072        // hstore + range types — typcategory 'U' (user) / 'R' (range).
11073        (3908, "tstzrange", -1, "r", "R", 0, 3909),
11074        (3910, "tsrange", -1, "r", "R", 0, 3911),
11075        (3904, "int4range", -1, "r", "R", 0, 3905),
11076        (3926, "int8range", -1, "r", "R", 0, 3927),
11077        (3906, "numrange", -1, "r", "R", 0, 3907),
11078        (3912, "daterange", -1, "r", "R", 0, 3913),
11079    ];
11080    // Array companion types share the typelem / typcategory='A'.
11081    // We emit just the array OIDs the scalars reference.
11082    let arrays: &[(i64, &str, i64)] = &[
11083        (1000, "_bool", 16),
11084        (1001, "_bytea", 17),
11085        (1002, "_char", 18),
11086        (1003, "_name", 19),
11087        (1016, "_int8", 20),
11088        (1005, "_int2", 21),
11089        (1007, "_int4", 23),
11090        (1008, "_regproc", 24),
11091        (1009, "_text", 25),
11092        (1028, "_oid", 26),
11093        (199, "_json", 114),
11094        (143, "_xml", 142),
11095        (1021, "_float4", 700),
11096        (1022, "_float8", 701),
11097        (651, "_cidr", 650),
11098        (1041, "_inet", 869),
11099        (1040, "_macaddr", 829),
11100        (1014, "_bpchar", 1042),
11101        (1015, "_varchar", 1043),
11102        (1182, "_date", 1082),
11103        (1183, "_time", 1083),
11104        (1115, "_timestamp", 1114),
11105        (1185, "_timestamptz", 1184),
11106        (1187, "_interval", 1186),
11107        (1270, "_timetz", 1266),
11108        (1231, "_numeric", 1700),
11109        (791, "_money", 790),
11110        (2951, "_uuid", 2950),
11111        (3807, "_jsonb", 3802),
11112        (3643, "_tsvector", 3614),
11113        (3645, "_tsquery", 3615),
11114    ];
11115    let mut rows: Vec<Row> = Vec::with_capacity(scalars.len() + arrays.len());
11116    for &(oid, name, len, ty, cat, elem, arr) in scalars {
11117        rows.push(Row::new(alloc::vec![
11118            Value::BigInt(oid),
11119            Value::Text(name.into()),
11120            Value::SmallInt(len),
11121            Value::Text(ty.into()),
11122            Value::Text(cat.into()),
11123            Value::BigInt(elem),
11124            Value::BigInt(arr),
11125            Value::BigInt(2200),
11126        ]));
11127    }
11128    for &(oid, name, elem) in arrays {
11129        rows.push(Row::new(alloc::vec![
11130            Value::BigInt(oid),
11131            Value::Text(name.into()),
11132            Value::SmallInt(-1),
11133            Value::Text("b".into()),
11134            Value::Text("A".into()),
11135            Value::BigInt(elem),
11136            Value::BigInt(0),
11137            Value::BigInt(2200),
11138        ]));
11139    }
11140    (schema, rows)
11141}
11142
11143/// v7.17.0 Phase 3.P0-51 — synthesise `pg_catalog.pg_proc`. ORM /
11144/// pgAdmin probes look up functions by name; SPG synthesises rows
11145/// for the built-in scalar functions / aggregates / window funcs
11146/// the engine actually dispatches. SPG has no user-defined
11147/// functions yet so the table is a stable static list.
11148///
11149/// Schema columns exposed:
11150///   * oid (BigInt) — function OID from PG's pg_proc.dat
11151///   * proname (Text) — function name (lowercase)
11152///   * pronamespace (BigInt) — 11 (`pg_catalog`)
11153///   * prokind (Text) — 'f' function, 'a' aggregate, 'w' window
11154///   * pronargs (SmallInt) — declared arg count (-1 for variadic)
11155///   * prorettype (BigInt) — return type OID (matches synth_pg_type)
11156/// v7.24 (round-16 D) — synthesise `pg_catalog.pg_trigger` from the
11157/// live catalog. PG-shaped core columns (tgname, tgenabled with
11158/// 'O'/'D') plus pragmatic text columns PG keeps relational
11159/// (relname, timing, events, function) so health checks don't need
11160/// oid joins.
11161fn synth_pg_trigger(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11162    let schema = alloc::vec![
11163        ColumnSchema::new("tgname", DataType::Text, false),
11164        ColumnSchema::new("relname", DataType::Text, false),
11165        ColumnSchema::new("tgenabled", DataType::Text, false),
11166        ColumnSchema::new("timing", DataType::Text, false),
11167        ColumnSchema::new("events", DataType::Text, false),
11168        ColumnSchema::new("function", DataType::Text, false),
11169    ];
11170    let rows: Vec<Row> = cat
11171        .triggers()
11172        .iter()
11173        .map(|t| {
11174            Row::new(alloc::vec![
11175                Value::Text(t.name.clone()),
11176                Value::Text(t.table.clone()),
11177                Value::Text(if t.enabled { "O".into() } else { "D".into() }),
11178                Value::Text(t.timing.clone()),
11179                Value::Text(t.events.join(" OR ")),
11180                Value::Text(t.function.clone()),
11181            ])
11182        })
11183        .collect();
11184    (schema, rows)
11185}
11186
11187fn synth_pg_proc(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11188    let schema = alloc::vec![
11189        ColumnSchema::new("oid", DataType::BigInt, false),
11190        ColumnSchema::new("proname", DataType::Text, false),
11191        ColumnSchema::new("pronamespace", DataType::BigInt, false),
11192        ColumnSchema::new("prokind", DataType::Text, false),
11193        ColumnSchema::new("pronargs", DataType::Int, false),
11194        ColumnSchema::new("prorettype", DataType::BigInt, false),
11195    ];
11196    // (oid, name, kind, nargs, rettype). OIDs taken from PG's
11197    // pg_proc.dat for the common subset.
11198    let funcs: &[(i64, &str, &str, i32, i64)] = &[
11199        // Scalar functions.
11200        (1318, "length", "f", 1, 23),
11201        (871, "upper", "f", 1, 25),
11202        (870, "lower", "f", 1, 25),
11203        (936, "substring", "f", 3, 25),
11204        (937, "substring", "f", 2, 25),
11205        (3055, "btrim", "f", 1, 25),
11206        (885, "btrim", "f", 2, 25),
11207        (3056, "ltrim", "f", 1, 25),
11208        (875, "ltrim", "f", 2, 25),
11209        (3057, "rtrim", "f", 1, 25),
11210        (876, "rtrim", "f", 2, 25),
11211        (1397, "abs", "f", 1, 23),
11212        (1396, "abs", "f", 1, 20),
11213        (1606, "round", "f", 1, 1700),
11214        (1707, "round", "f", 2, 1700),
11215        (2308, "ceil", "f", 1, 701),
11216        (2309, "ceiling", "f", 1, 701),
11217        (2310, "floor", "f", 1, 701),
11218        (1376, "sqrt", "f", 1, 701),
11219        (1369, "ln", "f", 1, 701),
11220        (1373, "exp", "f", 1, 701),
11221        (1368, "power", "f", 2, 701),
11222        (2228, "random", "f", 0, 701),
11223        // Date / time.
11224        (1299, "now", "f", 0, 1184),
11225        (1274, "current_timestamp", "f", 0, 1184),
11226        (1140, "current_date", "f", 0, 1082),
11227        (2050, "current_time", "f", 0, 1083),
11228        (1158, "date_trunc", "f", 2, 1184),
11229        (1171, "date_part", "f", 2, 701),
11230        (1172, "age", "f", 1, 1186),
11231        (936, "to_char", "f", 2, 25),
11232        // Session / introspection.
11233        (861, "current_database", "f", 0, 19),
11234        (745, "current_user", "f", 0, 19),
11235        (745, "session_user", "f", 0, 19),
11236        (1402, "current_schema", "f", 0, 19),
11237        // String concat / format.
11238        (3058, "concat", "f", -1, 25),
11239        (3059, "concat_ws", "f", -1, 25),
11240        (3539, "format", "f", -1, 25),
11241        // Type introspection.
11242        (2877, "pg_typeof", "f", 1, 2206),
11243        // JSON.
11244        (3198, "json_build_object", "f", -1, 114),
11245        (3199, "jsonb_build_object", "f", -1, 3802),
11246        (3271, "json_build_array", "f", -1, 114),
11247        (3272, "jsonb_build_array", "f", -1, 3802),
11248        // UUID.
11249        (3253, "gen_random_uuid", "f", 0, 2950),
11250        (3252, "uuid_generate_v4", "f", 0, 2950),
11251        // Aggregates.
11252        (2147, "count", "a", 0, 20),
11253        (2803, "count", "a", -1, 20),
11254        (2116, "max", "a", 1, 23),
11255        (2132, "min", "a", 1, 23),
11256        (2108, "sum", "a", 1, 20),
11257        (2100, "avg", "a", 1, 1700),
11258        (2517, "string_agg", "a", 2, 25),
11259        (2747, "array_agg", "a", 1, 1009),
11260        (2517, "bool_and", "a", 1, 16),
11261        (2518, "bool_or", "a", 1, 16),
11262        (2519, "every", "a", 1, 16),
11263        // Window functions.
11264        (3100, "row_number", "w", 0, 20),
11265        (3101, "rank", "w", 0, 20),
11266        (3102, "dense_rank", "w", 0, 20),
11267        (3103, "percent_rank", "w", 0, 701),
11268        (3104, "cume_dist", "w", 0, 701),
11269        (3105, "lag", "w", -1, 2283),
11270        (3106, "lead", "w", -1, 2283),
11271        (3107, "first_value", "w", 1, 2283),
11272        (3108, "last_value", "w", 1, 2283),
11273        (3109, "nth_value", "w", 2, 2283),
11274    ];
11275    let mut rows: Vec<Row> = Vec::with_capacity(funcs.len());
11276    for &(oid, name, kind, nargs, rettype) in funcs {
11277        rows.push(Row::new(alloc::vec![
11278            Value::BigInt(oid),
11279            Value::Text(name.into()),
11280            Value::BigInt(11),
11281            Value::Text(kind.into()),
11282            Value::Int(nargs),
11283            Value::BigInt(rettype),
11284        ]));
11285    }
11286    (schema, rows)
11287}
11288
11289/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.user`. MySQL admin
11290/// queries (`SELECT user, host FROM mysql.user`) probe this at
11291/// connect time to list accounts. SPG ships one row per
11292/// UserStore entry plus a synthetic `root` superuser row for
11293/// MySQL bootstrap compat.
11294fn synth_mysql_user(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11295    let schema = alloc::vec![
11296        ColumnSchema::new("user", DataType::Text, false),
11297        ColumnSchema::new("host", DataType::Text, false),
11298        ColumnSchema::new("select_priv", DataType::Text, false),
11299    ];
11300    let mut rows: Vec<Row> = Vec::new();
11301    rows.push(Row::new(alloc::vec![
11302        Value::Text("root".into()),
11303        Value::Text("localhost".into()),
11304        Value::Text("Y".into()),
11305    ]));
11306    for (name, _) in engine.users.iter() {
11307        if name != "root" {
11308            rows.push(Row::new(alloc::vec![
11309                Value::Text(name.to_string()),
11310                Value::Text("%".into()),
11311                Value::Text("Y".into()),
11312            ]));
11313        }
11314    }
11315    (schema, rows)
11316}
11317
11318/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.db`. The
11319/// per-database privileges table. SPG is single-database so the
11320/// table surfaces one row per declared user with full privileges
11321/// on the canonical `postgres` database.
11322fn synth_mysql_db() -> (Vec<ColumnSchema>, Vec<Row>) {
11323    let schema = alloc::vec![
11324        ColumnSchema::new("host", DataType::Text, false),
11325        ColumnSchema::new("db", DataType::Text, false),
11326        ColumnSchema::new("user", DataType::Text, false),
11327        ColumnSchema::new("select_priv", DataType::Text, false),
11328    ];
11329    let rows = alloc::vec![Row::new(alloc::vec![
11330        Value::Text("localhost".into()),
11331        Value::Text("postgres".into()),
11332        Value::Text("root".into()),
11333        Value::Text("Y".into()),
11334    ])];
11335    (schema, rows)
11336}
11337
11338/// v7.17.0 Phase 3.P0-63 — synthesise
11339/// `information_schema.KEY_COLUMN_USAGE`. ORM migration tools
11340/// (Alembic, Sequelize, TypeORM) walk this view to discover FK
11341/// relationships in MySQL-flavoured introspection queries.
11342///
11343/// Schema columns exposed:
11344///   * CONSTRAINT_NAME (Text)
11345///   * TABLE_NAME (Text)
11346///   * COLUMN_NAME (Text)
11347///   * ORDINAL_POSITION (Int)
11348///   * REFERENCED_TABLE_NAME (Text) — empty for non-FK rows
11349///   * REFERENCED_COLUMN_NAME (Text) — empty for non-FK rows
11350fn synth_info_key_column_usage(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11351    let schema = alloc::vec![
11352        ColumnSchema::new("constraint_name", DataType::Text, false),
11353        ColumnSchema::new("table_name", DataType::Text, false),
11354        ColumnSchema::new("column_name", DataType::Text, false),
11355        ColumnSchema::new("ordinal_position", DataType::Int, false),
11356        ColumnSchema::new("referenced_table_name", DataType::Text, false),
11357        ColumnSchema::new("referenced_column_name", DataType::Text, false),
11358    ];
11359    let mut rows: Vec<Row> = Vec::new();
11360    for tname in cat.table_names() {
11361        let Some(t) = cat.get(&tname) else { continue };
11362        let cols = &t.schema().columns;
11363        let col_name_at = |pos: usize| -> String {
11364            cols.get(pos)
11365                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
11366        };
11367        // FKs.
11368        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11369            let conname = fk
11370                .name
11371                .clone()
11372                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11373            for (i, (&local, &parent)) in fk
11374                .local_columns
11375                .iter()
11376                .zip(fk.parent_columns.iter())
11377                .enumerate()
11378            {
11379                let parent_name = cat
11380                    .get(&fk.parent_table)
11381                    .and_then(|pt| pt.schema().columns.get(parent).map(|c| c.name.clone()))
11382                    .unwrap_or_else(|| alloc::format!("col{parent}"));
11383                #[allow(clippy::cast_possible_wrap)]
11384                let ordinal = (i + 1) as i32;
11385                rows.push(Row::new(alloc::vec![
11386                    Value::Text(conname.clone()),
11387                    Value::Text(tname.clone()),
11388                    Value::Text(col_name_at(local)),
11389                    Value::Int(ordinal),
11390                    Value::Text(fk.parent_table.clone()),
11391                    Value::Text(parent_name),
11392                ]));
11393            }
11394        }
11395        // PK / composite UC entries.
11396        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
11397            let conname = if uc.is_primary_key {
11398                alloc::format!("{}_pkey", tname)
11399            } else {
11400                alloc::format!("{}_uniq{ci}", tname)
11401            };
11402            for (i, &local) in uc.columns.iter().enumerate() {
11403                #[allow(clippy::cast_possible_wrap)]
11404                let ordinal = (i + 1) as i32;
11405                rows.push(Row::new(alloc::vec![
11406                    Value::Text(conname.clone()),
11407                    Value::Text(tname.clone()),
11408                    Value::Text(col_name_at(local)),
11409                    Value::Int(ordinal),
11410                    Value::Text(String::new()),
11411                    Value::Text(String::new()),
11412                ]));
11413            }
11414        }
11415    }
11416    (schema, rows)
11417}
11418
11419/// v7.17.0 Phase 3.P0-64 — synthesise
11420/// `information_schema.REFERENTIAL_CONSTRAINTS`. One row per FK.
11421fn synth_info_referential_constraints(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11422    let schema = alloc::vec![
11423        ColumnSchema::new("constraint_name", DataType::Text, false),
11424        ColumnSchema::new("table_name", DataType::Text, false),
11425        ColumnSchema::new("referenced_table_name", DataType::Text, false),
11426        ColumnSchema::new("update_rule", DataType::Text, false),
11427        ColumnSchema::new("delete_rule", DataType::Text, false),
11428    ];
11429    fn rule_name(a: spg_storage::FkAction) -> &'static str {
11430        match a {
11431            spg_storage::FkAction::Cascade => "CASCADE",
11432            spg_storage::FkAction::SetNull => "SET NULL",
11433            spg_storage::FkAction::SetDefault => "SET DEFAULT",
11434            spg_storage::FkAction::Restrict => "RESTRICT",
11435            spg_storage::FkAction::NoAction => "NO ACTION",
11436        }
11437    }
11438    let mut rows: Vec<Row> = Vec::new();
11439    for tname in cat.table_names() {
11440        let Some(t) = cat.get(&tname) else { continue };
11441        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11442            let conname = fk
11443                .name
11444                .clone()
11445                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11446            rows.push(Row::new(alloc::vec![
11447                Value::Text(conname),
11448                Value::Text(tname.clone()),
11449                Value::Text(fk.parent_table.clone()),
11450                Value::Text(rule_name(fk.on_update).into()),
11451                Value::Text(rule_name(fk.on_delete).into()),
11452            ]));
11453        }
11454    }
11455    (schema, rows)
11456}
11457
11458/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.STATISTICS`.
11459/// One row per (index × column) — admin tools walk this to
11460/// surface index-cardinality estimates.
11461fn synth_info_statistics(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11462    let schema = alloc::vec![
11463        ColumnSchema::new("table_name", DataType::Text, false),
11464        ColumnSchema::new("index_name", DataType::Text, false),
11465        ColumnSchema::new("column_name", DataType::Text, false),
11466        ColumnSchema::new("seq_in_index", DataType::Int, false),
11467        ColumnSchema::new("non_unique", DataType::Int, false),
11468        ColumnSchema::new("index_type", DataType::Text, false),
11469    ];
11470    let mut rows: Vec<Row> = Vec::new();
11471    for tname in cat.table_names() {
11472        let Some(t) = cat.get(&tname) else { continue };
11473        for idx in t.indices() {
11474            let col = t
11475                .schema()
11476                .columns
11477                .get(idx.column_position)
11478                .map_or("?".into(), |c| c.name.clone());
11479            rows.push(Row::new(alloc::vec![
11480                Value::Text(tname.clone()),
11481                Value::Text(idx.name.clone()),
11482                Value::Text(col),
11483                Value::Int(1),
11484                Value::Int(i32::from(!idx.is_unique)),
11485                Value::Text("BTREE".into()),
11486            ]));
11487        }
11488    }
11489    (schema, rows)
11490}
11491
11492/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.ROUTINES`.
11493/// SPG has no user-defined functions in v7.17 so the surface is
11494/// always empty; admin tools just need the table to exist.
11495fn synth_info_routines() -> (Vec<ColumnSchema>, Vec<Row>) {
11496    let schema = alloc::vec![
11497        ColumnSchema::new("routine_name", DataType::Text, false),
11498        ColumnSchema::new("routine_type", DataType::Text, false),
11499        ColumnSchema::new("data_type", DataType::Text, false),
11500    ];
11501    (schema, Vec::new())
11502}
11503
11504/// v7.17.0 Phase 3.P0-54 — synthesise `pg_catalog.pg_constraint`.
11505/// ORM compilers (Diesel, sea-orm) and admin tools probe this for
11506/// FK / UNIQUE / PK / CHECK definitions to surface relationship
11507/// graphs and validation rules. SPG ships one row per
11508/// uniqueness constraint + foreign key declared in the catalog.
11509///
11510/// Schema columns exposed:
11511///   * conname (Text) — constraint name (synthetic when anonymous)
11512///   * contype (Text) — `p` PK, `u` UNIQUE, `f` FK, `c` CHECK
11513///   * conrelid (Text) — owner table name
11514///   * confrelid (Text) — referenced parent table (FK only;
11515///     empty string otherwise)
11516///   * conkey (Text) — comma-separated column names
11517///   * confkey (Text) — comma-separated parent column names (FK only)
11518fn synth_pg_constraint(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11519    let schema = alloc::vec![
11520        ColumnSchema::new("conname", DataType::Text, false),
11521        ColumnSchema::new("contype", DataType::Text, false),
11522        ColumnSchema::new("conrelid", DataType::Text, false),
11523        ColumnSchema::new("confrelid", DataType::Text, false),
11524        ColumnSchema::new("conkey", DataType::Text, false),
11525        ColumnSchema::new("confkey", DataType::Text, false),
11526    ];
11527    let mut rows: Vec<Row> = Vec::new();
11528    for tname in cat.table_names() {
11529        let Some(t) = cat.get(&tname) else { continue };
11530        let cols = &t.schema().columns;
11531        let col_name_at = |pos: usize| -> String {
11532            cols.get(pos)
11533                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
11534        };
11535        // Uniqueness constraints (composite UNIQUE / PRIMARY KEY).
11536        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
11537            let kind = if uc.is_primary_key { "p" } else { "u" };
11538            let conname = if uc.is_primary_key {
11539                alloc::format!("{}_pkey", tname)
11540            } else {
11541                alloc::format!("{}_uniq{ci}", tname)
11542            };
11543            let conkey: Vec<String> = uc.columns.iter().map(|&p| col_name_at(p)).collect();
11544            rows.push(Row::new(alloc::vec![
11545                Value::Text(conname),
11546                Value::Text(kind.into()),
11547                Value::Text(tname.clone()),
11548                Value::Text(String::new()),
11549                Value::Text(conkey.join(",")),
11550                Value::Text(String::new()),
11551            ]));
11552        }
11553        // Single-column PK / UNIQUE indexes that have no
11554        // matching entry in `uniqueness_constraints` (the engine
11555        // creates only the BTree index for the bare-column case;
11556        // composite forms ride the UC path above).
11557        for idx in t.indices() {
11558            if !idx.is_unique {
11559                continue;
11560            }
11561            let is_primary = idx.name.ends_with("_pkey");
11562            let conname = idx.name.clone();
11563            let kind = if is_primary { "p" } else { "u" };
11564            let col_name = col_name_at(idx.column_position);
11565            // Skip if already emitted via the UC loop above (same
11566            // tuple shape — single-column).
11567            let already = t
11568                .schema()
11569                .uniqueness_constraints
11570                .iter()
11571                .any(|uc| uc.columns.len() == 1 && uc.columns[0] == idx.column_position);
11572            if already {
11573                continue;
11574            }
11575            rows.push(Row::new(alloc::vec![
11576                Value::Text(conname),
11577                Value::Text(kind.into()),
11578                Value::Text(tname.clone()),
11579                Value::Text(String::new()),
11580                Value::Text(col_name),
11581                Value::Text(String::new()),
11582            ]));
11583        }
11584        // Foreign keys.
11585        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11586            let conname = fk
11587                .name
11588                .clone()
11589                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11590            let conkey: Vec<String> = fk.local_columns.iter().map(|&p| col_name_at(p)).collect();
11591            // Parent column names: look up the parent table's
11592            // schema if it exists; otherwise emit positions.
11593            let confkey: Vec<String> = if let Some(parent) = cat.get(&fk.parent_table) {
11594                fk.parent_columns
11595                    .iter()
11596                    .map(|&p| {
11597                        parent
11598                            .schema()
11599                            .columns
11600                            .get(p)
11601                            .map_or_else(|| alloc::format!("col{p}"), |c| c.name.clone())
11602                    })
11603                    .collect()
11604            } else {
11605                fk.parent_columns
11606                    .iter()
11607                    .map(|p| alloc::format!("col{p}"))
11608                    .collect()
11609            };
11610            rows.push(Row::new(alloc::vec![
11611                Value::Text(conname),
11612                Value::Text("f".into()),
11613                Value::Text(tname.clone()),
11614                Value::Text(fk.parent_table.clone()),
11615                Value::Text(conkey.join(",")),
11616                Value::Text(confkey.join(",")),
11617            ]));
11618        }
11619    }
11620    (schema, rows)
11621}
11622
11623/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_database`.
11624/// SPG is single-database so we surface a single row keyed on the
11625/// canonical `postgres` database name (matching what every PG
11626/// admin tool's startup screen expects to find).
11627fn synth_pg_database(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11628    let schema = alloc::vec![
11629        ColumnSchema::new("oid", DataType::BigInt, false),
11630        ColumnSchema::new("datname", DataType::Text, false),
11631        ColumnSchema::new("datdba", DataType::BigInt, false),
11632        ColumnSchema::new("encoding", DataType::Int, false),
11633        ColumnSchema::new("datcollate", DataType::Text, false),
11634    ];
11635    let rows = alloc::vec![Row::new(alloc::vec![
11636        Value::BigInt(16384),
11637        Value::Text("postgres".into()),
11638        Value::BigInt(10),
11639        Value::Int(6), // UTF8
11640        Value::Text("en_US.UTF-8".into()),
11641    ])];
11642    (schema, rows)
11643}
11644
11645/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_roles`. PG's
11646/// pg_roles is a view over pg_authid showing all roles. SPG ships
11647/// one row per declared user from the engine's UserStore so admin
11648/// tool startup screens can populate.
11649fn synth_pg_roles(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11650    let schema = alloc::vec![
11651        ColumnSchema::new("oid", DataType::BigInt, false),
11652        ColumnSchema::new("rolname", DataType::Text, false),
11653        ColumnSchema::new("rolsuper", DataType::Bool, false),
11654        ColumnSchema::new("rolinherit", DataType::Bool, false),
11655        ColumnSchema::new("rolcanlogin", DataType::Bool, false),
11656    ];
11657    let mut rows: Vec<Row> = Vec::new();
11658    let oid: i64 = 10;
11659    for (i, (name, _)) in engine.users.iter().enumerate() {
11660        rows.push(Row::new(alloc::vec![
11661            Value::BigInt(oid + (i as i64) + 1),
11662            Value::Text(name.to_string()),
11663            Value::Bool(false),
11664            Value::Bool(true),
11665            Value::Bool(true),
11666        ]));
11667    }
11668    // Always include `postgres` as the bootstrap superuser if not
11669    // already present — admin tools probe for it.
11670    if !rows
11671        .iter()
11672        .any(|r| matches!(&r.values[1], Value::Text(s) if s == "postgres"))
11673    {
11674        rows.insert(
11675            0,
11676            Row::new(alloc::vec![
11677                Value::BigInt(10),
11678                Value::Text("postgres".into()),
11679                Value::Bool(true),
11680                Value::Bool(true),
11681                Value::Bool(true),
11682            ]),
11683        );
11684    }
11685    (schema, rows)
11686}
11687
11688/// v7.17.0 Phase 3.P0-56 — synthesise `pg_catalog.pg_views`. PG's
11689/// pg_views is a view listing every catalog view; SPG ships one
11690/// row per declared view + its definition text.
11691/// Synthesise `pg_catalog.pg_extension`. SPG ships its "extension"
11692/// surfaces natively (vector, pg_trgm, plpgsql-shaped DO blocks), so
11693/// the table lists those as installed — `SELECT … FROM pg_extension
11694/// WHERE extname = 'vector'` probes from PG clients (mailrs embed
11695/// round-12) answer truthfully about capability presence.
11696fn synth_pg_extension() -> (Vec<ColumnSchema>, Vec<Row>) {
11697    let schema = alloc::vec![
11698        ColumnSchema::new("oid", DataType::BigInt, false),
11699        ColumnSchema::new("extname", DataType::Text, false),
11700        ColumnSchema::new("extversion", DataType::Text, false),
11701        ColumnSchema::new("extnamespace", DataType::Text, false),
11702    ];
11703    let exts: &[(&str, &str)] = &[("plpgsql", "1.0"), ("vector", "0.8.0"), ("pg_trgm", "1.6")];
11704    let rows = exts
11705        .iter()
11706        .enumerate()
11707        .map(|(i, (name, ver))| {
11708            Row::new(alloc::vec![
11709                Value::BigInt(16384 + i as i64),
11710                Value::Text((*name).into()),
11711                Value::Text((*ver).into()),
11712                Value::Text("pg_catalog".into()),
11713            ])
11714        })
11715        .collect();
11716    (schema, rows)
11717}
11718
11719fn synth_pg_views(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11720    let schema = alloc::vec![
11721        ColumnSchema::new("schemaname", DataType::Text, false),
11722        ColumnSchema::new("viewname", DataType::Text, false),
11723        ColumnSchema::new("definition", DataType::Text, false),
11724    ];
11725    let mut rows: Vec<Row> = Vec::new();
11726    for (name, def) in cat.views() {
11727        rows.push(Row::new(alloc::vec![
11728            Value::Text("public".into()),
11729            Value::Text(name.clone()),
11730            Value::Text(def.body.clone()),
11731        ]));
11732    }
11733    (schema, rows)
11734}
11735
11736/// v7.17.0 Phase 3.P0-57 — synthesise `pg_catalog.pg_settings`. ORM
11737/// connection-checkers (sqlx pre-flight, Diesel migrator) and admin
11738/// tools read `pg_settings` to discover server-side configuration.
11739/// SPG surfaces every session_param + a small set of canonical PG
11740/// defaults so the pre-flight queries match.
11741fn synth_pg_settings(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11742    let schema = alloc::vec![
11743        ColumnSchema::new("name", DataType::Text, false),
11744        ColumnSchema::new("setting", DataType::Text, false),
11745        ColumnSchema::new("category", DataType::Text, false),
11746    ];
11747    let mut rows: Vec<Row> = Vec::new();
11748    // Canonical defaults every admin tool expects to find.
11749    let defaults: &[(&str, &str, &str)] = &[
11750        ("server_version", "16.0 (spg)", "Preset Options"),
11751        ("server_encoding", "UTF8", "Client Connection Defaults"),
11752        ("client_encoding", "UTF8", "Client Connection Defaults"),
11753        ("DateStyle", "ISO, MDY", "Client Connection Defaults"),
11754        ("TimeZone", "UTC", "Client Connection Defaults"),
11755        ("standard_conforming_strings", "on", "Compatibility"),
11756        ("integer_datetimes", "on", "Compatibility"),
11757        ("max_connections", "100", "Connections and Authentication"),
11758    ];
11759    for &(name, val, cat) in defaults {
11760        rows.push(Row::new(alloc::vec![
11761            Value::Text(name.into()),
11762            Value::Text(val.into()),
11763            Value::Text(cat.into()),
11764        ]));
11765    }
11766    // Session-set params override the static defaults.
11767    for (k, v) in &engine.session_params {
11768        if !defaults
11769            .iter()
11770            .any(|(n, _, _)| (*n).eq_ignore_ascii_case(k))
11771        {
11772            rows.push(Row::new(alloc::vec![
11773                Value::Text(k.clone()),
11774                Value::Text(v.clone()),
11775                Value::Text("Session".into()),
11776            ]));
11777        }
11778    }
11779    (schema, rows)
11780}
11781
11782/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_indexes`.
11783/// PG's pg_indexes is a real view on pg_index + pg_class + pg_attribute.
11784/// SPG ships it as a synthesised flat table so admin tools (pgAdmin,
11785/// DataGrip) can list indexes by tablename without joining four catalogs.
11786///
11787/// Schema columns exposed:
11788///   * schemaname (Text) — always `public`
11789///   * tablename (Text)
11790///   * indexname (Text)
11791///   * indexdef (Text) — best-effort CREATE INDEX DDL
11792fn synth_pg_indexes(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11793    let schema = alloc::vec![
11794        ColumnSchema::new("schemaname", DataType::Text, false),
11795        ColumnSchema::new("tablename", DataType::Text, false),
11796        ColumnSchema::new("indexname", DataType::Text, false),
11797        ColumnSchema::new("indexdef", DataType::Text, false),
11798    ];
11799    let mut rows: Vec<Row> = Vec::new();
11800    for tname in cat.table_names() {
11801        let Some(t) = cat.get(&tname) else { continue };
11802        for idx in t.indices() {
11803            let col_name = t
11804                .schema()
11805                .columns
11806                .get(idx.column_position)
11807                .map_or("?".into(), |c| c.name.clone());
11808            let unique_kw = if idx.is_unique { "UNIQUE " } else { "" };
11809            let indexdef = alloc::format!(
11810                "CREATE {unique_kw}INDEX {} ON public.{} ({})",
11811                idx.name,
11812                tname,
11813                col_name
11814            );
11815            rows.push(Row::new(alloc::vec![
11816                Value::Text("public".into()),
11817                Value::Text(tname.clone()),
11818                Value::Text(idx.name.clone()),
11819                Value::Text(indexdef),
11820            ]));
11821        }
11822    }
11823    (schema, rows)
11824}
11825
11826/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_index`. The
11827/// "raw" pg_index catalog used by PG-internal tooling for index
11828/// flags and ordinal information. SPG ships the columns ORM probes
11829/// actually filter on.
11830///
11831/// Schema columns exposed:
11832///   * indexrelid (BigInt) — index OID (synthetic = position+1)
11833///   * indrelid (BigInt) — table OID (synthetic = position+1)
11834///   * indnatts (Int) — number of indexed columns
11835///   * indisunique (Bool)
11836///   * indisprimary (Bool)
11837fn synth_pg_index_raw(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11838    let schema = alloc::vec![
11839        ColumnSchema::new("indexrelid", DataType::BigInt, false),
11840        ColumnSchema::new("indrelid", DataType::BigInt, false),
11841        ColumnSchema::new("indnatts", DataType::Int, false),
11842        ColumnSchema::new("indisunique", DataType::Bool, false),
11843        ColumnSchema::new("indisprimary", DataType::Bool, false),
11844    ];
11845    let mut rows: Vec<Row> = Vec::new();
11846    let mut idx_oid: i64 = 100_000;
11847    for (table_idx, tname) in cat.table_names().iter().enumerate() {
11848        let Some(t) = cat.get(tname) else { continue };
11849        for idx in t.indices() {
11850            idx_oid += 1;
11851            #[allow(clippy::cast_possible_wrap)]
11852            let nattrs = (1 + idx.extra_column_positions.len()) as i32;
11853            // is_primary: SPG / PG flag the primary via the
11854            // index name convention `<table>_pkey`.
11855            let is_primary = idx.name.ends_with("_pkey");
11856            rows.push(Row::new(alloc::vec![
11857                Value::BigInt(idx_oid),
11858                Value::BigInt((table_idx + 1) as i64),
11859                Value::Int(nattrs),
11860                Value::Bool(idx.is_unique),
11861                Value::Bool(is_primary),
11862            ]));
11863        }
11864    }
11865    (schema, rows)
11866}
11867
11868/// v7.17.0 Phase 3.P0-52 — synthesise `pg_catalog.pg_namespace`.
11869/// SPG is single-schema so we expose the canonical PG schemas:
11870/// `public` (user-facing), `pg_catalog` (built-in), and
11871/// `information_schema` (PG meta).
11872fn synth_pg_namespace(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11873    let schema = alloc::vec![
11874        ColumnSchema::new("oid", DataType::BigInt, false),
11875        ColumnSchema::new("nspname", DataType::Text, false),
11876        ColumnSchema::new("nspowner", DataType::BigInt, false),
11877    ];
11878    let rows = alloc::vec![
11879        Row::new(alloc::vec![
11880            Value::BigInt(11),
11881            Value::Text("pg_catalog".into()),
11882            Value::BigInt(10),
11883        ]),
11884        Row::new(alloc::vec![
11885            Value::BigInt(2200),
11886            Value::Text("public".into()),
11887            Value::BigInt(10),
11888        ]),
11889        Row::new(alloc::vec![
11890            Value::BigInt(13000),
11891            Value::Text("information_schema".into()),
11892            Value::BigInt(10),
11893        ]),
11894    ];
11895    (schema, rows)
11896}
11897
11898/// v7.16.2 — drop the synthesised meta view into the enriched
11899/// catalog so the regular FROM-resolution path can see it.
11900fn materialise_meta_view(
11901    catalog: &mut Catalog,
11902    name: &str,
11903    columns: Vec<ColumnSchema>,
11904    rows: Vec<Row>,
11905) -> Result<(), EngineError> {
11906    let schema = TableSchema::new(name.to_string(), columns);
11907    catalog.create_table(schema).map_err(EngineError::Storage)?;
11908    let table = catalog
11909        .get_mut(name)
11910        .expect("just-created meta view must exist");
11911    for row in rows {
11912        table.insert(row).map_err(EngineError::Storage)?;
11913    }
11914    Ok(())
11915}
11916
11917/// v7.16.2 — true when the SELECT statement references any
11918/// `__spg_info_*` or `__spg_pg_*` synthetic table name (the
11919/// parser produces these for `information_schema.X` /
11920/// `pg_catalog.X`). Used by `exec_select_cancel` to short-
11921/// circuit into the meta-view materialisation path.
11922/// v7.17.0 Phase 1.2 — append the names of any catalog-known
11923/// views referenced by `tref` to `into`. Helper for
11924/// `Engine::expand_views_in_select`. A view that's been already
11925/// materialised as a table (e.g. via the synthetic CTE pass for
11926/// SELECT FROM v) is skipped — the table form wins so the
11927/// recursive exec_select_cancel call inside exec_with_ctes
11928/// doesn't re-expand and trigger the CTE-shadow guard.
11929fn collect_view_refs(
11930    tref: &spg_sql::ast::TableRef,
11931    cat: &spg_storage::Catalog,
11932    into: &mut Vec<String>,
11933) {
11934    if cat.views().contains_key(&tref.name)
11935        && cat.get(&tref.name).is_none()
11936        && !into.iter().any(|n| n == &tref.name)
11937    {
11938        into.push(tref.name.clone());
11939    }
11940}
11941
11942fn select_references_meta_view(stmt: &SelectStatement) -> bool {
11943    fn is_meta(name: &str) -> bool {
11944        name.starts_with("__spg_info_")
11945            || name.starts_with("__spg_pg_")
11946            || name.starts_with("__spg_mysql_")
11947    }
11948    if let Some(from) = &stmt.from {
11949        if is_meta(&from.primary.name) {
11950            return true;
11951        }
11952        for j in &from.joins {
11953            if is_meta(&j.table.name) {
11954                return true;
11955            }
11956        }
11957    }
11958    for cte in &stmt.ctes {
11959        if select_references_meta_view(&cte.body) {
11960            return true;
11961        }
11962    }
11963    false
11964}
11965
11966/// v7.16.2 — collect every meta-view name a SELECT touches.
11967/// Returns a deduplicated, sorted list. Caller materialises
11968/// each one into the enriched catalog before re-running the
11969/// SELECT. Walks JOINs, CTEs, and the primary FROM.
11970fn collect_meta_view_names(
11971    stmt: &SelectStatement,
11972    into: &mut alloc::collections::BTreeSet<String>,
11973) {
11974    fn is_meta(name: &str) -> bool {
11975        name.starts_with("__spg_info_")
11976            || name.starts_with("__spg_pg_")
11977            || name.starts_with("__spg_mysql_")
11978    }
11979    if let Some(from) = &stmt.from {
11980        if is_meta(&from.primary.name) {
11981            into.insert(from.primary.name.clone());
11982        }
11983        for j in &from.joins {
11984            if is_meta(&j.table.name) {
11985                into.insert(j.table.name.clone());
11986            }
11987        }
11988    }
11989    for cte in &stmt.ctes {
11990        collect_meta_view_names(&cte.body, into);
11991    }
11992}
11993
11994fn infer_column_types(columns: &[ColumnSchema], rows: &[Row]) -> Vec<ColumnSchema> {
11995    let mut out = columns.to_vec();
11996    for (col_idx, col) in out.iter_mut().enumerate() {
11997        if col.ty != DataType::Text {
11998            continue;
11999        }
12000        let mut inferred: Option<DataType> = None;
12001        let mut all_null = true;
12002        for row in rows {
12003            let Some(v) = row.values.get(col_idx) else {
12004                continue;
12005            };
12006            let ty = match v {
12007                Value::Null => continue,
12008                Value::SmallInt(_) => DataType::SmallInt,
12009                Value::Int(_) => DataType::Int,
12010                Value::BigInt(_) => DataType::BigInt,
12011                Value::Float(_) => DataType::Float,
12012                Value::Bool(_) => DataType::Bool,
12013                Value::Vector(_) => DataType::Vector {
12014                    dim: 0,
12015                    encoding: VecEncoding::F32,
12016                },
12017                _ => DataType::Text,
12018            };
12019            all_null = false;
12020            inferred = Some(match inferred {
12021                None => ty,
12022                Some(prev) if prev == ty => prev,
12023                Some(_) => DataType::Text,
12024            });
12025        }
12026        if let Some(t) = inferred {
12027            col.ty = t;
12028            col.nullable = true;
12029        } else if all_null {
12030            col.nullable = true;
12031        }
12032    }
12033    out
12034}
12035
12036/// v4.26: render a human-readable plan tree for `EXPLAIN <select>`.
12037/// Lines are pushed into `out`; `depth` controls indentation. We
12038/// describe the rewritten SELECT — what the executor *would* do —
12039/// using the engine handle to spot indexed lookups and table shapes.
12040#[allow(clippy::too_many_lines, clippy::format_push_string)]
12041/// v6.2.4 — Walk every line of the rendered plan tree and append
12042/// per-operator stats. Lines that name a known operator get
12043/// `(rows=N)` (`actual_rows` of the top-level operator equals the
12044/// final result row count; scans report their catalog row count
12045/// as the rows-considered metric). Other lines — Filter / Join /
12046/// GroupBy / OrderBy etc. — are marked `(—)` so the surface is
12047/// complete-by-construction; v6.2.5 fills these in via inline
12048/// executor counters.
12049/// v6.8.3 — surface "CREATE INDEX …" suggestions for every
12050/// `(table, column)` pair the query touches via WHERE / JOIN
12051/// that doesn't already have an index on the owning table.
12052/// Walks the SELECT's FROM clauses + WHERE expression tree;
12053/// returns one line per missing index. Deterministic order:
12054/// FROM-clause iteration order, then column-reference walk
12055/// order inside each WHERE. Each suggestion is a copy-pastable
12056/// DDL string.
12057fn build_index_suggestions(stmt: &SelectStatement, engine: &Engine) -> Vec<String> {
12058    use alloc::collections::BTreeSet;
12059    let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
12060    let mut out: Vec<String> = Vec::new();
12061    let cat = engine.active_catalog();
12062    // Build a (table, qualifier-or-alias) list from the FROM clause
12063    // so unqualified column refs in WHERE resolve to the correct
12064    // table.
12065    let Some(from) = &stmt.from else {
12066        return out;
12067    };
12068    let mut tables: Vec<String> = Vec::new();
12069    tables.push(from.primary.name.clone());
12070    for j in &from.joins {
12071        tables.push(j.table.name.clone());
12072    }
12073    // Collect column refs from the WHERE expression. JOIN ON
12074    // predicates also feed in.
12075    let mut col_refs: Vec<spg_sql::ast::ColumnName> = Vec::new();
12076    if let Some(w) = &stmt.where_ {
12077        collect_column_refs(w, &mut col_refs);
12078    }
12079    for j in &from.joins {
12080        if let Some(on) = &j.on {
12081            collect_column_refs(on, &mut col_refs);
12082        }
12083    }
12084    for cn in &col_refs {
12085        // Resolve owner table: explicit qualifier first, else
12086        // first table in FROM that has a column of this name.
12087        let owner: Option<String> = if let Some(q) = &cn.qualifier {
12088            tables.iter().find(|t| t == &q).cloned()
12089        } else {
12090            tables.iter().find_map(|t| {
12091                cat.get(t).and_then(|tbl| {
12092                    if tbl.schema().column_position(&cn.name).is_some() {
12093                        Some(t.clone())
12094                    } else {
12095                        None
12096                    }
12097                })
12098            })
12099        };
12100        let Some(owner) = owner else {
12101            continue;
12102        };
12103        let Some(tbl) = cat.get(&owner) else {
12104            continue;
12105        };
12106        let Some(col_pos) = tbl.schema().column_position(&cn.name) else {
12107            continue;
12108        };
12109        // Skip if any BTree index already covers this column as
12110        // its key.
12111        let already_indexed = tbl.indices().iter().any(|i| {
12112            matches!(i.kind, spg_storage::IndexKind::BTree(_))
12113                && i.column_position == col_pos
12114                && i.expression.is_none()
12115                && i.partial_predicate.is_none()
12116        });
12117        if already_indexed {
12118            continue;
12119        }
12120        if seen.insert((owner.clone(), cn.name.clone())) {
12121            out.push(alloc::format!(
12122                "SUGGEST: CREATE INDEX ix_{}_{} ON {} ({})",
12123                owner,
12124                cn.name,
12125                owner,
12126                cn.name
12127            ));
12128        }
12129    }
12130    out
12131}
12132
12133/// Walks an `Expr` and pushes every `ColumnName` it references.
12134/// Order is depth-first, left-to-right.
12135fn collect_column_refs(expr: &Expr, out: &mut Vec<spg_sql::ast::ColumnName>) {
12136    match expr {
12137        Expr::Column(cn) => out.push(cn.clone()),
12138        Expr::FunctionCall { args, .. } => {
12139            for a in args {
12140                collect_column_refs(a, out);
12141            }
12142        }
12143        Expr::Binary { lhs, rhs, .. } => {
12144            collect_column_refs(lhs, out);
12145            collect_column_refs(rhs, out);
12146        }
12147        Expr::Unary { expr: e, .. } => collect_column_refs(e, out),
12148        _ => {}
12149    }
12150}
12151
12152fn annotate_explain_lines(lines: &mut [String], total_rows: usize, engine: &Engine) {
12153    let catalog = engine.active_catalog();
12154    let cold_ids = catalog.cold_segment_ids_global();
12155    let any_cold = !cold_ids.is_empty();
12156    let cold_ids_repr = if any_cold {
12157        let mut s = alloc::string::String::from("[");
12158        for (i, id) in cold_ids.iter().enumerate() {
12159            if i > 0 {
12160                s.push(',');
12161            }
12162            s.push_str(&alloc::format!("{id}"));
12163        }
12164        s.push(']');
12165        s
12166    } else {
12167        alloc::string::String::new()
12168    };
12169    for (idx, line) in lines.iter_mut().enumerate() {
12170        let trimmed = line.trim_start();
12171        let is_top_level = idx == 0;
12172        if is_top_level {
12173            line.push_str(&alloc::format!(" (rows={total_rows})"));
12174            continue;
12175        }
12176        if let Some(rest) = trimmed.strip_prefix("From: ") {
12177            let (name, scan_kind) = match rest.split_once(" [") {
12178                Some((n, k)) => (n.trim(), k.trim_end_matches(']')),
12179                None => (rest.trim(), ""),
12180            };
12181            let bare = name.split_whitespace().next().unwrap_or(name);
12182            let hot = catalog.get(bare).map(|t| t.rows().len());
12183            // v6.2.7 — `cold_segments=[id0,id1,…]` enumerates every
12184            // cold-tier segment the scan COULD have walked. v6.2.x
12185            // can tighten to per-table by walking the table's
12186            // BTree-index cold locators.
12187            let annot = match (hot, scan_kind) {
12188                (Some(h), "full scan") => {
12189                    let mut s = alloc::format!(" (hot_rows={h}");
12190                    if any_cold {
12191                        s.push_str(&alloc::format!(
12192                            ", cold_tier=present, cold_segments={cold_ids_repr}"
12193                        ));
12194                    }
12195                    s.push(')');
12196                    s
12197                }
12198                (Some(h), "index seek") => {
12199                    let mut s = alloc::format!(" (hot_rows≤{h}");
12200                    if any_cold {
12201                        s.push_str(&alloc::format!(
12202                            ", cold_tier=present, cold_segments={cold_ids_repr}"
12203                        ));
12204                    }
12205                    s.push(')');
12206                    s
12207                }
12208                _ => " (rows=—)".to_string(),
12209            };
12210            line.push_str(&annot);
12211            continue;
12212        }
12213        // Filter / GroupBy / Having / OrderBy / Limit / Join etc.
12214        line.push_str(" (rows=—)");
12215    }
12216}
12217
12218fn explain_select(stmt: &SelectStatement, engine: &Engine, depth: usize, out: &mut Vec<String>) {
12219    let pad = "  ".repeat(depth);
12220    // 1) Top-level operator label.
12221    let top = if !stmt.ctes.is_empty() {
12222        if stmt.ctes.iter().any(|c| c.recursive) {
12223            "CTEScan (WITH RECURSIVE)"
12224        } else {
12225            "CTEScan (WITH)"
12226        }
12227    } else if !stmt.unions.is_empty() {
12228        "UnionScan"
12229    } else if select_has_window(stmt) {
12230        "WindowAgg"
12231    } else if aggregate::uses_aggregate(stmt) {
12232        "Aggregate"
12233    } else if stmt.distinct {
12234        "Distinct"
12235    } else if stmt.from.is_some() {
12236        "TableScan"
12237    } else {
12238        "Result"
12239    };
12240    out.push(alloc::format!("{pad}{top}"));
12241    let child = "  ".repeat(depth + 1);
12242    // 2) CTE bodies.
12243    for cte in &stmt.ctes {
12244        let head = if cte.recursive {
12245            alloc::format!("{child}CTE (recursive): {}", cte.name)
12246        } else {
12247            alloc::format!("{child}CTE: {}", cte.name)
12248        };
12249        out.push(head);
12250        explain_select(&cte.body, engine, depth + 2, out);
12251    }
12252    // 3) FROM details — primary table + joins, index hits.
12253    if let Some(from) = &stmt.from {
12254        let mut tag = alloc::format!("{child}From: {}", from.primary.name);
12255        if let Some(alias) = &from.primary.alias {
12256            tag.push_str(&alloc::format!(" AS {alias}"));
12257        }
12258        // Try to detect an index-seek opportunity on WHERE against
12259        // the primary table — same heuristic the executor uses.
12260        if let Some(w) = &stmt.where_
12261            && let Some(table) = engine.active_catalog().get(&from.primary.name)
12262        {
12263            let alias = from.primary.alias.as_deref().unwrap_or(&from.primary.name);
12264            let cols = &table.schema().columns;
12265            if try_index_seek(w, cols, engine.active_catalog(), table, alias).is_some() {
12266                tag.push_str(" [index seek]");
12267            } else {
12268                tag.push_str(" [full scan]");
12269            }
12270        } else {
12271            tag.push_str(" [full scan]");
12272        }
12273        out.push(tag);
12274        for j in &from.joins {
12275            let kind = match j.kind {
12276                spg_sql::ast::JoinKind::Inner => "INNER JOIN",
12277                spg_sql::ast::JoinKind::Left => "LEFT JOIN",
12278                spg_sql::ast::JoinKind::Cross => "CROSS JOIN",
12279            };
12280            let mut s = alloc::format!("{child}{kind}: {}", j.table.name);
12281            if let Some(alias) = &j.table.alias {
12282                s.push_str(&alloc::format!(" AS {alias}"));
12283            }
12284            if j.on.is_some() {
12285                s.push_str(" (ON …)");
12286            }
12287            out.push(s);
12288        }
12289    }
12290    // 4) WHERE / GROUP BY / HAVING / ORDER BY / LIMIT / OFFSET.
12291    if let Some(w) = &stmt.where_ {
12292        let mut s = alloc::format!("{child}Filter: {w}");
12293        if expr_has_subquery(w) {
12294            s.push_str(" [subquery]");
12295        }
12296        out.push(s);
12297    }
12298    if let Some(gs) = &stmt.group_by {
12299        let mut parts = Vec::new();
12300        for g in gs {
12301            parts.push(alloc::format!("{g}"));
12302        }
12303        out.push(alloc::format!("{child}GroupBy: {}", parts.join(", ")));
12304    }
12305    if let Some(h) = &stmt.having {
12306        out.push(alloc::format!("{child}Having: {h}"));
12307    }
12308    for o in &stmt.order_by {
12309        let dir = if o.desc { "DESC" } else { "ASC" };
12310        out.push(alloc::format!("{child}OrderBy: {} {dir}", o.expr));
12311    }
12312    if let Some(lim) = stmt.limit {
12313        out.push(alloc::format!("{child}Limit: {lim}"));
12314    }
12315    if let Some(off) = stmt.offset {
12316        out.push(alloc::format!("{child}Offset: {off}"));
12317    }
12318    // 5) Projection — collapse Wildcard or render N items.
12319    if stmt
12320        .items
12321        .iter()
12322        .any(|it| matches!(it, SelectItem::Wildcard))
12323    {
12324        out.push(alloc::format!("{child}Project: *"));
12325    } else {
12326        out.push(alloc::format!(
12327            "{child}Project: {} item(s)",
12328            stmt.items.len()
12329        ));
12330    }
12331    // 6) Recurse into UNION peers.
12332    for (kind, peer) in &stmt.unions {
12333        let label = match kind {
12334            UnionKind::All => "UNION ALL",
12335            UnionKind::Distinct => "UNION",
12336        };
12337        out.push(alloc::format!("{child}{label}"));
12338        explain_select(peer, engine, depth + 2, out);
12339    }
12340}
12341
12342/// v4.23: recognise the engine errors that indicate the inner
12343/// SELECT couldn't be evaluated in isolation because it references
12344/// an outer column — used by `subquery_replacement` to skip
12345/// materialisation and let row-eval handle it instead.
12346fn is_correlation_error(e: &EngineError) -> bool {
12347    matches!(
12348        e,
12349        EngineError::Eval(
12350            eval::EvalError::ColumnNotFound { .. } | eval::EvalError::UnknownQualifier { .. }
12351        )
12352    )
12353}
12354
12355/// v4.23: walk every Expr in `stmt` and replace each Column ref
12356/// that targets the outer scope (qualifier matches the outer
12357/// table alias) with a Literal carrying the outer row's value.
12358/// Conservative: only qualified refs are substituted, so the user
12359/// must write `outer_alias.col` to reference an outer column. This
12360/// matches PG's lexical scoping for correlated subqueries and
12361/// avoids accidentally rebinding inner columns of the same name.
12362/// v7.17.0 Phase 3.P0-41 — LATERAL peer descriptor. Either eagerly
12363/// materialised (every regular table / unnest / generate_series) or
12364/// lateral (subquery re-evaluated per outer row).
12365struct JoinedPeer<'a> {
12366    eager_rows: Option<Vec<Row>>,
12367    cols: Vec<ColumnSchema>,
12368    alias: String,
12369    kind: JoinKind,
12370    on: Option<&'a Expr>,
12371    lateral: Option<&'a SelectStatement>,
12372}
12373
12374/// v7.17.0 Phase 3.P0-41 — synthesise a column name for a LATERAL
12375/// projection item that has no explicit alias. PG names anonymous
12376/// projection items by the function call's name or by `column<i>`.
12377/// SPG mirrors the latter (lower-overhead than walking arbitrary
12378/// Expr shapes) so the probe-schema fallback path produces stable
12379/// names for the lateral peer's columns.
12380fn synth_lateral_col_name(expr: &Expr, idx: usize) -> String {
12381    match expr {
12382        // Bare column reference — use the column's own name.
12383        Expr::Column(c) => c.name.clone(),
12384        // Function call — use the function name (PG canonical:
12385        // `count` / `max` / `lower` …).
12386        Expr::FunctionCall { name, .. } => name.clone(),
12387        // Cast — drill into the inner expression.
12388        Expr::Cast { expr: inner, .. } => synth_lateral_col_name(inner, idx),
12389        // Everything else falls back to PG's `column<N>` placeholder.
12390        _ => alloc::format!("column{}", idx + 1),
12391    }
12392}
12393
12394/// v7.17.0 Phase 3.P0-41 — substitute every `<alias>.<col>` Expr
12395/// reference whose `<alias>.<col>` exists in the outer composite
12396/// schema with the matching value from the outer row. Walks the
12397/// entire SELECT body (items, WHERE, GROUP BY, HAVING, ORDER BY,
12398/// UNION peers) so any depth of outer reference inside the
12399/// LATERAL subquery resolves before execution.
12400fn substitute_outer_columns_multi(
12401    stmt: &mut SelectStatement,
12402    outer_row: &Row,
12403    outer_schema: &[ColumnSchema],
12404) {
12405    substitute_outer_in_select(stmt, outer_row, outer_schema);
12406}
12407
12408fn substitute_outer_in_select(
12409    stmt: &mut SelectStatement,
12410    outer_row: &Row,
12411    outer_schema: &[ColumnSchema],
12412) {
12413    for item in &mut stmt.items {
12414        if let SelectItem::Expr { expr, .. } = item {
12415            substitute_outer_in_expr(expr, outer_row, outer_schema);
12416        }
12417    }
12418    if let Some(w) = &mut stmt.where_ {
12419        substitute_outer_in_expr(w, outer_row, outer_schema);
12420    }
12421    if let Some(gs) = &mut stmt.group_by {
12422        for g in gs {
12423            substitute_outer_in_expr(g, outer_row, outer_schema);
12424        }
12425    }
12426    if let Some(h) = &mut stmt.having {
12427        substitute_outer_in_expr(h, outer_row, outer_schema);
12428    }
12429    for o in &mut stmt.order_by {
12430        substitute_outer_in_expr(&mut o.expr, outer_row, outer_schema);
12431    }
12432    for (_, peer) in &mut stmt.unions {
12433        substitute_outer_in_select(peer, outer_row, outer_schema);
12434    }
12435}
12436
12437fn substitute_outer_in_expr(e: &mut Expr, outer_row: &Row, outer_schema: &[ColumnSchema]) {
12438    if let Expr::Column(c) = e
12439        && let Some(qual) = &c.qualifier
12440    {
12441        let composite = alloc::format!("{qual}.{}", c.name);
12442        if let Some(idx) = outer_schema
12443            .iter()
12444            .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
12445        {
12446            let v = outer_row.values.get(idx).cloned().unwrap_or(Value::Null);
12447            if let Ok(lit) = value_to_literal_expr(v) {
12448                *e = lit;
12449                return;
12450            }
12451        }
12452    }
12453    match e {
12454        Expr::Binary { lhs, rhs, .. } => {
12455            substitute_outer_in_expr(lhs, outer_row, outer_schema);
12456            substitute_outer_in_expr(rhs, outer_row, outer_schema);
12457        }
12458        Expr::Unary { expr: inner, .. } => {
12459            substitute_outer_in_expr(inner, outer_row, outer_schema);
12460        }
12461        Expr::FunctionCall { args, .. } => {
12462            for a in args {
12463                substitute_outer_in_expr(a, outer_row, outer_schema);
12464            }
12465        }
12466        Expr::Cast { expr: inner, .. } => {
12467            substitute_outer_in_expr(inner, outer_row, outer_schema);
12468        }
12469        Expr::Case {
12470            operand,
12471            branches,
12472            else_branch,
12473        } => {
12474            if let Some(op) = operand {
12475                substitute_outer_in_expr(op, outer_row, outer_schema);
12476            }
12477            for (cond, val) in branches {
12478                substitute_outer_in_expr(cond, outer_row, outer_schema);
12479                substitute_outer_in_expr(val, outer_row, outer_schema);
12480            }
12481            if let Some(e) = else_branch {
12482                substitute_outer_in_expr(e, outer_row, outer_schema);
12483            }
12484        }
12485        _ => {}
12486    }
12487}
12488
12489fn substitute_outer_columns(stmt: &mut SelectStatement, row: &Row, ctx: &EvalContext<'_>) {
12490    // v7.24 (round-16 B) — joined outer contexts carry no single
12491    // table alias; their schemas use composite "alias.column" names
12492    // instead. Pass an unmatchable alias and let the composite
12493    // lookup in substitute_in_expr do the work (a correlated EXISTS
12494    // under a JOIN previously skipped substitution entirely and
12495    // died with "unknown table qualifier").
12496    let outer_alias = ctx.table_alias.unwrap_or("");
12497    substitute_in_select(stmt, row, ctx, outer_alias);
12498}
12499
12500fn substitute_in_select(
12501    stmt: &mut SelectStatement,
12502    row: &Row,
12503    ctx: &EvalContext<'_>,
12504    outer_alias: &str,
12505) {
12506    for item in &mut stmt.items {
12507        if let SelectItem::Expr { expr, .. } = item {
12508            substitute_in_expr(expr, row, ctx, outer_alias);
12509        }
12510    }
12511    if let Some(w) = &mut stmt.where_ {
12512        substitute_in_expr(w, row, ctx, outer_alias);
12513    }
12514    if let Some(gs) = &mut stmt.group_by {
12515        for g in gs {
12516            substitute_in_expr(g, row, ctx, outer_alias);
12517        }
12518    }
12519    if let Some(h) = &mut stmt.having {
12520        substitute_in_expr(h, row, ctx, outer_alias);
12521    }
12522    for o in &mut stmt.order_by {
12523        substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
12524    }
12525    for (_, peer) in &mut stmt.unions {
12526        substitute_in_select(peer, row, ctx, outer_alias);
12527    }
12528}
12529
12530fn substitute_in_expr(e: &mut Expr, row: &Row, ctx: &EvalContext<'_>, outer_alias: &str) {
12531    if let Expr::Column(c) = e
12532        && let Some(qual) = &c.qualifier
12533    {
12534        // Look up the column's index in the outer schema: plain name
12535        // when the qualifier is the outer table's alias, composite
12536        // "alias.column" for joined outer schemas (v7.24).
12537        let idx = if !outer_alias.is_empty() && qual.eq_ignore_ascii_case(outer_alias) {
12538            ctx.columns
12539                .iter()
12540                .position(|sc| sc.name.eq_ignore_ascii_case(&c.name))
12541        } else {
12542            None
12543        }
12544        .or_else(|| {
12545            let composite = alloc::format!("{qual}.{name}", name = c.name);
12546            ctx.columns
12547                .iter()
12548                .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
12549        });
12550        if let Some(idx) = idx {
12551            let v = row.values.get(idx).cloned().unwrap_or(Value::Null);
12552            if let Ok(lit) = value_to_literal_expr(v) {
12553                *e = lit;
12554                return;
12555            }
12556        }
12557    }
12558    match e {
12559        Expr::AggregateOrdered { call, order_by } => {
12560            substitute_in_expr(call, row, ctx, outer_alias);
12561            for o in order_by.iter_mut() {
12562                substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
12563            }
12564        }
12565        Expr::Binary { lhs, rhs, .. } => {
12566            substitute_in_expr(lhs, row, ctx, outer_alias);
12567            substitute_in_expr(rhs, row, ctx, outer_alias);
12568        }
12569        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12570            substitute_in_expr(expr, row, ctx, outer_alias);
12571        }
12572        Expr::Like { expr, pattern, .. } => {
12573            substitute_in_expr(expr, row, ctx, outer_alias);
12574            substitute_in_expr(pattern, row, ctx, outer_alias);
12575        }
12576        Expr::FunctionCall { args, .. } => {
12577            for a in args {
12578                substitute_in_expr(a, row, ctx, outer_alias);
12579            }
12580        }
12581        Expr::Extract { source, .. } => substitute_in_expr(source, row, ctx, outer_alias),
12582        Expr::WindowFunction {
12583            args,
12584            partition_by,
12585            order_by,
12586            ..
12587        } => {
12588            for a in args {
12589                substitute_in_expr(a, row, ctx, outer_alias);
12590            }
12591            for p in partition_by {
12592                substitute_in_expr(p, row, ctx, outer_alias);
12593            }
12594            for (o, _, _) in order_by {
12595                substitute_in_expr(o, row, ctx, outer_alias);
12596            }
12597        }
12598        Expr::ScalarSubquery(s) => substitute_in_select(s, row, ctx, outer_alias),
12599        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
12600            substitute_in_select(subquery, row, ctx, outer_alias);
12601        }
12602        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
12603        Expr::Array(items) => {
12604            for elem in items {
12605                substitute_in_expr(elem, row, ctx, outer_alias);
12606            }
12607        }
12608        Expr::ArraySubscript { target, index } => {
12609            substitute_in_expr(target, row, ctx, outer_alias);
12610            substitute_in_expr(index, row, ctx, outer_alias);
12611        }
12612        Expr::AnyAll { expr, array, .. } => {
12613            substitute_in_expr(expr, row, ctx, outer_alias);
12614            substitute_in_expr(array, row, ctx, outer_alias);
12615        }
12616        Expr::Case {
12617            operand,
12618            branches,
12619            else_branch,
12620        } => {
12621            if let Some(o) = operand {
12622                substitute_in_expr(o, row, ctx, outer_alias);
12623            }
12624            for (w, t) in branches {
12625                substitute_in_expr(w, row, ctx, outer_alias);
12626                substitute_in_expr(t, row, ctx, outer_alias);
12627            }
12628            if let Some(e) = else_branch {
12629                substitute_in_expr(e, row, ctx, outer_alias);
12630            }
12631        }
12632    }
12633}
12634
12635/// v4.22: encode a Row to a comparable byte key for UNION-DISTINCT
12636/// dedup inside the recursive iteration. Crude but deterministic
12637/// — Debug prints embed type discriminants so NULL ≠ "" ≠ 0.
12638fn encode_row_key(row: &Row) -> Vec<u8> {
12639    let mut out = Vec::new();
12640    for v in &row.values {
12641        let s = alloc::format!("{v:?}|");
12642        out.extend_from_slice(s.as_bytes());
12643    }
12644    out
12645}
12646
12647fn select_has_window(stmt: &SelectStatement) -> bool {
12648    for item in &stmt.items {
12649        if let SelectItem::Expr { expr, .. } = item
12650            && expr_has_window(expr)
12651        {
12652            return true;
12653        }
12654    }
12655    false
12656}
12657
12658fn expr_has_window(e: &Expr) -> bool {
12659    match e {
12660        Expr::WindowFunction { .. } => true,
12661        Expr::AggregateOrdered { call, order_by } => {
12662            expr_has_window(call) || order_by.iter().any(|o| expr_has_window(&o.expr))
12663        }
12664        Expr::Binary { lhs, rhs, .. } => expr_has_window(lhs) || expr_has_window(rhs),
12665        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12666            expr_has_window(expr)
12667        }
12668        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_window),
12669        Expr::Like { expr, pattern, .. } => expr_has_window(expr) || expr_has_window(pattern),
12670        Expr::Extract { source, .. } => expr_has_window(source),
12671        Expr::ScalarSubquery(_)
12672        | Expr::Exists { .. }
12673        | Expr::InSubquery { .. }
12674        | Expr::Literal(_)
12675        | Expr::Placeholder(_)
12676        | Expr::Column(_) => false,
12677        Expr::Array(items) => items.iter().any(expr_has_window),
12678        Expr::ArraySubscript { target, index } => expr_has_window(target) || expr_has_window(index),
12679        Expr::AnyAll { expr, array, .. } => expr_has_window(expr) || expr_has_window(array),
12680        Expr::Case {
12681            operand,
12682            branches,
12683            else_branch,
12684        } => {
12685            operand.as_deref().is_some_and(expr_has_window)
12686                || branches
12687                    .iter()
12688                    .any(|(w, t)| expr_has_window(w) || expr_has_window(t))
12689                || else_branch.as_deref().is_some_and(expr_has_window)
12690        }
12691    }
12692}
12693
12694fn collect_window_nodes(e: &Expr, out: &mut Vec<Expr>) {
12695    if let Expr::WindowFunction { .. } = e {
12696        // Deduplicate by structural equality on the expression
12697        // (cheap because window args + partition + order are
12698        // small). Without dedup we'd recompute identical windows
12699        // once per occurrence in the projection.
12700        if !out.iter().any(|x| x == e) {
12701            out.push(e.clone());
12702        }
12703        return;
12704    }
12705    match e {
12706        // Already handled by the early-return at the top.
12707        Expr::WindowFunction { .. } => unreachable!(),
12708        Expr::Binary { lhs, rhs, .. } => {
12709            collect_window_nodes(lhs, out);
12710            collect_window_nodes(rhs, out);
12711        }
12712        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12713            collect_window_nodes(expr, out);
12714        }
12715        Expr::FunctionCall { args, .. } => {
12716            for a in args {
12717                collect_window_nodes(a, out);
12718            }
12719        }
12720        Expr::Like { expr, pattern, .. } => {
12721            collect_window_nodes(expr, out);
12722            collect_window_nodes(pattern, out);
12723        }
12724        Expr::Extract { source, .. } => collect_window_nodes(source, out),
12725        _ => {}
12726    }
12727}
12728
12729fn rewrite_window_to_columns(e: &mut Expr, window_nodes: &[Expr]) {
12730    if let Expr::WindowFunction { .. } = e
12731        && let Some(idx) = window_nodes.iter().position(|w| w == e)
12732    {
12733        *e = Expr::Column(spg_sql::ast::ColumnName {
12734            qualifier: None,
12735            name: alloc::format!("__win_{idx}"),
12736        });
12737        return;
12738    }
12739    match e {
12740        Expr::Binary { lhs, rhs, .. } => {
12741            rewrite_window_to_columns(lhs, window_nodes);
12742            rewrite_window_to_columns(rhs, window_nodes);
12743        }
12744        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12745            rewrite_window_to_columns(expr, window_nodes);
12746        }
12747        Expr::FunctionCall { args, .. } => {
12748            for a in args {
12749                rewrite_window_to_columns(a, window_nodes);
12750            }
12751        }
12752        Expr::Like { expr, pattern, .. } => {
12753            rewrite_window_to_columns(expr, window_nodes);
12754            rewrite_window_to_columns(pattern, window_nodes);
12755        }
12756        Expr::Extract { source, .. } => rewrite_window_to_columns(source, window_nodes),
12757        _ => {}
12758    }
12759}
12760
12761/// Total order over partition-key tuples. NULL sorts as the
12762/// lowest value (matches the `<` partial order's NULL-last
12763/// behaviour with `INFINITY` flipped).
12764fn partition_key_cmp(a: &[Value], b: &[Value]) -> core::cmp::Ordering {
12765    for (x, y) in a.iter().zip(b.iter()) {
12766        let c = value_cmp(x, y);
12767        if c != core::cmp::Ordering::Equal {
12768            return c;
12769        }
12770    }
12771    a.len().cmp(&b.len())
12772}
12773
12774fn order_key_cmp(
12775    a: &[(Value, bool, Option<bool>)],
12776    b: &[(Value, bool, Option<bool>)],
12777) -> core::cmp::Ordering {
12778    // v7.24.1 — per-key DESC + effective NULLS placement (shared
12779    // contract with order_by_value_cmp).
12780    for ((va, desc, nf), (vb, _, _)) in a.iter().zip(b.iter()) {
12781        let c = order_by_value_cmp(*desc, *nf, va, vb);
12782        if c != core::cmp::Ordering::Equal {
12783            return c;
12784        }
12785    }
12786    a.len().cmp(&b.len())
12787}
12788
12789/// v7.17.0 Phase 3.10 — true when the Value is one of the
12790/// integer-shaped variants `generate_series` accepts as a start
12791/// / stop / step component. Float / NUMERIC are rejected — PG's
12792/// `generate_series(numeric, numeric)` overload is out of v7.17
12793/// scope.
12794const fn value_is_integer(v: &Value) -> bool {
12795    matches!(v, Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_))
12796}
12797
12798/// v7.17.0 Phase 3.10 — widen any integer-shaped Value to i64 for
12799/// the generate_series iteration loop. Non-integer inputs panic;
12800/// caller guards via `value_is_integer`.
12801const fn value_to_i64(v: &Value) -> i64 {
12802    match v {
12803        Value::SmallInt(n) => *n as i64,
12804        Value::Int(n) => *n as i64,
12805        Value::BigInt(n) => *n,
12806        _ => panic!("value_to_i64 called on non-integer Value"),
12807    }
12808}
12809
12810/// v7.17.0 Phase 3.10 — integer-mode generate_series materialiser.
12811/// Step direction follows the sign: positive step iterates upward
12812/// (stops when current > stop); negative iterates downward; zero
12813/// errors. Caller-facing row stream is `BigInt`-typed so a single
12814/// projection schema covers SmallInt / Int / BigInt callers.
12815fn generate_series_integers(
12816    start: i64,
12817    stop: i64,
12818    step: i64,
12819    cancel: &CancelToken<'_>,
12820) -> Result<alloc::vec::Vec<Row>, EngineError> {
12821    if step == 0 {
12822        return Err(EngineError::Unsupported(
12823            "generate_series(): step argument cannot be zero".into(),
12824        ));
12825    }
12826    let mut out = alloc::vec::Vec::new();
12827    let mut cur = start;
12828    // Hard cap to keep a runaway call from eating all memory. PG
12829    // has no such cap but does honour query timeout; SPG's cancel
12830    // token will fire too — this is a defense-in-depth backstop.
12831    const MAX_ROWS: usize = 10_000_000;
12832    loop {
12833        cancel.check()?;
12834        if step > 0 && cur > stop {
12835            break;
12836        }
12837        if step < 0 && cur < stop {
12838            break;
12839        }
12840        out.push(Row::new(alloc::vec![Value::BigInt(cur)]));
12841        if out.len() > MAX_ROWS {
12842            return Err(EngineError::Unsupported(alloc::format!(
12843                "generate_series(): exceeded {MAX_ROWS} rows; \
12844                 narrow start/stop or use a larger step"
12845            )));
12846        }
12847        cur = match cur.checked_add(step) {
12848            Some(n) => n,
12849            None => break,
12850        };
12851    }
12852    Ok(out)
12853}
12854
12855/// v7.17.0 Phase 3.10 — timestamp-mode generate_series. step is a
12856/// `Value::Interval { months, micros }` per the caller's guard;
12857/// each iteration adds the interval via `apply_binary_interval`
12858/// so month-shifting handles short-month rollover (PG semantics).
12859fn generate_series_timestamps(
12860    start: i64,
12861    stop: i64,
12862    step: Value,
12863    cancel: &CancelToken<'_>,
12864) -> Result<alloc::vec::Vec<Row>, EngineError> {
12865    let (months, micros) = match &step {
12866        Value::Interval { months, micros } => (*months, *micros),
12867        _ => unreachable!("caller guards step.is_interval"),
12868    };
12869    if months == 0 && micros == 0 {
12870        return Err(EngineError::Unsupported(
12871            "generate_series(): INTERVAL step cannot be zero".into(),
12872        ));
12873    }
12874    let ascending = months > 0 || micros > 0;
12875    let mut out = alloc::vec::Vec::new();
12876    let mut cur = Value::Timestamp(start);
12877    const MAX_ROWS: usize = 10_000_000;
12878    loop {
12879        cancel.check()?;
12880        let cur_t = match cur {
12881            Value::Timestamp(t) => t,
12882            _ => unreachable!("loop invariant: cur is Timestamp"),
12883        };
12884        if ascending && cur_t > stop {
12885            break;
12886        }
12887        if !ascending && cur_t < stop {
12888            break;
12889        }
12890        out.push(Row::new(alloc::vec![Value::Timestamp(cur_t)]));
12891        if out.len() > MAX_ROWS {
12892            return Err(EngineError::Unsupported(alloc::format!(
12893                "generate_series(): exceeded {MAX_ROWS} rows; \
12894                 narrow start/stop or use a larger step"
12895            )));
12896        }
12897        let next = eval::apply_binary_interval(
12898            spg_sql::ast::BinOp::Add,
12899            &cur,
12900            &Value::Interval { months, micros },
12901        )
12902        .map_err(EngineError::Eval)?;
12903        cur = match next {
12904            Some(v) => v,
12905            None => break,
12906        };
12907    }
12908    Ok(out)
12909}
12910
12911#[allow(clippy::match_same_arms)] // explicit arms per type document the supported pairs
12912/// v7.24 (round-16 A) — per-key ORDER BY comparator honouring DESC
12913/// and the effective NULLS placement (explicit NULLS FIRST/LAST,
12914/// else the PG default: NULLS LAST for ASC, NULLS FIRST for DESC).
12915/// NULL placement is absolute — it does not flip with DESC.
12916pub(crate) fn order_by_value_cmp(
12917    desc: bool,
12918    nulls_first: Option<bool>,
12919    a: &Value,
12920    b: &Value,
12921) -> core::cmp::Ordering {
12922    use core::cmp::Ordering;
12923    let nf = nulls_first.unwrap_or(desc);
12924    match (matches!(a, Value::Null), matches!(b, Value::Null)) {
12925        (true, true) => Ordering::Equal,
12926        (true, false) => {
12927            if nf {
12928                Ordering::Less
12929            } else {
12930                Ordering::Greater
12931            }
12932        }
12933        (false, true) => {
12934            if nf {
12935                Ordering::Greater
12936            } else {
12937                Ordering::Less
12938            }
12939        }
12940        (false, false) => {
12941            let c = value_cmp(a, b);
12942            if desc { c.reverse() } else { c }
12943        }
12944    }
12945}
12946
12947fn value_cmp(a: &Value, b: &Value) -> core::cmp::Ordering {
12948    use core::cmp::Ordering;
12949    match (a, b) {
12950        (Value::Null, Value::Null) => Ordering::Equal,
12951        (Value::Null, _) => Ordering::Less,
12952        (_, Value::Null) => Ordering::Greater,
12953        (Value::Int(x), Value::Int(y)) => x.cmp(y),
12954        (Value::BigInt(x), Value::BigInt(y)) => x.cmp(y),
12955        (Value::SmallInt(x), Value::SmallInt(y)) => x.cmp(y),
12956        (Value::Text(x), Value::Text(y)) => x.cmp(y),
12957        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
12958        (Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
12959        (Value::Date(x), Value::Date(y)) => x.cmp(y),
12960        (Value::Timestamp(x), Value::Timestamp(y)) => x.cmp(y),
12961        // Cross-type compare: fall back to the debug rendering —
12962        // same-partition is the goal, exact order is irrelevant.
12963        _ => alloc::format!("{a:?}").cmp(&alloc::format!("{b:?}")),
12964    }
12965}
12966
12967/// Compute the window function's per-row output for one partition.
12968/// `slice` has (partition key, order key, original-row-index)
12969/// tuples already sorted by order key. `filtered_rows` is the
12970/// full row list indexed by original-row-index. `out_vals` is
12971/// the destination, also indexed by original-row-index.
12972#[allow(
12973    clippy::too_many_arguments,
12974    clippy::cast_possible_truncation,
12975    clippy::cast_possible_wrap,
12976    clippy::cast_precision_loss,
12977    clippy::cast_sign_loss,
12978    clippy::doc_markdown,
12979    clippy::too_many_lines,
12980    clippy::type_complexity,
12981    clippy::match_same_arms
12982)]
12983fn compute_window_partition(
12984    name: &str,
12985    args: &[Expr],
12986    ordered: bool,
12987    frame: Option<&WindowFrame>,
12988    null_treatment: spg_sql::ast::NullTreatment,
12989    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
12990    filtered_rows: &[&Row],
12991    ctx: &EvalContext<'_>,
12992    out_vals: &mut [Value],
12993) -> Result<(), EngineError> {
12994    let ignore_nulls = matches!(null_treatment, spg_sql::ast::NullTreatment::Ignore);
12995    let lower = name.to_ascii_lowercase();
12996    match lower.as_str() {
12997        "row_number" => {
12998            for (rank, (_, _, idx)) in slice.iter().enumerate() {
12999                out_vals[*idx] = Value::BigInt((rank + 1) as i64);
13000            }
13001            Ok(())
13002        }
13003        "rank" => {
13004            let mut prev_key: Option<&[(Value, bool, Option<bool>)]> = None;
13005            let mut current_rank: i64 = 1;
13006            for (i, (_, okey, idx)) in slice.iter().enumerate() {
13007                if let Some(p) = prev_key
13008                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
13009                {
13010                    current_rank = (i + 1) as i64;
13011                }
13012                if prev_key.is_none() {
13013                    current_rank = 1;
13014                }
13015                out_vals[*idx] = Value::BigInt(current_rank);
13016                prev_key = Some(okey.as_slice());
13017            }
13018            Ok(())
13019        }
13020        "dense_rank" => {
13021            let mut prev_key: Option<&[(Value, bool, Option<bool>)]> = None;
13022            let mut current_rank: i64 = 0;
13023            for (_, okey, idx) in slice {
13024                if prev_key.is_none_or(|p| order_key_cmp(p, okey) != core::cmp::Ordering::Equal) {
13025                    current_rank += 1;
13026                }
13027                out_vals[*idx] = Value::BigInt(current_rank);
13028                prev_key = Some(okey.as_slice());
13029            }
13030            Ok(())
13031        }
13032        "sum" | "avg" | "min" | "max" | "count" | "count_star" => {
13033            // Pre-evaluate the function arg per row in the slice
13034            // (count_star has no arg).
13035            let arg_values: Vec<Value> = if lower == "count_star" || args.is_empty() {
13036                slice.iter().map(|_| Value::Null).collect()
13037            } else {
13038                slice
13039                    .iter()
13040                    .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
13041                    .collect::<Result<_, _>>()
13042                    .map_err(EngineError::Eval)?
13043            };
13044            // v4.20: pick the effective frame. Explicit frame
13045            // overrides the implicit default (running for ordered,
13046            // whole-partition for unordered).
13047            let eff = effective_frame(frame, ordered)?;
13048            #[allow(clippy::needless_range_loop)]
13049            for i in 0..slice.len() {
13050                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
13051                let mut sum: f64 = 0.0;
13052                let mut count: i64 = 0;
13053                let mut min_v: Option<f64> = None;
13054                let mut max_v: Option<f64> = None;
13055                let mut row_count: i64 = 0;
13056                if lo <= hi {
13057                    for j in lo..=hi {
13058                        let v = &arg_values[j];
13059                        match lower.as_str() {
13060                            "count_star" => row_count += 1,
13061                            "count" => {
13062                                if !v.is_null() {
13063                                    count += 1;
13064                                }
13065                            }
13066                            _ => {
13067                                if let Some(x) = value_to_f64(v) {
13068                                    sum += x;
13069                                    count += 1;
13070                                    min_v = Some(min_v.map_or(x, |m| m.min(x)));
13071                                    max_v = Some(max_v.map_or(x, |m| m.max(x)));
13072                                }
13073                            }
13074                        }
13075                    }
13076                }
13077                let value = match lower.as_str() {
13078                    "count_star" => Value::BigInt(row_count),
13079                    "count" => Value::BigInt(count),
13080                    "sum" => Value::Float(sum),
13081                    "avg" => {
13082                        if count == 0 {
13083                            Value::Null
13084                        } else {
13085                            Value::Float(sum / count as f64)
13086                        }
13087                    }
13088                    "min" => min_v.map_or(Value::Null, Value::Float),
13089                    "max" => max_v.map_or(Value::Null, Value::Float),
13090                    _ => unreachable!(),
13091                };
13092                let (_, _, idx) = &slice[i];
13093                out_vals[*idx] = value;
13094            }
13095            Ok(())
13096        }
13097        "lag" | "lead" => {
13098            // lag(expr [, offset [, default]])
13099            // lead(expr [, offset [, default]])
13100            if args.is_empty() {
13101                return Err(EngineError::Unsupported(alloc::format!(
13102                    "{lower}() requires at least one argument"
13103                )));
13104            }
13105            let offset: i64 = if args.len() >= 2 {
13106                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
13107                    .map_err(EngineError::Eval)?;
13108                match v {
13109                    Value::SmallInt(n) => i64::from(n),
13110                    Value::Int(n) => i64::from(n),
13111                    Value::BigInt(n) => n,
13112                    _ => {
13113                        return Err(EngineError::Unsupported(alloc::format!(
13114                            "{lower}() offset must be integer"
13115                        )));
13116                    }
13117                }
13118            } else {
13119                1
13120            };
13121            let default: Value = if args.len() >= 3 {
13122                eval::eval_expr(&args[2], filtered_rows[slice[0].2], ctx)
13123                    .map_err(EngineError::Eval)?
13124            } else {
13125                Value::Null
13126            };
13127            let values: Vec<Value> = slice
13128                .iter()
13129                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
13130                .collect::<Result<_, _>>()
13131                .map_err(EngineError::Eval)?;
13132            let n = slice.len();
13133            for (i, (_, _, idx)) in slice.iter().enumerate() {
13134                let signed_offset = if lower == "lag" { -offset } else { offset };
13135                let v = if ignore_nulls {
13136                    // v6.4.2 — IGNORE NULLS: walk in the offset direction
13137                    // skipping NULL values; the `offset`-th non-NULL
13138                    // encountered is the result.
13139                    let step: i64 = if signed_offset >= 0 { 1 } else { -1 };
13140                    let needed: i64 = signed_offset.abs();
13141                    if needed == 0 {
13142                        values[i].clone()
13143                    } else {
13144                        let mut j: i64 = i as i64;
13145                        let mut hits: i64 = 0;
13146                        let mut found: Option<Value> = None;
13147                        loop {
13148                            j += step;
13149                            if j < 0 || j >= n as i64 {
13150                                break;
13151                            }
13152                            #[allow(clippy::cast_sign_loss)]
13153                            let v = &values[j as usize];
13154                            if !v.is_null() {
13155                                hits += 1;
13156                                if hits == needed {
13157                                    found = Some(v.clone());
13158                                    break;
13159                                }
13160                            }
13161                        }
13162                        found.unwrap_or_else(|| default.clone())
13163                    }
13164                } else {
13165                    let target_signed = i64::try_from(i).unwrap_or(i64::MAX) + signed_offset;
13166                    if target_signed < 0 || target_signed >= i64::try_from(n).unwrap_or(i64::MAX) {
13167                        default.clone()
13168                    } else {
13169                        #[allow(clippy::cast_sign_loss)]
13170                        {
13171                            values[target_signed as usize].clone()
13172                        }
13173                    }
13174                };
13175                out_vals[*idx] = v;
13176            }
13177            Ok(())
13178        }
13179        "first_value" | "last_value" | "nth_value" => {
13180            if args.is_empty() {
13181                return Err(EngineError::Unsupported(alloc::format!(
13182                    "{lower}() requires at least one argument"
13183                )));
13184            }
13185            let values: Vec<Value> = slice
13186                .iter()
13187                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
13188                .collect::<Result<_, _>>()
13189                .map_err(EngineError::Eval)?;
13190            let nth: usize = if lower == "nth_value" {
13191                if args.len() < 2 {
13192                    return Err(EngineError::Unsupported(
13193                        "nth_value() requires (expr, n)".into(),
13194                    ));
13195                }
13196                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
13197                    .map_err(EngineError::Eval)?;
13198                let raw = match v {
13199                    Value::SmallInt(n) => i64::from(n),
13200                    Value::Int(n) => i64::from(n),
13201                    Value::BigInt(n) => n,
13202                    _ => {
13203                        return Err(EngineError::Unsupported(
13204                            "nth_value() n must be integer".into(),
13205                        ));
13206                    }
13207                };
13208                if raw < 1 {
13209                    return Err(EngineError::Unsupported(
13210                        "nth_value() n must be >= 1".into(),
13211                    ));
13212                }
13213                #[allow(clippy::cast_sign_loss)]
13214                {
13215                    raw as usize
13216                }
13217            } else {
13218                0
13219            };
13220            let eff = effective_frame(frame, ordered)?;
13221            for i in 0..slice.len() {
13222                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
13223                let (_, _, idx) = &slice[i];
13224                let v = if lo > hi {
13225                    Value::Null
13226                } else if ignore_nulls && matches!(lower.as_str(), "first_value" | "last_value") {
13227                    // v6.4.2 — IGNORE NULLS: skip NULL cells when
13228                    // selecting the boundary value within the frame.
13229                    if lower == "first_value" {
13230                        (lo..=hi)
13231                            .find_map(|j| {
13232                                let v = &values[j];
13233                                (!v.is_null()).then(|| v.clone())
13234                            })
13235                            .unwrap_or(Value::Null)
13236                    } else {
13237                        (lo..=hi)
13238                            .rev()
13239                            .find_map(|j| {
13240                                let v = &values[j];
13241                                (!v.is_null()).then(|| v.clone())
13242                            })
13243                            .unwrap_or(Value::Null)
13244                    }
13245                } else {
13246                    match lower.as_str() {
13247                        "first_value" => values[lo].clone(),
13248                        "last_value" => values[hi].clone(),
13249                        "nth_value" => {
13250                            let pos = lo + nth - 1;
13251                            if pos > hi {
13252                                Value::Null
13253                            } else {
13254                                values[pos].clone()
13255                            }
13256                        }
13257                        _ => unreachable!(),
13258                    }
13259                };
13260                out_vals[*idx] = v;
13261            }
13262            Ok(())
13263        }
13264        "ntile" => {
13265            if args.is_empty() {
13266                return Err(EngineError::Unsupported(
13267                    "ntile(n) requires an integer argument".into(),
13268                ));
13269            }
13270            let v = eval::eval_expr(&args[0], filtered_rows[slice[0].2], ctx)
13271                .map_err(EngineError::Eval)?;
13272            let bucket_count: i64 = match v {
13273                Value::SmallInt(n) => i64::from(n),
13274                Value::Int(n) => i64::from(n),
13275                Value::BigInt(n) => n,
13276                _ => {
13277                    return Err(EngineError::Unsupported(
13278                        "ntile() argument must be integer".into(),
13279                    ));
13280                }
13281            };
13282            if bucket_count < 1 {
13283                return Err(EngineError::Unsupported(
13284                    "ntile() argument must be >= 1".into(),
13285                ));
13286            }
13287            #[allow(clippy::cast_sign_loss)]
13288            let buckets = bucket_count as usize;
13289            let n = slice.len();
13290            // Each bucket gets `base` rows; the first `extras` buckets
13291            // get one extra. PG semantics.
13292            let base = n / buckets;
13293            let extras = n % buckets;
13294            let mut bucket: usize = 1;
13295            let mut remaining_in_bucket = if extras > 0 { base + 1 } else { base };
13296            let mut buckets_with_extra_remaining = extras;
13297            for (_, _, idx) in slice {
13298                if remaining_in_bucket == 0 {
13299                    bucket += 1;
13300                    buckets_with_extra_remaining = buckets_with_extra_remaining.saturating_sub(1);
13301                    remaining_in_bucket = if buckets_with_extra_remaining > 0 {
13302                        base + 1
13303                    } else {
13304                        base
13305                    };
13306                    // Edge: if base==0 and extras==0, all rows fit;
13307                    // shouldn't reach here, but guard anyway.
13308                    if remaining_in_bucket == 0 {
13309                        remaining_in_bucket = 1;
13310                    }
13311                }
13312                out_vals[*idx] = Value::BigInt(i64::try_from(bucket).unwrap_or(i64::MAX));
13313                remaining_in_bucket -= 1;
13314            }
13315            Ok(())
13316        }
13317        "percent_rank" => {
13318            // (rank - 1) / (n - 1) where rank is the standard RANK().
13319            // Single-row partitions get 0.
13320            let n = slice.len();
13321            let mut prev_key: Option<&[(Value, bool, Option<bool>)]> = None;
13322            let mut current_rank: i64 = 1;
13323            for (i, (_, okey, idx)) in slice.iter().enumerate() {
13324                if let Some(p) = prev_key
13325                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
13326                {
13327                    current_rank = i64::try_from(i + 1).unwrap_or(i64::MAX);
13328                }
13329                if prev_key.is_none() {
13330                    current_rank = 1;
13331                }
13332                #[allow(clippy::cast_precision_loss)]
13333                let pr = if n <= 1 {
13334                    0.0
13335                } else {
13336                    (current_rank - 1) as f64 / (n - 1) as f64
13337                };
13338                out_vals[*idx] = Value::Float(pr);
13339                prev_key = Some(okey.as_slice());
13340            }
13341            Ok(())
13342        }
13343        "cume_dist" => {
13344            // # rows up to and including this row's peer group / n.
13345            let n = slice.len();
13346            // First pass: find peer-group-end rank for each row.
13347            for i in 0..slice.len() {
13348                let peer_end = peer_group_end(slice, i);
13349                #[allow(clippy::cast_precision_loss)]
13350                let cd = (peer_end + 1) as f64 / n as f64;
13351                let (_, _, idx) = &slice[i];
13352                out_vals[*idx] = Value::Float(cd);
13353            }
13354            Ok(())
13355        }
13356        other => Err(EngineError::Unsupported(alloc::format!(
13357            "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)"
13358        ))),
13359    }
13360}
13361
13362/// v4.20: resolve the user-provided frame down to a normalised
13363/// `(kind, start, end)`. `None` means default — derive from
13364/// `ordered`: ordered ⇒ RANGE UNBOUNDED PRECEDING AND CURRENT ROW,
13365/// unordered ⇒ ROWS UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
13366/// Single-bound shorthand (e.g. `ROWS 5 PRECEDING`) normalises
13367/// end → CURRENT ROW per the PG spec.
13368fn effective_frame(
13369    frame: Option<&WindowFrame>,
13370    ordered: bool,
13371) -> Result<(FrameKind, FrameBound, FrameBound), EngineError> {
13372    match frame {
13373        None => {
13374            if ordered {
13375                Ok((
13376                    FrameKind::Range,
13377                    FrameBound::UnboundedPreceding,
13378                    FrameBound::CurrentRow,
13379                ))
13380            } else {
13381                Ok((
13382                    FrameKind::Rows,
13383                    FrameBound::UnboundedPreceding,
13384                    FrameBound::UnboundedFollowing,
13385                ))
13386            }
13387        }
13388        Some(fr) => {
13389            let end = fr.end.clone().unwrap_or(FrameBound::CurrentRow);
13390            // Reject start > end (a few impossible combinations).
13391            if matches!(fr.start, FrameBound::UnboundedFollowing)
13392                || matches!(end, FrameBound::UnboundedPreceding)
13393            {
13394                return Err(EngineError::Unsupported(alloc::format!(
13395                    "invalid frame: start={:?} end={:?}",
13396                    fr.start,
13397                    end
13398                )));
13399            }
13400            // RANGE OFFSET PRECEDING / FOLLOWING needs value-typed
13401            // arithmetic on the ORDER BY key (e.g. `RANGE BETWEEN
13402            // INTERVAL '1 day' PRECEDING AND CURRENT ROW`). Not
13403            // implemented in v4.20.
13404            if fr.kind == FrameKind::Range
13405                && (matches!(
13406                    fr.start,
13407                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
13408                ) || matches!(
13409                    end,
13410                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
13411                ))
13412            {
13413                return Err(EngineError::Unsupported(
13414                    "RANGE with explicit offset bounds is not supported (v4.20: only UNBOUNDED / CURRENT ROW for RANGE)".into(),
13415                ));
13416            }
13417            Ok((fr.kind, fr.start.clone(), end))
13418        }
13419    }
13420}
13421
13422/// Compute `(lo, hi)` row-index bounds inside the partition slice
13423/// for the row at position `i`. Inclusive, clamped to
13424/// `[0, slice.len()-1]`. Empty result if `lo > hi`.
13425#[allow(clippy::type_complexity)]
13426fn frame_bounds_for_row(
13427    eff: &(FrameKind, FrameBound, FrameBound),
13428    i: usize,
13429    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
13430) -> (usize, usize) {
13431    let (kind, start, end) = eff;
13432    let n = slice.len();
13433    let last = n.saturating_sub(1);
13434    let (mut lo, mut hi) = match kind {
13435        FrameKind::Rows => {
13436            let lo = match start {
13437                FrameBound::UnboundedPreceding => 0,
13438                FrameBound::OffsetPreceding(k) => {
13439                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13440                    i.saturating_sub(k)
13441                }
13442                FrameBound::CurrentRow => i,
13443                FrameBound::OffsetFollowing(k) => {
13444                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13445                    i.saturating_add(k).min(last)
13446                }
13447                FrameBound::UnboundedFollowing => last,
13448            };
13449            let hi = match end {
13450                FrameBound::UnboundedPreceding => 0,
13451                FrameBound::OffsetPreceding(k) => {
13452                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13453                    i.saturating_sub(k)
13454                }
13455                FrameBound::CurrentRow => i,
13456                FrameBound::OffsetFollowing(k) => {
13457                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13458                    i.saturating_add(k).min(last)
13459                }
13460                FrameBound::UnboundedFollowing => last,
13461            };
13462            (lo, hi)
13463        }
13464        FrameKind::Range => {
13465            // RANGE bounds are peer-aware. With only UNBOUNDED and
13466            // CURRENT ROW supported (rejected at effective_frame for
13467            // explicit offsets), the start/end map to the
13468            // partition's full extent at the same-order-key peer
13469            // group boundary.
13470            let lo = match start {
13471                FrameBound::UnboundedPreceding => 0,
13472                FrameBound::CurrentRow => peer_group_start(slice, i),
13473                FrameBound::UnboundedFollowing => last,
13474                _ => unreachable!("offset bounds rejected for RANGE"),
13475            };
13476            let hi = match end {
13477                FrameBound::UnboundedPreceding => 0,
13478                FrameBound::CurrentRow => peer_group_end(slice, i),
13479                FrameBound::UnboundedFollowing => last,
13480                _ => unreachable!("offset bounds rejected for RANGE"),
13481            };
13482            (lo, hi)
13483        }
13484    };
13485    if hi >= n {
13486        hi = last;
13487    }
13488    if lo >= n {
13489        lo = last;
13490    }
13491    (lo, hi)
13492}
13493
13494/// Find the inclusive index of the first row with the same ORDER
13495/// BY key as `slice[i]`. Slice is already sorted by partition then
13496/// order, so peers are contiguous.
13497#[allow(clippy::type_complexity)]
13498fn peer_group_start(
13499    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
13500    i: usize,
13501) -> usize {
13502    let key = &slice[i].1;
13503    let mut j = i;
13504    while j > 0 && order_key_cmp(&slice[j - 1].1, key) == core::cmp::Ordering::Equal {
13505        j -= 1;
13506    }
13507    j
13508}
13509
13510/// Find the inclusive index of the last row with the same ORDER
13511/// BY key as `slice[i]`.
13512#[allow(clippy::type_complexity)]
13513fn peer_group_end(
13514    slice: &[(Vec<Value>, Vec<(Value, bool, Option<bool>)>, usize)],
13515    i: usize,
13516) -> usize {
13517    let key = &slice[i].1;
13518    let mut j = i;
13519    while j + 1 < slice.len() && order_key_cmp(&slice[j + 1].1, key) == core::cmp::Ordering::Equal {
13520        j += 1;
13521    }
13522    j
13523}
13524
13525fn value_to_f64(v: &Value) -> Option<f64> {
13526    match v {
13527        Value::SmallInt(n) => Some(f64::from(*n)),
13528        Value::Int(n) => Some(f64::from(*n)),
13529        #[allow(clippy::cast_precision_loss)]
13530        Value::BigInt(n) => Some(*n as f64),
13531        Value::Float(x) => Some(*x),
13532        _ => None,
13533    }
13534}
13535
13536/// Quick scan for any subquery-bearing node in a SELECT's WHERE /
13537/// projection / `order_by` — saves cloning the AST when there are
13538/// none (the common case).
13539fn expr_tree_has_subquery(stmt: &SelectStatement) -> bool {
13540    let mut any = false;
13541    for item in &stmt.items {
13542        if let SelectItem::Expr { expr, .. } = item {
13543            any = any || expr_has_subquery(expr);
13544        }
13545    }
13546    if let Some(w) = &stmt.where_ {
13547        any = any || expr_has_subquery(w);
13548    }
13549    if let Some(h) = &stmt.having {
13550        any = any || expr_has_subquery(h);
13551    }
13552    for o in &stmt.order_by {
13553        any = any || expr_has_subquery(&o.expr);
13554    }
13555    for (_, peer) in &stmt.unions {
13556        any = any || expr_tree_has_subquery(peer);
13557    }
13558    any
13559}
13560
13561fn expr_has_subquery(e: &Expr) -> bool {
13562    match e {
13563        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => true,
13564        Expr::AggregateOrdered { call, order_by } => {
13565            expr_has_subquery(call) || order_by.iter().any(|o| expr_has_subquery(&o.expr))
13566        }
13567        Expr::Binary { lhs, rhs, .. } => expr_has_subquery(lhs) || expr_has_subquery(rhs),
13568        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13569            expr_has_subquery(expr)
13570        }
13571        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_subquery),
13572        Expr::Like { expr, pattern, .. } => expr_has_subquery(expr) || expr_has_subquery(pattern),
13573        Expr::Extract { source, .. } => expr_has_subquery(source),
13574        Expr::WindowFunction {
13575            args,
13576            partition_by,
13577            order_by,
13578            ..
13579        } => {
13580            args.iter().any(expr_has_subquery)
13581                || partition_by.iter().any(expr_has_subquery)
13582                || order_by.iter().any(|(e, _, _)| expr_has_subquery(e))
13583        }
13584        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
13585        Expr::Array(items) => items.iter().any(expr_has_subquery),
13586        Expr::ArraySubscript { target, index } => {
13587            expr_has_subquery(target) || expr_has_subquery(index)
13588        }
13589        Expr::AnyAll { expr, array, .. } => expr_has_subquery(expr) || expr_has_subquery(array),
13590        Expr::Case {
13591            operand,
13592            branches,
13593            else_branch,
13594        } => {
13595            operand.as_deref().is_some_and(expr_has_subquery)
13596                || branches
13597                    .iter()
13598                    .any(|(w, t)| expr_has_subquery(w) || expr_has_subquery(t))
13599                || else_branch.as_deref().is_some_and(expr_has_subquery)
13600        }
13601    }
13602}
13603
13604/// v4.10 helper: materialise a runtime `Value` back into an AST
13605/// `Expr::Literal` for the subquery-rewrite path. Supports the
13606/// types `Literal` can represent (Integer / Float / Text / Bool /
13607/// Null). Date / Timestamp / Numeric / Vector / Interval / JSON
13608/// would lose precision through Literal and aren't supported in
13609/// uncorrelated-subquery results; they error with a clear hint.
13610fn value_to_literal_expr(v: Value) -> Result<Expr, EngineError> {
13611    let lit = match v {
13612        Value::Null => Literal::Null,
13613        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13614        Value::Int(n) => Literal::Integer(i64::from(n)),
13615        Value::BigInt(n) => Literal::Integer(n),
13616        Value::Float(x) => Literal::Float(x),
13617        Value::Text(s) | Value::Json(s) => Literal::String(s),
13618        Value::Bool(b) => Literal::Bool(b),
13619        other => {
13620            return Err(EngineError::Unsupported(alloc::format!(
13621                "subquery result type {:?} not yet materialisable; cast to text or integer in the inner SELECT",
13622                other.data_type()
13623            )));
13624        }
13625    };
13626    Ok(Expr::Literal(lit))
13627}
13628
13629/// v7.13.0 — wider helper used by `INSERT … SELECT` (mailrs
13630/// round-5 G4). Covers the most common `Value` variants. Types
13631/// that need lossy textual round-trip (BYTEA, arrays, ts*)
13632/// surface as an Unsupported error so the caller can add a cast
13633/// in the inner SELECT.
13634fn value_to_literal_expr_permissive(v: Value) -> Result<Expr, EngineError> {
13635    let lit = match v {
13636        Value::Null => Literal::Null,
13637        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13638        Value::Int(n) => Literal::Integer(i64::from(n)),
13639        Value::BigInt(n) => Literal::Integer(n),
13640        Value::Float(x) => Literal::Float(x),
13641        Value::Text(s) | Value::Json(s) => Literal::String(s),
13642        Value::Bool(b) => Literal::Bool(b),
13643        Value::Vector(xs) => Literal::Vector(xs),
13644        // Date / Timestamp / Timestamptz / Numeric round-trip
13645        // through a TEXT literal that `coerce_value` re-parses
13646        // against the target column type.
13647        Value::Date(days) => {
13648            let micros = (i64::from(days)) * 86_400_000_000;
13649            Literal::String(format_timestamp_micros_as_date(micros))
13650        }
13651        Value::Timestamp(us) => Literal::String(format_timestamp_micros(us)),
13652        Value::Numeric { scaled, scale } => Literal::String(format_numeric(scaled, scale)),
13653        other => {
13654            return Err(EngineError::Unsupported(alloc::format!(
13655                "INSERT … SELECT cannot materialise value of type {:?}; \
13656                 add an explicit CAST in the inner SELECT",
13657                other.data_type()
13658            )));
13659        }
13660    };
13661    Ok(Expr::Literal(lit))
13662}
13663
13664fn format_timestamp_micros(us: i64) -> String {
13665    // Same Y/M/D split used by the wire layer; epoch-relative.
13666    let days = us.div_euclid(86_400_000_000);
13667    let intra_day = us.rem_euclid(86_400_000_000);
13668    let date = format_timestamp_micros_as_date(days * 86_400_000_000);
13669    let secs = intra_day / 1_000_000;
13670    let us_rem = intra_day % 1_000_000;
13671    let h = (secs / 3600) % 24;
13672    let m = (secs / 60) % 60;
13673    let s = secs % 60;
13674    if us_rem == 0 {
13675        alloc::format!("{date} {h:02}:{m:02}:{s:02}")
13676    } else {
13677        alloc::format!("{date} {h:02}:{m:02}:{s:02}.{us_rem:06}")
13678    }
13679}
13680
13681fn format_timestamp_micros_as_date(us: i64) -> String {
13682    // Days since 1970-01-01 → calendar Y-M-D via the proleptic
13683    // Gregorian conversion used by spg-engine's date helpers.
13684    let days = us.div_euclid(86_400_000_000);
13685    // 1970-01-01 = JDN 2440588.
13686    let jdn = days + 2_440_588;
13687    let (y, mo, d) = jdn_to_ymd(jdn);
13688    alloc::format!("{y:04}-{mo:02}-{d:02}")
13689}
13690
13691fn jdn_to_ymd(jdn: i64) -> (i64, u32, u32) {
13692    // Fliegel & Van Flandern (1968) — works for all positive JDNs.
13693    let l = jdn + 68569;
13694    let n = (4 * l) / 146_097;
13695    let l = l - (146_097 * n + 3) / 4;
13696    let i = (4000 * (l + 1)) / 1_461_001;
13697    let l = l - (1461 * i) / 4 + 31;
13698    let j = (80 * l) / 2447;
13699    let day = (l - (2447 * j) / 80) as u32;
13700    let l = j / 11;
13701    let month = (j + 2 - 12 * l) as u32;
13702    let year = 100 * (n - 49) + i + l;
13703    (year, month, day)
13704}
13705
13706fn format_numeric(scaled: i128, scale: u8) -> String {
13707    if scale == 0 {
13708        return alloc::format!("{scaled}");
13709    }
13710    let abs = scaled.unsigned_abs();
13711    let divisor = 10u128.pow(u32::from(scale));
13712    let whole = abs / divisor;
13713    let frac = abs % divisor;
13714    let sign = if scaled < 0 { "-" } else { "" };
13715    alloc::format!("{sign}{whole}.{frac:0width$}", width = usize::from(scale))
13716}
13717
13718/// v6.1.1 — walk the prepared `Statement` AST and replace every
13719/// `Expr::Placeholder(n)` with `Expr::Literal(value_to_literal(
13720/// params[n-1]))`. The dispatch downstream sees a `Statement`
13721/// indistinguishable from a simple-query parse, so the exec path
13722/// stays unchanged.
13723///
13724/// Errors fall into one shape: a `$N` references past the bound
13725/// `params.len()`. Out-of-range happens when the Bind didn't
13726/// supply enough values; pgwire surfaces this as a protocol error
13727/// to the client.
13728/// v7.15.0 — rewrite every (potentially-qualified) column
13729/// identifier matching `old` to `new` in a stored SQL source
13730/// string. Used by `ALTER TABLE … RENAME COLUMN` to patch
13731/// CHECK predicate sources, partial-index predicate sources,
13732/// and runtime DEFAULT expression sources before they get
13733/// re-parsed on the next INSERT/UPDATE.
13734///
13735/// Round-trips through the parser, so the rewritten output is
13736/// the canonical Display form (matches what the engine stores
13737/// for fresh predicates). If the source doesn't parse, surfaces
13738/// the parse error — the invariant that stored predicates are
13739/// in canonical Display form means a parse failure here is a
13740/// real bug, not a user mistake to swallow.
13741fn rewrite_column_in_source(
13742    src: &str,
13743    old: &str,
13744    new: &str,
13745) -> Result<alloc::string::String, EngineError> {
13746    let mut expr = spg_sql::parser::parse_expression(src).map_err(|e| {
13747        EngineError::Unsupported(alloc::format!(
13748            "ALTER TABLE RENAME COLUMN: stored predicate source {src:?} \
13749             failed to parse for rewrite ({e})"
13750        ))
13751    })?;
13752    rewrite_column_in_expr(&mut expr, old, new);
13753    Ok(alloc::format!("{expr}"))
13754}
13755
13756/// v7.15.0 — Expr walker that swaps `Expr::Column { name: old, .. }`
13757/// for `Expr::Column { name: new, .. }`. Qualifier is preserved
13758/// (e.g. `t.old` → `t.new`); a foreign-table qualifier still
13759/// gets rewritten because the AST has no way to tell us this
13760/// predicate is on table T versus table T2 — predicate sources
13761/// in SPG are always scoped to the owning table, so any
13762/// qualifier present is either redundant or wrong.
13763fn rewrite_column_in_expr(e: &mut Expr, old: &str, new: &str) {
13764    match e {
13765        Expr::AggregateOrdered { call, order_by } => {
13766            rewrite_column_in_expr(call, old, new);
13767            for o in order_by.iter_mut() {
13768                rewrite_column_in_expr(&mut o.expr, old, new);
13769            }
13770        }
13771        Expr::Column(c) => {
13772            if c.name.eq_ignore_ascii_case(old) {
13773                c.name = new.to_string();
13774            }
13775        }
13776        Expr::Binary { lhs, rhs, .. } => {
13777            rewrite_column_in_expr(lhs, old, new);
13778            rewrite_column_in_expr(rhs, old, new);
13779        }
13780        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13781            rewrite_column_in_expr(expr, old, new);
13782        }
13783        Expr::FunctionCall { args, .. } => {
13784            for a in args {
13785                rewrite_column_in_expr(a, old, new);
13786            }
13787        }
13788        Expr::Like { expr, pattern, .. } => {
13789            rewrite_column_in_expr(expr, old, new);
13790            rewrite_column_in_expr(pattern, old, new);
13791        }
13792        Expr::Extract { source, .. } => rewrite_column_in_expr(source, old, new),
13793        Expr::WindowFunction {
13794            args,
13795            partition_by,
13796            order_by,
13797            ..
13798        } => {
13799            for a in args {
13800                rewrite_column_in_expr(a, old, new);
13801            }
13802            for p in partition_by {
13803                rewrite_column_in_expr(p, old, new);
13804            }
13805            for (o, _, _) in order_by {
13806                rewrite_column_in_expr(o, old, new);
13807            }
13808        }
13809        Expr::Array(items) => {
13810            for elem in items {
13811                rewrite_column_in_expr(elem, old, new);
13812            }
13813        }
13814        Expr::ArraySubscript { target, index } => {
13815            rewrite_column_in_expr(target, old, new);
13816            rewrite_column_in_expr(index, old, new);
13817        }
13818        Expr::AnyAll { expr, array, .. } => {
13819            rewrite_column_in_expr(expr, old, new);
13820            rewrite_column_in_expr(array, old, new);
13821        }
13822        Expr::Case {
13823            operand,
13824            branches,
13825            else_branch,
13826        } => {
13827            if let Some(o) = operand {
13828                rewrite_column_in_expr(o, old, new);
13829            }
13830            for (w, t) in branches {
13831                rewrite_column_in_expr(w, old, new);
13832                rewrite_column_in_expr(t, old, new);
13833            }
13834            if let Some(e) = else_branch {
13835                rewrite_column_in_expr(e, old, new);
13836            }
13837        }
13838        // Stored predicate sources never contain subqueries —
13839        // CHECK / partial-index / runtime_default are all scalar.
13840        // If a future feature changes that, recurse here.
13841        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => {}
13842        Expr::Literal(_) | Expr::Placeholder(_) => {}
13843    }
13844}
13845
13846/// v7.16.0 — walks a parsed statement and replaces every
13847/// `Expr::Placeholder(N)` with the corresponding `params[N-1]`
13848/// re-encoded as an `Expr::Literal`. Used internally by
13849/// `Engine::execute_prepared` AND surfaced for the spg-embedded
13850/// WAL path (which needs the bind-final AST so replay sees a
13851/// simple-query-shaped statement, not a `$1`-shaped one). Errors
13852/// when a placeholder references an index past the params slice.
13853pub fn substitute_placeholders(stmt: &mut Statement, params: &[Value]) -> Result<(), EngineError> {
13854    match stmt {
13855        Statement::Select(s) => substitute_select(s, params)?,
13856        Statement::Insert(ins) => {
13857            for row in &mut ins.rows {
13858                for e in row {
13859                    substitute_expr(e, params)?;
13860                }
13861            }
13862            // ON CONFLICT DO UPDATE assignments / WHERE can carry
13863            // placeholders too (`… DO UPDATE SET reason = $2` —
13864            // mailrs embed round-12).
13865            if let Some(clause) = &mut ins.on_conflict
13866                && let spg_sql::ast::OnConflictAction::Update {
13867                    assignments,
13868                    where_,
13869                } = &mut clause.action
13870            {
13871                for (_, e) in assignments.iter_mut() {
13872                    substitute_expr(e, params)?;
13873                }
13874                if let Some(w) = where_ {
13875                    substitute_expr(w, params)?;
13876                }
13877            }
13878        }
13879        Statement::Update(u) => {
13880            for (_, e) in &mut u.assignments {
13881                substitute_expr(e, params)?;
13882            }
13883            if let Some(w) = &mut u.where_ {
13884                substitute_expr(w, params)?;
13885            }
13886        }
13887        Statement::Delete(d) => {
13888            if let Some(w) = &mut d.where_ {
13889                substitute_expr(w, params)?;
13890            }
13891        }
13892        Statement::Explain(e) => substitute_select(&mut e.inner, params)?,
13893        // Other statements (CREATE / BEGIN / SHOW / …) have no
13894        // expression slots; no walk needed.
13895        _ => {}
13896    }
13897    Ok(())
13898}
13899
13900fn substitute_select(s: &mut SelectStatement, params: &[Value]) -> Result<(), EngineError> {
13901    for item in &mut s.items {
13902        if let SelectItem::Expr { expr, .. } = item {
13903            substitute_expr(expr, params)?;
13904        }
13905    }
13906    if let Some(w) = &mut s.where_ {
13907        substitute_expr(w, params)?;
13908    }
13909    if let Some(gs) = &mut s.group_by {
13910        for g in gs {
13911            substitute_expr(g, params)?;
13912        }
13913    }
13914    if let Some(h) = &mut s.having {
13915        substitute_expr(h, params)?;
13916    }
13917    for o in &mut s.order_by {
13918        substitute_expr(&mut o.expr, params)?;
13919    }
13920    for (_, peer) in &mut s.unions {
13921        substitute_select(peer, params)?;
13922    }
13923    // v7.9.24 — LIMIT $N / OFFSET $N placeholder resolution.
13924    // mailrs H2. After this pass each LIMIT/OFFSET that was a
13925    // Placeholder is rewritten to Literal so the existing
13926    // `LimitExpr::as_literal` path consumes a concrete u32.
13927    if let Some(le) = s.limit {
13928        s.limit = Some(resolve_limit_placeholder(le, params)?);
13929    }
13930    if let Some(le) = s.offset {
13931        s.offset = Some(resolve_limit_placeholder(le, params)?);
13932    }
13933    Ok(())
13934}
13935
13936fn resolve_limit_placeholder(
13937    le: spg_sql::ast::LimitExpr,
13938    params: &[Value],
13939) -> Result<spg_sql::ast::LimitExpr, EngineError> {
13940    use spg_sql::ast::LimitExpr;
13941    match le {
13942        LimitExpr::Literal(_) => Ok(le),
13943        LimitExpr::Placeholder(n) => {
13944            let idx = usize::from(n).saturating_sub(1);
13945            let v = params.get(idx).ok_or_else(|| {
13946                EngineError::Eval(EvalError::PlaceholderOutOfRange {
13947                    n,
13948                    bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
13949                })
13950            })?;
13951            let int = match v {
13952                Value::SmallInt(x) => Some(i64::from(*x)),
13953                Value::Int(x) => Some(i64::from(*x)),
13954                Value::BigInt(x) => Some(*x),
13955                _ => None,
13956            }
13957            .ok_or_else(|| {
13958                EngineError::Unsupported(alloc::format!(
13959                    "LIMIT/OFFSET ${n} bound to non-integer {v:?}"
13960                ))
13961            })?;
13962            if int < 0 {
13963                return Err(EngineError::Unsupported(alloc::format!(
13964                    "LIMIT/OFFSET ${n} bound to negative value {int}"
13965                )));
13966            }
13967            let bounded = u32::try_from(int).map_err(|_| {
13968                EngineError::Unsupported(alloc::format!(
13969                    "LIMIT/OFFSET ${n} value {int} exceeds u32 range"
13970                ))
13971            })?;
13972            Ok(LimitExpr::Literal(bounded))
13973        }
13974    }
13975}
13976
13977fn substitute_expr(e: &mut Expr, params: &[Value]) -> Result<(), EngineError> {
13978    if let Expr::Placeholder(n) = e {
13979        let idx = usize::from(*n).saturating_sub(1);
13980        let v = params.get(idx).ok_or_else(|| {
13981            EngineError::Eval(EvalError::PlaceholderOutOfRange {
13982                n: *n,
13983                bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
13984            })
13985        })?;
13986        *e = Expr::Literal(value_to_literal(v.clone()));
13987        return Ok(());
13988    }
13989    match e {
13990        Expr::AggregateOrdered { call, order_by } => {
13991            substitute_expr(call, params)?;
13992            for o in order_by.iter_mut() {
13993                substitute_expr(&mut o.expr, params)?;
13994            }
13995        }
13996        Expr::Binary { lhs, rhs, .. } => {
13997            substitute_expr(lhs, params)?;
13998            substitute_expr(rhs, params)?;
13999        }
14000        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14001            substitute_expr(expr, params)?;
14002        }
14003        Expr::FunctionCall { args, .. } => {
14004            for a in args {
14005                substitute_expr(a, params)?;
14006            }
14007        }
14008        Expr::Like { expr, pattern, .. } => {
14009            substitute_expr(expr, params)?;
14010            substitute_expr(pattern, params)?;
14011        }
14012        Expr::Extract { source, .. } => substitute_expr(source, params)?,
14013        Expr::ScalarSubquery(s) => substitute_select(s, params)?,
14014        Expr::Exists { subquery, .. } => substitute_select(subquery, params)?,
14015        Expr::InSubquery { expr, subquery, .. } => {
14016            substitute_expr(expr, params)?;
14017            substitute_select(subquery, params)?;
14018        }
14019        Expr::WindowFunction {
14020            args,
14021            partition_by,
14022            order_by,
14023            ..
14024        } => {
14025            for a in args {
14026                substitute_expr(a, params)?;
14027            }
14028            for p in partition_by {
14029                substitute_expr(p, params)?;
14030            }
14031            for (e, _, _) in order_by {
14032                substitute_expr(e, params)?;
14033            }
14034        }
14035        Expr::Literal(_) | Expr::Column(_) => {}
14036        // Already handled above.
14037        Expr::Placeholder(_) => unreachable!("Placeholder handled at top of fn"),
14038        Expr::Array(items) => {
14039            for elem in items {
14040                substitute_expr(elem, params)?;
14041            }
14042        }
14043        Expr::ArraySubscript { target, index } => {
14044            substitute_expr(target, params)?;
14045            substitute_expr(index, params)?;
14046        }
14047        Expr::AnyAll { expr, array, .. } => {
14048            substitute_expr(expr, params)?;
14049            substitute_expr(array, params)?;
14050        }
14051        Expr::Case {
14052            operand,
14053            branches,
14054            else_branch,
14055        } => {
14056            if let Some(o) = operand {
14057                substitute_expr(o, params)?;
14058            }
14059            for (w, t) in branches {
14060                substitute_expr(w, params)?;
14061                substitute_expr(t, params)?;
14062            }
14063            if let Some(e) = else_branch {
14064                substitute_expr(e, params)?;
14065            }
14066        }
14067    }
14068    Ok(())
14069}
14070
14071/// v6.1.1 — convert a runtime `Value` into the closest matching
14072/// `Literal` for the substitute walker. Lossless for the simple
14073/// scalars (Int / Float / Text / Bool); Numeric / Date / Timestamp
14074/// / Json / Interval render as their canonical text form so the
14075/// downstream coerce_value can re-parse against the target column
14076/// type. SQ8 / HalfVector cells are NOT expected as bind params;
14077/// pgwire's Bind decodes vector params to the f32 representation
14078/// before they reach this helper.
14079/// v6.2.0 — total ordering on `Value`s used by ANALYZE to sort a
14080/// column's non-NULL sample before histogram building. Cross-type
14081/// pairs (Int vs Float, Date vs Timestamp, …) compare via the
14082/// same widening the eval-side `compare` operator uses; everything
14083/// else (the genuinely-incompatible pairs) falls back to ordering
14084/// by canonical string form so the sort is still total + stable.
14085/// Vector / SQ8 / Half / Json / Numeric / Interval values reach
14086/// here only via the string-fallback path because vector columns
14087/// are filtered out upstream.
14088fn sort_values_for_histogram(a: &Value, b: &Value) -> core::cmp::Ordering {
14089    use core::cmp::Ordering;
14090    match (a, b) {
14091        (Value::SmallInt(a), Value::SmallInt(b)) => a.cmp(b),
14092        (Value::Int(a), Value::Int(b)) => a.cmp(b),
14093        (Value::BigInt(a), Value::BigInt(b)) => a.cmp(b),
14094        (Value::SmallInt(a), Value::Int(b)) => i32::from(*a).cmp(b),
14095        (Value::Int(a), Value::SmallInt(b)) => a.cmp(&i32::from(*b)),
14096        (Value::Int(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
14097        (Value::BigInt(a), Value::Int(b)) => a.cmp(&i64::from(*b)),
14098        (Value::SmallInt(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
14099        (Value::BigInt(a), Value::SmallInt(b)) => a.cmp(&i64::from(*b)),
14100        (Value::Float(a), Value::Float(b)) => a.partial_cmp(b).unwrap_or(Ordering::Equal),
14101        (Value::Text(a), Value::Text(b)) | (Value::Json(a), Value::Json(b)) => a.cmp(b),
14102        (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
14103        (Value::Date(a), Value::Date(b)) => a.cmp(b),
14104        (Value::Timestamp(a), Value::Timestamp(b)) => a.cmp(b),
14105        // Mixed numeric/float — widen to f64 and compare.
14106        (Value::SmallInt(n), Value::Float(x)) => {
14107            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
14108        }
14109        (Value::Float(x), Value::SmallInt(n)) => {
14110            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
14111        }
14112        (Value::Int(n), Value::Float(x)) => {
14113            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
14114        }
14115        (Value::Float(x), Value::Int(n)) => {
14116            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
14117        }
14118        (Value::BigInt(n), Value::Float(x)) => {
14119            #[allow(clippy::cast_precision_loss)]
14120            let nf = *n as f64;
14121            nf.partial_cmp(x).unwrap_or(Ordering::Equal)
14122        }
14123        (Value::Float(x), Value::BigInt(n)) => {
14124            #[allow(clippy::cast_precision_loss)]
14125            let nf = *n as f64;
14126            x.partial_cmp(&nf).unwrap_or(Ordering::Equal)
14127        }
14128        // Cross-type fallback: lexicographic on canonical form.
14129        // Total + stable so the sort is well-defined.
14130        _ => canonical_value_repr(a).cmp(&canonical_value_repr(b)),
14131    }
14132}
14133
14134/// v6.2.0 — render the histogram bounds list as a `[v0, v1, ...]`
14135/// string for the `spg_statistic.histogram_bounds` column. Values
14136/// containing `,` or `[` / `]` are JSON-style escaped so the
14137/// rendering round-trips through a future parser; v6.2.0 only
14138/// uses the rendered form for human consumption, so the escaping
14139/// is conservative.
14140fn render_histogram_bounds(bounds: &[alloc::string::String]) -> alloc::string::String {
14141    let mut out = alloc::string::String::with_capacity(bounds.len() * 8 + 2);
14142    out.push('[');
14143    for (i, b) in bounds.iter().enumerate() {
14144        if i > 0 {
14145            out.push_str(", ");
14146        }
14147        let needs_quote = b.contains([',', '[', ']', '"']) || b.is_empty();
14148        if needs_quote {
14149            out.push('"');
14150            for ch in b.chars() {
14151                if ch == '"' || ch == '\\' {
14152                    out.push('\\');
14153                }
14154                out.push(ch);
14155            }
14156            out.push('"');
14157        } else {
14158            out.push_str(b);
14159        }
14160    }
14161    out.push(']');
14162    out
14163}
14164
14165/// v6.2.0 — canonical textual form of a `Value` for histogram
14166/// bound storage. Strings used by ANALYZE for sort + bound output.
14167/// INT / BIGINT → decimal; FLOAT → shortest-round-trip via
14168/// `{:?}`; TEXT pass-through; BOOL → `t` / `f`; DATE / TIMESTAMP →
14169/// the same form `format_date` / `format_timestamp` produce for
14170/// SQL Display. Vector / SQ8 / Half / Json / Numeric / Interval
14171/// reach this only via a non-Vector column (vector columns are
14172/// skipped upstream); they fall back to a Debug-derived form so
14173/// stats still serialise without crashing.
14174pub(crate) fn canonical_value_repr(v: &Value) -> alloc::string::String {
14175    match v {
14176        Value::Null => "NULL".to_string(),
14177        Value::SmallInt(n) => alloc::format!("{n}"),
14178        Value::Int(n) => alloc::format!("{n}"),
14179        Value::BigInt(n) => alloc::format!("{n}"),
14180        Value::Float(x) => alloc::format!("{x:?}"),
14181        Value::Text(s) | Value::Json(s) => s.clone(),
14182        Value::Bool(b) => if *b { "t" } else { "f" }.to_string(),
14183        Value::Date(d) => eval::format_date(*d),
14184        Value::Timestamp(t) => eval::format_timestamp(*t),
14185        // v7.17.0 Phase 3.P0-32 — PG TIME canonical text form.
14186        Value::Time(us) => eval::format_time(*us),
14187        // v7.17.0 Phase 3.P0-33 — MySQL YEAR 4-digit zero-padded.
14188        Value::Year(y) => alloc::format!("{y:04}"),
14189        // v7.17.0 Phase 3.P0-34 — PG TIMETZ canonical text form.
14190        Value::TimeTz { us, offset_secs } => eval::format_timetz(*us, *offset_secs),
14191        // v7.17.0 Phase 3.P0-35 — PG MONEY canonical en_US text form.
14192        Value::Money(c) => eval::format_money(*c),
14193        // v7.17.0 Phase 3.P0-38 — PG range canonical text form.
14194        v @ Value::Range { .. } => format_range_str(v),
14195        // v7.17.0 Phase 3.P0-39 — PG hstore canonical text form.
14196        Value::Hstore(pairs) => format_hstore_str(pairs),
14197        // v7.17.0 Phase 3.P0-40 — 2D array canonical text form.
14198        Value::IntArray2D(rows) => format_int_2d_text(rows),
14199        Value::BigIntArray2D(rows) => format_bigint_2d_text(rows),
14200        Value::TextArray2D(rows) => format_text_2d_text(rows),
14201        Value::Interval { months, micros } => eval::format_interval(*months, *micros),
14202        Value::Numeric { scaled, scale } => eval::format_numeric(*scaled, *scale),
14203        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
14204            // Unreachable in practice (vector columns are filtered
14205            // out before this). Defensive fallback so a future
14206            // vector-stats path doesn't crash.
14207            alloc::format!("{v:?}")
14208        }
14209        // v7.5.0 — Value is #[non_exhaustive] for downstream
14210        // forward-compat. Future variants fall through to Debug
14211        // form here (same shape as the vector fallback above).
14212        _ => alloc::format!("{v:?}"),
14213    }
14214}
14215
14216/// v6.2.0 — true for engine-managed catalog tables that the bare
14217/// `ANALYZE` (no target) should skip. v6.2.0 has no internal
14218/// tables yet (publications / subscriptions / users / statistics
14219/// all live as engine fields, not catalog tables), so this is a
14220/// reserved future-proofing hook — every existing user table is
14221/// analysed.
14222const fn is_internal_table_name(_name: &str) -> bool {
14223    false
14224}
14225
14226fn value_to_literal(v: Value) -> Literal {
14227    match v {
14228        Value::Null => Literal::Null,
14229        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
14230        Value::Int(n) => Literal::Integer(i64::from(n)),
14231        Value::BigInt(n) => Literal::Integer(n),
14232        Value::Float(x) => Literal::Float(x),
14233        Value::Text(s) | Value::Json(s) => Literal::String(s),
14234        Value::Bool(b) => Literal::Bool(b),
14235        Value::Vector(v) => Literal::Vector(v),
14236        Value::Numeric { scaled, scale } => Literal::String(eval::format_numeric(scaled, scale)),
14237        Value::Date(d) => Literal::String(eval::format_date(d)),
14238        Value::Timestamp(t) => Literal::String(eval::format_timestamp(t)),
14239        // v7.17.0 Phase 3.P0-69 — UUID round-trips via canonical
14240        // hyphenated text. Without this arm the fallback below
14241        // renders `Debug` form ("Uuid([85, …])") which the
14242        // engine's Text → Uuid coerce can't parse, breaking
14243        // prepared-bind round-trip from the spg-sqlx adapter.
14244        Value::Uuid(b) => Literal::String(spg_storage::format_uuid(&b)),
14245        // v7.16.0 — BYTEA round-trip for the spg-sqlx Bind path.
14246        // PG-canonical text rep is `\x` + lowercase hex; the
14247        // engine's coerce_value already accepts that on the
14248        // text → bytea direction.
14249        Value::Bytes(b) => Literal::String(eval::format_bytea_hex(&b)),
14250        // Arrays ride the AST natively (mailrs embed round-12) —
14251        // the prior `{a,b,c}` text form only worked where a column
14252        // type drove the re-parse; `= ANY($1)` has no column
14253        // context and saw a bare Text value.
14254        Value::TextArray(items) => Literal::TextArray(items),
14255        Value::IntArray(items) => Literal::IntArray(items),
14256        Value::BigIntArray(items) => Literal::BigIntArray(items),
14257        Value::Interval { months, micros } => Literal::Interval {
14258            months,
14259            micros,
14260            text: eval::format_interval(months, micros),
14261        },
14262        // SQ8 / halfvec cells dequantise to f32 before reaching the
14263        // substitute walker; pgwire's Bind path handles that.
14264        Value::Sq8Vector(q) => Literal::Vector(spg_storage::quantize::dequantize(&q)),
14265        Value::HalfVector(h) => Literal::Vector(h.to_f32_vec()),
14266        // v7.5.0 — Value is #[non_exhaustive]; future variants
14267        // render as Debug-form String literal until explicit
14268        // mapping is added.
14269        v => Literal::String(alloc::format!("{v:?}")),
14270    }
14271}
14272
14273fn rewrite_clock_calls(stmt: &mut Statement, now_micros: Option<i64>) {
14274    let Some(now) = now_micros else {
14275        return;
14276    };
14277    match stmt {
14278        Statement::Select(s) => rewrite_select_clock(s, now),
14279        Statement::Insert(ins) => {
14280            for row in &mut ins.rows {
14281                for e in row {
14282                    rewrite_expr_clock(e, now);
14283                }
14284            }
14285            // `ON CONFLICT … DO UPDATE SET created_at = NOW()` —
14286            // the upsert assignments carry clock calls too (mailrs
14287            // embed round-12).
14288            if let Some(clause) = &mut ins.on_conflict
14289                && let spg_sql::ast::OnConflictAction::Update {
14290                    assignments,
14291                    where_,
14292                } = &mut clause.action
14293            {
14294                for (_, e) in assignments.iter_mut() {
14295                    rewrite_expr_clock(e, now);
14296                }
14297                if let Some(w) = where_ {
14298                    rewrite_expr_clock(w, now);
14299                }
14300            }
14301        }
14302        // `UPDATE … SET seen_at = NOW() WHERE …` / `DELETE … WHERE
14303        // ts < NOW()` (mailrs embed round-12 — previously only
14304        // SELECT / INSERT-rows were walked).
14305        Statement::Update(u) => {
14306            for (_, e) in &mut u.assignments {
14307                rewrite_expr_clock(e, now);
14308            }
14309            if let Some(w) = &mut u.where_ {
14310                rewrite_expr_clock(w, now);
14311            }
14312        }
14313        Statement::Delete(d) => {
14314            if let Some(w) = &mut d.where_ {
14315                rewrite_expr_clock(w, now);
14316            }
14317        }
14318        _ => {}
14319    }
14320}
14321
14322fn rewrite_select_clock(s: &mut SelectStatement, now: i64) {
14323    for item in &mut s.items {
14324        if let SelectItem::Expr { expr, .. } = item {
14325            rewrite_expr_clock(expr, now);
14326        }
14327    }
14328    if let Some(w) = &mut s.where_ {
14329        rewrite_expr_clock(w, now);
14330    }
14331    if let Some(gs) = &mut s.group_by {
14332        for g in gs {
14333            rewrite_expr_clock(g, now);
14334        }
14335    }
14336    if let Some(h) = &mut s.having {
14337        rewrite_expr_clock(h, now);
14338    }
14339    for o in &mut s.order_by {
14340        rewrite_expr_clock(&mut o.expr, now);
14341    }
14342    for (_, peer) in &mut s.unions {
14343        rewrite_select_clock(peer, now);
14344    }
14345}
14346
14347/// v3.0.3 hot path: every recursion lands in exactly one `match` arm.
14348/// Literal / Column-with-qualifier (the dominant cases on a typical
14349/// AST) take a single pattern dispatch and exit. The clock-rewrite
14350/// targets (zero-arg `NOW` / `CURRENT_TIMESTAMP` / `CURRENT_DATE`
14351/// functions, and bare `CURRENT_TIMESTAMP` / `CURRENT_DATE` column
14352/// refs) sit on their own arms with match guards so the fall-through
14353/// to the recursive arms is unambiguous.
14354fn rewrite_expr_clock(e: &mut Expr, now: i64) {
14355    // Fast-path test on the no-recursion shapes first. We can't fold
14356    // them into the big match below because they need to *replace* `e`
14357    // outright; the recursive arms below match on its sub-fields.
14358    if let Some(replacement) = clock_replacement_for(e, now) {
14359        *e = replacement;
14360        return;
14361    }
14362    match e {
14363        Expr::AggregateOrdered { call, order_by } => {
14364            rewrite_expr_clock(call, now);
14365            for o in order_by.iter_mut() {
14366                rewrite_expr_clock(&mut o.expr, now);
14367            }
14368        }
14369        Expr::Binary { lhs, rhs, .. } => {
14370            rewrite_expr_clock(lhs, now);
14371            rewrite_expr_clock(rhs, now);
14372        }
14373        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14374            rewrite_expr_clock(expr, now);
14375        }
14376        Expr::FunctionCall { args, .. } => {
14377            for a in args {
14378                rewrite_expr_clock(a, now);
14379            }
14380        }
14381        Expr::Like { expr, pattern, .. } => {
14382            rewrite_expr_clock(expr, now);
14383            rewrite_expr_clock(pattern, now);
14384        }
14385        Expr::Extract { source, .. } => rewrite_expr_clock(source, now),
14386        // v4.10 subquery nodes — recurse into the inner SELECT's
14387        // expression slots so e.g. SELECT NOW() in a scalar
14388        // subquery picks up the same instant as the outer query.
14389        Expr::ScalarSubquery(s) => rewrite_select_clock(s, now),
14390        Expr::Exists { subquery, .. } => rewrite_select_clock(subquery, now),
14391        Expr::InSubquery { expr, subquery, .. } => {
14392            rewrite_expr_clock(expr, now);
14393            rewrite_select_clock(subquery, now);
14394        }
14395        // v4.12 window functions — args + PARTITION BY + ORDER BY
14396        // may all reference clock literals.
14397        Expr::WindowFunction {
14398            args,
14399            partition_by,
14400            order_by,
14401            ..
14402        } => {
14403            for a in args {
14404                rewrite_expr_clock(a, now);
14405            }
14406            for p in partition_by {
14407                rewrite_expr_clock(p, now);
14408            }
14409            for (e, _, _) in order_by {
14410                rewrite_expr_clock(e, now);
14411            }
14412        }
14413        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
14414        Expr::Array(items) => {
14415            for elem in items {
14416                rewrite_expr_clock(elem, now);
14417            }
14418        }
14419        Expr::ArraySubscript { target, index } => {
14420            rewrite_expr_clock(target, now);
14421            rewrite_expr_clock(index, now);
14422        }
14423        Expr::AnyAll { expr, array, .. } => {
14424            rewrite_expr_clock(expr, now);
14425            rewrite_expr_clock(array, now);
14426        }
14427        Expr::Case {
14428            operand,
14429            branches,
14430            else_branch,
14431        } => {
14432            if let Some(o) = operand {
14433                rewrite_expr_clock(o, now);
14434            }
14435            for (w, t) in branches {
14436                rewrite_expr_clock(w, now);
14437                rewrite_expr_clock(t, now);
14438            }
14439            if let Some(e) = else_branch {
14440                rewrite_expr_clock(e, now);
14441            }
14442        }
14443    }
14444}
14445
14446/// Returns `Some(Expr)` when `e` is one of the clock-call shapes that
14447/// must be rewritten; otherwise `None` so the caller falls through to
14448/// the recursive walk. Identifies both function-call forms (`NOW()` /
14449/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()`) and bare-identifier forms
14450/// (`CURRENT_TIMESTAMP` / `CURRENT_DATE` as unqualified column refs,
14451/// which is how PG accepts them without parens).
14452fn clock_replacement_for(e: &Expr, now: i64) -> Option<Expr> {
14453    let (kind, name) = match e {
14454        Expr::FunctionCall { name, args } if args.is_empty() => (ClockSite::Fn, name.as_str()),
14455        Expr::Column(c) if c.qualifier.is_none() => (ClockSite::BareIdent, c.name.as_str()),
14456        _ => return None,
14457    };
14458    // ASCII case-insensitive name match. Each entry decides what
14459    // synthetic literal the call expands to.
14460    //
14461    // v7.17.0 Phase 3.P0-29 — `unix_timestamp` (no args) joins this
14462    // table as MySQL's epoch-seconds equivalent of `now()`. Folded
14463    // to a BigInt literal here so apply_function never needs a
14464    // clock dependency.
14465    enum ClockShape {
14466        Timestamp,
14467        Date,
14468        UnixSeconds,
14469    }
14470    let shape = match name.len() {
14471        3 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("now") => {
14472            Some(ClockShape::Timestamp)
14473        }
14474        12 if name.eq_ignore_ascii_case("current_date") => Some(ClockShape::Date),
14475        14 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("unix_timestamp") => {
14476            Some(ClockShape::UnixSeconds)
14477        }
14478        17 if name.eq_ignore_ascii_case("current_timestamp") => Some(ClockShape::Timestamp),
14479        _ => None,
14480    };
14481    let shape = shape?;
14482    let payload = match shape {
14483        ClockShape::Timestamp => now,
14484        ClockShape::Date => now.div_euclid(86_400_000_000),
14485        ClockShape::UnixSeconds => now.div_euclid(1_000_000),
14486    };
14487    let target = match shape {
14488        ClockShape::Timestamp => spg_sql::ast::CastTarget::Timestamp,
14489        ClockShape::Date => spg_sql::ast::CastTarget::Date,
14490        ClockShape::UnixSeconds => spg_sql::ast::CastTarget::BigInt,
14491    };
14492    Some(Expr::Cast {
14493        expr: alloc::boxed::Box::new(Expr::Literal(spg_sql::ast::Literal::Integer(payload))),
14494        target,
14495    })
14496}
14497
14498#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14499enum ClockSite {
14500    Fn,
14501    BareIdent,
14502}
14503
14504/// `ORDER BY <integer>` references the N-th SELECT item (1-based).
14505/// Swap the integer literal for the matching item's expression so the
14506/// executor doesn't need a special-case branch. Recurses into UNION
14507/// peers because each peer keeps its own SELECT list.
14508/// v6.4.1 — expand `GROUP BY ALL` to every non-aggregate SELECT-list
14509/// item. Mirrors DuckDB / PG 19 semantics. Wildcards (`SELECT * …`)
14510/// are NOT expanded by GROUP BY ALL (PG 19 leaves the wildcard intact
14511/// and groups by whatever explicit non-aggregates remain — none in
14512/// the wildcard-only case, which still works for non-aggregate
14513/// queries).
14514fn expand_group_by_all(s: &mut SelectStatement) {
14515    if !s.group_by_all {
14516        for (_, peer) in &mut s.unions {
14517            expand_group_by_all(peer);
14518        }
14519        return;
14520    }
14521    let mut groups: Vec<Expr> = Vec::new();
14522    for item in &s.items {
14523        if let SelectItem::Expr { expr, .. } = item
14524            && !aggregate::contains_aggregate(expr)
14525        {
14526            groups.push(expr.clone());
14527        }
14528    }
14529    s.group_by = Some(groups);
14530    s.group_by_all = false;
14531    for (_, peer) in &mut s.unions {
14532        expand_group_by_all(peer);
14533    }
14534}
14535
14536fn resolve_order_by_position(s: &mut SelectStatement) {
14537    // v6.4.0 — iterate every ORDER BY key. Position references
14538    // (`ORDER BY 2`) bind to the 1-based projection index;
14539    // identifier references that match a SELECT-list alias bind to
14540    // the projected expression (Step 4 of L3a).
14541    for order in &mut s.order_by {
14542        match &order.expr {
14543            Expr::Literal(Literal::Integer(n)) if *n >= 1 => {
14544                if let Ok(idx_one_based) = usize::try_from(*n) {
14545                    let idx = idx_one_based - 1;
14546                    if idx < s.items.len()
14547                        && let SelectItem::Expr { expr, .. } = &s.items[idx]
14548                    {
14549                        order.expr = expr.clone();
14550                    }
14551                }
14552            }
14553            Expr::Column(c) if c.qualifier.is_none() => {
14554                // Alias-in-ORDER-BY lookup.
14555                for item in &s.items {
14556                    if let SelectItem::Expr {
14557                        expr,
14558                        alias: Some(a),
14559                    } = item
14560                        && a == &c.name
14561                    {
14562                        order.expr = expr.clone();
14563                        break;
14564                    }
14565                }
14566            }
14567            _ => {}
14568        }
14569    }
14570    for (_, peer) in &mut s.unions {
14571        resolve_order_by_position(peer);
14572    }
14573}
14574
14575/// Sort `tagged` by `f64` key, reversing the comparator under DESC.
14576/// Used by the UNION ORDER BY path; per-block paths inline the same
14577/// comparator because they already hold `&OrderBy` directly.
14578/// v3.1.1: partial-sort helper. When `keep` (= offset + limit) is
14579/// strictly less than `tagged.len()`, run `select_nth_unstable_by` to
14580/// partition the prefix in O(n), then sort just that prefix in O(k
14581/// log k). Total O(n + k log k), vs O(n log n) for a full sort. The
14582/// caller decides what `keep` is; passing `None` (no LIMIT) keeps the
14583/// full-sort behaviour.
14584///
14585/// `tagged` holds `(Option<f64>, Row)` (the SELECT path) — `None` keys
14586/// sort last in ascending order, mirroring NULL-sorts-last in SQL.
14587fn partial_sort_tagged(tagged: &mut Vec<(Vec<f64>, Row)>, keep: Option<usize>, descs: &[bool]) {
14588    let cmp = |a: &(Vec<f64>, Row), b: &(Vec<f64>, Row)| cmp_multi_key(&a.0, &b.0, descs);
14589    match keep {
14590        Some(k) if k < tagged.len() && k > 0 => {
14591            let pivot = k - 1;
14592            tagged.select_nth_unstable_by(pivot, cmp);
14593            tagged[..k].sort_by(cmp);
14594            tagged.truncate(k);
14595        }
14596        _ => {
14597            tagged.sort_by(cmp);
14598        }
14599    }
14600}
14601
14602fn sort_by_keys(tagged: &mut [(Vec<f64>, Row)], descs: &[bool]) {
14603    tagged.sort_by(|a, b| cmp_multi_key(&a.0, &b.0, descs));
14604}
14605
14606/// v6.4.0 — multi-key ORDER BY comparator. Each key's per-key DESC
14607/// flag is honored independently. NULL is encoded as `f64::INFINITY`
14608/// so it sorts last in ASC and first in DESC (matches PG default).
14609fn cmp_multi_key(a: &[f64], b: &[f64], descs: &[bool]) -> core::cmp::Ordering {
14610    use core::cmp::Ordering;
14611    for (i, (ka, kb)) in a.iter().zip(b.iter()).enumerate() {
14612        let ord = ka.partial_cmp(kb).unwrap_or(Ordering::Equal);
14613        let ord = if descs.get(i).copied().unwrap_or(false) {
14614            ord.reverse()
14615        } else {
14616            ord
14617        };
14618        if ord != Ordering::Equal {
14619            return ord;
14620        }
14621    }
14622    Ordering::Equal
14623}
14624
14625/// v6.4.0 — eval every ORDER BY expression for a row and pack the
14626/// resulting keys into a `Vec<f64>`. NULL → `f64::INFINITY`.
14627fn build_order_keys(
14628    order_by: &[OrderBy],
14629    row: &Row,
14630    ctx: &EvalContext,
14631) -> Result<Vec<f64>, EngineError> {
14632    let mut keys = Vec::with_capacity(order_by.len());
14633    for o in order_by {
14634        let v = eval::eval_expr(&o.expr, row, ctx)?;
14635        // v7.24 (round-16 A) — explicit NULLS FIRST/LAST. The f64
14636        // packing sorts ascending THEN applies the per-key DESC
14637        // reverse, so a NULL must land at +INF exactly when the
14638        // effective placement agrees with the reverse direction:
14639        // nf == desc → +INF (ASC default last / DESC default
14640        // first), nf != desc → -INF (the explicit flips).
14641        if matches!(v, Value::Null) {
14642            let nf = o.nulls_first.unwrap_or(o.desc);
14643            keys.push(if nf == o.desc {
14644                f64::INFINITY
14645            } else {
14646                f64::NEG_INFINITY
14647            });
14648        } else {
14649            keys.push(value_to_order_key(&v)?);
14650        }
14651    }
14652    Ok(keys)
14653}
14654
14655/// Drop the first `offset` rows then truncate to `limit`. PG / `MySQL`
14656/// agree: OFFSET applies *after* ORDER BY but *before* LIMIT (so
14657/// `LIMIT 10 OFFSET 5` keeps rows 6..=15).
14658fn apply_offset_and_limit(rows: &mut Vec<Row>, offset: Option<u32>, limit: Option<u32>) {
14659    if let Some(off) = offset {
14660        let off = off as usize;
14661        if off >= rows.len() {
14662            rows.clear();
14663        } else {
14664            rows.drain(..off);
14665        }
14666    }
14667    if let Some(n) = limit {
14668        rows.truncate(n as usize);
14669    }
14670}
14671
14672/// v7.17.0 Phase 3.P0-49 — offset + limit applied to a tagged
14673/// `(order_keys, row)` sequence, with optional SQL:2008 `WITH
14674/// TIES` extension. When `with_ties` is set, the truncated tail
14675/// is extended through every subsequent row whose order keys
14676/// equal the last-kept row's keys (so a "top 3 by score" with
14677/// WITH TIES emits row 4 too when row 4 ties row 3 on `score`).
14678///
14679/// The order-key vector is the per-row sort key the caller already
14680/// computed via `build_order_keys`; equal-key detection therefore
14681/// matches the sort comparator exactly.
14682fn apply_offset_and_limit_tagged(
14683    tagged: &mut Vec<(Vec<f64>, Row)>,
14684    offset: Option<u32>,
14685    limit: Option<u32>,
14686    with_ties: bool,
14687) {
14688    if let Some(off) = offset {
14689        let off = off as usize;
14690        if off >= tagged.len() {
14691            tagged.clear();
14692        } else {
14693            tagged.drain(..off);
14694        }
14695    }
14696    if let Some(n) = limit {
14697        let n = n as usize;
14698        if with_ties && n > 0 && n < tagged.len() {
14699            let cutoff_key = tagged[n - 1].0.clone();
14700            let mut end = n;
14701            while end < tagged.len() && tagged[end].0 == cutoff_key {
14702                end += 1;
14703            }
14704            tagged.truncate(end);
14705        } else {
14706            tagged.truncate(n);
14707        }
14708    }
14709}
14710
14711/// v7.17.0 Phase 3.P0-49 — PG-canonical: `FETCH FIRST <n> ROWS
14712/// WITH TIES` requires an `ORDER BY`. Without one, there's no
14713/// way to identify "ties" deterministically, so PG errors at
14714/// plan time. SPG mirrors that surface so the same DDL / app
14715/// behaviour holds on cutover.
14716fn check_with_ties_requires_order_by(stmt: &SelectStatement) -> Result<(), EngineError> {
14717    if stmt.limit_with_ties && stmt.order_by.is_empty() {
14718        return Err(EngineError::Unsupported(alloc::string::String::from(
14719            "FETCH FIRST … ROWS WITH TIES requires an ORDER BY clause",
14720        )));
14721    }
14722    Ok(())
14723}
14724
14725/// v7.6.1 — resolve a parser-level `ForeignKeyConstraint` (column
14726/// names + parent table name) into the storage-layer shape (column
14727/// indices + same parent table). Validates everything the engine
14728/// needs to know about the FK at CREATE TABLE time:
14729///
14730///   - parent table exists (catalog lookup, unless self-referencing)
14731///   - parent columns exist on the parent table
14732///   - parent column list matches the local arity (defaults to the
14733///     parent's primary index column when omitted)
14734///   - parent columns are covered by a `BTree` UNIQUE-class index
14735///     (SPG's stand-in for `PRIMARY KEY`/`UNIQUE`) — required so
14736///     the v7.6.2 INSERT path can do an O(log n) parent lookup
14737///   - local columns exist on the table being created
14738fn resolve_foreign_key(
14739    local_table_name: &str,
14740    local_cols: &[ColumnSchema],
14741    fk: spg_sql::ast::ForeignKeyConstraint,
14742    catalog: &Catalog,
14743) -> Result<spg_storage::ForeignKeyConstraint, EngineError> {
14744    // Resolve local columns.
14745    let mut local_columns = Vec::with_capacity(fk.columns.len());
14746    for name in &fk.columns {
14747        let pos = local_cols
14748            .iter()
14749            .position(|c| c.name == *name)
14750            .ok_or_else(|| {
14751                EngineError::Unsupported(alloc::format!(
14752                    "FOREIGN KEY references unknown local column {name:?}"
14753                ))
14754            })?;
14755        local_columns.push(pos);
14756    }
14757    // Self-referencing FK: parent table is the one we're creating.
14758    // The parent column resolution uses the local column list since
14759    // the catalog doesn't have this table yet.
14760    let is_self_ref = fk.parent_table == local_table_name;
14761    let (parent_cols_for_lookup, parent_table_str): (&[ColumnSchema], &str) = if is_self_ref {
14762        (local_cols, local_table_name)
14763    } else {
14764        let parent_table = catalog.get(&fk.parent_table).ok_or_else(|| {
14765            EngineError::Storage(StorageError::TableNotFound {
14766                name: fk.parent_table.clone(),
14767            })
14768        })?;
14769        (
14770            parent_table.schema().columns.as_slice(),
14771            fk.parent_table.as_str(),
14772        )
14773    };
14774    // Resolve parent column names → positions. If the FK omitted the
14775    // parent column list, fall back to the parent's primary index
14776    // column (single-column only — composite default is rejected
14777    // because there's no unambiguous "PK" in SPG's index list).
14778    let parent_columns: Vec<usize> = if fk.parent_columns.is_empty() {
14779        if fk.columns.len() != 1 {
14780            return Err(EngineError::Unsupported(
14781                "composite FOREIGN KEY without explicit parent column list is not supported \
14782                 — list the parent columns explicitly"
14783                    .into(),
14784            ));
14785        }
14786        // Find a single BTree index on the parent and use its column.
14787        let pos = pick_pk_index_column(catalog, parent_table_str, is_self_ref, local_cols)
14788            .ok_or_else(|| {
14789                EngineError::Unsupported(alloc::format!(
14790                    "parent table {parent_table_str:?} has no PRIMARY-key / UNIQUE BTree index \
14791                     to default the FOREIGN KEY against"
14792                ))
14793            })?;
14794        alloc::vec![pos]
14795    } else {
14796        let mut out = Vec::with_capacity(fk.parent_columns.len());
14797        for name in &fk.parent_columns {
14798            let pos = parent_cols_for_lookup
14799                .iter()
14800                .position(|c| c.name == *name)
14801                .ok_or_else(|| {
14802                    EngineError::Unsupported(alloc::format!(
14803                        "FOREIGN KEY references unknown parent column \
14804                         {name:?} on table {parent_table_str:?}"
14805                    ))
14806                })?;
14807            out.push(pos);
14808        }
14809        out
14810    };
14811    if parent_columns.len() != local_columns.len() {
14812        return Err(EngineError::Unsupported(alloc::format!(
14813            "FOREIGN KEY arity mismatch: {} local columns vs {} parent columns",
14814            local_columns.len(),
14815            parent_columns.len()
14816        )));
14817    }
14818    // For non-self-referencing FKs, verify the parent column set is
14819    // covered by a BTree index. SPG doesn't have a `PRIMARY KEY`
14820    // declaration; the convention is "the parent column for FK
14821    // purposes must have a BTree index" — which the user creates via
14822    // `CREATE INDEX ... USING btree (col)` (the default). We accept
14823    // any single-column BTree index that covers a parent column;
14824    // composite parent column lists require an index whose `column_position`
14825    // matches the first parent column (multi-column BTree indices
14826    // are not in the v7.x roadmap).
14827    if !is_self_ref {
14828        let parent_table = catalog.get(&fk.parent_table).expect("checked above");
14829        let primary_parent_col = parent_columns[0];
14830        let has_btree = parent_table
14831            .schema()
14832            .columns
14833            .get(primary_parent_col)
14834            .is_some()
14835            && parent_table.indices().iter().any(|idx| {
14836                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14837                    && idx.column_position == primary_parent_col
14838                    && idx.partial_predicate.is_none()
14839            });
14840        if !has_btree {
14841            return Err(EngineError::Unsupported(alloc::format!(
14842                "FOREIGN KEY parent column on {:?} is not covered by an unconditional BTree \
14843                 index — create one with `CREATE INDEX ... ON {} ({})` first",
14844                parent_table_str,
14845                parent_table_str,
14846                parent_table.schema().columns[primary_parent_col].name,
14847            )));
14848        }
14849    }
14850    let on_delete = fk_action_sql_to_storage(fk.on_delete);
14851    let on_update = fk_action_sql_to_storage(fk.on_update);
14852    Ok(spg_storage::ForeignKeyConstraint {
14853        name: fk.name,
14854        local_columns,
14855        parent_table: fk.parent_table,
14856        parent_columns,
14857        on_delete,
14858        on_update,
14859    })
14860}
14861
14862/// v7.6.1 — pick a sentinel "primary key" column from the parent
14863/// table when the FK didn't name parent columns. Picks the first
14864/// single-column unconditional BTree index — that's the closest
14865/// thing SPG has to a PRIMARY KEY today. Self-referencing FKs use
14866/// `local_cols` as the column source.
14867fn pick_pk_index_column(
14868    catalog: &Catalog,
14869    parent_name: &str,
14870    is_self_ref: bool,
14871    local_cols: &[ColumnSchema],
14872) -> Option<usize> {
14873    if is_self_ref {
14874        // Self-ref FK omitted parent columns: pick column 0 by
14875        // convention (no catalog entry yet). Engine will widen this
14876        // when v7.6.7 lands; v7.6.1 only handles the explicit form.
14877        let _ = local_cols;
14878        return Some(0);
14879    }
14880    let parent = catalog.get(parent_name)?;
14881    parent.indices().iter().find_map(|idx| {
14882        if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14883            && idx.partial_predicate.is_none()
14884            && idx.included_columns.is_empty()
14885            && idx.expression.is_none()
14886        {
14887            Some(idx.column_position)
14888        } else {
14889            None
14890        }
14891    })
14892}
14893
14894/// v7.9.8 / v7.9.10 — resolve the column positions that
14895/// identify a conflict for ON CONFLICT. Returns a Vec of
14896/// column positions (1 element for single-column form, N for
14897/// composite). When the user wrote bare `ON CONFLICT DO …`,
14898/// falls back to the table's first unconditional BTree index
14899/// (always single-column today).
14900fn resolve_on_conflict_columns(
14901    catalog: &Catalog,
14902    table_name: &str,
14903    target: &[String],
14904) -> Result<Vec<usize>, EngineError> {
14905    let table = catalog.get(table_name).ok_or_else(|| {
14906        EngineError::Storage(StorageError::TableNotFound {
14907            name: table_name.into(),
14908        })
14909    })?;
14910    if target.is_empty() {
14911        // v7.13.2 — mailrs round-6 S5 follow-up. Composite UNIQUE
14912        // constraints carry a multi-column tuple; the prior code
14913        // path picked only the leading column of the first BTree
14914        // index, which caused `ON CONFLICT DO NOTHING` to dedup
14915        // by leading column alone (3 rows with same group_id but
14916        // different permission collapsed to 1). PG semantics use
14917        // the full tuple. Prefer a UniquenessConstraint's full
14918        // column list when one exists; fall back to the leading
14919        // BTree column for legacy single-column UNIQUE.
14920        if let Some(uc) = table.schema().uniqueness_constraints.first() {
14921            return Ok(uc.columns.clone());
14922        }
14923        let pos = table
14924            .indices()
14925            .iter()
14926            .find_map(|idx| {
14927                if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14928                    && idx.partial_predicate.is_none()
14929                    && idx.included_columns.is_empty()
14930                    && idx.expression.is_none()
14931                {
14932                    Some(idx.column_position)
14933                } else {
14934                    None
14935                }
14936            })
14937            .ok_or_else(|| {
14938                EngineError::Unsupported(alloc::format!(
14939                    "ON CONFLICT without target requires a UNIQUE BTree index on {table_name:?}"
14940                ))
14941            })?;
14942        return Ok(alloc::vec![pos]);
14943    }
14944    let mut out = Vec::with_capacity(target.len());
14945    for name in target {
14946        let pos = table
14947            .schema()
14948            .columns
14949            .iter()
14950            .position(|c| c.name == *name)
14951            .ok_or_else(|| {
14952                EngineError::Unsupported(alloc::format!(
14953                    "ON CONFLICT target column {name:?} not found on {table_name:?}"
14954                ))
14955            })?;
14956        out.push(pos);
14957    }
14958    Ok(out)
14959}
14960
14961/// v7.9.8 — check whether the BTree index on `column_pos` of
14962/// `table_name` already has a row with this key.
14963fn on_conflict_key_exists(
14964    catalog: &Catalog,
14965    table_name: &str,
14966    column_pos: usize,
14967    key: &Value,
14968) -> bool {
14969    let Some(table) = catalog.get(table_name) else {
14970        return false;
14971    };
14972    let Some(idx_key) = spg_storage::IndexKey::from_value(key) else {
14973        return false;
14974    };
14975    table.indices().iter().any(|idx| {
14976        matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14977            && idx.column_position == column_pos
14978            && idx.partial_predicate.is_none()
14979            && !idx.lookup_eq(&idx_key).is_empty()
14980    })
14981}
14982
14983/// v7.9.9 / v7.9.10 — look up an existing row's position by
14984/// matching all `column_positions` against the incoming `key`
14985/// tuple. Single-column shape (one column) reduces to the
14986/// canonical PK lookup; composite shapes scan linearly until
14987/// every position matches.
14988fn lookup_row_position_by_keys(
14989    catalog: &Catalog,
14990    table_name: &str,
14991    column_positions: &[usize],
14992    key: &[&Value],
14993) -> Option<usize> {
14994    let table = catalog.get(table_name)?;
14995    table.rows().iter().position(|r| {
14996        column_positions
14997            .iter()
14998            .enumerate()
14999            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
15000    })
15001}
15002
15003/// v7.9.10 — does the table already contain a row whose
15004/// `column_positions` tuple equals `key`? Single-column shape
15005/// uses the existing BTree fast path; composite shapes fall
15006/// back to a row scan.
15007fn on_conflict_keys_exist(
15008    catalog: &Catalog,
15009    table_name: &str,
15010    column_positions: &[usize],
15011    key: &[&Value],
15012) -> bool {
15013    if column_positions.len() == 1 {
15014        return on_conflict_key_exists(catalog, table_name, column_positions[0], key[0]);
15015    }
15016    let Some(table) = catalog.get(table_name) else {
15017        return false;
15018    };
15019    table.rows().iter().any(|r| {
15020        column_positions
15021            .iter()
15022            .enumerate()
15023            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
15024    })
15025}
15026
15027/// v7.9.9 — apply ON CONFLICT DO UPDATE SET assignments to an
15028/// existing row.
15029///
15030/// `incoming` is the rejected INSERT row (used to resolve
15031/// `EXCLUDED.col` references in the assignment exprs);
15032/// `target_pos` is the position of the existing row in the table.
15033/// Each assignment substitutes `EXCLUDED.col` with the matching
15034/// incoming value, evaluates the resulting expression against
15035/// the existing row, and writes the new value into the
15036/// corresponding column of the returned `Vec<Value>`. If
15037/// `where_` evaluates falsy, returns Ok(None) — PG behaviour:
15038/// the conflicting row is silently kept unchanged.
15039fn apply_on_conflict_assignments(
15040    catalog: &Catalog,
15041    table_name: &str,
15042    target_pos: usize,
15043    incoming: &[Value],
15044    assignments: &[(String, Expr)],
15045    where_: Option<&Expr>,
15046) -> Result<Option<Vec<Value>>, EngineError> {
15047    let table = catalog.get(table_name).ok_or_else(|| {
15048        EngineError::Storage(StorageError::TableNotFound {
15049            name: table_name.into(),
15050        })
15051    })?;
15052    let schema_cols = table.schema().columns.clone();
15053    let existing = table
15054        .rows()
15055        .get(target_pos)
15056        .ok_or_else(|| {
15057            EngineError::Unsupported(alloc::format!(
15058                "ON CONFLICT DO UPDATE: row position {target_pos} out of bounds on {table_name:?}"
15059            ))
15060        })?
15061        .clone();
15062    let ctx = eval::EvalContext::new(&schema_cols, Some(table_name));
15063    // Optional WHERE filter on the conflict row.
15064    if let Some(w) = where_ {
15065        let pred = w.clone();
15066        let pred = substitute_excluded_refs(pred, &schema_cols, incoming);
15067        let v = eval::eval_expr(&pred, &existing, &ctx)?;
15068        if !matches!(v, Value::Bool(true)) {
15069            return Ok(None);
15070        }
15071    }
15072    let mut new_values = existing.values.clone();
15073    for (col_name, expr) in assignments {
15074        let target_idx = schema_cols
15075            .iter()
15076            .position(|c| c.name == *col_name)
15077            .ok_or_else(|| {
15078                EngineError::Eval(EvalError::ColumnNotFound {
15079                    name: col_name.clone(),
15080                })
15081            })?;
15082        let sub = substitute_excluded_refs(expr.clone(), &schema_cols, incoming);
15083        let v = eval::eval_expr(&sub, &existing, &ctx)?;
15084        let coerced = coerce_value(v, schema_cols[target_idx].ty, col_name, target_idx)?;
15085        check_unsigned_range(&coerced, &schema_cols[target_idx], target_idx)?;
15086        new_values[target_idx] = coerced;
15087    }
15088    Ok(Some(new_values))
15089}
15090
15091/// v7.9.9 — walk an `Expr` tree replacing any `Column { qualifier:
15092/// "EXCLUDED", name }` reference with a `Literal` of the matching
15093/// value from the incoming-row vec. Resolution against the
15094/// child-table column list (by name).
15095fn substitute_excluded_refs(expr: Expr, schema_cols: &[ColumnSchema], incoming: &[Value]) -> Expr {
15096    use spg_sql::ast::ColumnName;
15097    match expr {
15098        Expr::Column(ColumnName { qualifier, name })
15099            if qualifier
15100                .as_deref()
15101                .is_some_and(|q| q.eq_ignore_ascii_case("excluded")) =>
15102        {
15103            let pos = schema_cols.iter().position(|c| c.name == name);
15104            match pos {
15105                Some(p) => {
15106                    let v = incoming.get(p).cloned().unwrap_or(Value::Null);
15107                    value_to_literal_expr(v)
15108                        .unwrap_or_else(|_| Expr::Literal(spg_sql::ast::Literal::Null))
15109                }
15110                None => Expr::Column(ColumnName { qualifier, name }),
15111            }
15112        }
15113        Expr::Binary { op, lhs, rhs } => Expr::Binary {
15114            op,
15115            lhs: Box::new(substitute_excluded_refs(*lhs, schema_cols, incoming)),
15116            rhs: Box::new(substitute_excluded_refs(*rhs, schema_cols, incoming)),
15117        },
15118        Expr::Unary { op, expr } => Expr::Unary {
15119            op,
15120            expr: Box::new(substitute_excluded_refs(*expr, schema_cols, incoming)),
15121        },
15122        Expr::FunctionCall { name, args } => Expr::FunctionCall {
15123            name,
15124            args: args
15125                .into_iter()
15126                .map(|a| substitute_excluded_refs(a, schema_cols, incoming))
15127                .collect(),
15128        },
15129        other => other,
15130    }
15131}
15132
15133/// v7.6.2 / v7.6.7 — INSERT-side FK enforcement. For every row
15134/// about to be inserted into `child_table`, every FK declared on
15135/// that table is checked: the row's FK columns must either be
15136/// NULL (SQL spec skip) or match an existing parent row via the
15137/// parent's BTree PK / UNIQUE index.
15138///
15139/// Returns `EngineError::Unsupported` with a `FOREIGN KEY violation`
15140/// payload on first failure.
15141///
15142/// **Self-referencing FKs (v7.6.7 widening):** when `fk.parent_table
15143/// == child_table`, the parent rows visible to this check are
15144///  (a) rows already committed to the table, plus
15145///  (b) earlier rows from the *same* `rows` batch.
15146/// This makes `INSERT INTO tree VALUES (1, NULL), (2, 1), (3, 2)`
15147/// work in a single statement — common pattern for bulk-loading
15148/// hierarchies.
15149/// v7.9.19 — enforce table-level UNIQUE / PRIMARY KEY tuple
15150/// constraints at INSERT time. For each constraint declared on
15151/// the target table, check that no existing row + no earlier row
15152/// in the same batch has the same full-column tuple. NULL in
15153/// any column lifts the row out of the check (SQL spec: NULL
15154/// ≠ NULL for uniqueness). mailrs G1 + G6.
15155fn enforce_uniqueness_inserts(
15156    catalog: &Catalog,
15157    child_table: &str,
15158    constraints: &[spg_storage::UniquenessConstraint],
15159    rows: &[Vec<Value>],
15160) -> Result<(), EngineError> {
15161    if constraints.is_empty() {
15162        return Ok(());
15163    }
15164    let table = catalog.get(child_table).ok_or_else(|| {
15165        EngineError::Storage(StorageError::TableNotFound {
15166            name: child_table.into(),
15167        })
15168    })?;
15169    let schema = table.schema();
15170    for uc in constraints {
15171        for (batch_idx, row_values) in rows.iter().enumerate() {
15172            // v7.17.0 Phase 3.P0-45 — fold each key cell by its
15173            // column's declared Collation before comparing. Phase
15174            // 2.5b wired Collation into GROUP BY / ORDER BY / `=`
15175            // but the UNIQUE-constraint enforcement still compared
15176            // Text byte-wise; a `*_ci` column would let
15177            // `('Foo')` and `('FOO')` coexist when MySQL would
15178            // reject the second. Owned Values so the fold and
15179            // the borrow live in the same scope.
15180            let key: Vec<Value> = uc
15181                .columns
15182                .iter()
15183                .map(|&i| collated_key_cell(&row_values[i], i, schema))
15184                .collect();
15185            let has_null = key.iter().any(|v| matches!(v, Value::Null));
15186            // v7.13.0 — `NULLS NOT DISTINCT` (mailrs round-5 G10,
15187            // PG 15+): two rows whose constrained columns are all
15188            // NULL collide. SQL-standard `NULLS DISTINCT` lets any
15189            // NULL skip the check.
15190            if has_null && !uc.nulls_not_distinct {
15191                continue;
15192            }
15193            // Table-side collision: scan existing rows.
15194            let collides_in_table = table.rows().iter().any(|prow| {
15195                uc.columns.iter().enumerate().all(|(i, &p)| {
15196                    prow.values
15197                        .get(p)
15198                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
15199                })
15200            });
15201            // Batch-side collision: earlier rows in the same INSERT.
15202            let collides_in_batch = rows[..batch_idx].iter().any(|earlier| {
15203                uc.columns.iter().enumerate().all(|(i, &p)| {
15204                    earlier
15205                        .get(p)
15206                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
15207                })
15208            });
15209            if collides_in_table || collides_in_batch {
15210                let kind = if uc.is_primary_key {
15211                    "PRIMARY KEY"
15212                } else {
15213                    "UNIQUE"
15214                };
15215                let col_names: Vec<String> = uc
15216                    .columns
15217                    .iter()
15218                    .map(|&i| table.schema().columns[i].name.clone())
15219                    .collect();
15220                return Err(EngineError::Unsupported(alloc::format!(
15221                    "{kind} violation on {child_table:?} columns {col_names:?}: \
15222                     row #{batch_idx} duplicates an existing key"
15223                )));
15224            }
15225        }
15226    }
15227    Ok(())
15228}
15229
15230/// v7.17.0 Phase 3.P0-45 — return a key cell folded by its column's
15231/// declared `Collation`. For `CaseInsensitive`, fold Text payloads to
15232/// ASCII lowercase (matches Phase 2.5's `*_ci` semantics: ASCII case-
15233/// fold only, non-ASCII bytes stay byte-wise). For `Binary` or non-Text
15234/// values, the cell passes through unchanged. The caller compares the
15235/// folded values with `==`.
15236fn collated_key_cell(
15237    v: &spg_storage::Value,
15238    column_position: usize,
15239    schema: &spg_storage::TableSchema,
15240) -> spg_storage::Value {
15241    match (v, schema.columns.get(column_position).map(|c| c.collation)) {
15242        (spg_storage::Value::Text(s), Some(spg_storage::Collation::CaseInsensitive)) => {
15243            spg_storage::Value::Text(s.to_ascii_lowercase())
15244        }
15245        _ => v.clone(),
15246    }
15247}
15248
15249/// v7.9.29 — `true` iff `v` counts as a truthy SQL value for a
15250/// WHERE-style predicate. NULL → false (three-valued logic
15251/// collapses to "skip this row" for index inclusion). Numeric
15252/// non-zero, BIGINT non-zero, TINYINT non-zero, BOOLEAN true → true.
15253/// Everything else (strings, vectors, JSON, …) is not a valid
15254/// predicate result and surfaces as `false` so a malformed
15255/// predicate degrades to "row not in index" rather than panicking.
15256fn predicate_truthy(v: &spg_storage::Value) -> bool {
15257    use spg_storage::Value as V;
15258    match v {
15259        V::Bool(b) => *b,
15260        V::Int(n) => *n != 0,
15261        V::BigInt(n) => *n != 0,
15262        V::SmallInt(n) => *n != 0,
15263        _ => false,
15264    }
15265}
15266
15267/// v7.9.29 — at CREATE UNIQUE INDEX time, scan the table's
15268/// committed rows for pre-existing duplicates. If any pair of rows
15269/// matches the predicate AND has the same index key, refuse to
15270/// create the index so the user fixes the data before retrying.
15271fn check_existing_unique_violation(
15272    idx: &spg_storage::Index,
15273    schema: &spg_storage::TableSchema,
15274    rows: &[spg_storage::Row],
15275) -> Result<(), EngineError> {
15276    let predicate_expr = match idx.partial_predicate.as_deref() {
15277        Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
15278            EngineError::Unsupported(alloc::format!(
15279                "stored partial predicate {s:?} failed to re-parse: {e:?}"
15280            ))
15281        })?),
15282        None => None,
15283    };
15284    let ctx = eval::EvalContext::new(&schema.columns, None);
15285    let key_positions = unique_key_positions(idx);
15286    let mut seen: alloc::vec::Vec<alloc::vec::Vec<spg_storage::Value>> = alloc::vec::Vec::new();
15287    for row in rows {
15288        if let Some(expr) = &predicate_expr {
15289            let v = eval::eval_expr(expr, row, &ctx).map_err(|e| {
15290                EngineError::Unsupported(alloc::format!(
15291                    "evaluating UNIQUE INDEX predicate against existing row: {e:?}"
15292                ))
15293            })?;
15294            if !predicate_truthy(&v) {
15295                continue;
15296            }
15297        }
15298        let key: alloc::vec::Vec<spg_storage::Value> = key_positions
15299            .iter()
15300            .map(|&p| {
15301                let v = row
15302                    .values
15303                    .get(p)
15304                    .cloned()
15305                    .unwrap_or(spg_storage::Value::Null);
15306                collated_key_cell(&v, p, schema)
15307            })
15308            .collect();
15309        if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
15310            continue;
15311        }
15312        if seen.iter().any(|other| *other == key) {
15313            return Err(EngineError::Unsupported(alloc::format!(
15314                "CREATE UNIQUE INDEX {:?}: existing rows already violate the constraint",
15315                idx.name
15316            )));
15317        }
15318        seen.push(key);
15319    }
15320    Ok(())
15321}
15322
15323/// v7.9.29 — full key tuple for a UNIQUE INDEX (leading +
15324/// extra positions). For single-column indexes this is just
15325/// `[column_position]`.
15326fn unique_key_positions(idx: &spg_storage::Index) -> alloc::vec::Vec<usize> {
15327    let mut out = alloc::vec::Vec::with_capacity(1 + idx.extra_column_positions.len());
15328    out.push(idx.column_position);
15329    out.extend_from_slice(&idx.extra_column_positions);
15330    out
15331}
15332
15333/// v7.9.29 — at INSERT time, walk every `is_unique` index on the
15334/// target table. For each, eval the index's optional predicate
15335/// against (a) the candidate row and (b) every committed row plus
15336/// earlier batch rows; only rows where the predicate is truthy
15337/// participate. A duplicate key among predicate-matching rows is a
15338/// uniqueness violation. NULL keys lift the row out of the check
15339/// (matching PG's "UNIQUE allows multiple NULLs" semantics).
15340fn enforce_unique_index_inserts(
15341    catalog: &Catalog,
15342    table_name: &str,
15343    rows: &[alloc::vec::Vec<spg_storage::Value>],
15344) -> Result<(), EngineError> {
15345    let table = catalog.get(table_name).ok_or_else(|| {
15346        EngineError::Storage(StorageError::TableNotFound {
15347            name: table_name.into(),
15348        })
15349    })?;
15350    let schema = table.schema();
15351    let ctx = eval::EvalContext::new(&schema.columns, None);
15352    for idx in table.indices() {
15353        if !idx.is_unique {
15354            continue;
15355        }
15356        // Re-parse the predicate once per index per batch.
15357        let predicate_expr = match idx.partial_predicate.as_deref() {
15358            Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
15359                EngineError::Unsupported(alloc::format!(
15360                    "UNIQUE INDEX {:?} predicate {s:?} failed to re-parse: {e:?}",
15361                    idx.name
15362                ))
15363            })?),
15364            None => None,
15365        };
15366        let key_positions = unique_key_positions(idx);
15367        let key_of = |values: &[spg_storage::Value]| -> alloc::vec::Vec<spg_storage::Value> {
15368            // v7.17.0 Phase 3.P0-45 — fold per-column collation
15369            // before building the comparison key so a `*_ci`
15370            // column treats `'Foo'` and `'FOO'` as equal.
15371            key_positions
15372                .iter()
15373                .map(|&p| {
15374                    let v = values.get(p).cloned().unwrap_or(spg_storage::Value::Null);
15375                    collated_key_cell(&v, p, schema)
15376                })
15377                .collect()
15378        };
15379        // Helper: does `values` participate in this index? (predicate
15380        // truthy when present.) Wraps `values` into a transient Row
15381        // because eval_expr requires &Row.
15382        let participates = |values: &[spg_storage::Value]| -> Result<bool, EngineError> {
15383            let Some(expr) = &predicate_expr else {
15384                return Ok(true);
15385            };
15386            let tmp_row = spg_storage::Row {
15387                values: values.to_vec(),
15388            };
15389            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
15390                EngineError::Unsupported(alloc::format!(
15391                    "UNIQUE INDEX {:?} predicate eval: {e:?}",
15392                    idx.name
15393                ))
15394            })?;
15395            Ok(predicate_truthy(&v))
15396        };
15397        for (batch_idx, row_values) in rows.iter().enumerate() {
15398            if !participates(row_values)? {
15399                continue;
15400            }
15401            let key = key_of(row_values);
15402            if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
15403                continue;
15404            }
15405            // Committed-table collision.
15406            for prow in table.rows() {
15407                if !participates(&prow.values)? {
15408                    continue;
15409                }
15410                if key_of(&prow.values) == key {
15411                    return Err(EngineError::Unsupported(alloc::format!(
15412                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
15413                         row #{batch_idx} duplicates an existing key",
15414                        idx.name
15415                    )));
15416                }
15417            }
15418            // Within-batch collision: earlier rows in the same INSERT.
15419            for earlier in &rows[..batch_idx] {
15420                if !participates(earlier)? {
15421                    continue;
15422                }
15423                if key_of(earlier) == key {
15424                    return Err(EngineError::Unsupported(alloc::format!(
15425                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
15426                         row #{batch_idx} duplicates an earlier row in the same batch",
15427                        idx.name
15428                    )));
15429                }
15430            }
15431        }
15432    }
15433    Ok(())
15434}
15435
15436/// v7.13.0 — `UPDATE OF cols` filter helper (mailrs round-5 G7).
15437/// Returns `true` when at least one of `filter_cols` has a
15438/// different value in `new_row` vs `old_row`. Column lookup is
15439/// case-insensitive against `schema_cols`; unknown filter columns
15440/// are treated as "not changed" (the trigger therefore won't
15441/// fire on them — surfacing a parse-time error would be too
15442/// strict for catalog reloads where the schema may have drifted).
15443fn any_column_changed(
15444    filter_cols: &[String],
15445    schema_cols: &[ColumnSchema],
15446    old_row: &Row,
15447    new_row: &Row,
15448) -> bool {
15449    for col_name in filter_cols {
15450        let Some(pos) = schema_cols
15451            .iter()
15452            .position(|c| c.name.eq_ignore_ascii_case(col_name))
15453        else {
15454            continue;
15455        };
15456        let old_v = old_row.values.get(pos);
15457        let new_v = new_row.values.get(pos);
15458        if old_v != new_v {
15459            return true;
15460        }
15461    }
15462    false
15463}
15464
15465/// v7.13.0 — evaluate every CHECK predicate on the schema against
15466/// each candidate row. Mirrors PG semantics: a `false` result
15467/// rejects the mutation; a NULL result *passes* (CHECK rejects
15468/// only on definite-false, not on unknown). mailrs round-5 G3.
15469fn enforce_check_constraints(
15470    catalog: &Catalog,
15471    table_name: &str,
15472    rows: &[alloc::vec::Vec<spg_storage::Value>],
15473) -> Result<(), EngineError> {
15474    let table = catalog.get(table_name).ok_or_else(|| {
15475        EngineError::Storage(StorageError::TableNotFound {
15476            name: table_name.into(),
15477        })
15478    })?;
15479    let schema = table.schema();
15480    // v7.17.0 Phase 1.5 — domain-level CHECKs are enforced in
15481    // parallel with table-level CHECKs. Collect both lists up
15482    // front; if neither exists we early-out.
15483    let mut domain_checks_per_col: alloc::vec::Vec<(usize, alloc::vec::Vec<Expr>)> =
15484        alloc::vec::Vec::new();
15485    for (idx, col) in schema.columns.iter().enumerate() {
15486        let Some(dname) = &col.user_domain_type else {
15487            continue;
15488        };
15489        let Some(dom) = catalog.domain_types().get(dname) else {
15490            continue;
15491        };
15492        let mut parsed_for_col: alloc::vec::Vec<Expr> =
15493            alloc::vec::Vec::with_capacity(dom.checks.len());
15494        for src in &dom.checks {
15495            let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
15496                EngineError::Unsupported(alloc::format!(
15497                    "DOMAIN {dname:?} CHECK ({src:?}) on column {:?}: re-parse failed: {e:?}",
15498                    col.name
15499                ))
15500            })?;
15501            parsed_for_col.push(expr);
15502        }
15503        if !parsed_for_col.is_empty() {
15504            domain_checks_per_col.push((idx, parsed_for_col));
15505        }
15506    }
15507    if schema.checks.is_empty() && domain_checks_per_col.is_empty() {
15508        return Ok(());
15509    }
15510    let ctx = eval::EvalContext::new(&schema.columns, None);
15511    let mut parsed: alloc::vec::Vec<(usize, Expr)> = alloc::vec::Vec::new();
15512    for (i, src) in schema.checks.iter().enumerate() {
15513        let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
15514            EngineError::Unsupported(alloc::format!(
15515                "CHECK constraint #{i} on {table_name:?} ({src:?}) failed to re-parse: {e:?}"
15516            ))
15517        })?;
15518        parsed.push((i, expr));
15519    }
15520    for (batch_idx, row_values) in rows.iter().enumerate() {
15521        let tmp_row = spg_storage::Row {
15522            values: row_values.clone(),
15523        };
15524        for (i, expr) in &parsed {
15525            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
15526                EngineError::Unsupported(alloc::format!(
15527                    "CHECK constraint #{i} on {table_name:?} eval at row #{batch_idx}: {e:?}"
15528                ))
15529            })?;
15530            // PG: NULL passes (CHECK rejects on definite-false only).
15531            if matches!(v, spg_storage::Value::Bool(false)) {
15532                return Err(EngineError::Unsupported(alloc::format!(
15533                    "CHECK constraint violation on {table_name:?} (row #{batch_idx}): {:?}",
15534                    schema.checks[*i]
15535                )));
15536            }
15537        }
15538        // v7.17.0 Phase 1.5 — domain-level CHECKs. Each CHECK
15539        // expression references VALUE as a column-name; we
15540        // substitute the per-row cell into the eval context by
15541        // synthesising a single-column row of just that value
15542        // under a temporary `value` column schema.
15543        for (col_idx, checks) in &domain_checks_per_col {
15544            let cell = row_values
15545                .get(*col_idx)
15546                .cloned()
15547                .unwrap_or(spg_storage::Value::Null);
15548            let synth_cols = alloc::vec![spg_storage::ColumnSchema::new(
15549                "value",
15550                schema.columns[*col_idx].ty,
15551                schema.columns[*col_idx].nullable,
15552            )];
15553            let synth_ctx = eval::EvalContext::new(&synth_cols, None);
15554            let synth_row = spg_storage::Row {
15555                values: alloc::vec![cell],
15556            };
15557            for (ci, expr) in checks.iter().enumerate() {
15558                let v = eval::eval_expr(expr, &synth_row, &synth_ctx).map_err(|e| {
15559                    EngineError::Unsupported(alloc::format!(
15560                        "DOMAIN CHECK #{ci} on column {:?} eval at row #{batch_idx}: {e:?}",
15561                        schema.columns[*col_idx].name
15562                    ))
15563                })?;
15564                if matches!(v, spg_storage::Value::Bool(false)) {
15565                    return Err(EngineError::Unsupported(alloc::format!(
15566                        "DOMAIN CHECK violation on column {:?} (row #{batch_idx})",
15567                        schema.columns[*col_idx].name
15568                    )));
15569                }
15570            }
15571        }
15572    }
15573    Ok(())
15574}
15575
15576fn enforce_fk_inserts(
15577    catalog: &Catalog,
15578    child_table: &str,
15579    fks: &[spg_storage::ForeignKeyConstraint],
15580    rows: &[Vec<Value>],
15581) -> Result<(), EngineError> {
15582    for fk in fks {
15583        let parent_is_self = fk.parent_table == child_table;
15584        let parent = if parent_is_self {
15585            // Self-ref: read the current state of the same table.
15586            // The mut borrow on child has been dropped by the caller.
15587            catalog.get(child_table).ok_or_else(|| {
15588                EngineError::Storage(StorageError::TableNotFound {
15589                    name: child_table.into(),
15590                })
15591            })?
15592        } else {
15593            catalog.get(&fk.parent_table).ok_or_else(|| {
15594                EngineError::Storage(StorageError::TableNotFound {
15595                    name: fk.parent_table.clone(),
15596                })
15597            })?
15598        };
15599        for (batch_idx, row_values) in rows.iter().enumerate() {
15600            // Single-column FK fast path: try the parent's BTree
15601            // index for an O(log n) lookup. Composite FKs fall back
15602            // to a parent-row scan.
15603            if fk.local_columns.len() == 1 {
15604                let v = &row_values[fk.local_columns[0]];
15605                if matches!(v, Value::Null) {
15606                    continue;
15607                }
15608                let parent_col = fk.parent_columns[0];
15609                let key = spg_storage::IndexKey::from_value(v).ok_or_else(|| {
15610                    EngineError::Unsupported(alloc::format!(
15611                        "FOREIGN KEY column value of type {:?} is not index-eligible",
15612                        v.data_type()
15613                    ))
15614                })?;
15615                let present_committed = parent.indices().iter().any(|idx| {
15616                    matches!(idx.kind, spg_storage::IndexKind::BTree(_))
15617                        && idx.column_position == parent_col
15618                        && idx.partial_predicate.is_none()
15619                        && !idx.lookup_eq(&key).is_empty()
15620                });
15621                // v7.6.7 self-ref widening: also accept a match
15622                // against earlier rows in this same batch when the
15623                // FK points at the table being inserted into.
15624                let present_in_batch = parent_is_self
15625                    && rows[..batch_idx]
15626                        .iter()
15627                        .any(|earlier| earlier.get(parent_col) == Some(v));
15628                if !(present_committed || present_in_batch) {
15629                    return Err(EngineError::Unsupported(alloc::format!(
15630                        "FOREIGN KEY violation: no parent row in {:?} where {} = {:?}",
15631                        fk.parent_table,
15632                        parent
15633                            .schema()
15634                            .columns
15635                            .get(parent_col)
15636                            .map_or("?", |c| c.name.as_str()),
15637                        v,
15638                    )));
15639                }
15640            } else {
15641                // Composite FK: scan parent rows. v7.6.7 also
15642                // accepts a match against earlier rows in the same
15643                // batch (self-ref bulk-loading of hierarchies).
15644                if fk
15645                    .local_columns
15646                    .iter()
15647                    .all(|&i| matches!(row_values.get(i), Some(Value::Null)))
15648                {
15649                    continue;
15650                }
15651                let local: Vec<&Value> = fk.local_columns.iter().map(|&i| &row_values[i]).collect();
15652                let parent_match_committed = parent.rows().iter().any(|prow| {
15653                    fk.parent_columns
15654                        .iter()
15655                        .enumerate()
15656                        .all(|(i, &pi)| prow.values.get(pi) == Some(local[i]))
15657                });
15658                let parent_match_in_batch = parent_is_self
15659                    && rows[..batch_idx].iter().any(|earlier| {
15660                        fk.parent_columns
15661                            .iter()
15662                            .enumerate()
15663                            .all(|(i, &pi)| earlier.get(pi) == Some(local[i]))
15664                    });
15665                if !(parent_match_committed || parent_match_in_batch) {
15666                    return Err(EngineError::Unsupported(alloc::format!(
15667                        "FOREIGN KEY violation: no parent row in {:?} matching composite key",
15668                        fk.parent_table,
15669                    )));
15670                }
15671            }
15672        }
15673    }
15674    Ok(())
15675}
15676
15677/// v7.6.4 / v7.6.5 — one step of the FK action plan computed for a
15678/// DELETE on a parent. The plan is a list of these steps, stacked
15679/// across the FK graph by `plan_fk_parent_deletions`.
15680#[derive(Debug, Clone)]
15681struct FkChildStep {
15682    child_table: String,
15683    action: FkChildAction,
15684}
15685
15686#[derive(Debug, Clone)]
15687enum FkChildAction {
15688    /// CASCADE — remove these rows. Sorted, deduplicated positions.
15689    Delete { positions: Vec<usize> },
15690    /// SET NULL — for each (row, column) in the flat list, write
15691    /// NULL into that child cell. Multiple FKs on the same row may
15692    /// produce overlapping entries (deduped at plan time).
15693    SetNull {
15694        positions: Vec<usize>,
15695        columns: Vec<usize>,
15696    },
15697    /// SET DEFAULT — same shape as SetNull but writes the column's
15698    /// declared DEFAULT value (resolved at plan time). Columns
15699    /// without a DEFAULT raise an error during planning.
15700    SetDefault {
15701        positions: Vec<usize>,
15702        columns: Vec<usize>,
15703        defaults: Vec<Value>,
15704    },
15705}
15706
15707/// v7.6.3 → v7.6.5 — plan FK fallout for a DELETE on a parent table.
15708///
15709/// Walks every table in the catalog looking for FKs whose
15710/// `parent_table` is `parent_table_name`. For each such FK + each
15711/// to-be-deleted parent row:
15712///
15713///   - RESTRICT / NoAction → error, no plan returned
15714///   - CASCADE → child rows get scheduled for deletion; recursive
15715///   - SetNull → child FK column(s) scheduled to be NULL-ed.
15716///     Verified NULL-able at plan time.
15717///   - SetDefault → child FK column(s) scheduled to be reset to
15718///     their declared DEFAULT. Columns without a DEFAULT raise.
15719///
15720/// SET NULL / SET DEFAULT do NOT cascade further — the child row
15721/// stays; only one of its columns mutates.
15722fn plan_fk_parent_deletions(
15723    catalog: &Catalog,
15724    parent_table_name: &str,
15725    to_delete_positions: &[usize],
15726    to_delete_rows: &[Vec<Value>],
15727) -> Result<Vec<FkChildStep>, EngineError> {
15728    use alloc::collections::{BTreeMap, BTreeSet};
15729    if to_delete_rows.is_empty() {
15730        return Ok(Vec::new());
15731    }
15732    let mut delete_plan: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
15733    // setnull / setdefault keyed by child_table → (row_idx, col_idx) → optional default
15734    let mut setnull_plan: BTreeMap<String, BTreeSet<(usize, usize)>> = BTreeMap::new();
15735    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15736    let mut visited: BTreeSet<(String, usize)> = BTreeSet::new();
15737    for &p in to_delete_positions {
15738        visited.insert((parent_table_name.to_string(), p));
15739    }
15740    let mut work: Vec<(String, Vec<Value>)> = to_delete_rows
15741        .iter()
15742        .map(|r| (parent_table_name.to_string(), r.clone()))
15743        .collect();
15744    while let Some((cur_parent, parent_row)) = work.pop() {
15745        for child_name in catalog.table_names() {
15746            let child = catalog
15747                .get(&child_name)
15748                .expect("table_names → catalog.get round-trip is total");
15749            for fk in &child.schema().foreign_keys {
15750                if fk.parent_table != cur_parent {
15751                    continue;
15752                }
15753                let parent_key: Vec<&Value> = fk
15754                    .parent_columns
15755                    .iter()
15756                    .map(|&pi| &parent_row[pi])
15757                    .collect();
15758                if parent_key.iter().any(|v| matches!(v, Value::Null)) {
15759                    continue;
15760                }
15761                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
15762                    if child_name == cur_parent
15763                        && visited.contains(&(child_name.clone(), child_row_idx))
15764                    {
15765                        continue;
15766                    }
15767                    let matches_key = fk
15768                        .local_columns
15769                        .iter()
15770                        .enumerate()
15771                        .all(|(i, &li)| child_row.values.get(li) == Some(parent_key[i]));
15772                    if !matches_key {
15773                        continue;
15774                    }
15775                    match fk.on_delete {
15776                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
15777                            return Err(EngineError::Unsupported(alloc::format!(
15778                                "FOREIGN KEY violation: DELETE on {cur_parent:?} is \
15779                                 restricted by FK from {child_name:?}.{:?}",
15780                                fk.local_columns,
15781                            )));
15782                        }
15783                        spg_storage::FkAction::Cascade => {
15784                            if visited.insert((child_name.clone(), child_row_idx)) {
15785                                delete_plan
15786                                    .entry(child_name.clone())
15787                                    .or_default()
15788                                    .insert(child_row_idx);
15789                                work.push((child_name.clone(), child_row.values.clone()));
15790                            }
15791                        }
15792                        spg_storage::FkAction::SetNull => {
15793                            // Verify every local FK column is NULL-able.
15794                            for &li in &fk.local_columns {
15795                                let col = child.schema().columns.get(li).ok_or_else(|| {
15796                                    EngineError::Unsupported(alloc::format!(
15797                                        "FK local column {li} missing in {child_name:?}"
15798                                    ))
15799                                })?;
15800                                if !col.nullable {
15801                                    return Err(EngineError::Unsupported(alloc::format!(
15802                                        "FOREIGN KEY ON DELETE SET NULL: column \
15803                                         {child_name:?}.{:?} is NOT NULL — cannot SET NULL",
15804                                        col.name,
15805                                    )));
15806                                }
15807                            }
15808                            let entry = setnull_plan.entry(child_name.clone()).or_default();
15809                            for &li in &fk.local_columns {
15810                                entry.insert((child_row_idx, li));
15811                            }
15812                        }
15813                        spg_storage::FkAction::SetDefault => {
15814                            // Resolve the DEFAULT for every local FK col.
15815                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
15816                            for &li in &fk.local_columns {
15817                                let col = child.schema().columns.get(li).ok_or_else(|| {
15818                                    EngineError::Unsupported(alloc::format!(
15819                                        "FK local column {li} missing in {child_name:?}"
15820                                    ))
15821                                })?;
15822                                let default = col.default.clone().ok_or_else(|| {
15823                                    EngineError::Unsupported(alloc::format!(
15824                                        "FOREIGN KEY ON DELETE SET DEFAULT: column \
15825                                         {child_name:?}.{:?} has no DEFAULT declared",
15826                                        col.name,
15827                                    ))
15828                                })?;
15829                                entry.insert((child_row_idx, li), default);
15830                            }
15831                        }
15832                    }
15833                }
15834            }
15835        }
15836    }
15837    // Flatten the three plans into the ordered `FkChildStep` list.
15838    // Deletes are applied last per child (after any null/default
15839    // re-writes on the same child) so a child row that's both
15840    // re-written and then cascade-deleted only ends up deleted —
15841    // but in v7.6.5 SetNull/Cascade never overlap on the same row
15842    // (a single FK chooses exactly one action), so the order is
15843    // mostly a precaution.
15844    let mut steps: Vec<FkChildStep> = Vec::new();
15845    for (child_table, entries) in setnull_plan {
15846        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
15847        steps.push(FkChildStep {
15848            child_table,
15849            action: FkChildAction::SetNull { positions, columns },
15850        });
15851    }
15852    for (child_table, entries) in setdefault_plan {
15853        let mut positions = Vec::with_capacity(entries.len());
15854        let mut columns = Vec::with_capacity(entries.len());
15855        let mut defaults = Vec::with_capacity(entries.len());
15856        for ((p, c), v) in entries {
15857            positions.push(p);
15858            columns.push(c);
15859            defaults.push(v);
15860        }
15861        steps.push(FkChildStep {
15862            child_table,
15863            action: FkChildAction::SetDefault {
15864                positions,
15865                columns,
15866                defaults,
15867            },
15868        });
15869    }
15870    for (child_table, positions) in delete_plan {
15871        steps.push(FkChildStep {
15872            child_table,
15873            action: FkChildAction::Delete {
15874                positions: positions.into_iter().collect(),
15875            },
15876        });
15877    }
15878    Ok(steps)
15879}
15880
15881/// v7.6.6 — plan FK fallout for an UPDATE that mutates parent-side
15882/// PK/UNIQUE columns. Walks every other table whose FK references
15883/// `parent_table_name`; for each FK whose parent_columns overlap a
15884/// mutated column, decides the action by `fk.on_update`.
15885///
15886///   - RESTRICT / NoAction → error if any child references the OLD
15887///     value
15888///   - CASCADE → child FK columns get rewritten to the NEW parent
15889///     value (a SetNull-style update step with the new value)
15890///   - SetNull → child FK columns set to NULL
15891///   - SetDefault → child FK columns set to declared default
15892///
15893/// `plan_with_old` is `(row_position, old_values, new_values)` so
15894/// the planner can detect "did this row's parent key actually
15895/// change?" — only rows where at least one referenced parent
15896/// column moved trigger inbound work.
15897fn plan_fk_parent_updates(
15898    catalog: &Catalog,
15899    parent_table_name: &str,
15900    plan_with_old: &[(usize, Vec<Value>, Vec<Value>)],
15901) -> Result<Vec<FkChildStep>, EngineError> {
15902    use alloc::collections::BTreeMap;
15903    if plan_with_old.is_empty() {
15904        return Ok(Vec::new());
15905    }
15906    // For each child table we may touch, build per-child step
15907    // lists. UPDATE never deletes children — `delete_plan` stays
15908    // empty here but is kept structurally aligned with
15909    // `plan_fk_parent_deletions` for future use.
15910    let delete_plan: BTreeMap<String, alloc::collections::BTreeSet<usize>> = BTreeMap::new();
15911    let mut setnull_plan: BTreeMap<String, alloc::collections::BTreeSet<(usize, usize)>> =
15912        BTreeMap::new();
15913    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15914    // Cascade-update plan: child_table → row_idx → col_idx → new_value
15915    let mut cascade_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15916
15917    for child_name in catalog.table_names() {
15918        let child = catalog
15919            .get(&child_name)
15920            .expect("table_names → catalog.get total");
15921        for fk in &child.schema().foreign_keys {
15922            if fk.parent_table != parent_table_name {
15923                continue;
15924            }
15925            for (_pos, old_row, new_row) in plan_with_old {
15926                // Did any parent FK column change?
15927                let key_changed = fk
15928                    .parent_columns
15929                    .iter()
15930                    .any(|&pi| old_row.get(pi) != new_row.get(pi));
15931                if !key_changed {
15932                    continue;
15933                }
15934                // The OLD parent key — used to find referring children.
15935                let old_key: Vec<&Value> =
15936                    fk.parent_columns.iter().map(|&pi| &old_row[pi]).collect();
15937                if old_key.iter().any(|v| matches!(v, Value::Null)) {
15938                    // NULL parent has no children — skip.
15939                    continue;
15940                }
15941                let new_key: Vec<&Value> =
15942                    fk.parent_columns.iter().map(|&pi| &new_row[pi]).collect();
15943                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
15944                    // Self-ref same-row updates: a row updating its
15945                    // own PK doesn't restrict itself.
15946                    if child_name == parent_table_name
15947                        && plan_with_old.iter().any(|(p, _, _)| *p == child_row_idx)
15948                    {
15949                        continue;
15950                    }
15951                    let matches_key = fk
15952                        .local_columns
15953                        .iter()
15954                        .enumerate()
15955                        .all(|(i, &li)| child_row.values.get(li) == Some(old_key[i]));
15956                    if !matches_key {
15957                        continue;
15958                    }
15959                    match fk.on_update {
15960                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
15961                            return Err(EngineError::Unsupported(alloc::format!(
15962                                "FOREIGN KEY violation: UPDATE on {parent_table_name:?} PK is \
15963                                 restricted by FK from {child_name:?}.{:?}",
15964                                fk.local_columns,
15965                            )));
15966                        }
15967                        spg_storage::FkAction::Cascade => {
15968                            // Rewrite child FK columns to new key.
15969                            let entry = cascade_plan.entry(child_name.clone()).or_default();
15970                            for (i, &li) in fk.local_columns.iter().enumerate() {
15971                                entry.insert((child_row_idx, li), new_key[i].clone());
15972                            }
15973                        }
15974                        spg_storage::FkAction::SetNull => {
15975                            for &li in &fk.local_columns {
15976                                let col = child.schema().columns.get(li).ok_or_else(|| {
15977                                    EngineError::Unsupported(alloc::format!(
15978                                        "FK local column {li} missing in {child_name:?}"
15979                                    ))
15980                                })?;
15981                                if !col.nullable {
15982                                    return Err(EngineError::Unsupported(alloc::format!(
15983                                        "FOREIGN KEY ON UPDATE SET NULL: column \
15984                                         {child_name:?}.{:?} is NOT NULL",
15985                                        col.name,
15986                                    )));
15987                                }
15988                            }
15989                            let entry = setnull_plan.entry(child_name.clone()).or_default();
15990                            for &li in &fk.local_columns {
15991                                entry.insert((child_row_idx, li));
15992                            }
15993                        }
15994                        spg_storage::FkAction::SetDefault => {
15995                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
15996                            for &li in &fk.local_columns {
15997                                let col = child.schema().columns.get(li).ok_or_else(|| {
15998                                    EngineError::Unsupported(alloc::format!(
15999                                        "FK local column {li} missing in {child_name:?}"
16000                                    ))
16001                                })?;
16002                                let default = col.default.clone().ok_or_else(|| {
16003                                    EngineError::Unsupported(alloc::format!(
16004                                        "FOREIGN KEY ON UPDATE SET DEFAULT: column \
16005                                         {child_name:?}.{:?} has no DEFAULT",
16006                                        col.name,
16007                                    ))
16008                                })?;
16009                                entry.insert((child_row_idx, li), default);
16010                            }
16011                        }
16012                    }
16013                }
16014            }
16015        }
16016    }
16017    // Flatten into FkChildStep list. UPDATE doesn't produce
16018    // DeleteSteps (CASCADE on UPDATE just rewrites FK values).
16019    let mut steps: Vec<FkChildStep> = Vec::new();
16020    for (child_table, entries) in cascade_plan {
16021        let mut positions = Vec::with_capacity(entries.len());
16022        let mut columns = Vec::with_capacity(entries.len());
16023        let mut defaults = Vec::with_capacity(entries.len());
16024        for ((p, c), v) in entries {
16025            positions.push(p);
16026            columns.push(c);
16027            defaults.push(v);
16028        }
16029        // We reuse `FkChildAction::SetDefault` for cascade-update:
16030        // both shapes are "write a known value into specific cells"
16031        // — `apply_per_cell_writes` doesn't care whether the value
16032        // came from a DEFAULT declaration or a new parent key.
16033        steps.push(FkChildStep {
16034            child_table,
16035            action: FkChildAction::SetDefault {
16036                positions,
16037                columns,
16038                defaults,
16039            },
16040        });
16041    }
16042    for (child_table, entries) in setnull_plan {
16043        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
16044        steps.push(FkChildStep {
16045            child_table,
16046            action: FkChildAction::SetNull { positions, columns },
16047        });
16048    }
16049    for (child_table, entries) in setdefault_plan {
16050        let mut positions = Vec::with_capacity(entries.len());
16051        let mut columns = Vec::with_capacity(entries.len());
16052        let mut defaults = Vec::with_capacity(entries.len());
16053        for ((p, c), v) in entries {
16054            positions.push(p);
16055            columns.push(c);
16056            defaults.push(v);
16057        }
16058        steps.push(FkChildStep {
16059            child_table,
16060            action: FkChildAction::SetDefault {
16061                positions,
16062                columns,
16063                defaults,
16064            },
16065        });
16066    }
16067    let _ = delete_plan; // UPDATE never deletes children.
16068    Ok(steps)
16069}
16070
16071/// v7.6.5 — apply one FK child step to the catalog. Encapsulates
16072/// the three action variants so the DELETE executor stays a
16073/// simple loop over the planned steps.
16074fn apply_fk_child_step(catalog: &mut Catalog, step: &FkChildStep) -> Result<(), EngineError> {
16075    let child = catalog.get_mut(&step.child_table).ok_or_else(|| {
16076        EngineError::Storage(StorageError::TableNotFound {
16077            name: step.child_table.clone(),
16078        })
16079    })?;
16080    match &step.action {
16081        FkChildAction::Delete { positions } => {
16082            let _ = child.delete_rows(positions);
16083        }
16084        FkChildAction::SetNull { positions, columns } => {
16085            apply_per_cell_writes(child, positions, columns, |_| Value::Null)?;
16086        }
16087        FkChildAction::SetDefault {
16088            positions,
16089            columns,
16090            defaults,
16091        } => {
16092            apply_per_cell_writes(child, positions, columns, |i| defaults[i].clone())?;
16093        }
16094    }
16095    Ok(())
16096}
16097
16098/// v7.6.5 — write new values into selected child cells via
16099/// `Table::update_row` (the catalog's existing UPDATE entry).
16100/// Groups writes by row position so multi-column updates on the
16101/// same row only call `update_row` once. `value_for(i)` produces
16102/// the new value for the i-th (position, column) entry.
16103fn apply_per_cell_writes(
16104    child: &mut spg_storage::Table,
16105    positions: &[usize],
16106    columns: &[usize],
16107    mut value_for: impl FnMut(usize) -> Value,
16108) -> Result<(), EngineError> {
16109    use alloc::collections::BTreeMap;
16110    let mut by_row: BTreeMap<usize, Vec<(usize, Value)>> = BTreeMap::new();
16111    for i in 0..positions.len() {
16112        by_row
16113            .entry(positions[i])
16114            .or_default()
16115            .push((columns[i], value_for(i)));
16116    }
16117    for (pos, mutations) in by_row {
16118        let mut new_values = child.rows()[pos].values.clone();
16119        for (col, v) in mutations {
16120            if let Some(slot) = new_values.get_mut(col) {
16121                *slot = v;
16122            }
16123        }
16124        child
16125            .update_row(pos, new_values)
16126            .map_err(EngineError::Storage)?;
16127    }
16128    Ok(())
16129}
16130
16131fn fk_action_sql_to_storage(a: spg_sql::ast::FkAction) -> spg_storage::FkAction {
16132    match a {
16133        spg_sql::ast::FkAction::Restrict => spg_storage::FkAction::Restrict,
16134        spg_sql::ast::FkAction::Cascade => spg_storage::FkAction::Cascade,
16135        spg_sql::ast::FkAction::SetNull => spg_storage::FkAction::SetNull,
16136        spg_sql::ast::FkAction::SetDefault => spg_storage::FkAction::SetDefault,
16137        spg_sql::ast::FkAction::NoAction => spg_storage::FkAction::NoAction,
16138    }
16139}
16140
16141/// v7.9.21 — resolve a column's DEFAULT for INSERT-time
16142/// default-fill. Free fn (rather than `&self`) so callers
16143/// with an active `&mut Table` borrow can still use it.
16144/// Literal defaults take the cached path (`col.default`);
16145/// runtime defaults hit `clock_fn` at each call. mailrs G4.
16146fn resolve_column_default_free(
16147    col: &ColumnSchema,
16148    clock_fn: Option<ClockFn>,
16149) -> Result<Value, EngineError> {
16150    if let Some(rt) = &col.runtime_default {
16151        return eval_runtime_default_free(rt, col.ty, clock_fn);
16152    }
16153    Ok(col.default.clone().unwrap_or(Value::Null))
16154}
16155
16156fn eval_runtime_default_free(
16157    rt: &str,
16158    ty: DataType,
16159    clock_fn: Option<ClockFn>,
16160) -> Result<Value, EngineError> {
16161    let s = rt.trim().to_ascii_lowercase();
16162    // v7.17.0 Phase 2.1 — also strip `(N)` precision suffix
16163    // so MySQL `CURRENT_TIMESTAMP(6)` resolves the same as
16164    // bare `CURRENT_TIMESTAMP`. SPG stores TIMESTAMP at fixed
16165    // microsecond resolution; the precision modifier is
16166    // parser-only.
16167    let with_no_parens = s.trim_end_matches("()");
16168    let canonical: &str = if let Some(open_idx) = with_no_parens.find('(') {
16169        if with_no_parens.ends_with(')') {
16170            &with_no_parens[..open_idx]
16171        } else {
16172            with_no_parens
16173        }
16174    } else {
16175        with_no_parens
16176    };
16177    let now_us = match clock_fn {
16178        Some(f) => f(),
16179        None => 0,
16180    };
16181    let v = match canonical {
16182        "now" | "current_timestamp" | "localtimestamp" => Value::Timestamp(now_us),
16183        "current_date" => Value::Date((now_us / 86_400_000_000) as i32),
16184        "current_time" | "localtime" => Value::Timestamp(now_us),
16185        // v7.17.0 — UUID generators in DEFAULT clauses. Required
16186        // for the canonical Django / Rails / Hibernate `id UUID
16187        // PRIMARY KEY DEFAULT gen_random_uuid()` pattern. Each
16188        // INSERT evaluates the function fresh; the per-row UUID
16189        // is the storage value, not a cached literal.
16190        "gen_random_uuid" | "uuid_generate_v4" => Value::Uuid(eval::gen_random_uuid_bytes()),
16191        other => {
16192            return Err(EngineError::Unsupported(alloc::format!(
16193                "runtime DEFAULT expression {other:?} not supported \
16194                 (v7.17.0 whitelist: now() / current_timestamp / \
16195                 current_date / current_time / localtimestamp / \
16196                 localtime / gen_random_uuid() / \
16197                 uuid_generate_v4())"
16198            )));
16199        }
16200    };
16201    coerce_value(v, ty, "DEFAULT", 0)
16202}
16203
16204/// v7.9.21 — true when a DEFAULT expression needs INSERT-time
16205/// evaluation rather than being cacheable as a literal Value.
16206/// FunctionCall is the immediate case (`now()`,
16207/// `current_timestamp`). Literal expressions and simple sign-
16208/// flipped numerics still take the static-cache path.
16209fn is_runtime_default_expr(expr: &Expr) -> bool {
16210    match expr {
16211        Expr::FunctionCall { .. } => true,
16212        Expr::Unary { expr, .. } => is_runtime_default_expr(expr),
16213        _ => false,
16214    }
16215}
16216
16217/// v7.17.0 Phase 1.4 — INSERT/UPDATE-time enum label check. When
16218/// `col_idx` has a registered label list, the cell value must be
16219/// NULL or one of the labels (case-sensitive per PG).
16220/// v7.17.0 Phase 3.P0-37 — validate + canonicalise a MySQL inline
16221/// SET cell. For non-SET columns this is a no-op pass-through.
16222///
16223/// Semantics:
16224///   * NULL preserved.
16225///   * Empty string → `''` (zero flags).
16226///   * Otherwise split on ',', trim each token, validate every
16227///     token against the column's variant list (error on miss),
16228///     de-dup, then re-emit in DEFINITION order joined by ','.
16229fn canonicalize_set_value(
16230    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
16231    col_idx: usize,
16232    col_name: &str,
16233    value: Value,
16234) -> Result<Value, EngineError> {
16235    let Some(variants) = lookup.get(&col_idx) else {
16236        return Ok(value);
16237    };
16238    match value {
16239        Value::Null => Ok(Value::Null),
16240        Value::Text(s) => {
16241            if s.is_empty() {
16242                return Ok(Value::Text(alloc::string::String::new()));
16243            }
16244            // Collect a presence-set of variant indices to keep
16245            // definition order + handle de-dup in one pass.
16246            let mut present = alloc::vec![false; variants.len()];
16247            for raw in s.split(',') {
16248                let tok = raw.trim();
16249                if tok.is_empty() {
16250                    continue;
16251                }
16252                let idx = variants.iter().position(|v| v == tok).ok_or_else(|| {
16253                    EngineError::Unsupported(alloc::format!(
16254                        "column {col_name:?}: invalid SET token {tok:?}; \
16255                         allowed: {variants:?}"
16256                    ))
16257                })?;
16258                present[idx] = true;
16259            }
16260            // Re-emit in definition order.
16261            let mut out = alloc::string::String::new();
16262            let mut first = true;
16263            for (i, keep) in present.iter().enumerate() {
16264                if !keep {
16265                    continue;
16266                }
16267                if !first {
16268                    out.push(',');
16269                }
16270                first = false;
16271                out.push_str(&variants[i]);
16272            }
16273            Ok(Value::Text(out))
16274        }
16275        other => Err(EngineError::Unsupported(alloc::format!(
16276            "column {col_name:?}: SET-typed column expects TEXT, got {:?}",
16277            other.data_type()
16278        ))),
16279    }
16280}
16281
16282fn enforce_enum_label(
16283    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
16284    col_idx: usize,
16285    col_name: &str,
16286    value: &Value,
16287) -> Result<(), EngineError> {
16288    if let Some(labels) = lookup.get(&col_idx) {
16289        match value {
16290            Value::Null => Ok(()),
16291            Value::Text(s) => {
16292                if labels.iter().any(|l| l == s) {
16293                    Ok(())
16294                } else {
16295                    Err(EngineError::Unsupported(alloc::format!(
16296                        "column {col_name:?}: invalid enum label {s:?}; allowed: {labels:?}"
16297                    )))
16298                }
16299            }
16300            other => Err(EngineError::Unsupported(alloc::format!(
16301                "column {col_name:?}: enum-typed column expects TEXT, got {:?}",
16302                other.data_type()
16303            ))),
16304        }
16305    } else {
16306        Ok(())
16307    }
16308}
16309
16310fn column_def_to_schema(c: ColumnDef) -> Result<ColumnSchema, EngineError> {
16311    let ty = column_type_to_data_type(c.ty);
16312    let mut schema = ColumnSchema::new(c.name.clone(), ty, c.nullable);
16313    // user_type_ref is the raw ident the parser couldn't resolve
16314    // to a built-in; classification into enum vs domain happens
16315    // at exec_create_table where we have catalog access. We
16316    // park it temporarily as user_enum_type and the engine
16317    // promotes domain bindings to user_domain_type before the
16318    // table is stored.
16319    if let Some(name) = c.user_type_ref {
16320        schema.user_enum_type = Some(name);
16321    }
16322    // v7.17.0 Phase 2.1 — render the ON UPDATE expression to
16323    // canonical text (the engine re-parses at UPDATE time).
16324    if let Some(expr) = c.on_update_runtime {
16325        schema.on_update_runtime = Some(alloc::format!("{expr}"));
16326    }
16327    // v7.17.0 Phase 2.5 — bridge the AST `Collation` enum to the
16328    // storage one. Same variants, different crates (spg-storage
16329    // owns no dep on spg-sql).
16330    schema.collation = match c.collation {
16331        spg_sql::ast::Collation::Binary => spg_storage::Collation::Binary,
16332        spg_sql::ast::Collation::CaseInsensitive => spg_storage::Collation::CaseInsensitive,
16333    };
16334    // v7.17.0 Phase 4.4 — MySQL `UNSIGNED` flag propagates to
16335    // storage so engine INSERT / UPDATE can range-check.
16336    schema.is_unsigned = c.is_unsigned;
16337    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant list.
16338    // INSERT validation lives in coerce_value (Text → Text path
16339    // with the column's variant list as the accept-set).
16340    schema.inline_enum_variants = c.inline_enum_variants;
16341    // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
16342    // INSERT canonicalisation (de-dup + sort by definition order)
16343    // lives in the exec_insert path next to the ENUM check.
16344    schema.inline_set_variants = c.inline_set_variants;
16345    if let Some(default_expr) = c.default {
16346        // v7.9.21 — distinguish literal defaults (evaluated once
16347        // at CREATE TABLE) from expression defaults (deferred to
16348        // INSERT). Function calls (`now()`, `current_timestamp`
16349        // — see v7.9.20 keyword promotion) take the runtime path.
16350        // Literals continue to cache. mailrs G4.
16351        if is_runtime_default_expr(&default_expr) {
16352            let display = alloc::format!("{default_expr}");
16353            schema = schema.with_runtime_default(display);
16354        } else {
16355            let raw = literal_expr_to_value(default_expr)?;
16356            let coerced = coerce_value(raw, ty, &c.name, 0)?;
16357            schema = schema.with_default(coerced);
16358        }
16359    }
16360    if c.auto_increment {
16361        // AUTO_INCREMENT only makes sense on integer-shaped columns.
16362        if !matches!(ty, DataType::SmallInt | DataType::Int | DataType::BigInt) {
16363            return Err(EngineError::Unsupported(alloc::format!(
16364                "AUTO_INCREMENT requires an integer column type, got {ty:?}"
16365            )));
16366        }
16367        schema = schema.with_auto_increment();
16368    }
16369    Ok(schema)
16370}
16371
16372/// v7.10.4 — decode a BYTEA literal. Accepts:
16373///   * `\xDEADBEEF` (case-insensitive hex; whitespace stripped)
16374///   * `Hello\000world` (backslash escape form; `\\` for literal backslash)
16375///   * Anything else → raw UTF-8 bytes of the input (PG accepts this too).
16376fn decode_bytea_literal(s: &str) -> Result<alloc::vec::Vec<u8>, &'static str> {
16377    let s = s.trim();
16378    if let Some(hex) = s.strip_prefix("\\x").or_else(|| s.strip_prefix("\\X")) {
16379        // Hex form. Each pair of hex digits → one byte.
16380        let cleaned: alloc::string::String = hex.chars().filter(|c| !c.is_whitespace()).collect();
16381        if cleaned.len() % 2 != 0 {
16382            return Err("odd-length hex literal");
16383        }
16384        let mut out = alloc::vec::Vec::with_capacity(cleaned.len() / 2);
16385        let cleaned_bytes = cleaned.as_bytes();
16386        for i in (0..cleaned_bytes.len()).step_by(2) {
16387            let hi = hex_nibble(cleaned_bytes[i])?;
16388            let lo = hex_nibble(cleaned_bytes[i + 1])?;
16389            out.push((hi << 4) | lo);
16390        }
16391        return Ok(out);
16392    }
16393    // Escape form or raw. Walk char-by-char; `\\` and `\NNN` octal
16394    // sequences decode; anything else is a literal byte.
16395    let bytes = s.as_bytes();
16396    let mut out = alloc::vec::Vec::with_capacity(bytes.len());
16397    let mut i = 0;
16398    while i < bytes.len() {
16399        let b = bytes[i];
16400        if b == b'\\' && i + 1 < bytes.len() {
16401            let n = bytes[i + 1];
16402            if n == b'\\' {
16403                out.push(b'\\');
16404                i += 2;
16405                continue;
16406            }
16407            if n.is_ascii_digit()
16408                && i + 3 < bytes.len()
16409                && bytes[i + 2].is_ascii_digit()
16410                && bytes[i + 3].is_ascii_digit()
16411            {
16412                let oct = |x: u8| (x - b'0') as u32;
16413                let v = oct(n) * 64 + oct(bytes[i + 2]) * 8 + oct(bytes[i + 3]);
16414                if v <= 0xFF {
16415                    out.push(v as u8);
16416                    i += 4;
16417                    continue;
16418                }
16419            }
16420        }
16421        out.push(b);
16422        i += 1;
16423    }
16424    Ok(out)
16425}
16426
16427fn hex_nibble(b: u8) -> Result<u8, &'static str> {
16428    match b {
16429        b'0'..=b'9' => Ok(b - b'0'),
16430        b'a'..=b'f' => Ok(b - b'a' + 10),
16431        b'A'..=b'F' => Ok(b - b'A' + 10),
16432        _ => Err("invalid hex digit"),
16433    }
16434}
16435
16436/// v7.10.11 — decode a PG TEXT[] external array form
16437/// (`{a,b,NULL}` with optional double-quoted elements). The
16438/// engine takes a leading/trailing `{`/`}` and splits at commas.
16439/// Quoted elements (`"hello, world"`) preserve embedded commas;
16440/// `\\` and `\"` decode to literal backslash / quote. Plain
16441/// unquoted `NULL` (case-insensitive) maps to `None`.
16442/// v7.11.13 — pick the array type for `ARRAY[lit, …]` from the
16443/// element values. Single-element-type rules:
16444///   - all NULL / all Text → TextArray
16445///   - all Int (or Int+NULL) → IntArray
16446///   - any BigInt without Text → BigIntArray (widening)
16447///   - any Text → TextArray (fallback; non-string elements
16448///     render as text)
16449fn array_literal_widen(items: alloc::vec::Vec<Value>) -> Value {
16450    let mut has_text = false;
16451    let mut has_bigint = false;
16452    let mut has_int = false;
16453    for v in &items {
16454        match v {
16455            Value::Null => {}
16456            Value::Text(_) | Value::Json(_) => has_text = true,
16457            Value::BigInt(_) => has_bigint = true,
16458            Value::Int(_) | Value::SmallInt(_) => has_int = true,
16459            _ => has_text = true,
16460        }
16461    }
16462    if has_text || (!has_bigint && !has_int) {
16463        let out: alloc::vec::Vec<Option<alloc::string::String>> = items
16464            .into_iter()
16465            .map(|v| match v {
16466                Value::Null => None,
16467                Value::Text(s) | Value::Json(s) => Some(s),
16468                other => Some(alloc::format!("{other:?}")),
16469            })
16470            .collect();
16471        return Value::TextArray(out);
16472    }
16473    if has_bigint {
16474        let out: alloc::vec::Vec<Option<i64>> = items
16475            .into_iter()
16476            .map(|v| match v {
16477                Value::Null => None,
16478                Value::Int(n) => Some(i64::from(n)),
16479                Value::SmallInt(n) => Some(i64::from(n)),
16480                Value::BigInt(n) => Some(n),
16481                _ => unreachable!("widen: unexpected non-integer in BigInt path"),
16482            })
16483            .collect();
16484        return Value::BigIntArray(out);
16485    }
16486    let out: alloc::vec::Vec<Option<i32>> = items
16487        .into_iter()
16488        .map(|v| match v {
16489            Value::Null => None,
16490            Value::Int(n) => Some(n),
16491            Value::SmallInt(n) => Some(i32::from(n)),
16492            _ => unreachable!("widen: unexpected non-i32-compatible in Int path"),
16493        })
16494        .collect();
16495    Value::IntArray(out)
16496}
16497
16498fn decode_text_array_literal(
16499    s: &str,
16500) -> Result<alloc::vec::Vec<Option<alloc::string::String>>, &'static str> {
16501    let trimmed = s.trim();
16502    let inner = trimmed
16503        .strip_prefix('{')
16504        .and_then(|x| x.strip_suffix('}'))
16505        .ok_or("TEXT[] literal must be enclosed in '{...}'")?;
16506    let mut out: alloc::vec::Vec<Option<alloc::string::String>> = alloc::vec::Vec::new();
16507    if inner.trim().is_empty() {
16508        return Ok(out);
16509    }
16510    let bytes = inner.as_bytes();
16511    let mut i = 0;
16512    while i <= bytes.len() {
16513        // Skip leading whitespace.
16514        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
16515            i += 1;
16516        }
16517        // Quoted element.
16518        if i < bytes.len() && bytes[i] == b'"' {
16519            i += 1; // open quote
16520            let mut buf = alloc::string::String::new();
16521            while i < bytes.len() && bytes[i] != b'"' {
16522                if bytes[i] == b'\\' && i + 1 < bytes.len() {
16523                    buf.push(bytes[i + 1] as char);
16524                    i += 2;
16525                } else {
16526                    buf.push(bytes[i] as char);
16527                    i += 1;
16528                }
16529            }
16530            if i >= bytes.len() {
16531                return Err("unterminated quoted element");
16532            }
16533            i += 1; // close quote
16534            out.push(Some(buf));
16535        } else {
16536            // Unquoted element — read until next comma or end.
16537            let start = i;
16538            while i < bytes.len() && bytes[i] != b',' {
16539                i += 1;
16540            }
16541            let raw = inner[start..i].trim();
16542            if raw.eq_ignore_ascii_case("NULL") {
16543                out.push(None);
16544            } else {
16545                out.push(Some(alloc::string::ToString::to_string(raw)));
16546            }
16547        }
16548        // Skip whitespace, expect comma or end.
16549        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
16550            i += 1;
16551        }
16552        if i >= bytes.len() {
16553            break;
16554        }
16555        if bytes[i] != b',' {
16556            return Err("expected ',' between TEXT[] elements");
16557        }
16558        i += 1;
16559    }
16560    Ok(out)
16561}
16562
16563/// v7.10.11 — encode a TEXT[] back into the PG external array
16564/// form. NULL elements become the literal `NULL`; elements
16565/// containing commas, quotes, backslashes, or braces are
16566/// double-quoted with `\\` / `\"` escapes.
16567fn encode_text_array(items: &[Option<alloc::string::String>]) -> alloc::string::String {
16568    let mut out = alloc::string::String::with_capacity(2 + items.len() * 8);
16569    out.push('{');
16570    for (i, item) in items.iter().enumerate() {
16571        if i > 0 {
16572            out.push(',');
16573        }
16574        match item {
16575            None => out.push_str("NULL"),
16576            Some(s) => {
16577                let needs_quote = s.is_empty()
16578                    || s.eq_ignore_ascii_case("NULL")
16579                    || s.chars()
16580                        .any(|c| matches!(c, ',' | '{' | '}' | '"' | '\\' | ' ' | '\t'));
16581                if needs_quote {
16582                    out.push('"');
16583                    for c in s.chars() {
16584                        if c == '"' || c == '\\' {
16585                            out.push('\\');
16586                        }
16587                        out.push(c);
16588                    }
16589                    out.push('"');
16590                } else {
16591                    out.push_str(s);
16592                }
16593            }
16594        }
16595    }
16596    out.push('}');
16597    out
16598}
16599
16600/// v7.10.4 — encode BYTEA bytes in PG hex output format
16601/// (`\x` prefix, lowercase hex pairs). Used by Text-side
16602/// round-trip + the wire layer's text-mode encoder.
16603fn encode_bytea_hex(b: &[u8]) -> alloc::string::String {
16604    let mut out = alloc::string::String::with_capacity(2 + 2 * b.len());
16605    out.push_str("\\x");
16606    for byte in b {
16607        let hi = byte >> 4;
16608        let lo = byte & 0x0F;
16609        out.push(hex_digit(hi));
16610        out.push(hex_digit(lo));
16611    }
16612    out
16613}
16614
16615const fn hex_digit(n: u8) -> char {
16616    match n {
16617        0..=9 => (b'0' + n) as char,
16618        10..=15 => (b'a' + n - 10) as char,
16619        _ => '?',
16620    }
16621}
16622
16623/// v7.17.0 Phase 3.P0-39 — parse a PG `hstore` text literal into
16624/// a flat key→value map. Empty string → empty map. Duplicate
16625/// keys take last-write-wins (matches PG `hstore_in`).
16626///
16627/// Accepted shapes (minimal subset):
16628///   * `'a=>1, b=>2'`            — bareword keys/values
16629///   * `'"a"=>"1", "b"=>"2"'`    — quoted keys/values
16630///   * `'a=>NULL'`               — case-insensitive NULL token
16631///     surfaces as `None` (no quotes around NULL)
16632///
16633/// Returns None on parse failure → caller surfaces as hard error.
16634fn parse_hstore_str(
16635    s: &str,
16636) -> Option<Vec<(alloc::string::String, Option<alloc::string::String>)>> {
16637    let bytes = s.as_bytes();
16638    let mut i = 0;
16639    let mut out: Vec<(alloc::string::String, Option<alloc::string::String>)> = Vec::new();
16640    let skip_ws = |bytes: &[u8], i: &mut usize| {
16641        while *i < bytes.len() && matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r') {
16642            *i += 1;
16643        }
16644    };
16645    let parse_token = |bytes: &[u8], i: &mut usize| -> Option<alloc::string::String> {
16646        if *i >= bytes.len() {
16647            return None;
16648        }
16649        if bytes[*i] == b'"' {
16650            *i += 1;
16651            let mut out = alloc::string::String::new();
16652            while *i < bytes.len() {
16653                match bytes[*i] {
16654                    b'"' => {
16655                        *i += 1;
16656                        return Some(out);
16657                    }
16658                    b'\\' if *i + 1 < bytes.len() => {
16659                        out.push(bytes[*i + 1] as char);
16660                        *i += 2;
16661                    }
16662                    c => {
16663                        out.push(c as char);
16664                        *i += 1;
16665                    }
16666                }
16667            }
16668            None
16669        } else {
16670            let start = *i;
16671            while *i < bytes.len()
16672                && !matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r' | b',' | b'=')
16673            {
16674                *i += 1;
16675            }
16676            if *i == start {
16677                return None;
16678            }
16679            Some(alloc::str::from_utf8(&bytes[start..*i]).ok()?.to_string())
16680        }
16681    };
16682    skip_ws(bytes, &mut i);
16683    while i < bytes.len() {
16684        let key = parse_token(bytes, &mut i)?;
16685        skip_ws(bytes, &mut i);
16686        if i + 1 >= bytes.len() || bytes[i] != b'=' || bytes[i + 1] != b'>' {
16687            return None;
16688        }
16689        i += 2;
16690        skip_ws(bytes, &mut i);
16691        // Check for unquoted NULL token (case-insensitive).
16692        let val_token = if i + 4 <= bytes.len()
16693            && bytes[i..i + 4].eq_ignore_ascii_case(b"NULL")
16694            && (i + 4 == bytes.len() || matches!(bytes[i + 4], b' ' | b'\t' | b',' | b'\n' | b'\r'))
16695        {
16696            i += 4;
16697            None
16698        } else {
16699            Some(parse_token(bytes, &mut i)?)
16700        };
16701        // Replace any existing entry with the same key (last-wins).
16702        if let Some(pos) = out.iter().position(|(k, _)| k == &key) {
16703            out[pos] = (key, val_token);
16704        } else {
16705            out.push((key, val_token));
16706        }
16707        skip_ws(bytes, &mut i);
16708        if i >= bytes.len() {
16709            break;
16710        }
16711        if bytes[i] == b',' {
16712            i += 1;
16713            skip_ws(bytes, &mut i);
16714            continue;
16715        }
16716        return None;
16717    }
16718    Some(out)
16719}
16720
16721/// v7.17.0 Phase 3.P0-39 — render a hstore as canonical PG text
16722/// form `"k"=>"v"` (keys and non-NULL values always quoted;
16723/// NULL token is bare).
16724fn format_hstore_str(
16725    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
16726) -> alloc::string::String {
16727    let mut out = alloc::string::String::new();
16728    for (i, (k, v)) in pairs.iter().enumerate() {
16729        if i > 0 {
16730            out.push_str(", ");
16731        }
16732        out.push('"');
16733        out.push_str(k);
16734        out.push_str("\"=>");
16735        match v {
16736            None => out.push_str("NULL"),
16737            Some(val) => {
16738                out.push('"');
16739                out.push_str(val);
16740                out.push('"');
16741            }
16742        }
16743    }
16744    out
16745}
16746
16747/// v7.17.0 Phase 3.P0-39 — pub re-export so pgwire + sqllogictest
16748/// share the single hstore renderer.
16749pub fn format_hstore_text(
16750    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
16751) -> alloc::string::String {
16752    format_hstore_str(pairs)
16753}
16754
16755// ─── v7.17.0 Phase 3.P0-40 — 2D array parse + display ─────────
16756
16757/// Split a PG external 2D-array literal `'{{a,b},{c,d}}'` into
16758/// per-row token lists. Returns Err on shape mismatch.
16759fn split_2d_literal(s: &str) -> Result<Vec<Vec<alloc::string::String>>, &'static str> {
16760    let s = s.trim();
16761    let outer = s
16762        .strip_prefix('{')
16763        .and_then(|x| x.strip_suffix('}'))
16764        .ok_or("missing outer '{...}' braces")?;
16765    let trimmed = outer.trim();
16766    if trimmed.is_empty() {
16767        return Ok(Vec::new());
16768    }
16769    let mut rows: Vec<Vec<alloc::string::String>> = Vec::new();
16770    let mut i = 0;
16771    let bytes = trimmed.as_bytes();
16772    while i < bytes.len() {
16773        while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r' | b',') {
16774            i += 1;
16775        }
16776        if i >= bytes.len() {
16777            break;
16778        }
16779        if bytes[i] != b'{' {
16780            return Err("expected '{' opening a row");
16781        }
16782        i += 1;
16783        let row_start = i;
16784        let mut depth = 1;
16785        while i < bytes.len() && depth > 0 {
16786            match bytes[i] {
16787                b'{' => depth += 1,
16788                b'}' => depth -= 1,
16789                _ => {}
16790            }
16791            if depth > 0 {
16792                i += 1;
16793            }
16794        }
16795        if depth != 0 {
16796            return Err("unbalanced '{...}' in row");
16797        }
16798        let row_text = &trimmed[row_start..i];
16799        i += 1;
16800        let cells: Vec<alloc::string::String> = if row_text.trim().is_empty() {
16801            Vec::new()
16802        } else {
16803            row_text.split(',').map(|t| t.trim().to_string()).collect()
16804        };
16805        rows.push(cells);
16806    }
16807    if let Some(first) = rows.first() {
16808        let cols = first.len();
16809        for r in &rows {
16810            if r.len() != cols {
16811                return Err("ragged 2D array (rows have different column counts)");
16812            }
16813        }
16814    }
16815    Ok(rows)
16816}
16817
16818fn parse_int_2d_literal(s: &str) -> Result<Vec<Vec<Option<i32>>>, &'static str> {
16819    let raw = split_2d_literal(s)?;
16820    raw.into_iter()
16821        .map(|row| {
16822            row.into_iter()
16823                .map(|cell| {
16824                    if cell.eq_ignore_ascii_case("NULL") {
16825                        Ok(None)
16826                    } else {
16827                        cell.parse::<i32>()
16828                            .map(Some)
16829                            .map_err(|_| "invalid int element")
16830                    }
16831                })
16832                .collect()
16833        })
16834        .collect()
16835}
16836
16837fn parse_bigint_2d_literal(s: &str) -> Result<Vec<Vec<Option<i64>>>, &'static str> {
16838    let raw = split_2d_literal(s)?;
16839    raw.into_iter()
16840        .map(|row| {
16841            row.into_iter()
16842                .map(|cell| {
16843                    if cell.eq_ignore_ascii_case("NULL") {
16844                        Ok(None)
16845                    } else {
16846                        cell.parse::<i64>()
16847                            .map(Some)
16848                            .map_err(|_| "invalid bigint element")
16849                    }
16850                })
16851                .collect()
16852        })
16853        .collect()
16854}
16855
16856fn parse_text_2d_literal(s: &str) -> Result<Vec<Vec<Option<alloc::string::String>>>, &'static str> {
16857    let raw = split_2d_literal(s)?;
16858    Ok(raw
16859        .into_iter()
16860        .map(|row| {
16861            row.into_iter()
16862                .map(|cell| {
16863                    if cell.eq_ignore_ascii_case("NULL") {
16864                        None
16865                    } else {
16866                        Some(cell.trim_matches('"').to_string())
16867                    }
16868                })
16869                .collect()
16870        })
16871        .collect())
16872}
16873
16874fn format_int_2d_text(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
16875    let mut out = alloc::string::String::from("{");
16876    for (i, row) in rows.iter().enumerate() {
16877        if i > 0 {
16878            out.push(',');
16879        }
16880        out.push('{');
16881        for (j, cell) in row.iter().enumerate() {
16882            if j > 0 {
16883                out.push(',');
16884            }
16885            match cell {
16886                None => out.push_str("NULL"),
16887                Some(n) => out.push_str(&alloc::format!("{n}")),
16888            }
16889        }
16890        out.push('}');
16891    }
16892    out.push('}');
16893    out
16894}
16895
16896fn format_bigint_2d_text(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
16897    let mut out = alloc::string::String::from("{");
16898    for (i, row) in rows.iter().enumerate() {
16899        if i > 0 {
16900            out.push(',');
16901        }
16902        out.push('{');
16903        for (j, cell) in row.iter().enumerate() {
16904            if j > 0 {
16905                out.push(',');
16906            }
16907            match cell {
16908                None => out.push_str("NULL"),
16909                Some(n) => out.push_str(&alloc::format!("{n}")),
16910            }
16911        }
16912        out.push('}');
16913    }
16914    out.push('}');
16915    out
16916}
16917
16918fn format_text_2d_text(rows: &[Vec<Option<alloc::string::String>>]) -> alloc::string::String {
16919    let mut out = alloc::string::String::from("{");
16920    for (i, row) in rows.iter().enumerate() {
16921        if i > 0 {
16922            out.push(',');
16923        }
16924        out.push('{');
16925        for (j, cell) in row.iter().enumerate() {
16926            if j > 0 {
16927                out.push(',');
16928            }
16929            match cell {
16930                None => out.push_str("NULL"),
16931                Some(s) => out.push_str(s),
16932            }
16933        }
16934        out.push('}');
16935    }
16936    out.push('}');
16937    out
16938}
16939
16940/// v7.17.0 Phase 3.P0-40 — pub re-exports so pgwire + sqllogictest
16941/// share the single 2D-array renderer.
16942pub fn format_int_2d_text_pub(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
16943    format_int_2d_text(rows)
16944}
16945pub fn format_bigint_2d_text_pub(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
16946    format_bigint_2d_text(rows)
16947}
16948pub fn format_text_2d_text_pub(
16949    rows: &[Vec<Option<alloc::string::String>>],
16950) -> alloc::string::String {
16951    format_text_2d_text(rows)
16952}
16953
16954/// v7.17.0 Phase 3.P0-38 — parse a PG range literal of the form
16955/// `'[lo,up)'` / `'(lo,up]'` / `'[lo,up]'` / `'(lo,up)'` /
16956/// `'empty'`. Lower / upper may be empty (unbounded). Returns
16957/// `None` on any parse failure; caller surfaces as hard error.
16958fn parse_range_str(s: &str, kind: spg_storage::RangeKind) -> Option<Value> {
16959    let s = s.trim();
16960    if s.eq_ignore_ascii_case("empty") {
16961        return Some(Value::Range {
16962            kind,
16963            lower: None,
16964            upper: None,
16965            lower_inc: false,
16966            upper_inc: false,
16967            empty: true,
16968        });
16969    }
16970    let bytes = s.as_bytes();
16971    if bytes.len() < 3 {
16972        return None;
16973    }
16974    let lower_inc = match bytes[0] {
16975        b'[' => true,
16976        b'(' => false,
16977        _ => return None,
16978    };
16979    let upper_inc = match bytes[bytes.len() - 1] {
16980        b']' => true,
16981        b')' => false,
16982        _ => return None,
16983    };
16984    let inner = &s[1..s.len() - 1];
16985    let (lo_text, up_text) = inner.split_once(',')?;
16986    let lower = if lo_text.is_empty() {
16987        None
16988    } else {
16989        Some(alloc::boxed::Box::new(parse_range_element(lo_text, kind)?))
16990    };
16991    let upper = if up_text.is_empty() {
16992        None
16993    } else {
16994        Some(alloc::boxed::Box::new(parse_range_element(up_text, kind)?))
16995    };
16996    Some(Value::Range {
16997        kind,
16998        lower,
16999        upper,
17000        lower_inc,
17001        upper_inc,
17002        empty: false,
17003    })
17004}
17005
17006/// v7.17.0 Phase 3.P0-38 — parse a single range bound text into
17007/// the matching element Value for the RangeKind.
17008fn parse_range_element(text: &str, kind: spg_storage::RangeKind) -> Option<Value> {
17009    let text = text.trim().trim_matches('"');
17010    use spg_storage::RangeKind as K;
17011    match kind {
17012        K::Int4 => text.parse::<i32>().ok().map(Value::Int),
17013        K::Int8 => text.parse::<i64>().ok().map(Value::BigInt),
17014        K::Num => {
17015            // Reuse the Numeric parse via the engine's text-coercion
17016            // path; bail to None on failure.
17017            let dot = text.find('.');
17018            let scale: u8 = dot.map_or(0, |p| (text.len() - p - 1) as u8);
17019            let digits: alloc::string::String = text
17020                .chars()
17021                .filter(|c| *c == '-' || c.is_ascii_digit())
17022                .collect();
17023            let scaled: i128 = digits.parse().ok()?;
17024            Some(Value::Numeric { scaled, scale })
17025        }
17026        K::Ts | K::TsTz => {
17027            // Reuse the existing timestamp parse path. v7.17.0
17028            // expects `'YYYY-MM-DD HH:MM:SS[.ffffff]'` in range
17029            // bounds (TZ offset on TsTz is OOS for the initial
17030            // P0-38; ship plain Timestamp shape).
17031            crate::eval::parse_timestamp_literal(text).map(Value::Timestamp)
17032        }
17033        K::Date => crate::eval::parse_date_literal(text).map(Value::Date),
17034    }
17035}
17036
17037/// v7.17.0 Phase 3.P0-38 — render a Range value as its canonical
17038/// PG text form. Re-exported via [`format_range_text`] for use
17039/// from spg-server's pgwire layer.
17040pub fn format_range_text(v: &Value) -> alloc::string::String {
17041    format_range_str(v)
17042}
17043
17044fn format_range_str(v: &Value) -> alloc::string::String {
17045    let Value::Range {
17046        lower,
17047        upper,
17048        lower_inc,
17049        upper_inc,
17050        empty,
17051        ..
17052    } = v
17053    else {
17054        return alloc::string::String::new();
17055    };
17056    if *empty {
17057        return "empty".into();
17058    }
17059    let mut out = alloc::string::String::new();
17060    out.push(if *lower_inc { '[' } else { '(' });
17061    if let Some(l) = lower {
17062        out.push_str(&format_range_element(l));
17063    }
17064    out.push(',');
17065    if let Some(u) = upper {
17066        out.push_str(&format_range_element(u));
17067    }
17068    out.push(if *upper_inc { ']' } else { ')' });
17069    out
17070}
17071
17072fn format_range_element(v: &Value) -> alloc::string::String {
17073    match v {
17074        Value::Int(n) => alloc::format!("{n}"),
17075        Value::BigInt(n) => alloc::format!("{n}"),
17076        Value::Date(d) => crate::eval::format_date(*d),
17077        Value::Timestamp(t) => crate::eval::format_timestamp(*t),
17078        Value::Numeric { scaled, scale } => crate::eval::format_numeric(*scaled, *scale),
17079        other => alloc::format!("{other:?}"),
17080    }
17081}
17082
17083/// v7.17.0 Phase 3.P0-35 — parse a PG `money` literal into i64
17084/// cents. Accepts:
17085///   * Optional leading `-` (negative)
17086///   * Optional `$` prefix
17087///   * Integer portion with optional `,` thousands separators
17088///   * Optional `.` followed by 1-2 digits (cents); 1 digit
17089///     auto-pads to 2 (`.5` → 50 cents).
17090///
17091/// Returns None on any parse failure — caller surfaces as hard
17092/// SQL error.
17093fn parse_money_str(s: &str) -> Option<i64> {
17094    let s = s.trim();
17095    let (neg, rest) = match s.strip_prefix('-') {
17096        Some(r) => (true, r.trim_start()),
17097        None => (false, s),
17098    };
17099    let rest = rest.strip_prefix('$').unwrap_or(rest).trim_start();
17100    let (int_part, frac_part) = match rest.split_once('.') {
17101        Some((i, f)) => (i, Some(f)),
17102        None => (rest, None),
17103    };
17104    if int_part.is_empty() {
17105        return None;
17106    }
17107    // Validate + strip commas from the integer portion.
17108    let mut int_digits = alloc::string::String::with_capacity(int_part.len());
17109    for b in int_part.bytes() {
17110        match b {
17111            b',' => {}
17112            b'0'..=b'9' => int_digits.push(b as char),
17113            _ => return None,
17114        }
17115    }
17116    if int_digits.is_empty() {
17117        return None;
17118    }
17119    let dollars: i64 = int_digits.parse().ok()?;
17120    let cents: i64 = match frac_part {
17121        None => 0,
17122        Some(f) => {
17123            if f.is_empty() || f.len() > 2 || !f.bytes().all(|b| b.is_ascii_digit()) {
17124                return None;
17125            }
17126            let padded = if f.len() == 1 {
17127                alloc::format!("{f}0")
17128            } else {
17129                f.to_string()
17130            };
17131            padded.parse().ok()?
17132        }
17133    };
17134    let total = dollars.checked_mul(100)?.checked_add(cents)?;
17135    Some(if neg { -total } else { total })
17136}
17137
17138/// v7.17.0 Phase 3.P0-34 — parse a PG `timetz` literal
17139/// `HH:MM:SS[.fraction]±HH[:MM]` into (us, offset_secs).
17140///
17141/// The offset suffix is MANDATORY: SPG doesn't have a session TZ
17142/// wired into eval, so a bare `HH:MM:SS` literal would be
17143/// ambiguous. Returns None for any parse failure or out-of-range
17144/// component — caller surfaces as a hard SQL error.
17145///
17146/// Offset range: ±14 hours (±50400 seconds), matching PG's
17147/// internal limit.
17148fn parse_timetz_str(s: &str) -> Option<(i64, i32)> {
17149    let s = s.trim();
17150    // Find the offset sign — scan from right since the time part
17151    // never contains '+' / '-' (after the optional fractional dot
17152    // it's all digits and ':').
17153    let bytes = s.as_bytes();
17154    let sign_pos = bytes
17155        .iter()
17156        .enumerate()
17157        .rev()
17158        .find(|&(_, &b)| b == b'+' || b == b'-')
17159        .map(|(i, _)| i)?;
17160    if sign_pos == 0 {
17161        return None; // bare sign — no time component
17162    }
17163    let time_part = &s[..sign_pos];
17164    let offset_part = &s[sign_pos..];
17165    let us = parse_time_str(time_part)?;
17166    let sign: i32 = if offset_part.starts_with('+') { 1 } else { -1 };
17167    let offset_body = &offset_part[1..];
17168    let (hh_str, mm_str) = match offset_body.split_once(':') {
17169        Some((h, m)) => (h, m),
17170        None => (offset_body, "0"),
17171    };
17172    let hh: i32 = hh_str.parse().ok()?;
17173    let mm: i32 = mm_str.parse().ok()?;
17174    if !(0..=14).contains(&hh) || !(0..=59).contains(&mm) {
17175        return None;
17176    }
17177    let total = sign * (hh * 3600 + mm * 60);
17178    if total.abs() > 50_400 {
17179        return None;
17180    }
17181    Some((us, total))
17182}
17183
17184/// v7.17.0 Phase 3.P0-33 — funnel an integer literal through MySQL
17185/// YEAR range validation: 0 sentinel or 1901..=2155. Out-of-range
17186/// surfaces as a hard SQL error (no silent truncation, mirrors PG
17187/// `time_in` / `uuid_in` discipline).
17188fn coerce_int_to_year(n: i64, col_name: &str) -> Result<Value, EngineError> {
17189    if n == 0 || (1901..=2155).contains(&n) {
17190        // u16::try_from cannot fail in this range; the cast also
17191        // covers the 0 sentinel.
17192        return Ok(Value::Year(n as u16));
17193    }
17194    Err(EngineError::Eval(EvalError::TypeMismatch {
17195        detail: alloc::format!(
17196            "year value out of range: {n} (column `{col_name}`; \
17197             MySQL accepts 0 or 1901..=2155)"
17198        ),
17199    }))
17200}
17201
17202/// v7.17.0 Phase 3.P0-32 — parse a PG `time` literal
17203/// `HH:MM:SS[.fraction]` into microseconds since 00:00:00.
17204///
17205/// Accepts:
17206///   * `HH:MM:SS`            — exact-second precision
17207///   * `HH:MM:SS.f` .. `.ffffff` — 1-6 fractional digits, right-padded
17208///     with zeros to microseconds
17209///
17210/// Range: hour 0..=23, minute 0..=59, second 0..=59. Anything else
17211/// returns None — caller surfaces as a hard SQL error (no silent
17212/// truncation, matches PG's `time_in` behaviour).
17213fn parse_time_str(s: &str) -> Option<i64> {
17214    let s = s.trim();
17215    let (hms, frac) = match s.split_once('.') {
17216        Some((h, f)) => (h, Some(f)),
17217        None => (s, None),
17218    };
17219    let mut parts = hms.split(':');
17220    let hh: u32 = parts.next()?.parse().ok()?;
17221    let mm: u32 = parts.next()?.parse().ok()?;
17222    let ss: u32 = parts.next()?.parse().ok()?;
17223    if parts.next().is_some() {
17224        return None;
17225    }
17226    if hh > 23 || mm > 59 || ss > 59 {
17227        return None;
17228    }
17229    let frac_us: i64 = match frac {
17230        None => 0,
17231        Some(f) => {
17232            if f.is_empty() || f.len() > 6 || !f.bytes().all(|b| b.is_ascii_digit()) {
17233                return None;
17234            }
17235            // Right-pad with zeros so '.5' = 500000 µsec.
17236            let mut padded = alloc::string::String::with_capacity(6);
17237            padded.push_str(f);
17238            while padded.len() < 6 {
17239                padded.push('0');
17240            }
17241            padded.parse().ok()?
17242        }
17243    };
17244    Some(
17245        i64::from(hh) * 3_600_000_000
17246            + i64::from(mm) * 60_000_000
17247            + i64::from(ss) * 1_000_000
17248            + frac_us,
17249    )
17250}
17251
17252const fn column_type_to_data_type(t: ColumnTypeName) -> DataType {
17253    match t {
17254        ColumnTypeName::SmallInt => DataType::SmallInt,
17255        ColumnTypeName::Int => DataType::Int,
17256        ColumnTypeName::BigInt => DataType::BigInt,
17257        ColumnTypeName::Float => DataType::Float,
17258        ColumnTypeName::Text => DataType::Text,
17259        ColumnTypeName::Varchar(n) => DataType::Varchar(n),
17260        ColumnTypeName::Char(n) => DataType::Char(n),
17261        ColumnTypeName::Bool => DataType::Bool,
17262        ColumnTypeName::Vector { dim, encoding } => DataType::Vector {
17263            dim,
17264            encoding: match encoding {
17265                SqlVecEncoding::F32 => VecEncoding::F32,
17266                SqlVecEncoding::Sq8 => VecEncoding::Sq8,
17267                SqlVecEncoding::F16 => VecEncoding::F16,
17268            },
17269        },
17270        ColumnTypeName::Numeric(precision, scale) => DataType::Numeric { precision, scale },
17271        ColumnTypeName::Date => DataType::Date,
17272        ColumnTypeName::Timestamp => DataType::Timestamp,
17273        ColumnTypeName::Timestamptz => DataType::Timestamptz,
17274        ColumnTypeName::Json => DataType::Json,
17275        ColumnTypeName::Jsonb => DataType::Jsonb,
17276        ColumnTypeName::Bytes => DataType::Bytes,
17277        ColumnTypeName::TextArray => DataType::TextArray,
17278        ColumnTypeName::IntArray => DataType::IntArray,
17279        ColumnTypeName::BigIntArray => DataType::BigIntArray,
17280        ColumnTypeName::TsVector => DataType::TsVector,
17281        ColumnTypeName::TsQuery => DataType::TsQuery,
17282        ColumnTypeName::Uuid => DataType::Uuid,
17283        ColumnTypeName::Time => DataType::Time,
17284        ColumnTypeName::Year => DataType::Year,
17285        ColumnTypeName::TimeTz => DataType::TimeTz,
17286        ColumnTypeName::Money => DataType::Money,
17287        ColumnTypeName::Range(k) => DataType::Range(match k {
17288            spg_sql::ast::RangeKindAst::Int4 => spg_storage::RangeKind::Int4,
17289            spg_sql::ast::RangeKindAst::Int8 => spg_storage::RangeKind::Int8,
17290            spg_sql::ast::RangeKindAst::Num => spg_storage::RangeKind::Num,
17291            spg_sql::ast::RangeKindAst::Ts => spg_storage::RangeKind::Ts,
17292            spg_sql::ast::RangeKindAst::TsTz => spg_storage::RangeKind::TsTz,
17293            spg_sql::ast::RangeKindAst::Date => spg_storage::RangeKind::Date,
17294        }),
17295        ColumnTypeName::Hstore => DataType::Hstore,
17296        ColumnTypeName::IntArray2D => DataType::IntArray2D,
17297        ColumnTypeName::BigIntArray2D => DataType::BigIntArray2D,
17298        ColumnTypeName::TextArray2D => DataType::TextArray2D,
17299    }
17300}
17301
17302/// Convert an INSERT VALUES expression to a storage Value. Supports literal
17303/// expressions, unary-minus over numeric literals, and pgvector-style
17304/// `'[..]'::vector` cast (v1.2). Anything more complex returns `Unsupported`.
17305fn literal_expr_to_value(expr: Expr) -> Result<Value, EngineError> {
17306    match expr {
17307        Expr::Literal(l) => Ok(literal_to_value(l)),
17308        Expr::Cast { expr, target } => {
17309            let inner_value = literal_expr_to_value(*expr)?;
17310            crate::eval::cast_value(inner_value, target).map_err(EngineError::Eval)
17311        }
17312        Expr::Unary {
17313            op: UnOp::Neg,
17314            expr,
17315        } => match *expr {
17316            Expr::Literal(Literal::Integer(n)) => {
17317                // Fold to i32 if it fits, else BigInt. Parser emits Integer(i64)
17318                // — overflow on negate of i64::MIN is the one edge case.
17319                let neg = n.checked_neg().ok_or_else(|| {
17320                    EngineError::Unsupported("integer literal overflow on negation".into())
17321                })?;
17322                Ok(int_value_for(neg))
17323            }
17324            Expr::Literal(Literal::Float(x)) => Ok(Value::Float(-x)),
17325            other => Err(EngineError::Unsupported(alloc::format!(
17326                "unary minus over non-literal expression: {other:?}"
17327            ))),
17328        },
17329        // v7.10.10 — `ARRAY[lit, lit, …]` constructor accepted at
17330        // INSERT-time. Each element must reduce to a Value through
17331        // `literal_expr_to_value`; NULL elements become `None`.
17332        // v7.11.13 — deduce shape from element values: all Int →
17333        // IntArray; any BigInt → BigIntArray (widening); any Text
17334        // → TextArray. Cast targets (`ARRAY[]::INT[]`) flow through
17335        // the outer Cast arm before reaching here and re-coerce.
17336        Expr::Array(items) => {
17337            let mut materialised: alloc::vec::Vec<Value> =
17338                alloc::vec::Vec::with_capacity(items.len());
17339            for elem in items {
17340                materialised.push(literal_expr_to_value(elem)?);
17341            }
17342            Ok(array_literal_widen(materialised))
17343        }
17344        // Any other Expr shape — fall back to a general evaluation
17345        // against an empty row + empty schema. This unblocks the
17346        // app-common patterns where INSERT VALUES carries a
17347        // non-correlated function call:
17348        //   INSERT INTO t VALUES (concat('U-', 42))
17349        //   INSERT INTO t VALUES (now())
17350        //   INSERT INTO t VALUES (format('%s-%s', 'a', 'b'))
17351        // Any expression that references a column or `$N`
17352        // placeholder fails cleanly inside `eval_expr` with a
17353        // descriptive error; literals + casts + ARRAY[…] continue
17354        // to take the fast paths above so the hot INSERT path is
17355        // unchanged on the common case.
17356        other => {
17357            let empty_schema: alloc::vec::Vec<spg_storage::ColumnSchema> = alloc::vec::Vec::new();
17358            let ctx = EvalContext::new(&empty_schema, None);
17359            let empty_row = spg_storage::Row::new(alloc::vec::Vec::new());
17360            crate::eval::eval_expr(&other, &empty_row, &ctx).map_err(EngineError::Eval)
17361        }
17362    }
17363}
17364
17365fn literal_to_value(l: Literal) -> Value {
17366    match l {
17367        Literal::Integer(n) => int_value_for(n),
17368        Literal::Float(x) => Value::Float(x),
17369        Literal::String(s) => Value::Text(s),
17370        Literal::Bool(b) => Value::Bool(b),
17371        Literal::Null => Value::Null,
17372        Literal::Vector(v) => Value::Vector(v),
17373        Literal::TextArray(items) => Value::TextArray(items),
17374        Literal::IntArray(items) => Value::IntArray(items),
17375        Literal::BigIntArray(items) => Value::BigIntArray(items),
17376        Literal::Interval { months, micros, .. } => Value::Interval { months, micros },
17377    }
17378}
17379
17380/// Pick `Int` (`i32`) when the literal fits, else `BigInt`. `INT` vs `BIGINT`
17381/// columns will still enforce the right tag downstream — this is just the
17382/// default we synthesise from an unannotated integer literal.
17383fn int_value_for(n: i64) -> Value {
17384    if let Ok(small) = i32::try_from(n) {
17385        Value::Int(small)
17386    } else {
17387        Value::BigInt(n)
17388    }
17389}
17390
17391/// Widen / narrow `v` to fit `expected`. Numerics permit safe widening
17392/// (`Int → BigInt`, `Int/BigInt → Float`) and best-effort narrowing
17393/// (`BigInt → Int` succeeds only when the value fits in `i32`). Everything
17394/// else returns `TypeMismatch` carrying the column name for caller diagnostics.
17395/// `NULL` is always permitted; the nullability check happens later in storage.
17396#[allow(clippy::too_many_lines)]
17397/// v7.17.0 Phase 4.4 — reject negative integer values on UNSIGNED
17398/// columns. Called after `coerce_value` at each INSERT / UPDATE
17399/// site that has ColumnSchema context. NULL passes through (a
17400/// nullable UNSIGNED column can legitimately hold NULL).
17401fn check_unsigned_range(
17402    v: &Value,
17403    schema: &ColumnSchema,
17404    position: usize,
17405) -> Result<(), EngineError> {
17406    if !schema.is_unsigned {
17407        return Ok(());
17408    }
17409    let n = match v {
17410        Value::SmallInt(x) => i64::from(*x),
17411        Value::Int(x) => i64::from(*x),
17412        Value::BigInt(x) => *x,
17413        _ => return Ok(()), // non-integer cells (NULL, default) skip
17414    };
17415    if n < 0 {
17416        return Err(EngineError::Unsupported(alloc::format!(
17417            "column {:?} is UNSIGNED but got negative value {n} at position {position}",
17418            schema.name
17419        )));
17420    }
17421    Ok(())
17422}
17423
17424fn coerce_value(
17425    v: Value,
17426    expected: DataType,
17427    col_name: &str,
17428    position: usize,
17429) -> Result<Value, EngineError> {
17430    if v.is_null() {
17431        return Ok(Value::Null);
17432    }
17433    let actual = v.data_type().expect("non-null");
17434    if actual == expected {
17435        return Ok(v);
17436    }
17437    let coerced = match (v, expected) {
17438        (Value::Int(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
17439        (Value::Int(n), DataType::Float) => Some(Value::Float(f64::from(n))),
17440        (Value::Int(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
17441        (Value::Int(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
17442            i128::from(n),
17443            precision,
17444            scale,
17445            col_name,
17446        )?),
17447        (Value::SmallInt(n), DataType::Int) => Some(Value::Int(i32::from(n))),
17448        (Value::SmallInt(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
17449        (Value::SmallInt(n), DataType::Float) => Some(Value::Float(f64::from(n))),
17450        (Value::SmallInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
17451            i128::from(n),
17452            precision,
17453            scale,
17454            col_name,
17455        )?),
17456        (Value::BigInt(n), DataType::Int) => i32::try_from(n).ok().map(Value::Int),
17457        (Value::BigInt(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
17458        #[allow(clippy::cast_precision_loss)]
17459        (Value::BigInt(n), DataType::Float) => Some(Value::Float(n as f64)),
17460        (Value::BigInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
17461            i128::from(n),
17462            precision,
17463            scale,
17464            col_name,
17465        )?),
17466        (Value::Float(x), DataType::Numeric { precision, scale }) => {
17467            Some(numeric_from_float(x, precision, scale, col_name)?)
17468        }
17469        // v7.17.0 Phase 3.P0-67 — Text → NUMERIC. Parse a
17470        // canonical decimal text (`"-1234.56"` / `"42"` /
17471        // `"0.0001"`) into `(mantissa, source_scale)` and rescale
17472        // to the column's declared scale. Required for prepared
17473        // binds: `value_to_literal` flattens a Value::Numeric
17474        // into a TEXT literal because Literal carries no native
17475        // Numeric variant, so the placeholder substitution path
17476        // reaches coerce_value as Text → Numeric. Without this
17477        // arm the round-trip surfaces a TypeMismatch even though
17478        // the cell already left the engine as a valid Numeric.
17479        (Value::Text(s), DataType::Numeric { precision, scale }) => {
17480            let Some((mantissa, src_scale)) = parse_numeric_text(&s) else {
17481                return Err(EngineError::Eval(EvalError::TypeMismatch {
17482                    detail: alloc::format!("cannot parse {s:?} as NUMERIC for column `{col_name}`"),
17483                }));
17484            };
17485            Some(numeric_rescale(
17486                mantissa, src_scale, precision, scale, col_name,
17487            )?)
17488        }
17489        // Text → DATE / TIMESTAMP: parse canonical text forms.
17490        (Value::Text(s), DataType::Date) => {
17491            let d = eval::parse_date_literal(&s).ok_or_else(|| {
17492                EngineError::Eval(EvalError::TypeMismatch {
17493                    detail: alloc::format!("cannot parse {s:?} as DATE for column `{col_name}`"),
17494                })
17495            })?;
17496            Some(Value::Date(d))
17497        }
17498        // v7.14.0 — MySQL DEFAULT clauses quote integer / float
17499        // / boolean literals (`DEFAULT '0'`, `DEFAULT '1'`,
17500        // `DEFAULT '3.14'`, `DEFAULT 'true'`). Coerce the text
17501        // form to the column's numeric / bool type at DEFAULT-
17502        // installation time so the storage check sees a typed
17503        // value. Parse failures fall through to TypeMismatch.
17504        (Value::Text(s), DataType::SmallInt) => s.parse::<i16>().ok().map(Value::SmallInt),
17505        (Value::Text(s), DataType::Int) => s.parse::<i32>().ok().map(Value::Int),
17506        (Value::Text(s), DataType::BigInt) => s.parse::<i64>().ok().map(Value::BigInt),
17507        (Value::Text(s), DataType::Float) => s.parse::<f64>().ok().map(Value::Float),
17508        (Value::Text(s), DataType::Bool) => match s.to_ascii_lowercase().as_str() {
17509            "0" | "false" | "f" | "no" | "off" => Some(Value::Bool(false)),
17510            "1" | "true" | "t" | "yes" | "on" => Some(Value::Bool(true)),
17511            _ => None,
17512        },
17513        // v7.17.0 Phase 3.P0-46 — MySQL TINYINT(1) (which Phase 4.3
17514        // classifies as DataType::Bool) is the storage shape every
17515        // mysqldump-restored boolean column lands in. mysqldump emits
17516        // the values as integer `0` / `1` literals, so int → bool
17517        // coerce on INSERT is required for a 0-change cutover. MySQL's
17518        // rule is "any non-zero is truthy"; we follow that for all
17519        // signed int widths so the same coerce path serves an
17520        // explicit `BOOLEAN` column too.
17521        (Value::Int(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17522        (Value::SmallInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17523        (Value::BigInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17524        // v4.9: Text ↔ JSON coercion. No structural validation —
17525        // any text literal is accepted; the responsibility for
17526        // valid JSON lies with the producer.
17527        (Value::Text(s), DataType::Json | DataType::Jsonb) => Some(Value::Json(s)),
17528        (Value::Json(s), DataType::Text) => Some(Value::Text(s)),
17529        // v7.13.3 — mailrs round-7 S10. SPG's storage represents
17530        // both JSON and JSONB on-disk as `Value::Json(String)` —
17531        // they share the underlying text payload. The cast
17532        // `'<text>'::jsonb` produces a Value::Json that needs to
17533        // satisfy a DataType::Jsonb column. Identity coerce in
17534        // both directions so JSON ↔ JSONB assignments work at all
17535        // INSERT / ALTER COLUMN TYPE / DEFAULT contexts.
17536        (Value::Json(s), DataType::Jsonb | DataType::Json) => Some(Value::Json(s)),
17537        // v7.10.4 — Text → BYTEA. Decode PG-style literal forms:
17538        //   - Hex:    `\x48656c6c6f`  (case-insensitive hex pairs)
17539        //   - Escape: `Hello\\000world`  (backslash + octal triples)
17540        //   - Plain:  any string → raw UTF-8 bytes (PG also accepts)
17541        // Errors surface as TypeMismatch so the operator gets a
17542        // clear "this literal isn't a bytea literal" hint.
17543        (Value::Text(s), DataType::Bytes) => {
17544            let bytes = decode_bytea_literal(&s).map_err(|e| {
17545                EngineError::Eval(EvalError::TypeMismatch {
17546                    detail: alloc::format!(
17547                        "cannot parse {s:?} as BYTEA for column `{col_name}`: {e}"
17548                    ),
17549                })
17550            })?;
17551            Some(Value::Bytes(bytes))
17552        }
17553        // v7.10.4 — BYTEA → Text round-trip uses the PG hex
17554        // output (lowercase, `\x` prefix). Important when a
17555        // SELECT pulls a bytea cell through a Text column path.
17556        (Value::Bytes(b), DataType::Text) => Some(Value::Text(encode_bytea_hex(&b))),
17557        // v7.17.0 — Text → UUID. PG accepts canonical hyphenated,
17558        // unhyphenated, uppercase, and `{...}`-braced forms; we
17559        // funnel all four through `spg_storage::parse_uuid_str`.
17560        // A malformed literal surfaces as a SQL TypeMismatch
17561        // rather than silently inserting garbage — `0-change
17562        // cutover` requires that an app inserting bad UUID text
17563        // sees the same hard error PG would raise.
17564        (Value::Text(s), DataType::Uuid) => match spg_storage::parse_uuid_str(&s) {
17565            Some(b) => Some(Value::Uuid(b)),
17566            None => {
17567                return Err(EngineError::Eval(EvalError::TypeMismatch {
17568                    detail: alloc::format!(
17569                        "invalid input syntax for type uuid: {s:?} (column `{col_name}`)"
17570                    ),
17571                }));
17572            }
17573        },
17574        // v7.17.0 — UUID → Text canonical 8-4-4-4-12 lowercase.
17575        // Surfaces when a SELECT plucks a uuid cell through a
17576        // Text column path (e.g. INSERT INTO log SELECT id::text
17577        // FROM other_table).
17578        (Value::Uuid(b), DataType::Text) => Some(Value::Text(spg_storage::format_uuid(&b))),
17579        // v7.17.0 Phase 3.P0-32 — Text → TIME. Accepts
17580        // `HH:MM:SS` and `HH:MM:SS.ffffff` (1-6 fractional digits).
17581        // Out-of-range hour/min/sec is a hard SQL error (no
17582        // silent truncation — same 0-change-cutover discipline
17583        // we apply to UUID).
17584        (Value::Text(s), DataType::Time) => match parse_time_str(&s) {
17585            Some(us) => Some(Value::Time(us)),
17586            None => {
17587                return Err(EngineError::Eval(EvalError::TypeMismatch {
17588                    detail: alloc::format!(
17589                        "invalid input syntax for type time: {s:?} (column `{col_name}`)"
17590                    ),
17591                }));
17592            }
17593        },
17594        // v7.17.0 Phase 3.P0-32 — TIME → Text canonical `HH:MM:SS[.ffffff]`.
17595        (Value::Time(us), DataType::Text) => Some(Value::Text(eval::format_time(us))),
17596        // v7.17.0 Phase 3.P0-33 — int / bigint → YEAR. Range
17597        // check enforces the MySQL canonical 1901..=2155 + 0
17598        // sentinel; out-of-range is a hard SQL error (no silent
17599        // truncation, mirrors P0-32 / P0-25 discipline).
17600        (Value::SmallInt(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
17601        (Value::Int(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
17602        (Value::BigInt(n), DataType::Year) => Some(coerce_int_to_year(n, col_name)?),
17603        // Text → YEAR. Accepts the 4-digit decimal form only;
17604        // two-digit YEAR (`'99'` → 1999) was deprecated in MySQL
17605        // 5.7 and is out of scope for v7.17.0.
17606        (Value::Text(s), DataType::Year) => match s.trim().parse::<i64>() {
17607            Ok(n) => Some(coerce_int_to_year(n, col_name)?),
17608            Err(_) => {
17609                return Err(EngineError::Eval(EvalError::TypeMismatch {
17610                    detail: alloc::format!(
17611                        "invalid input syntax for type year: {s:?} (column `{col_name}`)"
17612                    ),
17613                }));
17614            }
17615        },
17616        // YEAR → Text 4-digit zero-padded.
17617        (Value::Year(y), DataType::Text) => Some(Value::Text(alloc::format!("{y:04}"))),
17618        // v7.17.0 Phase 3.P0-34 — Text → TIMETZ. Mandatory
17619        // signed offset suffix; missing offset is a hard error
17620        // (SPG has no session TZ wired into eval, unlike PG).
17621        (Value::Text(s), DataType::TimeTz) => match parse_timetz_str(&s) {
17622            Some((us, offset_secs)) => Some(Value::TimeTz { us, offset_secs }),
17623            None => {
17624                return Err(EngineError::Eval(EvalError::TypeMismatch {
17625                    detail: alloc::format!(
17626                        "invalid input syntax for type time with time zone: \
17627                         {s:?} (column `{col_name}`)"
17628                    ),
17629                }));
17630            }
17631        },
17632        // TIMETZ → Text canonical `HH:MM:SS[.ffffff]±HH[:MM]`.
17633        (Value::TimeTz { us, offset_secs }, DataType::Text) => {
17634            Some(Value::Text(eval::format_timetz(us, offset_secs)))
17635        }
17636        // v7.17.0 Phase 3.P0-35 — Text → MONEY. Accepts `$N.NN`,
17637        // `$N,NNN.NN`, optional leading `-`. Bare numeric literals
17638        // arrive via the Int/BigInt/Float/Numeric arms below.
17639        (Value::Text(s), DataType::Money) => match parse_money_str(&s) {
17640            Some(c) => Some(Value::Money(c)),
17641            None => {
17642                return Err(EngineError::Eval(EvalError::TypeMismatch {
17643                    detail: alloc::format!(
17644                        "invalid input syntax for type money: {s:?} (column `{col_name}`)"
17645                    ),
17646                }));
17647            }
17648        },
17649        // Int / BigInt / SmallInt / Float / Numeric → MONEY.
17650        // Bare numeric literal is interpreted as a major-unit
17651        // amount (matches PG: `100`::money → $100.00 = 10000 cents).
17652        (Value::SmallInt(n), DataType::Money) => {
17653            Some(Value::Money(i64::from(n).saturating_mul(100)))
17654        }
17655        (Value::Int(n), DataType::Money) => Some(Value::Money(i64::from(n).saturating_mul(100))),
17656        (Value::BigInt(n), DataType::Money) => Some(Value::Money(n.saturating_mul(100))),
17657        (Value::Float(x), DataType::Money) => {
17658            // Round half-away-from-zero to cents (no_std — no
17659            // `f64::round`, so hand-roll via biased truncation).
17660            let scaled = x * 100.0;
17661            let cents = if scaled >= 0.0 {
17662                (scaled + 0.5) as i64
17663            } else {
17664                (scaled - 0.5) as i64
17665            };
17666            Some(Value::Money(cents))
17667        }
17668        (Value::Numeric { scaled, scale }, DataType::Money) => {
17669            // Convert exact decimal to cents (scale 2). If scale > 2,
17670            // round half-away-from-zero. If scale < 2, multiply up.
17671            let cents = if scale == 2 {
17672                scaled
17673            } else if scale < 2 {
17674                let mult = 10_i128.pow(u32::from(2 - scale));
17675                scaled.saturating_mul(mult)
17676            } else {
17677                let div = 10_i128.pow(u32::from(scale - 2));
17678                let half = div / 2;
17679                let bias = if scaled >= 0 { half } else { -half };
17680                (scaled + bias) / div
17681            };
17682            Some(Value::Money(i64::try_from(cents).unwrap_or(i64::MAX)))
17683        }
17684        // MONEY → Text canonical `$N,NNN.CC`.
17685        (Value::Money(c), DataType::Text) => Some(Value::Text(eval::format_money(c))),
17686        // v7.17.0 Phase 3.P0-38 — Text → Range. Accepts canonical
17687        // PG forms: `'empty'`, `'[a,b)'`, `'(a,b]'`, `'[a,b]'`,
17688        // `'(a,b)'`, with empty lower or upper for unbounded.
17689        (Value::Text(s), DataType::Range(kind)) => match parse_range_str(&s, kind) {
17690            Some(v) => Some(v),
17691            None => {
17692                return Err(EngineError::Eval(EvalError::TypeMismatch {
17693                    detail: alloc::format!(
17694                        "invalid input syntax for range type: {s:?} (column `{col_name}`)"
17695                    ),
17696                }));
17697            }
17698        },
17699        // Range → Text canonical form (`[a,b)`, `'empty'`, etc).
17700        (v @ Value::Range { .. }, DataType::Text) => Some(Value::Text(format_range_str(&v))),
17701        // v7.17.0 Phase 3.P0-39 — Text → Hstore.
17702        (Value::Text(s), DataType::Hstore) => match parse_hstore_str(&s) {
17703            Some(pairs) => Some(Value::Hstore(pairs)),
17704            None => {
17705                return Err(EngineError::Eval(EvalError::TypeMismatch {
17706                    detail: alloc::format!(
17707                        "invalid input syntax for type hstore: {s:?} (column `{col_name}`)"
17708                    ),
17709                }));
17710            }
17711        },
17712        // Hstore → Text canonical `"k"=>"v"` form.
17713        (Value::Hstore(pairs), DataType::Text) => Some(Value::Text(format_hstore_str(&pairs))),
17714        // v7.17.0 Phase 3.P0-40 — Text → 2D arrays via PG
17715        // external `'{{a,b},{c,d}}'` literal.
17716        (Value::Text(s), DataType::IntArray2D) => match parse_int_2d_literal(&s) {
17717            Ok(m) => Some(Value::IntArray2D(m)),
17718            Err(e) => {
17719                return Err(EngineError::Eval(EvalError::TypeMismatch {
17720                    detail: alloc::format!(
17721                        "invalid input syntax for INT[][]: {s:?} (column `{col_name}`): {e}"
17722                    ),
17723                }));
17724            }
17725        },
17726        (Value::Text(s), DataType::BigIntArray2D) => match parse_bigint_2d_literal(&s) {
17727            Ok(m) => Some(Value::BigIntArray2D(m)),
17728            Err(e) => {
17729                return Err(EngineError::Eval(EvalError::TypeMismatch {
17730                    detail: alloc::format!(
17731                        "invalid input syntax for BIGINT[][]: {s:?} (column `{col_name}`): {e}"
17732                    ),
17733                }));
17734            }
17735        },
17736        (Value::Text(s), DataType::TextArray2D) => match parse_text_2d_literal(&s) {
17737            Ok(m) => Some(Value::TextArray2D(m)),
17738            Err(e) => {
17739                return Err(EngineError::Eval(EvalError::TypeMismatch {
17740                    detail: alloc::format!(
17741                        "invalid input syntax for TEXT[][]: {s:?} (column `{col_name}`): {e}"
17742                    ),
17743                }));
17744            }
17745        },
17746        // 2D arrays → Text canonical nested form.
17747        (Value::IntArray2D(rows), DataType::Text) => Some(Value::Text(format_int_2d_text(&rows))),
17748        (Value::BigIntArray2D(rows), DataType::Text) => {
17749            Some(Value::Text(format_bigint_2d_text(&rows)))
17750        }
17751        (Value::TextArray2D(rows), DataType::Text) => Some(Value::Text(format_text_2d_text(&rows))),
17752        // v7.10.11 — Text → TEXT[]. Decode PG's external array
17753        // form `'{a,b,NULL}'`. NULL element token (case-insensitive)
17754        // is the literal `NULL`; everything else is a quoted or
17755        // unquoted text element. mailrs `'{label1,label2}'::TEXT[]`.
17756        (Value::Text(s), DataType::TextArray) => {
17757            let arr = decode_text_array_literal(&s).map_err(|e| {
17758                EngineError::Eval(EvalError::TypeMismatch {
17759                    detail: alloc::format!(
17760                        "cannot parse {s:?} as TEXT[] for column `{col_name}`: {e}"
17761                    ),
17762                })
17763            })?;
17764            Some(Value::TextArray(arr))
17765        }
17766        // v7.16.0 — Text → IntArray / BigIntArray for the
17767        // spg-sqlx Bind path. Decode the PG external form
17768        // `{1,2,3}` as a TEXT array first, then parse each
17769        // element as int. Same shape as the TextArray decode
17770        // above with an element-wise narrow.
17771        (Value::Text(s), DataType::IntArray) => {
17772            let arr = decode_text_array_literal(&s).map_err(|e| {
17773                EngineError::Eval(EvalError::TypeMismatch {
17774                    detail: alloc::format!(
17775                        "cannot parse {s:?} as INT[] for column `{col_name}`: {e}"
17776                    ),
17777                })
17778            })?;
17779            let mut out: Vec<Option<i32>> = Vec::with_capacity(arr.len());
17780            for elem in arr {
17781                match elem {
17782                    None => out.push(None),
17783                    Some(t) => {
17784                        let n: i32 = t.parse().map_err(|_| {
17785                            EngineError::Eval(EvalError::TypeMismatch {
17786                                detail: alloc::format!(
17787                                    "cannot parse {t:?} as INT element for `{col_name}`"
17788                                ),
17789                            })
17790                        })?;
17791                        out.push(Some(n));
17792                    }
17793                }
17794            }
17795            Some(Value::IntArray(out))
17796        }
17797        (Value::Text(s), DataType::BigIntArray) => {
17798            let arr = decode_text_array_literal(&s).map_err(|e| {
17799                EngineError::Eval(EvalError::TypeMismatch {
17800                    detail: alloc::format!(
17801                        "cannot parse {s:?} as BIGINT[] for column `{col_name}`: {e}"
17802                    ),
17803                })
17804            })?;
17805            let mut out: Vec<Option<i64>> = Vec::with_capacity(arr.len());
17806            for elem in arr {
17807                match elem {
17808                    None => out.push(None),
17809                    Some(t) => {
17810                        let n: i64 = t.parse().map_err(|_| {
17811                            EngineError::Eval(EvalError::TypeMismatch {
17812                                detail: alloc::format!(
17813                                    "cannot parse {t:?} as BIGINT element for `{col_name}`"
17814                                ),
17815                            })
17816                        })?;
17817                        out.push(Some(n));
17818                    }
17819                }
17820            }
17821            Some(Value::BigIntArray(out))
17822        }
17823        // v7.10.11 — TEXT[] → Text round-trip uses PG's
17824        // external array form (`{a,b,NULL}`). Lets a SELECT
17825        // pull an array column through any Text-side codepath.
17826        (Value::TextArray(items), DataType::Text) => Some(Value::Text(encode_text_array(&items))),
17827        // v7.17.0 Phase 3.P0-68 — Text → VECTOR auto-coerce.
17828        // Matches the existing Text → TsVector arm and the
17829        // `::vector` cast: PG-canonical pgvector external form
17830        // (`'[1, 2, -3]'`) becomes a typed Vector value at the
17831        // column boundary. Dim mismatch surfaces as TypeMismatch.
17832        // For SQ8 / HALF encodings we chain through the standard
17833        // quantise helpers so the storage shape matches the
17834        // declared encoding without a second coerce pass.
17835        (Value::Text(s), DataType::Vector { dim, encoding }) => {
17836            let parsed = eval::parse_vector_text(&s).ok_or_else(|| {
17837                EngineError::Eval(EvalError::TypeMismatch {
17838                    detail: alloc::format!("cannot parse {s:?} as VECTOR for column `{col_name}`"),
17839                })
17840            })?;
17841            if parsed.len() != dim as usize {
17842                return Err(EngineError::Eval(EvalError::TypeMismatch {
17843                    detail: alloc::format!(
17844                        "VECTOR({dim}) column `{col_name}` rejects literal of length {}",
17845                        parsed.len()
17846                    ),
17847                }));
17848            }
17849            Some(match encoding {
17850                VecEncoding::F32 => Value::Vector(parsed),
17851                VecEncoding::Sq8 => Value::Sq8Vector(spg_storage::quantize::quantize(&parsed)),
17852                VecEncoding::F16 => {
17853                    Value::HalfVector(spg_storage::halfvec::HalfVector::from_f32_slice(&parsed))
17854                }
17855            })
17856        }
17857        // v7.16.1 — Text → TSVECTOR auto-coerce for the
17858        // INSERT-side wire path (mailrs round-9 A.2.a). PG
17859        // implicitly promotes the TEXT literal at INSERT into a
17860        // TSVECTOR column; SPG previously rejected with a hard
17861        // type mismatch, blocking 23,276 pg_dump rows into
17862        // `messages.search_vector`. We route through the same
17863        // `decode_tsvector_external` the `::tsvector` cast
17864        // already uses, so PG-canonical forms (`'word'`,
17865        // `'word:1A,2B'`, multi-lexeme, empty `''`) all parse.
17866        (Value::Text(s), DataType::TsVector) => {
17867            let lexs = eval::decode_tsvector_external(&s).map_err(|e| {
17868                EngineError::Eval(EvalError::TypeMismatch {
17869                    detail: alloc::format!(
17870                        "cannot parse {s:?} as TSVECTOR for column `{col_name}`: {e}"
17871                    ),
17872                })
17873            })?;
17874            Some(Value::TsVector(lexs))
17875        }
17876        (Value::Text(s), DataType::Timestamp | DataType::Timestamptz) => {
17877            let t = eval::parse_timestamp_literal(&s).ok_or_else(|| {
17878                EngineError::Eval(EvalError::TypeMismatch {
17879                    detail: alloc::format!(
17880                        "cannot parse {s:?} as TIMESTAMP for column `{col_name}`"
17881                    ),
17882                })
17883            })?;
17884            Some(Value::Timestamp(t))
17885        }
17886        // DATE ↔ TIMESTAMP convertibility (DATE → midnight,
17887        // TIMESTAMP → day truncation).
17888        (Value::Date(d), DataType::Timestamp | DataType::Timestamptz) => {
17889            Some(Value::Timestamp(i64::from(d) * 86_400_000_000))
17890        }
17891        // v7.9.21 — Value::Timestamp lands in either Timestamp
17892        // or Timestamptz columns; the on-disk layout is the
17893        // same i64 microseconds UTC.
17894        (Value::Timestamp(t), DataType::Timestamptz) => Some(Value::Timestamp(t)),
17895        (Value::Timestamp(t), DataType::Date) => {
17896            let days = t.div_euclid(86_400_000_000);
17897            i32::try_from(days).ok().map(Value::Date)
17898        }
17899        (
17900            Value::Numeric {
17901                scaled,
17902                scale: src_scale,
17903            },
17904            DataType::Numeric { precision, scale },
17905        ) => Some(numeric_rescale(
17906            scaled, src_scale, precision, scale, col_name,
17907        )?),
17908        #[allow(clippy::cast_precision_loss)]
17909        (Value::Numeric { scaled, scale }, DataType::Float) => {
17910            let mut div = 1.0_f64;
17911            for _ in 0..scale {
17912                div *= 10.0;
17913            }
17914            Some(Value::Float((scaled as f64) / div))
17915        }
17916        (Value::Numeric { scaled, scale }, DataType::Int) => {
17917            let truncated = numeric_truncate_to_integer(scaled, scale);
17918            i32::try_from(truncated).ok().map(Value::Int)
17919        }
17920        (Value::Numeric { scaled, scale }, DataType::BigInt) => {
17921            let truncated = numeric_truncate_to_integer(scaled, scale);
17922            i64::try_from(truncated).ok().map(Value::BigInt)
17923        }
17924        (Value::Numeric { scaled, scale }, DataType::SmallInt) => {
17925            let truncated = numeric_truncate_to_integer(scaled, scale);
17926            i16::try_from(truncated).ok().map(Value::SmallInt)
17927        }
17928        // VARCHAR(n) enforces an upper bound on character count.
17929        (Value::Text(s), DataType::Varchar(max)) => {
17930            if u32::try_from(s.chars().count()).unwrap_or(u32::MAX) <= max {
17931                Some(Value::Text(s))
17932            } else {
17933                return Err(EngineError::Unsupported(alloc::format!(
17934                    "value for VARCHAR({max}) column `{col_name}` exceeds length: \
17935                     {} chars",
17936                    s.chars().count()
17937                )));
17938            }
17939        }
17940        // v6.0.1: f32 → SQ8 INSERT-time quantisation. Triggered
17941        // when the column declares `VECTOR(N) USING SQ8` and
17942        // the INSERT VALUES expression yields a raw f32 vector
17943        // (the normal pgvector-shape literal). Dim mismatch
17944        // falls through the `_ => None` arm and surfaces as
17945        // `TypeMismatch` with the expected SQ8 column type —
17946        // matching the F32 path's existing error.
17947        (
17948            Value::Vector(v),
17949            DataType::Vector {
17950                dim,
17951                encoding: VecEncoding::Sq8,
17952            },
17953        ) if v.len() == dim as usize => Some(Value::Sq8Vector(spg_storage::quantize::quantize(&v))),
17954        // v6.0.3: f32 → f16 INSERT-time conversion for HALF
17955        // columns. Bit-exact at the storage layer (modulo
17956        // half-precision rounding); no rerank pass needed at
17957        // search time.
17958        (
17959            Value::Vector(v),
17960            DataType::Vector {
17961                dim,
17962                encoding: VecEncoding::F16,
17963            },
17964        ) if v.len() == dim as usize => Some(Value::HalfVector(
17965            spg_storage::halfvec::HalfVector::from_f32_slice(&v),
17966        )),
17967        // CHAR(n) right-pads with U+0020 to exactly n chars; if the input
17968        // is already longer we reject (PG truncates trailing-space-only;
17969        // staying strict for v1).
17970        (Value::Text(s), DataType::Char(size)) => {
17971            let len = u32::try_from(s.chars().count()).unwrap_or(u32::MAX);
17972            if len > size {
17973                return Err(EngineError::Unsupported(alloc::format!(
17974                    "value for CHAR({size}) column `{col_name}` exceeds length: \
17975                     {len} chars"
17976                )));
17977            }
17978            let need = (size - len) as usize;
17979            let mut padded = s;
17980            padded.reserve(need);
17981            for _ in 0..need {
17982                padded.push(' ');
17983            }
17984            Some(Value::Text(padded))
17985        }
17986        _ => None,
17987    };
17988    coerced.ok_or(EngineError::Storage(StorageError::TypeMismatch {
17989        column: col_name.into(),
17990        expected,
17991        actual,
17992        position,
17993    }))
17994}
17995
17996/// v7.12.4 — render a function arg list into the
17997/// canonical form the storage layer caches as
17998/// [`spg_storage::FunctionDef::args_repr`]. The catalogue uses
17999/// this string for both display + as a coarse signature key
18000/// for the (deferred) overload resolution v7.12.5+ adds.
18001fn render_function_args(args: &[spg_sql::ast::FunctionArg]) -> alloc::string::String {
18002    use core::fmt::Write;
18003    let mut out = alloc::string::String::from("(");
18004    for (i, a) in args.iter().enumerate() {
18005        if i > 0 {
18006            out.push_str(", ");
18007        }
18008        match a.mode {
18009            spg_sql::ast::FunctionArgMode::In => {}
18010            spg_sql::ast::FunctionArgMode::Out => out.push_str("OUT "),
18011            spg_sql::ast::FunctionArgMode::InOut => out.push_str("INOUT "),
18012        }
18013        if let Some(n) = &a.name {
18014            out.push_str(n);
18015            out.push(' ');
18016        }
18017        match &a.ty {
18018            spg_sql::ast::FunctionArgType::Typed(t) => {
18019                let _ = write!(out, "{t}");
18020            }
18021            spg_sql::ast::FunctionArgType::Raw(s) => out.push_str(s),
18022        }
18023    }
18024    out.push(')');
18025    out
18026}
18027
18028/// v7.19 P5 — true iff `expr` is `unnest(arg)` at the top level
18029/// (case-insensitive). Used by `exec_select_cancel`'s
18030/// projection loop to detect Set-Returning-Function rows that
18031/// need per-row expansion. Only the top-level call counts —
18032/// `coalesce(unnest(arr), 'x')` is NOT a SRF row from the
18033/// projection's perspective; it would surface as an "unknown
18034/// function" mismatch downstream, which is what we want
18035/// (multi-SRF / nested SRF is documented carve-out for v7.19).
18036fn is_top_level_unnest(expr: &spg_sql::ast::Expr) -> bool {
18037    match expr {
18038        spg_sql::ast::Expr::FunctionCall { name, args } => {
18039            name.eq_ignore_ascii_case("unnest") && args.len() == 1
18040        }
18041        _ => false,
18042    }
18043}
18044
18045/// v7.19 P5 — extract the array argument out of a top-level
18046/// `unnest(arg)` call. `None` if `expr` isn't a `unnest` call
18047/// of arity 1 (mirrors `is_top_level_unnest`).
18048fn top_level_unnest_arg(expr: &spg_sql::ast::Expr) -> Option<&spg_sql::ast::Expr> {
18049    match expr {
18050        spg_sql::ast::Expr::FunctionCall { name, args }
18051            if name.eq_ignore_ascii_case("unnest") && args.len() == 1 =>
18052        {
18053            Some(&args[0])
18054        }
18055        _ => None,
18056    }
18057}
18058
18059/// v7.19 P5 — turn an array-typed `Value` into the element list
18060/// `unnest()` projection emits. NULL → empty list (PG: `unnest(NULL)
18061/// = (no rows)`). Non-array values fall through to a type-mismatch
18062/// error.
18063fn array_value_to_elements(v: &Value) -> Result<Vec<Value>, EngineError> {
18064    match v {
18065        Value::Null => Ok(Vec::new()),
18066        Value::TextArray(items) => Ok(items
18067            .iter()
18068            .map(|opt| {
18069                opt.as_ref()
18070                    .map(|s| Value::Text(s.clone()))
18071                    .unwrap_or(Value::Null)
18072            })
18073            .collect()),
18074        Value::IntArray(items) => Ok(items
18075            .iter()
18076            .map(|opt| opt.map(Value::Int).unwrap_or(Value::Null))
18077            .collect()),
18078        Value::BigIntArray(items) => Ok(items
18079            .iter()
18080            .map(|opt| opt.map(Value::BigInt).unwrap_or(Value::Null))
18081            .collect()),
18082        other => Err(EngineError::Eval(EvalError::TypeMismatch {
18083            detail: alloc::format!(
18084                "unnest() expects an array argument, got {:?}",
18085                other.data_type()
18086            ),
18087        })),
18088    }
18089}
18090
18091#[cfg(test)]
18092mod tests {
18093    use super::*;
18094    use alloc::vec;
18095
18096    fn unwrap_command_ok(r: &QueryResult) -> usize {
18097        match r {
18098            QueryResult::CommandOk { affected, .. } => *affected,
18099            QueryResult::Rows { .. } => panic!("expected CommandOk, got Rows"),
18100        }
18101    }
18102
18103    #[test]
18104    fn update_seek_positions_engages_on_indexed_eq() {
18105        let mut e = Engine::new();
18106        e.execute("CREATE TABLE b (id INT NOT NULL, v INT NOT NULL)")
18107            .unwrap();
18108        e.execute("CREATE INDEX b_id ON b (id)").unwrap();
18109        for i in 0..100 {
18110            e.execute(&alloc::format!("INSERT INTO b VALUES ({i}, {i})"))
18111                .unwrap();
18112        }
18113        let stmt = spg_sql::parser::parse_statement("UPDATE b SET v = v + 1 WHERE id = 42")
18114            .expect("parse");
18115        let Statement::Update(u) = stmt else {
18116            panic!("expected Update, got {stmt:?}");
18117        };
18118        let w = u.where_.as_ref().expect("where");
18119        let table = e.catalog().get("b").unwrap();
18120        let schema_cols = table.schema().columns.clone();
18121        // step-by-step: each sub-resolution must succeed.
18122        let Expr::Binary { lhs, op, rhs } = w else {
18123            panic!("WHERE not Binary: {w:?}");
18124        };
18125        assert_eq!(*op, BinOp::Eq, "op not Eq");
18126        let pair = resolve_col_literal_pair(lhs, rhs, &schema_cols, "b");
18127        assert!(
18128            pair.is_some(),
18129            "resolve_col_literal_pair None: lhs={lhs:?} rhs={rhs:?}"
18130        );
18131        let (col_pos, value) = pair.unwrap();
18132        assert!(
18133            table.index_on(col_pos).is_some(),
18134            "no index on col {col_pos}"
18135        );
18136        assert!(
18137            IndexKey::from_value(&value).is_some(),
18138            "IndexKey::from_value None for {value:?}"
18139        );
18140        let positions = try_index_seek_positions(w, &schema_cols, table, "b");
18141        assert_eq!(positions, Some(vec![42]), "seek did not engage");
18142    }
18143
18144    #[test]
18145    fn create_table_registers_schema() {
18146        let mut e = Engine::new();
18147        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT)")
18148            .unwrap();
18149        assert_eq!(e.catalog().table_count(), 1);
18150        let t = e.catalog().get("foo").unwrap();
18151        assert_eq!(t.schema().columns.len(), 2);
18152        assert_eq!(t.schema().columns[0].ty, DataType::Int);
18153        assert!(!t.schema().columns[0].nullable);
18154        assert_eq!(t.schema().columns[1].ty, DataType::Text);
18155    }
18156
18157    #[test]
18158    fn create_table_vector_default_is_f32_encoded() {
18159        let mut e = Engine::new();
18160        e.execute("CREATE TABLE t (v VECTOR(8))").unwrap();
18161        let t = e.catalog().get("t").unwrap();
18162        assert_eq!(
18163            t.schema().columns[0].ty,
18164            DataType::Vector {
18165                dim: 8,
18166                encoding: VecEncoding::F32,
18167            },
18168        );
18169    }
18170
18171    #[test]
18172    fn create_table_vector_using_sq8_succeeds() {
18173        // v6.0.1 step 3: the step-1 fence in `column_def_to_schema`
18174        // is lifted. CREATE TABLE persists an SQ8 column type in
18175        // the catalog; INSERT (next test) quantises raw f32 input.
18176        let mut e = Engine::new();
18177        e.execute("CREATE TABLE t (v VECTOR(8) USING SQ8)").unwrap();
18178        let t = e.catalog().get("t").unwrap();
18179        assert_eq!(
18180            t.schema().columns[0].ty,
18181            DataType::Vector {
18182                dim: 8,
18183                encoding: VecEncoding::Sq8,
18184            },
18185        );
18186    }
18187
18188    #[test]
18189    fn insert_into_sq8_column_quantises_f32_payload() {
18190        // v6.0.1 step 3: INSERT-time `coerce_value` rewrites a raw
18191        // `Value::Vector(Vec<f32>)` literal into the column's
18192        // quantised representation. The row that lands in the
18193        // catalog must therefore hold a `Value::Sq8Vector`, not the
18194        // original f32 buffer — that's the bit that delivers the
18195        // 4× compression target.
18196        let mut e = Engine::new();
18197        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
18198        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
18199            .unwrap();
18200        let t = e.catalog().get("t").unwrap();
18201        assert_eq!(t.rows().len(), 1);
18202        match &t.rows()[0].values[0] {
18203            Value::Sq8Vector(q) => {
18204                assert_eq!(q.bytes.len(), 4);
18205                // min/max are derived from the payload: min=0.0, max=1.0.
18206                assert!((q.min - 0.0).abs() < 1e-6);
18207                assert!((q.max - 1.0).abs() < 1e-6);
18208            }
18209            other => panic!("expected Sq8Vector cell, got {other:?}"),
18210        }
18211    }
18212
18213    #[test]
18214    fn create_table_vector_using_half_succeeds_and_insert_converts_to_f16() {
18215        // v6.0.3: CREATE TABLE accepts USING HALF; INSERT path
18216        // converts the incoming `Value::Vector(Vec<f32>)` cell
18217        // into `Value::HalfVector(HalfVector)` via the new
18218        // `coerce_value` arm. The dequantised round-trip is
18219        // bit-exact for f16-representable values, so 0.0 / 0.25
18220        // / 0.5 / 1.0 hit their grid points exactly.
18221        let mut e = Engine::new();
18222        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
18223            .unwrap();
18224        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
18225            .unwrap();
18226        let t = e.catalog().get("t").unwrap();
18227        assert_eq!(t.rows().len(), 1);
18228        match &t.rows()[0].values[0] {
18229            Value::HalfVector(h) => {
18230                assert_eq!(h.dim(), 4);
18231                let back = h.to_f32_vec();
18232                let expected = alloc::vec![0.0_f32, 0.25, 0.5, 1.0];
18233                for (g, e) in back.iter().zip(expected.iter()) {
18234                    assert!(
18235                        (g - e).abs() < 1e-6,
18236                        "{g} vs {e} should be exact on f16 grid"
18237                    );
18238                }
18239            }
18240            other => panic!("expected HalfVector cell, got {other:?}"),
18241        }
18242    }
18243
18244    #[test]
18245    fn alter_index_rebuild_in_place_succeeds() {
18246        // v6.0.4: bare REBUILD (no encoding switch) walks every
18247        // row again to rebuild the NSW graph. Verifies the engine
18248        // dispatch + storage helper plumbing without changing any
18249        // cell encoding.
18250        let mut e = Engine::new();
18251        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
18252            .unwrap();
18253        for i in 0..8_i32 {
18254            #[allow(clippy::cast_precision_loss)]
18255            let base = (i as f32) * 0.1;
18256            e.execute(&alloc::format!(
18257                "INSERT INTO t VALUES ({i}, [{base}, {b1}, {b2}])",
18258                b1 = base + 0.01,
18259                b2 = base + 0.02,
18260            ))
18261            .unwrap();
18262        }
18263        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
18264        e.execute("ALTER INDEX t_idx REBUILD").unwrap();
18265        // Schema encoding stays F32 (no encoding clause).
18266        assert_eq!(
18267            e.catalog().get("t").unwrap().schema().columns[1].ty,
18268            DataType::Vector {
18269                dim: 3,
18270                encoding: VecEncoding::F32,
18271            },
18272        );
18273    }
18274
18275    #[test]
18276    fn alter_index_rebuild_with_encoding_switches_cell_type() {
18277        // v6.0.4: REBUILD WITH (encoding = SQ8) recodes every
18278        // stored cell from F32 → SQ8 + rebuilds the graph atop the
18279        // new encoding. Post-rebuild, cells must be Sq8Vector and
18280        // the schema must report encoding = Sq8.
18281        let mut e = Engine::new();
18282        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(4) NOT NULL)")
18283            .unwrap();
18284        e.execute("INSERT INTO t VALUES (1, [0.0, 0.25, 0.5, 1.0])")
18285            .unwrap();
18286        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
18287        e.execute("ALTER INDEX t_idx REBUILD WITH (encoding = SQ8)")
18288            .unwrap();
18289        let t = e.catalog().get("t").unwrap();
18290        assert_eq!(
18291            t.schema().columns[1].ty,
18292            DataType::Vector {
18293                dim: 4,
18294                encoding: VecEncoding::Sq8,
18295            },
18296        );
18297        assert!(matches!(t.rows()[0].values[1], Value::Sq8Vector(_)));
18298    }
18299
18300    #[test]
18301    fn alter_index_rebuild_unknown_index_errors() {
18302        let mut e = Engine::new();
18303        let err = e.execute("ALTER INDEX nope REBUILD").unwrap_err();
18304        assert!(
18305            matches!(
18306                &err,
18307                EngineError::Storage(StorageError::IndexNotFound { name }) if name == "nope"
18308            ),
18309            "got: {err}"
18310        );
18311    }
18312
18313    #[test]
18314    fn alter_index_rebuild_on_btree_index_errors() {
18315        // REBUILD on a B-tree index has no semantic meaning in
18316        // v6.0.4 — rejected at the storage layer with `Unsupported`.
18317        let mut e = Engine::new();
18318        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18319        e.execute("INSERT INTO t VALUES (1)").unwrap();
18320        e.execute("CREATE INDEX t_idx ON t (id)").unwrap();
18321        let err = e.execute("ALTER INDEX t_idx REBUILD").unwrap_err();
18322        assert!(
18323            matches!(&err, EngineError::Storage(StorageError::Unsupported(_))),
18324            "got: {err}"
18325        );
18326    }
18327
18328    #[test]
18329    fn prepared_insert_substitutes_placeholders() {
18330        // v6.1.1: prepare() parses once; execute_prepared() walks the
18331        // AST and replaces $1/$2 with the param Values BEFORE the
18332        // dispatch sees them. Same logical result as a simple-query
18333        // INSERT, but parse happens once per *statement*, not per
18334        // execution.
18335        let mut e = Engine::new();
18336        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
18337            .unwrap();
18338        let stmt = e.prepare("INSERT INTO t VALUES ($1, $2)").unwrap();
18339        for (id, name) in [(1, "alice"), (2, "bob"), (3, "carol")] {
18340            e.execute_prepared(stmt.clone(), &[Value::Int(id), Value::Text(name.into())])
18341                .unwrap();
18342        }
18343        // Read back via simple-query SELECT.
18344        let rows_result = e.execute("SELECT id, name FROM t").unwrap();
18345        let QueryResult::Rows { rows, .. } = rows_result else {
18346            panic!("expected Rows")
18347        };
18348        assert_eq!(rows.len(), 3);
18349    }
18350
18351    #[test]
18352    fn prepared_select_with_placeholder_filters_rows() {
18353        let mut e = Engine::new();
18354        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
18355            .unwrap();
18356        for i in 0..10_i32 {
18357            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
18358                .unwrap();
18359        }
18360        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
18361        let QueryResult::Rows { rows, .. } = e.execute_prepared(stmt, &[Value::Int(35)]).unwrap()
18362        else {
18363            panic!("expected Rows")
18364        };
18365        // v = 35 means i*7 = 35 → i = 5.
18366        assert_eq!(rows.len(), 1);
18367        assert_eq!(rows[0].values[0], Value::Int(5));
18368    }
18369
18370    #[test]
18371    fn prepared_too_few_params_errors() {
18372        let mut e = Engine::new();
18373        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18374        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
18375        let err = e.execute_prepared(stmt, &[]).unwrap_err();
18376        assert!(
18377            matches!(
18378                &err,
18379                EngineError::Eval(EvalError::PlaceholderOutOfRange { n: 1, bound: 0 })
18380            ),
18381            "got: {err}"
18382        );
18383    }
18384
18385    #[test]
18386    fn bytea_cast_round_trips_text_input() {
18387        // v7.18 — `'hello'::bytea` produces the raw bytes. Closes
18388        // the mailrs D-pre #3 reverse-acceptance gap.
18389        let e = Engine::new();
18390        let r = e.execute_readonly("SELECT 'hello'::bytea").unwrap();
18391        let QueryResult::Rows { rows, .. } = r else {
18392            panic!("expected Rows")
18393        };
18394        assert_eq!(rows.len(), 1);
18395        assert_eq!(rows[0].values[0], Value::Bytes(b"hello".to_vec()));
18396    }
18397
18398    #[test]
18399    fn bytea_cast_pg_escape_hex_form() {
18400        // E'\\xdeadbeef'::bytea — E-string decodes to `\xdeadbeef`
18401        // (literal 10 chars), then ::bytea reads it as PG hex
18402        // form bytea literal → 4 bytes.
18403        let e = Engine::new();
18404        let r = e.execute_readonly(r"SELECT E'\\xdeadbeef'::bytea").unwrap();
18405        let QueryResult::Rows { rows, .. } = r else {
18406            panic!("expected Rows")
18407        };
18408        assert_eq!(
18409            rows[0].values[0],
18410            Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef])
18411        );
18412    }
18413
18414    #[test]
18415    fn bytea_cast_chains_through_octet_length() {
18416        // octet_length('hello'::bytea) → 5. Confirms the cast
18417        // composes inside larger expressions, not just at top
18418        // level.
18419        let e = Engine::new();
18420        let r = e
18421            .execute_readonly("SELECT octet_length('hello'::bytea)")
18422            .unwrap();
18423        let QueryResult::Rows { rows, .. } = r else {
18424            panic!("expected Rows")
18425        };
18426        match &rows[0].values[0] {
18427            Value::Int(n) => assert_eq!(*n, 5),
18428            Value::BigInt(n) => assert_eq!(*n, 5),
18429            other => panic!("expected integer length, got {other:?}"),
18430        }
18431    }
18432
18433    #[test]
18434    fn readonly_prepared_on_snapshot_select_with_placeholder() {
18435        // v7.18 — sqlx Pool fan-out relies on running prepared
18436        // SELECTs against a frozen snapshot without re-entering
18437        // the writer engine. Mirrors the simple-query SELECT path
18438        // in `execute_readonly_on_snapshot` but takes a Statement
18439        // + bound params (the shape sqlx's Execute path produces).
18440        let mut e = Engine::new();
18441        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
18442            .unwrap();
18443        for i in 0..10_i32 {
18444            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
18445                .unwrap();
18446        }
18447        let snapshot = e.clone_snapshot();
18448        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
18449        let QueryResult::Rows { rows, .. } =
18450            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(35)])
18451                .unwrap()
18452        else {
18453            panic!("expected Rows")
18454        };
18455        assert_eq!(rows.len(), 1);
18456        assert_eq!(rows[0].values[0], Value::Int(5));
18457    }
18458
18459    #[test]
18460    fn readonly_prepared_on_snapshot_rejects_writes() {
18461        // DDL / DML prepared statements on the readonly path must
18462        // surface `WriteRequired` so the spg-sqlx connection layer
18463        // routes them to the writer mutex instead of the snapshot.
18464        let mut e = Engine::new();
18465        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18466        let snapshot = e.clone_snapshot();
18467        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
18468        let err = Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(1)])
18469            .unwrap_err();
18470        assert!(matches!(&err, EngineError::WriteRequired), "got: {err}");
18471    }
18472
18473    #[test]
18474    fn readonly_prepared_on_snapshot_frozen_view() {
18475        // The snapshot reflects engine state at clone_snapshot()
18476        // time. Writes after the snapshot are NOT visible — caller
18477        // takes a fresh snapshot (or `AsyncReadHandle::refresh()`)
18478        // to see them. This is the contract the per-statement
18479        // refresh in spg-sqlx relies on.
18480        let mut e = Engine::new();
18481        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18482        e.execute("INSERT INTO t VALUES (1)").unwrap();
18483        let snapshot = e.clone_snapshot();
18484        e.execute("INSERT INTO t VALUES (2)").unwrap();
18485        let stmt = e.prepare("SELECT id FROM t WHERE id = $1").unwrap();
18486        let QueryResult::Rows { rows, .. } =
18487            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(2)])
18488                .unwrap()
18489        else {
18490            panic!("expected Rows")
18491        };
18492        assert!(rows.is_empty(), "id=2 was inserted after snapshot");
18493    }
18494
18495    #[test]
18496    fn describe_prepared_on_snapshot_resolves_columns() {
18497        // v7.18 — sqlx's Executor::describe path on the readonly
18498        // fan-out needs to resolve column names + types against
18499        // the snapshot's catalog (not the live engine's catalog,
18500        // which may have moved on).
18501        let mut e = Engine::new();
18502        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
18503            .unwrap();
18504        let snapshot = e.clone_snapshot();
18505        let stmt = e.prepare("SELECT id, name FROM t WHERE id = $1").unwrap();
18506        let (_params, cols) = Engine::describe_prepared_on_snapshot(&snapshot, &stmt);
18507        assert_eq!(cols.len(), 2);
18508        assert_eq!(cols[0].name, "id");
18509        assert_eq!(cols[0].ty, DataType::Int);
18510        assert_eq!(cols[1].name, "name");
18511        assert_eq!(cols[1].ty, DataType::Text);
18512    }
18513
18514    #[test]
18515    fn insert_into_half_column_dim_mismatch_errors() {
18516        let mut e = Engine::new();
18517        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
18518            .unwrap();
18519        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
18520        assert!(matches!(
18521            &err,
18522            EngineError::Storage(StorageError::TypeMismatch { .. })
18523        ));
18524    }
18525
18526    #[test]
18527    fn insert_into_sq8_column_dim_mismatch_errors() {
18528        // Dim mismatch falls through the `coerce_value` Vector→Sq8
18529        // arm's guard and surfaces as `TypeMismatch` — the same
18530        // error the F32 path produces today, so client error
18531        // handling stays uniform across encodings.
18532        let mut e = Engine::new();
18533        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
18534        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
18535        assert!(
18536            matches!(
18537                &err,
18538                EngineError::Storage(StorageError::TypeMismatch { .. })
18539            ),
18540            "got: {err}",
18541        );
18542    }
18543
18544    #[test]
18545    fn create_table_duplicate_errors() {
18546        let mut e = Engine::new();
18547        e.execute("CREATE TABLE foo (a INT)").unwrap();
18548        let err = e.execute("CREATE TABLE foo (a INT)").unwrap_err();
18549        assert!(matches!(
18550            err,
18551            EngineError::Storage(StorageError::DuplicateTable { ref name }) if name == "foo"
18552        ));
18553    }
18554
18555    #[test]
18556    fn insert_into_unknown_table_errors() {
18557        let mut e = Engine::new();
18558        let err = e.execute("INSERT INTO ghost VALUES (1)").unwrap_err();
18559        assert!(matches!(
18560            err,
18561            EngineError::Storage(StorageError::TableNotFound { ref name }) if name == "ghost"
18562        ));
18563    }
18564
18565    #[test]
18566    fn insert_happy_path_reports_one_affected() {
18567        let mut e = Engine::new();
18568        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18569        let r = e.execute("INSERT INTO foo VALUES (42)").unwrap();
18570        assert_eq!(unwrap_command_ok(&r), 1);
18571        assert_eq!(e.catalog().get("foo").unwrap().row_count(), 1);
18572    }
18573
18574    #[test]
18575    fn insert_arity_mismatch_propagates() {
18576        let mut e = Engine::new();
18577        e.execute("CREATE TABLE foo (a INT, b TEXT)").unwrap();
18578        let err = e.execute("INSERT INTO foo VALUES (1)").unwrap_err();
18579        assert!(matches!(
18580            err,
18581            EngineError::Storage(StorageError::ArityMismatch { .. })
18582        ));
18583    }
18584
18585    #[test]
18586    fn insert_negative_integer_via_unary_minus() {
18587        let mut e = Engine::new();
18588        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18589        e.execute("INSERT INTO foo VALUES (-7)").unwrap();
18590        let rows = e.catalog().get("foo").unwrap().rows();
18591        assert_eq!(rows[0].values[0], Value::Int(-7));
18592    }
18593
18594    #[test]
18595    fn insert_expression_evaluated_against_empty_context() {
18596        // PG-canonical: INSERT VALUES accepts an arbitrary scalar
18597        // expression. The engine evaluates against an empty row
18598        // context — column references would error, but pure
18599        // arithmetic / function calls are fine.
18600        let mut e = Engine::new();
18601        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18602        e.execute("INSERT INTO foo VALUES (1 + 2)").unwrap();
18603        let rows = e.catalog().get("foo").unwrap().rows();
18604        assert_eq!(rows[0].values[0], Value::Int(3));
18605    }
18606
18607    #[test]
18608    fn select_star_returns_all_rows_in_insertion_order() {
18609        let mut e = Engine::new();
18610        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT NOT NULL)")
18611            .unwrap();
18612        e.execute("INSERT INTO foo VALUES (1, 'one')").unwrap();
18613        e.execute("INSERT INTO foo VALUES (2, 'two')").unwrap();
18614        e.execute("INSERT INTO foo VALUES (3, 'three')").unwrap();
18615
18616        let r = e.execute("SELECT * FROM foo").unwrap();
18617        let QueryResult::Rows { columns, rows } = r else {
18618            panic!("expected Rows")
18619        };
18620        assert_eq!(columns.len(), 2);
18621        assert_eq!(columns[0].name, "a");
18622        assert_eq!(rows.len(), 3);
18623        assert_eq!(
18624            rows[1].values,
18625            vec![Value::Int(2), Value::Text("two".into())]
18626        );
18627    }
18628
18629    #[test]
18630    fn select_star_on_empty_table_returns_zero_rows() {
18631        let mut e = Engine::new();
18632        e.execute("CREATE TABLE foo (a INT)").unwrap();
18633        let r = e.execute("SELECT * FROM foo").unwrap();
18634        match r {
18635            QueryResult::Rows { rows, .. } => assert!(rows.is_empty()),
18636            QueryResult::CommandOk { .. } => panic!("expected Rows"),
18637        }
18638    }
18639
18640    // --- v0.4: WHERE + projection ------------------------------------------
18641
18642    fn make_three_row_users(e: &mut Engine) {
18643        e.execute("CREATE TABLE users (id INT NOT NULL, name TEXT NOT NULL, score INT)")
18644            .unwrap();
18645        e.execute("INSERT INTO users VALUES (1, 'alice', 90)")
18646            .unwrap();
18647        e.execute("INSERT INTO users VALUES (2, 'bob', NULL)")
18648            .unwrap();
18649        e.execute("INSERT INTO users VALUES (3, 'cara', 70)")
18650            .unwrap();
18651    }
18652
18653    fn unwrap_rows(r: QueryResult) -> (Vec<ColumnSchema>, Vec<Row>) {
18654        match r {
18655            QueryResult::Rows { columns, rows } => (columns, rows),
18656            QueryResult::CommandOk { .. } => panic!("expected Rows"),
18657        }
18658    }
18659
18660    #[test]
18661    fn where_filter_passes_only_true_rows() {
18662        let mut e = Engine::new();
18663        make_three_row_users(&mut e);
18664        let r = e.execute("SELECT * FROM users WHERE id > 1").unwrap();
18665        let (_, rows) = unwrap_rows(r);
18666        assert_eq!(rows.len(), 2);
18667        assert_eq!(rows[0].values[0], Value::Int(2));
18668        assert_eq!(rows[1].values[0], Value::Int(3));
18669    }
18670
18671    #[test]
18672    fn where_with_null_result_filters_out_row() {
18673        let mut e = Engine::new();
18674        make_three_row_users(&mut e);
18675        // score is NULL for bob → score > 80 is NULL → row excluded
18676        let r = e.execute("SELECT * FROM users WHERE score > 80").unwrap();
18677        let (_, rows) = unwrap_rows(r);
18678        assert_eq!(rows.len(), 1);
18679        assert_eq!(rows[0].values[1], Value::Text("alice".into()));
18680    }
18681
18682    #[test]
18683    fn projection_named_columns() {
18684        let mut e = Engine::new();
18685        make_three_row_users(&mut e);
18686        let r = e.execute("SELECT name, score FROM users").unwrap();
18687        let (cols, rows) = unwrap_rows(r);
18688        assert_eq!(cols.len(), 2);
18689        assert_eq!(cols[0].name, "name");
18690        assert_eq!(cols[1].name, "score");
18691        assert_eq!(rows.len(), 3);
18692        assert_eq!(
18693            rows[0].values,
18694            vec![Value::Text("alice".into()), Value::Int(90)]
18695        );
18696    }
18697
18698    #[test]
18699    fn projection_with_column_alias() {
18700        let mut e = Engine::new();
18701        make_three_row_users(&mut e);
18702        let r = e
18703            .execute("SELECT name AS who FROM users WHERE id = 1")
18704            .unwrap();
18705        let (cols, rows) = unwrap_rows(r);
18706        assert_eq!(cols[0].name, "who");
18707        assert_eq!(rows.len(), 1);
18708        assert_eq!(rows[0].values[0], Value::Text("alice".into()));
18709    }
18710
18711    #[test]
18712    fn qualified_column_with_table_alias_resolves() {
18713        let mut e = Engine::new();
18714        make_three_row_users(&mut e);
18715        let r = e
18716            .execute("SELECT u.id, u.name FROM users AS u WHERE u.id < 3")
18717            .unwrap();
18718        let (cols, rows) = unwrap_rows(r);
18719        assert_eq!(cols.len(), 2);
18720        assert_eq!(rows.len(), 2);
18721    }
18722
18723    #[test]
18724    fn qualified_column_with_wrong_alias_errors() {
18725        let mut e = Engine::new();
18726        make_three_row_users(&mut e);
18727        let err = e.execute("SELECT x.id FROM users AS u").unwrap_err();
18728        assert!(matches!(
18729            err,
18730            EngineError::Eval(EvalError::UnknownQualifier { ref qualifier }) if qualifier == "x"
18731        ));
18732    }
18733
18734    #[test]
18735    fn select_unknown_column_errors_in_projection() {
18736        let mut e = Engine::new();
18737        make_three_row_users(&mut e);
18738        let err = e.execute("SELECT ghost FROM users").unwrap_err();
18739        assert!(matches!(
18740            err,
18741            EngineError::Eval(EvalError::ColumnNotFound { ref name }) if name == "ghost"
18742        ));
18743    }
18744
18745    #[test]
18746    fn where_unknown_column_errors() {
18747        let mut e = Engine::new();
18748        make_three_row_users(&mut e);
18749        let err = e
18750            .execute("SELECT * FROM users WHERE ghost = 1")
18751            .unwrap_err();
18752        assert!(matches!(
18753            err,
18754            EngineError::Eval(EvalError::ColumnNotFound { .. })
18755        ));
18756    }
18757
18758    #[test]
18759    fn expression_projection_evaluates_and_renders() {
18760        // Compound expressions in the SELECT list are evaluated per row;
18761        // the output column is typed TEXT, name defaults to the expression.
18762        let mut e = Engine::new();
18763        e.execute("CREATE TABLE t (a INT NOT NULL)").unwrap();
18764        e.execute("INSERT INTO t VALUES (3)").unwrap();
18765        let (_, rows) = unwrap_rows(e.execute("SELECT 1 + 2 FROM t").unwrap());
18766        assert_eq!(rows.len(), 1);
18767        // The expression evaluates to integer 3; rendered as the cell value
18768        // (storage::Value::Int(3) since arithmetic kept ints).
18769        assert_eq!(rows[0].values[0], Value::Int(3));
18770    }
18771
18772    #[test]
18773    fn select_unknown_table_errors() {
18774        let mut e = Engine::new();
18775        let err = e.execute("SELECT * FROM ghost").unwrap_err();
18776        assert!(matches!(
18777            err,
18778            EngineError::Storage(StorageError::TableNotFound { .. })
18779        ));
18780    }
18781
18782    #[test]
18783    fn invalid_sql_returns_parse_error() {
18784        // v4.4: UPDATE is now real SQL, so use a true syntactic
18785        // garbage payload for the parse-error path.
18786        let mut e = Engine::new();
18787        let err = e.execute("THIS_IS_NOT_A_KEYWORD foo bar baz").unwrap_err();
18788        assert!(matches!(err, EngineError::Parse(_)));
18789    }
18790
18791    // --- v0.8 CREATE INDEX + index seek ------------------------------------
18792
18793    #[test]
18794    fn create_index_registers_on_table() {
18795        let mut e = Engine::new();
18796        make_three_row_users(&mut e);
18797        e.execute("CREATE INDEX by_name ON users (name)").unwrap();
18798        let t = e.catalog().get("users").unwrap();
18799        assert_eq!(t.indices().len(), 1);
18800        assert_eq!(t.indices()[0].name, "by_name");
18801    }
18802
18803    #[test]
18804    fn create_index_on_unknown_table_errors() {
18805        let mut e = Engine::new();
18806        let err = e.execute("CREATE INDEX i ON ghost (a)").unwrap_err();
18807        assert!(matches!(
18808            err,
18809            EngineError::Storage(StorageError::TableNotFound { .. })
18810        ));
18811    }
18812
18813    #[test]
18814    fn create_index_on_unknown_column_errors() {
18815        let mut e = Engine::new();
18816        make_three_row_users(&mut e);
18817        let err = e.execute("CREATE INDEX i ON users (ghost)").unwrap_err();
18818        assert!(matches!(
18819            err,
18820            EngineError::Storage(StorageError::ColumnNotFound { .. })
18821        ));
18822    }
18823
18824    #[test]
18825    fn select_eq_uses_index_returns_same_rows_as_scan() {
18826        // Build two engines: one with an index, one without. Same query →
18827        // same row set (index is a planner optimisation, not a semantic
18828        // change).
18829        let mut without = Engine::new();
18830        make_three_row_users(&mut without);
18831        let mut with = Engine::new();
18832        make_three_row_users(&mut with);
18833        with.execute("CREATE INDEX by_id ON users (id)").unwrap();
18834
18835        let q = "SELECT * FROM users WHERE id = 2";
18836        let (_, no_idx_rows) = unwrap_rows(without.execute(q).unwrap());
18837        let (_, idx_rows) = unwrap_rows(with.execute(q).unwrap());
18838        assert_eq!(no_idx_rows, idx_rows);
18839        assert_eq!(idx_rows.len(), 1);
18840    }
18841
18842    #[test]
18843    fn select_eq_with_no_matching_index_value_returns_empty() {
18844        let mut e = Engine::new();
18845        make_three_row_users(&mut e);
18846        e.execute("CREATE INDEX by_id ON users (id)").unwrap();
18847        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM users WHERE id = 999").unwrap());
18848        assert_eq!(rows.len(), 0);
18849    }
18850
18851    // --- v0.9 transactions -------------------------------------------------
18852
18853    #[test]
18854    fn begin_sets_in_transaction_flag() {
18855        let mut e = Engine::new();
18856        assert!(!e.in_transaction());
18857        e.execute("BEGIN").unwrap();
18858        assert!(e.in_transaction());
18859    }
18860
18861    #[test]
18862    fn double_begin_errors() {
18863        let mut e = Engine::new();
18864        e.execute("BEGIN").unwrap();
18865        let err = e.execute("BEGIN").unwrap_err();
18866        assert_eq!(err, EngineError::TransactionAlreadyOpen);
18867    }
18868
18869    #[test]
18870    fn commit_without_begin_errors() {
18871        let mut e = Engine::new();
18872        let err = e.execute("COMMIT").unwrap_err();
18873        assert_eq!(err, EngineError::NoActiveTransaction);
18874    }
18875
18876    #[test]
18877    fn rollback_without_begin_errors() {
18878        let mut e = Engine::new();
18879        let err = e.execute("ROLLBACK").unwrap_err();
18880        assert_eq!(err, EngineError::NoActiveTransaction);
18881    }
18882
18883    #[test]
18884    fn commit_applies_shadow_to_committed_catalog() {
18885        let mut e = Engine::new();
18886        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18887        e.execute("BEGIN").unwrap();
18888        e.execute("INSERT INTO t VALUES (1)").unwrap();
18889        e.execute("INSERT INTO t VALUES (2)").unwrap();
18890        e.execute("COMMIT").unwrap();
18891        assert!(!e.in_transaction());
18892        assert_eq!(e.catalog().get("t").unwrap().row_count(), 2);
18893    }
18894
18895    #[test]
18896    fn rollback_discards_shadow() {
18897        let mut e = Engine::new();
18898        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18899        e.execute("BEGIN").unwrap();
18900        e.execute("INSERT INTO t VALUES (1)").unwrap();
18901        e.execute("INSERT INTO t VALUES (2)").unwrap();
18902        e.execute("ROLLBACK").unwrap();
18903        assert!(!e.in_transaction());
18904        assert_eq!(e.catalog().get("t").unwrap().row_count(), 0);
18905    }
18906
18907    #[test]
18908    fn select_during_tx_sees_uncommitted_writes_own_session() {
18909        // The shadow catalog is read by SELECTs while a TX is open — the
18910        // session can see its own pending writes.
18911        let mut e = Engine::new();
18912        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18913        e.execute("BEGIN").unwrap();
18914        e.execute("INSERT INTO t VALUES (42)").unwrap();
18915        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM t").unwrap());
18916        assert_eq!(rows.len(), 1);
18917        assert_eq!(rows[0].values[0], Value::Int(42));
18918    }
18919
18920    #[test]
18921    fn snapshot_with_no_users_is_bare_catalog_format() {
18922        let mut e = Engine::new();
18923        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18924        let bytes = e.snapshot();
18925        assert_eq!(
18926            &bytes[..8],
18927            b"SPGDB001",
18928            "must be the bare v3.x catalog magic"
18929        );
18930        let e2 = Engine::restore_envelope(&bytes).unwrap();
18931        assert!(e2.users().is_empty());
18932        assert_eq!(e2.catalog().table_count(), 1);
18933    }
18934
18935    #[test]
18936    fn snapshot_with_users_round_trips_both_via_envelope() {
18937        let mut e = Engine::new();
18938        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18939        e.create_user("alice", "pw1", Role::Admin, [9; 16]).unwrap();
18940        e.create_user("bob", "pw2", Role::ReadOnly, [5; 16])
18941            .unwrap();
18942        let bytes = e.snapshot();
18943        assert_eq!(&bytes[..8], b"SPGENV01", "must be the v4.1 envelope magic");
18944        let e2 = Engine::restore_envelope(&bytes).unwrap();
18945        assert_eq!(e2.users().len(), 2);
18946        assert_eq!(e2.verify_user("alice", "pw1"), Some(Role::Admin));
18947        assert_eq!(e2.verify_user("bob", "pw2"), Some(Role::ReadOnly));
18948        assert_eq!(e2.verify_user("alice", "wrong"), None);
18949        assert_eq!(e2.catalog().table_count(), 1);
18950    }
18951
18952    #[test]
18953    fn ddl_inside_tx_also_rolled_back() {
18954        let mut e = Engine::new();
18955        e.execute("BEGIN").unwrap();
18956        e.execute("CREATE TABLE t (v INT)").unwrap();
18957        // Visible inside the TX.
18958        e.execute("SELECT * FROM t").unwrap();
18959        e.execute("ROLLBACK").unwrap();
18960        // Gone after rollback.
18961        let err = e.execute("SELECT * FROM t").unwrap_err();
18962        assert!(matches!(
18963            err,
18964            EngineError::Storage(StorageError::TableNotFound { .. })
18965        ));
18966    }
18967
18968    // ── v6.1.2: CREATE / DROP PUBLICATION (engine-side) ──────
18969
18970    #[test]
18971    fn create_publication_lands_in_catalog() {
18972        let mut e = Engine::new();
18973        assert!(e.publications().is_empty());
18974        e.execute("CREATE PUBLICATION pub_a").unwrap();
18975        assert_eq!(e.publications().len(), 1);
18976        assert!(e.publications().contains("pub_a"));
18977    }
18978
18979    #[test]
18980    fn create_publication_duplicate_errors() {
18981        let mut e = Engine::new();
18982        e.execute("CREATE PUBLICATION pub_a").unwrap();
18983        let err = e.execute("CREATE PUBLICATION pub_a").unwrap_err();
18984        assert!(
18985            alloc::format!("{err:?}").contains("DuplicateName"),
18986            "got {err:?}"
18987        );
18988    }
18989
18990    #[test]
18991    fn drop_publication_silent_when_absent() {
18992        let mut e = Engine::new();
18993        // PG-compatible: DROP a publication that doesn't exist
18994        // succeeds (no-op) but reports zero affected.
18995        let r = e.execute("DROP PUBLICATION nope").unwrap();
18996        match r {
18997            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
18998            other => panic!("expected CommandOk, got {other:?}"),
18999        }
19000    }
19001
19002    #[test]
19003    fn drop_publication_present_reports_one_affected() {
19004        let mut e = Engine::new();
19005        e.execute("CREATE PUBLICATION pub_a").unwrap();
19006        let r = e.execute("DROP PUBLICATION pub_a").unwrap();
19007        match r {
19008            QueryResult::CommandOk {
19009                affected,
19010                modified_catalog,
19011            } => {
19012                assert_eq!(affected, 1);
19013                assert!(modified_catalog);
19014            }
19015            other => panic!("expected CommandOk, got {other:?}"),
19016        }
19017        assert!(e.publications().is_empty());
19018    }
19019
19020    #[test]
19021    fn publications_persist_across_snapshot_restore() {
19022        // The persist-across-restart ship-gate at the engine layer —
19023        // snapshot → restore_envelope round trip must preserve the
19024        // publication catalog. The spg-server e2e covers the
19025        // process-restart variant.
19026        let mut e = Engine::new();
19027        e.execute("CREATE PUBLICATION pub_a").unwrap();
19028        e.execute("CREATE PUBLICATION pub_b FOR ALL TABLES")
19029            .unwrap();
19030        let snap = e.snapshot();
19031        let e2 = Engine::restore_envelope(&snap).unwrap();
19032        assert_eq!(e2.publications().len(), 2);
19033        assert!(e2.publications().contains("pub_a"));
19034        assert!(e2.publications().contains("pub_b"));
19035    }
19036
19037    #[test]
19038    fn create_publication_allowed_inside_transaction() {
19039        // v6.1.4 dropped the v6.1.2 in-TX guard — PG allows
19040        // CREATE PUBLICATION inside a TX and the auto-commit
19041        // wrap path needs the same allowance.
19042        let mut e = Engine::new();
19043        e.execute("BEGIN").unwrap();
19044        e.execute("CREATE PUBLICATION pub_a").unwrap();
19045        e.execute("COMMIT").unwrap();
19046        assert!(e.publications().contains("pub_a"));
19047    }
19048
19049    // ── v6.1.3: SHOW PUBLICATIONS + FOR-list variants ───────
19050
19051    #[test]
19052    fn create_publication_for_table_list_lands_with_scope() {
19053        let mut e = Engine::new();
19054        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
19055        e.execute("CREATE TABLE t2 (id INT NOT NULL)").unwrap();
19056        e.execute("CREATE PUBLICATION pub_a FOR TABLE t1, t2")
19057            .unwrap();
19058        let scope = e.publications().get("pub_a").cloned();
19059        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = scope else {
19060            panic!("expected ForTables scope, got {scope:?}")
19061        };
19062        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
19063    }
19064
19065    #[test]
19066    fn create_publication_all_tables_except_lands_with_scope() {
19067        let mut e = Engine::new();
19068        e.execute("CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t3")
19069            .unwrap();
19070        let scope = e.publications().get("pub_a").cloned();
19071        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = scope else {
19072            panic!("expected AllTablesExcept scope, got {scope:?}")
19073        };
19074        assert_eq!(ts, alloc::vec!["t3".to_string()]);
19075    }
19076
19077    #[test]
19078    fn show_publications_empty_returns_zero_rows() {
19079        let e = Engine::new();
19080        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
19081        let QueryResult::Rows { rows, columns } = r else {
19082            panic!()
19083        };
19084        assert!(rows.is_empty());
19085        assert_eq!(columns.len(), 3);
19086        assert_eq!(columns[0].name, "name");
19087        assert_eq!(columns[1].name, "scope");
19088        assert_eq!(columns[2].name, "table_count");
19089    }
19090
19091    #[test]
19092    fn show_publications_returns_one_row_per_publication_ordered_by_name() {
19093        let mut e = Engine::new();
19094        e.execute("CREATE PUBLICATION z_pub").unwrap();
19095        e.execute("CREATE PUBLICATION a_pub FOR TABLE t1, t2")
19096            .unwrap();
19097        e.execute("CREATE PUBLICATION m_pub FOR ALL TABLES EXCEPT bad")
19098            .unwrap();
19099        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
19100        let QueryResult::Rows { rows, .. } = r else {
19101            panic!()
19102        };
19103        assert_eq!(rows.len(), 3);
19104        // Alphabetical order: a_pub, m_pub, z_pub.
19105        let names: Vec<&str> = rows
19106            .iter()
19107            .map(|r| {
19108                if let Value::Text(s) = &r.values[0] {
19109                    s.as_str()
19110                } else {
19111                    panic!()
19112                }
19113            })
19114            .collect();
19115        assert_eq!(names, alloc::vec!["a_pub", "m_pub", "z_pub"]);
19116        // Row 0 — a_pub scope summary + table_count = 2.
19117        match &rows[0].values[1] {
19118            Value::Text(s) => assert_eq!(s, "FOR TABLE t1, t2"),
19119            other => panic!("expected Text, got {other:?}"),
19120        }
19121        assert_eq!(rows[0].values[2], Value::Int(2));
19122        // Row 1 — m_pub.
19123        match &rows[1].values[1] {
19124            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES EXCEPT bad"),
19125            other => panic!("expected Text, got {other:?}"),
19126        }
19127        assert_eq!(rows[1].values[2], Value::Int(1));
19128        // Row 2 — z_pub (AllTables → NULL count).
19129        match &rows[2].values[1] {
19130            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES"),
19131            other => panic!("expected Text, got {other:?}"),
19132        }
19133        assert_eq!(rows[2].values[2], Value::Null);
19134    }
19135
19136    #[test]
19137    fn for_list_scopes_persist_across_snapshot() {
19138        // The v6.1.2 envelope-v3 round-trip exercised AllTables;
19139        // v6.1.3 needs the scope-1 / scope-2 tags to survive too.
19140        let mut e = Engine::new();
19141        e.execute("CREATE PUBLICATION p1 FOR TABLE t1, t2").unwrap();
19142        e.execute("CREATE PUBLICATION p2 FOR ALL TABLES EXCEPT bad, worse")
19143            .unwrap();
19144        let snap = e.snapshot();
19145        let e2 = Engine::restore_envelope(&snap).unwrap();
19146        assert_eq!(e2.publications().len(), 2);
19147        let p1 = e2.publications().get("p1").cloned();
19148        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = p1 else {
19149            panic!("p1 scope lost: {p1:?}")
19150        };
19151        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
19152        let p2 = e2.publications().get("p2").cloned();
19153        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = p2 else {
19154            panic!("p2 scope lost: {p2:?}")
19155        };
19156        assert_eq!(ts, alloc::vec!["bad".to_string(), "worse".to_string()]);
19157    }
19158
19159    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW + envelope v4 ─
19160
19161    #[test]
19162    fn create_subscription_lands_in_catalog_with_defaults() {
19163        let mut e = Engine::new();
19164        e.execute(
19165            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
19166        )
19167        .unwrap();
19168        let s = e.subscriptions().get("sub_a").cloned().expect("present");
19169        assert_eq!(s.conn_str, "host=127.0.0.1 port=20002");
19170        assert_eq!(s.publications, alloc::vec!["pub_a".to_string()]);
19171        assert!(s.enabled);
19172        assert_eq!(s.last_received_pos, 0);
19173    }
19174
19175    #[test]
19176    fn create_subscription_duplicate_name_errors() {
19177        let mut e = Engine::new();
19178        e.execute("CREATE SUBSCRIPTION s CONNECTION 'host=x' PUBLICATION p")
19179            .unwrap();
19180        let err = e
19181            .execute("CREATE SUBSCRIPTION s CONNECTION 'host=y' PUBLICATION p")
19182            .unwrap_err();
19183        assert!(
19184            alloc::format!("{err:?}").contains("DuplicateName"),
19185            "got {err:?}"
19186        );
19187    }
19188
19189    #[test]
19190    fn drop_subscription_silent_when_absent() {
19191        let mut e = Engine::new();
19192        let r = e.execute("DROP SUBSCRIPTION never").unwrap();
19193        match r {
19194            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
19195            other => panic!("expected CommandOk, got {other:?}"),
19196        }
19197    }
19198
19199    #[test]
19200    fn subscription_advance_updates_last_pos_monotone() {
19201        let mut e = Engine::new();
19202        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
19203            .unwrap();
19204        assert!(e.subscription_advance("s", 100));
19205        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
19206        assert!(e.subscription_advance("s", 50)); // stale → ignored
19207        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
19208        assert!(e.subscription_advance("s", 200));
19209        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 200);
19210        assert!(!e.subscription_advance("missing", 1));
19211    }
19212
19213    #[test]
19214    fn show_subscriptions_returns_rows_ordered_by_name() {
19215        let mut e = Engine::new();
19216        e.execute("CREATE SUBSCRIPTION z_sub CONNECTION 'h=x' PUBLICATION p1, p2")
19217            .unwrap();
19218        e.execute("CREATE SUBSCRIPTION a_sub CONNECTION 'h=y' PUBLICATION p3")
19219            .unwrap();
19220        let r = e.execute_readonly("SHOW SUBSCRIPTIONS").unwrap();
19221        let QueryResult::Rows { rows, columns } = r else {
19222            panic!()
19223        };
19224        assert_eq!(rows.len(), 2);
19225        assert_eq!(columns.len(), 5);
19226        assert_eq!(columns[0].name, "name");
19227        assert_eq!(columns[4].name, "last_received_pos");
19228        // Alphabetical: a_sub, z_sub.
19229        let names: Vec<&str> = rows
19230            .iter()
19231            .map(|r| {
19232                if let Value::Text(s) = &r.values[0] {
19233                    s.as_str()
19234                } else {
19235                    panic!()
19236                }
19237            })
19238            .collect();
19239        assert_eq!(names, alloc::vec!["a_sub", "z_sub"]);
19240        // Row 0: a_sub
19241        assert_eq!(rows[0].values[1], Value::Text("h=y".to_string()));
19242        assert_eq!(rows[0].values[2], Value::Text("p3".to_string()));
19243        assert_eq!(rows[0].values[3], Value::Bool(true));
19244        assert_eq!(rows[0].values[4], Value::BigInt(0));
19245        // Row 1: z_sub — publications join with ", "
19246        assert_eq!(rows[1].values[2], Value::Text("p1, p2".to_string()));
19247    }
19248
19249    #[test]
19250    fn subscriptions_persist_across_snapshot_envelope_v4() {
19251        let mut e = Engine::new();
19252        e.execute("CREATE SUBSCRIPTION s1 CONNECTION 'h=A' PUBLICATION p1, p2")
19253            .unwrap();
19254        e.execute("CREATE SUBSCRIPTION s2 CONNECTION 'h=B' PUBLICATION p3")
19255            .unwrap();
19256        e.subscription_advance("s2", 42);
19257        let snap = e.snapshot();
19258        let e2 = Engine::restore_envelope(&snap).unwrap();
19259        assert_eq!(e2.subscriptions().len(), 2);
19260        let s1 = e2.subscriptions().get("s1").unwrap();
19261        assert_eq!(s1.conn_str, "h=A");
19262        assert_eq!(
19263            s1.publications,
19264            alloc::vec!["p1".to_string(), "p2".to_string()]
19265        );
19266        assert_eq!(s1.last_received_pos, 0);
19267        let s2 = e2.subscriptions().get("s2").unwrap();
19268        assert_eq!(s2.last_received_pos, 42);
19269    }
19270
19271    #[test]
19272    fn v3_envelope_loads_with_empty_subscriptions() {
19273        // v3 snapshot (publications-only). Forge it by hand so we
19274        // verify v6.1.4 readers don't panic — they must surface
19275        // empty subscriptions and a populated publication table.
19276        let mut e = Engine::new();
19277        e.execute("CREATE PUBLICATION pub_legacy").unwrap();
19278        let catalog = e.catalog.serialize();
19279        let users = crate::users::serialize_users(&e.users);
19280        let pubs = e.publications.serialize();
19281        let mut buf = Vec::new();
19282        buf.extend_from_slice(b"SPGENV01");
19283        buf.push(3u8); // v3
19284        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
19285        buf.extend_from_slice(&catalog);
19286        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
19287        buf.extend_from_slice(&users);
19288        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
19289        buf.extend_from_slice(&pubs);
19290        let crc = spg_crypto::crc32::crc32(&buf);
19291        buf.extend_from_slice(&crc.to_le_bytes());
19292
19293        let e2 = Engine::restore_envelope(&buf).expect("v3 envelope restores under v4 reader");
19294        assert!(e2.subscriptions().is_empty());
19295        assert!(e2.publications().contains("pub_legacy"));
19296    }
19297
19298    #[test]
19299    fn create_subscription_allowed_inside_transaction() {
19300        let mut e = Engine::new();
19301        e.execute("BEGIN").unwrap();
19302        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
19303            .unwrap();
19304        e.execute("COMMIT").unwrap();
19305        assert!(e.subscriptions().contains("s"));
19306    }
19307
19308    // ── v6.2.0: ANALYZE + spg_statistic + envelope v5 ──────────
19309    #[test]
19310    fn analyze_populates_histogram_bounds() {
19311        let mut e = Engine::new();
19312        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT)")
19313            .unwrap();
19314        for i in 0..50 {
19315            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'name{i}')"))
19316                .unwrap();
19317        }
19318        e.execute("ANALYZE t").unwrap();
19319        let stats = e.statistics();
19320        let id_stats = stats.get("t", "id").unwrap();
19321        assert!(id_stats.histogram_bounds.len() >= 2);
19322        assert_eq!(id_stats.histogram_bounds.first().unwrap(), "0");
19323        assert_eq!(id_stats.histogram_bounds.last().unwrap(), "49");
19324        assert!((id_stats.null_frac - 0.0).abs() < 1e-6);
19325        assert_eq!(id_stats.n_distinct, 50);
19326    }
19327
19328    #[test]
19329    fn reanalyze_overwrites_prior_stats() {
19330        let mut e = Engine::new();
19331        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19332        for i in 0..10 {
19333            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19334                .unwrap();
19335        }
19336        e.execute("ANALYZE t").unwrap();
19337        let n1 = e.statistics().get("t", "id").unwrap().n_distinct;
19338        assert_eq!(n1, 10);
19339        for i in 10..30 {
19340            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19341                .unwrap();
19342        }
19343        e.execute("ANALYZE t").unwrap();
19344        let n2 = e.statistics().get("t", "id").unwrap().n_distinct;
19345        assert_eq!(n2, 30);
19346    }
19347
19348    #[test]
19349    fn analyze_unknown_table_errors() {
19350        let mut e = Engine::new();
19351        let err = e.execute("ANALYZE nonexistent").unwrap_err();
19352        assert!(matches!(
19353            err,
19354            EngineError::Storage(StorageError::TableNotFound { .. })
19355        ));
19356    }
19357
19358    #[test]
19359    fn bare_analyze_covers_all_user_tables() {
19360        let mut e = Engine::new();
19361        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
19362        e.execute("CREATE TABLE t2 (name TEXT NOT NULL)").unwrap();
19363        e.execute("INSERT INTO t1 VALUES (1)").unwrap();
19364        e.execute("INSERT INTO t2 VALUES ('alice')").unwrap();
19365        let r = e.execute("ANALYZE").unwrap();
19366        match r {
19367            QueryResult::CommandOk {
19368                affected,
19369                modified_catalog,
19370            } => {
19371                assert_eq!(affected, 2);
19372                assert!(modified_catalog);
19373            }
19374            other => panic!("expected CommandOk, got {other:?}"),
19375        }
19376        assert!(e.statistics().get("t1", "id").is_some());
19377        assert!(e.statistics().get("t2", "name").is_some());
19378    }
19379
19380    #[test]
19381    fn select_from_spg_statistic_returns_rows_per_column() {
19382        let mut e = Engine::new();
19383        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
19384            .unwrap();
19385        e.execute("INSERT INTO t VALUES (1, 'a')").unwrap();
19386        e.execute("INSERT INTO t VALUES (2, 'b')").unwrap();
19387        e.execute("ANALYZE t").unwrap();
19388        let r = e.execute_readonly("SELECT * FROM spg_statistic").unwrap();
19389        let QueryResult::Rows { rows, columns } = r else {
19390            panic!()
19391        };
19392        // v6.7.0 — spg_statistic gained a `cold_row_count` column.
19393        assert_eq!(columns.len(), 6);
19394        assert_eq!(columns[0].name, "table_name");
19395        assert_eq!(columns[4].name, "histogram_bounds");
19396        assert_eq!(columns[5].name, "cold_row_count");
19397        assert_eq!(rows.len(), 2, "one row per column of t");
19398        // Sorted by (table_name, column_name).
19399        match (&rows[0].values[0], &rows[0].values[1]) {
19400            (Value::Text(t), Value::Text(c)) => {
19401                assert_eq!(t, "t");
19402                // BTreeMap orders (table, column); columns "id" < "label".
19403                assert_eq!(c, "id");
19404            }
19405            _ => panic!(),
19406        }
19407    }
19408
19409    #[test]
19410    fn analyze_skips_vector_columns() {
19411        // Vector columns have their own stats shape (HNSW graph);
19412        // ANALYZE leaves them out of spg_statistic.
19413        let mut e = Engine::new();
19414        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
19415            .unwrap();
19416        e.execute("INSERT INTO t VALUES (1, [1, 2, 3])").unwrap();
19417        e.execute("ANALYZE t").unwrap();
19418        assert!(e.statistics().get("t", "id").is_some());
19419        assert!(e.statistics().get("t", "v").is_none());
19420    }
19421
19422    #[test]
19423    fn statistics_persist_across_envelope_v5_round_trip() {
19424        let mut e = Engine::new();
19425        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19426        for i in 0..20 {
19427            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19428                .unwrap();
19429        }
19430        e.execute("ANALYZE").unwrap();
19431        let snap = e.snapshot();
19432        let e2 = Engine::restore_envelope(&snap).unwrap();
19433        let s = e2.statistics().get("t", "id").unwrap();
19434        assert_eq!(s.n_distinct, 20);
19435    }
19436
19437    // ── v6.2.1 auto-analyze threshold ───────────────────────────
19438
19439    #[test]
19440    fn auto_analyze_threshold_fires_after_10pct_of_min_rows_on_small_table() {
19441        // For a table with 0 rows then 10 inserts → modified=10,
19442        // row_count=10. Threshold = 0.1 × max(10, 100) = 10. So
19443        // after the 10th INSERT the threshold is met.
19444        let mut e = Engine::new();
19445        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19446        for i in 0..9 {
19447            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19448                .unwrap();
19449        }
19450        assert!(e.tables_needing_analyze().is_empty(), "9 < threshold");
19451        e.execute("INSERT INTO t VALUES (9)").unwrap();
19452        let needs = e.tables_needing_analyze();
19453        assert_eq!(needs, alloc::vec!["t".to_string()]);
19454    }
19455
19456    #[test]
19457    fn auto_analyze_threshold_uses_10pct_of_row_count_for_large_tables() {
19458        // After ANALYZE on 1000 rows, threshold = 0.1 × row_count.
19459        // Each new INSERT bumps both modified and row_count, so to
19460        // trigger from N=1000 we need modifications ≥ 0.1 × (1000+M),
19461        // i.e. M ≥ 112. The test inserts 50 (no fire), then 150
19462        // more (200 total mods, row_count=1200, threshold=120 → fire).
19463        let mut e = Engine::new();
19464        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19465        for i in 0..1000 {
19466            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19467                .unwrap();
19468        }
19469        e.execute("ANALYZE t").unwrap();
19470        assert!(e.tables_needing_analyze().is_empty(), "fresh ANALYZE");
19471        for i in 1000..1050 {
19472            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19473                .unwrap();
19474        }
19475        assert!(
19476            e.tables_needing_analyze().is_empty(),
19477            "50 inserts < threshold of ~105"
19478        );
19479        for i in 1050..1200 {
19480            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19481                .unwrap();
19482        }
19483        assert_eq!(
19484            e.tables_needing_analyze(),
19485            alloc::vec!["t".to_string()],
19486            "200 inserts > 0.1 × 1200 threshold"
19487        );
19488    }
19489
19490    #[test]
19491    fn auto_analyze_threshold_resets_after_analyze() {
19492        let mut e = Engine::new();
19493        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19494        for i in 0..200 {
19495            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19496                .unwrap();
19497        }
19498        assert!(!e.tables_needing_analyze().is_empty());
19499        e.execute("ANALYZE").unwrap();
19500        assert!(
19501            e.tables_needing_analyze().is_empty(),
19502            "ANALYZE must reset the counter"
19503        );
19504    }
19505
19506    #[test]
19507    fn auto_analyze_threshold_tracks_updates_and_deletes() {
19508        let mut e = Engine::new();
19509        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
19510            .unwrap();
19511        for i in 0..50 {
19512            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'x')"))
19513                .unwrap();
19514        }
19515        e.execute("ANALYZE t").unwrap();
19516        // UPDATE 20 rows + DELETE 5 → modified=25. Threshold = 0.1
19517        // × max(50, 100) = 10. So 25 >= 10 → trigger.
19518        e.execute("UPDATE t SET label = 'y' WHERE id < 20").unwrap();
19519        e.execute("DELETE FROM t WHERE id >= 45").unwrap();
19520        assert_eq!(e.tables_needing_analyze(), alloc::vec!["t".to_string()]);
19521    }
19522
19523    #[test]
19524    fn v4_envelope_loads_with_empty_statistics() {
19525        // Forge a v4 envelope by hand: catalog + users + pubs +
19526        // subs trailer, no statistics. A v6.2.0 reader must accept
19527        // it and surface an empty Statistics.
19528        let mut e = Engine::new();
19529        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
19530            .unwrap();
19531        let catalog = e.catalog.serialize();
19532        let users = crate::users::serialize_users(&e.users);
19533        let pubs = e.publications.serialize();
19534        let subs = e.subscriptions.serialize();
19535        let mut buf = Vec::new();
19536        buf.extend_from_slice(b"SPGENV01");
19537        buf.push(4u8);
19538        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
19539        buf.extend_from_slice(&catalog);
19540        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
19541        buf.extend_from_slice(&users);
19542        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
19543        buf.extend_from_slice(&pubs);
19544        buf.extend_from_slice(&u32::try_from(subs.len()).unwrap().to_le_bytes());
19545        buf.extend_from_slice(&subs);
19546        let crc = spg_crypto::crc32::crc32(&buf);
19547        buf.extend_from_slice(&crc.to_le_bytes());
19548        let e2 = Engine::restore_envelope(&buf).expect("v4 envelope restores");
19549        assert!(e2.statistics().is_empty());
19550    }
19551
19552    #[test]
19553    fn v1_v2_envelope_loads_with_empty_publications() {
19554        // A snapshot taken before v6.1.2 (no publication trailer,
19555        // envelope v2) must still deserialise — and the resulting
19556        // engine must report zero publications. Use the engine's own
19557        // round-trip with no publications: that emits v3 but with an
19558        // empty pubs block. Then forge a v2 envelope by hand to lock
19559        // the back-compat path.
19560        let mut e = Engine::new();
19561        // Force users to be non-empty so the snapshot takes the
19562        // envelope path rather than the bare-catalog fallback.
19563        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
19564            .unwrap();
19565
19566        // Forge an envelope v2: same shape as v3 but no pubs trailer.
19567        let catalog = e.catalog.serialize();
19568        let users = crate::users::serialize_users(&e.users);
19569        let mut buf = Vec::new();
19570        buf.extend_from_slice(b"SPGENV01");
19571        buf.push(2u8); // v2
19572        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
19573        buf.extend_from_slice(&catalog);
19574        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
19575        buf.extend_from_slice(&users);
19576        let crc = spg_crypto::crc32::crc32(&buf);
19577        buf.extend_from_slice(&crc.to_le_bytes());
19578
19579        let e2 = Engine::restore_envelope(&buf).expect("v2 envelope restores");
19580        assert!(e2.publications().is_empty());
19581    }
19582}