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                        let cond = eval::eval_expr(on_expr, &combined, &ctx)?;
8388                        matches!(cond, Value::Bool(true))
8389                    } else {
8390                        true
8391                    };
8392                    if keep {
8393                        next.push(combined);
8394                        left_matched = true;
8395                    }
8396                }
8397                if !left_matched && matches!(peer.kind, JoinKind::Left) {
8398                    let mut combined_vals = left.values.clone();
8399                    for _ in 0..right_arity {
8400                        combined_vals.push(Value::Null);
8401                    }
8402                    next.push(Row::new(combined_vals));
8403                }
8404            }
8405            working = next;
8406            consumed_cols += right_arity;
8407            debug_assert!(consumed_cols <= combined_schema.len());
8408        }
8409        let mut filtered: Vec<Row> = Vec::new();
8410        // v7.24 (round-16 B) — the joined WHERE filter ran the plain
8411        // row evaluator, so a correlated EXISTS/IN/scalar subquery
8412        // under a JOIN hit "subquery reached row eval". Route through
8413        // the correlated-aware evaluator (memoized, same as the
8414        // single-table path).
8415        let mut memo = memoize::MemoizeCache::default();
8416        for row in working {
8417            if let Some(where_expr) = where_ {
8418                let cond = self.eval_expr_with_correlated(
8419                    where_expr,
8420                    &row,
8421                    &ctx,
8422                    cancel,
8423                    Some(&mut memo),
8424                )?;
8425                if !matches!(cond, Value::Bool(true)) {
8426                    continue;
8427                }
8428            }
8429            filtered.push(row);
8430        }
8431        Ok((combined_schema, filtered))
8432    }
8433
8434    /// v7.17.0 Phase 3.P0-41 — probe a LATERAL subquery's projection
8435    /// schema by running it once with a NULL-padded outer context.
8436    /// The probe never materialises real outer rows; it just executes
8437    /// the inner SELECT with `outer_alias.col` references substituted
8438    /// to NULL so the projection's type inference is exercised.
8439    fn lateral_probe_schema(
8440        &self,
8441        inner: &SelectStatement,
8442    ) -> Result<Vec<ColumnSchema>, EngineError> {
8443        // Substitute every qualified column reference whose qualifier
8444        // does NOT match an in-subquery FROM alias with NULL. The
8445        // safest probe is to walk the inner SELECT and replace any
8446        // `<qual>.<col>` whose qual isn't bound inside the subquery
8447        // with a Null literal. For the v7.17 probe we just run the
8448        // unmodified subquery and surface the columns; if it fails
8449        // (e.g. references an outer column the probe can't resolve),
8450        // we synthesise a best-effort schema from the SELECT items
8451        // by inferring a single Text-typed column per projection.
8452        match self.execute_readonly_select_for_lateral_probe(inner) {
8453            Ok(QueryResult::Rows { columns, .. }) => Ok(columns),
8454            // Best-effort fallback: each SELECT item becomes a TEXT
8455            // column. Real schemas only differ when the inner SELECT
8456            // references outer columns at projection-time; those
8457            // queries surface via the substitution path during
8458            // per-row execution and still return the right values.
8459            _ => {
8460                let mut out: Vec<ColumnSchema> = Vec::new();
8461                for (i, item) in inner.items.iter().enumerate() {
8462                    let name = match item {
8463                        SelectItem::Expr { alias: Some(a), .. } => a.clone(),
8464                        SelectItem::Expr { expr, .. } => synth_lateral_col_name(expr, i),
8465                        SelectItem::Wildcard => alloc::format!("col{i}"),
8466                    };
8467                    out.push(ColumnSchema::new(name, DataType::Text, true));
8468                }
8469                Ok(out)
8470            }
8471        }
8472    }
8473
8474    /// v7.17.0 Phase 3.P0-41 — try the inner LATERAL subquery against
8475    /// the engine in read-only mode for schema-probe purposes. Failure
8476    /// is expected when the subquery references an outer column the
8477    /// probe can't resolve; the caller falls back to a best-effort
8478    /// schema based on the SELECT items.
8479    fn execute_readonly_select_for_lateral_probe(
8480        &self,
8481        inner: &SelectStatement,
8482    ) -> Result<QueryResult, EngineError> {
8483        self.exec_bare_select_cancel(inner, CancelToken::none())
8484    }
8485
8486    /// v7.17.0 Phase 3.P0-41 — materialise a LATERAL subquery's rows
8487    /// for one outer-row context. Walks the inner SELECT, replaces
8488    /// every `<outer_alias>.<col>` reference whose alias appears in
8489    /// the outer schema with the literal value from the outer row,
8490    /// then runs the rewritten SELECT against the engine.
8491    fn materialise_lateral_for_outer(
8492        &self,
8493        inner: &SelectStatement,
8494        outer_schema: &[ColumnSchema],
8495        outer_row: &Row,
8496    ) -> Result<Vec<Row>, EngineError> {
8497        let mut substituted = inner.clone();
8498        substitute_outer_columns_multi(&mut substituted, outer_row, outer_schema);
8499        let result = self.exec_bare_select_cancel(&substituted, CancelToken::none())?;
8500        match result {
8501            QueryResult::Rows { rows, .. } => Ok(rows),
8502            _ => Err(EngineError::Unsupported(
8503                "LATERAL subquery must be a SELECT (cannot be a write statement)".into(),
8504            )),
8505        }
8506    }
8507
8508    fn exec_joined_select(
8509        &self,
8510        stmt: &SelectStatement,
8511        from: &FromClause,
8512        cancel: CancelToken<'_>,
8513    ) -> Result<QueryResult, EngineError> {
8514        // v7.17.0 Phase 3.P0-43 + P0-41 — delegate the join +
8515        // WHERE materialisation to the shared helper so the LATERAL
8516        // / UNNEST / regular-catalog paths route through one place.
8517        // (`build_joined_filtered_rows` carries LATERAL support as
8518        // of Phase 3.P0-41.) Downstream we still handle aggregate /
8519        // projection / ORDER BY / DISTINCT / LIMIT inline because
8520        // those depend on the SelectStatement's items list.
8521        let (combined_schema, filtered) =
8522            self.build_joined_filtered_rows(from, stmt.where_.as_ref(), cancel)?;
8523        let ctx = EvalContext::new(&combined_schema, None);
8524        // Aggregate path: handle GROUP BY / aggregate calls over the
8525        // joined+filtered rows.
8526        if aggregate::uses_aggregate(stmt) {
8527            let refs: Vec<&Row> = filtered.iter().collect();
8528            let mut agg = aggregate::run(stmt, &refs, &combined_schema, None)?;
8529            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
8530            return Ok(QueryResult::Rows {
8531                columns: agg.columns,
8532                rows: agg.rows,
8533            });
8534        }
8535
8536        let projection = build_projection(&stmt.items, &combined_schema, "")?;
8537        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
8538        let mut proj_memo = memoize::MemoizeCache::default();
8539        for row in &filtered {
8540            let mut values = Vec::with_capacity(projection.len());
8541            for p in &projection {
8542                // v7.24 (round-16 B) — select-list subqueries under a
8543                // JOIN go through the correlated-aware evaluator too.
8544                values.push(self.eval_expr_with_correlated(
8545                    &p.expr,
8546                    row,
8547                    &ctx,
8548                    cancel,
8549                    Some(&mut proj_memo),
8550                )?);
8551            }
8552            let order_keys = if stmt.order_by.is_empty() {
8553                Vec::new()
8554            } else {
8555                build_order_keys(&stmt.order_by, row, &ctx)?
8556            };
8557            tagged.push((order_keys, Row::new(values)));
8558        }
8559        if !stmt.order_by.is_empty() {
8560            let keep = if stmt.distinct {
8561                None
8562            } else {
8563                stmt.limit_literal()
8564                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
8565            };
8566            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
8567            partial_sort_tagged(&mut tagged, keep, &descs);
8568        }
8569        let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
8570        if stmt.distinct {
8571            output_rows = dedup_rows(output_rows);
8572        }
8573        apply_offset_and_limit(
8574            &mut output_rows,
8575            stmt.offset_literal(),
8576            stmt.limit_literal(),
8577        );
8578        let columns: Vec<ColumnSchema> = projection
8579            .into_iter()
8580            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8581            .collect();
8582        Ok(QueryResult::Rows {
8583            columns,
8584            rows: output_rows,
8585        })
8586    }
8587}
8588
8589/// One row-producing projection: an expression to evaluate, the resulting
8590/// column's user-visible name, its inferred type, and nullability.
8591#[derive(Debug, Clone)]
8592struct ProjectedItem {
8593    expr: Expr,
8594    output_name: String,
8595    ty: DataType,
8596    nullable: bool,
8597}
8598
8599/// Dedupe a row set, preserving first-seen order. `Row`'s `PartialEq` is
8600/// structural (`Vec<Value>` ⇒ pairwise `Value` equality), which gives SQL
8601/// `NULL = NULL → TRUE` and `NaN = NaN → FALSE`. The first agrees with
8602/// the spec's "two NULLs are not distinct"; the second is a tolerated
8603/// quirk for v1 (no NaN literals are reachable from the SQL surface).
8604fn dedup_rows(rows: Vec<Row>) -> Vec<Row> {
8605    let mut out: Vec<Row> = Vec::with_capacity(rows.len());
8606    for r in rows {
8607        if !out.iter().any(|seen| seen == &r) {
8608            out.push(r);
8609        }
8610    }
8611    out
8612}
8613
8614/// Coerce a `Value` to an `f64` sort key for ORDER BY. Numbers map directly;
8615/// NULL sorts last (treated as `+∞`); booleans are 0.0 / 1.0; text uses lex
8616/// order via the byte values; vectors are not sortable.
8617fn value_to_order_key(v: &Value) -> Result<f64, EngineError> {
8618    match v {
8619        Value::Null => Ok(f64::INFINITY),
8620        Value::SmallInt(n) => Ok(f64::from(*n)),
8621        Value::Int(n) => Ok(f64::from(*n)),
8622        Value::Date(d) => Ok(f64::from(*d)),
8623        #[allow(clippy::cast_precision_loss)]
8624        Value::Timestamp(t) => Ok(*t as f64),
8625        // v7.17.0 Phase 3.P0-32 — PG TIME ordered by underlying
8626        // i64 microseconds (matches wall-clock ordering).
8627        #[allow(clippy::cast_precision_loss)]
8628        Value::Time(us) => Ok(*us as f64),
8629        // v7.17.0 Phase 3.P0-33 — MySQL YEAR ordered by underlying
8630        // u16 (matches calendar ordering; zero-year sentinel
8631        // sorts before 1901).
8632        Value::Year(y) => Ok(f64::from(*y)),
8633        // v7.17.0 Phase 3.P0-34 — PG TIMETZ ordered by the
8634        // UTC-equivalent microseconds (local wall - offset). Two
8635        // values for the same physical instant in different zones
8636        // sort equal — matches PG TIMETZ index behaviour.
8637        #[allow(clippy::cast_precision_loss)]
8638        Value::TimeTz { us, offset_secs } => Ok((us - i64::from(*offset_secs) * 1_000_000) as f64),
8639        // v7.17.0 Phase 3.P0-35 — PG MONEY ordered by i64 cents.
8640        #[allow(clippy::cast_precision_loss)]
8641        Value::Money(c) => Ok(*c as f64),
8642        // v7.17.0 Phase 3.P0-38 — range ordering is not supported
8643        // in v7.17.0 (needs lex-then-inclusivity tiebreak).
8644        Value::Range { .. } => Err(EngineError::Unsupported(
8645            "ORDER BY of a range value is not supported in v7.17.0".into(),
8646        )),
8647        // v7.17.0 Phase 3.P0-39 — hstore is not orderable.
8648        Value::Hstore(_) => Err(EngineError::Unsupported(
8649            "ORDER BY of a hstore value is not supported".into(),
8650        )),
8651        // v7.17.0 Phase 3.P0-40 — 2D arrays not orderable.
8652        Value::IntArray2D(_) | Value::BigIntArray2D(_) | Value::TextArray2D(_) => Err(
8653            EngineError::Unsupported("ORDER BY of a 2D array is not supported in v7.17.0".into()),
8654        ),
8655        #[allow(clippy::cast_precision_loss)]
8656        Value::Numeric { scaled, scale } => {
8657            // Scaled integer / 10^scale, computed via f64 for sort
8658            // ordering only. Precision losses here only matter for
8659            // ORDER BY tie-breaks well past 15 significant digits.
8660            // `f64::powi` lives in std; we hand-roll the loop so the
8661            // no_std engine crate doesn't need it.
8662            let mut divisor = 1.0_f64;
8663            for _ in 0..*scale {
8664                divisor *= 10.0;
8665            }
8666            Ok((*scaled as f64) / divisor)
8667        }
8668        #[allow(clippy::cast_precision_loss)]
8669        Value::BigInt(n) => Ok(*n as f64),
8670        Value::Float(x) => Ok(*x),
8671        Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
8672        Value::Text(s) => {
8673            // Lex order by codepoints — good enough for ORDER BY name.
8674            // Map first 8 bytes packed into u64 as a coarse key; ties fall to
8675            // partial_cmp Equal. v1.x can swap in a real string comparator.
8676            let mut key: u64 = 0;
8677            for &b in s.as_bytes().iter().take(8) {
8678                key = (key << 8) | u64::from(b);
8679            }
8680            #[allow(clippy::cast_precision_loss)]
8681            Ok(key as f64)
8682        }
8683        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
8684            Err(EngineError::Unsupported(
8685                "ORDER BY of a raw vector column is not meaningful — use `<->`".into(),
8686            ))
8687        }
8688        Value::Interval { .. } => Err(EngineError::Unsupported(
8689            "ORDER BY of an INTERVAL is not supported in v2.11 \
8690             (months vs micros has no single canonical ordering)"
8691                .into(),
8692        )),
8693        Value::Json(_) => Err(EngineError::Unsupported(
8694            "ORDER BY of a JSON value is not supported — cast the document to text first".into(),
8695        )),
8696        // v7.5.0 — Value is #[non_exhaustive]; future variants need
8697        // an explicit ORDER BY mapping. Surface as Unsupported until
8698        // engine support is added.
8699        _ => Err(EngineError::Unsupported(
8700            "ORDER BY of this value type is not supported".into(),
8701        )),
8702    }
8703}
8704
8705/// Try to plan a WHERE clause as an equality lookup against an existing
8706/// index. Returns the candidate row indices on success; `None` means the
8707/// caller should fall back to a full scan.
8708///
8709/// v0.8 recognises a single top-level `col = literal` (in either operand
8710/// order). AND chains and range scans land in later milestones.
8711/// Look for `ORDER BY col <dist-op> literal LIMIT k` against an
8712/// NSW-indexed vector column. Recognised distance ops: `<->` (L2),
8713/// `<#>` (inner product), `<=>` (cosine). When a WHERE clause is
8714/// present, the planner does an "over-fetch and filter" pass — it
8715/// asks the graph for `k * over_fetch` candidates, evaluates WHERE
8716/// against each, and trims back to `k`. Returns the row indices in
8717/// ascending-distance order when the plan applies.
8718fn try_nsw_knn(
8719    stmt: &SelectStatement,
8720    table: &Table,
8721    schema_cols: &[ColumnSchema],
8722    table_alias: &str,
8723) -> Option<Vec<usize>> {
8724    if stmt.distinct {
8725        return None;
8726    }
8727    let limit = usize::try_from(stmt.limit_literal()?).ok()?;
8728    if limit == 0 {
8729        return None;
8730    }
8731    // v6.4.0 — NSW kNN dispatch needs a single ORDER BY key on the
8732    // distance metric. Multi-key ORDER BY falls through to the
8733    // generic sort path.
8734    if stmt.order_by.len() != 1 {
8735        return None;
8736    }
8737    let order = &stmt.order_by[0];
8738    // NSW kNN returns rows ascending by distance — DESC inverts the
8739    // natural order, so the planner can't handle it without a sort
8740    // pass. Fall back to the generic ORDER BY path.
8741    if order.desc {
8742        return None;
8743    }
8744    let Expr::Binary { lhs, op, rhs } = &order.expr else {
8745        return None;
8746    };
8747    let metric = match op {
8748        BinOp::L2Distance => spg_storage::NswMetric::L2,
8749        BinOp::InnerProduct => spg_storage::NswMetric::InnerProduct,
8750        BinOp::CosineDistance => spg_storage::NswMetric::Cosine,
8751        _ => return None,
8752    };
8753    // Accept both `col <op> literal` and `literal <op> col`.
8754    let ((Expr::Column(col), literal) | (literal, Expr::Column(col))) =
8755        (lhs.as_ref(), rhs.as_ref())
8756    else {
8757        return None;
8758    };
8759    if let Some(q) = &col.qualifier
8760        && q != table_alias
8761    {
8762        return None;
8763    }
8764    let col_pos = schema_cols.iter().position(|s| s.name == col.name)?;
8765    let query = literal_to_vector(literal)?;
8766    let idx = spg_storage::nsw_index_on(table, col_pos)?;
8767    if let Some(where_expr) = &stmt.where_ {
8768        // Over-fetch and filter. The factor (10×) is a heuristic that
8769        // covers typical selectivity for the corpus tests; v2.x will
8770        // make it configurable.
8771        let over_fetch = limit.saturating_mul(10).max(NSW_OVER_FETCH_FLOOR);
8772        let candidates = spg_storage::nsw_query(table, &idx.name, &query, over_fetch, metric);
8773        let ctx = EvalContext::new(schema_cols, Some(table_alias));
8774        let mut kept: Vec<usize> = Vec::with_capacity(limit);
8775        for i in candidates {
8776            let row = &table.rows()[i];
8777            let cond = eval::eval_expr(where_expr, row, &ctx).ok()?;
8778            if matches!(cond, Value::Bool(true)) {
8779                kept.push(i);
8780                if kept.len() >= limit {
8781                    break;
8782                }
8783            }
8784        }
8785        Some(kept)
8786    } else {
8787        Some(spg_storage::nsw_query(
8788            table, &idx.name, &query, limit, metric,
8789        ))
8790    }
8791}
8792
8793/// Lower bound on the over-fetch pool when WHERE is present — even
8794/// for tiny `LIMIT 1` queries we keep enough candidates to absorb a
8795/// few WHERE rejections.
8796const NSW_OVER_FETCH_FLOOR: usize = 32;
8797
8798/// Pull a `Vec<f32>` out of a literal-or-cast expression. Returns
8799/// `None` for anything we can't fold at plan time.
8800fn literal_to_vector(e: &Expr) -> Option<Vec<f32>> {
8801    match e {
8802        Expr::Literal(Literal::Vector(v)) => Some(v.clone()),
8803        Expr::Cast { expr, .. } => literal_to_vector(expr),
8804        _ => None,
8805    }
8806}
8807
8808/// Materialise rows in a planner-supplied order (used by the NSW path)
8809/// without re-running ORDER BY. The projection + LIMIT slot mirror the
8810/// equivalent block in `exec_bare_select`.
8811fn materialise_in_order(
8812    stmt: &SelectStatement,
8813    table: &Table,
8814    schema_cols: &[ColumnSchema],
8815    table_alias: &str,
8816    ordered_rows: &[usize],
8817) -> Result<QueryResult, EngineError> {
8818    let ctx = EvalContext::new(schema_cols, Some(table_alias));
8819    let projection = build_projection(&stmt.items, schema_cols, table_alias)?;
8820    let mut output_rows: Vec<Row> = Vec::with_capacity(ordered_rows.len());
8821    for &i in ordered_rows {
8822        let row = &table.rows()[i];
8823        let mut values = Vec::with_capacity(projection.len());
8824        for p in &projection {
8825            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8826        }
8827        output_rows.push(Row::new(values));
8828    }
8829    apply_offset_and_limit(
8830        &mut output_rows,
8831        stmt.offset_literal(),
8832        stmt.limit_literal(),
8833    );
8834    let columns: Vec<ColumnSchema> = projection
8835        .into_iter()
8836        .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8837        .collect();
8838    Ok(QueryResult::Rows {
8839        columns,
8840        rows: output_rows,
8841    })
8842}
8843
8844/// v7.20 P4 — hot-row POSITION seek for the mutation paths
8845/// (UPDATE / DELETE index their planned writes by position in
8846/// `table.rows()`, so the Cow-row shape `try_index_seek`
8847/// returns doesn't fit). Same top-level-AND recursion and
8848/// col=literal resolution; the caller re-applies the full WHERE
8849/// to every returned row so the index only narrows candidates.
8850///
8851/// Returns `None` (→ caller full-scans) when no equality leaf
8852/// hits an index OR any matching locator lives in the cold tier
8853/// — the mutation paths operate on hot rows, and the PK
8854/// promote-then-walk upstream already handles the
8855/// cold-single-row case.
8856fn try_index_seek_positions(
8857    where_expr: &Expr,
8858    schema_cols: &[ColumnSchema],
8859    table: &Table,
8860    table_alias: &str,
8861) -> Option<Vec<usize>> {
8862    if let Expr::Binary {
8863        lhs,
8864        op: BinOp::And,
8865        rhs,
8866    } = where_expr
8867    {
8868        if let Some(p) = try_index_seek_positions(lhs, schema_cols, table, table_alias) {
8869            return Some(p);
8870        }
8871        return try_index_seek_positions(rhs, schema_cols, table, table_alias);
8872    }
8873    let Expr::Binary {
8874        lhs,
8875        op: BinOp::Eq,
8876        rhs,
8877    } = where_expr
8878    else {
8879        return None;
8880    };
8881    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
8882        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
8883    let idx = table.index_on(col_pos)?;
8884    let key = IndexKey::from_value(&value)?;
8885    let locators = idx.lookup_eq(&key);
8886    let mut out = Vec::with_capacity(locators.len());
8887    for loc in locators {
8888        match *loc {
8889            spg_storage::RowLocator::Hot(i) => out.push(i),
8890            spg_storage::RowLocator::Cold { .. } => return None,
8891        }
8892    }
8893    Some(out)
8894}
8895
8896fn try_index_seek<'a>(
8897    where_expr: &Expr,
8898    schema_cols: &[ColumnSchema],
8899    catalog: &'a Catalog,
8900    table: &'a Table,
8901    table_alias: &str,
8902) -> Option<Vec<Cow<'a, Row>>> {
8903    // v7.11.3 — recurse through top-level `AND` so a PG-style
8904    // composite predicate like `WHERE id = 1 AND created_at > $1`
8905    // still hits the index on `id`. The caller re-applies the
8906    // full WHERE expression to each returned row, so dropping the
8907    // residual conjuncts here is correct — the index just narrows
8908    // the candidate set.
8909    if let Expr::Binary {
8910        lhs,
8911        op: BinOp::And,
8912        rhs,
8913    } = where_expr
8914    {
8915        // Try LHS first (typical convention: leading equality on
8916        // the indexed column comes first in user-written SQL).
8917        if let Some(rows) = try_index_seek(lhs, schema_cols, catalog, table, table_alias) {
8918            return Some(rows);
8919        }
8920        return try_index_seek(rhs, schema_cols, catalog, table, table_alias);
8921    }
8922    let Expr::Binary {
8923        lhs,
8924        op: BinOp::Eq,
8925        rhs,
8926    } = where_expr
8927    else {
8928        return None;
8929    };
8930    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
8931        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
8932    let idx = table.index_on(col_pos)?;
8933    let key = IndexKey::from_value(&value)?;
8934    let locators = idx.lookup_eq(&key);
8935    let table_name = table.schema().name.as_str();
8936    // v5.1: each locator dispatches to either the hot tier (zero-
8937    // copy borrow of `table.rows()[i]`) or a cold-tier segment
8938    // (one page read + dense row decode, ~µs scale). Cold rows are
8939    // returned as `Cow::Owned` so the caller's `&Row` iteration
8940    // doesn't see a tier distinction; pre-freezer (no cold
8941    // segments loaded) every locator is `Hot` and every entry is
8942    // `Cow::Borrowed` — identical cost to the pre-v5.1 path.
8943    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(locators.len());
8944    for loc in locators {
8945        match *loc {
8946            spg_storage::RowLocator::Hot(i) => {
8947                if let Some(row) = table.rows().get(i) {
8948                    out.push(Cow::Borrowed(row));
8949                }
8950            }
8951            spg_storage::RowLocator::Cold { segment_id, .. } => {
8952                if let Some(row) = catalog.resolve_cold_locator(table_name, segment_id, &key) {
8953                    out.push(Cow::Owned(row));
8954                }
8955            }
8956        }
8957    }
8958    Some(out)
8959}
8960
8961/// v7.12.3 — GIN-accelerated candidate seek for `WHERE col @@ <ts_query>`.
8962///
8963/// Recurses through top-level `AND` like [`try_index_seek`] so a
8964/// composite predicate `WHERE search_vector @@ q AND id > $1` still
8965/// hits the GIN index on `search_vector` — the caller re-applies the
8966/// full WHERE expression to each returned candidate, so dropping the
8967/// `id > $1` residual here stays semantically correct.
8968///
8969/// Returns `None` when:
8970///   - no leaf is a `col @@ <rhs>` shape on a GIN-indexed column;
8971///   - the RHS can't be const-evaluated to a `Value::TsQuery`
8972///     (typically because it references row columns);
8973///   - the resolved `TsQuery` uses query shapes the MVP doesn't
8974///     accelerate (`Not`, `Phrase` — those fall through to full scan).
8975///
8976/// On `Some(rows)` the caller iterates only `rows` and re-evaluates
8977/// the full `@@` predicate per row, so an over-approximate candidate
8978/// set is safe.
8979fn try_gin_seek<'a>(
8980    where_expr: &Expr,
8981    schema_cols: &[ColumnSchema],
8982    catalog: &'a Catalog,
8983    table: &'a Table,
8984    table_alias: &str,
8985    ctx: &eval::EvalContext<'_>,
8986) -> Option<Vec<Cow<'a, Row>>> {
8987    if let Expr::Binary {
8988        lhs,
8989        op: BinOp::And,
8990        rhs,
8991    } = where_expr
8992    {
8993        if let Some(rows) = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx) {
8994            return Some(rows);
8995        }
8996        return try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx);
8997    }
8998    // v7.17.0 Phase 3.P0-44 — MySQL `MATCH(col1, col2) AGAINST (...)`
8999    // desugars into `(to_tsvector(col1) @@ q) OR (to_tsvector(col2) @@ q)`
9000    // in the parser. To accelerate the multi-column case, walk OR the same
9001    // way we walk AND: only emit a candidate set if BOTH sides can seek
9002    // (otherwise the OR result is unbounded and we must fall through to
9003    // the full scan). Candidates are union'd; the caller's WHERE re-eval
9004    // verifies the full predicate per row, so duplicates / supersets stay
9005    // semantically safe.
9006    if let Expr::Binary {
9007        lhs,
9008        op: BinOp::Or,
9009        rhs,
9010    } = where_expr
9011    {
9012        let left = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx)?;
9013        let right = try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx)?;
9014        let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(left.len() + right.len());
9015        out.extend(left);
9016        out.extend(right);
9017        return Some(out);
9018    }
9019    let Expr::Binary {
9020        lhs,
9021        op: BinOp::TsMatch,
9022        rhs,
9023    } = where_expr
9024    else {
9025        return None;
9026    };
9027    // Either side can be the column; pgvector idiom (`vec @@ q`)
9028    // hits the first arm, FROM-clause-derived (`plainto_tsquery($1)
9029    // q ... WHERE search_vector @@ q`) the same. CROSS JOIN derived
9030    // tables resolve `q` to a Column too.
9031    let (col_pos, query) = resolve_gin_col_query(lhs, rhs, schema_cols, table_alias, ctx)
9032        .or_else(|| resolve_gin_col_query(rhs, lhs, schema_cols, table_alias, ctx))?;
9033    // v7.17.0 Phase 3.P0-44 — MySQL `FULLTEXT KEY` builds a
9034    // `IndexKind::GinFulltext` posting list (Phase 2.2). It shares
9035    // the same `gin_lookup_word` shape as the tsvector-typed GIN,
9036    // so the MATCH-AGAINST `@@` predicate (desugared by the parser
9037    // into `to_tsvector(col) @@ plainto_tsquery('term')`) routes
9038    // through the same candidate-set seek.
9039    let idx = table
9040        .indices()
9041        .iter()
9042        .find(|i| i.column_position == col_pos && (i.is_gin() || i.is_gin_fulltext()))?;
9043    let candidates = gin_query_candidates(idx, &query)?;
9044    let _ = catalog; // cold-tier row resolution unused in MVP; see below.
9045    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(candidates.len());
9046    for loc in candidates {
9047        match loc {
9048            spg_storage::RowLocator::Hot(i) => {
9049                if let Some(row) = table.rows().get(i) {
9050                    out.push(Cow::Borrowed(row));
9051                }
9052            }
9053            // GIN cold-tier rows in the MVP: skipped, matching the
9054            // full-scan `@@` path which itself only iterates
9055            // `table.rows()` (hot tier). When v7.13+ adds cold-tier
9056            // scan-time materialisation for `@@`, the parallel
9057            // resolution lands here; until then both paths see the
9058            // same hot-only candidate set so correctness is preserved.
9059            spg_storage::RowLocator::Cold { .. } => {}
9060        }
9061    }
9062    Some(out)
9063}
9064
9065/// v7.15.0 — trigram-GIN-accelerated candidate seek for
9066/// `WHERE col LIKE '<pat>'` and `WHERE col ILIKE '<pat>'` when
9067/// the column has a `gin_trgm_ops` GIN index.
9068///
9069/// Walks top-level `AND` so multi-predicate WHEREs (`col LIKE
9070/// 'foo%' AND id > 1`) still hit the trigram index; the caller
9071/// re-evaluates the full WHERE per candidate row, so dropping
9072/// non-LIKE conjuncts here stays semantically correct.
9073///
9074/// Returns `None` when:
9075///   - no leaf is `col LIKE/ILIKE <literal>` on a trigram-GIN-
9076///     indexed column;
9077///   - the pattern's literal runs are too short to constrain
9078///     (pattern decomposes into `< 3`-char runs, e.g. `%ab%`);
9079///   - the pattern doesn't const-evaluate to a TEXT.
9080fn try_trgm_seek<'a>(
9081    where_expr: &Expr,
9082    schema_cols: &[ColumnSchema],
9083    table: &'a Table,
9084    table_alias: &str,
9085) -> Option<Vec<Cow<'a, Row>>> {
9086    if let Expr::Binary {
9087        lhs,
9088        op: BinOp::And,
9089        rhs,
9090    } = where_expr
9091    {
9092        if let Some(rows) = try_trgm_seek(lhs, schema_cols, table, table_alias) {
9093            return Some(rows);
9094        }
9095        return try_trgm_seek(rhs, schema_cols, table, table_alias);
9096    }
9097    // LIKE node is what carries the column reference + pattern.
9098    // ILIKE is the same AST node — PG's LIKE/ILIKE both lower
9099    // through `Expr::Like { expr, pattern, negated }`. The trigram
9100    // index posting-list keys are already lower-cased and
9101    // case-folded, so we only need the pattern's literal runs.
9102    let Expr::Like { expr, pattern, .. } = where_expr else {
9103        return None;
9104    };
9105    // Column side.
9106    let Expr::Column(c) = expr.as_ref() else {
9107        return None;
9108    };
9109    if let Some(q) = &c.qualifier
9110        && q != table_alias
9111    {
9112        return None;
9113    }
9114    let col_pos = schema_cols
9115        .iter()
9116        .position(|s| s.name.eq_ignore_ascii_case(&c.name))?;
9117    // Index must exist on that column AND be a trigram-GIN.
9118    let idx = table
9119        .indices()
9120        .iter()
9121        .find(|i| i.column_position == col_pos && i.is_gin_trgm())?;
9122    // Pattern side must be a literal TEXT — anything else (column
9123    // ref, function call, parameter that hasn't been bound yet)
9124    // falls through to full scan.
9125    let Expr::Literal(spg_sql::ast::Literal::String(pat)) = pattern.as_ref() else {
9126        return None;
9127    };
9128    let trigrams = spg_storage::trgm::trigrams_from_like_pattern(pat)?;
9129    // Intersect every trigram's posting list. Empty intersection
9130    // → empty candidate set (caller short-circuits its row loop).
9131    let mut iter = trigrams.iter();
9132    let first = iter.next()?;
9133    let mut acc: Vec<spg_storage::RowLocator> = {
9134        let mut v = idx.gin_trgm_lookup(first).to_vec();
9135        v.sort_by_key(locator_sort_key);
9136        v.dedup_by_key(|l| locator_sort_key(l));
9137        v
9138    };
9139    for tri in iter {
9140        let mut next: Vec<spg_storage::RowLocator> = idx.gin_trgm_lookup(tri).to_vec();
9141        next.sort_by_key(locator_sort_key);
9142        next.dedup_by_key(|l| locator_sort_key(l));
9143        // Sorted-merge intersection.
9144        let mut merged: Vec<spg_storage::RowLocator> =
9145            Vec::with_capacity(acc.len().min(next.len()));
9146        let (mut i, mut j) = (0usize, 0usize);
9147        while i < acc.len() && j < next.len() {
9148            let lk = locator_sort_key(&acc[i]);
9149            let rk = locator_sort_key(&next[j]);
9150            match lk.cmp(&rk) {
9151                core::cmp::Ordering::Less => i += 1,
9152                core::cmp::Ordering::Greater => j += 1,
9153                core::cmp::Ordering::Equal => {
9154                    merged.push(acc[i]);
9155                    i += 1;
9156                    j += 1;
9157                }
9158            }
9159        }
9160        acc = merged;
9161        if acc.is_empty() {
9162            break;
9163        }
9164    }
9165    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(acc.len());
9166    for loc in acc {
9167        if let spg_storage::RowLocator::Hot(i) = loc
9168            && let Some(row) = table.rows().get(i)
9169        {
9170            out.push(Cow::Borrowed(row));
9171        }
9172        // Cold-tier rows: skipped in MVP (same as try_gin_seek).
9173    }
9174    Some(out)
9175}
9176
9177/// v7.12.3 — extract `(column_position, TsQueryAst)` when one side of
9178/// the binary is a column reference to a GIN-indexed tsvector column
9179/// and the other side const-evaluates to a `Value::TsQuery`. Returns
9180/// `None` if the column reference is for the wrong table alias, or if
9181/// the RHS expression depends on row data.
9182fn resolve_gin_col_query(
9183    col_side: &Expr,
9184    query_side: &Expr,
9185    schema_cols: &[ColumnSchema],
9186    table_alias: &str,
9187    ctx: &eval::EvalContext<'_>,
9188) -> Option<(usize, spg_storage::TsQueryAst)> {
9189    // v7.17.0 Phase 3.P0-44 — the MATCH AGAINST desugar wraps the
9190    // column in `to_tsvector('simple', col)`, so we peel that wrapper
9191    // before the column lookup. Direct `col @@ tsquery` paths (the
9192    // tsvector-typed v7.12 surface) skip the wrapper entirely.
9193    let column = match col_side {
9194        Expr::Column(c) => c,
9195        Expr::FunctionCall { name, args }
9196            if name.eq_ignore_ascii_case("to_tsvector") && !args.is_empty() =>
9197        {
9198            // PG `to_tsvector` accepts either `to_tsvector(col)` or
9199            // `to_tsvector(config, col)`. In both shapes the column
9200            // we care about is the final argument.
9201            if let Expr::Column(c) = args.last().unwrap() {
9202                c
9203            } else {
9204                return None;
9205            }
9206        }
9207        _ => return None,
9208    };
9209    let c = column;
9210    if let Some(q) = &c.qualifier
9211        && q != table_alias
9212    {
9213        return None;
9214    }
9215    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
9216    // Const-evaluate the query side with an empty row — fails fast
9217    // (with a `ColumnNotFound` / similar) if the expression actually
9218    // depends on row data, which is exactly the bail signal we want.
9219    let empty_row = Row::new(Vec::new());
9220    let v = eval::eval_expr(query_side, &empty_row, ctx).ok()?;
9221    let Value::TsQuery(q) = v else { return None };
9222    Some((pos, q))
9223}
9224
9225/// v7.12.3 — walk a `TsQueryAst` against an [`IndexKind::Gin`] index
9226/// to produce a candidate row-locator set. Returns `None` for query
9227/// shapes the MVP doesn't accelerate (`Not` / `Phrase` — both bail to
9228/// full scan since their semantics need either complementation across
9229/// the whole row set or positional verification beyond what the
9230/// posting list carries).
9231///
9232/// Candidate sets are over-approximate — the caller re-applies the
9233/// full `@@` predicate per row, so reporting "row was in some
9234/// posting list" without verifying positions / weights stays correct.
9235fn gin_query_candidates(
9236    idx: &spg_storage::Index,
9237    query: &spg_storage::TsQueryAst,
9238) -> Option<Vec<spg_storage::RowLocator>> {
9239    use spg_storage::TsQueryAst;
9240    match query {
9241        TsQueryAst::Term { word, .. } => {
9242            let mut v: Vec<spg_storage::RowLocator> = idx.gin_lookup_word(word).to_vec();
9243            v.sort_by_key(locator_sort_key);
9244            v.dedup_by_key(|l| locator_sort_key(l));
9245            Some(v)
9246        }
9247        TsQueryAst::And(l, r) => {
9248            let mut left = gin_query_candidates(idx, l)?;
9249            let mut right = gin_query_candidates(idx, r)?;
9250            left.sort_by_key(locator_sort_key);
9251            right.sort_by_key(locator_sort_key);
9252            // Sorted-merge intersection.
9253            let mut out: Vec<spg_storage::RowLocator> = Vec::new();
9254            let (mut i, mut j) = (0usize, 0usize);
9255            while i < left.len() && j < right.len() {
9256                let lk = locator_sort_key(&left[i]);
9257                let rk = locator_sort_key(&right[j]);
9258                match lk.cmp(&rk) {
9259                    core::cmp::Ordering::Less => i += 1,
9260                    core::cmp::Ordering::Greater => j += 1,
9261                    core::cmp::Ordering::Equal => {
9262                        out.push(left[i]);
9263                        i += 1;
9264                        j += 1;
9265                    }
9266                }
9267            }
9268            Some(out)
9269        }
9270        TsQueryAst::Or(l, r) => {
9271            let mut out = gin_query_candidates(idx, l)?;
9272            out.extend(gin_query_candidates(idx, r)?);
9273            out.sort_by_key(locator_sort_key);
9274            out.dedup_by_key(|l| locator_sort_key(l));
9275            Some(out)
9276        }
9277        // Not / Phrase bail to full scan in the MVP. Not needs
9278        // complementation against the whole row set (not represented
9279        // in the posting-list view); Phrase needs positional
9280        // verification beyond what `word → rows` carries.
9281        TsQueryAst::Not(_) | TsQueryAst::Phrase { .. } => None,
9282    }
9283}
9284
9285/// v7.12.3 — total ordering on `RowLocator` for sort/dedup purposes
9286/// inside the GIN intersection / union loops. Hot rows order by their
9287/// row index; Cold rows order after all Hot rows, then by
9288/// `(segment_id, the cold sub-key)`.
9289fn locator_sort_key(l: &spg_storage::RowLocator) -> (u8, u64, u64) {
9290    match *l {
9291        spg_storage::RowLocator::Hot(i) => (0, i as u64, 0),
9292        spg_storage::RowLocator::Cold {
9293            segment_id,
9294            page_offset,
9295        } => (1, u64::from(segment_id), u64::from(page_offset)),
9296    }
9297}
9298
9299/// v5.2.3: extract `(column_position, IndexKey)` when `where_expr`
9300/// is a simple `col = literal` predicate suitable for a `BTree` index
9301/// seek. Used by `exec_update_cancel` / `exec_delete_cancel` to
9302/// decide whether a write touches a cold-tier row (which requires
9303/// promote-on-write / shadow-on-delete) before falling through to
9304/// the hot-tier row walk.
9305///
9306/// Returns `None` for any predicate shape the planner can't push
9307/// down to an index seek — complex WHERE clauses always take the
9308/// hot-only path (cold rows are immutable to non-indexed writes
9309/// until a future scan-fanout sub-version).
9310fn try_pk_predicate(
9311    where_expr: &Expr,
9312    schema_cols: &[ColumnSchema],
9313    table_alias: &str,
9314) -> Option<(usize, IndexKey)> {
9315    let Expr::Binary {
9316        lhs,
9317        op: BinOp::Eq,
9318        rhs,
9319    } = where_expr
9320    else {
9321        return None;
9322    };
9323    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
9324        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
9325    let key = IndexKey::from_value(&value)?;
9326    Some((col_pos, key))
9327}
9328
9329fn resolve_col_literal_pair(
9330    col_side: &Expr,
9331    lit_side: &Expr,
9332    schema_cols: &[ColumnSchema],
9333    table_alias: &str,
9334) -> Option<(usize, Value)> {
9335    let Expr::Column(c) = col_side else {
9336        return None;
9337    };
9338    if let Some(q) = &c.qualifier
9339        && q != table_alias
9340    {
9341        return None;
9342    }
9343    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
9344    let Expr::Literal(l) = lit_side else {
9345        return None;
9346    };
9347    let v = match l {
9348        Literal::Integer(n) => {
9349            if let Ok(small) = i32::try_from(*n) {
9350                Value::Int(small)
9351            } else {
9352                Value::BigInt(*n)
9353            }
9354        }
9355        Literal::Float(x) => Value::Float(*x),
9356        Literal::String(s) => Value::Text(s.clone()),
9357        Literal::Bool(b) => Value::Bool(*b),
9358        Literal::Null => Value::Null,
9359        // Vector, array and Interval literals can't be used as B-tree
9360        // index keys. Tell the planner to fall back to full-scan.
9361        Literal::Vector(_)
9362        | Literal::Interval { .. }
9363        | Literal::TextArray(_)
9364        | Literal::IntArray(_)
9365        | Literal::BigIntArray(_) => return None,
9366    };
9367    Some((pos, v))
9368}
9369
9370/// Find the schema entry that a SELECT-list `Expr::Column` refers to.
9371/// Mirrors `resolve_column` in `eval.rs`, but returns a proper
9372/// `EngineError` so the projection-build path keeps `UnknownQualifier`
9373/// vs `ColumnNotFound` distinct.
9374fn resolve_projection_column<'a>(
9375    c: &ColumnName,
9376    schema_cols: &'a [ColumnSchema],
9377    table_alias: &str,
9378) -> Result<&'a ColumnSchema, EngineError> {
9379    if let Some(q) = &c.qualifier {
9380        let composite = alloc::format!("{q}.{name}", name = c.name);
9381        if let Some(s) = schema_cols.iter().find(|s| s.name == composite) {
9382            return Ok(s);
9383        }
9384        // Single-table case: the qualifier may equal the active alias —
9385        // then look for the bare column name.
9386        if q == table_alias
9387            && let Some(s) = schema_cols.iter().find(|s| s.name == c.name)
9388        {
9389            return Ok(s);
9390        }
9391        // For multi-table schemas the qualifier is unknown only if no
9392        // column bears the "<q>." prefix. For single-table, the alias
9393        // mismatch alone is enough.
9394        let prefix = alloc::format!("{q}.");
9395        let qualifier_known =
9396            q == table_alias || schema_cols.iter().any(|s| s.name.starts_with(&prefix));
9397        if !qualifier_known {
9398            return Err(EngineError::Eval(EvalError::UnknownQualifier {
9399                qualifier: q.clone(),
9400            }));
9401        }
9402        return Err(EngineError::Eval(EvalError::ColumnNotFound {
9403            name: c.name.clone(),
9404        }));
9405    }
9406    if let Some(s) = schema_cols.iter().find(|s| s.name == c.name) {
9407        return Ok(s);
9408    }
9409    let suffix = alloc::format!(".{name}", name = c.name);
9410    let mut matches = schema_cols.iter().filter(|s| s.name.ends_with(&suffix));
9411    let first = matches.next();
9412    let extra = matches.next();
9413    match (first, extra) {
9414        (Some(s), None) => Ok(s),
9415        (Some(_), Some(_)) => Err(EngineError::Eval(EvalError::TypeMismatch {
9416            detail: alloc::format!("ambiguous column reference: {}", c.name),
9417        })),
9418        _ => Err(EngineError::Eval(EvalError::ColumnNotFound {
9419            name: c.name.clone(),
9420        })),
9421    }
9422}
9423
9424fn build_projection(
9425    items: &[SelectItem],
9426    schema_cols: &[ColumnSchema],
9427    table_alias: &str,
9428) -> Result<Vec<ProjectedItem>, EngineError> {
9429    let mut out = Vec::new();
9430    for item in items {
9431        match item {
9432            SelectItem::Wildcard => {
9433                for col in schema_cols {
9434                    out.push(ProjectedItem {
9435                        expr: Expr::Column(ColumnName {
9436                            qualifier: None,
9437                            name: col.name.clone(),
9438                        }),
9439                        output_name: col.name.clone(),
9440                        ty: col.ty,
9441                        nullable: col.nullable,
9442                    });
9443                }
9444            }
9445            SelectItem::Expr { expr, alias } => {
9446                // Plain column ref keeps full schema info (real type +
9447                // nullability). For compound expressions try the
9448                // describe-side function-return-type table first
9449                // (e.g. `SELECT now()` → Timestamptz, `SELECT
9450                // concat(…)` → Text). Falls back to nullable Text
9451                // for shapes the describe path can't resolve.
9452                if let Expr::Column(c) = expr {
9453                    let sch = resolve_projection_column(c, schema_cols, table_alias)?;
9454                    let output_name = alias.clone().unwrap_or_else(|| c.name.clone());
9455                    out.push(ProjectedItem {
9456                        expr: expr.clone(),
9457                        output_name,
9458                        ty: sch.ty,
9459                        nullable: sch.nullable,
9460                    });
9461                } else if let Some(shape) = describe::describe_expr(expr, schema_cols) {
9462                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
9463                    out.push(ProjectedItem {
9464                        expr: expr.clone(),
9465                        output_name,
9466                        ty: shape.ty,
9467                        nullable: shape.nullable,
9468                    });
9469                } else {
9470                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
9471                    out.push(ProjectedItem {
9472                        expr: expr.clone(),
9473                        output_name,
9474                        ty: DataType::Text,
9475                        nullable: true,
9476                    });
9477                }
9478            }
9479        }
9480    }
9481    Ok(out)
9482}
9483
9484/// Promote an integer to a NUMERIC value at the requested scale.
9485/// Rejects values that, after scaling, would overflow the column's
9486/// precision budget.
9487fn numeric_from_integer(
9488    n: i128,
9489    precision: u8,
9490    scale: u8,
9491    col_name: &str,
9492) -> Result<Value, EngineError> {
9493    let factor = pow10_i128(scale);
9494    let scaled = n.checked_mul(factor).ok_or_else(|| {
9495        EngineError::Unsupported(alloc::format!(
9496            "integer overflow scaling value for column `{col_name}` to scale {scale}"
9497        ))
9498    })?;
9499    check_precision(scaled, precision, col_name)?;
9500    Ok(Value::Numeric { scaled, scale })
9501}
9502
9503/// Float → NUMERIC. Uses round-half-away-from-zero on `x * 10^scale`,
9504/// then verifies the result fits the column's precision.
9505#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
9506fn numeric_from_float(
9507    x: f64,
9508    precision: u8,
9509    scale: u8,
9510    col_name: &str,
9511) -> Result<Value, EngineError> {
9512    if !x.is_finite() {
9513        return Err(EngineError::Unsupported(alloc::format!(
9514            "cannot store non-finite float in NUMERIC column `{col_name}`"
9515        )));
9516    }
9517    let mut factor = 1.0_f64;
9518    for _ in 0..scale {
9519        factor *= 10.0;
9520    }
9521    // Round half-away-from-zero by biasing then casting (`as i128`
9522    // truncates toward zero, so the bias + truncation gives the
9523    // desired rounding). `f64::floor` / `ceil` live in std; we don't
9524    // need them — the cast handles the truncation step.
9525    let shifted = x * factor;
9526    let biased = if shifted >= 0.0 {
9527        shifted + 0.5
9528    } else {
9529        shifted - 0.5
9530    };
9531    // Range-check before casting back to i128 — the cast itself is
9532    // saturating in Rust, which would silently truncate huge inputs.
9533    if !(-1e38..=1e38).contains(&biased) {
9534        return Err(EngineError::Unsupported(alloc::format!(
9535            "value {x} overflows NUMERIC range for column `{col_name}`"
9536        )));
9537    }
9538    let scaled = biased as i128;
9539    check_precision(scaled, precision, col_name)?;
9540    Ok(Value::Numeric { scaled, scale })
9541}
9542
9543/// v7.17.0 Phase 3.P0-67 — parse PG-canonical decimal text into
9544/// `(mantissa: i128, source_scale: u8)`. Accepts optional sign,
9545/// optional integer part, optional fractional part. Rejects
9546/// scientific notation, embedded spaces, locale-specific
9547/// thousand separators. Returns None on bad input — coerce_value
9548/// turns that into a TypeMismatch error.
9549fn parse_numeric_text(s: &str) -> Option<(i128, u8)> {
9550    let s = s.trim();
9551    if s.is_empty() {
9552        return None;
9553    }
9554    let (negative, rest) = match s.as_bytes()[0] {
9555        b'-' => (true, &s[1..]),
9556        b'+' => (false, &s[1..]),
9557        _ => (false, s),
9558    };
9559    if rest.is_empty() {
9560        return None;
9561    }
9562    // Reject scientific notation — bigdecimal collapses it before
9563    // hitting the wire, and we want a clear error if a stray `e`
9564    // sneaks in.
9565    if rest.bytes().any(|b| b == b'e' || b == b'E') {
9566        return None;
9567    }
9568    let (int_part, frac_part) = match rest.find('.') {
9569        Some(idx) => (&rest[..idx], &rest[idx + 1..]),
9570        None => (rest, ""),
9571    };
9572    if int_part.is_empty() && frac_part.is_empty() {
9573        return None;
9574    }
9575    if int_part.bytes().any(|b| !b.is_ascii_digit()) {
9576        return None;
9577    }
9578    if frac_part.bytes().any(|b| !b.is_ascii_digit()) {
9579        return None;
9580    }
9581    let scale_u32 = u32::try_from(frac_part.len()).ok()?;
9582    if scale_u32 > u32::from(u8::MAX) {
9583        return None;
9584    }
9585    let scale = scale_u32 as u8;
9586    let mut digits = alloc::string::String::with_capacity(int_part.len() + frac_part.len() + 1);
9587    if negative {
9588        digits.push('-');
9589    }
9590    digits.push_str(int_part);
9591    digits.push_str(frac_part);
9592    // Strip a leading "+0..0" so parse doesn't choke on "00" etc.
9593    let digits = if digits == "-" {
9594        return None;
9595    } else if digits.is_empty() {
9596        "0"
9597    } else {
9598        digits.as_str()
9599    };
9600    let mantissa: i128 = digits.parse().ok()?;
9601    Some((mantissa, scale))
9602}
9603
9604/// Move a Numeric value from `src_scale` to `dst_scale`. Going up
9605/// multiplies by 10; going down rounds half-away-from-zero.
9606fn numeric_rescale(
9607    scaled: i128,
9608    src_scale: u8,
9609    precision: u8,
9610    dst_scale: u8,
9611    col_name: &str,
9612) -> Result<Value, EngineError> {
9613    let new_scaled = if dst_scale >= src_scale {
9614        let bump = pow10_i128(dst_scale - src_scale);
9615        scaled.checked_mul(bump).ok_or_else(|| {
9616            EngineError::Unsupported(alloc::format!(
9617                "overflow rescaling NUMERIC for column `{col_name}`"
9618            ))
9619        })?
9620    } else {
9621        let drop = pow10_i128(src_scale - dst_scale);
9622        let half = drop / 2;
9623        if scaled >= 0 {
9624            (scaled + half) / drop
9625        } else {
9626            (scaled - half) / drop
9627        }
9628    };
9629    check_precision(new_scaled, precision, col_name)?;
9630    Ok(Value::Numeric {
9631        scaled: new_scaled,
9632        scale: dst_scale,
9633    })
9634}
9635
9636/// Drop the fractional part of a scaled integer, returning the integer
9637/// portion (toward zero). Used for NUMERIC → INT casts.
9638const fn numeric_truncate_to_integer(scaled: i128, scale: u8) -> i128 {
9639    if scale == 0 {
9640        return scaled;
9641    }
9642    let factor = pow10_i128_const(scale);
9643    scaled / factor
9644}
9645
9646/// Verify a scaled NUMERIC value fits the column's declared precision.
9647/// `precision == 0` is the "unconstrained" form (bare `NUMERIC`); we
9648/// skip the check there.
9649fn check_precision(scaled: i128, precision: u8, col_name: &str) -> Result<(), EngineError> {
9650    if precision == 0 {
9651        return Ok(());
9652    }
9653    let limit = pow10_i128(precision);
9654    if scaled.unsigned_abs() >= limit.unsigned_abs() {
9655        return Err(EngineError::Unsupported(alloc::format!(
9656            "NUMERIC value exceeds precision {precision} for column `{col_name}`"
9657        )));
9658    }
9659    Ok(())
9660}
9661
9662const fn pow10_i128_const(p: u8) -> i128 {
9663    let mut acc: i128 = 1;
9664    let mut i = 0;
9665    while i < p {
9666        acc *= 10;
9667        i += 1;
9668    }
9669    acc
9670}
9671
9672fn pow10_i128(p: u8) -> i128 {
9673    pow10_i128_const(p)
9674}
9675
9676/// Walk a parsed `Statement`, swapping any `NOW()` /
9677/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()` function calls for a
9678/// literal cast that wraps the engine's per-statement clock reading.
9679/// When `now_micros` is `None`, calls stay as-is and surface as
9680/// `unknown function` at eval time — keeps the error path explicit.
9681/// v4.10: pre-walk the WHERE / projection / etc. of a SELECT and
9682/// replace every subquery node with a materialised literal. SPG
9683/// only supports uncorrelated subqueries — the inner SELECT does
9684/// not see outer-row columns, so the result is the same for every
9685/// outer row and can be evaluated once.
9686///
9687/// Returns the rewritten statement; the caller passes this to the
9688/// regular row-loop executor which no longer sees Subquery nodes
9689/// in its tree.
9690impl Engine {
9691    /// v4.12 window executor. Implements `ROW_NUMBER` / `RANK` /
9692    /// `DENSE_RANK` and the partition-aware aggregates `SUM` /
9693    /// `AVG` / `COUNT` / `MIN` / `MAX`. The plan is:
9694    /// 1. Apply the WHERE filter.
9695    /// 2. For each unique `WindowFunction` node in the projection,
9696    ///    partition + sort, compute the per-row value.
9697    /// 3. Append the window values as synthetic columns (`__win_N`)
9698    ///    to the row schema.
9699    /// 4. Rewrite the projection to read those columns.
9700    /// 5. Hand off to the regular project / ORDER BY / LIMIT pipe.
9701    #[allow(
9702        clippy::too_many_lines,
9703        clippy::type_complexity,
9704        clippy::needless_range_loop
9705    )] // window-eval is one cohesive pipe; splitting fragments
9706    fn exec_select_with_window(
9707        &self,
9708        stmt: &SelectStatement,
9709        cancel: CancelToken<'_>,
9710    ) -> Result<QueryResult, EngineError> {
9711        let from = stmt.from.as_ref().ok_or_else(|| {
9712            EngineError::Unsupported("window functions require a FROM clause".into())
9713        })?;
9714        // v7.17.0 Phase 3.P0-43 — JOIN + window functions. Phase
9715        // 3.6 rejected this combination outright ("queued for
9716        // v5.x"); P0-43 materialises the join + WHERE through the
9717        // existing nested-loop helper and runs the window pipeline
9718        // on the joined row set with the combined `alias.col`
9719        // schema. The window expressions resolve through the
9720        // qualifier-aware column resolver same as the aggregate /
9721        // projection paths on JOIN.
9722        let (schema_cols_owned, alias_opt): (Vec<ColumnSchema>, Option<&str>);
9723        let filtered: Vec<Row>;
9724        if from.joins.is_empty() {
9725            let primary = &from.primary;
9726            let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
9727                StorageError::TableNotFound {
9728                    name: primary.name.clone(),
9729                }
9730            })?;
9731            let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
9732            schema_cols_owned = table.schema().columns.clone();
9733            alias_opt = Some(alias);
9734            // Materialise WHERE-filtered rows owned so the JOIN
9735            // and single-table paths share a single downstream
9736            // shape. The clone is cheap relative to the window
9737            // computation that follows.
9738            let ctx = self.ev_ctx(&schema_cols_owned, alias_opt);
9739            let mut owned: Vec<Row> = Vec::new();
9740            for (i, row) in table.rows().iter().enumerate() {
9741                if i.is_multiple_of(256) {
9742                    cancel.check()?;
9743                }
9744                if let Some(w) = &stmt.where_ {
9745                    let cond = eval::eval_expr(w, row, &ctx)?;
9746                    if !matches!(cond, Value::Bool(true)) {
9747                        continue;
9748                    }
9749                }
9750                owned.push(row.clone());
9751            }
9752            filtered = owned;
9753        } else {
9754            let (combined_schema, rows) =
9755                self.build_joined_filtered_rows(from, stmt.where_.as_ref(), cancel)?;
9756            schema_cols_owned = combined_schema;
9757            alias_opt = None;
9758            filtered = rows;
9759        }
9760        let schema_cols = &schema_cols_owned;
9761        let ctx = self.ev_ctx(schema_cols, alias_opt);
9762        let alias = alias_opt.unwrap_or("");
9763        let n_rows = filtered.len();
9764        // Borrow refs into the owned row vec once so the downstream
9765        // `compute_window_partition` call (which takes `&[&Row]`) and
9766        // the per-row eval loops share a single backing buffer.
9767        let filtered_refs: Vec<&Row> = filtered.iter().collect();
9768
9769        // 2) Collect unique window function nodes from projection.
9770        let mut window_nodes: Vec<Expr> = Vec::new();
9771        for item in &stmt.items {
9772            if let SelectItem::Expr { expr, .. } = item {
9773                collect_window_nodes(expr, &mut window_nodes);
9774            }
9775        }
9776
9777        // 3) For each window, compute per-row value.
9778        // Index: same order as window_nodes; for row i, win_vals[w][i].
9779        let mut win_vals: Vec<Vec<Value>> = Vec::with_capacity(window_nodes.len());
9780        for wnode in &window_nodes {
9781            let Expr::WindowFunction {
9782                name,
9783                args,
9784                partition_by,
9785                order_by,
9786                frame,
9787                null_treatment,
9788            } = wnode
9789            else {
9790                unreachable!("collect_window_nodes pushes only WindowFunction");
9791            };
9792            // Compute (partition_key, order_key, original_index) for each row.
9793            let mut indexed: Vec<(Vec<Value>, Vec<(Value, bool)>, usize)> =
9794                Vec::with_capacity(n_rows);
9795            for (i, row) in filtered.iter().enumerate() {
9796                let pkey: Vec<Value> = partition_by
9797                    .iter()
9798                    .map(|p| eval::eval_expr(p, row, &ctx))
9799                    .collect::<Result<_, _>>()?;
9800                let okey: Vec<(Value, bool)> = order_by
9801                    .iter()
9802                    .map(|(e, desc)| eval::eval_expr(e, row, &ctx).map(|v| (v, *desc)))
9803                    .collect::<Result<_, _>>()?;
9804                indexed.push((pkey, okey, i));
9805            }
9806            // Sort by (partition_key, order_key). Partition key uses
9807            // a stable encoded form; order key respects ASC/DESC.
9808            indexed.sort_by(|a, b| {
9809                let p_cmp = partition_key_cmp(&a.0, &b.0);
9810                if p_cmp != core::cmp::Ordering::Equal {
9811                    return p_cmp;
9812                }
9813                order_key_cmp(&a.1, &b.1)
9814            });
9815            // Per-partition compute.
9816            let mut out_vals: Vec<Value> = alloc::vec![Value::Null; n_rows];
9817            let mut p_start = 0;
9818            while p_start < indexed.len() {
9819                let mut p_end = p_start + 1;
9820                while p_end < indexed.len()
9821                    && partition_key_cmp(&indexed[p_start].0, &indexed[p_end].0)
9822                        == core::cmp::Ordering::Equal
9823                {
9824                    p_end += 1;
9825                }
9826                // Compute the function within this partition slice.
9827                compute_window_partition(
9828                    name,
9829                    args,
9830                    !order_by.is_empty(),
9831                    frame.as_ref(),
9832                    *null_treatment,
9833                    &indexed[p_start..p_end],
9834                    &filtered_refs,
9835                    &ctx,
9836                    &mut out_vals,
9837                )?;
9838                p_start = p_end;
9839            }
9840            win_vals.push(out_vals);
9841        }
9842
9843        // 4) Build extended schema: original columns + synthetic.
9844        let mut ext_cols = schema_cols.clone();
9845        for i in 0..window_nodes.len() {
9846            ext_cols.push(ColumnSchema::new(
9847                alloc::format!("__win_{i}"),
9848                DataType::Text, // type doesn't matter for projection eval
9849                true,
9850            ));
9851        }
9852        // 5) Build extended rows: each row gets its window values appended.
9853        let mut ext_rows: Vec<Row> = Vec::with_capacity(n_rows);
9854        for i in 0..n_rows {
9855            let mut values = filtered[i].values.clone();
9856            for w in 0..window_nodes.len() {
9857                values.push(win_vals[w][i].clone());
9858            }
9859            ext_rows.push(Row::new(values));
9860        }
9861        // 6) Rewrite the projection: WindowFunction nodes → Column(__win_N).
9862        let mut rewritten_items: Vec<SelectItem> = Vec::with_capacity(stmt.items.len());
9863        for item in &stmt.items {
9864            let new_item = match item {
9865                SelectItem::Wildcard => SelectItem::Wildcard,
9866                SelectItem::Expr { expr, alias } => {
9867                    let mut e = expr.clone();
9868                    rewrite_window_to_columns(&mut e, &window_nodes);
9869                    SelectItem::Expr {
9870                        expr: e,
9871                        alias: alias.clone(),
9872                    }
9873                }
9874            };
9875            rewritten_items.push(new_item);
9876        }
9877
9878        // 7) Project into final rows. JOIN case uses None so the
9879        // qualifier check in `resolve_column` falls through to the
9880        // composite `alias.col` schema lookup; single-table case
9881        // keeps the bare alias so `bare_col` resolution still
9882        // works for the projection's per-row column references.
9883        let ext_ctx = EvalContext::new(&ext_cols, alias_opt);
9884        let projection = build_projection(&rewritten_items, &ext_cols, alias)?;
9885        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(n_rows);
9886        for (i, row) in ext_rows.iter().enumerate() {
9887            if i.is_multiple_of(256) {
9888                cancel.check()?;
9889            }
9890            let mut values = Vec::with_capacity(projection.len());
9891            for p in &projection {
9892                values.push(eval::eval_expr(&p.expr, row, &ext_ctx)?);
9893            }
9894            let order_keys = if stmt.order_by.is_empty() {
9895                Vec::new()
9896            } else {
9897                let mut keys = Vec::with_capacity(stmt.order_by.len());
9898                for o in &stmt.order_by {
9899                    let mut e = o.expr.clone();
9900                    rewrite_window_to_columns(&mut e, &window_nodes);
9901                    let key = eval::eval_expr(&e, row, &ext_ctx)?;
9902                    keys.push(value_to_order_key(&key)?);
9903                }
9904                keys
9905            };
9906            tagged.push((order_keys, Row::new(values)));
9907        }
9908        // ORDER BY + LIMIT/OFFSET on the projected rows.
9909        if !stmt.order_by.is_empty() {
9910            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
9911            sort_by_keys(&mut tagged, &descs);
9912        }
9913        let mut out_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
9914        apply_offset_and_limit(&mut out_rows, stmt.offset_literal(), stmt.limit_literal());
9915        let final_cols: Vec<ColumnSchema> = projection
9916            .into_iter()
9917            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
9918            .collect();
9919        Ok(QueryResult::Rows {
9920            columns: final_cols,
9921            rows: out_rows,
9922        })
9923    }
9924
9925    /// v4.11: materialise each CTE into a temp table inside a
9926    /// cloned catalog, then run the body SELECT against a fresh
9927    /// engine instance that owns the enriched catalog. The clone
9928    /// is moderately expensive — only paid by CTE-bearing queries.
9929    /// Subqueries inside CTE bodies / the main body resolve as
9930    /// usual; `clock_fn` is propagated so `NOW()` lines up.
9931    /// v7.16.2 — mailrs round-10 A.3. Materialise the
9932    /// `information_schema.*` / `pg_catalog.*` virtual views
9933    /// the SELECT references, then re-execute the SELECT
9934    /// against an enriched catalog where those views are real
9935    /// tables. Same pattern as `exec_with_ctes`. The temp
9936    /// engine carries `meta_views_materialised = true` so its
9937    /// own meta-dispatch short-circuits — without that we'd
9938    /// infinite-recurse since the temp catalog's view name
9939    /// still starts with `__spg_info_` and re-triggers the
9940    /// check.
9941    fn exec_select_with_meta_views(
9942        &self,
9943        stmt: &SelectStatement,
9944        cancel: CancelToken<'_>,
9945    ) -> Result<QueryResult, EngineError> {
9946        let mut needed: alloc::collections::BTreeSet<String> = alloc::collections::BTreeSet::new();
9947        collect_meta_view_names(stmt, &mut needed);
9948        let mut catalog = self.active_catalog().clone();
9949        for view in &needed {
9950            if catalog.get(view).is_some() {
9951                continue;
9952            }
9953            match view.as_str() {
9954                "__spg_info_columns" => {
9955                    let (schema, rows) = synth_information_schema_columns(self.active_catalog());
9956                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9957                }
9958                "__spg_info_tables" => {
9959                    let (schema, rows) = synth_information_schema_tables(self.active_catalog());
9960                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9961                }
9962                "__spg_pg_class" => {
9963                    let (schema, rows) = synth_pg_class(self.active_catalog());
9964                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9965                }
9966                "__spg_pg_attribute" => {
9967                    let (schema, rows) = synth_pg_attribute(self.active_catalog());
9968                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9969                }
9970                // v7.17.0 Phase 3.P0-50 — pg_catalog.pg_type for
9971                // sqlx / SQLAlchemy / Diesel / pgAdmin lookups.
9972                "__spg_pg_type" => {
9973                    let (schema, rows) = synth_pg_type(self.active_catalog());
9974                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9975                }
9976                // v7.17.0 Phase 3.P0-51 — pg_catalog.pg_proc for
9977                // function-name introspection (ORM / pgAdmin).
9978                "__spg_pg_proc" => {
9979                    let (schema, rows) = synth_pg_proc(self.active_catalog());
9980                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9981                }
9982                // v7.24 (round-16 D) — pg_catalog.pg_trigger. The
9983                // round-16 "why doesn't prod fire the trigger"
9984                // question was unanswerable because triggers had NO
9985                // introspection surface; tgname/tgenabled plus the
9986                // pragmatic relname/timing/events/function columns
9987                // make "is it registered and enabled" a one-liner.
9988                "__spg_pg_trigger" => {
9989                    let (schema, rows) = synth_pg_trigger(self.active_catalog());
9990                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9991                }
9992                // v7.17.0 Phase 3.P0-52 — pg_catalog.pg_namespace
9993                // (schema list for admin tools' tree views).
9994                "__spg_pg_namespace" => {
9995                    let (schema, rows) = synth_pg_namespace(self.active_catalog());
9996                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9997                }
9998                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_indexes view
9999                // for pgAdmin / DataGrip "indexes per table" listings.
10000                "__spg_pg_indexes" => {
10001                    let (schema, rows) = synth_pg_indexes(self.active_catalog());
10002                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10003                }
10004                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_index (raw)
10005                // for index introspection by ORM compilers.
10006                "__spg_pg_index" => {
10007                    let (schema, rows) = synth_pg_index_raw(self.active_catalog());
10008                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10009                }
10010                // v7.17.0 Phase 3.P0-54 — pg_catalog.pg_constraint
10011                // for FK / UNIQUE / PK / CHECK introspection.
10012                "__spg_pg_constraint" => {
10013                    let (schema, rows) = synth_pg_constraint(self.active_catalog());
10014                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10015                }
10016                // v7.17.0 Phase 3.P0-55 — pg_catalog.pg_database /
10017                // pg_roles / pg_user. SPG is single-database so
10018                // pg_database surfaces just `postgres`; pg_roles
10019                // / pg_user walk the engine's UserStore.
10020                "__spg_pg_database" => {
10021                    let (schema, rows) = synth_pg_database(self.active_catalog());
10022                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10023                }
10024                "__spg_pg_roles" | "__spg_pg_user" => {
10025                    let (schema, rows) = synth_pg_roles(self);
10026                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10027                }
10028                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_views. PG's
10029                // pg_views surfaces every CREATE VIEW result; SPG
10030                // ships one row per declared view from the catalog.
10031                "__spg_pg_views" => {
10032                    let (schema, rows) = synth_pg_views(self.active_catalog());
10033                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10034                }
10035                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_matviews.
10036                // SPG has no materialised view surface yet so the
10037                // table shares pg_views's schema but stays empty.
10038                "__spg_pg_matviews" => {
10039                    let (schema, _) = synth_pg_views(self.active_catalog());
10040                    materialise_meta_view(&mut catalog, view, schema, Vec::new())?;
10041                }
10042                // pg_catalog.pg_extension — native capability list
10043                // (mailrs embed round-12).
10044                "__spg_pg_extension" => {
10045                    let (schema, rows) = synth_pg_extension();
10046                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10047                }
10048                // v7.17.0 Phase 3.P0-57 — pg_catalog.pg_settings.
10049                "__spg_pg_settings" => {
10050                    let (schema, rows) = synth_pg_settings(self);
10051                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10052                }
10053                // v7.17.0 Phase 3.P0-63 — information_schema.KEY_COLUMN_USAGE.
10054                "__spg_info_key_column_usage" => {
10055                    let (schema, rows) = synth_info_key_column_usage(self.active_catalog());
10056                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10057                }
10058                // v7.17.0 Phase 3.P0-64 — information_schema.REFERENTIAL_CONSTRAINTS.
10059                "__spg_info_referential_constraints" => {
10060                    let (schema, rows) = synth_info_referential_constraints(self.active_catalog());
10061                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10062                }
10063                // v7.17.0 Phase 3.P0-64 — information_schema.STATISTICS.
10064                "__spg_info_statistics" => {
10065                    let (schema, rows) = synth_info_statistics(self.active_catalog());
10066                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10067                }
10068                // v7.17.0 Phase 3.P0-64 — information_schema.ROUTINES.
10069                "__spg_info_routines" => {
10070                    let (schema, rows) = synth_info_routines();
10071                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10072                }
10073                // v7.17.0 Phase 3.P0-65 — mysql.user / mysql.db.
10074                "__spg_mysql_user" => {
10075                    let (schema, rows) = synth_mysql_user(self);
10076                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10077                }
10078                "__spg_mysql_db" => {
10079                    let (schema, rows) = synth_mysql_db();
10080                    materialise_meta_view(&mut catalog, view, schema, rows)?;
10081                }
10082                _ => {
10083                    return Err(EngineError::Unsupported(alloc::format!(
10084                        "meta view {view:?} is not yet materialisable; \
10085                         v7.16.2 covers information_schema.columns / .tables \
10086                         and pg_catalog.pg_class / pg_attribute; \
10087                         v7.17.0 P0-50..P0-57 add pg_type / pg_proc / pg_namespace / \
10088                         pg_indexes / pg_index / pg_constraint / pg_database / pg_roles / \
10089                         pg_user / pg_views / pg_matviews / pg_settings"
10090                    )));
10091                }
10092            }
10093        }
10094        let mut temp = Engine::restore(catalog);
10095        if let Some(c) = self.clock {
10096            temp = temp.with_clock(c);
10097        }
10098        if let Some(f) = self.salt_fn {
10099            temp = temp.with_salt_fn(f);
10100        }
10101        temp.meta_views_materialised = true;
10102        temp.exec_select_cancel(stmt, cancel)
10103    }
10104
10105    fn exec_with_ctes(
10106        &self,
10107        stmt: &SelectStatement,
10108        cancel: CancelToken<'_>,
10109    ) -> Result<QueryResult, EngineError> {
10110        cancel.check()?;
10111        let mut catalog = self.active_catalog().clone();
10112        for cte in &stmt.ctes {
10113            if catalog.get(&cte.name).is_some() {
10114                return Err(EngineError::Unsupported(alloc::format!(
10115                    "CTE name {:?} shadows an existing table; rename the CTE",
10116                    cte.name
10117                )));
10118            }
10119            let (columns, rows) = if cte.recursive {
10120                self.materialise_recursive_cte(cte, &catalog, cancel)?
10121            } else {
10122                let body_result = self.exec_select_cancel(&cte.body, cancel)?;
10123                let QueryResult::Rows { columns, rows } = body_result else {
10124                    return Err(EngineError::Unsupported(alloc::format!(
10125                        "CTE {:?} body did not return rows",
10126                        cte.name
10127                    )));
10128                };
10129                (columns, rows)
10130            };
10131            // v4.22: the projection builder labels any non-column
10132            // expression as Text — including literal SELECT 1.
10133            // Promote each column's type to whatever the rows
10134            // actually carry so the CTE storage table accepts them.
10135            let inferred = infer_column_types(&columns, &rows);
10136            let mut columns = inferred;
10137            // v4.22: apply optional `WITH name(a, b, c)` overrides.
10138            if !cte.column_overrides.is_empty() {
10139                if cte.column_overrides.len() != columns.len() {
10140                    return Err(EngineError::Unsupported(alloc::format!(
10141                        "CTE {:?} column list has {} names but body returns {} columns",
10142                        cte.name,
10143                        cte.column_overrides.len(),
10144                        columns.len()
10145                    )));
10146                }
10147                for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
10148                    col.name.clone_from(name);
10149                }
10150            }
10151            let schema = TableSchema::new(cte.name.clone(), columns);
10152            catalog.create_table(schema).map_err(EngineError::Storage)?;
10153            let table = catalog
10154                .get_mut(&cte.name)
10155                .expect("just-created CTE table must exist");
10156            for row in rows {
10157                table.insert(row).map_err(EngineError::Storage)?;
10158            }
10159        }
10160        // Strip CTEs from the body before running on the temp engine
10161        // so we don't recurse forever.
10162        let mut body = stmt.clone();
10163        body.ctes = Vec::new();
10164        let mut temp = Engine::restore(catalog);
10165        if let Some(c) = self.clock {
10166            temp = temp.with_clock(c);
10167        }
10168        if let Some(f) = self.salt_fn {
10169            temp = temp.with_salt_fn(f);
10170        }
10171        temp.exec_select_cancel(&body, cancel)
10172    }
10173
10174    /// v4.22: materialise a WITH RECURSIVE CTE. The body must be a
10175    /// UNION (or UNION ALL) of an anchor that does not reference
10176    /// the CTE name, and one or more recursive terms that do. The
10177    /// anchor runs first; each subsequent iteration runs the
10178    /// recursive term against a temp catalog where the CTE name is
10179    /// bound to the *previous* iteration's output. Iteration stops
10180    /// when the recursive term yields no rows; UNION (DISTINCT)
10181    /// deduplicates against the accumulated result, UNION ALL does
10182    /// not. A hard cap on total rows prevents runaway queries.
10183    #[allow(clippy::too_many_lines)]
10184    fn materialise_recursive_cte(
10185        &self,
10186        cte: &spg_sql::ast::Cte,
10187        base_catalog: &Catalog,
10188        cancel: CancelToken<'_>,
10189    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
10190        const MAX_TOTAL_ROWS: usize = 1_000_000;
10191        const MAX_ITERATIONS: usize = 100_000;
10192        cancel.check()?;
10193        if cte.body.unions.is_empty() {
10194            return Err(EngineError::Unsupported(alloc::format!(
10195                "WITH RECURSIVE {:?} body must be a UNION of an anchor and a recursive term",
10196                cte.name
10197            )));
10198        }
10199        // Anchor: the body's leading SELECT, with unions stripped.
10200        let mut anchor = cte.body.clone();
10201        let union_terms = core::mem::take(&mut anchor.unions);
10202        anchor.ctes = Vec::new();
10203        // Anchor must not reference the CTE name.
10204        if select_refers_to(&anchor, &cte.name) {
10205            return Err(EngineError::Unsupported(alloc::format!(
10206                "WITH RECURSIVE {:?}: the anchor must not reference the CTE itself",
10207                cte.name
10208            )));
10209        }
10210        let anchor_result = self.exec_select_cancel(&anchor, cancel)?;
10211        let QueryResult::Rows {
10212            columns: anchor_cols,
10213            rows: anchor_rows,
10214        } = anchor_result
10215        else {
10216            return Err(EngineError::Unsupported(alloc::format!(
10217                "WITH RECURSIVE {:?}: anchor did not return rows",
10218                cte.name
10219            )));
10220        };
10221        // The projection builder labels non-column expressions Text;
10222        // refine column types from the anchor's actual values so the
10223        // intermediate iter-catalog tables accept them.
10224        let mut columns = infer_column_types(&anchor_cols, &anchor_rows);
10225        if !cte.column_overrides.is_empty() {
10226            if cte.column_overrides.len() != columns.len() {
10227                return Err(EngineError::Unsupported(alloc::format!(
10228                    "CTE {:?} column list has {} names but anchor returns {} columns",
10229                    cte.name,
10230                    cte.column_overrides.len(),
10231                    columns.len()
10232                )));
10233            }
10234            for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
10235                col.name.clone_from(name);
10236            }
10237        }
10238        let mut all_rows: Vec<Row> = anchor_rows.clone();
10239        let mut working_set: Vec<Row> = anchor_rows;
10240        let mut seen: alloc::collections::BTreeSet<Vec<u8>> = alloc::collections::BTreeSet::new();
10241        // Track at least one "all UNION ALL" flag — if every union
10242        // kind is ALL we skip the dedup step (faster + matches PG).
10243        let all_union_all = union_terms.iter().all(|(k, _)| matches!(k, UnionKind::All));
10244        if !all_union_all {
10245            for r in &all_rows {
10246                seen.insert(encode_row_key(r));
10247            }
10248        }
10249        for iter in 0..MAX_ITERATIONS {
10250            cancel.check()?;
10251            if working_set.is_empty() {
10252                break;
10253            }
10254            // Build a fresh catalog: base + CTE bound to working_set.
10255            let mut iter_catalog = base_catalog.clone();
10256            let schema = TableSchema::new(cte.name.clone(), columns.clone());
10257            iter_catalog
10258                .create_table(schema)
10259                .map_err(EngineError::Storage)?;
10260            {
10261                let table = iter_catalog.get_mut(&cte.name).expect("just-created");
10262                for row in &working_set {
10263                    table.insert(row.clone()).map_err(EngineError::Storage)?;
10264                }
10265            }
10266            let mut iter_engine = Engine::restore(iter_catalog);
10267            if let Some(c) = self.clock {
10268                iter_engine = iter_engine.with_clock(c);
10269            }
10270            if let Some(f) = self.salt_fn {
10271                iter_engine = iter_engine.with_salt_fn(f);
10272            }
10273            // Run each recursive term in sequence and collect new rows.
10274            let mut next_set: Vec<Row> = Vec::new();
10275            for (_, term) in &union_terms {
10276                let mut term = term.clone();
10277                term.ctes = Vec::new();
10278                let r = iter_engine.exec_select_cancel(&term, cancel)?;
10279                let QueryResult::Rows {
10280                    columns: rc,
10281                    rows: rs,
10282                } = r
10283                else {
10284                    return Err(EngineError::Unsupported(alloc::format!(
10285                        "WITH RECURSIVE {:?}: recursive term did not return rows",
10286                        cte.name
10287                    )));
10288                };
10289                if rc.len() != columns.len() {
10290                    return Err(EngineError::Unsupported(alloc::format!(
10291                        "WITH RECURSIVE {:?}: column count of recursive term ({}) does not match anchor ({})",
10292                        cte.name,
10293                        rc.len(),
10294                        columns.len()
10295                    )));
10296                }
10297                for row in rs {
10298                    if !all_union_all {
10299                        let key = encode_row_key(&row);
10300                        if !seen.insert(key) {
10301                            continue;
10302                        }
10303                    }
10304                    next_set.push(row);
10305                }
10306            }
10307            if next_set.is_empty() {
10308                break;
10309            }
10310            all_rows.extend(next_set.iter().cloned());
10311            working_set = next_set;
10312            if all_rows.len() > MAX_TOTAL_ROWS {
10313                return Err(EngineError::Unsupported(alloc::format!(
10314                    "WITH RECURSIVE {:?}: produced more than {MAX_TOTAL_ROWS} rows — likely runaway recursion",
10315                    cte.name
10316                )));
10317            }
10318            if iter + 1 == MAX_ITERATIONS {
10319                return Err(EngineError::Unsupported(alloc::format!(
10320                    "WITH RECURSIVE {:?}: exceeded {MAX_ITERATIONS} iterations",
10321                    cte.name
10322                )));
10323            }
10324        }
10325        Ok((columns, all_rows))
10326    }
10327
10328    fn resolve_select_subqueries(
10329        &self,
10330        stmt: &mut SelectStatement,
10331        cancel: CancelToken<'_>,
10332    ) -> Result<(), EngineError> {
10333        for item in &mut stmt.items {
10334            if let SelectItem::Expr { expr, .. } = item {
10335                self.resolve_expr_subqueries(expr, cancel)?;
10336            }
10337        }
10338        if let Some(w) = &mut stmt.where_ {
10339            self.resolve_expr_subqueries(w, cancel)?;
10340        }
10341        if let Some(gs) = &mut stmt.group_by {
10342            for g in gs {
10343                self.resolve_expr_subqueries(g, cancel)?;
10344            }
10345        }
10346        if let Some(h) = &mut stmt.having {
10347            self.resolve_expr_subqueries(h, cancel)?;
10348        }
10349        for o in &mut stmt.order_by {
10350            self.resolve_expr_subqueries(&mut o.expr, cancel)?;
10351        }
10352        for (_, peer) in &mut stmt.unions {
10353            self.resolve_select_subqueries(peer, cancel)?;
10354        }
10355        Ok(())
10356    }
10357
10358    #[allow(clippy::only_used_in_recursion)] // engine handle reads aren't really pure
10359    fn resolve_expr_subqueries(
10360        &self,
10361        e: &mut Expr,
10362        cancel: CancelToken<'_>,
10363    ) -> Result<(), EngineError> {
10364        // Replace-on-this-node cases first.
10365        if let Some(replacement) = self.subquery_replacement(e, cancel)? {
10366            *e = replacement;
10367            return Ok(());
10368        }
10369        match e {
10370            Expr::AggregateOrdered { call, order_by } => {
10371                self.resolve_expr_subqueries(call, cancel)?;
10372                for o in order_by.iter_mut() {
10373                    self.resolve_expr_subqueries(&mut o.expr, cancel)?;
10374                }
10375            }
10376            Expr::Binary { lhs, rhs, .. } => {
10377                self.resolve_expr_subqueries(lhs, cancel)?;
10378                self.resolve_expr_subqueries(rhs, cancel)?;
10379            }
10380            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10381                self.resolve_expr_subqueries(expr, cancel)?;
10382            }
10383            Expr::FunctionCall { args, .. } => {
10384                for a in args {
10385                    self.resolve_expr_subqueries(a, cancel)?;
10386                }
10387            }
10388            Expr::Like { expr, pattern, .. } => {
10389                self.resolve_expr_subqueries(expr, cancel)?;
10390                self.resolve_expr_subqueries(pattern, cancel)?;
10391            }
10392            Expr::Extract { source, .. } => self.resolve_expr_subqueries(source, cancel)?,
10393            // v4.12 window functions — recurse into args + ORDER BY
10394            // + PARTITION BY in case they carry inner subqueries.
10395            Expr::WindowFunction {
10396                args,
10397                partition_by,
10398                order_by,
10399                ..
10400            } => {
10401                for a in args {
10402                    self.resolve_expr_subqueries(a, cancel)?;
10403                }
10404                for p in partition_by {
10405                    self.resolve_expr_subqueries(p, cancel)?;
10406                }
10407                for (e, _) in order_by {
10408                    self.resolve_expr_subqueries(e, cancel)?;
10409                }
10410            }
10411            // Subquery nodes are handled in subquery_replacement
10412            // (which returned None — defensive no-op); Literal /
10413            // Column are leaves.
10414            Expr::ScalarSubquery(_)
10415            | Expr::Exists { .. }
10416            | Expr::InSubquery { .. }
10417            | Expr::Literal(_)
10418            | Expr::Placeholder(_)
10419            | Expr::Column(_) => {}
10420            // v7.10.10 — recurse children.
10421            Expr::Array(items) => {
10422                for elem in items {
10423                    self.resolve_expr_subqueries(elem, cancel)?;
10424                }
10425            }
10426            Expr::ArraySubscript { target, index } => {
10427                self.resolve_expr_subqueries(target, cancel)?;
10428                self.resolve_expr_subqueries(index, cancel)?;
10429            }
10430            Expr::AnyAll { expr, array, .. } => {
10431                self.resolve_expr_subqueries(expr, cancel)?;
10432                self.resolve_expr_subqueries(array, cancel)?;
10433            }
10434            Expr::Case {
10435                operand,
10436                branches,
10437                else_branch,
10438            } => {
10439                if let Some(o) = operand {
10440                    self.resolve_expr_subqueries(o, cancel)?;
10441                }
10442                for (w, t) in branches {
10443                    self.resolve_expr_subqueries(w, cancel)?;
10444                    self.resolve_expr_subqueries(t, cancel)?;
10445                }
10446                if let Some(e) = else_branch {
10447                    self.resolve_expr_subqueries(e, cancel)?;
10448                }
10449            }
10450        }
10451        Ok(())
10452    }
10453
10454    /// v4.23: per-row eval that handles correlated subqueries.
10455    /// Equivalent to `eval::eval_expr` when the expression has no
10456    /// subqueries; otherwise clones the expression, substitutes
10457    /// outer-row columns into each surviving subquery node, runs
10458    /// the inner SELECT, and replaces the node with the literal
10459    /// result. Only the WHERE-filter call sites use this path so
10460    /// the uncorrelated fast path is preserved everywhere else.
10461    fn eval_expr_with_correlated(
10462        &self,
10463        expr: &Expr,
10464        row: &Row,
10465        ctx: &EvalContext<'_>,
10466        cancel: CancelToken<'_>,
10467        memo: Option<&mut memoize::MemoizeCache>,
10468    ) -> Result<Value, EngineError> {
10469        if !expr_has_subquery(expr) {
10470            return eval::eval_expr(expr, row, ctx).map_err(EngineError::Eval);
10471        }
10472        let mut e = expr.clone();
10473        self.resolve_correlated_in_expr(&mut e, row, ctx, cancel, memo)?;
10474        eval::eval_expr(&e, row, ctx).map_err(EngineError::Eval)
10475    }
10476
10477    fn resolve_correlated_in_expr(
10478        &self,
10479        e: &mut Expr,
10480        row: &Row,
10481        ctx: &EvalContext<'_>,
10482        cancel: CancelToken<'_>,
10483        mut memo: Option<&mut memoize::MemoizeCache>,
10484    ) -> Result<(), EngineError> {
10485        match e {
10486            Expr::AggregateOrdered { call, order_by } => {
10487                self.resolve_correlated_in_expr(call, row, ctx, cancel, memo.as_deref_mut())?;
10488                for o in order_by.iter_mut() {
10489                    self.resolve_correlated_in_expr(
10490                        &mut o.expr,
10491                        row,
10492                        ctx,
10493                        cancel,
10494                        memo.as_deref_mut(),
10495                    )?;
10496                }
10497            }
10498            Expr::ScalarSubquery(inner) => {
10499                // v6.2.6 — Memoize: build the cache key from the
10500                // pre-substitution subquery repr + the outer row's
10501                // values. Two outer rows with identical correlated
10502                // values hit the same entry.
10503                let cache_key = memo.as_ref().map(|_| memoize::CacheKey {
10504                    subquery_repr: alloc::format!("{}", **inner),
10505                    outer_values: row.values.clone(),
10506                });
10507                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key.as_ref())
10508                    && let Some(cached) = cache.get(k)
10509                {
10510                    *e = value_to_literal_expr(cached)?;
10511                    return Ok(());
10512                }
10513                let mut s = (**inner).clone();
10514                substitute_outer_columns(&mut s, row, ctx);
10515                let r = self.exec_select_cancel(&s, cancel)?;
10516                let QueryResult::Rows { rows, .. } = r else {
10517                    return Err(EngineError::Unsupported(
10518                        "scalar subquery: inner did not return rows".into(),
10519                    ));
10520                };
10521                let value = match rows.as_slice() {
10522                    [] => Value::Null,
10523                    [r0] => r0.values.first().cloned().unwrap_or(Value::Null),
10524                    _ => {
10525                        return Err(EngineError::Unsupported(alloc::format!(
10526                            "scalar subquery returned {} rows; expected 0 or 1",
10527                            rows.len()
10528                        )));
10529                    }
10530                };
10531                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key) {
10532                    cache.insert(k, value.clone());
10533                }
10534                *e = value_to_literal_expr(value)?;
10535            }
10536            Expr::Exists { subquery, negated } => {
10537                let mut s = (**subquery).clone();
10538                substitute_outer_columns(&mut s, row, ctx);
10539                let r = self.exec_select_cancel(&s, cancel)?;
10540                let exists = matches!(r, QueryResult::Rows { rows, .. } if !rows.is_empty());
10541                let bit = if *negated { !exists } else { exists };
10542                *e = Expr::Literal(Literal::Bool(bit));
10543            }
10544            Expr::InSubquery {
10545                expr: lhs,
10546                subquery,
10547                negated,
10548            } => {
10549                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
10550                let lhs_val = eval::eval_expr(lhs, row, ctx).map_err(EngineError::Eval)?;
10551                let mut s = (**subquery).clone();
10552                substitute_outer_columns(&mut s, row, ctx);
10553                let r = self.exec_select_cancel(&s, cancel)?;
10554                let QueryResult::Rows { columns, rows, .. } = r else {
10555                    return Err(EngineError::Unsupported(
10556                        "IN-subquery: inner did not return rows".into(),
10557                    ));
10558                };
10559                if columns.len() != 1 {
10560                    return Err(EngineError::Unsupported(alloc::format!(
10561                        "IN-subquery must project exactly one column; got {}",
10562                        columns.len()
10563                    )));
10564                }
10565                let mut found = false;
10566                let mut any_null = false;
10567                for r0 in rows {
10568                    let v = r0.values.into_iter().next().unwrap_or(Value::Null);
10569                    if v.is_null() {
10570                        any_null = true;
10571                        continue;
10572                    }
10573                    if value_cmp(&v, &lhs_val) == core::cmp::Ordering::Equal {
10574                        found = true;
10575                        break;
10576                    }
10577                }
10578                let bit = if found {
10579                    !*negated
10580                } else if any_null {
10581                    return Err(EngineError::Unsupported(
10582                        "IN-subquery with NULL in result and no match: NULL semantics not yet implemented".into(),
10583                    ));
10584                } else {
10585                    *negated
10586                };
10587                *e = Expr::Literal(Literal::Bool(bit));
10588            }
10589            Expr::Binary { lhs, rhs, .. } => {
10590                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
10591                self.resolve_correlated_in_expr(rhs, row, ctx, cancel, memo.as_deref_mut())?;
10592            }
10593            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10594                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10595            }
10596            Expr::Like { expr, pattern, .. } => {
10597                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10598                self.resolve_correlated_in_expr(pattern, row, ctx, cancel, memo.as_deref_mut())?;
10599            }
10600            Expr::FunctionCall { args, .. } => {
10601                for a in args {
10602                    self.resolve_correlated_in_expr(a, row, ctx, cancel, memo.as_deref_mut())?;
10603                }
10604            }
10605            Expr::Extract { source, .. } => {
10606                self.resolve_correlated_in_expr(source, row, ctx, cancel, memo.as_deref_mut())?;
10607            }
10608            Expr::WindowFunction { .. }
10609            | Expr::Literal(_)
10610            | Expr::Placeholder(_)
10611            | Expr::Column(_) => {}
10612            // v7.10.10 — recurse children.
10613            Expr::Array(items) => {
10614                for elem in items {
10615                    self.resolve_correlated_in_expr(elem, row, ctx, cancel, memo.as_deref_mut())?;
10616                }
10617            }
10618            Expr::ArraySubscript { target, index } => {
10619                self.resolve_correlated_in_expr(target, row, ctx, cancel, memo.as_deref_mut())?;
10620                self.resolve_correlated_in_expr(index, row, ctx, cancel, memo.as_deref_mut())?;
10621            }
10622            Expr::AnyAll { expr, array, .. } => {
10623                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10624                self.resolve_correlated_in_expr(array, row, ctx, cancel, memo.as_deref_mut())?;
10625            }
10626            Expr::Case {
10627                operand,
10628                branches,
10629                else_branch,
10630            } => {
10631                if let Some(o) = operand {
10632                    self.resolve_correlated_in_expr(o, row, ctx, cancel, memo.as_deref_mut())?;
10633                }
10634                for (w, t) in branches {
10635                    self.resolve_correlated_in_expr(w, row, ctx, cancel, memo.as_deref_mut())?;
10636                    self.resolve_correlated_in_expr(t, row, ctx, cancel, memo.as_deref_mut())?;
10637                }
10638                if let Some(e) = else_branch {
10639                    self.resolve_correlated_in_expr(e, row, ctx, cancel, memo.as_deref_mut())?;
10640                }
10641            }
10642        }
10643        Ok(())
10644    }
10645
10646    fn subquery_replacement(
10647        &self,
10648        e: &Expr,
10649        cancel: CancelToken<'_>,
10650    ) -> Result<Option<Expr>, EngineError> {
10651        match e {
10652            Expr::ScalarSubquery(inner) => {
10653                let mut s = (**inner).clone();
10654                // Recurse into the inner SELECT first so nested
10655                // subqueries materialise bottom-up.
10656                self.resolve_select_subqueries(&mut s, cancel)?;
10657                let r = match self.exec_bare_select_cancel(&s, cancel) {
10658                    Ok(r) => r,
10659                    Err(e) if is_correlation_error(&e) => return Ok(None),
10660                    Err(e) => return Err(e),
10661                };
10662                let QueryResult::Rows { rows, .. } = r else {
10663                    return Err(EngineError::Unsupported(
10664                        "scalar subquery: inner statement did not return rows".into(),
10665                    ));
10666                };
10667                let value = match rows.as_slice() {
10668                    [] => Value::Null,
10669                    [row] => row.values.first().cloned().unwrap_or(Value::Null),
10670                    _ => {
10671                        return Err(EngineError::Unsupported(alloc::format!(
10672                            "scalar subquery returned {} rows; expected 0 or 1",
10673                            rows.len()
10674                        )));
10675                    }
10676                };
10677                Ok(Some(value_to_literal_expr(value)?))
10678            }
10679            Expr::Exists { subquery, negated } => {
10680                let mut s = (**subquery).clone();
10681                self.resolve_select_subqueries(&mut s, cancel)?;
10682                let r = match self.exec_bare_select_cancel(&s, cancel) {
10683                    Ok(r) => r,
10684                    Err(e) if is_correlation_error(&e) => return Ok(None),
10685                    Err(e) => return Err(e),
10686                };
10687                let exists = match r {
10688                    QueryResult::Rows { rows, .. } => !rows.is_empty(),
10689                    QueryResult::CommandOk { .. } => false,
10690                };
10691                let bit = if *negated { !exists } else { exists };
10692                Ok(Some(Expr::Literal(Literal::Bool(bit))))
10693            }
10694            Expr::InSubquery {
10695                expr,
10696                subquery,
10697                negated,
10698            } => {
10699                let mut s = (**subquery).clone();
10700                self.resolve_select_subqueries(&mut s, cancel)?;
10701                let r = match self.exec_bare_select_cancel(&s, cancel) {
10702                    Ok(r) => r,
10703                    Err(e) if is_correlation_error(&e) => return Ok(None),
10704                    Err(e) => return Err(e),
10705                };
10706                let QueryResult::Rows { columns, rows, .. } = r else {
10707                    return Err(EngineError::Unsupported(
10708                        "IN-subquery: inner statement did not return rows".into(),
10709                    ));
10710                };
10711                if columns.len() != 1 {
10712                    return Err(EngineError::Unsupported(alloc::format!(
10713                        "IN-subquery must project exactly one column; got {}",
10714                        columns.len()
10715                    )));
10716                }
10717                // Build the same OR-Eq chain the parse-time literal-list
10718                // path constructs, with each value lifted into a Literal.
10719                let mut acc: Option<Expr> = None;
10720                for row in rows {
10721                    let v = row.values.into_iter().next().unwrap_or(Value::Null);
10722                    let lit = value_to_literal_expr(v)?;
10723                    let cmp = Expr::Binary {
10724                        lhs: expr.clone(),
10725                        op: BinOp::Eq,
10726                        rhs: Box::new(lit),
10727                    };
10728                    acc = Some(match acc {
10729                        None => cmp,
10730                        Some(prev) => Expr::Binary {
10731                            lhs: Box::new(prev),
10732                            op: BinOp::Or,
10733                            rhs: Box::new(cmp),
10734                        },
10735                    });
10736                }
10737                let combined = acc.unwrap_or(Expr::Literal(Literal::Bool(false)));
10738                let final_expr = if *negated {
10739                    Expr::Unary {
10740                        op: UnOp::Not,
10741                        expr: Box::new(combined),
10742                    }
10743                } else {
10744                    combined
10745                };
10746                Ok(Some(final_expr))
10747            }
10748            _ => Ok(None),
10749        }
10750    }
10751}
10752
10753// ---- v4.12 window-function helpers ----
10754// The (partition-key, order-key, original-index) tuple shape used
10755// across these helpers is intrinsic to the planner. Factoring it
10756// into a typedef adds indirection without making the code clearer,
10757// so several lints are allowed inline on the affected functions
10758// rather than module-wide.
10759
10760/// v4.22: cheap structural scan for `FROM <name>` (qualified or
10761/// not) inside a SELECT — used to verify the anchor of a WITH
10762/// RECURSIVE CTE doesn't recurse into itself. Conservative: walks
10763/// FROM joins, subqueries, and unions.
10764fn select_refers_to(stmt: &SelectStatement, target: &str) -> bool {
10765    if let Some(from) = &stmt.from
10766        && from_refers_to(from, target)
10767    {
10768        return true;
10769    }
10770    for (_, peer) in &stmt.unions {
10771        if select_refers_to(peer, target) {
10772            return true;
10773        }
10774    }
10775    for item in &stmt.items {
10776        if let SelectItem::Expr { expr, .. } = item
10777            && expr_refers_to(expr, target)
10778        {
10779            return true;
10780        }
10781    }
10782    if let Some(w) = &stmt.where_
10783        && expr_refers_to(w, target)
10784    {
10785        return true;
10786    }
10787    false
10788}
10789
10790fn from_refers_to(from: &FromClause, target: &str) -> bool {
10791    if from.primary.name.eq_ignore_ascii_case(target) {
10792        return true;
10793    }
10794    from.joins
10795        .iter()
10796        .any(|j| j.table.name.eq_ignore_ascii_case(target))
10797}
10798
10799fn expr_refers_to(e: &Expr, target: &str) -> bool {
10800    match e {
10801        Expr::AggregateOrdered { call, order_by } => {
10802            expr_refers_to(call, target) || order_by.iter().any(|o| expr_refers_to(&o.expr, target))
10803        }
10804        Expr::ScalarSubquery(s) => select_refers_to(s, target),
10805        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
10806            select_refers_to(subquery, target)
10807        }
10808        Expr::Binary { lhs, rhs, .. } => expr_refers_to(lhs, target) || expr_refers_to(rhs, target),
10809        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10810            expr_refers_to(expr, target)
10811        }
10812        Expr::Like { expr, pattern, .. } => {
10813            expr_refers_to(expr, target) || expr_refers_to(pattern, target)
10814        }
10815        Expr::FunctionCall { args, .. } => args.iter().any(|a| expr_refers_to(a, target)),
10816        Expr::Extract { source, .. } => expr_refers_to(source, target),
10817        Expr::WindowFunction {
10818            args,
10819            partition_by,
10820            order_by,
10821            ..
10822        } => {
10823            args.iter().any(|a| expr_refers_to(a, target))
10824                || partition_by.iter().any(|p| expr_refers_to(p, target))
10825                || order_by.iter().any(|(o, _)| expr_refers_to(o, target))
10826        }
10827        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
10828        Expr::Array(items) => items.iter().any(|e| expr_refers_to(e, target)),
10829        Expr::ArraySubscript { target: t, index } => {
10830            expr_refers_to(t, target) || expr_refers_to(index, target)
10831        }
10832        Expr::AnyAll { expr, array, .. } => {
10833            expr_refers_to(expr, target) || expr_refers_to(array, target)
10834        }
10835        Expr::Case {
10836            operand,
10837            branches,
10838            else_branch,
10839        } => {
10840            operand
10841                .as_deref()
10842                .is_some_and(|o| expr_refers_to(o, target))
10843                || branches
10844                    .iter()
10845                    .any(|(w, t)| expr_refers_to(w, target) || expr_refers_to(t, target))
10846                || else_branch
10847                    .as_deref()
10848                    .is_some_and(|e| expr_refers_to(e, target))
10849        }
10850    }
10851}
10852
10853/// v4.22: pick more specific column types from observed rows when
10854/// the projection builder defaulted to Text (the v1.x behavior for
10855/// non-column expressions). Lets `WITH t(n) AS (SELECT 1 ...)`
10856/// land an Int column in the CTE storage table rather than failing
10857/// the insert with "expected TEXT, got INT".
10858/// v7.16.2 — map an SPG [`DataType`] to the PG-canonical
10859/// `information_schema.columns.data_type` text. Covers the
10860/// values mailrs's migrations probe (`'ARRAY'`, `'integer'`,
10861/// `'text'`, …). Unknown variants fall back to the SPG name
10862/// downcased — better than panicking on a future DataType.
10863fn pg_data_type_text(ty: DataType) -> alloc::string::String {
10864    let s = match ty {
10865        DataType::Int => "integer",
10866        DataType::BigInt => "bigint",
10867        DataType::SmallInt => "smallint",
10868        DataType::Float => "double precision",
10869        DataType::Bool => "boolean",
10870        DataType::Text => "text",
10871        DataType::Varchar(_) => "character varying",
10872        DataType::Date => "date",
10873        DataType::Timestamp => "timestamp without time zone",
10874        DataType::Timestamptz => "timestamp with time zone",
10875        DataType::Json => "jsonb",
10876        DataType::Bytes => "bytea",
10877        DataType::TextArray | DataType::IntArray | DataType::BigIntArray => "ARRAY",
10878        DataType::TsVector => "tsvector",
10879        DataType::TsQuery => "tsquery",
10880        DataType::Vector { .. } => "USER-DEFINED",
10881        // Non-exhaustive — fall back to "USER-DEFINED" the way
10882        // PG labels any pg_type it doesn't recognise.
10883        _ => "USER-DEFINED",
10884    };
10885    alloc::string::String::from(s)
10886}
10887
10888/// v7.16.2 — synthesise `information_schema.columns`. mailrs
10889/// queries are of shape `SELECT 1 FROM information_schema.columns
10890/// WHERE table_name = … AND column_name = … AND data_type = …` —
10891/// the v7.16.2 view returns the columns mailrs probes; broader
10892/// PG-spec parity (ordinal_position, is_nullable, character_
10893/// maximum_length, udt_name, …) lands as needed.
10894fn synth_information_schema_columns(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10895    let schema = alloc::vec![
10896        ColumnSchema::new("table_catalog", DataType::Text, false),
10897        ColumnSchema::new("table_schema", DataType::Text, false),
10898        ColumnSchema::new("table_name", DataType::Text, false),
10899        ColumnSchema::new("column_name", DataType::Text, false),
10900        ColumnSchema::new("ordinal_position", DataType::Int, false),
10901        ColumnSchema::new("is_nullable", DataType::Text, false),
10902        ColumnSchema::new("data_type", DataType::Text, false),
10903    ];
10904    let mut rows: Vec<Row> = Vec::new();
10905    for tname in cat.table_names() {
10906        let Some(t) = cat.get(&tname) else { continue };
10907        for (i, col) in t.schema().columns.iter().enumerate() {
10908            #[allow(clippy::cast_possible_wrap)]
10909            let ordinal = (i + 1) as i32;
10910            rows.push(Row::new(alloc::vec![
10911                Value::Text("spg".into()),
10912                Value::Text("public".into()),
10913                Value::Text(tname.clone()),
10914                Value::Text(col.name.clone()),
10915                Value::Int(ordinal),
10916                Value::Text(if col.nullable {
10917                    "YES".into()
10918                } else {
10919                    "NO".into()
10920                }),
10921                Value::Text(pg_data_type_text(col.ty)),
10922            ]));
10923        }
10924    }
10925    (schema, rows)
10926}
10927
10928/// v7.16.2 — synthesise `information_schema.tables`.
10929fn synth_information_schema_tables(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10930    let schema = alloc::vec![
10931        ColumnSchema::new("table_catalog", DataType::Text, false),
10932        ColumnSchema::new("table_schema", DataType::Text, false),
10933        ColumnSchema::new("table_name", DataType::Text, false),
10934        ColumnSchema::new("table_type", DataType::Text, false),
10935    ];
10936    let mut rows: Vec<Row> = Vec::new();
10937    for tname in cat.table_names() {
10938        rows.push(Row::new(alloc::vec![
10939            Value::Text("spg".into()),
10940            Value::Text("public".into()),
10941            Value::Text(tname.clone()),
10942            Value::Text("BASE TABLE".into()),
10943        ]));
10944    }
10945    (schema, rows)
10946}
10947
10948/// v7.16.2 — synthesise `pg_catalog.pg_class`. Minimum shape
10949/// for psql `\d` / ORM probes: `relname` + `relkind`. Each
10950/// user table emits one row.
10951fn synth_pg_class(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10952    let schema = alloc::vec![
10953        ColumnSchema::new("relname", DataType::Text, false),
10954        ColumnSchema::new("relkind", DataType::Text, false),
10955        ColumnSchema::new("relnamespace", DataType::BigInt, false),
10956    ];
10957    let mut rows: Vec<Row> = Vec::new();
10958    for tname in cat.table_names() {
10959        rows.push(Row::new(alloc::vec![
10960            Value::Text(tname.clone()),
10961            Value::Text("r".into()),
10962            Value::BigInt(2200), // PG's `public` namespace OID
10963        ]));
10964    }
10965    (schema, rows)
10966}
10967
10968/// v7.16.2 — synthesise `pg_catalog.pg_attribute`. Minimum
10969/// shape: `attrelid` (text — SPG has no OID), `attname`,
10970/// `attnum`, `atttypid` (text), `attnotnull`.
10971fn synth_pg_attribute(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10972    let schema = alloc::vec![
10973        ColumnSchema::new("attrelid", DataType::Text, false),
10974        ColumnSchema::new("attname", DataType::Text, false),
10975        ColumnSchema::new("attnum", DataType::Int, false),
10976        ColumnSchema::new("atttypid", DataType::Text, false),
10977        ColumnSchema::new("attnotnull", DataType::Bool, false),
10978    ];
10979    let mut rows: Vec<Row> = Vec::new();
10980    for tname in cat.table_names() {
10981        let Some(t) = cat.get(&tname) else { continue };
10982        for (i, col) in t.schema().columns.iter().enumerate() {
10983            #[allow(clippy::cast_possible_wrap)]
10984            let ordinal = (i + 1) as i32;
10985            rows.push(Row::new(alloc::vec![
10986                Value::Text(tname.clone()),
10987                Value::Text(col.name.clone()),
10988                Value::Int(ordinal),
10989                Value::Text(pg_data_type_text(col.ty)),
10990                Value::Bool(!col.nullable),
10991            ]));
10992        }
10993    }
10994    (schema, rows)
10995}
10996
10997/// v7.17.0 Phase 3.P0-50 — synthesise `pg_catalog.pg_type`. The
10998/// returned rows cover every built-in scalar / array type sqlx,
10999/// SQLAlchemy, Diesel and pgAdmin look up at compile / connect
11000/// time. PG-canonical schema columns we expose:
11001///   * oid           — type OID (the lookup key sqlx uses)
11002///   * typname       — canonical type name (`int4`, `text`, …)
11003///   * typlen        — width in bytes (-1 for var-length)
11004///   * typtype       — `b`ase / `c`omposite / `e`num / etc.
11005///   * typcategory   — PG type category single-char
11006///   * typelem       — element OID for arrays (0 otherwise)
11007///   * typarray      — array-type OID (0 if no array type)
11008///   * typnamespace  — schema OID (always `public` = 2200)
11009///
11010/// Other pg_type columns (typowner, typinput/typoutput, etc.)
11011/// land in follow-up work — sqlx encoders don't query them at
11012/// connect time.
11013fn synth_pg_type(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11014    let schema = alloc::vec![
11015        ColumnSchema::new("oid", DataType::BigInt, false),
11016        ColumnSchema::new("typname", DataType::Text, false),
11017        ColumnSchema::new("typlen", DataType::SmallInt, false),
11018        ColumnSchema::new("typtype", DataType::Text, false),
11019        ColumnSchema::new("typcategory", DataType::Text, false),
11020        ColumnSchema::new("typelem", DataType::BigInt, false),
11021        ColumnSchema::new("typarray", DataType::BigInt, false),
11022        ColumnSchema::new("typnamespace", DataType::BigInt, false),
11023    ];
11024    // (oid, name, len, type, cat, elem, array_oid). PG OID
11025    // numbers come straight from `pg_type.dat`.
11026    let scalars: &[(i64, &str, i16, &str, &str, i64, i64)] = &[
11027        // bool
11028        (16, "bool", 1, "b", "B", 0, 1000),
11029        (17, "bytea", -1, "b", "U", 0, 1001),
11030        (18, "char", 1, "b", "S", 0, 1002),
11031        (19, "name", 64, "b", "S", 0, 1003),
11032        (20, "int8", 8, "b", "N", 0, 1016),
11033        (21, "int2", 2, "b", "N", 0, 1005),
11034        (23, "int4", 4, "b", "N", 0, 1007),
11035        (24, "regproc", 4, "b", "N", 0, 1008),
11036        (25, "text", -1, "b", "S", 0, 1009),
11037        (26, "oid", 4, "b", "N", 0, 1028),
11038        (114, "json", -1, "b", "U", 0, 199),
11039        (142, "xml", -1, "b", "U", 0, 143),
11040        (700, "float4", 4, "b", "N", 0, 1021),
11041        (701, "float8", 8, "b", "N", 0, 1022),
11042        (650, "cidr", -1, "b", "I", 0, 651),
11043        (869, "inet", -1, "b", "I", 0, 1041),
11044        (829, "macaddr", 6, "b", "U", 0, 1040),
11045        (1042, "bpchar", -1, "b", "S", 0, 1014),
11046        (1043, "varchar", -1, "b", "S", 0, 1015),
11047        (1082, "date", 4, "b", "D", 0, 1182),
11048        (1083, "time", 8, "b", "D", 0, 1183),
11049        (1114, "timestamp", 8, "b", "D", 0, 1115),
11050        (1184, "timestamptz", 8, "b", "D", 0, 1185),
11051        (1186, "interval", 16, "b", "T", 0, 1187),
11052        (1266, "timetz", 12, "b", "D", 0, 1270),
11053        (1700, "numeric", -1, "b", "N", 0, 1231),
11054        (790, "money", 8, "b", "N", 0, 791),
11055        (2950, "uuid", 16, "b", "U", 0, 2951),
11056        (3802, "jsonb", -1, "b", "U", 0, 3807),
11057        (3614, "tsvector", -1, "b", "U", 0, 3643),
11058        (3615, "tsquery", -1, "b", "U", 0, 3645),
11059        // hstore + range types — typcategory 'U' (user) / 'R' (range).
11060        (3908, "tstzrange", -1, "r", "R", 0, 3909),
11061        (3910, "tsrange", -1, "r", "R", 0, 3911),
11062        (3904, "int4range", -1, "r", "R", 0, 3905),
11063        (3926, "int8range", -1, "r", "R", 0, 3927),
11064        (3906, "numrange", -1, "r", "R", 0, 3907),
11065        (3912, "daterange", -1, "r", "R", 0, 3913),
11066    ];
11067    // Array companion types share the typelem / typcategory='A'.
11068    // We emit just the array OIDs the scalars reference.
11069    let arrays: &[(i64, &str, i64)] = &[
11070        (1000, "_bool", 16),
11071        (1001, "_bytea", 17),
11072        (1002, "_char", 18),
11073        (1003, "_name", 19),
11074        (1016, "_int8", 20),
11075        (1005, "_int2", 21),
11076        (1007, "_int4", 23),
11077        (1008, "_regproc", 24),
11078        (1009, "_text", 25),
11079        (1028, "_oid", 26),
11080        (199, "_json", 114),
11081        (143, "_xml", 142),
11082        (1021, "_float4", 700),
11083        (1022, "_float8", 701),
11084        (651, "_cidr", 650),
11085        (1041, "_inet", 869),
11086        (1040, "_macaddr", 829),
11087        (1014, "_bpchar", 1042),
11088        (1015, "_varchar", 1043),
11089        (1182, "_date", 1082),
11090        (1183, "_time", 1083),
11091        (1115, "_timestamp", 1114),
11092        (1185, "_timestamptz", 1184),
11093        (1187, "_interval", 1186),
11094        (1270, "_timetz", 1266),
11095        (1231, "_numeric", 1700),
11096        (791, "_money", 790),
11097        (2951, "_uuid", 2950),
11098        (3807, "_jsonb", 3802),
11099        (3643, "_tsvector", 3614),
11100        (3645, "_tsquery", 3615),
11101    ];
11102    let mut rows: Vec<Row> = Vec::with_capacity(scalars.len() + arrays.len());
11103    for &(oid, name, len, ty, cat, elem, arr) in scalars {
11104        rows.push(Row::new(alloc::vec![
11105            Value::BigInt(oid),
11106            Value::Text(name.into()),
11107            Value::SmallInt(len),
11108            Value::Text(ty.into()),
11109            Value::Text(cat.into()),
11110            Value::BigInt(elem),
11111            Value::BigInt(arr),
11112            Value::BigInt(2200),
11113        ]));
11114    }
11115    for &(oid, name, elem) in arrays {
11116        rows.push(Row::new(alloc::vec![
11117            Value::BigInt(oid),
11118            Value::Text(name.into()),
11119            Value::SmallInt(-1),
11120            Value::Text("b".into()),
11121            Value::Text("A".into()),
11122            Value::BigInt(elem),
11123            Value::BigInt(0),
11124            Value::BigInt(2200),
11125        ]));
11126    }
11127    (schema, rows)
11128}
11129
11130/// v7.17.0 Phase 3.P0-51 — synthesise `pg_catalog.pg_proc`. ORM /
11131/// pgAdmin probes look up functions by name; SPG synthesises rows
11132/// for the built-in scalar functions / aggregates / window funcs
11133/// the engine actually dispatches. SPG has no user-defined
11134/// functions yet so the table is a stable static list.
11135///
11136/// Schema columns exposed:
11137///   * oid (BigInt) — function OID from PG's pg_proc.dat
11138///   * proname (Text) — function name (lowercase)
11139///   * pronamespace (BigInt) — 11 (`pg_catalog`)
11140///   * prokind (Text) — 'f' function, 'a' aggregate, 'w' window
11141///   * pronargs (SmallInt) — declared arg count (-1 for variadic)
11142///   * prorettype (BigInt) — return type OID (matches synth_pg_type)
11143/// v7.24 (round-16 D) — synthesise `pg_catalog.pg_trigger` from the
11144/// live catalog. PG-shaped core columns (tgname, tgenabled with
11145/// 'O'/'D') plus pragmatic text columns PG keeps relational
11146/// (relname, timing, events, function) so health checks don't need
11147/// oid joins.
11148fn synth_pg_trigger(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11149    let schema = alloc::vec![
11150        ColumnSchema::new("tgname", DataType::Text, false),
11151        ColumnSchema::new("relname", DataType::Text, false),
11152        ColumnSchema::new("tgenabled", DataType::Text, false),
11153        ColumnSchema::new("timing", DataType::Text, false),
11154        ColumnSchema::new("events", DataType::Text, false),
11155        ColumnSchema::new("function", DataType::Text, false),
11156    ];
11157    let rows: Vec<Row> = cat
11158        .triggers()
11159        .iter()
11160        .map(|t| {
11161            Row::new(alloc::vec![
11162                Value::Text(t.name.clone()),
11163                Value::Text(t.table.clone()),
11164                Value::Text(if t.enabled { "O".into() } else { "D".into() }),
11165                Value::Text(t.timing.clone()),
11166                Value::Text(t.events.join(" OR ")),
11167                Value::Text(t.function.clone()),
11168            ])
11169        })
11170        .collect();
11171    (schema, rows)
11172}
11173
11174fn synth_pg_proc(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11175    let schema = alloc::vec![
11176        ColumnSchema::new("oid", DataType::BigInt, false),
11177        ColumnSchema::new("proname", DataType::Text, false),
11178        ColumnSchema::new("pronamespace", DataType::BigInt, false),
11179        ColumnSchema::new("prokind", DataType::Text, false),
11180        ColumnSchema::new("pronargs", DataType::Int, false),
11181        ColumnSchema::new("prorettype", DataType::BigInt, false),
11182    ];
11183    // (oid, name, kind, nargs, rettype). OIDs taken from PG's
11184    // pg_proc.dat for the common subset.
11185    let funcs: &[(i64, &str, &str, i32, i64)] = &[
11186        // Scalar functions.
11187        (1318, "length", "f", 1, 23),
11188        (871, "upper", "f", 1, 25),
11189        (870, "lower", "f", 1, 25),
11190        (936, "substring", "f", 3, 25),
11191        (937, "substring", "f", 2, 25),
11192        (3055, "btrim", "f", 1, 25),
11193        (885, "btrim", "f", 2, 25),
11194        (3056, "ltrim", "f", 1, 25),
11195        (875, "ltrim", "f", 2, 25),
11196        (3057, "rtrim", "f", 1, 25),
11197        (876, "rtrim", "f", 2, 25),
11198        (1397, "abs", "f", 1, 23),
11199        (1396, "abs", "f", 1, 20),
11200        (1606, "round", "f", 1, 1700),
11201        (1707, "round", "f", 2, 1700),
11202        (2308, "ceil", "f", 1, 701),
11203        (2309, "ceiling", "f", 1, 701),
11204        (2310, "floor", "f", 1, 701),
11205        (1376, "sqrt", "f", 1, 701),
11206        (1369, "ln", "f", 1, 701),
11207        (1373, "exp", "f", 1, 701),
11208        (1368, "power", "f", 2, 701),
11209        (2228, "random", "f", 0, 701),
11210        // Date / time.
11211        (1299, "now", "f", 0, 1184),
11212        (1274, "current_timestamp", "f", 0, 1184),
11213        (1140, "current_date", "f", 0, 1082),
11214        (2050, "current_time", "f", 0, 1083),
11215        (1158, "date_trunc", "f", 2, 1184),
11216        (1171, "date_part", "f", 2, 701),
11217        (1172, "age", "f", 1, 1186),
11218        (936, "to_char", "f", 2, 25),
11219        // Session / introspection.
11220        (861, "current_database", "f", 0, 19),
11221        (745, "current_user", "f", 0, 19),
11222        (745, "session_user", "f", 0, 19),
11223        (1402, "current_schema", "f", 0, 19),
11224        // String concat / format.
11225        (3058, "concat", "f", -1, 25),
11226        (3059, "concat_ws", "f", -1, 25),
11227        (3539, "format", "f", -1, 25),
11228        // Type introspection.
11229        (2877, "pg_typeof", "f", 1, 2206),
11230        // JSON.
11231        (3198, "json_build_object", "f", -1, 114),
11232        (3199, "jsonb_build_object", "f", -1, 3802),
11233        (3271, "json_build_array", "f", -1, 114),
11234        (3272, "jsonb_build_array", "f", -1, 3802),
11235        // UUID.
11236        (3253, "gen_random_uuid", "f", 0, 2950),
11237        (3252, "uuid_generate_v4", "f", 0, 2950),
11238        // Aggregates.
11239        (2147, "count", "a", 0, 20),
11240        (2803, "count", "a", -1, 20),
11241        (2116, "max", "a", 1, 23),
11242        (2132, "min", "a", 1, 23),
11243        (2108, "sum", "a", 1, 20),
11244        (2100, "avg", "a", 1, 1700),
11245        (2517, "string_agg", "a", 2, 25),
11246        (2747, "array_agg", "a", 1, 1009),
11247        (2517, "bool_and", "a", 1, 16),
11248        (2518, "bool_or", "a", 1, 16),
11249        (2519, "every", "a", 1, 16),
11250        // Window functions.
11251        (3100, "row_number", "w", 0, 20),
11252        (3101, "rank", "w", 0, 20),
11253        (3102, "dense_rank", "w", 0, 20),
11254        (3103, "percent_rank", "w", 0, 701),
11255        (3104, "cume_dist", "w", 0, 701),
11256        (3105, "lag", "w", -1, 2283),
11257        (3106, "lead", "w", -1, 2283),
11258        (3107, "first_value", "w", 1, 2283),
11259        (3108, "last_value", "w", 1, 2283),
11260        (3109, "nth_value", "w", 2, 2283),
11261    ];
11262    let mut rows: Vec<Row> = Vec::with_capacity(funcs.len());
11263    for &(oid, name, kind, nargs, rettype) in funcs {
11264        rows.push(Row::new(alloc::vec![
11265            Value::BigInt(oid),
11266            Value::Text(name.into()),
11267            Value::BigInt(11),
11268            Value::Text(kind.into()),
11269            Value::Int(nargs),
11270            Value::BigInt(rettype),
11271        ]));
11272    }
11273    (schema, rows)
11274}
11275
11276/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.user`. MySQL admin
11277/// queries (`SELECT user, host FROM mysql.user`) probe this at
11278/// connect time to list accounts. SPG ships one row per
11279/// UserStore entry plus a synthetic `root` superuser row for
11280/// MySQL bootstrap compat.
11281fn synth_mysql_user(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11282    let schema = alloc::vec![
11283        ColumnSchema::new("user", DataType::Text, false),
11284        ColumnSchema::new("host", DataType::Text, false),
11285        ColumnSchema::new("select_priv", DataType::Text, false),
11286    ];
11287    let mut rows: Vec<Row> = Vec::new();
11288    rows.push(Row::new(alloc::vec![
11289        Value::Text("root".into()),
11290        Value::Text("localhost".into()),
11291        Value::Text("Y".into()),
11292    ]));
11293    for (name, _) in engine.users.iter() {
11294        if name != "root" {
11295            rows.push(Row::new(alloc::vec![
11296                Value::Text(name.to_string()),
11297                Value::Text("%".into()),
11298                Value::Text("Y".into()),
11299            ]));
11300        }
11301    }
11302    (schema, rows)
11303}
11304
11305/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.db`. The
11306/// per-database privileges table. SPG is single-database so the
11307/// table surfaces one row per declared user with full privileges
11308/// on the canonical `postgres` database.
11309fn synth_mysql_db() -> (Vec<ColumnSchema>, Vec<Row>) {
11310    let schema = alloc::vec![
11311        ColumnSchema::new("host", DataType::Text, false),
11312        ColumnSchema::new("db", DataType::Text, false),
11313        ColumnSchema::new("user", DataType::Text, false),
11314        ColumnSchema::new("select_priv", DataType::Text, false),
11315    ];
11316    let rows = alloc::vec![Row::new(alloc::vec![
11317        Value::Text("localhost".into()),
11318        Value::Text("postgres".into()),
11319        Value::Text("root".into()),
11320        Value::Text("Y".into()),
11321    ])];
11322    (schema, rows)
11323}
11324
11325/// v7.17.0 Phase 3.P0-63 — synthesise
11326/// `information_schema.KEY_COLUMN_USAGE`. ORM migration tools
11327/// (Alembic, Sequelize, TypeORM) walk this view to discover FK
11328/// relationships in MySQL-flavoured introspection queries.
11329///
11330/// Schema columns exposed:
11331///   * CONSTRAINT_NAME (Text)
11332///   * TABLE_NAME (Text)
11333///   * COLUMN_NAME (Text)
11334///   * ORDINAL_POSITION (Int)
11335///   * REFERENCED_TABLE_NAME (Text) — empty for non-FK rows
11336///   * REFERENCED_COLUMN_NAME (Text) — empty for non-FK rows
11337fn synth_info_key_column_usage(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11338    let schema = alloc::vec![
11339        ColumnSchema::new("constraint_name", DataType::Text, false),
11340        ColumnSchema::new("table_name", DataType::Text, false),
11341        ColumnSchema::new("column_name", DataType::Text, false),
11342        ColumnSchema::new("ordinal_position", DataType::Int, false),
11343        ColumnSchema::new("referenced_table_name", DataType::Text, false),
11344        ColumnSchema::new("referenced_column_name", DataType::Text, false),
11345    ];
11346    let mut rows: Vec<Row> = Vec::new();
11347    for tname in cat.table_names() {
11348        let Some(t) = cat.get(&tname) else { continue };
11349        let cols = &t.schema().columns;
11350        let col_name_at = |pos: usize| -> String {
11351            cols.get(pos)
11352                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
11353        };
11354        // FKs.
11355        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11356            let conname = fk
11357                .name
11358                .clone()
11359                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11360            for (i, (&local, &parent)) in fk
11361                .local_columns
11362                .iter()
11363                .zip(fk.parent_columns.iter())
11364                .enumerate()
11365            {
11366                let parent_name = cat
11367                    .get(&fk.parent_table)
11368                    .and_then(|pt| pt.schema().columns.get(parent).map(|c| c.name.clone()))
11369                    .unwrap_or_else(|| alloc::format!("col{parent}"));
11370                #[allow(clippy::cast_possible_wrap)]
11371                let ordinal = (i + 1) as i32;
11372                rows.push(Row::new(alloc::vec![
11373                    Value::Text(conname.clone()),
11374                    Value::Text(tname.clone()),
11375                    Value::Text(col_name_at(local)),
11376                    Value::Int(ordinal),
11377                    Value::Text(fk.parent_table.clone()),
11378                    Value::Text(parent_name),
11379                ]));
11380            }
11381        }
11382        // PK / composite UC entries.
11383        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
11384            let conname = if uc.is_primary_key {
11385                alloc::format!("{}_pkey", tname)
11386            } else {
11387                alloc::format!("{}_uniq{ci}", tname)
11388            };
11389            for (i, &local) in uc.columns.iter().enumerate() {
11390                #[allow(clippy::cast_possible_wrap)]
11391                let ordinal = (i + 1) as i32;
11392                rows.push(Row::new(alloc::vec![
11393                    Value::Text(conname.clone()),
11394                    Value::Text(tname.clone()),
11395                    Value::Text(col_name_at(local)),
11396                    Value::Int(ordinal),
11397                    Value::Text(String::new()),
11398                    Value::Text(String::new()),
11399                ]));
11400            }
11401        }
11402    }
11403    (schema, rows)
11404}
11405
11406/// v7.17.0 Phase 3.P0-64 — synthesise
11407/// `information_schema.REFERENTIAL_CONSTRAINTS`. One row per FK.
11408fn synth_info_referential_constraints(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11409    let schema = alloc::vec![
11410        ColumnSchema::new("constraint_name", DataType::Text, false),
11411        ColumnSchema::new("table_name", DataType::Text, false),
11412        ColumnSchema::new("referenced_table_name", DataType::Text, false),
11413        ColumnSchema::new("update_rule", DataType::Text, false),
11414        ColumnSchema::new("delete_rule", DataType::Text, false),
11415    ];
11416    fn rule_name(a: spg_storage::FkAction) -> &'static str {
11417        match a {
11418            spg_storage::FkAction::Cascade => "CASCADE",
11419            spg_storage::FkAction::SetNull => "SET NULL",
11420            spg_storage::FkAction::SetDefault => "SET DEFAULT",
11421            spg_storage::FkAction::Restrict => "RESTRICT",
11422            spg_storage::FkAction::NoAction => "NO ACTION",
11423        }
11424    }
11425    let mut rows: Vec<Row> = Vec::new();
11426    for tname in cat.table_names() {
11427        let Some(t) = cat.get(&tname) else { continue };
11428        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11429            let conname = fk
11430                .name
11431                .clone()
11432                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11433            rows.push(Row::new(alloc::vec![
11434                Value::Text(conname),
11435                Value::Text(tname.clone()),
11436                Value::Text(fk.parent_table.clone()),
11437                Value::Text(rule_name(fk.on_update).into()),
11438                Value::Text(rule_name(fk.on_delete).into()),
11439            ]));
11440        }
11441    }
11442    (schema, rows)
11443}
11444
11445/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.STATISTICS`.
11446/// One row per (index × column) — admin tools walk this to
11447/// surface index-cardinality estimates.
11448fn synth_info_statistics(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11449    let schema = alloc::vec![
11450        ColumnSchema::new("table_name", DataType::Text, false),
11451        ColumnSchema::new("index_name", DataType::Text, false),
11452        ColumnSchema::new("column_name", DataType::Text, false),
11453        ColumnSchema::new("seq_in_index", DataType::Int, false),
11454        ColumnSchema::new("non_unique", DataType::Int, false),
11455        ColumnSchema::new("index_type", DataType::Text, false),
11456    ];
11457    let mut rows: Vec<Row> = Vec::new();
11458    for tname in cat.table_names() {
11459        let Some(t) = cat.get(&tname) else { continue };
11460        for idx in t.indices() {
11461            let col = t
11462                .schema()
11463                .columns
11464                .get(idx.column_position)
11465                .map_or("?".into(), |c| c.name.clone());
11466            rows.push(Row::new(alloc::vec![
11467                Value::Text(tname.clone()),
11468                Value::Text(idx.name.clone()),
11469                Value::Text(col),
11470                Value::Int(1),
11471                Value::Int(i32::from(!idx.is_unique)),
11472                Value::Text("BTREE".into()),
11473            ]));
11474        }
11475    }
11476    (schema, rows)
11477}
11478
11479/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.ROUTINES`.
11480/// SPG has no user-defined functions in v7.17 so the surface is
11481/// always empty; admin tools just need the table to exist.
11482fn synth_info_routines() -> (Vec<ColumnSchema>, Vec<Row>) {
11483    let schema = alloc::vec![
11484        ColumnSchema::new("routine_name", DataType::Text, false),
11485        ColumnSchema::new("routine_type", DataType::Text, false),
11486        ColumnSchema::new("data_type", DataType::Text, false),
11487    ];
11488    (schema, Vec::new())
11489}
11490
11491/// v7.17.0 Phase 3.P0-54 — synthesise `pg_catalog.pg_constraint`.
11492/// ORM compilers (Diesel, sea-orm) and admin tools probe this for
11493/// FK / UNIQUE / PK / CHECK definitions to surface relationship
11494/// graphs and validation rules. SPG ships one row per
11495/// uniqueness constraint + foreign key declared in the catalog.
11496///
11497/// Schema columns exposed:
11498///   * conname (Text) — constraint name (synthetic when anonymous)
11499///   * contype (Text) — `p` PK, `u` UNIQUE, `f` FK, `c` CHECK
11500///   * conrelid (Text) — owner table name
11501///   * confrelid (Text) — referenced parent table (FK only;
11502///     empty string otherwise)
11503///   * conkey (Text) — comma-separated column names
11504///   * confkey (Text) — comma-separated parent column names (FK only)
11505fn synth_pg_constraint(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11506    let schema = alloc::vec![
11507        ColumnSchema::new("conname", DataType::Text, false),
11508        ColumnSchema::new("contype", DataType::Text, false),
11509        ColumnSchema::new("conrelid", DataType::Text, false),
11510        ColumnSchema::new("confrelid", DataType::Text, false),
11511        ColumnSchema::new("conkey", DataType::Text, false),
11512        ColumnSchema::new("confkey", DataType::Text, false),
11513    ];
11514    let mut rows: Vec<Row> = Vec::new();
11515    for tname in cat.table_names() {
11516        let Some(t) = cat.get(&tname) else { continue };
11517        let cols = &t.schema().columns;
11518        let col_name_at = |pos: usize| -> String {
11519            cols.get(pos)
11520                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
11521        };
11522        // Uniqueness constraints (composite UNIQUE / PRIMARY KEY).
11523        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
11524            let kind = if uc.is_primary_key { "p" } else { "u" };
11525            let conname = if uc.is_primary_key {
11526                alloc::format!("{}_pkey", tname)
11527            } else {
11528                alloc::format!("{}_uniq{ci}", tname)
11529            };
11530            let conkey: Vec<String> = uc.columns.iter().map(|&p| col_name_at(p)).collect();
11531            rows.push(Row::new(alloc::vec![
11532                Value::Text(conname),
11533                Value::Text(kind.into()),
11534                Value::Text(tname.clone()),
11535                Value::Text(String::new()),
11536                Value::Text(conkey.join(",")),
11537                Value::Text(String::new()),
11538            ]));
11539        }
11540        // Single-column PK / UNIQUE indexes that have no
11541        // matching entry in `uniqueness_constraints` (the engine
11542        // creates only the BTree index for the bare-column case;
11543        // composite forms ride the UC path above).
11544        for idx in t.indices() {
11545            if !idx.is_unique {
11546                continue;
11547            }
11548            let is_primary = idx.name.ends_with("_pkey");
11549            let conname = idx.name.clone();
11550            let kind = if is_primary { "p" } else { "u" };
11551            let col_name = col_name_at(idx.column_position);
11552            // Skip if already emitted via the UC loop above (same
11553            // tuple shape — single-column).
11554            let already = t
11555                .schema()
11556                .uniqueness_constraints
11557                .iter()
11558                .any(|uc| uc.columns.len() == 1 && uc.columns[0] == idx.column_position);
11559            if already {
11560                continue;
11561            }
11562            rows.push(Row::new(alloc::vec![
11563                Value::Text(conname),
11564                Value::Text(kind.into()),
11565                Value::Text(tname.clone()),
11566                Value::Text(String::new()),
11567                Value::Text(col_name),
11568                Value::Text(String::new()),
11569            ]));
11570        }
11571        // Foreign keys.
11572        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11573            let conname = fk
11574                .name
11575                .clone()
11576                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11577            let conkey: Vec<String> = fk.local_columns.iter().map(|&p| col_name_at(p)).collect();
11578            // Parent column names: look up the parent table's
11579            // schema if it exists; otherwise emit positions.
11580            let confkey: Vec<String> = if let Some(parent) = cat.get(&fk.parent_table) {
11581                fk.parent_columns
11582                    .iter()
11583                    .map(|&p| {
11584                        parent
11585                            .schema()
11586                            .columns
11587                            .get(p)
11588                            .map_or_else(|| alloc::format!("col{p}"), |c| c.name.clone())
11589                    })
11590                    .collect()
11591            } else {
11592                fk.parent_columns
11593                    .iter()
11594                    .map(|p| alloc::format!("col{p}"))
11595                    .collect()
11596            };
11597            rows.push(Row::new(alloc::vec![
11598                Value::Text(conname),
11599                Value::Text("f".into()),
11600                Value::Text(tname.clone()),
11601                Value::Text(fk.parent_table.clone()),
11602                Value::Text(conkey.join(",")),
11603                Value::Text(confkey.join(",")),
11604            ]));
11605        }
11606    }
11607    (schema, rows)
11608}
11609
11610/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_database`.
11611/// SPG is single-database so we surface a single row keyed on the
11612/// canonical `postgres` database name (matching what every PG
11613/// admin tool's startup screen expects to find).
11614fn synth_pg_database(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11615    let schema = alloc::vec![
11616        ColumnSchema::new("oid", DataType::BigInt, false),
11617        ColumnSchema::new("datname", DataType::Text, false),
11618        ColumnSchema::new("datdba", DataType::BigInt, false),
11619        ColumnSchema::new("encoding", DataType::Int, false),
11620        ColumnSchema::new("datcollate", DataType::Text, false),
11621    ];
11622    let rows = alloc::vec![Row::new(alloc::vec![
11623        Value::BigInt(16384),
11624        Value::Text("postgres".into()),
11625        Value::BigInt(10),
11626        Value::Int(6), // UTF8
11627        Value::Text("en_US.UTF-8".into()),
11628    ])];
11629    (schema, rows)
11630}
11631
11632/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_roles`. PG's
11633/// pg_roles is a view over pg_authid showing all roles. SPG ships
11634/// one row per declared user from the engine's UserStore so admin
11635/// tool startup screens can populate.
11636fn synth_pg_roles(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11637    let schema = alloc::vec![
11638        ColumnSchema::new("oid", DataType::BigInt, false),
11639        ColumnSchema::new("rolname", DataType::Text, false),
11640        ColumnSchema::new("rolsuper", DataType::Bool, false),
11641        ColumnSchema::new("rolinherit", DataType::Bool, false),
11642        ColumnSchema::new("rolcanlogin", DataType::Bool, false),
11643    ];
11644    let mut rows: Vec<Row> = Vec::new();
11645    let oid: i64 = 10;
11646    for (i, (name, _)) in engine.users.iter().enumerate() {
11647        rows.push(Row::new(alloc::vec![
11648            Value::BigInt(oid + (i as i64) + 1),
11649            Value::Text(name.to_string()),
11650            Value::Bool(false),
11651            Value::Bool(true),
11652            Value::Bool(true),
11653        ]));
11654    }
11655    // Always include `postgres` as the bootstrap superuser if not
11656    // already present — admin tools probe for it.
11657    if !rows
11658        .iter()
11659        .any(|r| matches!(&r.values[1], Value::Text(s) if s == "postgres"))
11660    {
11661        rows.insert(
11662            0,
11663            Row::new(alloc::vec![
11664                Value::BigInt(10),
11665                Value::Text("postgres".into()),
11666                Value::Bool(true),
11667                Value::Bool(true),
11668                Value::Bool(true),
11669            ]),
11670        );
11671    }
11672    (schema, rows)
11673}
11674
11675/// v7.17.0 Phase 3.P0-56 — synthesise `pg_catalog.pg_views`. PG's
11676/// pg_views is a view listing every catalog view; SPG ships one
11677/// row per declared view + its definition text.
11678/// Synthesise `pg_catalog.pg_extension`. SPG ships its "extension"
11679/// surfaces natively (vector, pg_trgm, plpgsql-shaped DO blocks), so
11680/// the table lists those as installed — `SELECT … FROM pg_extension
11681/// WHERE extname = 'vector'` probes from PG clients (mailrs embed
11682/// round-12) answer truthfully about capability presence.
11683fn synth_pg_extension() -> (Vec<ColumnSchema>, Vec<Row>) {
11684    let schema = alloc::vec![
11685        ColumnSchema::new("oid", DataType::BigInt, false),
11686        ColumnSchema::new("extname", DataType::Text, false),
11687        ColumnSchema::new("extversion", DataType::Text, false),
11688        ColumnSchema::new("extnamespace", DataType::Text, false),
11689    ];
11690    let exts: &[(&str, &str)] = &[("plpgsql", "1.0"), ("vector", "0.8.0"), ("pg_trgm", "1.6")];
11691    let rows = exts
11692        .iter()
11693        .enumerate()
11694        .map(|(i, (name, ver))| {
11695            Row::new(alloc::vec![
11696                Value::BigInt(16384 + i as i64),
11697                Value::Text((*name).into()),
11698                Value::Text((*ver).into()),
11699                Value::Text("pg_catalog".into()),
11700            ])
11701        })
11702        .collect();
11703    (schema, rows)
11704}
11705
11706fn synth_pg_views(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11707    let schema = alloc::vec![
11708        ColumnSchema::new("schemaname", DataType::Text, false),
11709        ColumnSchema::new("viewname", DataType::Text, false),
11710        ColumnSchema::new("definition", DataType::Text, false),
11711    ];
11712    let mut rows: Vec<Row> = Vec::new();
11713    for (name, def) in cat.views() {
11714        rows.push(Row::new(alloc::vec![
11715            Value::Text("public".into()),
11716            Value::Text(name.clone()),
11717            Value::Text(def.body.clone()),
11718        ]));
11719    }
11720    (schema, rows)
11721}
11722
11723/// v7.17.0 Phase 3.P0-57 — synthesise `pg_catalog.pg_settings`. ORM
11724/// connection-checkers (sqlx pre-flight, Diesel migrator) and admin
11725/// tools read `pg_settings` to discover server-side configuration.
11726/// SPG surfaces every session_param + a small set of canonical PG
11727/// defaults so the pre-flight queries match.
11728fn synth_pg_settings(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11729    let schema = alloc::vec![
11730        ColumnSchema::new("name", DataType::Text, false),
11731        ColumnSchema::new("setting", DataType::Text, false),
11732        ColumnSchema::new("category", DataType::Text, false),
11733    ];
11734    let mut rows: Vec<Row> = Vec::new();
11735    // Canonical defaults every admin tool expects to find.
11736    let defaults: &[(&str, &str, &str)] = &[
11737        ("server_version", "16.0 (spg)", "Preset Options"),
11738        ("server_encoding", "UTF8", "Client Connection Defaults"),
11739        ("client_encoding", "UTF8", "Client Connection Defaults"),
11740        ("DateStyle", "ISO, MDY", "Client Connection Defaults"),
11741        ("TimeZone", "UTC", "Client Connection Defaults"),
11742        ("standard_conforming_strings", "on", "Compatibility"),
11743        ("integer_datetimes", "on", "Compatibility"),
11744        ("max_connections", "100", "Connections and Authentication"),
11745    ];
11746    for &(name, val, cat) in defaults {
11747        rows.push(Row::new(alloc::vec![
11748            Value::Text(name.into()),
11749            Value::Text(val.into()),
11750            Value::Text(cat.into()),
11751        ]));
11752    }
11753    // Session-set params override the static defaults.
11754    for (k, v) in &engine.session_params {
11755        if !defaults
11756            .iter()
11757            .any(|(n, _, _)| (*n).eq_ignore_ascii_case(k))
11758        {
11759            rows.push(Row::new(alloc::vec![
11760                Value::Text(k.clone()),
11761                Value::Text(v.clone()),
11762                Value::Text("Session".into()),
11763            ]));
11764        }
11765    }
11766    (schema, rows)
11767}
11768
11769/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_indexes`.
11770/// PG's pg_indexes is a real view on pg_index + pg_class + pg_attribute.
11771/// SPG ships it as a synthesised flat table so admin tools (pgAdmin,
11772/// DataGrip) can list indexes by tablename without joining four catalogs.
11773///
11774/// Schema columns exposed:
11775///   * schemaname (Text) — always `public`
11776///   * tablename (Text)
11777///   * indexname (Text)
11778///   * indexdef (Text) — best-effort CREATE INDEX DDL
11779fn synth_pg_indexes(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11780    let schema = alloc::vec![
11781        ColumnSchema::new("schemaname", DataType::Text, false),
11782        ColumnSchema::new("tablename", DataType::Text, false),
11783        ColumnSchema::new("indexname", DataType::Text, false),
11784        ColumnSchema::new("indexdef", DataType::Text, false),
11785    ];
11786    let mut rows: Vec<Row> = Vec::new();
11787    for tname in cat.table_names() {
11788        let Some(t) = cat.get(&tname) else { continue };
11789        for idx in t.indices() {
11790            let col_name = t
11791                .schema()
11792                .columns
11793                .get(idx.column_position)
11794                .map_or("?".into(), |c| c.name.clone());
11795            let unique_kw = if idx.is_unique { "UNIQUE " } else { "" };
11796            let indexdef = alloc::format!(
11797                "CREATE {unique_kw}INDEX {} ON public.{} ({})",
11798                idx.name,
11799                tname,
11800                col_name
11801            );
11802            rows.push(Row::new(alloc::vec![
11803                Value::Text("public".into()),
11804                Value::Text(tname.clone()),
11805                Value::Text(idx.name.clone()),
11806                Value::Text(indexdef),
11807            ]));
11808        }
11809    }
11810    (schema, rows)
11811}
11812
11813/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_index`. The
11814/// "raw" pg_index catalog used by PG-internal tooling for index
11815/// flags and ordinal information. SPG ships the columns ORM probes
11816/// actually filter on.
11817///
11818/// Schema columns exposed:
11819///   * indexrelid (BigInt) — index OID (synthetic = position+1)
11820///   * indrelid (BigInt) — table OID (synthetic = position+1)
11821///   * indnatts (Int) — number of indexed columns
11822///   * indisunique (Bool)
11823///   * indisprimary (Bool)
11824fn synth_pg_index_raw(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11825    let schema = alloc::vec![
11826        ColumnSchema::new("indexrelid", DataType::BigInt, false),
11827        ColumnSchema::new("indrelid", DataType::BigInt, false),
11828        ColumnSchema::new("indnatts", DataType::Int, false),
11829        ColumnSchema::new("indisunique", DataType::Bool, false),
11830        ColumnSchema::new("indisprimary", DataType::Bool, false),
11831    ];
11832    let mut rows: Vec<Row> = Vec::new();
11833    let mut idx_oid: i64 = 100_000;
11834    for (table_idx, tname) in cat.table_names().iter().enumerate() {
11835        let Some(t) = cat.get(tname) else { continue };
11836        for idx in t.indices() {
11837            idx_oid += 1;
11838            #[allow(clippy::cast_possible_wrap)]
11839            let nattrs = (1 + idx.extra_column_positions.len()) as i32;
11840            // is_primary: SPG / PG flag the primary via the
11841            // index name convention `<table>_pkey`.
11842            let is_primary = idx.name.ends_with("_pkey");
11843            rows.push(Row::new(alloc::vec![
11844                Value::BigInt(idx_oid),
11845                Value::BigInt((table_idx + 1) as i64),
11846                Value::Int(nattrs),
11847                Value::Bool(idx.is_unique),
11848                Value::Bool(is_primary),
11849            ]));
11850        }
11851    }
11852    (schema, rows)
11853}
11854
11855/// v7.17.0 Phase 3.P0-52 — synthesise `pg_catalog.pg_namespace`.
11856/// SPG is single-schema so we expose the canonical PG schemas:
11857/// `public` (user-facing), `pg_catalog` (built-in), and
11858/// `information_schema` (PG meta).
11859fn synth_pg_namespace(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11860    let schema = alloc::vec![
11861        ColumnSchema::new("oid", DataType::BigInt, false),
11862        ColumnSchema::new("nspname", DataType::Text, false),
11863        ColumnSchema::new("nspowner", DataType::BigInt, false),
11864    ];
11865    let rows = alloc::vec![
11866        Row::new(alloc::vec![
11867            Value::BigInt(11),
11868            Value::Text("pg_catalog".into()),
11869            Value::BigInt(10),
11870        ]),
11871        Row::new(alloc::vec![
11872            Value::BigInt(2200),
11873            Value::Text("public".into()),
11874            Value::BigInt(10),
11875        ]),
11876        Row::new(alloc::vec![
11877            Value::BigInt(13000),
11878            Value::Text("information_schema".into()),
11879            Value::BigInt(10),
11880        ]),
11881    ];
11882    (schema, rows)
11883}
11884
11885/// v7.16.2 — drop the synthesised meta view into the enriched
11886/// catalog so the regular FROM-resolution path can see it.
11887fn materialise_meta_view(
11888    catalog: &mut Catalog,
11889    name: &str,
11890    columns: Vec<ColumnSchema>,
11891    rows: Vec<Row>,
11892) -> Result<(), EngineError> {
11893    let schema = TableSchema::new(name.to_string(), columns);
11894    catalog.create_table(schema).map_err(EngineError::Storage)?;
11895    let table = catalog
11896        .get_mut(name)
11897        .expect("just-created meta view must exist");
11898    for row in rows {
11899        table.insert(row).map_err(EngineError::Storage)?;
11900    }
11901    Ok(())
11902}
11903
11904/// v7.16.2 — true when the SELECT statement references any
11905/// `__spg_info_*` or `__spg_pg_*` synthetic table name (the
11906/// parser produces these for `information_schema.X` /
11907/// `pg_catalog.X`). Used by `exec_select_cancel` to short-
11908/// circuit into the meta-view materialisation path.
11909/// v7.17.0 Phase 1.2 — append the names of any catalog-known
11910/// views referenced by `tref` to `into`. Helper for
11911/// `Engine::expand_views_in_select`. A view that's been already
11912/// materialised as a table (e.g. via the synthetic CTE pass for
11913/// SELECT FROM v) is skipped — the table form wins so the
11914/// recursive exec_select_cancel call inside exec_with_ctes
11915/// doesn't re-expand and trigger the CTE-shadow guard.
11916fn collect_view_refs(
11917    tref: &spg_sql::ast::TableRef,
11918    cat: &spg_storage::Catalog,
11919    into: &mut Vec<String>,
11920) {
11921    if cat.views().contains_key(&tref.name)
11922        && cat.get(&tref.name).is_none()
11923        && !into.iter().any(|n| n == &tref.name)
11924    {
11925        into.push(tref.name.clone());
11926    }
11927}
11928
11929fn select_references_meta_view(stmt: &SelectStatement) -> bool {
11930    fn is_meta(name: &str) -> bool {
11931        name.starts_with("__spg_info_")
11932            || name.starts_with("__spg_pg_")
11933            || name.starts_with("__spg_mysql_")
11934    }
11935    if let Some(from) = &stmt.from {
11936        if is_meta(&from.primary.name) {
11937            return true;
11938        }
11939        for j in &from.joins {
11940            if is_meta(&j.table.name) {
11941                return true;
11942            }
11943        }
11944    }
11945    for cte in &stmt.ctes {
11946        if select_references_meta_view(&cte.body) {
11947            return true;
11948        }
11949    }
11950    false
11951}
11952
11953/// v7.16.2 — collect every meta-view name a SELECT touches.
11954/// Returns a deduplicated, sorted list. Caller materialises
11955/// each one into the enriched catalog before re-running the
11956/// SELECT. Walks JOINs, CTEs, and the primary FROM.
11957fn collect_meta_view_names(
11958    stmt: &SelectStatement,
11959    into: &mut alloc::collections::BTreeSet<String>,
11960) {
11961    fn is_meta(name: &str) -> bool {
11962        name.starts_with("__spg_info_")
11963            || name.starts_with("__spg_pg_")
11964            || name.starts_with("__spg_mysql_")
11965    }
11966    if let Some(from) = &stmt.from {
11967        if is_meta(&from.primary.name) {
11968            into.insert(from.primary.name.clone());
11969        }
11970        for j in &from.joins {
11971            if is_meta(&j.table.name) {
11972                into.insert(j.table.name.clone());
11973            }
11974        }
11975    }
11976    for cte in &stmt.ctes {
11977        collect_meta_view_names(&cte.body, into);
11978    }
11979}
11980
11981fn infer_column_types(columns: &[ColumnSchema], rows: &[Row]) -> Vec<ColumnSchema> {
11982    let mut out = columns.to_vec();
11983    for (col_idx, col) in out.iter_mut().enumerate() {
11984        if col.ty != DataType::Text {
11985            continue;
11986        }
11987        let mut inferred: Option<DataType> = None;
11988        let mut all_null = true;
11989        for row in rows {
11990            let Some(v) = row.values.get(col_idx) else {
11991                continue;
11992            };
11993            let ty = match v {
11994                Value::Null => continue,
11995                Value::SmallInt(_) => DataType::SmallInt,
11996                Value::Int(_) => DataType::Int,
11997                Value::BigInt(_) => DataType::BigInt,
11998                Value::Float(_) => DataType::Float,
11999                Value::Bool(_) => DataType::Bool,
12000                Value::Vector(_) => DataType::Vector {
12001                    dim: 0,
12002                    encoding: VecEncoding::F32,
12003                },
12004                _ => DataType::Text,
12005            };
12006            all_null = false;
12007            inferred = Some(match inferred {
12008                None => ty,
12009                Some(prev) if prev == ty => prev,
12010                Some(_) => DataType::Text,
12011            });
12012        }
12013        if let Some(t) = inferred {
12014            col.ty = t;
12015            col.nullable = true;
12016        } else if all_null {
12017            col.nullable = true;
12018        }
12019    }
12020    out
12021}
12022
12023/// v4.26: render a human-readable plan tree for `EXPLAIN <select>`.
12024/// Lines are pushed into `out`; `depth` controls indentation. We
12025/// describe the rewritten SELECT — what the executor *would* do —
12026/// using the engine handle to spot indexed lookups and table shapes.
12027#[allow(clippy::too_many_lines, clippy::format_push_string)]
12028/// v6.2.4 — Walk every line of the rendered plan tree and append
12029/// per-operator stats. Lines that name a known operator get
12030/// `(rows=N)` (`actual_rows` of the top-level operator equals the
12031/// final result row count; scans report their catalog row count
12032/// as the rows-considered metric). Other lines — Filter / Join /
12033/// GroupBy / OrderBy etc. — are marked `(—)` so the surface is
12034/// complete-by-construction; v6.2.5 fills these in via inline
12035/// executor counters.
12036/// v6.8.3 — surface "CREATE INDEX …" suggestions for every
12037/// `(table, column)` pair the query touches via WHERE / JOIN
12038/// that doesn't already have an index on the owning table.
12039/// Walks the SELECT's FROM clauses + WHERE expression tree;
12040/// returns one line per missing index. Deterministic order:
12041/// FROM-clause iteration order, then column-reference walk
12042/// order inside each WHERE. Each suggestion is a copy-pastable
12043/// DDL string.
12044fn build_index_suggestions(stmt: &SelectStatement, engine: &Engine) -> Vec<String> {
12045    use alloc::collections::BTreeSet;
12046    let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
12047    let mut out: Vec<String> = Vec::new();
12048    let cat = engine.active_catalog();
12049    // Build a (table, qualifier-or-alias) list from the FROM clause
12050    // so unqualified column refs in WHERE resolve to the correct
12051    // table.
12052    let Some(from) = &stmt.from else {
12053        return out;
12054    };
12055    let mut tables: Vec<String> = Vec::new();
12056    tables.push(from.primary.name.clone());
12057    for j in &from.joins {
12058        tables.push(j.table.name.clone());
12059    }
12060    // Collect column refs from the WHERE expression. JOIN ON
12061    // predicates also feed in.
12062    let mut col_refs: Vec<spg_sql::ast::ColumnName> = Vec::new();
12063    if let Some(w) = &stmt.where_ {
12064        collect_column_refs(w, &mut col_refs);
12065    }
12066    for j in &from.joins {
12067        if let Some(on) = &j.on {
12068            collect_column_refs(on, &mut col_refs);
12069        }
12070    }
12071    for cn in &col_refs {
12072        // Resolve owner table: explicit qualifier first, else
12073        // first table in FROM that has a column of this name.
12074        let owner: Option<String> = if let Some(q) = &cn.qualifier {
12075            tables.iter().find(|t| t == &q).cloned()
12076        } else {
12077            tables.iter().find_map(|t| {
12078                cat.get(t).and_then(|tbl| {
12079                    if tbl.schema().column_position(&cn.name).is_some() {
12080                        Some(t.clone())
12081                    } else {
12082                        None
12083                    }
12084                })
12085            })
12086        };
12087        let Some(owner) = owner else {
12088            continue;
12089        };
12090        let Some(tbl) = cat.get(&owner) else {
12091            continue;
12092        };
12093        let Some(col_pos) = tbl.schema().column_position(&cn.name) else {
12094            continue;
12095        };
12096        // Skip if any BTree index already covers this column as
12097        // its key.
12098        let already_indexed = tbl.indices().iter().any(|i| {
12099            matches!(i.kind, spg_storage::IndexKind::BTree(_))
12100                && i.column_position == col_pos
12101                && i.expression.is_none()
12102                && i.partial_predicate.is_none()
12103        });
12104        if already_indexed {
12105            continue;
12106        }
12107        if seen.insert((owner.clone(), cn.name.clone())) {
12108            out.push(alloc::format!(
12109                "SUGGEST: CREATE INDEX ix_{}_{} ON {} ({})",
12110                owner,
12111                cn.name,
12112                owner,
12113                cn.name
12114            ));
12115        }
12116    }
12117    out
12118}
12119
12120/// Walks an `Expr` and pushes every `ColumnName` it references.
12121/// Order is depth-first, left-to-right.
12122fn collect_column_refs(expr: &Expr, out: &mut Vec<spg_sql::ast::ColumnName>) {
12123    match expr {
12124        Expr::Column(cn) => out.push(cn.clone()),
12125        Expr::FunctionCall { args, .. } => {
12126            for a in args {
12127                collect_column_refs(a, out);
12128            }
12129        }
12130        Expr::Binary { lhs, rhs, .. } => {
12131            collect_column_refs(lhs, out);
12132            collect_column_refs(rhs, out);
12133        }
12134        Expr::Unary { expr: e, .. } => collect_column_refs(e, out),
12135        _ => {}
12136    }
12137}
12138
12139fn annotate_explain_lines(lines: &mut [String], total_rows: usize, engine: &Engine) {
12140    let catalog = engine.active_catalog();
12141    let cold_ids = catalog.cold_segment_ids_global();
12142    let any_cold = !cold_ids.is_empty();
12143    let cold_ids_repr = if any_cold {
12144        let mut s = alloc::string::String::from("[");
12145        for (i, id) in cold_ids.iter().enumerate() {
12146            if i > 0 {
12147                s.push(',');
12148            }
12149            s.push_str(&alloc::format!("{id}"));
12150        }
12151        s.push(']');
12152        s
12153    } else {
12154        alloc::string::String::new()
12155    };
12156    for (idx, line) in lines.iter_mut().enumerate() {
12157        let trimmed = line.trim_start();
12158        let is_top_level = idx == 0;
12159        if is_top_level {
12160            line.push_str(&alloc::format!(" (rows={total_rows})"));
12161            continue;
12162        }
12163        if let Some(rest) = trimmed.strip_prefix("From: ") {
12164            let (name, scan_kind) = match rest.split_once(" [") {
12165                Some((n, k)) => (n.trim(), k.trim_end_matches(']')),
12166                None => (rest.trim(), ""),
12167            };
12168            let bare = name.split_whitespace().next().unwrap_or(name);
12169            let hot = catalog.get(bare).map(|t| t.rows().len());
12170            // v6.2.7 — `cold_segments=[id0,id1,…]` enumerates every
12171            // cold-tier segment the scan COULD have walked. v6.2.x
12172            // can tighten to per-table by walking the table's
12173            // BTree-index cold locators.
12174            let annot = match (hot, scan_kind) {
12175                (Some(h), "full scan") => {
12176                    let mut s = alloc::format!(" (hot_rows={h}");
12177                    if any_cold {
12178                        s.push_str(&alloc::format!(
12179                            ", cold_tier=present, cold_segments={cold_ids_repr}"
12180                        ));
12181                    }
12182                    s.push(')');
12183                    s
12184                }
12185                (Some(h), "index seek") => {
12186                    let mut s = alloc::format!(" (hot_rows≤{h}");
12187                    if any_cold {
12188                        s.push_str(&alloc::format!(
12189                            ", cold_tier=present, cold_segments={cold_ids_repr}"
12190                        ));
12191                    }
12192                    s.push(')');
12193                    s
12194                }
12195                _ => " (rows=—)".to_string(),
12196            };
12197            line.push_str(&annot);
12198            continue;
12199        }
12200        // Filter / GroupBy / Having / OrderBy / Limit / Join etc.
12201        line.push_str(" (rows=—)");
12202    }
12203}
12204
12205fn explain_select(stmt: &SelectStatement, engine: &Engine, depth: usize, out: &mut Vec<String>) {
12206    let pad = "  ".repeat(depth);
12207    // 1) Top-level operator label.
12208    let top = if !stmt.ctes.is_empty() {
12209        if stmt.ctes.iter().any(|c| c.recursive) {
12210            "CTEScan (WITH RECURSIVE)"
12211        } else {
12212            "CTEScan (WITH)"
12213        }
12214    } else if !stmt.unions.is_empty() {
12215        "UnionScan"
12216    } else if select_has_window(stmt) {
12217        "WindowAgg"
12218    } else if aggregate::uses_aggregate(stmt) {
12219        "Aggregate"
12220    } else if stmt.distinct {
12221        "Distinct"
12222    } else if stmt.from.is_some() {
12223        "TableScan"
12224    } else {
12225        "Result"
12226    };
12227    out.push(alloc::format!("{pad}{top}"));
12228    let child = "  ".repeat(depth + 1);
12229    // 2) CTE bodies.
12230    for cte in &stmt.ctes {
12231        let head = if cte.recursive {
12232            alloc::format!("{child}CTE (recursive): {}", cte.name)
12233        } else {
12234            alloc::format!("{child}CTE: {}", cte.name)
12235        };
12236        out.push(head);
12237        explain_select(&cte.body, engine, depth + 2, out);
12238    }
12239    // 3) FROM details — primary table + joins, index hits.
12240    if let Some(from) = &stmt.from {
12241        let mut tag = alloc::format!("{child}From: {}", from.primary.name);
12242        if let Some(alias) = &from.primary.alias {
12243            tag.push_str(&alloc::format!(" AS {alias}"));
12244        }
12245        // Try to detect an index-seek opportunity on WHERE against
12246        // the primary table — same heuristic the executor uses.
12247        if let Some(w) = &stmt.where_
12248            && let Some(table) = engine.active_catalog().get(&from.primary.name)
12249        {
12250            let alias = from.primary.alias.as_deref().unwrap_or(&from.primary.name);
12251            let cols = &table.schema().columns;
12252            if try_index_seek(w, cols, engine.active_catalog(), table, alias).is_some() {
12253                tag.push_str(" [index seek]");
12254            } else {
12255                tag.push_str(" [full scan]");
12256            }
12257        } else {
12258            tag.push_str(" [full scan]");
12259        }
12260        out.push(tag);
12261        for j in &from.joins {
12262            let kind = match j.kind {
12263                spg_sql::ast::JoinKind::Inner => "INNER JOIN",
12264                spg_sql::ast::JoinKind::Left => "LEFT JOIN",
12265                spg_sql::ast::JoinKind::Cross => "CROSS JOIN",
12266            };
12267            let mut s = alloc::format!("{child}{kind}: {}", j.table.name);
12268            if let Some(alias) = &j.table.alias {
12269                s.push_str(&alloc::format!(" AS {alias}"));
12270            }
12271            if j.on.is_some() {
12272                s.push_str(" (ON …)");
12273            }
12274            out.push(s);
12275        }
12276    }
12277    // 4) WHERE / GROUP BY / HAVING / ORDER BY / LIMIT / OFFSET.
12278    if let Some(w) = &stmt.where_ {
12279        let mut s = alloc::format!("{child}Filter: {w}");
12280        if expr_has_subquery(w) {
12281            s.push_str(" [subquery]");
12282        }
12283        out.push(s);
12284    }
12285    if let Some(gs) = &stmt.group_by {
12286        let mut parts = Vec::new();
12287        for g in gs {
12288            parts.push(alloc::format!("{g}"));
12289        }
12290        out.push(alloc::format!("{child}GroupBy: {}", parts.join(", ")));
12291    }
12292    if let Some(h) = &stmt.having {
12293        out.push(alloc::format!("{child}Having: {h}"));
12294    }
12295    for o in &stmt.order_by {
12296        let dir = if o.desc { "DESC" } else { "ASC" };
12297        out.push(alloc::format!("{child}OrderBy: {} {dir}", o.expr));
12298    }
12299    if let Some(lim) = stmt.limit {
12300        out.push(alloc::format!("{child}Limit: {lim}"));
12301    }
12302    if let Some(off) = stmt.offset {
12303        out.push(alloc::format!("{child}Offset: {off}"));
12304    }
12305    // 5) Projection — collapse Wildcard or render N items.
12306    if stmt
12307        .items
12308        .iter()
12309        .any(|it| matches!(it, SelectItem::Wildcard))
12310    {
12311        out.push(alloc::format!("{child}Project: *"));
12312    } else {
12313        out.push(alloc::format!(
12314            "{child}Project: {} item(s)",
12315            stmt.items.len()
12316        ));
12317    }
12318    // 6) Recurse into UNION peers.
12319    for (kind, peer) in &stmt.unions {
12320        let label = match kind {
12321            UnionKind::All => "UNION ALL",
12322            UnionKind::Distinct => "UNION",
12323        };
12324        out.push(alloc::format!("{child}{label}"));
12325        explain_select(peer, engine, depth + 2, out);
12326    }
12327}
12328
12329/// v4.23: recognise the engine errors that indicate the inner
12330/// SELECT couldn't be evaluated in isolation because it references
12331/// an outer column — used by `subquery_replacement` to skip
12332/// materialisation and let row-eval handle it instead.
12333fn is_correlation_error(e: &EngineError) -> bool {
12334    matches!(
12335        e,
12336        EngineError::Eval(
12337            eval::EvalError::ColumnNotFound { .. } | eval::EvalError::UnknownQualifier { .. }
12338        )
12339    )
12340}
12341
12342/// v4.23: walk every Expr in `stmt` and replace each Column ref
12343/// that targets the outer scope (qualifier matches the outer
12344/// table alias) with a Literal carrying the outer row's value.
12345/// Conservative: only qualified refs are substituted, so the user
12346/// must write `outer_alias.col` to reference an outer column. This
12347/// matches PG's lexical scoping for correlated subqueries and
12348/// avoids accidentally rebinding inner columns of the same name.
12349/// v7.17.0 Phase 3.P0-41 — LATERAL peer descriptor. Either eagerly
12350/// materialised (every regular table / unnest / generate_series) or
12351/// lateral (subquery re-evaluated per outer row).
12352struct JoinedPeer<'a> {
12353    eager_rows: Option<Vec<Row>>,
12354    cols: Vec<ColumnSchema>,
12355    alias: String,
12356    kind: JoinKind,
12357    on: Option<&'a Expr>,
12358    lateral: Option<&'a SelectStatement>,
12359}
12360
12361/// v7.17.0 Phase 3.P0-41 — synthesise a column name for a LATERAL
12362/// projection item that has no explicit alias. PG names anonymous
12363/// projection items by the function call's name or by `column<i>`.
12364/// SPG mirrors the latter (lower-overhead than walking arbitrary
12365/// Expr shapes) so the probe-schema fallback path produces stable
12366/// names for the lateral peer's columns.
12367fn synth_lateral_col_name(expr: &Expr, idx: usize) -> String {
12368    match expr {
12369        // Bare column reference — use the column's own name.
12370        Expr::Column(c) => c.name.clone(),
12371        // Function call — use the function name (PG canonical:
12372        // `count` / `max` / `lower` …).
12373        Expr::FunctionCall { name, .. } => name.clone(),
12374        // Cast — drill into the inner expression.
12375        Expr::Cast { expr: inner, .. } => synth_lateral_col_name(inner, idx),
12376        // Everything else falls back to PG's `column<N>` placeholder.
12377        _ => alloc::format!("column{}", idx + 1),
12378    }
12379}
12380
12381/// v7.17.0 Phase 3.P0-41 — substitute every `<alias>.<col>` Expr
12382/// reference whose `<alias>.<col>` exists in the outer composite
12383/// schema with the matching value from the outer row. Walks the
12384/// entire SELECT body (items, WHERE, GROUP BY, HAVING, ORDER BY,
12385/// UNION peers) so any depth of outer reference inside the
12386/// LATERAL subquery resolves before execution.
12387fn substitute_outer_columns_multi(
12388    stmt: &mut SelectStatement,
12389    outer_row: &Row,
12390    outer_schema: &[ColumnSchema],
12391) {
12392    substitute_outer_in_select(stmt, outer_row, outer_schema);
12393}
12394
12395fn substitute_outer_in_select(
12396    stmt: &mut SelectStatement,
12397    outer_row: &Row,
12398    outer_schema: &[ColumnSchema],
12399) {
12400    for item in &mut stmt.items {
12401        if let SelectItem::Expr { expr, .. } = item {
12402            substitute_outer_in_expr(expr, outer_row, outer_schema);
12403        }
12404    }
12405    if let Some(w) = &mut stmt.where_ {
12406        substitute_outer_in_expr(w, outer_row, outer_schema);
12407    }
12408    if let Some(gs) = &mut stmt.group_by {
12409        for g in gs {
12410            substitute_outer_in_expr(g, outer_row, outer_schema);
12411        }
12412    }
12413    if let Some(h) = &mut stmt.having {
12414        substitute_outer_in_expr(h, outer_row, outer_schema);
12415    }
12416    for o in &mut stmt.order_by {
12417        substitute_outer_in_expr(&mut o.expr, outer_row, outer_schema);
12418    }
12419    for (_, peer) in &mut stmt.unions {
12420        substitute_outer_in_select(peer, outer_row, outer_schema);
12421    }
12422}
12423
12424fn substitute_outer_in_expr(e: &mut Expr, outer_row: &Row, outer_schema: &[ColumnSchema]) {
12425    if let Expr::Column(c) = e
12426        && let Some(qual) = &c.qualifier
12427    {
12428        let composite = alloc::format!("{qual}.{}", c.name);
12429        if let Some(idx) = outer_schema
12430            .iter()
12431            .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
12432        {
12433            let v = outer_row.values.get(idx).cloned().unwrap_or(Value::Null);
12434            if let Ok(lit) = value_to_literal_expr(v) {
12435                *e = lit;
12436                return;
12437            }
12438        }
12439    }
12440    match e {
12441        Expr::Binary { lhs, rhs, .. } => {
12442            substitute_outer_in_expr(lhs, outer_row, outer_schema);
12443            substitute_outer_in_expr(rhs, outer_row, outer_schema);
12444        }
12445        Expr::Unary { expr: inner, .. } => {
12446            substitute_outer_in_expr(inner, outer_row, outer_schema);
12447        }
12448        Expr::FunctionCall { args, .. } => {
12449            for a in args {
12450                substitute_outer_in_expr(a, outer_row, outer_schema);
12451            }
12452        }
12453        Expr::Cast { expr: inner, .. } => {
12454            substitute_outer_in_expr(inner, outer_row, outer_schema);
12455        }
12456        Expr::Case {
12457            operand,
12458            branches,
12459            else_branch,
12460        } => {
12461            if let Some(op) = operand {
12462                substitute_outer_in_expr(op, outer_row, outer_schema);
12463            }
12464            for (cond, val) in branches {
12465                substitute_outer_in_expr(cond, outer_row, outer_schema);
12466                substitute_outer_in_expr(val, outer_row, outer_schema);
12467            }
12468            if let Some(e) = else_branch {
12469                substitute_outer_in_expr(e, outer_row, outer_schema);
12470            }
12471        }
12472        _ => {}
12473    }
12474}
12475
12476fn substitute_outer_columns(stmt: &mut SelectStatement, row: &Row, ctx: &EvalContext<'_>) {
12477    // v7.24 (round-16 B) — joined outer contexts carry no single
12478    // table alias; their schemas use composite "alias.column" names
12479    // instead. Pass an unmatchable alias and let the composite
12480    // lookup in substitute_in_expr do the work (a correlated EXISTS
12481    // under a JOIN previously skipped substitution entirely and
12482    // died with "unknown table qualifier").
12483    let outer_alias = ctx.table_alias.unwrap_or("");
12484    substitute_in_select(stmt, row, ctx, outer_alias);
12485}
12486
12487fn substitute_in_select(
12488    stmt: &mut SelectStatement,
12489    row: &Row,
12490    ctx: &EvalContext<'_>,
12491    outer_alias: &str,
12492) {
12493    for item in &mut stmt.items {
12494        if let SelectItem::Expr { expr, .. } = item {
12495            substitute_in_expr(expr, row, ctx, outer_alias);
12496        }
12497    }
12498    if let Some(w) = &mut stmt.where_ {
12499        substitute_in_expr(w, row, ctx, outer_alias);
12500    }
12501    if let Some(gs) = &mut stmt.group_by {
12502        for g in gs {
12503            substitute_in_expr(g, row, ctx, outer_alias);
12504        }
12505    }
12506    if let Some(h) = &mut stmt.having {
12507        substitute_in_expr(h, row, ctx, outer_alias);
12508    }
12509    for o in &mut stmt.order_by {
12510        substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
12511    }
12512    for (_, peer) in &mut stmt.unions {
12513        substitute_in_select(peer, row, ctx, outer_alias);
12514    }
12515}
12516
12517fn substitute_in_expr(e: &mut Expr, row: &Row, ctx: &EvalContext<'_>, outer_alias: &str) {
12518    if let Expr::Column(c) = e
12519        && let Some(qual) = &c.qualifier
12520    {
12521        // Look up the column's index in the outer schema: plain name
12522        // when the qualifier is the outer table's alias, composite
12523        // "alias.column" for joined outer schemas (v7.24).
12524        let idx = if !outer_alias.is_empty() && qual.eq_ignore_ascii_case(outer_alias) {
12525            ctx.columns
12526                .iter()
12527                .position(|sc| sc.name.eq_ignore_ascii_case(&c.name))
12528        } else {
12529            None
12530        }
12531        .or_else(|| {
12532            let composite = alloc::format!("{qual}.{name}", name = c.name);
12533            ctx.columns
12534                .iter()
12535                .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
12536        });
12537        if let Some(idx) = idx {
12538            let v = row.values.get(idx).cloned().unwrap_or(Value::Null);
12539            if let Ok(lit) = value_to_literal_expr(v) {
12540                *e = lit;
12541                return;
12542            }
12543        }
12544    }
12545    match e {
12546        Expr::AggregateOrdered { call, order_by } => {
12547            substitute_in_expr(call, row, ctx, outer_alias);
12548            for o in order_by.iter_mut() {
12549                substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
12550            }
12551        }
12552        Expr::Binary { lhs, rhs, .. } => {
12553            substitute_in_expr(lhs, row, ctx, outer_alias);
12554            substitute_in_expr(rhs, row, ctx, outer_alias);
12555        }
12556        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12557            substitute_in_expr(expr, row, ctx, outer_alias);
12558        }
12559        Expr::Like { expr, pattern, .. } => {
12560            substitute_in_expr(expr, row, ctx, outer_alias);
12561            substitute_in_expr(pattern, row, ctx, outer_alias);
12562        }
12563        Expr::FunctionCall { args, .. } => {
12564            for a in args {
12565                substitute_in_expr(a, row, ctx, outer_alias);
12566            }
12567        }
12568        Expr::Extract { source, .. } => substitute_in_expr(source, row, ctx, outer_alias),
12569        Expr::WindowFunction {
12570            args,
12571            partition_by,
12572            order_by,
12573            ..
12574        } => {
12575            for a in args {
12576                substitute_in_expr(a, row, ctx, outer_alias);
12577            }
12578            for p in partition_by {
12579                substitute_in_expr(p, row, ctx, outer_alias);
12580            }
12581            for (o, _) in order_by {
12582                substitute_in_expr(o, row, ctx, outer_alias);
12583            }
12584        }
12585        Expr::ScalarSubquery(s) => substitute_in_select(s, row, ctx, outer_alias),
12586        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
12587            substitute_in_select(subquery, row, ctx, outer_alias);
12588        }
12589        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
12590        Expr::Array(items) => {
12591            for elem in items {
12592                substitute_in_expr(elem, row, ctx, outer_alias);
12593            }
12594        }
12595        Expr::ArraySubscript { target, index } => {
12596            substitute_in_expr(target, row, ctx, outer_alias);
12597            substitute_in_expr(index, row, ctx, outer_alias);
12598        }
12599        Expr::AnyAll { expr, array, .. } => {
12600            substitute_in_expr(expr, row, ctx, outer_alias);
12601            substitute_in_expr(array, row, ctx, outer_alias);
12602        }
12603        Expr::Case {
12604            operand,
12605            branches,
12606            else_branch,
12607        } => {
12608            if let Some(o) = operand {
12609                substitute_in_expr(o, row, ctx, outer_alias);
12610            }
12611            for (w, t) in branches {
12612                substitute_in_expr(w, row, ctx, outer_alias);
12613                substitute_in_expr(t, row, ctx, outer_alias);
12614            }
12615            if let Some(e) = else_branch {
12616                substitute_in_expr(e, row, ctx, outer_alias);
12617            }
12618        }
12619    }
12620}
12621
12622/// v4.22: encode a Row to a comparable byte key for UNION-DISTINCT
12623/// dedup inside the recursive iteration. Crude but deterministic
12624/// — Debug prints embed type discriminants so NULL ≠ "" ≠ 0.
12625fn encode_row_key(row: &Row) -> Vec<u8> {
12626    let mut out = Vec::new();
12627    for v in &row.values {
12628        let s = alloc::format!("{v:?}|");
12629        out.extend_from_slice(s.as_bytes());
12630    }
12631    out
12632}
12633
12634fn select_has_window(stmt: &SelectStatement) -> bool {
12635    for item in &stmt.items {
12636        if let SelectItem::Expr { expr, .. } = item
12637            && expr_has_window(expr)
12638        {
12639            return true;
12640        }
12641    }
12642    false
12643}
12644
12645fn expr_has_window(e: &Expr) -> bool {
12646    match e {
12647        Expr::WindowFunction { .. } => true,
12648        Expr::AggregateOrdered { call, order_by } => {
12649            expr_has_window(call) || order_by.iter().any(|o| expr_has_window(&o.expr))
12650        }
12651        Expr::Binary { lhs, rhs, .. } => expr_has_window(lhs) || expr_has_window(rhs),
12652        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12653            expr_has_window(expr)
12654        }
12655        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_window),
12656        Expr::Like { expr, pattern, .. } => expr_has_window(expr) || expr_has_window(pattern),
12657        Expr::Extract { source, .. } => expr_has_window(source),
12658        Expr::ScalarSubquery(_)
12659        | Expr::Exists { .. }
12660        | Expr::InSubquery { .. }
12661        | Expr::Literal(_)
12662        | Expr::Placeholder(_)
12663        | Expr::Column(_) => false,
12664        Expr::Array(items) => items.iter().any(expr_has_window),
12665        Expr::ArraySubscript { target, index } => expr_has_window(target) || expr_has_window(index),
12666        Expr::AnyAll { expr, array, .. } => expr_has_window(expr) || expr_has_window(array),
12667        Expr::Case {
12668            operand,
12669            branches,
12670            else_branch,
12671        } => {
12672            operand.as_deref().is_some_and(expr_has_window)
12673                || branches
12674                    .iter()
12675                    .any(|(w, t)| expr_has_window(w) || expr_has_window(t))
12676                || else_branch.as_deref().is_some_and(expr_has_window)
12677        }
12678    }
12679}
12680
12681fn collect_window_nodes(e: &Expr, out: &mut Vec<Expr>) {
12682    if let Expr::WindowFunction { .. } = e {
12683        // Deduplicate by structural equality on the expression
12684        // (cheap because window args + partition + order are
12685        // small). Without dedup we'd recompute identical windows
12686        // once per occurrence in the projection.
12687        if !out.iter().any(|x| x == e) {
12688            out.push(e.clone());
12689        }
12690        return;
12691    }
12692    match e {
12693        // Already handled by the early-return at the top.
12694        Expr::WindowFunction { .. } => unreachable!(),
12695        Expr::Binary { lhs, rhs, .. } => {
12696            collect_window_nodes(lhs, out);
12697            collect_window_nodes(rhs, out);
12698        }
12699        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12700            collect_window_nodes(expr, out);
12701        }
12702        Expr::FunctionCall { args, .. } => {
12703            for a in args {
12704                collect_window_nodes(a, out);
12705            }
12706        }
12707        Expr::Like { expr, pattern, .. } => {
12708            collect_window_nodes(expr, out);
12709            collect_window_nodes(pattern, out);
12710        }
12711        Expr::Extract { source, .. } => collect_window_nodes(source, out),
12712        _ => {}
12713    }
12714}
12715
12716fn rewrite_window_to_columns(e: &mut Expr, window_nodes: &[Expr]) {
12717    if let Expr::WindowFunction { .. } = e
12718        && let Some(idx) = window_nodes.iter().position(|w| w == e)
12719    {
12720        *e = Expr::Column(spg_sql::ast::ColumnName {
12721            qualifier: None,
12722            name: alloc::format!("__win_{idx}"),
12723        });
12724        return;
12725    }
12726    match e {
12727        Expr::Binary { lhs, rhs, .. } => {
12728            rewrite_window_to_columns(lhs, window_nodes);
12729            rewrite_window_to_columns(rhs, window_nodes);
12730        }
12731        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12732            rewrite_window_to_columns(expr, window_nodes);
12733        }
12734        Expr::FunctionCall { args, .. } => {
12735            for a in args {
12736                rewrite_window_to_columns(a, window_nodes);
12737            }
12738        }
12739        Expr::Like { expr, pattern, .. } => {
12740            rewrite_window_to_columns(expr, window_nodes);
12741            rewrite_window_to_columns(pattern, window_nodes);
12742        }
12743        Expr::Extract { source, .. } => rewrite_window_to_columns(source, window_nodes),
12744        _ => {}
12745    }
12746}
12747
12748/// Total order over partition-key tuples. NULL sorts as the
12749/// lowest value (matches the `<` partial order's NULL-last
12750/// behaviour with `INFINITY` flipped).
12751fn partition_key_cmp(a: &[Value], b: &[Value]) -> core::cmp::Ordering {
12752    for (x, y) in a.iter().zip(b.iter()) {
12753        let c = value_cmp(x, y);
12754        if c != core::cmp::Ordering::Equal {
12755            return c;
12756        }
12757    }
12758    a.len().cmp(&b.len())
12759}
12760
12761fn order_key_cmp(a: &[(Value, bool)], b: &[(Value, bool)]) -> core::cmp::Ordering {
12762    for ((va, desc), (vb, _)) in a.iter().zip(b.iter()) {
12763        let c = value_cmp(va, vb);
12764        let c = if *desc { c.reverse() } else { c };
12765        if c != core::cmp::Ordering::Equal {
12766            return c;
12767        }
12768    }
12769    a.len().cmp(&b.len())
12770}
12771
12772/// v7.17.0 Phase 3.10 — true when the Value is one of the
12773/// integer-shaped variants `generate_series` accepts as a start
12774/// / stop / step component. Float / NUMERIC are rejected — PG's
12775/// `generate_series(numeric, numeric)` overload is out of v7.17
12776/// scope.
12777const fn value_is_integer(v: &Value) -> bool {
12778    matches!(v, Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_))
12779}
12780
12781/// v7.17.0 Phase 3.10 — widen any integer-shaped Value to i64 for
12782/// the generate_series iteration loop. Non-integer inputs panic;
12783/// caller guards via `value_is_integer`.
12784const fn value_to_i64(v: &Value) -> i64 {
12785    match v {
12786        Value::SmallInt(n) => *n as i64,
12787        Value::Int(n) => *n as i64,
12788        Value::BigInt(n) => *n,
12789        _ => panic!("value_to_i64 called on non-integer Value"),
12790    }
12791}
12792
12793/// v7.17.0 Phase 3.10 — integer-mode generate_series materialiser.
12794/// Step direction follows the sign: positive step iterates upward
12795/// (stops when current > stop); negative iterates downward; zero
12796/// errors. Caller-facing row stream is `BigInt`-typed so a single
12797/// projection schema covers SmallInt / Int / BigInt callers.
12798fn generate_series_integers(
12799    start: i64,
12800    stop: i64,
12801    step: i64,
12802    cancel: &CancelToken<'_>,
12803) -> Result<alloc::vec::Vec<Row>, EngineError> {
12804    if step == 0 {
12805        return Err(EngineError::Unsupported(
12806            "generate_series(): step argument cannot be zero".into(),
12807        ));
12808    }
12809    let mut out = alloc::vec::Vec::new();
12810    let mut cur = start;
12811    // Hard cap to keep a runaway call from eating all memory. PG
12812    // has no such cap but does honour query timeout; SPG's cancel
12813    // token will fire too — this is a defense-in-depth backstop.
12814    const MAX_ROWS: usize = 10_000_000;
12815    loop {
12816        cancel.check()?;
12817        if step > 0 && cur > stop {
12818            break;
12819        }
12820        if step < 0 && cur < stop {
12821            break;
12822        }
12823        out.push(Row::new(alloc::vec![Value::BigInt(cur)]));
12824        if out.len() > MAX_ROWS {
12825            return Err(EngineError::Unsupported(alloc::format!(
12826                "generate_series(): exceeded {MAX_ROWS} rows; \
12827                 narrow start/stop or use a larger step"
12828            )));
12829        }
12830        cur = match cur.checked_add(step) {
12831            Some(n) => n,
12832            None => break,
12833        };
12834    }
12835    Ok(out)
12836}
12837
12838/// v7.17.0 Phase 3.10 — timestamp-mode generate_series. step is a
12839/// `Value::Interval { months, micros }` per the caller's guard;
12840/// each iteration adds the interval via `apply_binary_interval`
12841/// so month-shifting handles short-month rollover (PG semantics).
12842fn generate_series_timestamps(
12843    start: i64,
12844    stop: i64,
12845    step: Value,
12846    cancel: &CancelToken<'_>,
12847) -> Result<alloc::vec::Vec<Row>, EngineError> {
12848    let (months, micros) = match &step {
12849        Value::Interval { months, micros } => (*months, *micros),
12850        _ => unreachable!("caller guards step.is_interval"),
12851    };
12852    if months == 0 && micros == 0 {
12853        return Err(EngineError::Unsupported(
12854            "generate_series(): INTERVAL step cannot be zero".into(),
12855        ));
12856    }
12857    let ascending = months > 0 || micros > 0;
12858    let mut out = alloc::vec::Vec::new();
12859    let mut cur = Value::Timestamp(start);
12860    const MAX_ROWS: usize = 10_000_000;
12861    loop {
12862        cancel.check()?;
12863        let cur_t = match cur {
12864            Value::Timestamp(t) => t,
12865            _ => unreachable!("loop invariant: cur is Timestamp"),
12866        };
12867        if ascending && cur_t > stop {
12868            break;
12869        }
12870        if !ascending && cur_t < stop {
12871            break;
12872        }
12873        out.push(Row::new(alloc::vec![Value::Timestamp(cur_t)]));
12874        if out.len() > MAX_ROWS {
12875            return Err(EngineError::Unsupported(alloc::format!(
12876                "generate_series(): exceeded {MAX_ROWS} rows; \
12877                 narrow start/stop or use a larger step"
12878            )));
12879        }
12880        let next = eval::apply_binary_interval(
12881            spg_sql::ast::BinOp::Add,
12882            &cur,
12883            &Value::Interval { months, micros },
12884        )
12885        .map_err(EngineError::Eval)?;
12886        cur = match next {
12887            Some(v) => v,
12888            None => break,
12889        };
12890    }
12891    Ok(out)
12892}
12893
12894#[allow(clippy::match_same_arms)] // explicit arms per type document the supported pairs
12895/// v7.24 (round-16 A) — per-key ORDER BY comparator honouring DESC
12896/// and the effective NULLS placement (explicit NULLS FIRST/LAST,
12897/// else the PG default: NULLS LAST for ASC, NULLS FIRST for DESC).
12898/// NULL placement is absolute — it does not flip with DESC.
12899pub(crate) fn order_by_value_cmp(
12900    desc: bool,
12901    nulls_first: Option<bool>,
12902    a: &Value,
12903    b: &Value,
12904) -> core::cmp::Ordering {
12905    use core::cmp::Ordering;
12906    let nf = nulls_first.unwrap_or(desc);
12907    match (matches!(a, Value::Null), matches!(b, Value::Null)) {
12908        (true, true) => Ordering::Equal,
12909        (true, false) => {
12910            if nf {
12911                Ordering::Less
12912            } else {
12913                Ordering::Greater
12914            }
12915        }
12916        (false, true) => {
12917            if nf {
12918                Ordering::Greater
12919            } else {
12920                Ordering::Less
12921            }
12922        }
12923        (false, false) => {
12924            let c = value_cmp(a, b);
12925            if desc { c.reverse() } else { c }
12926        }
12927    }
12928}
12929
12930fn value_cmp(a: &Value, b: &Value) -> core::cmp::Ordering {
12931    use core::cmp::Ordering;
12932    match (a, b) {
12933        (Value::Null, Value::Null) => Ordering::Equal,
12934        (Value::Null, _) => Ordering::Less,
12935        (_, Value::Null) => Ordering::Greater,
12936        (Value::Int(x), Value::Int(y)) => x.cmp(y),
12937        (Value::BigInt(x), Value::BigInt(y)) => x.cmp(y),
12938        (Value::SmallInt(x), Value::SmallInt(y)) => x.cmp(y),
12939        (Value::Text(x), Value::Text(y)) => x.cmp(y),
12940        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
12941        (Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
12942        (Value::Date(x), Value::Date(y)) => x.cmp(y),
12943        (Value::Timestamp(x), Value::Timestamp(y)) => x.cmp(y),
12944        // Cross-type compare: fall back to the debug rendering —
12945        // same-partition is the goal, exact order is irrelevant.
12946        _ => alloc::format!("{a:?}").cmp(&alloc::format!("{b:?}")),
12947    }
12948}
12949
12950/// Compute the window function's per-row output for one partition.
12951/// `slice` has (partition key, order key, original-row-index)
12952/// tuples already sorted by order key. `filtered_rows` is the
12953/// full row list indexed by original-row-index. `out_vals` is
12954/// the destination, also indexed by original-row-index.
12955#[allow(
12956    clippy::too_many_arguments,
12957    clippy::cast_possible_truncation,
12958    clippy::cast_possible_wrap,
12959    clippy::cast_precision_loss,
12960    clippy::cast_sign_loss,
12961    clippy::doc_markdown,
12962    clippy::too_many_lines,
12963    clippy::type_complexity,
12964    clippy::match_same_arms
12965)]
12966fn compute_window_partition(
12967    name: &str,
12968    args: &[Expr],
12969    ordered: bool,
12970    frame: Option<&WindowFrame>,
12971    null_treatment: spg_sql::ast::NullTreatment,
12972    slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)],
12973    filtered_rows: &[&Row],
12974    ctx: &EvalContext<'_>,
12975    out_vals: &mut [Value],
12976) -> Result<(), EngineError> {
12977    let ignore_nulls = matches!(null_treatment, spg_sql::ast::NullTreatment::Ignore);
12978    let lower = name.to_ascii_lowercase();
12979    match lower.as_str() {
12980        "row_number" => {
12981            for (rank, (_, _, idx)) in slice.iter().enumerate() {
12982                out_vals[*idx] = Value::BigInt((rank + 1) as i64);
12983            }
12984            Ok(())
12985        }
12986        "rank" => {
12987            let mut prev_key: Option<&[(Value, bool)]> = None;
12988            let mut current_rank: i64 = 1;
12989            for (i, (_, okey, idx)) in slice.iter().enumerate() {
12990                if let Some(p) = prev_key
12991                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
12992                {
12993                    current_rank = (i + 1) as i64;
12994                }
12995                if prev_key.is_none() {
12996                    current_rank = 1;
12997                }
12998                out_vals[*idx] = Value::BigInt(current_rank);
12999                prev_key = Some(okey.as_slice());
13000            }
13001            Ok(())
13002        }
13003        "dense_rank" => {
13004            let mut prev_key: Option<&[(Value, bool)]> = None;
13005            let mut current_rank: i64 = 0;
13006            for (_, okey, idx) in slice {
13007                if prev_key.is_none_or(|p| order_key_cmp(p, okey) != core::cmp::Ordering::Equal) {
13008                    current_rank += 1;
13009                }
13010                out_vals[*idx] = Value::BigInt(current_rank);
13011                prev_key = Some(okey.as_slice());
13012            }
13013            Ok(())
13014        }
13015        "sum" | "avg" | "min" | "max" | "count" | "count_star" => {
13016            // Pre-evaluate the function arg per row in the slice
13017            // (count_star has no arg).
13018            let arg_values: Vec<Value> = if lower == "count_star" || args.is_empty() {
13019                slice.iter().map(|_| Value::Null).collect()
13020            } else {
13021                slice
13022                    .iter()
13023                    .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
13024                    .collect::<Result<_, _>>()
13025                    .map_err(EngineError::Eval)?
13026            };
13027            // v4.20: pick the effective frame. Explicit frame
13028            // overrides the implicit default (running for ordered,
13029            // whole-partition for unordered).
13030            let eff = effective_frame(frame, ordered)?;
13031            #[allow(clippy::needless_range_loop)]
13032            for i in 0..slice.len() {
13033                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
13034                let mut sum: f64 = 0.0;
13035                let mut count: i64 = 0;
13036                let mut min_v: Option<f64> = None;
13037                let mut max_v: Option<f64> = None;
13038                let mut row_count: i64 = 0;
13039                if lo <= hi {
13040                    for j in lo..=hi {
13041                        let v = &arg_values[j];
13042                        match lower.as_str() {
13043                            "count_star" => row_count += 1,
13044                            "count" => {
13045                                if !v.is_null() {
13046                                    count += 1;
13047                                }
13048                            }
13049                            _ => {
13050                                if let Some(x) = value_to_f64(v) {
13051                                    sum += x;
13052                                    count += 1;
13053                                    min_v = Some(min_v.map_or(x, |m| m.min(x)));
13054                                    max_v = Some(max_v.map_or(x, |m| m.max(x)));
13055                                }
13056                            }
13057                        }
13058                    }
13059                }
13060                let value = match lower.as_str() {
13061                    "count_star" => Value::BigInt(row_count),
13062                    "count" => Value::BigInt(count),
13063                    "sum" => Value::Float(sum),
13064                    "avg" => {
13065                        if count == 0 {
13066                            Value::Null
13067                        } else {
13068                            Value::Float(sum / count as f64)
13069                        }
13070                    }
13071                    "min" => min_v.map_or(Value::Null, Value::Float),
13072                    "max" => max_v.map_or(Value::Null, Value::Float),
13073                    _ => unreachable!(),
13074                };
13075                let (_, _, idx) = &slice[i];
13076                out_vals[*idx] = value;
13077            }
13078            Ok(())
13079        }
13080        "lag" | "lead" => {
13081            // lag(expr [, offset [, default]])
13082            // lead(expr [, offset [, default]])
13083            if args.is_empty() {
13084                return Err(EngineError::Unsupported(alloc::format!(
13085                    "{lower}() requires at least one argument"
13086                )));
13087            }
13088            let offset: i64 = if args.len() >= 2 {
13089                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
13090                    .map_err(EngineError::Eval)?;
13091                match v {
13092                    Value::SmallInt(n) => i64::from(n),
13093                    Value::Int(n) => i64::from(n),
13094                    Value::BigInt(n) => n,
13095                    _ => {
13096                        return Err(EngineError::Unsupported(alloc::format!(
13097                            "{lower}() offset must be integer"
13098                        )));
13099                    }
13100                }
13101            } else {
13102                1
13103            };
13104            let default: Value = if args.len() >= 3 {
13105                eval::eval_expr(&args[2], filtered_rows[slice[0].2], ctx)
13106                    .map_err(EngineError::Eval)?
13107            } else {
13108                Value::Null
13109            };
13110            let values: Vec<Value> = slice
13111                .iter()
13112                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
13113                .collect::<Result<_, _>>()
13114                .map_err(EngineError::Eval)?;
13115            let n = slice.len();
13116            for (i, (_, _, idx)) in slice.iter().enumerate() {
13117                let signed_offset = if lower == "lag" { -offset } else { offset };
13118                let v = if ignore_nulls {
13119                    // v6.4.2 — IGNORE NULLS: walk in the offset direction
13120                    // skipping NULL values; the `offset`-th non-NULL
13121                    // encountered is the result.
13122                    let step: i64 = if signed_offset >= 0 { 1 } else { -1 };
13123                    let needed: i64 = signed_offset.abs();
13124                    if needed == 0 {
13125                        values[i].clone()
13126                    } else {
13127                        let mut j: i64 = i as i64;
13128                        let mut hits: i64 = 0;
13129                        let mut found: Option<Value> = None;
13130                        loop {
13131                            j += step;
13132                            if j < 0 || j >= n as i64 {
13133                                break;
13134                            }
13135                            #[allow(clippy::cast_sign_loss)]
13136                            let v = &values[j as usize];
13137                            if !v.is_null() {
13138                                hits += 1;
13139                                if hits == needed {
13140                                    found = Some(v.clone());
13141                                    break;
13142                                }
13143                            }
13144                        }
13145                        found.unwrap_or_else(|| default.clone())
13146                    }
13147                } else {
13148                    let target_signed = i64::try_from(i).unwrap_or(i64::MAX) + signed_offset;
13149                    if target_signed < 0 || target_signed >= i64::try_from(n).unwrap_or(i64::MAX) {
13150                        default.clone()
13151                    } else {
13152                        #[allow(clippy::cast_sign_loss)]
13153                        {
13154                            values[target_signed as usize].clone()
13155                        }
13156                    }
13157                };
13158                out_vals[*idx] = v;
13159            }
13160            Ok(())
13161        }
13162        "first_value" | "last_value" | "nth_value" => {
13163            if args.is_empty() {
13164                return Err(EngineError::Unsupported(alloc::format!(
13165                    "{lower}() requires at least one argument"
13166                )));
13167            }
13168            let values: Vec<Value> = slice
13169                .iter()
13170                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
13171                .collect::<Result<_, _>>()
13172                .map_err(EngineError::Eval)?;
13173            let nth: usize = if lower == "nth_value" {
13174                if args.len() < 2 {
13175                    return Err(EngineError::Unsupported(
13176                        "nth_value() requires (expr, n)".into(),
13177                    ));
13178                }
13179                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
13180                    .map_err(EngineError::Eval)?;
13181                let raw = match v {
13182                    Value::SmallInt(n) => i64::from(n),
13183                    Value::Int(n) => i64::from(n),
13184                    Value::BigInt(n) => n,
13185                    _ => {
13186                        return Err(EngineError::Unsupported(
13187                            "nth_value() n must be integer".into(),
13188                        ));
13189                    }
13190                };
13191                if raw < 1 {
13192                    return Err(EngineError::Unsupported(
13193                        "nth_value() n must be >= 1".into(),
13194                    ));
13195                }
13196                #[allow(clippy::cast_sign_loss)]
13197                {
13198                    raw as usize
13199                }
13200            } else {
13201                0
13202            };
13203            let eff = effective_frame(frame, ordered)?;
13204            for i in 0..slice.len() {
13205                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
13206                let (_, _, idx) = &slice[i];
13207                let v = if lo > hi {
13208                    Value::Null
13209                } else if ignore_nulls && matches!(lower.as_str(), "first_value" | "last_value") {
13210                    // v6.4.2 — IGNORE NULLS: skip NULL cells when
13211                    // selecting the boundary value within the frame.
13212                    if lower == "first_value" {
13213                        (lo..=hi)
13214                            .find_map(|j| {
13215                                let v = &values[j];
13216                                (!v.is_null()).then(|| v.clone())
13217                            })
13218                            .unwrap_or(Value::Null)
13219                    } else {
13220                        (lo..=hi)
13221                            .rev()
13222                            .find_map(|j| {
13223                                let v = &values[j];
13224                                (!v.is_null()).then(|| v.clone())
13225                            })
13226                            .unwrap_or(Value::Null)
13227                    }
13228                } else {
13229                    match lower.as_str() {
13230                        "first_value" => values[lo].clone(),
13231                        "last_value" => values[hi].clone(),
13232                        "nth_value" => {
13233                            let pos = lo + nth - 1;
13234                            if pos > hi {
13235                                Value::Null
13236                            } else {
13237                                values[pos].clone()
13238                            }
13239                        }
13240                        _ => unreachable!(),
13241                    }
13242                };
13243                out_vals[*idx] = v;
13244            }
13245            Ok(())
13246        }
13247        "ntile" => {
13248            if args.is_empty() {
13249                return Err(EngineError::Unsupported(
13250                    "ntile(n) requires an integer argument".into(),
13251                ));
13252            }
13253            let v = eval::eval_expr(&args[0], filtered_rows[slice[0].2], ctx)
13254                .map_err(EngineError::Eval)?;
13255            let bucket_count: i64 = match v {
13256                Value::SmallInt(n) => i64::from(n),
13257                Value::Int(n) => i64::from(n),
13258                Value::BigInt(n) => n,
13259                _ => {
13260                    return Err(EngineError::Unsupported(
13261                        "ntile() argument must be integer".into(),
13262                    ));
13263                }
13264            };
13265            if bucket_count < 1 {
13266                return Err(EngineError::Unsupported(
13267                    "ntile() argument must be >= 1".into(),
13268                ));
13269            }
13270            #[allow(clippy::cast_sign_loss)]
13271            let buckets = bucket_count as usize;
13272            let n = slice.len();
13273            // Each bucket gets `base` rows; the first `extras` buckets
13274            // get one extra. PG semantics.
13275            let base = n / buckets;
13276            let extras = n % buckets;
13277            let mut bucket: usize = 1;
13278            let mut remaining_in_bucket = if extras > 0 { base + 1 } else { base };
13279            let mut buckets_with_extra_remaining = extras;
13280            for (_, _, idx) in slice {
13281                if remaining_in_bucket == 0 {
13282                    bucket += 1;
13283                    buckets_with_extra_remaining = buckets_with_extra_remaining.saturating_sub(1);
13284                    remaining_in_bucket = if buckets_with_extra_remaining > 0 {
13285                        base + 1
13286                    } else {
13287                        base
13288                    };
13289                    // Edge: if base==0 and extras==0, all rows fit;
13290                    // shouldn't reach here, but guard anyway.
13291                    if remaining_in_bucket == 0 {
13292                        remaining_in_bucket = 1;
13293                    }
13294                }
13295                out_vals[*idx] = Value::BigInt(i64::try_from(bucket).unwrap_or(i64::MAX));
13296                remaining_in_bucket -= 1;
13297            }
13298            Ok(())
13299        }
13300        "percent_rank" => {
13301            // (rank - 1) / (n - 1) where rank is the standard RANK().
13302            // Single-row partitions get 0.
13303            let n = slice.len();
13304            let mut prev_key: Option<&[(Value, bool)]> = None;
13305            let mut current_rank: i64 = 1;
13306            for (i, (_, okey, idx)) in slice.iter().enumerate() {
13307                if let Some(p) = prev_key
13308                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
13309                {
13310                    current_rank = i64::try_from(i + 1).unwrap_or(i64::MAX);
13311                }
13312                if prev_key.is_none() {
13313                    current_rank = 1;
13314                }
13315                #[allow(clippy::cast_precision_loss)]
13316                let pr = if n <= 1 {
13317                    0.0
13318                } else {
13319                    (current_rank - 1) as f64 / (n - 1) as f64
13320                };
13321                out_vals[*idx] = Value::Float(pr);
13322                prev_key = Some(okey.as_slice());
13323            }
13324            Ok(())
13325        }
13326        "cume_dist" => {
13327            // # rows up to and including this row's peer group / n.
13328            let n = slice.len();
13329            // First pass: find peer-group-end rank for each row.
13330            for i in 0..slice.len() {
13331                let peer_end = peer_group_end(slice, i);
13332                #[allow(clippy::cast_precision_loss)]
13333                let cd = (peer_end + 1) as f64 / n as f64;
13334                let (_, _, idx) = &slice[i];
13335                out_vals[*idx] = Value::Float(cd);
13336            }
13337            Ok(())
13338        }
13339        other => Err(EngineError::Unsupported(alloc::format!(
13340            "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)"
13341        ))),
13342    }
13343}
13344
13345/// v4.20: resolve the user-provided frame down to a normalised
13346/// `(kind, start, end)`. `None` means default — derive from
13347/// `ordered`: ordered ⇒ RANGE UNBOUNDED PRECEDING AND CURRENT ROW,
13348/// unordered ⇒ ROWS UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
13349/// Single-bound shorthand (e.g. `ROWS 5 PRECEDING`) normalises
13350/// end → CURRENT ROW per the PG spec.
13351fn effective_frame(
13352    frame: Option<&WindowFrame>,
13353    ordered: bool,
13354) -> Result<(FrameKind, FrameBound, FrameBound), EngineError> {
13355    match frame {
13356        None => {
13357            if ordered {
13358                Ok((
13359                    FrameKind::Range,
13360                    FrameBound::UnboundedPreceding,
13361                    FrameBound::CurrentRow,
13362                ))
13363            } else {
13364                Ok((
13365                    FrameKind::Rows,
13366                    FrameBound::UnboundedPreceding,
13367                    FrameBound::UnboundedFollowing,
13368                ))
13369            }
13370        }
13371        Some(fr) => {
13372            let end = fr.end.clone().unwrap_or(FrameBound::CurrentRow);
13373            // Reject start > end (a few impossible combinations).
13374            if matches!(fr.start, FrameBound::UnboundedFollowing)
13375                || matches!(end, FrameBound::UnboundedPreceding)
13376            {
13377                return Err(EngineError::Unsupported(alloc::format!(
13378                    "invalid frame: start={:?} end={:?}",
13379                    fr.start,
13380                    end
13381                )));
13382            }
13383            // RANGE OFFSET PRECEDING / FOLLOWING needs value-typed
13384            // arithmetic on the ORDER BY key (e.g. `RANGE BETWEEN
13385            // INTERVAL '1 day' PRECEDING AND CURRENT ROW`). Not
13386            // implemented in v4.20.
13387            if fr.kind == FrameKind::Range
13388                && (matches!(
13389                    fr.start,
13390                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
13391                ) || matches!(
13392                    end,
13393                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
13394                ))
13395            {
13396                return Err(EngineError::Unsupported(
13397                    "RANGE with explicit offset bounds is not supported (v4.20: only UNBOUNDED / CURRENT ROW for RANGE)".into(),
13398                ));
13399            }
13400            Ok((fr.kind, fr.start.clone(), end))
13401        }
13402    }
13403}
13404
13405/// Compute `(lo, hi)` row-index bounds inside the partition slice
13406/// for the row at position `i`. Inclusive, clamped to
13407/// `[0, slice.len()-1]`. Empty result if `lo > hi`.
13408#[allow(clippy::type_complexity)]
13409fn frame_bounds_for_row(
13410    eff: &(FrameKind, FrameBound, FrameBound),
13411    i: usize,
13412    slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)],
13413) -> (usize, usize) {
13414    let (kind, start, end) = eff;
13415    let n = slice.len();
13416    let last = n.saturating_sub(1);
13417    let (mut lo, mut hi) = match kind {
13418        FrameKind::Rows => {
13419            let lo = match start {
13420                FrameBound::UnboundedPreceding => 0,
13421                FrameBound::OffsetPreceding(k) => {
13422                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13423                    i.saturating_sub(k)
13424                }
13425                FrameBound::CurrentRow => i,
13426                FrameBound::OffsetFollowing(k) => {
13427                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13428                    i.saturating_add(k).min(last)
13429                }
13430                FrameBound::UnboundedFollowing => last,
13431            };
13432            let hi = match end {
13433                FrameBound::UnboundedPreceding => 0,
13434                FrameBound::OffsetPreceding(k) => {
13435                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13436                    i.saturating_sub(k)
13437                }
13438                FrameBound::CurrentRow => i,
13439                FrameBound::OffsetFollowing(k) => {
13440                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13441                    i.saturating_add(k).min(last)
13442                }
13443                FrameBound::UnboundedFollowing => last,
13444            };
13445            (lo, hi)
13446        }
13447        FrameKind::Range => {
13448            // RANGE bounds are peer-aware. With only UNBOUNDED and
13449            // CURRENT ROW supported (rejected at effective_frame for
13450            // explicit offsets), the start/end map to the
13451            // partition's full extent at the same-order-key peer
13452            // group boundary.
13453            let lo = match start {
13454                FrameBound::UnboundedPreceding => 0,
13455                FrameBound::CurrentRow => peer_group_start(slice, i),
13456                FrameBound::UnboundedFollowing => last,
13457                _ => unreachable!("offset bounds rejected for RANGE"),
13458            };
13459            let hi = match end {
13460                FrameBound::UnboundedPreceding => 0,
13461                FrameBound::CurrentRow => peer_group_end(slice, i),
13462                FrameBound::UnboundedFollowing => last,
13463                _ => unreachable!("offset bounds rejected for RANGE"),
13464            };
13465            (lo, hi)
13466        }
13467    };
13468    if hi >= n {
13469        hi = last;
13470    }
13471    if lo >= n {
13472        lo = last;
13473    }
13474    (lo, hi)
13475}
13476
13477/// Find the inclusive index of the first row with the same ORDER
13478/// BY key as `slice[i]`. Slice is already sorted by partition then
13479/// order, so peers are contiguous.
13480#[allow(clippy::type_complexity)]
13481fn peer_group_start(slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)], i: usize) -> usize {
13482    let key = &slice[i].1;
13483    let mut j = i;
13484    while j > 0 && order_key_cmp(&slice[j - 1].1, key) == core::cmp::Ordering::Equal {
13485        j -= 1;
13486    }
13487    j
13488}
13489
13490/// Find the inclusive index of the last row with the same ORDER
13491/// BY key as `slice[i]`.
13492#[allow(clippy::type_complexity)]
13493fn peer_group_end(slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)], i: usize) -> usize {
13494    let key = &slice[i].1;
13495    let mut j = i;
13496    while j + 1 < slice.len() && order_key_cmp(&slice[j + 1].1, key) == core::cmp::Ordering::Equal {
13497        j += 1;
13498    }
13499    j
13500}
13501
13502fn value_to_f64(v: &Value) -> Option<f64> {
13503    match v {
13504        Value::SmallInt(n) => Some(f64::from(*n)),
13505        Value::Int(n) => Some(f64::from(*n)),
13506        #[allow(clippy::cast_precision_loss)]
13507        Value::BigInt(n) => Some(*n as f64),
13508        Value::Float(x) => Some(*x),
13509        _ => None,
13510    }
13511}
13512
13513/// Quick scan for any subquery-bearing node in a SELECT's WHERE /
13514/// projection / `order_by` — saves cloning the AST when there are
13515/// none (the common case).
13516fn expr_tree_has_subquery(stmt: &SelectStatement) -> bool {
13517    let mut any = false;
13518    for item in &stmt.items {
13519        if let SelectItem::Expr { expr, .. } = item {
13520            any = any || expr_has_subquery(expr);
13521        }
13522    }
13523    if let Some(w) = &stmt.where_ {
13524        any = any || expr_has_subquery(w);
13525    }
13526    if let Some(h) = &stmt.having {
13527        any = any || expr_has_subquery(h);
13528    }
13529    for o in &stmt.order_by {
13530        any = any || expr_has_subquery(&o.expr);
13531    }
13532    for (_, peer) in &stmt.unions {
13533        any = any || expr_tree_has_subquery(peer);
13534    }
13535    any
13536}
13537
13538fn expr_has_subquery(e: &Expr) -> bool {
13539    match e {
13540        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => true,
13541        Expr::AggregateOrdered { call, order_by } => {
13542            expr_has_subquery(call) || order_by.iter().any(|o| expr_has_subquery(&o.expr))
13543        }
13544        Expr::Binary { lhs, rhs, .. } => expr_has_subquery(lhs) || expr_has_subquery(rhs),
13545        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13546            expr_has_subquery(expr)
13547        }
13548        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_subquery),
13549        Expr::Like { expr, pattern, .. } => expr_has_subquery(expr) || expr_has_subquery(pattern),
13550        Expr::Extract { source, .. } => expr_has_subquery(source),
13551        Expr::WindowFunction {
13552            args,
13553            partition_by,
13554            order_by,
13555            ..
13556        } => {
13557            args.iter().any(expr_has_subquery)
13558                || partition_by.iter().any(expr_has_subquery)
13559                || order_by.iter().any(|(e, _)| expr_has_subquery(e))
13560        }
13561        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
13562        Expr::Array(items) => items.iter().any(expr_has_subquery),
13563        Expr::ArraySubscript { target, index } => {
13564            expr_has_subquery(target) || expr_has_subquery(index)
13565        }
13566        Expr::AnyAll { expr, array, .. } => expr_has_subquery(expr) || expr_has_subquery(array),
13567        Expr::Case {
13568            operand,
13569            branches,
13570            else_branch,
13571        } => {
13572            operand.as_deref().is_some_and(expr_has_subquery)
13573                || branches
13574                    .iter()
13575                    .any(|(w, t)| expr_has_subquery(w) || expr_has_subquery(t))
13576                || else_branch.as_deref().is_some_and(expr_has_subquery)
13577        }
13578    }
13579}
13580
13581/// v4.10 helper: materialise a runtime `Value` back into an AST
13582/// `Expr::Literal` for the subquery-rewrite path. Supports the
13583/// types `Literal` can represent (Integer / Float / Text / Bool /
13584/// Null). Date / Timestamp / Numeric / Vector / Interval / JSON
13585/// would lose precision through Literal and aren't supported in
13586/// uncorrelated-subquery results; they error with a clear hint.
13587fn value_to_literal_expr(v: Value) -> Result<Expr, EngineError> {
13588    let lit = match v {
13589        Value::Null => Literal::Null,
13590        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13591        Value::Int(n) => Literal::Integer(i64::from(n)),
13592        Value::BigInt(n) => Literal::Integer(n),
13593        Value::Float(x) => Literal::Float(x),
13594        Value::Text(s) | Value::Json(s) => Literal::String(s),
13595        Value::Bool(b) => Literal::Bool(b),
13596        other => {
13597            return Err(EngineError::Unsupported(alloc::format!(
13598                "subquery result type {:?} not yet materialisable; cast to text or integer in the inner SELECT",
13599                other.data_type()
13600            )));
13601        }
13602    };
13603    Ok(Expr::Literal(lit))
13604}
13605
13606/// v7.13.0 — wider helper used by `INSERT … SELECT` (mailrs
13607/// round-5 G4). Covers the most common `Value` variants. Types
13608/// that need lossy textual round-trip (BYTEA, arrays, ts*)
13609/// surface as an Unsupported error so the caller can add a cast
13610/// in the inner SELECT.
13611fn value_to_literal_expr_permissive(v: Value) -> Result<Expr, EngineError> {
13612    let lit = match v {
13613        Value::Null => Literal::Null,
13614        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13615        Value::Int(n) => Literal::Integer(i64::from(n)),
13616        Value::BigInt(n) => Literal::Integer(n),
13617        Value::Float(x) => Literal::Float(x),
13618        Value::Text(s) | Value::Json(s) => Literal::String(s),
13619        Value::Bool(b) => Literal::Bool(b),
13620        Value::Vector(xs) => Literal::Vector(xs),
13621        // Date / Timestamp / Timestamptz / Numeric round-trip
13622        // through a TEXT literal that `coerce_value` re-parses
13623        // against the target column type.
13624        Value::Date(days) => {
13625            let micros = (i64::from(days)) * 86_400_000_000;
13626            Literal::String(format_timestamp_micros_as_date(micros))
13627        }
13628        Value::Timestamp(us) => Literal::String(format_timestamp_micros(us)),
13629        Value::Numeric { scaled, scale } => Literal::String(format_numeric(scaled, scale)),
13630        other => {
13631            return Err(EngineError::Unsupported(alloc::format!(
13632                "INSERT … SELECT cannot materialise value of type {:?}; \
13633                 add an explicit CAST in the inner SELECT",
13634                other.data_type()
13635            )));
13636        }
13637    };
13638    Ok(Expr::Literal(lit))
13639}
13640
13641fn format_timestamp_micros(us: i64) -> String {
13642    // Same Y/M/D split used by the wire layer; epoch-relative.
13643    let days = us.div_euclid(86_400_000_000);
13644    let intra_day = us.rem_euclid(86_400_000_000);
13645    let date = format_timestamp_micros_as_date(days * 86_400_000_000);
13646    let secs = intra_day / 1_000_000;
13647    let us_rem = intra_day % 1_000_000;
13648    let h = (secs / 3600) % 24;
13649    let m = (secs / 60) % 60;
13650    let s = secs % 60;
13651    if us_rem == 0 {
13652        alloc::format!("{date} {h:02}:{m:02}:{s:02}")
13653    } else {
13654        alloc::format!("{date} {h:02}:{m:02}:{s:02}.{us_rem:06}")
13655    }
13656}
13657
13658fn format_timestamp_micros_as_date(us: i64) -> String {
13659    // Days since 1970-01-01 → calendar Y-M-D via the proleptic
13660    // Gregorian conversion used by spg-engine's date helpers.
13661    let days = us.div_euclid(86_400_000_000);
13662    // 1970-01-01 = JDN 2440588.
13663    let jdn = days + 2_440_588;
13664    let (y, mo, d) = jdn_to_ymd(jdn);
13665    alloc::format!("{y:04}-{mo:02}-{d:02}")
13666}
13667
13668fn jdn_to_ymd(jdn: i64) -> (i64, u32, u32) {
13669    // Fliegel & Van Flandern (1968) — works for all positive JDNs.
13670    let l = jdn + 68569;
13671    let n = (4 * l) / 146_097;
13672    let l = l - (146_097 * n + 3) / 4;
13673    let i = (4000 * (l + 1)) / 1_461_001;
13674    let l = l - (1461 * i) / 4 + 31;
13675    let j = (80 * l) / 2447;
13676    let day = (l - (2447 * j) / 80) as u32;
13677    let l = j / 11;
13678    let month = (j + 2 - 12 * l) as u32;
13679    let year = 100 * (n - 49) + i + l;
13680    (year, month, day)
13681}
13682
13683fn format_numeric(scaled: i128, scale: u8) -> String {
13684    if scale == 0 {
13685        return alloc::format!("{scaled}");
13686    }
13687    let abs = scaled.unsigned_abs();
13688    let divisor = 10u128.pow(u32::from(scale));
13689    let whole = abs / divisor;
13690    let frac = abs % divisor;
13691    let sign = if scaled < 0 { "-" } else { "" };
13692    alloc::format!("{sign}{whole}.{frac:0width$}", width = usize::from(scale))
13693}
13694
13695/// v6.1.1 — walk the prepared `Statement` AST and replace every
13696/// `Expr::Placeholder(n)` with `Expr::Literal(value_to_literal(
13697/// params[n-1]))`. The dispatch downstream sees a `Statement`
13698/// indistinguishable from a simple-query parse, so the exec path
13699/// stays unchanged.
13700///
13701/// Errors fall into one shape: a `$N` references past the bound
13702/// `params.len()`. Out-of-range happens when the Bind didn't
13703/// supply enough values; pgwire surfaces this as a protocol error
13704/// to the client.
13705/// v7.15.0 — rewrite every (potentially-qualified) column
13706/// identifier matching `old` to `new` in a stored SQL source
13707/// string. Used by `ALTER TABLE … RENAME COLUMN` to patch
13708/// CHECK predicate sources, partial-index predicate sources,
13709/// and runtime DEFAULT expression sources before they get
13710/// re-parsed on the next INSERT/UPDATE.
13711///
13712/// Round-trips through the parser, so the rewritten output is
13713/// the canonical Display form (matches what the engine stores
13714/// for fresh predicates). If the source doesn't parse, surfaces
13715/// the parse error — the invariant that stored predicates are
13716/// in canonical Display form means a parse failure here is a
13717/// real bug, not a user mistake to swallow.
13718fn rewrite_column_in_source(
13719    src: &str,
13720    old: &str,
13721    new: &str,
13722) -> Result<alloc::string::String, EngineError> {
13723    let mut expr = spg_sql::parser::parse_expression(src).map_err(|e| {
13724        EngineError::Unsupported(alloc::format!(
13725            "ALTER TABLE RENAME COLUMN: stored predicate source {src:?} \
13726             failed to parse for rewrite ({e})"
13727        ))
13728    })?;
13729    rewrite_column_in_expr(&mut expr, old, new);
13730    Ok(alloc::format!("{expr}"))
13731}
13732
13733/// v7.15.0 — Expr walker that swaps `Expr::Column { name: old, .. }`
13734/// for `Expr::Column { name: new, .. }`. Qualifier is preserved
13735/// (e.g. `t.old` → `t.new`); a foreign-table qualifier still
13736/// gets rewritten because the AST has no way to tell us this
13737/// predicate is on table T versus table T2 — predicate sources
13738/// in SPG are always scoped to the owning table, so any
13739/// qualifier present is either redundant or wrong.
13740fn rewrite_column_in_expr(e: &mut Expr, old: &str, new: &str) {
13741    match e {
13742        Expr::AggregateOrdered { call, order_by } => {
13743            rewrite_column_in_expr(call, old, new);
13744            for o in order_by.iter_mut() {
13745                rewrite_column_in_expr(&mut o.expr, old, new);
13746            }
13747        }
13748        Expr::Column(c) => {
13749            if c.name.eq_ignore_ascii_case(old) {
13750                c.name = new.to_string();
13751            }
13752        }
13753        Expr::Binary { lhs, rhs, .. } => {
13754            rewrite_column_in_expr(lhs, old, new);
13755            rewrite_column_in_expr(rhs, old, new);
13756        }
13757        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13758            rewrite_column_in_expr(expr, old, new);
13759        }
13760        Expr::FunctionCall { args, .. } => {
13761            for a in args {
13762                rewrite_column_in_expr(a, old, new);
13763            }
13764        }
13765        Expr::Like { expr, pattern, .. } => {
13766            rewrite_column_in_expr(expr, old, new);
13767            rewrite_column_in_expr(pattern, old, new);
13768        }
13769        Expr::Extract { source, .. } => rewrite_column_in_expr(source, old, new),
13770        Expr::WindowFunction {
13771            args,
13772            partition_by,
13773            order_by,
13774            ..
13775        } => {
13776            for a in args {
13777                rewrite_column_in_expr(a, old, new);
13778            }
13779            for p in partition_by {
13780                rewrite_column_in_expr(p, old, new);
13781            }
13782            for (o, _) in order_by {
13783                rewrite_column_in_expr(o, old, new);
13784            }
13785        }
13786        Expr::Array(items) => {
13787            for elem in items {
13788                rewrite_column_in_expr(elem, old, new);
13789            }
13790        }
13791        Expr::ArraySubscript { target, index } => {
13792            rewrite_column_in_expr(target, old, new);
13793            rewrite_column_in_expr(index, old, new);
13794        }
13795        Expr::AnyAll { expr, array, .. } => {
13796            rewrite_column_in_expr(expr, old, new);
13797            rewrite_column_in_expr(array, old, new);
13798        }
13799        Expr::Case {
13800            operand,
13801            branches,
13802            else_branch,
13803        } => {
13804            if let Some(o) = operand {
13805                rewrite_column_in_expr(o, old, new);
13806            }
13807            for (w, t) in branches {
13808                rewrite_column_in_expr(w, old, new);
13809                rewrite_column_in_expr(t, old, new);
13810            }
13811            if let Some(e) = else_branch {
13812                rewrite_column_in_expr(e, old, new);
13813            }
13814        }
13815        // Stored predicate sources never contain subqueries —
13816        // CHECK / partial-index / runtime_default are all scalar.
13817        // If a future feature changes that, recurse here.
13818        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => {}
13819        Expr::Literal(_) | Expr::Placeholder(_) => {}
13820    }
13821}
13822
13823/// v7.16.0 — walks a parsed statement and replaces every
13824/// `Expr::Placeholder(N)` with the corresponding `params[N-1]`
13825/// re-encoded as an `Expr::Literal`. Used internally by
13826/// `Engine::execute_prepared` AND surfaced for the spg-embedded
13827/// WAL path (which needs the bind-final AST so replay sees a
13828/// simple-query-shaped statement, not a `$1`-shaped one). Errors
13829/// when a placeholder references an index past the params slice.
13830pub fn substitute_placeholders(stmt: &mut Statement, params: &[Value]) -> Result<(), EngineError> {
13831    match stmt {
13832        Statement::Select(s) => substitute_select(s, params)?,
13833        Statement::Insert(ins) => {
13834            for row in &mut ins.rows {
13835                for e in row {
13836                    substitute_expr(e, params)?;
13837                }
13838            }
13839            // ON CONFLICT DO UPDATE assignments / WHERE can carry
13840            // placeholders too (`… DO UPDATE SET reason = $2` —
13841            // mailrs embed round-12).
13842            if let Some(clause) = &mut ins.on_conflict
13843                && let spg_sql::ast::OnConflictAction::Update {
13844                    assignments,
13845                    where_,
13846                } = &mut clause.action
13847            {
13848                for (_, e) in assignments.iter_mut() {
13849                    substitute_expr(e, params)?;
13850                }
13851                if let Some(w) = where_ {
13852                    substitute_expr(w, params)?;
13853                }
13854            }
13855        }
13856        Statement::Update(u) => {
13857            for (_, e) in &mut u.assignments {
13858                substitute_expr(e, params)?;
13859            }
13860            if let Some(w) = &mut u.where_ {
13861                substitute_expr(w, params)?;
13862            }
13863        }
13864        Statement::Delete(d) => {
13865            if let Some(w) = &mut d.where_ {
13866                substitute_expr(w, params)?;
13867            }
13868        }
13869        Statement::Explain(e) => substitute_select(&mut e.inner, params)?,
13870        // Other statements (CREATE / BEGIN / SHOW / …) have no
13871        // expression slots; no walk needed.
13872        _ => {}
13873    }
13874    Ok(())
13875}
13876
13877fn substitute_select(s: &mut SelectStatement, params: &[Value]) -> Result<(), EngineError> {
13878    for item in &mut s.items {
13879        if let SelectItem::Expr { expr, .. } = item {
13880            substitute_expr(expr, params)?;
13881        }
13882    }
13883    if let Some(w) = &mut s.where_ {
13884        substitute_expr(w, params)?;
13885    }
13886    if let Some(gs) = &mut s.group_by {
13887        for g in gs {
13888            substitute_expr(g, params)?;
13889        }
13890    }
13891    if let Some(h) = &mut s.having {
13892        substitute_expr(h, params)?;
13893    }
13894    for o in &mut s.order_by {
13895        substitute_expr(&mut o.expr, params)?;
13896    }
13897    for (_, peer) in &mut s.unions {
13898        substitute_select(peer, params)?;
13899    }
13900    // v7.9.24 — LIMIT $N / OFFSET $N placeholder resolution.
13901    // mailrs H2. After this pass each LIMIT/OFFSET that was a
13902    // Placeholder is rewritten to Literal so the existing
13903    // `LimitExpr::as_literal` path consumes a concrete u32.
13904    if let Some(le) = s.limit {
13905        s.limit = Some(resolve_limit_placeholder(le, params)?);
13906    }
13907    if let Some(le) = s.offset {
13908        s.offset = Some(resolve_limit_placeholder(le, params)?);
13909    }
13910    Ok(())
13911}
13912
13913fn resolve_limit_placeholder(
13914    le: spg_sql::ast::LimitExpr,
13915    params: &[Value],
13916) -> Result<spg_sql::ast::LimitExpr, EngineError> {
13917    use spg_sql::ast::LimitExpr;
13918    match le {
13919        LimitExpr::Literal(_) => Ok(le),
13920        LimitExpr::Placeholder(n) => {
13921            let idx = usize::from(n).saturating_sub(1);
13922            let v = params.get(idx).ok_or_else(|| {
13923                EngineError::Eval(EvalError::PlaceholderOutOfRange {
13924                    n,
13925                    bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
13926                })
13927            })?;
13928            let int = match v {
13929                Value::SmallInt(x) => Some(i64::from(*x)),
13930                Value::Int(x) => Some(i64::from(*x)),
13931                Value::BigInt(x) => Some(*x),
13932                _ => None,
13933            }
13934            .ok_or_else(|| {
13935                EngineError::Unsupported(alloc::format!(
13936                    "LIMIT/OFFSET ${n} bound to non-integer {v:?}"
13937                ))
13938            })?;
13939            if int < 0 {
13940                return Err(EngineError::Unsupported(alloc::format!(
13941                    "LIMIT/OFFSET ${n} bound to negative value {int}"
13942                )));
13943            }
13944            let bounded = u32::try_from(int).map_err(|_| {
13945                EngineError::Unsupported(alloc::format!(
13946                    "LIMIT/OFFSET ${n} value {int} exceeds u32 range"
13947                ))
13948            })?;
13949            Ok(LimitExpr::Literal(bounded))
13950        }
13951    }
13952}
13953
13954fn substitute_expr(e: &mut Expr, params: &[Value]) -> Result<(), EngineError> {
13955    if let Expr::Placeholder(n) = e {
13956        let idx = usize::from(*n).saturating_sub(1);
13957        let v = params.get(idx).ok_or_else(|| {
13958            EngineError::Eval(EvalError::PlaceholderOutOfRange {
13959                n: *n,
13960                bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
13961            })
13962        })?;
13963        *e = Expr::Literal(value_to_literal(v.clone()));
13964        return Ok(());
13965    }
13966    match e {
13967        Expr::AggregateOrdered { call, order_by } => {
13968            substitute_expr(call, params)?;
13969            for o in order_by.iter_mut() {
13970                substitute_expr(&mut o.expr, params)?;
13971            }
13972        }
13973        Expr::Binary { lhs, rhs, .. } => {
13974            substitute_expr(lhs, params)?;
13975            substitute_expr(rhs, params)?;
13976        }
13977        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13978            substitute_expr(expr, params)?;
13979        }
13980        Expr::FunctionCall { args, .. } => {
13981            for a in args {
13982                substitute_expr(a, params)?;
13983            }
13984        }
13985        Expr::Like { expr, pattern, .. } => {
13986            substitute_expr(expr, params)?;
13987            substitute_expr(pattern, params)?;
13988        }
13989        Expr::Extract { source, .. } => substitute_expr(source, params)?,
13990        Expr::ScalarSubquery(s) => substitute_select(s, params)?,
13991        Expr::Exists { subquery, .. } => substitute_select(subquery, params)?,
13992        Expr::InSubquery { expr, subquery, .. } => {
13993            substitute_expr(expr, params)?;
13994            substitute_select(subquery, params)?;
13995        }
13996        Expr::WindowFunction {
13997            args,
13998            partition_by,
13999            order_by,
14000            ..
14001        } => {
14002            for a in args {
14003                substitute_expr(a, params)?;
14004            }
14005            for p in partition_by {
14006                substitute_expr(p, params)?;
14007            }
14008            for (e, _) in order_by {
14009                substitute_expr(e, params)?;
14010            }
14011        }
14012        Expr::Literal(_) | Expr::Column(_) => {}
14013        // Already handled above.
14014        Expr::Placeholder(_) => unreachable!("Placeholder handled at top of fn"),
14015        Expr::Array(items) => {
14016            for elem in items {
14017                substitute_expr(elem, params)?;
14018            }
14019        }
14020        Expr::ArraySubscript { target, index } => {
14021            substitute_expr(target, params)?;
14022            substitute_expr(index, params)?;
14023        }
14024        Expr::AnyAll { expr, array, .. } => {
14025            substitute_expr(expr, params)?;
14026            substitute_expr(array, params)?;
14027        }
14028        Expr::Case {
14029            operand,
14030            branches,
14031            else_branch,
14032        } => {
14033            if let Some(o) = operand {
14034                substitute_expr(o, params)?;
14035            }
14036            for (w, t) in branches {
14037                substitute_expr(w, params)?;
14038                substitute_expr(t, params)?;
14039            }
14040            if let Some(e) = else_branch {
14041                substitute_expr(e, params)?;
14042            }
14043        }
14044    }
14045    Ok(())
14046}
14047
14048/// v6.1.1 — convert a runtime `Value` into the closest matching
14049/// `Literal` for the substitute walker. Lossless for the simple
14050/// scalars (Int / Float / Text / Bool); Numeric / Date / Timestamp
14051/// / Json / Interval render as their canonical text form so the
14052/// downstream coerce_value can re-parse against the target column
14053/// type. SQ8 / HalfVector cells are NOT expected as bind params;
14054/// pgwire's Bind decodes vector params to the f32 representation
14055/// before they reach this helper.
14056/// v6.2.0 — total ordering on `Value`s used by ANALYZE to sort a
14057/// column's non-NULL sample before histogram building. Cross-type
14058/// pairs (Int vs Float, Date vs Timestamp, …) compare via the
14059/// same widening the eval-side `compare` operator uses; everything
14060/// else (the genuinely-incompatible pairs) falls back to ordering
14061/// by canonical string form so the sort is still total + stable.
14062/// Vector / SQ8 / Half / Json / Numeric / Interval values reach
14063/// here only via the string-fallback path because vector columns
14064/// are filtered out upstream.
14065fn sort_values_for_histogram(a: &Value, b: &Value) -> core::cmp::Ordering {
14066    use core::cmp::Ordering;
14067    match (a, b) {
14068        (Value::SmallInt(a), Value::SmallInt(b)) => a.cmp(b),
14069        (Value::Int(a), Value::Int(b)) => a.cmp(b),
14070        (Value::BigInt(a), Value::BigInt(b)) => a.cmp(b),
14071        (Value::SmallInt(a), Value::Int(b)) => i32::from(*a).cmp(b),
14072        (Value::Int(a), Value::SmallInt(b)) => a.cmp(&i32::from(*b)),
14073        (Value::Int(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
14074        (Value::BigInt(a), Value::Int(b)) => a.cmp(&i64::from(*b)),
14075        (Value::SmallInt(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
14076        (Value::BigInt(a), Value::SmallInt(b)) => a.cmp(&i64::from(*b)),
14077        (Value::Float(a), Value::Float(b)) => a.partial_cmp(b).unwrap_or(Ordering::Equal),
14078        (Value::Text(a), Value::Text(b)) | (Value::Json(a), Value::Json(b)) => a.cmp(b),
14079        (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
14080        (Value::Date(a), Value::Date(b)) => a.cmp(b),
14081        (Value::Timestamp(a), Value::Timestamp(b)) => a.cmp(b),
14082        // Mixed numeric/float — widen to f64 and compare.
14083        (Value::SmallInt(n), Value::Float(x)) => {
14084            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
14085        }
14086        (Value::Float(x), Value::SmallInt(n)) => {
14087            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
14088        }
14089        (Value::Int(n), Value::Float(x)) => {
14090            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
14091        }
14092        (Value::Float(x), Value::Int(n)) => {
14093            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
14094        }
14095        (Value::BigInt(n), Value::Float(x)) => {
14096            #[allow(clippy::cast_precision_loss)]
14097            let nf = *n as f64;
14098            nf.partial_cmp(x).unwrap_or(Ordering::Equal)
14099        }
14100        (Value::Float(x), Value::BigInt(n)) => {
14101            #[allow(clippy::cast_precision_loss)]
14102            let nf = *n as f64;
14103            x.partial_cmp(&nf).unwrap_or(Ordering::Equal)
14104        }
14105        // Cross-type fallback: lexicographic on canonical form.
14106        // Total + stable so the sort is well-defined.
14107        _ => canonical_value_repr(a).cmp(&canonical_value_repr(b)),
14108    }
14109}
14110
14111/// v6.2.0 — render the histogram bounds list as a `[v0, v1, ...]`
14112/// string for the `spg_statistic.histogram_bounds` column. Values
14113/// containing `,` or `[` / `]` are JSON-style escaped so the
14114/// rendering round-trips through a future parser; v6.2.0 only
14115/// uses the rendered form for human consumption, so the escaping
14116/// is conservative.
14117fn render_histogram_bounds(bounds: &[alloc::string::String]) -> alloc::string::String {
14118    let mut out = alloc::string::String::with_capacity(bounds.len() * 8 + 2);
14119    out.push('[');
14120    for (i, b) in bounds.iter().enumerate() {
14121        if i > 0 {
14122            out.push_str(", ");
14123        }
14124        let needs_quote = b.contains([',', '[', ']', '"']) || b.is_empty();
14125        if needs_quote {
14126            out.push('"');
14127            for ch in b.chars() {
14128                if ch == '"' || ch == '\\' {
14129                    out.push('\\');
14130                }
14131                out.push(ch);
14132            }
14133            out.push('"');
14134        } else {
14135            out.push_str(b);
14136        }
14137    }
14138    out.push(']');
14139    out
14140}
14141
14142/// v6.2.0 — canonical textual form of a `Value` for histogram
14143/// bound storage. Strings used by ANALYZE for sort + bound output.
14144/// INT / BIGINT → decimal; FLOAT → shortest-round-trip via
14145/// `{:?}`; TEXT pass-through; BOOL → `t` / `f`; DATE / TIMESTAMP →
14146/// the same form `format_date` / `format_timestamp` produce for
14147/// SQL Display. Vector / SQ8 / Half / Json / Numeric / Interval
14148/// reach this only via a non-Vector column (vector columns are
14149/// skipped upstream); they fall back to a Debug-derived form so
14150/// stats still serialise without crashing.
14151pub(crate) fn canonical_value_repr(v: &Value) -> alloc::string::String {
14152    match v {
14153        Value::Null => "NULL".to_string(),
14154        Value::SmallInt(n) => alloc::format!("{n}"),
14155        Value::Int(n) => alloc::format!("{n}"),
14156        Value::BigInt(n) => alloc::format!("{n}"),
14157        Value::Float(x) => alloc::format!("{x:?}"),
14158        Value::Text(s) | Value::Json(s) => s.clone(),
14159        Value::Bool(b) => if *b { "t" } else { "f" }.to_string(),
14160        Value::Date(d) => eval::format_date(*d),
14161        Value::Timestamp(t) => eval::format_timestamp(*t),
14162        // v7.17.0 Phase 3.P0-32 — PG TIME canonical text form.
14163        Value::Time(us) => eval::format_time(*us),
14164        // v7.17.0 Phase 3.P0-33 — MySQL YEAR 4-digit zero-padded.
14165        Value::Year(y) => alloc::format!("{y:04}"),
14166        // v7.17.0 Phase 3.P0-34 — PG TIMETZ canonical text form.
14167        Value::TimeTz { us, offset_secs } => eval::format_timetz(*us, *offset_secs),
14168        // v7.17.0 Phase 3.P0-35 — PG MONEY canonical en_US text form.
14169        Value::Money(c) => eval::format_money(*c),
14170        // v7.17.0 Phase 3.P0-38 — PG range canonical text form.
14171        v @ Value::Range { .. } => format_range_str(v),
14172        // v7.17.0 Phase 3.P0-39 — PG hstore canonical text form.
14173        Value::Hstore(pairs) => format_hstore_str(pairs),
14174        // v7.17.0 Phase 3.P0-40 — 2D array canonical text form.
14175        Value::IntArray2D(rows) => format_int_2d_text(rows),
14176        Value::BigIntArray2D(rows) => format_bigint_2d_text(rows),
14177        Value::TextArray2D(rows) => format_text_2d_text(rows),
14178        Value::Interval { months, micros } => eval::format_interval(*months, *micros),
14179        Value::Numeric { scaled, scale } => eval::format_numeric(*scaled, *scale),
14180        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
14181            // Unreachable in practice (vector columns are filtered
14182            // out before this). Defensive fallback so a future
14183            // vector-stats path doesn't crash.
14184            alloc::format!("{v:?}")
14185        }
14186        // v7.5.0 — Value is #[non_exhaustive] for downstream
14187        // forward-compat. Future variants fall through to Debug
14188        // form here (same shape as the vector fallback above).
14189        _ => alloc::format!("{v:?}"),
14190    }
14191}
14192
14193/// v6.2.0 — true for engine-managed catalog tables that the bare
14194/// `ANALYZE` (no target) should skip. v6.2.0 has no internal
14195/// tables yet (publications / subscriptions / users / statistics
14196/// all live as engine fields, not catalog tables), so this is a
14197/// reserved future-proofing hook — every existing user table is
14198/// analysed.
14199const fn is_internal_table_name(_name: &str) -> bool {
14200    false
14201}
14202
14203fn value_to_literal(v: Value) -> Literal {
14204    match v {
14205        Value::Null => Literal::Null,
14206        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
14207        Value::Int(n) => Literal::Integer(i64::from(n)),
14208        Value::BigInt(n) => Literal::Integer(n),
14209        Value::Float(x) => Literal::Float(x),
14210        Value::Text(s) | Value::Json(s) => Literal::String(s),
14211        Value::Bool(b) => Literal::Bool(b),
14212        Value::Vector(v) => Literal::Vector(v),
14213        Value::Numeric { scaled, scale } => Literal::String(eval::format_numeric(scaled, scale)),
14214        Value::Date(d) => Literal::String(eval::format_date(d)),
14215        Value::Timestamp(t) => Literal::String(eval::format_timestamp(t)),
14216        // v7.17.0 Phase 3.P0-69 — UUID round-trips via canonical
14217        // hyphenated text. Without this arm the fallback below
14218        // renders `Debug` form ("Uuid([85, …])") which the
14219        // engine's Text → Uuid coerce can't parse, breaking
14220        // prepared-bind round-trip from the spg-sqlx adapter.
14221        Value::Uuid(b) => Literal::String(spg_storage::format_uuid(&b)),
14222        // v7.16.0 — BYTEA round-trip for the spg-sqlx Bind path.
14223        // PG-canonical text rep is `\x` + lowercase hex; the
14224        // engine's coerce_value already accepts that on the
14225        // text → bytea direction.
14226        Value::Bytes(b) => Literal::String(eval::format_bytea_hex(&b)),
14227        // Arrays ride the AST natively (mailrs embed round-12) —
14228        // the prior `{a,b,c}` text form only worked where a column
14229        // type drove the re-parse; `= ANY($1)` has no column
14230        // context and saw a bare Text value.
14231        Value::TextArray(items) => Literal::TextArray(items),
14232        Value::IntArray(items) => Literal::IntArray(items),
14233        Value::BigIntArray(items) => Literal::BigIntArray(items),
14234        Value::Interval { months, micros } => Literal::Interval {
14235            months,
14236            micros,
14237            text: eval::format_interval(months, micros),
14238        },
14239        // SQ8 / halfvec cells dequantise to f32 before reaching the
14240        // substitute walker; pgwire's Bind path handles that.
14241        Value::Sq8Vector(q) => Literal::Vector(spg_storage::quantize::dequantize(&q)),
14242        Value::HalfVector(h) => Literal::Vector(h.to_f32_vec()),
14243        // v7.5.0 — Value is #[non_exhaustive]; future variants
14244        // render as Debug-form String literal until explicit
14245        // mapping is added.
14246        v => Literal::String(alloc::format!("{v:?}")),
14247    }
14248}
14249
14250fn rewrite_clock_calls(stmt: &mut Statement, now_micros: Option<i64>) {
14251    let Some(now) = now_micros else {
14252        return;
14253    };
14254    match stmt {
14255        Statement::Select(s) => rewrite_select_clock(s, now),
14256        Statement::Insert(ins) => {
14257            for row in &mut ins.rows {
14258                for e in row {
14259                    rewrite_expr_clock(e, now);
14260                }
14261            }
14262            // `ON CONFLICT … DO UPDATE SET created_at = NOW()` —
14263            // the upsert assignments carry clock calls too (mailrs
14264            // embed round-12).
14265            if let Some(clause) = &mut ins.on_conflict
14266                && let spg_sql::ast::OnConflictAction::Update {
14267                    assignments,
14268                    where_,
14269                } = &mut clause.action
14270            {
14271                for (_, e) in assignments.iter_mut() {
14272                    rewrite_expr_clock(e, now);
14273                }
14274                if let Some(w) = where_ {
14275                    rewrite_expr_clock(w, now);
14276                }
14277            }
14278        }
14279        // `UPDATE … SET seen_at = NOW() WHERE …` / `DELETE … WHERE
14280        // ts < NOW()` (mailrs embed round-12 — previously only
14281        // SELECT / INSERT-rows were walked).
14282        Statement::Update(u) => {
14283            for (_, e) in &mut u.assignments {
14284                rewrite_expr_clock(e, now);
14285            }
14286            if let Some(w) = &mut u.where_ {
14287                rewrite_expr_clock(w, now);
14288            }
14289        }
14290        Statement::Delete(d) => {
14291            if let Some(w) = &mut d.where_ {
14292                rewrite_expr_clock(w, now);
14293            }
14294        }
14295        _ => {}
14296    }
14297}
14298
14299fn rewrite_select_clock(s: &mut SelectStatement, now: i64) {
14300    for item in &mut s.items {
14301        if let SelectItem::Expr { expr, .. } = item {
14302            rewrite_expr_clock(expr, now);
14303        }
14304    }
14305    if let Some(w) = &mut s.where_ {
14306        rewrite_expr_clock(w, now);
14307    }
14308    if let Some(gs) = &mut s.group_by {
14309        for g in gs {
14310            rewrite_expr_clock(g, now);
14311        }
14312    }
14313    if let Some(h) = &mut s.having {
14314        rewrite_expr_clock(h, now);
14315    }
14316    for o in &mut s.order_by {
14317        rewrite_expr_clock(&mut o.expr, now);
14318    }
14319    for (_, peer) in &mut s.unions {
14320        rewrite_select_clock(peer, now);
14321    }
14322}
14323
14324/// v3.0.3 hot path: every recursion lands in exactly one `match` arm.
14325/// Literal / Column-with-qualifier (the dominant cases on a typical
14326/// AST) take a single pattern dispatch and exit. The clock-rewrite
14327/// targets (zero-arg `NOW` / `CURRENT_TIMESTAMP` / `CURRENT_DATE`
14328/// functions, and bare `CURRENT_TIMESTAMP` / `CURRENT_DATE` column
14329/// refs) sit on their own arms with match guards so the fall-through
14330/// to the recursive arms is unambiguous.
14331fn rewrite_expr_clock(e: &mut Expr, now: i64) {
14332    // Fast-path test on the no-recursion shapes first. We can't fold
14333    // them into the big match below because they need to *replace* `e`
14334    // outright; the recursive arms below match on its sub-fields.
14335    if let Some(replacement) = clock_replacement_for(e, now) {
14336        *e = replacement;
14337        return;
14338    }
14339    match e {
14340        Expr::AggregateOrdered { call, order_by } => {
14341            rewrite_expr_clock(call, now);
14342            for o in order_by.iter_mut() {
14343                rewrite_expr_clock(&mut o.expr, now);
14344            }
14345        }
14346        Expr::Binary { lhs, rhs, .. } => {
14347            rewrite_expr_clock(lhs, now);
14348            rewrite_expr_clock(rhs, now);
14349        }
14350        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
14351            rewrite_expr_clock(expr, now);
14352        }
14353        Expr::FunctionCall { args, .. } => {
14354            for a in args {
14355                rewrite_expr_clock(a, now);
14356            }
14357        }
14358        Expr::Like { expr, pattern, .. } => {
14359            rewrite_expr_clock(expr, now);
14360            rewrite_expr_clock(pattern, now);
14361        }
14362        Expr::Extract { source, .. } => rewrite_expr_clock(source, now),
14363        // v4.10 subquery nodes — recurse into the inner SELECT's
14364        // expression slots so e.g. SELECT NOW() in a scalar
14365        // subquery picks up the same instant as the outer query.
14366        Expr::ScalarSubquery(s) => rewrite_select_clock(s, now),
14367        Expr::Exists { subquery, .. } => rewrite_select_clock(subquery, now),
14368        Expr::InSubquery { expr, subquery, .. } => {
14369            rewrite_expr_clock(expr, now);
14370            rewrite_select_clock(subquery, now);
14371        }
14372        // v4.12 window functions — args + PARTITION BY + ORDER BY
14373        // may all reference clock literals.
14374        Expr::WindowFunction {
14375            args,
14376            partition_by,
14377            order_by,
14378            ..
14379        } => {
14380            for a in args {
14381                rewrite_expr_clock(a, now);
14382            }
14383            for p in partition_by {
14384                rewrite_expr_clock(p, now);
14385            }
14386            for (e, _) in order_by {
14387                rewrite_expr_clock(e, now);
14388            }
14389        }
14390        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
14391        Expr::Array(items) => {
14392            for elem in items {
14393                rewrite_expr_clock(elem, now);
14394            }
14395        }
14396        Expr::ArraySubscript { target, index } => {
14397            rewrite_expr_clock(target, now);
14398            rewrite_expr_clock(index, now);
14399        }
14400        Expr::AnyAll { expr, array, .. } => {
14401            rewrite_expr_clock(expr, now);
14402            rewrite_expr_clock(array, now);
14403        }
14404        Expr::Case {
14405            operand,
14406            branches,
14407            else_branch,
14408        } => {
14409            if let Some(o) = operand {
14410                rewrite_expr_clock(o, now);
14411            }
14412            for (w, t) in branches {
14413                rewrite_expr_clock(w, now);
14414                rewrite_expr_clock(t, now);
14415            }
14416            if let Some(e) = else_branch {
14417                rewrite_expr_clock(e, now);
14418            }
14419        }
14420    }
14421}
14422
14423/// Returns `Some(Expr)` when `e` is one of the clock-call shapes that
14424/// must be rewritten; otherwise `None` so the caller falls through to
14425/// the recursive walk. Identifies both function-call forms (`NOW()` /
14426/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()`) and bare-identifier forms
14427/// (`CURRENT_TIMESTAMP` / `CURRENT_DATE` as unqualified column refs,
14428/// which is how PG accepts them without parens).
14429fn clock_replacement_for(e: &Expr, now: i64) -> Option<Expr> {
14430    let (kind, name) = match e {
14431        Expr::FunctionCall { name, args } if args.is_empty() => (ClockSite::Fn, name.as_str()),
14432        Expr::Column(c) if c.qualifier.is_none() => (ClockSite::BareIdent, c.name.as_str()),
14433        _ => return None,
14434    };
14435    // ASCII case-insensitive name match. Each entry decides what
14436    // synthetic literal the call expands to.
14437    //
14438    // v7.17.0 Phase 3.P0-29 — `unix_timestamp` (no args) joins this
14439    // table as MySQL's epoch-seconds equivalent of `now()`. Folded
14440    // to a BigInt literal here so apply_function never needs a
14441    // clock dependency.
14442    enum ClockShape {
14443        Timestamp,
14444        Date,
14445        UnixSeconds,
14446    }
14447    let shape = match name.len() {
14448        3 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("now") => {
14449            Some(ClockShape::Timestamp)
14450        }
14451        12 if name.eq_ignore_ascii_case("current_date") => Some(ClockShape::Date),
14452        14 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("unix_timestamp") => {
14453            Some(ClockShape::UnixSeconds)
14454        }
14455        17 if name.eq_ignore_ascii_case("current_timestamp") => Some(ClockShape::Timestamp),
14456        _ => None,
14457    };
14458    let shape = shape?;
14459    let payload = match shape {
14460        ClockShape::Timestamp => now,
14461        ClockShape::Date => now.div_euclid(86_400_000_000),
14462        ClockShape::UnixSeconds => now.div_euclid(1_000_000),
14463    };
14464    let target = match shape {
14465        ClockShape::Timestamp => spg_sql::ast::CastTarget::Timestamp,
14466        ClockShape::Date => spg_sql::ast::CastTarget::Date,
14467        ClockShape::UnixSeconds => spg_sql::ast::CastTarget::BigInt,
14468    };
14469    Some(Expr::Cast {
14470        expr: alloc::boxed::Box::new(Expr::Literal(spg_sql::ast::Literal::Integer(payload))),
14471        target,
14472    })
14473}
14474
14475#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14476enum ClockSite {
14477    Fn,
14478    BareIdent,
14479}
14480
14481/// `ORDER BY <integer>` references the N-th SELECT item (1-based).
14482/// Swap the integer literal for the matching item's expression so the
14483/// executor doesn't need a special-case branch. Recurses into UNION
14484/// peers because each peer keeps its own SELECT list.
14485/// v6.4.1 — expand `GROUP BY ALL` to every non-aggregate SELECT-list
14486/// item. Mirrors DuckDB / PG 19 semantics. Wildcards (`SELECT * …`)
14487/// are NOT expanded by GROUP BY ALL (PG 19 leaves the wildcard intact
14488/// and groups by whatever explicit non-aggregates remain — none in
14489/// the wildcard-only case, which still works for non-aggregate
14490/// queries).
14491fn expand_group_by_all(s: &mut SelectStatement) {
14492    if !s.group_by_all {
14493        for (_, peer) in &mut s.unions {
14494            expand_group_by_all(peer);
14495        }
14496        return;
14497    }
14498    let mut groups: Vec<Expr> = Vec::new();
14499    for item in &s.items {
14500        if let SelectItem::Expr { expr, .. } = item
14501            && !aggregate::contains_aggregate(expr)
14502        {
14503            groups.push(expr.clone());
14504        }
14505    }
14506    s.group_by = Some(groups);
14507    s.group_by_all = false;
14508    for (_, peer) in &mut s.unions {
14509        expand_group_by_all(peer);
14510    }
14511}
14512
14513fn resolve_order_by_position(s: &mut SelectStatement) {
14514    // v6.4.0 — iterate every ORDER BY key. Position references
14515    // (`ORDER BY 2`) bind to the 1-based projection index;
14516    // identifier references that match a SELECT-list alias bind to
14517    // the projected expression (Step 4 of L3a).
14518    for order in &mut s.order_by {
14519        match &order.expr {
14520            Expr::Literal(Literal::Integer(n)) if *n >= 1 => {
14521                if let Ok(idx_one_based) = usize::try_from(*n) {
14522                    let idx = idx_one_based - 1;
14523                    if idx < s.items.len()
14524                        && let SelectItem::Expr { expr, .. } = &s.items[idx]
14525                    {
14526                        order.expr = expr.clone();
14527                    }
14528                }
14529            }
14530            Expr::Column(c) if c.qualifier.is_none() => {
14531                // Alias-in-ORDER-BY lookup.
14532                for item in &s.items {
14533                    if let SelectItem::Expr {
14534                        expr,
14535                        alias: Some(a),
14536                    } = item
14537                        && a == &c.name
14538                    {
14539                        order.expr = expr.clone();
14540                        break;
14541                    }
14542                }
14543            }
14544            _ => {}
14545        }
14546    }
14547    for (_, peer) in &mut s.unions {
14548        resolve_order_by_position(peer);
14549    }
14550}
14551
14552/// Sort `tagged` by `f64` key, reversing the comparator under DESC.
14553/// Used by the UNION ORDER BY path; per-block paths inline the same
14554/// comparator because they already hold `&OrderBy` directly.
14555/// v3.1.1: partial-sort helper. When `keep` (= offset + limit) is
14556/// strictly less than `tagged.len()`, run `select_nth_unstable_by` to
14557/// partition the prefix in O(n), then sort just that prefix in O(k
14558/// log k). Total O(n + k log k), vs O(n log n) for a full sort. The
14559/// caller decides what `keep` is; passing `None` (no LIMIT) keeps the
14560/// full-sort behaviour.
14561///
14562/// `tagged` holds `(Option<f64>, Row)` (the SELECT path) — `None` keys
14563/// sort last in ascending order, mirroring NULL-sorts-last in SQL.
14564fn partial_sort_tagged(tagged: &mut Vec<(Vec<f64>, Row)>, keep: Option<usize>, descs: &[bool]) {
14565    let cmp = |a: &(Vec<f64>, Row), b: &(Vec<f64>, Row)| cmp_multi_key(&a.0, &b.0, descs);
14566    match keep {
14567        Some(k) if k < tagged.len() && k > 0 => {
14568            let pivot = k - 1;
14569            tagged.select_nth_unstable_by(pivot, cmp);
14570            tagged[..k].sort_by(cmp);
14571            tagged.truncate(k);
14572        }
14573        _ => {
14574            tagged.sort_by(cmp);
14575        }
14576    }
14577}
14578
14579fn sort_by_keys(tagged: &mut [(Vec<f64>, Row)], descs: &[bool]) {
14580    tagged.sort_by(|a, b| cmp_multi_key(&a.0, &b.0, descs));
14581}
14582
14583/// v6.4.0 — multi-key ORDER BY comparator. Each key's per-key DESC
14584/// flag is honored independently. NULL is encoded as `f64::INFINITY`
14585/// so it sorts last in ASC and first in DESC (matches PG default).
14586fn cmp_multi_key(a: &[f64], b: &[f64], descs: &[bool]) -> core::cmp::Ordering {
14587    use core::cmp::Ordering;
14588    for (i, (ka, kb)) in a.iter().zip(b.iter()).enumerate() {
14589        let ord = ka.partial_cmp(kb).unwrap_or(Ordering::Equal);
14590        let ord = if descs.get(i).copied().unwrap_or(false) {
14591            ord.reverse()
14592        } else {
14593            ord
14594        };
14595        if ord != Ordering::Equal {
14596            return ord;
14597        }
14598    }
14599    Ordering::Equal
14600}
14601
14602/// v6.4.0 — eval every ORDER BY expression for a row and pack the
14603/// resulting keys into a `Vec<f64>`. NULL → `f64::INFINITY`.
14604fn build_order_keys(
14605    order_by: &[OrderBy],
14606    row: &Row,
14607    ctx: &EvalContext,
14608) -> Result<Vec<f64>, EngineError> {
14609    let mut keys = Vec::with_capacity(order_by.len());
14610    for o in order_by {
14611        let v = eval::eval_expr(&o.expr, row, ctx)?;
14612        // v7.24 (round-16 A) — explicit NULLS FIRST/LAST. The f64
14613        // packing sorts ascending THEN applies the per-key DESC
14614        // reverse, so a NULL must land at +INF exactly when the
14615        // effective placement agrees with the reverse direction:
14616        // nf == desc → +INF (ASC default last / DESC default
14617        // first), nf != desc → -INF (the explicit flips).
14618        if matches!(v, Value::Null) {
14619            let nf = o.nulls_first.unwrap_or(o.desc);
14620            keys.push(if nf == o.desc {
14621                f64::INFINITY
14622            } else {
14623                f64::NEG_INFINITY
14624            });
14625        } else {
14626            keys.push(value_to_order_key(&v)?);
14627        }
14628    }
14629    Ok(keys)
14630}
14631
14632/// Drop the first `offset` rows then truncate to `limit`. PG / `MySQL`
14633/// agree: OFFSET applies *after* ORDER BY but *before* LIMIT (so
14634/// `LIMIT 10 OFFSET 5` keeps rows 6..=15).
14635fn apply_offset_and_limit(rows: &mut Vec<Row>, offset: Option<u32>, limit: Option<u32>) {
14636    if let Some(off) = offset {
14637        let off = off as usize;
14638        if off >= rows.len() {
14639            rows.clear();
14640        } else {
14641            rows.drain(..off);
14642        }
14643    }
14644    if let Some(n) = limit {
14645        rows.truncate(n as usize);
14646    }
14647}
14648
14649/// v7.17.0 Phase 3.P0-49 — offset + limit applied to a tagged
14650/// `(order_keys, row)` sequence, with optional SQL:2008 `WITH
14651/// TIES` extension. When `with_ties` is set, the truncated tail
14652/// is extended through every subsequent row whose order keys
14653/// equal the last-kept row's keys (so a "top 3 by score" with
14654/// WITH TIES emits row 4 too when row 4 ties row 3 on `score`).
14655///
14656/// The order-key vector is the per-row sort key the caller already
14657/// computed via `build_order_keys`; equal-key detection therefore
14658/// matches the sort comparator exactly.
14659fn apply_offset_and_limit_tagged(
14660    tagged: &mut Vec<(Vec<f64>, Row)>,
14661    offset: Option<u32>,
14662    limit: Option<u32>,
14663    with_ties: bool,
14664) {
14665    if let Some(off) = offset {
14666        let off = off as usize;
14667        if off >= tagged.len() {
14668            tagged.clear();
14669        } else {
14670            tagged.drain(..off);
14671        }
14672    }
14673    if let Some(n) = limit {
14674        let n = n as usize;
14675        if with_ties && n > 0 && n < tagged.len() {
14676            let cutoff_key = tagged[n - 1].0.clone();
14677            let mut end = n;
14678            while end < tagged.len() && tagged[end].0 == cutoff_key {
14679                end += 1;
14680            }
14681            tagged.truncate(end);
14682        } else {
14683            tagged.truncate(n);
14684        }
14685    }
14686}
14687
14688/// v7.17.0 Phase 3.P0-49 — PG-canonical: `FETCH FIRST <n> ROWS
14689/// WITH TIES` requires an `ORDER BY`. Without one, there's no
14690/// way to identify "ties" deterministically, so PG errors at
14691/// plan time. SPG mirrors that surface so the same DDL / app
14692/// behaviour holds on cutover.
14693fn check_with_ties_requires_order_by(stmt: &SelectStatement) -> Result<(), EngineError> {
14694    if stmt.limit_with_ties && stmt.order_by.is_empty() {
14695        return Err(EngineError::Unsupported(alloc::string::String::from(
14696            "FETCH FIRST … ROWS WITH TIES requires an ORDER BY clause",
14697        )));
14698    }
14699    Ok(())
14700}
14701
14702/// v7.6.1 — resolve a parser-level `ForeignKeyConstraint` (column
14703/// names + parent table name) into the storage-layer shape (column
14704/// indices + same parent table). Validates everything the engine
14705/// needs to know about the FK at CREATE TABLE time:
14706///
14707///   - parent table exists (catalog lookup, unless self-referencing)
14708///   - parent columns exist on the parent table
14709///   - parent column list matches the local arity (defaults to the
14710///     parent's primary index column when omitted)
14711///   - parent columns are covered by a `BTree` UNIQUE-class index
14712///     (SPG's stand-in for `PRIMARY KEY`/`UNIQUE`) — required so
14713///     the v7.6.2 INSERT path can do an O(log n) parent lookup
14714///   - local columns exist on the table being created
14715fn resolve_foreign_key(
14716    local_table_name: &str,
14717    local_cols: &[ColumnSchema],
14718    fk: spg_sql::ast::ForeignKeyConstraint,
14719    catalog: &Catalog,
14720) -> Result<spg_storage::ForeignKeyConstraint, EngineError> {
14721    // Resolve local columns.
14722    let mut local_columns = Vec::with_capacity(fk.columns.len());
14723    for name in &fk.columns {
14724        let pos = local_cols
14725            .iter()
14726            .position(|c| c.name == *name)
14727            .ok_or_else(|| {
14728                EngineError::Unsupported(alloc::format!(
14729                    "FOREIGN KEY references unknown local column {name:?}"
14730                ))
14731            })?;
14732        local_columns.push(pos);
14733    }
14734    // Self-referencing FK: parent table is the one we're creating.
14735    // The parent column resolution uses the local column list since
14736    // the catalog doesn't have this table yet.
14737    let is_self_ref = fk.parent_table == local_table_name;
14738    let (parent_cols_for_lookup, parent_table_str): (&[ColumnSchema], &str) = if is_self_ref {
14739        (local_cols, local_table_name)
14740    } else {
14741        let parent_table = catalog.get(&fk.parent_table).ok_or_else(|| {
14742            EngineError::Storage(StorageError::TableNotFound {
14743                name: fk.parent_table.clone(),
14744            })
14745        })?;
14746        (
14747            parent_table.schema().columns.as_slice(),
14748            fk.parent_table.as_str(),
14749        )
14750    };
14751    // Resolve parent column names → positions. If the FK omitted the
14752    // parent column list, fall back to the parent's primary index
14753    // column (single-column only — composite default is rejected
14754    // because there's no unambiguous "PK" in SPG's index list).
14755    let parent_columns: Vec<usize> = if fk.parent_columns.is_empty() {
14756        if fk.columns.len() != 1 {
14757            return Err(EngineError::Unsupported(
14758                "composite FOREIGN KEY without explicit parent column list is not supported \
14759                 — list the parent columns explicitly"
14760                    .into(),
14761            ));
14762        }
14763        // Find a single BTree index on the parent and use its column.
14764        let pos = pick_pk_index_column(catalog, parent_table_str, is_self_ref, local_cols)
14765            .ok_or_else(|| {
14766                EngineError::Unsupported(alloc::format!(
14767                    "parent table {parent_table_str:?} has no PRIMARY-key / UNIQUE BTree index \
14768                     to default the FOREIGN KEY against"
14769                ))
14770            })?;
14771        alloc::vec![pos]
14772    } else {
14773        let mut out = Vec::with_capacity(fk.parent_columns.len());
14774        for name in &fk.parent_columns {
14775            let pos = parent_cols_for_lookup
14776                .iter()
14777                .position(|c| c.name == *name)
14778                .ok_or_else(|| {
14779                    EngineError::Unsupported(alloc::format!(
14780                        "FOREIGN KEY references unknown parent column \
14781                         {name:?} on table {parent_table_str:?}"
14782                    ))
14783                })?;
14784            out.push(pos);
14785        }
14786        out
14787    };
14788    if parent_columns.len() != local_columns.len() {
14789        return Err(EngineError::Unsupported(alloc::format!(
14790            "FOREIGN KEY arity mismatch: {} local columns vs {} parent columns",
14791            local_columns.len(),
14792            parent_columns.len()
14793        )));
14794    }
14795    // For non-self-referencing FKs, verify the parent column set is
14796    // covered by a BTree index. SPG doesn't have a `PRIMARY KEY`
14797    // declaration; the convention is "the parent column for FK
14798    // purposes must have a BTree index" — which the user creates via
14799    // `CREATE INDEX ... USING btree (col)` (the default). We accept
14800    // any single-column BTree index that covers a parent column;
14801    // composite parent column lists require an index whose `column_position`
14802    // matches the first parent column (multi-column BTree indices
14803    // are not in the v7.x roadmap).
14804    if !is_self_ref {
14805        let parent_table = catalog.get(&fk.parent_table).expect("checked above");
14806        let primary_parent_col = parent_columns[0];
14807        let has_btree = parent_table
14808            .schema()
14809            .columns
14810            .get(primary_parent_col)
14811            .is_some()
14812            && parent_table.indices().iter().any(|idx| {
14813                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14814                    && idx.column_position == primary_parent_col
14815                    && idx.partial_predicate.is_none()
14816            });
14817        if !has_btree {
14818            return Err(EngineError::Unsupported(alloc::format!(
14819                "FOREIGN KEY parent column on {:?} is not covered by an unconditional BTree \
14820                 index — create one with `CREATE INDEX ... ON {} ({})` first",
14821                parent_table_str,
14822                parent_table_str,
14823                parent_table.schema().columns[primary_parent_col].name,
14824            )));
14825        }
14826    }
14827    let on_delete = fk_action_sql_to_storage(fk.on_delete);
14828    let on_update = fk_action_sql_to_storage(fk.on_update);
14829    Ok(spg_storage::ForeignKeyConstraint {
14830        name: fk.name,
14831        local_columns,
14832        parent_table: fk.parent_table,
14833        parent_columns,
14834        on_delete,
14835        on_update,
14836    })
14837}
14838
14839/// v7.6.1 — pick a sentinel "primary key" column from the parent
14840/// table when the FK didn't name parent columns. Picks the first
14841/// single-column unconditional BTree index — that's the closest
14842/// thing SPG has to a PRIMARY KEY today. Self-referencing FKs use
14843/// `local_cols` as the column source.
14844fn pick_pk_index_column(
14845    catalog: &Catalog,
14846    parent_name: &str,
14847    is_self_ref: bool,
14848    local_cols: &[ColumnSchema],
14849) -> Option<usize> {
14850    if is_self_ref {
14851        // Self-ref FK omitted parent columns: pick column 0 by
14852        // convention (no catalog entry yet). Engine will widen this
14853        // when v7.6.7 lands; v7.6.1 only handles the explicit form.
14854        let _ = local_cols;
14855        return Some(0);
14856    }
14857    let parent = catalog.get(parent_name)?;
14858    parent.indices().iter().find_map(|idx| {
14859        if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14860            && idx.partial_predicate.is_none()
14861            && idx.included_columns.is_empty()
14862            && idx.expression.is_none()
14863        {
14864            Some(idx.column_position)
14865        } else {
14866            None
14867        }
14868    })
14869}
14870
14871/// v7.9.8 / v7.9.10 — resolve the column positions that
14872/// identify a conflict for ON CONFLICT. Returns a Vec of
14873/// column positions (1 element for single-column form, N for
14874/// composite). When the user wrote bare `ON CONFLICT DO …`,
14875/// falls back to the table's first unconditional BTree index
14876/// (always single-column today).
14877fn resolve_on_conflict_columns(
14878    catalog: &Catalog,
14879    table_name: &str,
14880    target: &[String],
14881) -> Result<Vec<usize>, EngineError> {
14882    let table = catalog.get(table_name).ok_or_else(|| {
14883        EngineError::Storage(StorageError::TableNotFound {
14884            name: table_name.into(),
14885        })
14886    })?;
14887    if target.is_empty() {
14888        // v7.13.2 — mailrs round-6 S5 follow-up. Composite UNIQUE
14889        // constraints carry a multi-column tuple; the prior code
14890        // path picked only the leading column of the first BTree
14891        // index, which caused `ON CONFLICT DO NOTHING` to dedup
14892        // by leading column alone (3 rows with same group_id but
14893        // different permission collapsed to 1). PG semantics use
14894        // the full tuple. Prefer a UniquenessConstraint's full
14895        // column list when one exists; fall back to the leading
14896        // BTree column for legacy single-column UNIQUE.
14897        if let Some(uc) = table.schema().uniqueness_constraints.first() {
14898            return Ok(uc.columns.clone());
14899        }
14900        let pos = table
14901            .indices()
14902            .iter()
14903            .find_map(|idx| {
14904                if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14905                    && idx.partial_predicate.is_none()
14906                    && idx.included_columns.is_empty()
14907                    && idx.expression.is_none()
14908                {
14909                    Some(idx.column_position)
14910                } else {
14911                    None
14912                }
14913            })
14914            .ok_or_else(|| {
14915                EngineError::Unsupported(alloc::format!(
14916                    "ON CONFLICT without target requires a UNIQUE BTree index on {table_name:?}"
14917                ))
14918            })?;
14919        return Ok(alloc::vec![pos]);
14920    }
14921    let mut out = Vec::with_capacity(target.len());
14922    for name in target {
14923        let pos = table
14924            .schema()
14925            .columns
14926            .iter()
14927            .position(|c| c.name == *name)
14928            .ok_or_else(|| {
14929                EngineError::Unsupported(alloc::format!(
14930                    "ON CONFLICT target column {name:?} not found on {table_name:?}"
14931                ))
14932            })?;
14933        out.push(pos);
14934    }
14935    Ok(out)
14936}
14937
14938/// v7.9.8 — check whether the BTree index on `column_pos` of
14939/// `table_name` already has a row with this key.
14940fn on_conflict_key_exists(
14941    catalog: &Catalog,
14942    table_name: &str,
14943    column_pos: usize,
14944    key: &Value,
14945) -> bool {
14946    let Some(table) = catalog.get(table_name) else {
14947        return false;
14948    };
14949    let Some(idx_key) = spg_storage::IndexKey::from_value(key) else {
14950        return false;
14951    };
14952    table.indices().iter().any(|idx| {
14953        matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14954            && idx.column_position == column_pos
14955            && idx.partial_predicate.is_none()
14956            && !idx.lookup_eq(&idx_key).is_empty()
14957    })
14958}
14959
14960/// v7.9.9 / v7.9.10 — look up an existing row's position by
14961/// matching all `column_positions` against the incoming `key`
14962/// tuple. Single-column shape (one column) reduces to the
14963/// canonical PK lookup; composite shapes scan linearly until
14964/// every position matches.
14965fn lookup_row_position_by_keys(
14966    catalog: &Catalog,
14967    table_name: &str,
14968    column_positions: &[usize],
14969    key: &[&Value],
14970) -> Option<usize> {
14971    let table = catalog.get(table_name)?;
14972    table.rows().iter().position(|r| {
14973        column_positions
14974            .iter()
14975            .enumerate()
14976            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
14977    })
14978}
14979
14980/// v7.9.10 — does the table already contain a row whose
14981/// `column_positions` tuple equals `key`? Single-column shape
14982/// uses the existing BTree fast path; composite shapes fall
14983/// back to a row scan.
14984fn on_conflict_keys_exist(
14985    catalog: &Catalog,
14986    table_name: &str,
14987    column_positions: &[usize],
14988    key: &[&Value],
14989) -> bool {
14990    if column_positions.len() == 1 {
14991        return on_conflict_key_exists(catalog, table_name, column_positions[0], key[0]);
14992    }
14993    let Some(table) = catalog.get(table_name) else {
14994        return false;
14995    };
14996    table.rows().iter().any(|r| {
14997        column_positions
14998            .iter()
14999            .enumerate()
15000            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
15001    })
15002}
15003
15004/// v7.9.9 — apply ON CONFLICT DO UPDATE SET assignments to an
15005/// existing row.
15006///
15007/// `incoming` is the rejected INSERT row (used to resolve
15008/// `EXCLUDED.col` references in the assignment exprs);
15009/// `target_pos` is the position of the existing row in the table.
15010/// Each assignment substitutes `EXCLUDED.col` with the matching
15011/// incoming value, evaluates the resulting expression against
15012/// the existing row, and writes the new value into the
15013/// corresponding column of the returned `Vec<Value>`. If
15014/// `where_` evaluates falsy, returns Ok(None) — PG behaviour:
15015/// the conflicting row is silently kept unchanged.
15016fn apply_on_conflict_assignments(
15017    catalog: &Catalog,
15018    table_name: &str,
15019    target_pos: usize,
15020    incoming: &[Value],
15021    assignments: &[(String, Expr)],
15022    where_: Option<&Expr>,
15023) -> Result<Option<Vec<Value>>, EngineError> {
15024    let table = catalog.get(table_name).ok_or_else(|| {
15025        EngineError::Storage(StorageError::TableNotFound {
15026            name: table_name.into(),
15027        })
15028    })?;
15029    let schema_cols = table.schema().columns.clone();
15030    let existing = table
15031        .rows()
15032        .get(target_pos)
15033        .ok_or_else(|| {
15034            EngineError::Unsupported(alloc::format!(
15035                "ON CONFLICT DO UPDATE: row position {target_pos} out of bounds on {table_name:?}"
15036            ))
15037        })?
15038        .clone();
15039    let ctx = eval::EvalContext::new(&schema_cols, Some(table_name));
15040    // Optional WHERE filter on the conflict row.
15041    if let Some(w) = where_ {
15042        let pred = w.clone();
15043        let pred = substitute_excluded_refs(pred, &schema_cols, incoming);
15044        let v = eval::eval_expr(&pred, &existing, &ctx)?;
15045        if !matches!(v, Value::Bool(true)) {
15046            return Ok(None);
15047        }
15048    }
15049    let mut new_values = existing.values.clone();
15050    for (col_name, expr) in assignments {
15051        let target_idx = schema_cols
15052            .iter()
15053            .position(|c| c.name == *col_name)
15054            .ok_or_else(|| {
15055                EngineError::Eval(EvalError::ColumnNotFound {
15056                    name: col_name.clone(),
15057                })
15058            })?;
15059        let sub = substitute_excluded_refs(expr.clone(), &schema_cols, incoming);
15060        let v = eval::eval_expr(&sub, &existing, &ctx)?;
15061        let coerced = coerce_value(v, schema_cols[target_idx].ty, col_name, target_idx)?;
15062        check_unsigned_range(&coerced, &schema_cols[target_idx], target_idx)?;
15063        new_values[target_idx] = coerced;
15064    }
15065    Ok(Some(new_values))
15066}
15067
15068/// v7.9.9 — walk an `Expr` tree replacing any `Column { qualifier:
15069/// "EXCLUDED", name }` reference with a `Literal` of the matching
15070/// value from the incoming-row vec. Resolution against the
15071/// child-table column list (by name).
15072fn substitute_excluded_refs(expr: Expr, schema_cols: &[ColumnSchema], incoming: &[Value]) -> Expr {
15073    use spg_sql::ast::ColumnName;
15074    match expr {
15075        Expr::Column(ColumnName { qualifier, name })
15076            if qualifier
15077                .as_deref()
15078                .is_some_and(|q| q.eq_ignore_ascii_case("excluded")) =>
15079        {
15080            let pos = schema_cols.iter().position(|c| c.name == name);
15081            match pos {
15082                Some(p) => {
15083                    let v = incoming.get(p).cloned().unwrap_or(Value::Null);
15084                    value_to_literal_expr(v)
15085                        .unwrap_or_else(|_| Expr::Literal(spg_sql::ast::Literal::Null))
15086                }
15087                None => Expr::Column(ColumnName { qualifier, name }),
15088            }
15089        }
15090        Expr::Binary { op, lhs, rhs } => Expr::Binary {
15091            op,
15092            lhs: Box::new(substitute_excluded_refs(*lhs, schema_cols, incoming)),
15093            rhs: Box::new(substitute_excluded_refs(*rhs, schema_cols, incoming)),
15094        },
15095        Expr::Unary { op, expr } => Expr::Unary {
15096            op,
15097            expr: Box::new(substitute_excluded_refs(*expr, schema_cols, incoming)),
15098        },
15099        Expr::FunctionCall { name, args } => Expr::FunctionCall {
15100            name,
15101            args: args
15102                .into_iter()
15103                .map(|a| substitute_excluded_refs(a, schema_cols, incoming))
15104                .collect(),
15105        },
15106        other => other,
15107    }
15108}
15109
15110/// v7.6.2 / v7.6.7 — INSERT-side FK enforcement. For every row
15111/// about to be inserted into `child_table`, every FK declared on
15112/// that table is checked: the row's FK columns must either be
15113/// NULL (SQL spec skip) or match an existing parent row via the
15114/// parent's BTree PK / UNIQUE index.
15115///
15116/// Returns `EngineError::Unsupported` with a `FOREIGN KEY violation`
15117/// payload on first failure.
15118///
15119/// **Self-referencing FKs (v7.6.7 widening):** when `fk.parent_table
15120/// == child_table`, the parent rows visible to this check are
15121///  (a) rows already committed to the table, plus
15122///  (b) earlier rows from the *same* `rows` batch.
15123/// This makes `INSERT INTO tree VALUES (1, NULL), (2, 1), (3, 2)`
15124/// work in a single statement — common pattern for bulk-loading
15125/// hierarchies.
15126/// v7.9.19 — enforce table-level UNIQUE / PRIMARY KEY tuple
15127/// constraints at INSERT time. For each constraint declared on
15128/// the target table, check that no existing row + no earlier row
15129/// in the same batch has the same full-column tuple. NULL in
15130/// any column lifts the row out of the check (SQL spec: NULL
15131/// ≠ NULL for uniqueness). mailrs G1 + G6.
15132fn enforce_uniqueness_inserts(
15133    catalog: &Catalog,
15134    child_table: &str,
15135    constraints: &[spg_storage::UniquenessConstraint],
15136    rows: &[Vec<Value>],
15137) -> Result<(), EngineError> {
15138    if constraints.is_empty() {
15139        return Ok(());
15140    }
15141    let table = catalog.get(child_table).ok_or_else(|| {
15142        EngineError::Storage(StorageError::TableNotFound {
15143            name: child_table.into(),
15144        })
15145    })?;
15146    let schema = table.schema();
15147    for uc in constraints {
15148        for (batch_idx, row_values) in rows.iter().enumerate() {
15149            // v7.17.0 Phase 3.P0-45 — fold each key cell by its
15150            // column's declared Collation before comparing. Phase
15151            // 2.5b wired Collation into GROUP BY / ORDER BY / `=`
15152            // but the UNIQUE-constraint enforcement still compared
15153            // Text byte-wise; a `*_ci` column would let
15154            // `('Foo')` and `('FOO')` coexist when MySQL would
15155            // reject the second. Owned Values so the fold and
15156            // the borrow live in the same scope.
15157            let key: Vec<Value> = uc
15158                .columns
15159                .iter()
15160                .map(|&i| collated_key_cell(&row_values[i], i, schema))
15161                .collect();
15162            let has_null = key.iter().any(|v| matches!(v, Value::Null));
15163            // v7.13.0 — `NULLS NOT DISTINCT` (mailrs round-5 G10,
15164            // PG 15+): two rows whose constrained columns are all
15165            // NULL collide. SQL-standard `NULLS DISTINCT` lets any
15166            // NULL skip the check.
15167            if has_null && !uc.nulls_not_distinct {
15168                continue;
15169            }
15170            // Table-side collision: scan existing rows.
15171            let collides_in_table = table.rows().iter().any(|prow| {
15172                uc.columns.iter().enumerate().all(|(i, &p)| {
15173                    prow.values
15174                        .get(p)
15175                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
15176                })
15177            });
15178            // Batch-side collision: earlier rows in the same INSERT.
15179            let collides_in_batch = rows[..batch_idx].iter().any(|earlier| {
15180                uc.columns.iter().enumerate().all(|(i, &p)| {
15181                    earlier
15182                        .get(p)
15183                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
15184                })
15185            });
15186            if collides_in_table || collides_in_batch {
15187                let kind = if uc.is_primary_key {
15188                    "PRIMARY KEY"
15189                } else {
15190                    "UNIQUE"
15191                };
15192                let col_names: Vec<String> = uc
15193                    .columns
15194                    .iter()
15195                    .map(|&i| table.schema().columns[i].name.clone())
15196                    .collect();
15197                return Err(EngineError::Unsupported(alloc::format!(
15198                    "{kind} violation on {child_table:?} columns {col_names:?}: \
15199                     row #{batch_idx} duplicates an existing key"
15200                )));
15201            }
15202        }
15203    }
15204    Ok(())
15205}
15206
15207/// v7.17.0 Phase 3.P0-45 — return a key cell folded by its column's
15208/// declared `Collation`. For `CaseInsensitive`, fold Text payloads to
15209/// ASCII lowercase (matches Phase 2.5's `*_ci` semantics: ASCII case-
15210/// fold only, non-ASCII bytes stay byte-wise). For `Binary` or non-Text
15211/// values, the cell passes through unchanged. The caller compares the
15212/// folded values with `==`.
15213fn collated_key_cell(
15214    v: &spg_storage::Value,
15215    column_position: usize,
15216    schema: &spg_storage::TableSchema,
15217) -> spg_storage::Value {
15218    match (v, schema.columns.get(column_position).map(|c| c.collation)) {
15219        (spg_storage::Value::Text(s), Some(spg_storage::Collation::CaseInsensitive)) => {
15220            spg_storage::Value::Text(s.to_ascii_lowercase())
15221        }
15222        _ => v.clone(),
15223    }
15224}
15225
15226/// v7.9.29 — `true` iff `v` counts as a truthy SQL value for a
15227/// WHERE-style predicate. NULL → false (three-valued logic
15228/// collapses to "skip this row" for index inclusion). Numeric
15229/// non-zero, BIGINT non-zero, TINYINT non-zero, BOOLEAN true → true.
15230/// Everything else (strings, vectors, JSON, …) is not a valid
15231/// predicate result and surfaces as `false` so a malformed
15232/// predicate degrades to "row not in index" rather than panicking.
15233fn predicate_truthy(v: &spg_storage::Value) -> bool {
15234    use spg_storage::Value as V;
15235    match v {
15236        V::Bool(b) => *b,
15237        V::Int(n) => *n != 0,
15238        V::BigInt(n) => *n != 0,
15239        V::SmallInt(n) => *n != 0,
15240        _ => false,
15241    }
15242}
15243
15244/// v7.9.29 — at CREATE UNIQUE INDEX time, scan the table's
15245/// committed rows for pre-existing duplicates. If any pair of rows
15246/// matches the predicate AND has the same index key, refuse to
15247/// create the index so the user fixes the data before retrying.
15248fn check_existing_unique_violation(
15249    idx: &spg_storage::Index,
15250    schema: &spg_storage::TableSchema,
15251    rows: &[spg_storage::Row],
15252) -> Result<(), EngineError> {
15253    let predicate_expr = match idx.partial_predicate.as_deref() {
15254        Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
15255            EngineError::Unsupported(alloc::format!(
15256                "stored partial predicate {s:?} failed to re-parse: {e:?}"
15257            ))
15258        })?),
15259        None => None,
15260    };
15261    let ctx = eval::EvalContext::new(&schema.columns, None);
15262    let key_positions = unique_key_positions(idx);
15263    let mut seen: alloc::vec::Vec<alloc::vec::Vec<spg_storage::Value>> = alloc::vec::Vec::new();
15264    for row in rows {
15265        if let Some(expr) = &predicate_expr {
15266            let v = eval::eval_expr(expr, row, &ctx).map_err(|e| {
15267                EngineError::Unsupported(alloc::format!(
15268                    "evaluating UNIQUE INDEX predicate against existing row: {e:?}"
15269                ))
15270            })?;
15271            if !predicate_truthy(&v) {
15272                continue;
15273            }
15274        }
15275        let key: alloc::vec::Vec<spg_storage::Value> = key_positions
15276            .iter()
15277            .map(|&p| {
15278                let v = row
15279                    .values
15280                    .get(p)
15281                    .cloned()
15282                    .unwrap_or(spg_storage::Value::Null);
15283                collated_key_cell(&v, p, schema)
15284            })
15285            .collect();
15286        if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
15287            continue;
15288        }
15289        if seen.iter().any(|other| *other == key) {
15290            return Err(EngineError::Unsupported(alloc::format!(
15291                "CREATE UNIQUE INDEX {:?}: existing rows already violate the constraint",
15292                idx.name
15293            )));
15294        }
15295        seen.push(key);
15296    }
15297    Ok(())
15298}
15299
15300/// v7.9.29 — full key tuple for a UNIQUE INDEX (leading +
15301/// extra positions). For single-column indexes this is just
15302/// `[column_position]`.
15303fn unique_key_positions(idx: &spg_storage::Index) -> alloc::vec::Vec<usize> {
15304    let mut out = alloc::vec::Vec::with_capacity(1 + idx.extra_column_positions.len());
15305    out.push(idx.column_position);
15306    out.extend_from_slice(&idx.extra_column_positions);
15307    out
15308}
15309
15310/// v7.9.29 — at INSERT time, walk every `is_unique` index on the
15311/// target table. For each, eval the index's optional predicate
15312/// against (a) the candidate row and (b) every committed row plus
15313/// earlier batch rows; only rows where the predicate is truthy
15314/// participate. A duplicate key among predicate-matching rows is a
15315/// uniqueness violation. NULL keys lift the row out of the check
15316/// (matching PG's "UNIQUE allows multiple NULLs" semantics).
15317fn enforce_unique_index_inserts(
15318    catalog: &Catalog,
15319    table_name: &str,
15320    rows: &[alloc::vec::Vec<spg_storage::Value>],
15321) -> Result<(), EngineError> {
15322    let table = catalog.get(table_name).ok_or_else(|| {
15323        EngineError::Storage(StorageError::TableNotFound {
15324            name: table_name.into(),
15325        })
15326    })?;
15327    let schema = table.schema();
15328    let ctx = eval::EvalContext::new(&schema.columns, None);
15329    for idx in table.indices() {
15330        if !idx.is_unique {
15331            continue;
15332        }
15333        // Re-parse the predicate once per index per batch.
15334        let predicate_expr = match idx.partial_predicate.as_deref() {
15335            Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
15336                EngineError::Unsupported(alloc::format!(
15337                    "UNIQUE INDEX {:?} predicate {s:?} failed to re-parse: {e:?}",
15338                    idx.name
15339                ))
15340            })?),
15341            None => None,
15342        };
15343        let key_positions = unique_key_positions(idx);
15344        let key_of = |values: &[spg_storage::Value]| -> alloc::vec::Vec<spg_storage::Value> {
15345            // v7.17.0 Phase 3.P0-45 — fold per-column collation
15346            // before building the comparison key so a `*_ci`
15347            // column treats `'Foo'` and `'FOO'` as equal.
15348            key_positions
15349                .iter()
15350                .map(|&p| {
15351                    let v = values.get(p).cloned().unwrap_or(spg_storage::Value::Null);
15352                    collated_key_cell(&v, p, schema)
15353                })
15354                .collect()
15355        };
15356        // Helper: does `values` participate in this index? (predicate
15357        // truthy when present.) Wraps `values` into a transient Row
15358        // because eval_expr requires &Row.
15359        let participates = |values: &[spg_storage::Value]| -> Result<bool, EngineError> {
15360            let Some(expr) = &predicate_expr else {
15361                return Ok(true);
15362            };
15363            let tmp_row = spg_storage::Row {
15364                values: values.to_vec(),
15365            };
15366            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
15367                EngineError::Unsupported(alloc::format!(
15368                    "UNIQUE INDEX {:?} predicate eval: {e:?}",
15369                    idx.name
15370                ))
15371            })?;
15372            Ok(predicate_truthy(&v))
15373        };
15374        for (batch_idx, row_values) in rows.iter().enumerate() {
15375            if !participates(row_values)? {
15376                continue;
15377            }
15378            let key = key_of(row_values);
15379            if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
15380                continue;
15381            }
15382            // Committed-table collision.
15383            for prow in table.rows() {
15384                if !participates(&prow.values)? {
15385                    continue;
15386                }
15387                if key_of(&prow.values) == key {
15388                    return Err(EngineError::Unsupported(alloc::format!(
15389                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
15390                         row #{batch_idx} duplicates an existing key",
15391                        idx.name
15392                    )));
15393                }
15394            }
15395            // Within-batch collision: earlier rows in the same INSERT.
15396            for earlier in &rows[..batch_idx] {
15397                if !participates(earlier)? {
15398                    continue;
15399                }
15400                if key_of(earlier) == key {
15401                    return Err(EngineError::Unsupported(alloc::format!(
15402                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
15403                         row #{batch_idx} duplicates an earlier row in the same batch",
15404                        idx.name
15405                    )));
15406                }
15407            }
15408        }
15409    }
15410    Ok(())
15411}
15412
15413/// v7.13.0 — `UPDATE OF cols` filter helper (mailrs round-5 G7).
15414/// Returns `true` when at least one of `filter_cols` has a
15415/// different value in `new_row` vs `old_row`. Column lookup is
15416/// case-insensitive against `schema_cols`; unknown filter columns
15417/// are treated as "not changed" (the trigger therefore won't
15418/// fire on them — surfacing a parse-time error would be too
15419/// strict for catalog reloads where the schema may have drifted).
15420fn any_column_changed(
15421    filter_cols: &[String],
15422    schema_cols: &[ColumnSchema],
15423    old_row: &Row,
15424    new_row: &Row,
15425) -> bool {
15426    for col_name in filter_cols {
15427        let Some(pos) = schema_cols
15428            .iter()
15429            .position(|c| c.name.eq_ignore_ascii_case(col_name))
15430        else {
15431            continue;
15432        };
15433        let old_v = old_row.values.get(pos);
15434        let new_v = new_row.values.get(pos);
15435        if old_v != new_v {
15436            return true;
15437        }
15438    }
15439    false
15440}
15441
15442/// v7.13.0 — evaluate every CHECK predicate on the schema against
15443/// each candidate row. Mirrors PG semantics: a `false` result
15444/// rejects the mutation; a NULL result *passes* (CHECK rejects
15445/// only on definite-false, not on unknown). mailrs round-5 G3.
15446fn enforce_check_constraints(
15447    catalog: &Catalog,
15448    table_name: &str,
15449    rows: &[alloc::vec::Vec<spg_storage::Value>],
15450) -> Result<(), EngineError> {
15451    let table = catalog.get(table_name).ok_or_else(|| {
15452        EngineError::Storage(StorageError::TableNotFound {
15453            name: table_name.into(),
15454        })
15455    })?;
15456    let schema = table.schema();
15457    // v7.17.0 Phase 1.5 — domain-level CHECKs are enforced in
15458    // parallel with table-level CHECKs. Collect both lists up
15459    // front; if neither exists we early-out.
15460    let mut domain_checks_per_col: alloc::vec::Vec<(usize, alloc::vec::Vec<Expr>)> =
15461        alloc::vec::Vec::new();
15462    for (idx, col) in schema.columns.iter().enumerate() {
15463        let Some(dname) = &col.user_domain_type else {
15464            continue;
15465        };
15466        let Some(dom) = catalog.domain_types().get(dname) else {
15467            continue;
15468        };
15469        let mut parsed_for_col: alloc::vec::Vec<Expr> =
15470            alloc::vec::Vec::with_capacity(dom.checks.len());
15471        for src in &dom.checks {
15472            let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
15473                EngineError::Unsupported(alloc::format!(
15474                    "DOMAIN {dname:?} CHECK ({src:?}) on column {:?}: re-parse failed: {e:?}",
15475                    col.name
15476                ))
15477            })?;
15478            parsed_for_col.push(expr);
15479        }
15480        if !parsed_for_col.is_empty() {
15481            domain_checks_per_col.push((idx, parsed_for_col));
15482        }
15483    }
15484    if schema.checks.is_empty() && domain_checks_per_col.is_empty() {
15485        return Ok(());
15486    }
15487    let ctx = eval::EvalContext::new(&schema.columns, None);
15488    let mut parsed: alloc::vec::Vec<(usize, Expr)> = alloc::vec::Vec::new();
15489    for (i, src) in schema.checks.iter().enumerate() {
15490        let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
15491            EngineError::Unsupported(alloc::format!(
15492                "CHECK constraint #{i} on {table_name:?} ({src:?}) failed to re-parse: {e:?}"
15493            ))
15494        })?;
15495        parsed.push((i, expr));
15496    }
15497    for (batch_idx, row_values) in rows.iter().enumerate() {
15498        let tmp_row = spg_storage::Row {
15499            values: row_values.clone(),
15500        };
15501        for (i, expr) in &parsed {
15502            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
15503                EngineError::Unsupported(alloc::format!(
15504                    "CHECK constraint #{i} on {table_name:?} eval at row #{batch_idx}: {e:?}"
15505                ))
15506            })?;
15507            // PG: NULL passes (CHECK rejects on definite-false only).
15508            if matches!(v, spg_storage::Value::Bool(false)) {
15509                return Err(EngineError::Unsupported(alloc::format!(
15510                    "CHECK constraint violation on {table_name:?} (row #{batch_idx}): {:?}",
15511                    schema.checks[*i]
15512                )));
15513            }
15514        }
15515        // v7.17.0 Phase 1.5 — domain-level CHECKs. Each CHECK
15516        // expression references VALUE as a column-name; we
15517        // substitute the per-row cell into the eval context by
15518        // synthesising a single-column row of just that value
15519        // under a temporary `value` column schema.
15520        for (col_idx, checks) in &domain_checks_per_col {
15521            let cell = row_values
15522                .get(*col_idx)
15523                .cloned()
15524                .unwrap_or(spg_storage::Value::Null);
15525            let synth_cols = alloc::vec![spg_storage::ColumnSchema::new(
15526                "value",
15527                schema.columns[*col_idx].ty,
15528                schema.columns[*col_idx].nullable,
15529            )];
15530            let synth_ctx = eval::EvalContext::new(&synth_cols, None);
15531            let synth_row = spg_storage::Row {
15532                values: alloc::vec![cell],
15533            };
15534            for (ci, expr) in checks.iter().enumerate() {
15535                let v = eval::eval_expr(expr, &synth_row, &synth_ctx).map_err(|e| {
15536                    EngineError::Unsupported(alloc::format!(
15537                        "DOMAIN CHECK #{ci} on column {:?} eval at row #{batch_idx}: {e:?}",
15538                        schema.columns[*col_idx].name
15539                    ))
15540                })?;
15541                if matches!(v, spg_storage::Value::Bool(false)) {
15542                    return Err(EngineError::Unsupported(alloc::format!(
15543                        "DOMAIN CHECK violation on column {:?} (row #{batch_idx})",
15544                        schema.columns[*col_idx].name
15545                    )));
15546                }
15547            }
15548        }
15549    }
15550    Ok(())
15551}
15552
15553fn enforce_fk_inserts(
15554    catalog: &Catalog,
15555    child_table: &str,
15556    fks: &[spg_storage::ForeignKeyConstraint],
15557    rows: &[Vec<Value>],
15558) -> Result<(), EngineError> {
15559    for fk in fks {
15560        let parent_is_self = fk.parent_table == child_table;
15561        let parent = if parent_is_self {
15562            // Self-ref: read the current state of the same table.
15563            // The mut borrow on child has been dropped by the caller.
15564            catalog.get(child_table).ok_or_else(|| {
15565                EngineError::Storage(StorageError::TableNotFound {
15566                    name: child_table.into(),
15567                })
15568            })?
15569        } else {
15570            catalog.get(&fk.parent_table).ok_or_else(|| {
15571                EngineError::Storage(StorageError::TableNotFound {
15572                    name: fk.parent_table.clone(),
15573                })
15574            })?
15575        };
15576        for (batch_idx, row_values) in rows.iter().enumerate() {
15577            // Single-column FK fast path: try the parent's BTree
15578            // index for an O(log n) lookup. Composite FKs fall back
15579            // to a parent-row scan.
15580            if fk.local_columns.len() == 1 {
15581                let v = &row_values[fk.local_columns[0]];
15582                if matches!(v, Value::Null) {
15583                    continue;
15584                }
15585                let parent_col = fk.parent_columns[0];
15586                let key = spg_storage::IndexKey::from_value(v).ok_or_else(|| {
15587                    EngineError::Unsupported(alloc::format!(
15588                        "FOREIGN KEY column value of type {:?} is not index-eligible",
15589                        v.data_type()
15590                    ))
15591                })?;
15592                let present_committed = parent.indices().iter().any(|idx| {
15593                    matches!(idx.kind, spg_storage::IndexKind::BTree(_))
15594                        && idx.column_position == parent_col
15595                        && idx.partial_predicate.is_none()
15596                        && !idx.lookup_eq(&key).is_empty()
15597                });
15598                // v7.6.7 self-ref widening: also accept a match
15599                // against earlier rows in this same batch when the
15600                // FK points at the table being inserted into.
15601                let present_in_batch = parent_is_self
15602                    && rows[..batch_idx]
15603                        .iter()
15604                        .any(|earlier| earlier.get(parent_col) == Some(v));
15605                if !(present_committed || present_in_batch) {
15606                    return Err(EngineError::Unsupported(alloc::format!(
15607                        "FOREIGN KEY violation: no parent row in {:?} where {} = {:?}",
15608                        fk.parent_table,
15609                        parent
15610                            .schema()
15611                            .columns
15612                            .get(parent_col)
15613                            .map_or("?", |c| c.name.as_str()),
15614                        v,
15615                    )));
15616                }
15617            } else {
15618                // Composite FK: scan parent rows. v7.6.7 also
15619                // accepts a match against earlier rows in the same
15620                // batch (self-ref bulk-loading of hierarchies).
15621                if fk
15622                    .local_columns
15623                    .iter()
15624                    .all(|&i| matches!(row_values.get(i), Some(Value::Null)))
15625                {
15626                    continue;
15627                }
15628                let local: Vec<&Value> = fk.local_columns.iter().map(|&i| &row_values[i]).collect();
15629                let parent_match_committed = parent.rows().iter().any(|prow| {
15630                    fk.parent_columns
15631                        .iter()
15632                        .enumerate()
15633                        .all(|(i, &pi)| prow.values.get(pi) == Some(local[i]))
15634                });
15635                let parent_match_in_batch = parent_is_self
15636                    && rows[..batch_idx].iter().any(|earlier| {
15637                        fk.parent_columns
15638                            .iter()
15639                            .enumerate()
15640                            .all(|(i, &pi)| earlier.get(pi) == Some(local[i]))
15641                    });
15642                if !(parent_match_committed || parent_match_in_batch) {
15643                    return Err(EngineError::Unsupported(alloc::format!(
15644                        "FOREIGN KEY violation: no parent row in {:?} matching composite key",
15645                        fk.parent_table,
15646                    )));
15647                }
15648            }
15649        }
15650    }
15651    Ok(())
15652}
15653
15654/// v7.6.4 / v7.6.5 — one step of the FK action plan computed for a
15655/// DELETE on a parent. The plan is a list of these steps, stacked
15656/// across the FK graph by `plan_fk_parent_deletions`.
15657#[derive(Debug, Clone)]
15658struct FkChildStep {
15659    child_table: String,
15660    action: FkChildAction,
15661}
15662
15663#[derive(Debug, Clone)]
15664enum FkChildAction {
15665    /// CASCADE — remove these rows. Sorted, deduplicated positions.
15666    Delete { positions: Vec<usize> },
15667    /// SET NULL — for each (row, column) in the flat list, write
15668    /// NULL into that child cell. Multiple FKs on the same row may
15669    /// produce overlapping entries (deduped at plan time).
15670    SetNull {
15671        positions: Vec<usize>,
15672        columns: Vec<usize>,
15673    },
15674    /// SET DEFAULT — same shape as SetNull but writes the column's
15675    /// declared DEFAULT value (resolved at plan time). Columns
15676    /// without a DEFAULT raise an error during planning.
15677    SetDefault {
15678        positions: Vec<usize>,
15679        columns: Vec<usize>,
15680        defaults: Vec<Value>,
15681    },
15682}
15683
15684/// v7.6.3 → v7.6.5 — plan FK fallout for a DELETE on a parent table.
15685///
15686/// Walks every table in the catalog looking for FKs whose
15687/// `parent_table` is `parent_table_name`. For each such FK + each
15688/// to-be-deleted parent row:
15689///
15690///   - RESTRICT / NoAction → error, no plan returned
15691///   - CASCADE → child rows get scheduled for deletion; recursive
15692///   - SetNull → child FK column(s) scheduled to be NULL-ed.
15693///     Verified NULL-able at plan time.
15694///   - SetDefault → child FK column(s) scheduled to be reset to
15695///     their declared DEFAULT. Columns without a DEFAULT raise.
15696///
15697/// SET NULL / SET DEFAULT do NOT cascade further — the child row
15698/// stays; only one of its columns mutates.
15699fn plan_fk_parent_deletions(
15700    catalog: &Catalog,
15701    parent_table_name: &str,
15702    to_delete_positions: &[usize],
15703    to_delete_rows: &[Vec<Value>],
15704) -> Result<Vec<FkChildStep>, EngineError> {
15705    use alloc::collections::{BTreeMap, BTreeSet};
15706    if to_delete_rows.is_empty() {
15707        return Ok(Vec::new());
15708    }
15709    let mut delete_plan: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
15710    // setnull / setdefault keyed by child_table → (row_idx, col_idx) → optional default
15711    let mut setnull_plan: BTreeMap<String, BTreeSet<(usize, usize)>> = BTreeMap::new();
15712    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15713    let mut visited: BTreeSet<(String, usize)> = BTreeSet::new();
15714    for &p in to_delete_positions {
15715        visited.insert((parent_table_name.to_string(), p));
15716    }
15717    let mut work: Vec<(String, Vec<Value>)> = to_delete_rows
15718        .iter()
15719        .map(|r| (parent_table_name.to_string(), r.clone()))
15720        .collect();
15721    while let Some((cur_parent, parent_row)) = work.pop() {
15722        for child_name in catalog.table_names() {
15723            let child = catalog
15724                .get(&child_name)
15725                .expect("table_names → catalog.get round-trip is total");
15726            for fk in &child.schema().foreign_keys {
15727                if fk.parent_table != cur_parent {
15728                    continue;
15729                }
15730                let parent_key: Vec<&Value> = fk
15731                    .parent_columns
15732                    .iter()
15733                    .map(|&pi| &parent_row[pi])
15734                    .collect();
15735                if parent_key.iter().any(|v| matches!(v, Value::Null)) {
15736                    continue;
15737                }
15738                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
15739                    if child_name == cur_parent
15740                        && visited.contains(&(child_name.clone(), child_row_idx))
15741                    {
15742                        continue;
15743                    }
15744                    let matches_key = fk
15745                        .local_columns
15746                        .iter()
15747                        .enumerate()
15748                        .all(|(i, &li)| child_row.values.get(li) == Some(parent_key[i]));
15749                    if !matches_key {
15750                        continue;
15751                    }
15752                    match fk.on_delete {
15753                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
15754                            return Err(EngineError::Unsupported(alloc::format!(
15755                                "FOREIGN KEY violation: DELETE on {cur_parent:?} is \
15756                                 restricted by FK from {child_name:?}.{:?}",
15757                                fk.local_columns,
15758                            )));
15759                        }
15760                        spg_storage::FkAction::Cascade => {
15761                            if visited.insert((child_name.clone(), child_row_idx)) {
15762                                delete_plan
15763                                    .entry(child_name.clone())
15764                                    .or_default()
15765                                    .insert(child_row_idx);
15766                                work.push((child_name.clone(), child_row.values.clone()));
15767                            }
15768                        }
15769                        spg_storage::FkAction::SetNull => {
15770                            // Verify every local FK column is NULL-able.
15771                            for &li in &fk.local_columns {
15772                                let col = child.schema().columns.get(li).ok_or_else(|| {
15773                                    EngineError::Unsupported(alloc::format!(
15774                                        "FK local column {li} missing in {child_name:?}"
15775                                    ))
15776                                })?;
15777                                if !col.nullable {
15778                                    return Err(EngineError::Unsupported(alloc::format!(
15779                                        "FOREIGN KEY ON DELETE SET NULL: column \
15780                                         {child_name:?}.{:?} is NOT NULL — cannot SET NULL",
15781                                        col.name,
15782                                    )));
15783                                }
15784                            }
15785                            let entry = setnull_plan.entry(child_name.clone()).or_default();
15786                            for &li in &fk.local_columns {
15787                                entry.insert((child_row_idx, li));
15788                            }
15789                        }
15790                        spg_storage::FkAction::SetDefault => {
15791                            // Resolve the DEFAULT for every local FK col.
15792                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
15793                            for &li in &fk.local_columns {
15794                                let col = child.schema().columns.get(li).ok_or_else(|| {
15795                                    EngineError::Unsupported(alloc::format!(
15796                                        "FK local column {li} missing in {child_name:?}"
15797                                    ))
15798                                })?;
15799                                let default = col.default.clone().ok_or_else(|| {
15800                                    EngineError::Unsupported(alloc::format!(
15801                                        "FOREIGN KEY ON DELETE SET DEFAULT: column \
15802                                         {child_name:?}.{:?} has no DEFAULT declared",
15803                                        col.name,
15804                                    ))
15805                                })?;
15806                                entry.insert((child_row_idx, li), default);
15807                            }
15808                        }
15809                    }
15810                }
15811            }
15812        }
15813    }
15814    // Flatten the three plans into the ordered `FkChildStep` list.
15815    // Deletes are applied last per child (after any null/default
15816    // re-writes on the same child) so a child row that's both
15817    // re-written and then cascade-deleted only ends up deleted —
15818    // but in v7.6.5 SetNull/Cascade never overlap on the same row
15819    // (a single FK chooses exactly one action), so the order is
15820    // mostly a precaution.
15821    let mut steps: Vec<FkChildStep> = Vec::new();
15822    for (child_table, entries) in setnull_plan {
15823        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
15824        steps.push(FkChildStep {
15825            child_table,
15826            action: FkChildAction::SetNull { positions, columns },
15827        });
15828    }
15829    for (child_table, entries) in setdefault_plan {
15830        let mut positions = Vec::with_capacity(entries.len());
15831        let mut columns = Vec::with_capacity(entries.len());
15832        let mut defaults = Vec::with_capacity(entries.len());
15833        for ((p, c), v) in entries {
15834            positions.push(p);
15835            columns.push(c);
15836            defaults.push(v);
15837        }
15838        steps.push(FkChildStep {
15839            child_table,
15840            action: FkChildAction::SetDefault {
15841                positions,
15842                columns,
15843                defaults,
15844            },
15845        });
15846    }
15847    for (child_table, positions) in delete_plan {
15848        steps.push(FkChildStep {
15849            child_table,
15850            action: FkChildAction::Delete {
15851                positions: positions.into_iter().collect(),
15852            },
15853        });
15854    }
15855    Ok(steps)
15856}
15857
15858/// v7.6.6 — plan FK fallout for an UPDATE that mutates parent-side
15859/// PK/UNIQUE columns. Walks every other table whose FK references
15860/// `parent_table_name`; for each FK whose parent_columns overlap a
15861/// mutated column, decides the action by `fk.on_update`.
15862///
15863///   - RESTRICT / NoAction → error if any child references the OLD
15864///     value
15865///   - CASCADE → child FK columns get rewritten to the NEW parent
15866///     value (a SetNull-style update step with the new value)
15867///   - SetNull → child FK columns set to NULL
15868///   - SetDefault → child FK columns set to declared default
15869///
15870/// `plan_with_old` is `(row_position, old_values, new_values)` so
15871/// the planner can detect "did this row's parent key actually
15872/// change?" — only rows where at least one referenced parent
15873/// column moved trigger inbound work.
15874fn plan_fk_parent_updates(
15875    catalog: &Catalog,
15876    parent_table_name: &str,
15877    plan_with_old: &[(usize, Vec<Value>, Vec<Value>)],
15878) -> Result<Vec<FkChildStep>, EngineError> {
15879    use alloc::collections::BTreeMap;
15880    if plan_with_old.is_empty() {
15881        return Ok(Vec::new());
15882    }
15883    // For each child table we may touch, build per-child step
15884    // lists. UPDATE never deletes children — `delete_plan` stays
15885    // empty here but is kept structurally aligned with
15886    // `plan_fk_parent_deletions` for future use.
15887    let delete_plan: BTreeMap<String, alloc::collections::BTreeSet<usize>> = BTreeMap::new();
15888    let mut setnull_plan: BTreeMap<String, alloc::collections::BTreeSet<(usize, usize)>> =
15889        BTreeMap::new();
15890    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15891    // Cascade-update plan: child_table → row_idx → col_idx → new_value
15892    let mut cascade_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15893
15894    for child_name in catalog.table_names() {
15895        let child = catalog
15896            .get(&child_name)
15897            .expect("table_names → catalog.get total");
15898        for fk in &child.schema().foreign_keys {
15899            if fk.parent_table != parent_table_name {
15900                continue;
15901            }
15902            for (_pos, old_row, new_row) in plan_with_old {
15903                // Did any parent FK column change?
15904                let key_changed = fk
15905                    .parent_columns
15906                    .iter()
15907                    .any(|&pi| old_row.get(pi) != new_row.get(pi));
15908                if !key_changed {
15909                    continue;
15910                }
15911                // The OLD parent key — used to find referring children.
15912                let old_key: Vec<&Value> =
15913                    fk.parent_columns.iter().map(|&pi| &old_row[pi]).collect();
15914                if old_key.iter().any(|v| matches!(v, Value::Null)) {
15915                    // NULL parent has no children — skip.
15916                    continue;
15917                }
15918                let new_key: Vec<&Value> =
15919                    fk.parent_columns.iter().map(|&pi| &new_row[pi]).collect();
15920                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
15921                    // Self-ref same-row updates: a row updating its
15922                    // own PK doesn't restrict itself.
15923                    if child_name == parent_table_name
15924                        && plan_with_old.iter().any(|(p, _, _)| *p == child_row_idx)
15925                    {
15926                        continue;
15927                    }
15928                    let matches_key = fk
15929                        .local_columns
15930                        .iter()
15931                        .enumerate()
15932                        .all(|(i, &li)| child_row.values.get(li) == Some(old_key[i]));
15933                    if !matches_key {
15934                        continue;
15935                    }
15936                    match fk.on_update {
15937                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
15938                            return Err(EngineError::Unsupported(alloc::format!(
15939                                "FOREIGN KEY violation: UPDATE on {parent_table_name:?} PK is \
15940                                 restricted by FK from {child_name:?}.{:?}",
15941                                fk.local_columns,
15942                            )));
15943                        }
15944                        spg_storage::FkAction::Cascade => {
15945                            // Rewrite child FK columns to new key.
15946                            let entry = cascade_plan.entry(child_name.clone()).or_default();
15947                            for (i, &li) in fk.local_columns.iter().enumerate() {
15948                                entry.insert((child_row_idx, li), new_key[i].clone());
15949                            }
15950                        }
15951                        spg_storage::FkAction::SetNull => {
15952                            for &li in &fk.local_columns {
15953                                let col = child.schema().columns.get(li).ok_or_else(|| {
15954                                    EngineError::Unsupported(alloc::format!(
15955                                        "FK local column {li} missing in {child_name:?}"
15956                                    ))
15957                                })?;
15958                                if !col.nullable {
15959                                    return Err(EngineError::Unsupported(alloc::format!(
15960                                        "FOREIGN KEY ON UPDATE SET NULL: column \
15961                                         {child_name:?}.{:?} is NOT NULL",
15962                                        col.name,
15963                                    )));
15964                                }
15965                            }
15966                            let entry = setnull_plan.entry(child_name.clone()).or_default();
15967                            for &li in &fk.local_columns {
15968                                entry.insert((child_row_idx, li));
15969                            }
15970                        }
15971                        spg_storage::FkAction::SetDefault => {
15972                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
15973                            for &li in &fk.local_columns {
15974                                let col = child.schema().columns.get(li).ok_or_else(|| {
15975                                    EngineError::Unsupported(alloc::format!(
15976                                        "FK local column {li} missing in {child_name:?}"
15977                                    ))
15978                                })?;
15979                                let default = col.default.clone().ok_or_else(|| {
15980                                    EngineError::Unsupported(alloc::format!(
15981                                        "FOREIGN KEY ON UPDATE SET DEFAULT: column \
15982                                         {child_name:?}.{:?} has no DEFAULT",
15983                                        col.name,
15984                                    ))
15985                                })?;
15986                                entry.insert((child_row_idx, li), default);
15987                            }
15988                        }
15989                    }
15990                }
15991            }
15992        }
15993    }
15994    // Flatten into FkChildStep list. UPDATE doesn't produce
15995    // DeleteSteps (CASCADE on UPDATE just rewrites FK values).
15996    let mut steps: Vec<FkChildStep> = Vec::new();
15997    for (child_table, entries) in cascade_plan {
15998        let mut positions = Vec::with_capacity(entries.len());
15999        let mut columns = Vec::with_capacity(entries.len());
16000        let mut defaults = Vec::with_capacity(entries.len());
16001        for ((p, c), v) in entries {
16002            positions.push(p);
16003            columns.push(c);
16004            defaults.push(v);
16005        }
16006        // We reuse `FkChildAction::SetDefault` for cascade-update:
16007        // both shapes are "write a known value into specific cells"
16008        // — `apply_per_cell_writes` doesn't care whether the value
16009        // came from a DEFAULT declaration or a new parent key.
16010        steps.push(FkChildStep {
16011            child_table,
16012            action: FkChildAction::SetDefault {
16013                positions,
16014                columns,
16015                defaults,
16016            },
16017        });
16018    }
16019    for (child_table, entries) in setnull_plan {
16020        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
16021        steps.push(FkChildStep {
16022            child_table,
16023            action: FkChildAction::SetNull { positions, columns },
16024        });
16025    }
16026    for (child_table, entries) in setdefault_plan {
16027        let mut positions = Vec::with_capacity(entries.len());
16028        let mut columns = Vec::with_capacity(entries.len());
16029        let mut defaults = Vec::with_capacity(entries.len());
16030        for ((p, c), v) in entries {
16031            positions.push(p);
16032            columns.push(c);
16033            defaults.push(v);
16034        }
16035        steps.push(FkChildStep {
16036            child_table,
16037            action: FkChildAction::SetDefault {
16038                positions,
16039                columns,
16040                defaults,
16041            },
16042        });
16043    }
16044    let _ = delete_plan; // UPDATE never deletes children.
16045    Ok(steps)
16046}
16047
16048/// v7.6.5 — apply one FK child step to the catalog. Encapsulates
16049/// the three action variants so the DELETE executor stays a
16050/// simple loop over the planned steps.
16051fn apply_fk_child_step(catalog: &mut Catalog, step: &FkChildStep) -> Result<(), EngineError> {
16052    let child = catalog.get_mut(&step.child_table).ok_or_else(|| {
16053        EngineError::Storage(StorageError::TableNotFound {
16054            name: step.child_table.clone(),
16055        })
16056    })?;
16057    match &step.action {
16058        FkChildAction::Delete { positions } => {
16059            let _ = child.delete_rows(positions);
16060        }
16061        FkChildAction::SetNull { positions, columns } => {
16062            apply_per_cell_writes(child, positions, columns, |_| Value::Null)?;
16063        }
16064        FkChildAction::SetDefault {
16065            positions,
16066            columns,
16067            defaults,
16068        } => {
16069            apply_per_cell_writes(child, positions, columns, |i| defaults[i].clone())?;
16070        }
16071    }
16072    Ok(())
16073}
16074
16075/// v7.6.5 — write new values into selected child cells via
16076/// `Table::update_row` (the catalog's existing UPDATE entry).
16077/// Groups writes by row position so multi-column updates on the
16078/// same row only call `update_row` once. `value_for(i)` produces
16079/// the new value for the i-th (position, column) entry.
16080fn apply_per_cell_writes(
16081    child: &mut spg_storage::Table,
16082    positions: &[usize],
16083    columns: &[usize],
16084    mut value_for: impl FnMut(usize) -> Value,
16085) -> Result<(), EngineError> {
16086    use alloc::collections::BTreeMap;
16087    let mut by_row: BTreeMap<usize, Vec<(usize, Value)>> = BTreeMap::new();
16088    for i in 0..positions.len() {
16089        by_row
16090            .entry(positions[i])
16091            .or_default()
16092            .push((columns[i], value_for(i)));
16093    }
16094    for (pos, mutations) in by_row {
16095        let mut new_values = child.rows()[pos].values.clone();
16096        for (col, v) in mutations {
16097            if let Some(slot) = new_values.get_mut(col) {
16098                *slot = v;
16099            }
16100        }
16101        child
16102            .update_row(pos, new_values)
16103            .map_err(EngineError::Storage)?;
16104    }
16105    Ok(())
16106}
16107
16108fn fk_action_sql_to_storage(a: spg_sql::ast::FkAction) -> spg_storage::FkAction {
16109    match a {
16110        spg_sql::ast::FkAction::Restrict => spg_storage::FkAction::Restrict,
16111        spg_sql::ast::FkAction::Cascade => spg_storage::FkAction::Cascade,
16112        spg_sql::ast::FkAction::SetNull => spg_storage::FkAction::SetNull,
16113        spg_sql::ast::FkAction::SetDefault => spg_storage::FkAction::SetDefault,
16114        spg_sql::ast::FkAction::NoAction => spg_storage::FkAction::NoAction,
16115    }
16116}
16117
16118/// v7.9.21 — resolve a column's DEFAULT for INSERT-time
16119/// default-fill. Free fn (rather than `&self`) so callers
16120/// with an active `&mut Table` borrow can still use it.
16121/// Literal defaults take the cached path (`col.default`);
16122/// runtime defaults hit `clock_fn` at each call. mailrs G4.
16123fn resolve_column_default_free(
16124    col: &ColumnSchema,
16125    clock_fn: Option<ClockFn>,
16126) -> Result<Value, EngineError> {
16127    if let Some(rt) = &col.runtime_default {
16128        return eval_runtime_default_free(rt, col.ty, clock_fn);
16129    }
16130    Ok(col.default.clone().unwrap_or(Value::Null))
16131}
16132
16133fn eval_runtime_default_free(
16134    rt: &str,
16135    ty: DataType,
16136    clock_fn: Option<ClockFn>,
16137) -> Result<Value, EngineError> {
16138    let s = rt.trim().to_ascii_lowercase();
16139    // v7.17.0 Phase 2.1 — also strip `(N)` precision suffix
16140    // so MySQL `CURRENT_TIMESTAMP(6)` resolves the same as
16141    // bare `CURRENT_TIMESTAMP`. SPG stores TIMESTAMP at fixed
16142    // microsecond resolution; the precision modifier is
16143    // parser-only.
16144    let with_no_parens = s.trim_end_matches("()");
16145    let canonical: &str = if let Some(open_idx) = with_no_parens.find('(') {
16146        if with_no_parens.ends_with(')') {
16147            &with_no_parens[..open_idx]
16148        } else {
16149            with_no_parens
16150        }
16151    } else {
16152        with_no_parens
16153    };
16154    let now_us = match clock_fn {
16155        Some(f) => f(),
16156        None => 0,
16157    };
16158    let v = match canonical {
16159        "now" | "current_timestamp" | "localtimestamp" => Value::Timestamp(now_us),
16160        "current_date" => Value::Date((now_us / 86_400_000_000) as i32),
16161        "current_time" | "localtime" => Value::Timestamp(now_us),
16162        // v7.17.0 — UUID generators in DEFAULT clauses. Required
16163        // for the canonical Django / Rails / Hibernate `id UUID
16164        // PRIMARY KEY DEFAULT gen_random_uuid()` pattern. Each
16165        // INSERT evaluates the function fresh; the per-row UUID
16166        // is the storage value, not a cached literal.
16167        "gen_random_uuid" | "uuid_generate_v4" => Value::Uuid(eval::gen_random_uuid_bytes()),
16168        other => {
16169            return Err(EngineError::Unsupported(alloc::format!(
16170                "runtime DEFAULT expression {other:?} not supported \
16171                 (v7.17.0 whitelist: now() / current_timestamp / \
16172                 current_date / current_time / localtimestamp / \
16173                 localtime / gen_random_uuid() / \
16174                 uuid_generate_v4())"
16175            )));
16176        }
16177    };
16178    coerce_value(v, ty, "DEFAULT", 0)
16179}
16180
16181/// v7.9.21 — true when a DEFAULT expression needs INSERT-time
16182/// evaluation rather than being cacheable as a literal Value.
16183/// FunctionCall is the immediate case (`now()`,
16184/// `current_timestamp`). Literal expressions and simple sign-
16185/// flipped numerics still take the static-cache path.
16186fn is_runtime_default_expr(expr: &Expr) -> bool {
16187    match expr {
16188        Expr::FunctionCall { .. } => true,
16189        Expr::Unary { expr, .. } => is_runtime_default_expr(expr),
16190        _ => false,
16191    }
16192}
16193
16194/// v7.17.0 Phase 1.4 — INSERT/UPDATE-time enum label check. When
16195/// `col_idx` has a registered label list, the cell value must be
16196/// NULL or one of the labels (case-sensitive per PG).
16197/// v7.17.0 Phase 3.P0-37 — validate + canonicalise a MySQL inline
16198/// SET cell. For non-SET columns this is a no-op pass-through.
16199///
16200/// Semantics:
16201///   * NULL preserved.
16202///   * Empty string → `''` (zero flags).
16203///   * Otherwise split on ',', trim each token, validate every
16204///     token against the column's variant list (error on miss),
16205///     de-dup, then re-emit in DEFINITION order joined by ','.
16206fn canonicalize_set_value(
16207    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
16208    col_idx: usize,
16209    col_name: &str,
16210    value: Value,
16211) -> Result<Value, EngineError> {
16212    let Some(variants) = lookup.get(&col_idx) else {
16213        return Ok(value);
16214    };
16215    match value {
16216        Value::Null => Ok(Value::Null),
16217        Value::Text(s) => {
16218            if s.is_empty() {
16219                return Ok(Value::Text(alloc::string::String::new()));
16220            }
16221            // Collect a presence-set of variant indices to keep
16222            // definition order + handle de-dup in one pass.
16223            let mut present = alloc::vec![false; variants.len()];
16224            for raw in s.split(',') {
16225                let tok = raw.trim();
16226                if tok.is_empty() {
16227                    continue;
16228                }
16229                let idx = variants.iter().position(|v| v == tok).ok_or_else(|| {
16230                    EngineError::Unsupported(alloc::format!(
16231                        "column {col_name:?}: invalid SET token {tok:?}; \
16232                         allowed: {variants:?}"
16233                    ))
16234                })?;
16235                present[idx] = true;
16236            }
16237            // Re-emit in definition order.
16238            let mut out = alloc::string::String::new();
16239            let mut first = true;
16240            for (i, keep) in present.iter().enumerate() {
16241                if !keep {
16242                    continue;
16243                }
16244                if !first {
16245                    out.push(',');
16246                }
16247                first = false;
16248                out.push_str(&variants[i]);
16249            }
16250            Ok(Value::Text(out))
16251        }
16252        other => Err(EngineError::Unsupported(alloc::format!(
16253            "column {col_name:?}: SET-typed column expects TEXT, got {:?}",
16254            other.data_type()
16255        ))),
16256    }
16257}
16258
16259fn enforce_enum_label(
16260    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
16261    col_idx: usize,
16262    col_name: &str,
16263    value: &Value,
16264) -> Result<(), EngineError> {
16265    if let Some(labels) = lookup.get(&col_idx) {
16266        match value {
16267            Value::Null => Ok(()),
16268            Value::Text(s) => {
16269                if labels.iter().any(|l| l == s) {
16270                    Ok(())
16271                } else {
16272                    Err(EngineError::Unsupported(alloc::format!(
16273                        "column {col_name:?}: invalid enum label {s:?}; allowed: {labels:?}"
16274                    )))
16275                }
16276            }
16277            other => Err(EngineError::Unsupported(alloc::format!(
16278                "column {col_name:?}: enum-typed column expects TEXT, got {:?}",
16279                other.data_type()
16280            ))),
16281        }
16282    } else {
16283        Ok(())
16284    }
16285}
16286
16287fn column_def_to_schema(c: ColumnDef) -> Result<ColumnSchema, EngineError> {
16288    let ty = column_type_to_data_type(c.ty);
16289    let mut schema = ColumnSchema::new(c.name.clone(), ty, c.nullable);
16290    // user_type_ref is the raw ident the parser couldn't resolve
16291    // to a built-in; classification into enum vs domain happens
16292    // at exec_create_table where we have catalog access. We
16293    // park it temporarily as user_enum_type and the engine
16294    // promotes domain bindings to user_domain_type before the
16295    // table is stored.
16296    if let Some(name) = c.user_type_ref {
16297        schema.user_enum_type = Some(name);
16298    }
16299    // v7.17.0 Phase 2.1 — render the ON UPDATE expression to
16300    // canonical text (the engine re-parses at UPDATE time).
16301    if let Some(expr) = c.on_update_runtime {
16302        schema.on_update_runtime = Some(alloc::format!("{expr}"));
16303    }
16304    // v7.17.0 Phase 2.5 — bridge the AST `Collation` enum to the
16305    // storage one. Same variants, different crates (spg-storage
16306    // owns no dep on spg-sql).
16307    schema.collation = match c.collation {
16308        spg_sql::ast::Collation::Binary => spg_storage::Collation::Binary,
16309        spg_sql::ast::Collation::CaseInsensitive => spg_storage::Collation::CaseInsensitive,
16310    };
16311    // v7.17.0 Phase 4.4 — MySQL `UNSIGNED` flag propagates to
16312    // storage so engine INSERT / UPDATE can range-check.
16313    schema.is_unsigned = c.is_unsigned;
16314    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant list.
16315    // INSERT validation lives in coerce_value (Text → Text path
16316    // with the column's variant list as the accept-set).
16317    schema.inline_enum_variants = c.inline_enum_variants;
16318    // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
16319    // INSERT canonicalisation (de-dup + sort by definition order)
16320    // lives in the exec_insert path next to the ENUM check.
16321    schema.inline_set_variants = c.inline_set_variants;
16322    if let Some(default_expr) = c.default {
16323        // v7.9.21 — distinguish literal defaults (evaluated once
16324        // at CREATE TABLE) from expression defaults (deferred to
16325        // INSERT). Function calls (`now()`, `current_timestamp`
16326        // — see v7.9.20 keyword promotion) take the runtime path.
16327        // Literals continue to cache. mailrs G4.
16328        if is_runtime_default_expr(&default_expr) {
16329            let display = alloc::format!("{default_expr}");
16330            schema = schema.with_runtime_default(display);
16331        } else {
16332            let raw = literal_expr_to_value(default_expr)?;
16333            let coerced = coerce_value(raw, ty, &c.name, 0)?;
16334            schema = schema.with_default(coerced);
16335        }
16336    }
16337    if c.auto_increment {
16338        // AUTO_INCREMENT only makes sense on integer-shaped columns.
16339        if !matches!(ty, DataType::SmallInt | DataType::Int | DataType::BigInt) {
16340            return Err(EngineError::Unsupported(alloc::format!(
16341                "AUTO_INCREMENT requires an integer column type, got {ty:?}"
16342            )));
16343        }
16344        schema = schema.with_auto_increment();
16345    }
16346    Ok(schema)
16347}
16348
16349/// v7.10.4 — decode a BYTEA literal. Accepts:
16350///   * `\xDEADBEEF` (case-insensitive hex; whitespace stripped)
16351///   * `Hello\000world` (backslash escape form; `\\` for literal backslash)
16352///   * Anything else → raw UTF-8 bytes of the input (PG accepts this too).
16353fn decode_bytea_literal(s: &str) -> Result<alloc::vec::Vec<u8>, &'static str> {
16354    let s = s.trim();
16355    if let Some(hex) = s.strip_prefix("\\x").or_else(|| s.strip_prefix("\\X")) {
16356        // Hex form. Each pair of hex digits → one byte.
16357        let cleaned: alloc::string::String = hex.chars().filter(|c| !c.is_whitespace()).collect();
16358        if cleaned.len() % 2 != 0 {
16359            return Err("odd-length hex literal");
16360        }
16361        let mut out = alloc::vec::Vec::with_capacity(cleaned.len() / 2);
16362        let cleaned_bytes = cleaned.as_bytes();
16363        for i in (0..cleaned_bytes.len()).step_by(2) {
16364            let hi = hex_nibble(cleaned_bytes[i])?;
16365            let lo = hex_nibble(cleaned_bytes[i + 1])?;
16366            out.push((hi << 4) | lo);
16367        }
16368        return Ok(out);
16369    }
16370    // Escape form or raw. Walk char-by-char; `\\` and `\NNN` octal
16371    // sequences decode; anything else is a literal byte.
16372    let bytes = s.as_bytes();
16373    let mut out = alloc::vec::Vec::with_capacity(bytes.len());
16374    let mut i = 0;
16375    while i < bytes.len() {
16376        let b = bytes[i];
16377        if b == b'\\' && i + 1 < bytes.len() {
16378            let n = bytes[i + 1];
16379            if n == b'\\' {
16380                out.push(b'\\');
16381                i += 2;
16382                continue;
16383            }
16384            if n.is_ascii_digit()
16385                && i + 3 < bytes.len()
16386                && bytes[i + 2].is_ascii_digit()
16387                && bytes[i + 3].is_ascii_digit()
16388            {
16389                let oct = |x: u8| (x - b'0') as u32;
16390                let v = oct(n) * 64 + oct(bytes[i + 2]) * 8 + oct(bytes[i + 3]);
16391                if v <= 0xFF {
16392                    out.push(v as u8);
16393                    i += 4;
16394                    continue;
16395                }
16396            }
16397        }
16398        out.push(b);
16399        i += 1;
16400    }
16401    Ok(out)
16402}
16403
16404fn hex_nibble(b: u8) -> Result<u8, &'static str> {
16405    match b {
16406        b'0'..=b'9' => Ok(b - b'0'),
16407        b'a'..=b'f' => Ok(b - b'a' + 10),
16408        b'A'..=b'F' => Ok(b - b'A' + 10),
16409        _ => Err("invalid hex digit"),
16410    }
16411}
16412
16413/// v7.10.11 — decode a PG TEXT[] external array form
16414/// (`{a,b,NULL}` with optional double-quoted elements). The
16415/// engine takes a leading/trailing `{`/`}` and splits at commas.
16416/// Quoted elements (`"hello, world"`) preserve embedded commas;
16417/// `\\` and `\"` decode to literal backslash / quote. Plain
16418/// unquoted `NULL` (case-insensitive) maps to `None`.
16419/// v7.11.13 — pick the array type for `ARRAY[lit, …]` from the
16420/// element values. Single-element-type rules:
16421///   - all NULL / all Text → TextArray
16422///   - all Int (or Int+NULL) → IntArray
16423///   - any BigInt without Text → BigIntArray (widening)
16424///   - any Text → TextArray (fallback; non-string elements
16425///     render as text)
16426fn array_literal_widen(items: alloc::vec::Vec<Value>) -> Value {
16427    let mut has_text = false;
16428    let mut has_bigint = false;
16429    let mut has_int = false;
16430    for v in &items {
16431        match v {
16432            Value::Null => {}
16433            Value::Text(_) | Value::Json(_) => has_text = true,
16434            Value::BigInt(_) => has_bigint = true,
16435            Value::Int(_) | Value::SmallInt(_) => has_int = true,
16436            _ => has_text = true,
16437        }
16438    }
16439    if has_text || (!has_bigint && !has_int) {
16440        let out: alloc::vec::Vec<Option<alloc::string::String>> = items
16441            .into_iter()
16442            .map(|v| match v {
16443                Value::Null => None,
16444                Value::Text(s) | Value::Json(s) => Some(s),
16445                other => Some(alloc::format!("{other:?}")),
16446            })
16447            .collect();
16448        return Value::TextArray(out);
16449    }
16450    if has_bigint {
16451        let out: alloc::vec::Vec<Option<i64>> = items
16452            .into_iter()
16453            .map(|v| match v {
16454                Value::Null => None,
16455                Value::Int(n) => Some(i64::from(n)),
16456                Value::SmallInt(n) => Some(i64::from(n)),
16457                Value::BigInt(n) => Some(n),
16458                _ => unreachable!("widen: unexpected non-integer in BigInt path"),
16459            })
16460            .collect();
16461        return Value::BigIntArray(out);
16462    }
16463    let out: alloc::vec::Vec<Option<i32>> = items
16464        .into_iter()
16465        .map(|v| match v {
16466            Value::Null => None,
16467            Value::Int(n) => Some(n),
16468            Value::SmallInt(n) => Some(i32::from(n)),
16469            _ => unreachable!("widen: unexpected non-i32-compatible in Int path"),
16470        })
16471        .collect();
16472    Value::IntArray(out)
16473}
16474
16475fn decode_text_array_literal(
16476    s: &str,
16477) -> Result<alloc::vec::Vec<Option<alloc::string::String>>, &'static str> {
16478    let trimmed = s.trim();
16479    let inner = trimmed
16480        .strip_prefix('{')
16481        .and_then(|x| x.strip_suffix('}'))
16482        .ok_or("TEXT[] literal must be enclosed in '{...}'")?;
16483    let mut out: alloc::vec::Vec<Option<alloc::string::String>> = alloc::vec::Vec::new();
16484    if inner.trim().is_empty() {
16485        return Ok(out);
16486    }
16487    let bytes = inner.as_bytes();
16488    let mut i = 0;
16489    while i <= bytes.len() {
16490        // Skip leading whitespace.
16491        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
16492            i += 1;
16493        }
16494        // Quoted element.
16495        if i < bytes.len() && bytes[i] == b'"' {
16496            i += 1; // open quote
16497            let mut buf = alloc::string::String::new();
16498            while i < bytes.len() && bytes[i] != b'"' {
16499                if bytes[i] == b'\\' && i + 1 < bytes.len() {
16500                    buf.push(bytes[i + 1] as char);
16501                    i += 2;
16502                } else {
16503                    buf.push(bytes[i] as char);
16504                    i += 1;
16505                }
16506            }
16507            if i >= bytes.len() {
16508                return Err("unterminated quoted element");
16509            }
16510            i += 1; // close quote
16511            out.push(Some(buf));
16512        } else {
16513            // Unquoted element — read until next comma or end.
16514            let start = i;
16515            while i < bytes.len() && bytes[i] != b',' {
16516                i += 1;
16517            }
16518            let raw = inner[start..i].trim();
16519            if raw.eq_ignore_ascii_case("NULL") {
16520                out.push(None);
16521            } else {
16522                out.push(Some(alloc::string::ToString::to_string(raw)));
16523            }
16524        }
16525        // Skip whitespace, expect comma or end.
16526        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
16527            i += 1;
16528        }
16529        if i >= bytes.len() {
16530            break;
16531        }
16532        if bytes[i] != b',' {
16533            return Err("expected ',' between TEXT[] elements");
16534        }
16535        i += 1;
16536    }
16537    Ok(out)
16538}
16539
16540/// v7.10.11 — encode a TEXT[] back into the PG external array
16541/// form. NULL elements become the literal `NULL`; elements
16542/// containing commas, quotes, backslashes, or braces are
16543/// double-quoted with `\\` / `\"` escapes.
16544fn encode_text_array(items: &[Option<alloc::string::String>]) -> alloc::string::String {
16545    let mut out = alloc::string::String::with_capacity(2 + items.len() * 8);
16546    out.push('{');
16547    for (i, item) in items.iter().enumerate() {
16548        if i > 0 {
16549            out.push(',');
16550        }
16551        match item {
16552            None => out.push_str("NULL"),
16553            Some(s) => {
16554                let needs_quote = s.is_empty()
16555                    || s.eq_ignore_ascii_case("NULL")
16556                    || s.chars()
16557                        .any(|c| matches!(c, ',' | '{' | '}' | '"' | '\\' | ' ' | '\t'));
16558                if needs_quote {
16559                    out.push('"');
16560                    for c in s.chars() {
16561                        if c == '"' || c == '\\' {
16562                            out.push('\\');
16563                        }
16564                        out.push(c);
16565                    }
16566                    out.push('"');
16567                } else {
16568                    out.push_str(s);
16569                }
16570            }
16571        }
16572    }
16573    out.push('}');
16574    out
16575}
16576
16577/// v7.10.4 — encode BYTEA bytes in PG hex output format
16578/// (`\x` prefix, lowercase hex pairs). Used by Text-side
16579/// round-trip + the wire layer's text-mode encoder.
16580fn encode_bytea_hex(b: &[u8]) -> alloc::string::String {
16581    let mut out = alloc::string::String::with_capacity(2 + 2 * b.len());
16582    out.push_str("\\x");
16583    for byte in b {
16584        let hi = byte >> 4;
16585        let lo = byte & 0x0F;
16586        out.push(hex_digit(hi));
16587        out.push(hex_digit(lo));
16588    }
16589    out
16590}
16591
16592const fn hex_digit(n: u8) -> char {
16593    match n {
16594        0..=9 => (b'0' + n) as char,
16595        10..=15 => (b'a' + n - 10) as char,
16596        _ => '?',
16597    }
16598}
16599
16600/// v7.17.0 Phase 3.P0-39 — parse a PG `hstore` text literal into
16601/// a flat key→value map. Empty string → empty map. Duplicate
16602/// keys take last-write-wins (matches PG `hstore_in`).
16603///
16604/// Accepted shapes (minimal subset):
16605///   * `'a=>1, b=>2'`            — bareword keys/values
16606///   * `'"a"=>"1", "b"=>"2"'`    — quoted keys/values
16607///   * `'a=>NULL'`               — case-insensitive NULL token
16608///     surfaces as `None` (no quotes around NULL)
16609///
16610/// Returns None on parse failure → caller surfaces as hard error.
16611fn parse_hstore_str(
16612    s: &str,
16613) -> Option<Vec<(alloc::string::String, Option<alloc::string::String>)>> {
16614    let bytes = s.as_bytes();
16615    let mut i = 0;
16616    let mut out: Vec<(alloc::string::String, Option<alloc::string::String>)> = Vec::new();
16617    let skip_ws = |bytes: &[u8], i: &mut usize| {
16618        while *i < bytes.len() && matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r') {
16619            *i += 1;
16620        }
16621    };
16622    let parse_token = |bytes: &[u8], i: &mut usize| -> Option<alloc::string::String> {
16623        if *i >= bytes.len() {
16624            return None;
16625        }
16626        if bytes[*i] == b'"' {
16627            *i += 1;
16628            let mut out = alloc::string::String::new();
16629            while *i < bytes.len() {
16630                match bytes[*i] {
16631                    b'"' => {
16632                        *i += 1;
16633                        return Some(out);
16634                    }
16635                    b'\\' if *i + 1 < bytes.len() => {
16636                        out.push(bytes[*i + 1] as char);
16637                        *i += 2;
16638                    }
16639                    c => {
16640                        out.push(c as char);
16641                        *i += 1;
16642                    }
16643                }
16644            }
16645            None
16646        } else {
16647            let start = *i;
16648            while *i < bytes.len()
16649                && !matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r' | b',' | b'=')
16650            {
16651                *i += 1;
16652            }
16653            if *i == start {
16654                return None;
16655            }
16656            Some(alloc::str::from_utf8(&bytes[start..*i]).ok()?.to_string())
16657        }
16658    };
16659    skip_ws(bytes, &mut i);
16660    while i < bytes.len() {
16661        let key = parse_token(bytes, &mut i)?;
16662        skip_ws(bytes, &mut i);
16663        if i + 1 >= bytes.len() || bytes[i] != b'=' || bytes[i + 1] != b'>' {
16664            return None;
16665        }
16666        i += 2;
16667        skip_ws(bytes, &mut i);
16668        // Check for unquoted NULL token (case-insensitive).
16669        let val_token = if i + 4 <= bytes.len()
16670            && bytes[i..i + 4].eq_ignore_ascii_case(b"NULL")
16671            && (i + 4 == bytes.len() || matches!(bytes[i + 4], b' ' | b'\t' | b',' | b'\n' | b'\r'))
16672        {
16673            i += 4;
16674            None
16675        } else {
16676            Some(parse_token(bytes, &mut i)?)
16677        };
16678        // Replace any existing entry with the same key (last-wins).
16679        if let Some(pos) = out.iter().position(|(k, _)| k == &key) {
16680            out[pos] = (key, val_token);
16681        } else {
16682            out.push((key, val_token));
16683        }
16684        skip_ws(bytes, &mut i);
16685        if i >= bytes.len() {
16686            break;
16687        }
16688        if bytes[i] == b',' {
16689            i += 1;
16690            skip_ws(bytes, &mut i);
16691            continue;
16692        }
16693        return None;
16694    }
16695    Some(out)
16696}
16697
16698/// v7.17.0 Phase 3.P0-39 — render a hstore as canonical PG text
16699/// form `"k"=>"v"` (keys and non-NULL values always quoted;
16700/// NULL token is bare).
16701fn format_hstore_str(
16702    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
16703) -> alloc::string::String {
16704    let mut out = alloc::string::String::new();
16705    for (i, (k, v)) in pairs.iter().enumerate() {
16706        if i > 0 {
16707            out.push_str(", ");
16708        }
16709        out.push('"');
16710        out.push_str(k);
16711        out.push_str("\"=>");
16712        match v {
16713            None => out.push_str("NULL"),
16714            Some(val) => {
16715                out.push('"');
16716                out.push_str(val);
16717                out.push('"');
16718            }
16719        }
16720    }
16721    out
16722}
16723
16724/// v7.17.0 Phase 3.P0-39 — pub re-export so pgwire + sqllogictest
16725/// share the single hstore renderer.
16726pub fn format_hstore_text(
16727    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
16728) -> alloc::string::String {
16729    format_hstore_str(pairs)
16730}
16731
16732// ─── v7.17.0 Phase 3.P0-40 — 2D array parse + display ─────────
16733
16734/// Split a PG external 2D-array literal `'{{a,b},{c,d}}'` into
16735/// per-row token lists. Returns Err on shape mismatch.
16736fn split_2d_literal(s: &str) -> Result<Vec<Vec<alloc::string::String>>, &'static str> {
16737    let s = s.trim();
16738    let outer = s
16739        .strip_prefix('{')
16740        .and_then(|x| x.strip_suffix('}'))
16741        .ok_or("missing outer '{...}' braces")?;
16742    let trimmed = outer.trim();
16743    if trimmed.is_empty() {
16744        return Ok(Vec::new());
16745    }
16746    let mut rows: Vec<Vec<alloc::string::String>> = Vec::new();
16747    let mut i = 0;
16748    let bytes = trimmed.as_bytes();
16749    while i < bytes.len() {
16750        while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r' | b',') {
16751            i += 1;
16752        }
16753        if i >= bytes.len() {
16754            break;
16755        }
16756        if bytes[i] != b'{' {
16757            return Err("expected '{' opening a row");
16758        }
16759        i += 1;
16760        let row_start = i;
16761        let mut depth = 1;
16762        while i < bytes.len() && depth > 0 {
16763            match bytes[i] {
16764                b'{' => depth += 1,
16765                b'}' => depth -= 1,
16766                _ => {}
16767            }
16768            if depth > 0 {
16769                i += 1;
16770            }
16771        }
16772        if depth != 0 {
16773            return Err("unbalanced '{...}' in row");
16774        }
16775        let row_text = &trimmed[row_start..i];
16776        i += 1;
16777        let cells: Vec<alloc::string::String> = if row_text.trim().is_empty() {
16778            Vec::new()
16779        } else {
16780            row_text.split(',').map(|t| t.trim().to_string()).collect()
16781        };
16782        rows.push(cells);
16783    }
16784    if let Some(first) = rows.first() {
16785        let cols = first.len();
16786        for r in &rows {
16787            if r.len() != cols {
16788                return Err("ragged 2D array (rows have different column counts)");
16789            }
16790        }
16791    }
16792    Ok(rows)
16793}
16794
16795fn parse_int_2d_literal(s: &str) -> Result<Vec<Vec<Option<i32>>>, &'static str> {
16796    let raw = split_2d_literal(s)?;
16797    raw.into_iter()
16798        .map(|row| {
16799            row.into_iter()
16800                .map(|cell| {
16801                    if cell.eq_ignore_ascii_case("NULL") {
16802                        Ok(None)
16803                    } else {
16804                        cell.parse::<i32>()
16805                            .map(Some)
16806                            .map_err(|_| "invalid int element")
16807                    }
16808                })
16809                .collect()
16810        })
16811        .collect()
16812}
16813
16814fn parse_bigint_2d_literal(s: &str) -> Result<Vec<Vec<Option<i64>>>, &'static str> {
16815    let raw = split_2d_literal(s)?;
16816    raw.into_iter()
16817        .map(|row| {
16818            row.into_iter()
16819                .map(|cell| {
16820                    if cell.eq_ignore_ascii_case("NULL") {
16821                        Ok(None)
16822                    } else {
16823                        cell.parse::<i64>()
16824                            .map(Some)
16825                            .map_err(|_| "invalid bigint element")
16826                    }
16827                })
16828                .collect()
16829        })
16830        .collect()
16831}
16832
16833fn parse_text_2d_literal(s: &str) -> Result<Vec<Vec<Option<alloc::string::String>>>, &'static str> {
16834    let raw = split_2d_literal(s)?;
16835    Ok(raw
16836        .into_iter()
16837        .map(|row| {
16838            row.into_iter()
16839                .map(|cell| {
16840                    if cell.eq_ignore_ascii_case("NULL") {
16841                        None
16842                    } else {
16843                        Some(cell.trim_matches('"').to_string())
16844                    }
16845                })
16846                .collect()
16847        })
16848        .collect())
16849}
16850
16851fn format_int_2d_text(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
16852    let mut out = alloc::string::String::from("{");
16853    for (i, row) in rows.iter().enumerate() {
16854        if i > 0 {
16855            out.push(',');
16856        }
16857        out.push('{');
16858        for (j, cell) in row.iter().enumerate() {
16859            if j > 0 {
16860                out.push(',');
16861            }
16862            match cell {
16863                None => out.push_str("NULL"),
16864                Some(n) => out.push_str(&alloc::format!("{n}")),
16865            }
16866        }
16867        out.push('}');
16868    }
16869    out.push('}');
16870    out
16871}
16872
16873fn format_bigint_2d_text(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
16874    let mut out = alloc::string::String::from("{");
16875    for (i, row) in rows.iter().enumerate() {
16876        if i > 0 {
16877            out.push(',');
16878        }
16879        out.push('{');
16880        for (j, cell) in row.iter().enumerate() {
16881            if j > 0 {
16882                out.push(',');
16883            }
16884            match cell {
16885                None => out.push_str("NULL"),
16886                Some(n) => out.push_str(&alloc::format!("{n}")),
16887            }
16888        }
16889        out.push('}');
16890    }
16891    out.push('}');
16892    out
16893}
16894
16895fn format_text_2d_text(rows: &[Vec<Option<alloc::string::String>>]) -> alloc::string::String {
16896    let mut out = alloc::string::String::from("{");
16897    for (i, row) in rows.iter().enumerate() {
16898        if i > 0 {
16899            out.push(',');
16900        }
16901        out.push('{');
16902        for (j, cell) in row.iter().enumerate() {
16903            if j > 0 {
16904                out.push(',');
16905            }
16906            match cell {
16907                None => out.push_str("NULL"),
16908                Some(s) => out.push_str(s),
16909            }
16910        }
16911        out.push('}');
16912    }
16913    out.push('}');
16914    out
16915}
16916
16917/// v7.17.0 Phase 3.P0-40 — pub re-exports so pgwire + sqllogictest
16918/// share the single 2D-array renderer.
16919pub fn format_int_2d_text_pub(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
16920    format_int_2d_text(rows)
16921}
16922pub fn format_bigint_2d_text_pub(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
16923    format_bigint_2d_text(rows)
16924}
16925pub fn format_text_2d_text_pub(
16926    rows: &[Vec<Option<alloc::string::String>>],
16927) -> alloc::string::String {
16928    format_text_2d_text(rows)
16929}
16930
16931/// v7.17.0 Phase 3.P0-38 — parse a PG range literal of the form
16932/// `'[lo,up)'` / `'(lo,up]'` / `'[lo,up]'` / `'(lo,up)'` /
16933/// `'empty'`. Lower / upper may be empty (unbounded). Returns
16934/// `None` on any parse failure; caller surfaces as hard error.
16935fn parse_range_str(s: &str, kind: spg_storage::RangeKind) -> Option<Value> {
16936    let s = s.trim();
16937    if s.eq_ignore_ascii_case("empty") {
16938        return Some(Value::Range {
16939            kind,
16940            lower: None,
16941            upper: None,
16942            lower_inc: false,
16943            upper_inc: false,
16944            empty: true,
16945        });
16946    }
16947    let bytes = s.as_bytes();
16948    if bytes.len() < 3 {
16949        return None;
16950    }
16951    let lower_inc = match bytes[0] {
16952        b'[' => true,
16953        b'(' => false,
16954        _ => return None,
16955    };
16956    let upper_inc = match bytes[bytes.len() - 1] {
16957        b']' => true,
16958        b')' => false,
16959        _ => return None,
16960    };
16961    let inner = &s[1..s.len() - 1];
16962    let (lo_text, up_text) = inner.split_once(',')?;
16963    let lower = if lo_text.is_empty() {
16964        None
16965    } else {
16966        Some(alloc::boxed::Box::new(parse_range_element(lo_text, kind)?))
16967    };
16968    let upper = if up_text.is_empty() {
16969        None
16970    } else {
16971        Some(alloc::boxed::Box::new(parse_range_element(up_text, kind)?))
16972    };
16973    Some(Value::Range {
16974        kind,
16975        lower,
16976        upper,
16977        lower_inc,
16978        upper_inc,
16979        empty: false,
16980    })
16981}
16982
16983/// v7.17.0 Phase 3.P0-38 — parse a single range bound text into
16984/// the matching element Value for the RangeKind.
16985fn parse_range_element(text: &str, kind: spg_storage::RangeKind) -> Option<Value> {
16986    let text = text.trim().trim_matches('"');
16987    use spg_storage::RangeKind as K;
16988    match kind {
16989        K::Int4 => text.parse::<i32>().ok().map(Value::Int),
16990        K::Int8 => text.parse::<i64>().ok().map(Value::BigInt),
16991        K::Num => {
16992            // Reuse the Numeric parse via the engine's text-coercion
16993            // path; bail to None on failure.
16994            let dot = text.find('.');
16995            let scale: u8 = dot.map_or(0, |p| (text.len() - p - 1) as u8);
16996            let digits: alloc::string::String = text
16997                .chars()
16998                .filter(|c| *c == '-' || c.is_ascii_digit())
16999                .collect();
17000            let scaled: i128 = digits.parse().ok()?;
17001            Some(Value::Numeric { scaled, scale })
17002        }
17003        K::Ts | K::TsTz => {
17004            // Reuse the existing timestamp parse path. v7.17.0
17005            // expects `'YYYY-MM-DD HH:MM:SS[.ffffff]'` in range
17006            // bounds (TZ offset on TsTz is OOS for the initial
17007            // P0-38; ship plain Timestamp shape).
17008            crate::eval::parse_timestamp_literal(text).map(Value::Timestamp)
17009        }
17010        K::Date => crate::eval::parse_date_literal(text).map(Value::Date),
17011    }
17012}
17013
17014/// v7.17.0 Phase 3.P0-38 — render a Range value as its canonical
17015/// PG text form. Re-exported via [`format_range_text`] for use
17016/// from spg-server's pgwire layer.
17017pub fn format_range_text(v: &Value) -> alloc::string::String {
17018    format_range_str(v)
17019}
17020
17021fn format_range_str(v: &Value) -> alloc::string::String {
17022    let Value::Range {
17023        lower,
17024        upper,
17025        lower_inc,
17026        upper_inc,
17027        empty,
17028        ..
17029    } = v
17030    else {
17031        return alloc::string::String::new();
17032    };
17033    if *empty {
17034        return "empty".into();
17035    }
17036    let mut out = alloc::string::String::new();
17037    out.push(if *lower_inc { '[' } else { '(' });
17038    if let Some(l) = lower {
17039        out.push_str(&format_range_element(l));
17040    }
17041    out.push(',');
17042    if let Some(u) = upper {
17043        out.push_str(&format_range_element(u));
17044    }
17045    out.push(if *upper_inc { ']' } else { ')' });
17046    out
17047}
17048
17049fn format_range_element(v: &Value) -> alloc::string::String {
17050    match v {
17051        Value::Int(n) => alloc::format!("{n}"),
17052        Value::BigInt(n) => alloc::format!("{n}"),
17053        Value::Date(d) => crate::eval::format_date(*d),
17054        Value::Timestamp(t) => crate::eval::format_timestamp(*t),
17055        Value::Numeric { scaled, scale } => crate::eval::format_numeric(*scaled, *scale),
17056        other => alloc::format!("{other:?}"),
17057    }
17058}
17059
17060/// v7.17.0 Phase 3.P0-35 — parse a PG `money` literal into i64
17061/// cents. Accepts:
17062///   * Optional leading `-` (negative)
17063///   * Optional `$` prefix
17064///   * Integer portion with optional `,` thousands separators
17065///   * Optional `.` followed by 1-2 digits (cents); 1 digit
17066///     auto-pads to 2 (`.5` → 50 cents).
17067///
17068/// Returns None on any parse failure — caller surfaces as hard
17069/// SQL error.
17070fn parse_money_str(s: &str) -> Option<i64> {
17071    let s = s.trim();
17072    let (neg, rest) = match s.strip_prefix('-') {
17073        Some(r) => (true, r.trim_start()),
17074        None => (false, s),
17075    };
17076    let rest = rest.strip_prefix('$').unwrap_or(rest).trim_start();
17077    let (int_part, frac_part) = match rest.split_once('.') {
17078        Some((i, f)) => (i, Some(f)),
17079        None => (rest, None),
17080    };
17081    if int_part.is_empty() {
17082        return None;
17083    }
17084    // Validate + strip commas from the integer portion.
17085    let mut int_digits = alloc::string::String::with_capacity(int_part.len());
17086    for b in int_part.bytes() {
17087        match b {
17088            b',' => {}
17089            b'0'..=b'9' => int_digits.push(b as char),
17090            _ => return None,
17091        }
17092    }
17093    if int_digits.is_empty() {
17094        return None;
17095    }
17096    let dollars: i64 = int_digits.parse().ok()?;
17097    let cents: i64 = match frac_part {
17098        None => 0,
17099        Some(f) => {
17100            if f.is_empty() || f.len() > 2 || !f.bytes().all(|b| b.is_ascii_digit()) {
17101                return None;
17102            }
17103            let padded = if f.len() == 1 {
17104                alloc::format!("{f}0")
17105            } else {
17106                f.to_string()
17107            };
17108            padded.parse().ok()?
17109        }
17110    };
17111    let total = dollars.checked_mul(100)?.checked_add(cents)?;
17112    Some(if neg { -total } else { total })
17113}
17114
17115/// v7.17.0 Phase 3.P0-34 — parse a PG `timetz` literal
17116/// `HH:MM:SS[.fraction]±HH[:MM]` into (us, offset_secs).
17117///
17118/// The offset suffix is MANDATORY: SPG doesn't have a session TZ
17119/// wired into eval, so a bare `HH:MM:SS` literal would be
17120/// ambiguous. Returns None for any parse failure or out-of-range
17121/// component — caller surfaces as a hard SQL error.
17122///
17123/// Offset range: ±14 hours (±50400 seconds), matching PG's
17124/// internal limit.
17125fn parse_timetz_str(s: &str) -> Option<(i64, i32)> {
17126    let s = s.trim();
17127    // Find the offset sign — scan from right since the time part
17128    // never contains '+' / '-' (after the optional fractional dot
17129    // it's all digits and ':').
17130    let bytes = s.as_bytes();
17131    let sign_pos = bytes
17132        .iter()
17133        .enumerate()
17134        .rev()
17135        .find(|&(_, &b)| b == b'+' || b == b'-')
17136        .map(|(i, _)| i)?;
17137    if sign_pos == 0 {
17138        return None; // bare sign — no time component
17139    }
17140    let time_part = &s[..sign_pos];
17141    let offset_part = &s[sign_pos..];
17142    let us = parse_time_str(time_part)?;
17143    let sign: i32 = if offset_part.starts_with('+') { 1 } else { -1 };
17144    let offset_body = &offset_part[1..];
17145    let (hh_str, mm_str) = match offset_body.split_once(':') {
17146        Some((h, m)) => (h, m),
17147        None => (offset_body, "0"),
17148    };
17149    let hh: i32 = hh_str.parse().ok()?;
17150    let mm: i32 = mm_str.parse().ok()?;
17151    if !(0..=14).contains(&hh) || !(0..=59).contains(&mm) {
17152        return None;
17153    }
17154    let total = sign * (hh * 3600 + mm * 60);
17155    if total.abs() > 50_400 {
17156        return None;
17157    }
17158    Some((us, total))
17159}
17160
17161/// v7.17.0 Phase 3.P0-33 — funnel an integer literal through MySQL
17162/// YEAR range validation: 0 sentinel or 1901..=2155. Out-of-range
17163/// surfaces as a hard SQL error (no silent truncation, mirrors PG
17164/// `time_in` / `uuid_in` discipline).
17165fn coerce_int_to_year(n: i64, col_name: &str) -> Result<Value, EngineError> {
17166    if n == 0 || (1901..=2155).contains(&n) {
17167        // u16::try_from cannot fail in this range; the cast also
17168        // covers the 0 sentinel.
17169        return Ok(Value::Year(n as u16));
17170    }
17171    Err(EngineError::Eval(EvalError::TypeMismatch {
17172        detail: alloc::format!(
17173            "year value out of range: {n} (column `{col_name}`; \
17174             MySQL accepts 0 or 1901..=2155)"
17175        ),
17176    }))
17177}
17178
17179/// v7.17.0 Phase 3.P0-32 — parse a PG `time` literal
17180/// `HH:MM:SS[.fraction]` into microseconds since 00:00:00.
17181///
17182/// Accepts:
17183///   * `HH:MM:SS`            — exact-second precision
17184///   * `HH:MM:SS.f` .. `.ffffff` — 1-6 fractional digits, right-padded
17185///     with zeros to microseconds
17186///
17187/// Range: hour 0..=23, minute 0..=59, second 0..=59. Anything else
17188/// returns None — caller surfaces as a hard SQL error (no silent
17189/// truncation, matches PG's `time_in` behaviour).
17190fn parse_time_str(s: &str) -> Option<i64> {
17191    let s = s.trim();
17192    let (hms, frac) = match s.split_once('.') {
17193        Some((h, f)) => (h, Some(f)),
17194        None => (s, None),
17195    };
17196    let mut parts = hms.split(':');
17197    let hh: u32 = parts.next()?.parse().ok()?;
17198    let mm: u32 = parts.next()?.parse().ok()?;
17199    let ss: u32 = parts.next()?.parse().ok()?;
17200    if parts.next().is_some() {
17201        return None;
17202    }
17203    if hh > 23 || mm > 59 || ss > 59 {
17204        return None;
17205    }
17206    let frac_us: i64 = match frac {
17207        None => 0,
17208        Some(f) => {
17209            if f.is_empty() || f.len() > 6 || !f.bytes().all(|b| b.is_ascii_digit()) {
17210                return None;
17211            }
17212            // Right-pad with zeros so '.5' = 500000 µsec.
17213            let mut padded = alloc::string::String::with_capacity(6);
17214            padded.push_str(f);
17215            while padded.len() < 6 {
17216                padded.push('0');
17217            }
17218            padded.parse().ok()?
17219        }
17220    };
17221    Some(
17222        i64::from(hh) * 3_600_000_000
17223            + i64::from(mm) * 60_000_000
17224            + i64::from(ss) * 1_000_000
17225            + frac_us,
17226    )
17227}
17228
17229const fn column_type_to_data_type(t: ColumnTypeName) -> DataType {
17230    match t {
17231        ColumnTypeName::SmallInt => DataType::SmallInt,
17232        ColumnTypeName::Int => DataType::Int,
17233        ColumnTypeName::BigInt => DataType::BigInt,
17234        ColumnTypeName::Float => DataType::Float,
17235        ColumnTypeName::Text => DataType::Text,
17236        ColumnTypeName::Varchar(n) => DataType::Varchar(n),
17237        ColumnTypeName::Char(n) => DataType::Char(n),
17238        ColumnTypeName::Bool => DataType::Bool,
17239        ColumnTypeName::Vector { dim, encoding } => DataType::Vector {
17240            dim,
17241            encoding: match encoding {
17242                SqlVecEncoding::F32 => VecEncoding::F32,
17243                SqlVecEncoding::Sq8 => VecEncoding::Sq8,
17244                SqlVecEncoding::F16 => VecEncoding::F16,
17245            },
17246        },
17247        ColumnTypeName::Numeric(precision, scale) => DataType::Numeric { precision, scale },
17248        ColumnTypeName::Date => DataType::Date,
17249        ColumnTypeName::Timestamp => DataType::Timestamp,
17250        ColumnTypeName::Timestamptz => DataType::Timestamptz,
17251        ColumnTypeName::Json => DataType::Json,
17252        ColumnTypeName::Jsonb => DataType::Jsonb,
17253        ColumnTypeName::Bytes => DataType::Bytes,
17254        ColumnTypeName::TextArray => DataType::TextArray,
17255        ColumnTypeName::IntArray => DataType::IntArray,
17256        ColumnTypeName::BigIntArray => DataType::BigIntArray,
17257        ColumnTypeName::TsVector => DataType::TsVector,
17258        ColumnTypeName::TsQuery => DataType::TsQuery,
17259        ColumnTypeName::Uuid => DataType::Uuid,
17260        ColumnTypeName::Time => DataType::Time,
17261        ColumnTypeName::Year => DataType::Year,
17262        ColumnTypeName::TimeTz => DataType::TimeTz,
17263        ColumnTypeName::Money => DataType::Money,
17264        ColumnTypeName::Range(k) => DataType::Range(match k {
17265            spg_sql::ast::RangeKindAst::Int4 => spg_storage::RangeKind::Int4,
17266            spg_sql::ast::RangeKindAst::Int8 => spg_storage::RangeKind::Int8,
17267            spg_sql::ast::RangeKindAst::Num => spg_storage::RangeKind::Num,
17268            spg_sql::ast::RangeKindAst::Ts => spg_storage::RangeKind::Ts,
17269            spg_sql::ast::RangeKindAst::TsTz => spg_storage::RangeKind::TsTz,
17270            spg_sql::ast::RangeKindAst::Date => spg_storage::RangeKind::Date,
17271        }),
17272        ColumnTypeName::Hstore => DataType::Hstore,
17273        ColumnTypeName::IntArray2D => DataType::IntArray2D,
17274        ColumnTypeName::BigIntArray2D => DataType::BigIntArray2D,
17275        ColumnTypeName::TextArray2D => DataType::TextArray2D,
17276    }
17277}
17278
17279/// Convert an INSERT VALUES expression to a storage Value. Supports literal
17280/// expressions, unary-minus over numeric literals, and pgvector-style
17281/// `'[..]'::vector` cast (v1.2). Anything more complex returns `Unsupported`.
17282fn literal_expr_to_value(expr: Expr) -> Result<Value, EngineError> {
17283    match expr {
17284        Expr::Literal(l) => Ok(literal_to_value(l)),
17285        Expr::Cast { expr, target } => {
17286            let inner_value = literal_expr_to_value(*expr)?;
17287            crate::eval::cast_value(inner_value, target).map_err(EngineError::Eval)
17288        }
17289        Expr::Unary {
17290            op: UnOp::Neg,
17291            expr,
17292        } => match *expr {
17293            Expr::Literal(Literal::Integer(n)) => {
17294                // Fold to i32 if it fits, else BigInt. Parser emits Integer(i64)
17295                // — overflow on negate of i64::MIN is the one edge case.
17296                let neg = n.checked_neg().ok_or_else(|| {
17297                    EngineError::Unsupported("integer literal overflow on negation".into())
17298                })?;
17299                Ok(int_value_for(neg))
17300            }
17301            Expr::Literal(Literal::Float(x)) => Ok(Value::Float(-x)),
17302            other => Err(EngineError::Unsupported(alloc::format!(
17303                "unary minus over non-literal expression: {other:?}"
17304            ))),
17305        },
17306        // v7.10.10 — `ARRAY[lit, lit, …]` constructor accepted at
17307        // INSERT-time. Each element must reduce to a Value through
17308        // `literal_expr_to_value`; NULL elements become `None`.
17309        // v7.11.13 — deduce shape from element values: all Int →
17310        // IntArray; any BigInt → BigIntArray (widening); any Text
17311        // → TextArray. Cast targets (`ARRAY[]::INT[]`) flow through
17312        // the outer Cast arm before reaching here and re-coerce.
17313        Expr::Array(items) => {
17314            let mut materialised: alloc::vec::Vec<Value> =
17315                alloc::vec::Vec::with_capacity(items.len());
17316            for elem in items {
17317                materialised.push(literal_expr_to_value(elem)?);
17318            }
17319            Ok(array_literal_widen(materialised))
17320        }
17321        // Any other Expr shape — fall back to a general evaluation
17322        // against an empty row + empty schema. This unblocks the
17323        // app-common patterns where INSERT VALUES carries a
17324        // non-correlated function call:
17325        //   INSERT INTO t VALUES (concat('U-', 42))
17326        //   INSERT INTO t VALUES (now())
17327        //   INSERT INTO t VALUES (format('%s-%s', 'a', 'b'))
17328        // Any expression that references a column or `$N`
17329        // placeholder fails cleanly inside `eval_expr` with a
17330        // descriptive error; literals + casts + ARRAY[…] continue
17331        // to take the fast paths above so the hot INSERT path is
17332        // unchanged on the common case.
17333        other => {
17334            let empty_schema: alloc::vec::Vec<spg_storage::ColumnSchema> = alloc::vec::Vec::new();
17335            let ctx = EvalContext::new(&empty_schema, None);
17336            let empty_row = spg_storage::Row::new(alloc::vec::Vec::new());
17337            crate::eval::eval_expr(&other, &empty_row, &ctx).map_err(EngineError::Eval)
17338        }
17339    }
17340}
17341
17342fn literal_to_value(l: Literal) -> Value {
17343    match l {
17344        Literal::Integer(n) => int_value_for(n),
17345        Literal::Float(x) => Value::Float(x),
17346        Literal::String(s) => Value::Text(s),
17347        Literal::Bool(b) => Value::Bool(b),
17348        Literal::Null => Value::Null,
17349        Literal::Vector(v) => Value::Vector(v),
17350        Literal::TextArray(items) => Value::TextArray(items),
17351        Literal::IntArray(items) => Value::IntArray(items),
17352        Literal::BigIntArray(items) => Value::BigIntArray(items),
17353        Literal::Interval { months, micros, .. } => Value::Interval { months, micros },
17354    }
17355}
17356
17357/// Pick `Int` (`i32`) when the literal fits, else `BigInt`. `INT` vs `BIGINT`
17358/// columns will still enforce the right tag downstream — this is just the
17359/// default we synthesise from an unannotated integer literal.
17360fn int_value_for(n: i64) -> Value {
17361    if let Ok(small) = i32::try_from(n) {
17362        Value::Int(small)
17363    } else {
17364        Value::BigInt(n)
17365    }
17366}
17367
17368/// Widen / narrow `v` to fit `expected`. Numerics permit safe widening
17369/// (`Int → BigInt`, `Int/BigInt → Float`) and best-effort narrowing
17370/// (`BigInt → Int` succeeds only when the value fits in `i32`). Everything
17371/// else returns `TypeMismatch` carrying the column name for caller diagnostics.
17372/// `NULL` is always permitted; the nullability check happens later in storage.
17373#[allow(clippy::too_many_lines)]
17374/// v7.17.0 Phase 4.4 — reject negative integer values on UNSIGNED
17375/// columns. Called after `coerce_value` at each INSERT / UPDATE
17376/// site that has ColumnSchema context. NULL passes through (a
17377/// nullable UNSIGNED column can legitimately hold NULL).
17378fn check_unsigned_range(
17379    v: &Value,
17380    schema: &ColumnSchema,
17381    position: usize,
17382) -> Result<(), EngineError> {
17383    if !schema.is_unsigned {
17384        return Ok(());
17385    }
17386    let n = match v {
17387        Value::SmallInt(x) => i64::from(*x),
17388        Value::Int(x) => i64::from(*x),
17389        Value::BigInt(x) => *x,
17390        _ => return Ok(()), // non-integer cells (NULL, default) skip
17391    };
17392    if n < 0 {
17393        return Err(EngineError::Unsupported(alloc::format!(
17394            "column {:?} is UNSIGNED but got negative value {n} at position {position}",
17395            schema.name
17396        )));
17397    }
17398    Ok(())
17399}
17400
17401fn coerce_value(
17402    v: Value,
17403    expected: DataType,
17404    col_name: &str,
17405    position: usize,
17406) -> Result<Value, EngineError> {
17407    if v.is_null() {
17408        return Ok(Value::Null);
17409    }
17410    let actual = v.data_type().expect("non-null");
17411    if actual == expected {
17412        return Ok(v);
17413    }
17414    let coerced = match (v, expected) {
17415        (Value::Int(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
17416        (Value::Int(n), DataType::Float) => Some(Value::Float(f64::from(n))),
17417        (Value::Int(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
17418        (Value::Int(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
17419            i128::from(n),
17420            precision,
17421            scale,
17422            col_name,
17423        )?),
17424        (Value::SmallInt(n), DataType::Int) => Some(Value::Int(i32::from(n))),
17425        (Value::SmallInt(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
17426        (Value::SmallInt(n), DataType::Float) => Some(Value::Float(f64::from(n))),
17427        (Value::SmallInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
17428            i128::from(n),
17429            precision,
17430            scale,
17431            col_name,
17432        )?),
17433        (Value::BigInt(n), DataType::Int) => i32::try_from(n).ok().map(Value::Int),
17434        (Value::BigInt(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
17435        #[allow(clippy::cast_precision_loss)]
17436        (Value::BigInt(n), DataType::Float) => Some(Value::Float(n as f64)),
17437        (Value::BigInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
17438            i128::from(n),
17439            precision,
17440            scale,
17441            col_name,
17442        )?),
17443        (Value::Float(x), DataType::Numeric { precision, scale }) => {
17444            Some(numeric_from_float(x, precision, scale, col_name)?)
17445        }
17446        // v7.17.0 Phase 3.P0-67 — Text → NUMERIC. Parse a
17447        // canonical decimal text (`"-1234.56"` / `"42"` /
17448        // `"0.0001"`) into `(mantissa, source_scale)` and rescale
17449        // to the column's declared scale. Required for prepared
17450        // binds: `value_to_literal` flattens a Value::Numeric
17451        // into a TEXT literal because Literal carries no native
17452        // Numeric variant, so the placeholder substitution path
17453        // reaches coerce_value as Text → Numeric. Without this
17454        // arm the round-trip surfaces a TypeMismatch even though
17455        // the cell already left the engine as a valid Numeric.
17456        (Value::Text(s), DataType::Numeric { precision, scale }) => {
17457            let Some((mantissa, src_scale)) = parse_numeric_text(&s) else {
17458                return Err(EngineError::Eval(EvalError::TypeMismatch {
17459                    detail: alloc::format!("cannot parse {s:?} as NUMERIC for column `{col_name}`"),
17460                }));
17461            };
17462            Some(numeric_rescale(
17463                mantissa, src_scale, precision, scale, col_name,
17464            )?)
17465        }
17466        // Text → DATE / TIMESTAMP: parse canonical text forms.
17467        (Value::Text(s), DataType::Date) => {
17468            let d = eval::parse_date_literal(&s).ok_or_else(|| {
17469                EngineError::Eval(EvalError::TypeMismatch {
17470                    detail: alloc::format!("cannot parse {s:?} as DATE for column `{col_name}`"),
17471                })
17472            })?;
17473            Some(Value::Date(d))
17474        }
17475        // v7.14.0 — MySQL DEFAULT clauses quote integer / float
17476        // / boolean literals (`DEFAULT '0'`, `DEFAULT '1'`,
17477        // `DEFAULT '3.14'`, `DEFAULT 'true'`). Coerce the text
17478        // form to the column's numeric / bool type at DEFAULT-
17479        // installation time so the storage check sees a typed
17480        // value. Parse failures fall through to TypeMismatch.
17481        (Value::Text(s), DataType::SmallInt) => s.parse::<i16>().ok().map(Value::SmallInt),
17482        (Value::Text(s), DataType::Int) => s.parse::<i32>().ok().map(Value::Int),
17483        (Value::Text(s), DataType::BigInt) => s.parse::<i64>().ok().map(Value::BigInt),
17484        (Value::Text(s), DataType::Float) => s.parse::<f64>().ok().map(Value::Float),
17485        (Value::Text(s), DataType::Bool) => match s.to_ascii_lowercase().as_str() {
17486            "0" | "false" | "f" | "no" | "off" => Some(Value::Bool(false)),
17487            "1" | "true" | "t" | "yes" | "on" => Some(Value::Bool(true)),
17488            _ => None,
17489        },
17490        // v7.17.0 Phase 3.P0-46 — MySQL TINYINT(1) (which Phase 4.3
17491        // classifies as DataType::Bool) is the storage shape every
17492        // mysqldump-restored boolean column lands in. mysqldump emits
17493        // the values as integer `0` / `1` literals, so int → bool
17494        // coerce on INSERT is required for a 0-change cutover. MySQL's
17495        // rule is "any non-zero is truthy"; we follow that for all
17496        // signed int widths so the same coerce path serves an
17497        // explicit `BOOLEAN` column too.
17498        (Value::Int(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17499        (Value::SmallInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17500        (Value::BigInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17501        // v4.9: Text ↔ JSON coercion. No structural validation —
17502        // any text literal is accepted; the responsibility for
17503        // valid JSON lies with the producer.
17504        (Value::Text(s), DataType::Json | DataType::Jsonb) => Some(Value::Json(s)),
17505        (Value::Json(s), DataType::Text) => Some(Value::Text(s)),
17506        // v7.13.3 — mailrs round-7 S10. SPG's storage represents
17507        // both JSON and JSONB on-disk as `Value::Json(String)` —
17508        // they share the underlying text payload. The cast
17509        // `'<text>'::jsonb` produces a Value::Json that needs to
17510        // satisfy a DataType::Jsonb column. Identity coerce in
17511        // both directions so JSON ↔ JSONB assignments work at all
17512        // INSERT / ALTER COLUMN TYPE / DEFAULT contexts.
17513        (Value::Json(s), DataType::Jsonb | DataType::Json) => Some(Value::Json(s)),
17514        // v7.10.4 — Text → BYTEA. Decode PG-style literal forms:
17515        //   - Hex:    `\x48656c6c6f`  (case-insensitive hex pairs)
17516        //   - Escape: `Hello\\000world`  (backslash + octal triples)
17517        //   - Plain:  any string → raw UTF-8 bytes (PG also accepts)
17518        // Errors surface as TypeMismatch so the operator gets a
17519        // clear "this literal isn't a bytea literal" hint.
17520        (Value::Text(s), DataType::Bytes) => {
17521            let bytes = decode_bytea_literal(&s).map_err(|e| {
17522                EngineError::Eval(EvalError::TypeMismatch {
17523                    detail: alloc::format!(
17524                        "cannot parse {s:?} as BYTEA for column `{col_name}`: {e}"
17525                    ),
17526                })
17527            })?;
17528            Some(Value::Bytes(bytes))
17529        }
17530        // v7.10.4 — BYTEA → Text round-trip uses the PG hex
17531        // output (lowercase, `\x` prefix). Important when a
17532        // SELECT pulls a bytea cell through a Text column path.
17533        (Value::Bytes(b), DataType::Text) => Some(Value::Text(encode_bytea_hex(&b))),
17534        // v7.17.0 — Text → UUID. PG accepts canonical hyphenated,
17535        // unhyphenated, uppercase, and `{...}`-braced forms; we
17536        // funnel all four through `spg_storage::parse_uuid_str`.
17537        // A malformed literal surfaces as a SQL TypeMismatch
17538        // rather than silently inserting garbage — `0-change
17539        // cutover` requires that an app inserting bad UUID text
17540        // sees the same hard error PG would raise.
17541        (Value::Text(s), DataType::Uuid) => match spg_storage::parse_uuid_str(&s) {
17542            Some(b) => Some(Value::Uuid(b)),
17543            None => {
17544                return Err(EngineError::Eval(EvalError::TypeMismatch {
17545                    detail: alloc::format!(
17546                        "invalid input syntax for type uuid: {s:?} (column `{col_name}`)"
17547                    ),
17548                }));
17549            }
17550        },
17551        // v7.17.0 — UUID → Text canonical 8-4-4-4-12 lowercase.
17552        // Surfaces when a SELECT plucks a uuid cell through a
17553        // Text column path (e.g. INSERT INTO log SELECT id::text
17554        // FROM other_table).
17555        (Value::Uuid(b), DataType::Text) => Some(Value::Text(spg_storage::format_uuid(&b))),
17556        // v7.17.0 Phase 3.P0-32 — Text → TIME. Accepts
17557        // `HH:MM:SS` and `HH:MM:SS.ffffff` (1-6 fractional digits).
17558        // Out-of-range hour/min/sec is a hard SQL error (no
17559        // silent truncation — same 0-change-cutover discipline
17560        // we apply to UUID).
17561        (Value::Text(s), DataType::Time) => match parse_time_str(&s) {
17562            Some(us) => Some(Value::Time(us)),
17563            None => {
17564                return Err(EngineError::Eval(EvalError::TypeMismatch {
17565                    detail: alloc::format!(
17566                        "invalid input syntax for type time: {s:?} (column `{col_name}`)"
17567                    ),
17568                }));
17569            }
17570        },
17571        // v7.17.0 Phase 3.P0-32 — TIME → Text canonical `HH:MM:SS[.ffffff]`.
17572        (Value::Time(us), DataType::Text) => Some(Value::Text(eval::format_time(us))),
17573        // v7.17.0 Phase 3.P0-33 — int / bigint → YEAR. Range
17574        // check enforces the MySQL canonical 1901..=2155 + 0
17575        // sentinel; out-of-range is a hard SQL error (no silent
17576        // truncation, mirrors P0-32 / P0-25 discipline).
17577        (Value::SmallInt(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
17578        (Value::Int(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
17579        (Value::BigInt(n), DataType::Year) => Some(coerce_int_to_year(n, col_name)?),
17580        // Text → YEAR. Accepts the 4-digit decimal form only;
17581        // two-digit YEAR (`'99'` → 1999) was deprecated in MySQL
17582        // 5.7 and is out of scope for v7.17.0.
17583        (Value::Text(s), DataType::Year) => match s.trim().parse::<i64>() {
17584            Ok(n) => Some(coerce_int_to_year(n, col_name)?),
17585            Err(_) => {
17586                return Err(EngineError::Eval(EvalError::TypeMismatch {
17587                    detail: alloc::format!(
17588                        "invalid input syntax for type year: {s:?} (column `{col_name}`)"
17589                    ),
17590                }));
17591            }
17592        },
17593        // YEAR → Text 4-digit zero-padded.
17594        (Value::Year(y), DataType::Text) => Some(Value::Text(alloc::format!("{y:04}"))),
17595        // v7.17.0 Phase 3.P0-34 — Text → TIMETZ. Mandatory
17596        // signed offset suffix; missing offset is a hard error
17597        // (SPG has no session TZ wired into eval, unlike PG).
17598        (Value::Text(s), DataType::TimeTz) => match parse_timetz_str(&s) {
17599            Some((us, offset_secs)) => Some(Value::TimeTz { us, offset_secs }),
17600            None => {
17601                return Err(EngineError::Eval(EvalError::TypeMismatch {
17602                    detail: alloc::format!(
17603                        "invalid input syntax for type time with time zone: \
17604                         {s:?} (column `{col_name}`)"
17605                    ),
17606                }));
17607            }
17608        },
17609        // TIMETZ → Text canonical `HH:MM:SS[.ffffff]±HH[:MM]`.
17610        (Value::TimeTz { us, offset_secs }, DataType::Text) => {
17611            Some(Value::Text(eval::format_timetz(us, offset_secs)))
17612        }
17613        // v7.17.0 Phase 3.P0-35 — Text → MONEY. Accepts `$N.NN`,
17614        // `$N,NNN.NN`, optional leading `-`. Bare numeric literals
17615        // arrive via the Int/BigInt/Float/Numeric arms below.
17616        (Value::Text(s), DataType::Money) => match parse_money_str(&s) {
17617            Some(c) => Some(Value::Money(c)),
17618            None => {
17619                return Err(EngineError::Eval(EvalError::TypeMismatch {
17620                    detail: alloc::format!(
17621                        "invalid input syntax for type money: {s:?} (column `{col_name}`)"
17622                    ),
17623                }));
17624            }
17625        },
17626        // Int / BigInt / SmallInt / Float / Numeric → MONEY.
17627        // Bare numeric literal is interpreted as a major-unit
17628        // amount (matches PG: `100`::money → $100.00 = 10000 cents).
17629        (Value::SmallInt(n), DataType::Money) => {
17630            Some(Value::Money(i64::from(n).saturating_mul(100)))
17631        }
17632        (Value::Int(n), DataType::Money) => Some(Value::Money(i64::from(n).saturating_mul(100))),
17633        (Value::BigInt(n), DataType::Money) => Some(Value::Money(n.saturating_mul(100))),
17634        (Value::Float(x), DataType::Money) => {
17635            // Round half-away-from-zero to cents (no_std — no
17636            // `f64::round`, so hand-roll via biased truncation).
17637            let scaled = x * 100.0;
17638            let cents = if scaled >= 0.0 {
17639                (scaled + 0.5) as i64
17640            } else {
17641                (scaled - 0.5) as i64
17642            };
17643            Some(Value::Money(cents))
17644        }
17645        (Value::Numeric { scaled, scale }, DataType::Money) => {
17646            // Convert exact decimal to cents (scale 2). If scale > 2,
17647            // round half-away-from-zero. If scale < 2, multiply up.
17648            let cents = if scale == 2 {
17649                scaled
17650            } else if scale < 2 {
17651                let mult = 10_i128.pow(u32::from(2 - scale));
17652                scaled.saturating_mul(mult)
17653            } else {
17654                let div = 10_i128.pow(u32::from(scale - 2));
17655                let half = div / 2;
17656                let bias = if scaled >= 0 { half } else { -half };
17657                (scaled + bias) / div
17658            };
17659            Some(Value::Money(i64::try_from(cents).unwrap_or(i64::MAX)))
17660        }
17661        // MONEY → Text canonical `$N,NNN.CC`.
17662        (Value::Money(c), DataType::Text) => Some(Value::Text(eval::format_money(c))),
17663        // v7.17.0 Phase 3.P0-38 — Text → Range. Accepts canonical
17664        // PG forms: `'empty'`, `'[a,b)'`, `'(a,b]'`, `'[a,b]'`,
17665        // `'(a,b)'`, with empty lower or upper for unbounded.
17666        (Value::Text(s), DataType::Range(kind)) => match parse_range_str(&s, kind) {
17667            Some(v) => Some(v),
17668            None => {
17669                return Err(EngineError::Eval(EvalError::TypeMismatch {
17670                    detail: alloc::format!(
17671                        "invalid input syntax for range type: {s:?} (column `{col_name}`)"
17672                    ),
17673                }));
17674            }
17675        },
17676        // Range → Text canonical form (`[a,b)`, `'empty'`, etc).
17677        (v @ Value::Range { .. }, DataType::Text) => Some(Value::Text(format_range_str(&v))),
17678        // v7.17.0 Phase 3.P0-39 — Text → Hstore.
17679        (Value::Text(s), DataType::Hstore) => match parse_hstore_str(&s) {
17680            Some(pairs) => Some(Value::Hstore(pairs)),
17681            None => {
17682                return Err(EngineError::Eval(EvalError::TypeMismatch {
17683                    detail: alloc::format!(
17684                        "invalid input syntax for type hstore: {s:?} (column `{col_name}`)"
17685                    ),
17686                }));
17687            }
17688        },
17689        // Hstore → Text canonical `"k"=>"v"` form.
17690        (Value::Hstore(pairs), DataType::Text) => Some(Value::Text(format_hstore_str(&pairs))),
17691        // v7.17.0 Phase 3.P0-40 — Text → 2D arrays via PG
17692        // external `'{{a,b},{c,d}}'` literal.
17693        (Value::Text(s), DataType::IntArray2D) => match parse_int_2d_literal(&s) {
17694            Ok(m) => Some(Value::IntArray2D(m)),
17695            Err(e) => {
17696                return Err(EngineError::Eval(EvalError::TypeMismatch {
17697                    detail: alloc::format!(
17698                        "invalid input syntax for INT[][]: {s:?} (column `{col_name}`): {e}"
17699                    ),
17700                }));
17701            }
17702        },
17703        (Value::Text(s), DataType::BigIntArray2D) => match parse_bigint_2d_literal(&s) {
17704            Ok(m) => Some(Value::BigIntArray2D(m)),
17705            Err(e) => {
17706                return Err(EngineError::Eval(EvalError::TypeMismatch {
17707                    detail: alloc::format!(
17708                        "invalid input syntax for BIGINT[][]: {s:?} (column `{col_name}`): {e}"
17709                    ),
17710                }));
17711            }
17712        },
17713        (Value::Text(s), DataType::TextArray2D) => match parse_text_2d_literal(&s) {
17714            Ok(m) => Some(Value::TextArray2D(m)),
17715            Err(e) => {
17716                return Err(EngineError::Eval(EvalError::TypeMismatch {
17717                    detail: alloc::format!(
17718                        "invalid input syntax for TEXT[][]: {s:?} (column `{col_name}`): {e}"
17719                    ),
17720                }));
17721            }
17722        },
17723        // 2D arrays → Text canonical nested form.
17724        (Value::IntArray2D(rows), DataType::Text) => Some(Value::Text(format_int_2d_text(&rows))),
17725        (Value::BigIntArray2D(rows), DataType::Text) => {
17726            Some(Value::Text(format_bigint_2d_text(&rows)))
17727        }
17728        (Value::TextArray2D(rows), DataType::Text) => Some(Value::Text(format_text_2d_text(&rows))),
17729        // v7.10.11 — Text → TEXT[]. Decode PG's external array
17730        // form `'{a,b,NULL}'`. NULL element token (case-insensitive)
17731        // is the literal `NULL`; everything else is a quoted or
17732        // unquoted text element. mailrs `'{label1,label2}'::TEXT[]`.
17733        (Value::Text(s), DataType::TextArray) => {
17734            let arr = decode_text_array_literal(&s).map_err(|e| {
17735                EngineError::Eval(EvalError::TypeMismatch {
17736                    detail: alloc::format!(
17737                        "cannot parse {s:?} as TEXT[] for column `{col_name}`: {e}"
17738                    ),
17739                })
17740            })?;
17741            Some(Value::TextArray(arr))
17742        }
17743        // v7.16.0 — Text → IntArray / BigIntArray for the
17744        // spg-sqlx Bind path. Decode the PG external form
17745        // `{1,2,3}` as a TEXT array first, then parse each
17746        // element as int. Same shape as the TextArray decode
17747        // above with an element-wise narrow.
17748        (Value::Text(s), DataType::IntArray) => {
17749            let arr = decode_text_array_literal(&s).map_err(|e| {
17750                EngineError::Eval(EvalError::TypeMismatch {
17751                    detail: alloc::format!(
17752                        "cannot parse {s:?} as INT[] for column `{col_name}`: {e}"
17753                    ),
17754                })
17755            })?;
17756            let mut out: Vec<Option<i32>> = Vec::with_capacity(arr.len());
17757            for elem in arr {
17758                match elem {
17759                    None => out.push(None),
17760                    Some(t) => {
17761                        let n: i32 = t.parse().map_err(|_| {
17762                            EngineError::Eval(EvalError::TypeMismatch {
17763                                detail: alloc::format!(
17764                                    "cannot parse {t:?} as INT element for `{col_name}`"
17765                                ),
17766                            })
17767                        })?;
17768                        out.push(Some(n));
17769                    }
17770                }
17771            }
17772            Some(Value::IntArray(out))
17773        }
17774        (Value::Text(s), DataType::BigIntArray) => {
17775            let arr = decode_text_array_literal(&s).map_err(|e| {
17776                EngineError::Eval(EvalError::TypeMismatch {
17777                    detail: alloc::format!(
17778                        "cannot parse {s:?} as BIGINT[] for column `{col_name}`: {e}"
17779                    ),
17780                })
17781            })?;
17782            let mut out: Vec<Option<i64>> = Vec::with_capacity(arr.len());
17783            for elem in arr {
17784                match elem {
17785                    None => out.push(None),
17786                    Some(t) => {
17787                        let n: i64 = t.parse().map_err(|_| {
17788                            EngineError::Eval(EvalError::TypeMismatch {
17789                                detail: alloc::format!(
17790                                    "cannot parse {t:?} as BIGINT element for `{col_name}`"
17791                                ),
17792                            })
17793                        })?;
17794                        out.push(Some(n));
17795                    }
17796                }
17797            }
17798            Some(Value::BigIntArray(out))
17799        }
17800        // v7.10.11 — TEXT[] → Text round-trip uses PG's
17801        // external array form (`{a,b,NULL}`). Lets a SELECT
17802        // pull an array column through any Text-side codepath.
17803        (Value::TextArray(items), DataType::Text) => Some(Value::Text(encode_text_array(&items))),
17804        // v7.17.0 Phase 3.P0-68 — Text → VECTOR auto-coerce.
17805        // Matches the existing Text → TsVector arm and the
17806        // `::vector` cast: PG-canonical pgvector external form
17807        // (`'[1, 2, -3]'`) becomes a typed Vector value at the
17808        // column boundary. Dim mismatch surfaces as TypeMismatch.
17809        // For SQ8 / HALF encodings we chain through the standard
17810        // quantise helpers so the storage shape matches the
17811        // declared encoding without a second coerce pass.
17812        (Value::Text(s), DataType::Vector { dim, encoding }) => {
17813            let parsed = eval::parse_vector_text(&s).ok_or_else(|| {
17814                EngineError::Eval(EvalError::TypeMismatch {
17815                    detail: alloc::format!("cannot parse {s:?} as VECTOR for column `{col_name}`"),
17816                })
17817            })?;
17818            if parsed.len() != dim as usize {
17819                return Err(EngineError::Eval(EvalError::TypeMismatch {
17820                    detail: alloc::format!(
17821                        "VECTOR({dim}) column `{col_name}` rejects literal of length {}",
17822                        parsed.len()
17823                    ),
17824                }));
17825            }
17826            Some(match encoding {
17827                VecEncoding::F32 => Value::Vector(parsed),
17828                VecEncoding::Sq8 => Value::Sq8Vector(spg_storage::quantize::quantize(&parsed)),
17829                VecEncoding::F16 => {
17830                    Value::HalfVector(spg_storage::halfvec::HalfVector::from_f32_slice(&parsed))
17831                }
17832            })
17833        }
17834        // v7.16.1 — Text → TSVECTOR auto-coerce for the
17835        // INSERT-side wire path (mailrs round-9 A.2.a). PG
17836        // implicitly promotes the TEXT literal at INSERT into a
17837        // TSVECTOR column; SPG previously rejected with a hard
17838        // type mismatch, blocking 23,276 pg_dump rows into
17839        // `messages.search_vector`. We route through the same
17840        // `decode_tsvector_external` the `::tsvector` cast
17841        // already uses, so PG-canonical forms (`'word'`,
17842        // `'word:1A,2B'`, multi-lexeme, empty `''`) all parse.
17843        (Value::Text(s), DataType::TsVector) => {
17844            let lexs = eval::decode_tsvector_external(&s).map_err(|e| {
17845                EngineError::Eval(EvalError::TypeMismatch {
17846                    detail: alloc::format!(
17847                        "cannot parse {s:?} as TSVECTOR for column `{col_name}`: {e}"
17848                    ),
17849                })
17850            })?;
17851            Some(Value::TsVector(lexs))
17852        }
17853        (Value::Text(s), DataType::Timestamp | DataType::Timestamptz) => {
17854            let t = eval::parse_timestamp_literal(&s).ok_or_else(|| {
17855                EngineError::Eval(EvalError::TypeMismatch {
17856                    detail: alloc::format!(
17857                        "cannot parse {s:?} as TIMESTAMP for column `{col_name}`"
17858                    ),
17859                })
17860            })?;
17861            Some(Value::Timestamp(t))
17862        }
17863        // DATE ↔ TIMESTAMP convertibility (DATE → midnight,
17864        // TIMESTAMP → day truncation).
17865        (Value::Date(d), DataType::Timestamp | DataType::Timestamptz) => {
17866            Some(Value::Timestamp(i64::from(d) * 86_400_000_000))
17867        }
17868        // v7.9.21 — Value::Timestamp lands in either Timestamp
17869        // or Timestamptz columns; the on-disk layout is the
17870        // same i64 microseconds UTC.
17871        (Value::Timestamp(t), DataType::Timestamptz) => Some(Value::Timestamp(t)),
17872        (Value::Timestamp(t), DataType::Date) => {
17873            let days = t.div_euclid(86_400_000_000);
17874            i32::try_from(days).ok().map(Value::Date)
17875        }
17876        (
17877            Value::Numeric {
17878                scaled,
17879                scale: src_scale,
17880            },
17881            DataType::Numeric { precision, scale },
17882        ) => Some(numeric_rescale(
17883            scaled, src_scale, precision, scale, col_name,
17884        )?),
17885        #[allow(clippy::cast_precision_loss)]
17886        (Value::Numeric { scaled, scale }, DataType::Float) => {
17887            let mut div = 1.0_f64;
17888            for _ in 0..scale {
17889                div *= 10.0;
17890            }
17891            Some(Value::Float((scaled as f64) / div))
17892        }
17893        (Value::Numeric { scaled, scale }, DataType::Int) => {
17894            let truncated = numeric_truncate_to_integer(scaled, scale);
17895            i32::try_from(truncated).ok().map(Value::Int)
17896        }
17897        (Value::Numeric { scaled, scale }, DataType::BigInt) => {
17898            let truncated = numeric_truncate_to_integer(scaled, scale);
17899            i64::try_from(truncated).ok().map(Value::BigInt)
17900        }
17901        (Value::Numeric { scaled, scale }, DataType::SmallInt) => {
17902            let truncated = numeric_truncate_to_integer(scaled, scale);
17903            i16::try_from(truncated).ok().map(Value::SmallInt)
17904        }
17905        // VARCHAR(n) enforces an upper bound on character count.
17906        (Value::Text(s), DataType::Varchar(max)) => {
17907            if u32::try_from(s.chars().count()).unwrap_or(u32::MAX) <= max {
17908                Some(Value::Text(s))
17909            } else {
17910                return Err(EngineError::Unsupported(alloc::format!(
17911                    "value for VARCHAR({max}) column `{col_name}` exceeds length: \
17912                     {} chars",
17913                    s.chars().count()
17914                )));
17915            }
17916        }
17917        // v6.0.1: f32 → SQ8 INSERT-time quantisation. Triggered
17918        // when the column declares `VECTOR(N) USING SQ8` and
17919        // the INSERT VALUES expression yields a raw f32 vector
17920        // (the normal pgvector-shape literal). Dim mismatch
17921        // falls through the `_ => None` arm and surfaces as
17922        // `TypeMismatch` with the expected SQ8 column type —
17923        // matching the F32 path's existing error.
17924        (
17925            Value::Vector(v),
17926            DataType::Vector {
17927                dim,
17928                encoding: VecEncoding::Sq8,
17929            },
17930        ) if v.len() == dim as usize => Some(Value::Sq8Vector(spg_storage::quantize::quantize(&v))),
17931        // v6.0.3: f32 → f16 INSERT-time conversion for HALF
17932        // columns. Bit-exact at the storage layer (modulo
17933        // half-precision rounding); no rerank pass needed at
17934        // search time.
17935        (
17936            Value::Vector(v),
17937            DataType::Vector {
17938                dim,
17939                encoding: VecEncoding::F16,
17940            },
17941        ) if v.len() == dim as usize => Some(Value::HalfVector(
17942            spg_storage::halfvec::HalfVector::from_f32_slice(&v),
17943        )),
17944        // CHAR(n) right-pads with U+0020 to exactly n chars; if the input
17945        // is already longer we reject (PG truncates trailing-space-only;
17946        // staying strict for v1).
17947        (Value::Text(s), DataType::Char(size)) => {
17948            let len = u32::try_from(s.chars().count()).unwrap_or(u32::MAX);
17949            if len > size {
17950                return Err(EngineError::Unsupported(alloc::format!(
17951                    "value for CHAR({size}) column `{col_name}` exceeds length: \
17952                     {len} chars"
17953                )));
17954            }
17955            let need = (size - len) as usize;
17956            let mut padded = s;
17957            padded.reserve(need);
17958            for _ in 0..need {
17959                padded.push(' ');
17960            }
17961            Some(Value::Text(padded))
17962        }
17963        _ => None,
17964    };
17965    coerced.ok_or(EngineError::Storage(StorageError::TypeMismatch {
17966        column: col_name.into(),
17967        expected,
17968        actual,
17969        position,
17970    }))
17971}
17972
17973/// v7.12.4 — render a function arg list into the
17974/// canonical form the storage layer caches as
17975/// [`spg_storage::FunctionDef::args_repr`]. The catalogue uses
17976/// this string for both display + as a coarse signature key
17977/// for the (deferred) overload resolution v7.12.5+ adds.
17978fn render_function_args(args: &[spg_sql::ast::FunctionArg]) -> alloc::string::String {
17979    use core::fmt::Write;
17980    let mut out = alloc::string::String::from("(");
17981    for (i, a) in args.iter().enumerate() {
17982        if i > 0 {
17983            out.push_str(", ");
17984        }
17985        match a.mode {
17986            spg_sql::ast::FunctionArgMode::In => {}
17987            spg_sql::ast::FunctionArgMode::Out => out.push_str("OUT "),
17988            spg_sql::ast::FunctionArgMode::InOut => out.push_str("INOUT "),
17989        }
17990        if let Some(n) = &a.name {
17991            out.push_str(n);
17992            out.push(' ');
17993        }
17994        match &a.ty {
17995            spg_sql::ast::FunctionArgType::Typed(t) => {
17996                let _ = write!(out, "{t}");
17997            }
17998            spg_sql::ast::FunctionArgType::Raw(s) => out.push_str(s),
17999        }
18000    }
18001    out.push(')');
18002    out
18003}
18004
18005/// v7.19 P5 — true iff `expr` is `unnest(arg)` at the top level
18006/// (case-insensitive). Used by `exec_select_cancel`'s
18007/// projection loop to detect Set-Returning-Function rows that
18008/// need per-row expansion. Only the top-level call counts —
18009/// `coalesce(unnest(arr), 'x')` is NOT a SRF row from the
18010/// projection's perspective; it would surface as an "unknown
18011/// function" mismatch downstream, which is what we want
18012/// (multi-SRF / nested SRF is documented carve-out for v7.19).
18013fn is_top_level_unnest(expr: &spg_sql::ast::Expr) -> bool {
18014    match expr {
18015        spg_sql::ast::Expr::FunctionCall { name, args } => {
18016            name.eq_ignore_ascii_case("unnest") && args.len() == 1
18017        }
18018        _ => false,
18019    }
18020}
18021
18022/// v7.19 P5 — extract the array argument out of a top-level
18023/// `unnest(arg)` call. `None` if `expr` isn't a `unnest` call
18024/// of arity 1 (mirrors `is_top_level_unnest`).
18025fn top_level_unnest_arg(expr: &spg_sql::ast::Expr) -> Option<&spg_sql::ast::Expr> {
18026    match expr {
18027        spg_sql::ast::Expr::FunctionCall { name, args }
18028            if name.eq_ignore_ascii_case("unnest") && args.len() == 1 =>
18029        {
18030            Some(&args[0])
18031        }
18032        _ => None,
18033    }
18034}
18035
18036/// v7.19 P5 — turn an array-typed `Value` into the element list
18037/// `unnest()` projection emits. NULL → empty list (PG: `unnest(NULL)
18038/// = (no rows)`). Non-array values fall through to a type-mismatch
18039/// error.
18040fn array_value_to_elements(v: &Value) -> Result<Vec<Value>, EngineError> {
18041    match v {
18042        Value::Null => Ok(Vec::new()),
18043        Value::TextArray(items) => Ok(items
18044            .iter()
18045            .map(|opt| {
18046                opt.as_ref()
18047                    .map(|s| Value::Text(s.clone()))
18048                    .unwrap_or(Value::Null)
18049            })
18050            .collect()),
18051        Value::IntArray(items) => Ok(items
18052            .iter()
18053            .map(|opt| opt.map(Value::Int).unwrap_or(Value::Null))
18054            .collect()),
18055        Value::BigIntArray(items) => Ok(items
18056            .iter()
18057            .map(|opt| opt.map(Value::BigInt).unwrap_or(Value::Null))
18058            .collect()),
18059        other => Err(EngineError::Eval(EvalError::TypeMismatch {
18060            detail: alloc::format!(
18061                "unnest() expects an array argument, got {:?}",
18062                other.data_type()
18063            ),
18064        })),
18065    }
18066}
18067
18068#[cfg(test)]
18069mod tests {
18070    use super::*;
18071    use alloc::vec;
18072
18073    fn unwrap_command_ok(r: &QueryResult) -> usize {
18074        match r {
18075            QueryResult::CommandOk { affected, .. } => *affected,
18076            QueryResult::Rows { .. } => panic!("expected CommandOk, got Rows"),
18077        }
18078    }
18079
18080    #[test]
18081    fn update_seek_positions_engages_on_indexed_eq() {
18082        let mut e = Engine::new();
18083        e.execute("CREATE TABLE b (id INT NOT NULL, v INT NOT NULL)")
18084            .unwrap();
18085        e.execute("CREATE INDEX b_id ON b (id)").unwrap();
18086        for i in 0..100 {
18087            e.execute(&alloc::format!("INSERT INTO b VALUES ({i}, {i})"))
18088                .unwrap();
18089        }
18090        let stmt = spg_sql::parser::parse_statement("UPDATE b SET v = v + 1 WHERE id = 42")
18091            .expect("parse");
18092        let Statement::Update(u) = stmt else {
18093            panic!("expected Update, got {stmt:?}");
18094        };
18095        let w = u.where_.as_ref().expect("where");
18096        let table = e.catalog().get("b").unwrap();
18097        let schema_cols = table.schema().columns.clone();
18098        // step-by-step: each sub-resolution must succeed.
18099        let Expr::Binary { lhs, op, rhs } = w else {
18100            panic!("WHERE not Binary: {w:?}");
18101        };
18102        assert_eq!(*op, BinOp::Eq, "op not Eq");
18103        let pair = resolve_col_literal_pair(lhs, rhs, &schema_cols, "b");
18104        assert!(
18105            pair.is_some(),
18106            "resolve_col_literal_pair None: lhs={lhs:?} rhs={rhs:?}"
18107        );
18108        let (col_pos, value) = pair.unwrap();
18109        assert!(
18110            table.index_on(col_pos).is_some(),
18111            "no index on col {col_pos}"
18112        );
18113        assert!(
18114            IndexKey::from_value(&value).is_some(),
18115            "IndexKey::from_value None for {value:?}"
18116        );
18117        let positions = try_index_seek_positions(w, &schema_cols, table, "b");
18118        assert_eq!(positions, Some(vec![42]), "seek did not engage");
18119    }
18120
18121    #[test]
18122    fn create_table_registers_schema() {
18123        let mut e = Engine::new();
18124        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT)")
18125            .unwrap();
18126        assert_eq!(e.catalog().table_count(), 1);
18127        let t = e.catalog().get("foo").unwrap();
18128        assert_eq!(t.schema().columns.len(), 2);
18129        assert_eq!(t.schema().columns[0].ty, DataType::Int);
18130        assert!(!t.schema().columns[0].nullable);
18131        assert_eq!(t.schema().columns[1].ty, DataType::Text);
18132    }
18133
18134    #[test]
18135    fn create_table_vector_default_is_f32_encoded() {
18136        let mut e = Engine::new();
18137        e.execute("CREATE TABLE t (v VECTOR(8))").unwrap();
18138        let t = e.catalog().get("t").unwrap();
18139        assert_eq!(
18140            t.schema().columns[0].ty,
18141            DataType::Vector {
18142                dim: 8,
18143                encoding: VecEncoding::F32,
18144            },
18145        );
18146    }
18147
18148    #[test]
18149    fn create_table_vector_using_sq8_succeeds() {
18150        // v6.0.1 step 3: the step-1 fence in `column_def_to_schema`
18151        // is lifted. CREATE TABLE persists an SQ8 column type in
18152        // the catalog; INSERT (next test) quantises raw f32 input.
18153        let mut e = Engine::new();
18154        e.execute("CREATE TABLE t (v VECTOR(8) USING SQ8)").unwrap();
18155        let t = e.catalog().get("t").unwrap();
18156        assert_eq!(
18157            t.schema().columns[0].ty,
18158            DataType::Vector {
18159                dim: 8,
18160                encoding: VecEncoding::Sq8,
18161            },
18162        );
18163    }
18164
18165    #[test]
18166    fn insert_into_sq8_column_quantises_f32_payload() {
18167        // v6.0.1 step 3: INSERT-time `coerce_value` rewrites a raw
18168        // `Value::Vector(Vec<f32>)` literal into the column's
18169        // quantised representation. The row that lands in the
18170        // catalog must therefore hold a `Value::Sq8Vector`, not the
18171        // original f32 buffer — that's the bit that delivers the
18172        // 4× compression target.
18173        let mut e = Engine::new();
18174        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
18175        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
18176            .unwrap();
18177        let t = e.catalog().get("t").unwrap();
18178        assert_eq!(t.rows().len(), 1);
18179        match &t.rows()[0].values[0] {
18180            Value::Sq8Vector(q) => {
18181                assert_eq!(q.bytes.len(), 4);
18182                // min/max are derived from the payload: min=0.0, max=1.0.
18183                assert!((q.min - 0.0).abs() < 1e-6);
18184                assert!((q.max - 1.0).abs() < 1e-6);
18185            }
18186            other => panic!("expected Sq8Vector cell, got {other:?}"),
18187        }
18188    }
18189
18190    #[test]
18191    fn create_table_vector_using_half_succeeds_and_insert_converts_to_f16() {
18192        // v6.0.3: CREATE TABLE accepts USING HALF; INSERT path
18193        // converts the incoming `Value::Vector(Vec<f32>)` cell
18194        // into `Value::HalfVector(HalfVector)` via the new
18195        // `coerce_value` arm. The dequantised round-trip is
18196        // bit-exact for f16-representable values, so 0.0 / 0.25
18197        // / 0.5 / 1.0 hit their grid points exactly.
18198        let mut e = Engine::new();
18199        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
18200            .unwrap();
18201        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
18202            .unwrap();
18203        let t = e.catalog().get("t").unwrap();
18204        assert_eq!(t.rows().len(), 1);
18205        match &t.rows()[0].values[0] {
18206            Value::HalfVector(h) => {
18207                assert_eq!(h.dim(), 4);
18208                let back = h.to_f32_vec();
18209                let expected = alloc::vec![0.0_f32, 0.25, 0.5, 1.0];
18210                for (g, e) in back.iter().zip(expected.iter()) {
18211                    assert!(
18212                        (g - e).abs() < 1e-6,
18213                        "{g} vs {e} should be exact on f16 grid"
18214                    );
18215                }
18216            }
18217            other => panic!("expected HalfVector cell, got {other:?}"),
18218        }
18219    }
18220
18221    #[test]
18222    fn alter_index_rebuild_in_place_succeeds() {
18223        // v6.0.4: bare REBUILD (no encoding switch) walks every
18224        // row again to rebuild the NSW graph. Verifies the engine
18225        // dispatch + storage helper plumbing without changing any
18226        // cell encoding.
18227        let mut e = Engine::new();
18228        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
18229            .unwrap();
18230        for i in 0..8_i32 {
18231            #[allow(clippy::cast_precision_loss)]
18232            let base = (i as f32) * 0.1;
18233            e.execute(&alloc::format!(
18234                "INSERT INTO t VALUES ({i}, [{base}, {b1}, {b2}])",
18235                b1 = base + 0.01,
18236                b2 = base + 0.02,
18237            ))
18238            .unwrap();
18239        }
18240        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
18241        e.execute("ALTER INDEX t_idx REBUILD").unwrap();
18242        // Schema encoding stays F32 (no encoding clause).
18243        assert_eq!(
18244            e.catalog().get("t").unwrap().schema().columns[1].ty,
18245            DataType::Vector {
18246                dim: 3,
18247                encoding: VecEncoding::F32,
18248            },
18249        );
18250    }
18251
18252    #[test]
18253    fn alter_index_rebuild_with_encoding_switches_cell_type() {
18254        // v6.0.4: REBUILD WITH (encoding = SQ8) recodes every
18255        // stored cell from F32 → SQ8 + rebuilds the graph atop the
18256        // new encoding. Post-rebuild, cells must be Sq8Vector and
18257        // the schema must report encoding = Sq8.
18258        let mut e = Engine::new();
18259        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(4) NOT NULL)")
18260            .unwrap();
18261        e.execute("INSERT INTO t VALUES (1, [0.0, 0.25, 0.5, 1.0])")
18262            .unwrap();
18263        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
18264        e.execute("ALTER INDEX t_idx REBUILD WITH (encoding = SQ8)")
18265            .unwrap();
18266        let t = e.catalog().get("t").unwrap();
18267        assert_eq!(
18268            t.schema().columns[1].ty,
18269            DataType::Vector {
18270                dim: 4,
18271                encoding: VecEncoding::Sq8,
18272            },
18273        );
18274        assert!(matches!(t.rows()[0].values[1], Value::Sq8Vector(_)));
18275    }
18276
18277    #[test]
18278    fn alter_index_rebuild_unknown_index_errors() {
18279        let mut e = Engine::new();
18280        let err = e.execute("ALTER INDEX nope REBUILD").unwrap_err();
18281        assert!(
18282            matches!(
18283                &err,
18284                EngineError::Storage(StorageError::IndexNotFound { name }) if name == "nope"
18285            ),
18286            "got: {err}"
18287        );
18288    }
18289
18290    #[test]
18291    fn alter_index_rebuild_on_btree_index_errors() {
18292        // REBUILD on a B-tree index has no semantic meaning in
18293        // v6.0.4 — rejected at the storage layer with `Unsupported`.
18294        let mut e = Engine::new();
18295        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18296        e.execute("INSERT INTO t VALUES (1)").unwrap();
18297        e.execute("CREATE INDEX t_idx ON t (id)").unwrap();
18298        let err = e.execute("ALTER INDEX t_idx REBUILD").unwrap_err();
18299        assert!(
18300            matches!(&err, EngineError::Storage(StorageError::Unsupported(_))),
18301            "got: {err}"
18302        );
18303    }
18304
18305    #[test]
18306    fn prepared_insert_substitutes_placeholders() {
18307        // v6.1.1: prepare() parses once; execute_prepared() walks the
18308        // AST and replaces $1/$2 with the param Values BEFORE the
18309        // dispatch sees them. Same logical result as a simple-query
18310        // INSERT, but parse happens once per *statement*, not per
18311        // execution.
18312        let mut e = Engine::new();
18313        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
18314            .unwrap();
18315        let stmt = e.prepare("INSERT INTO t VALUES ($1, $2)").unwrap();
18316        for (id, name) in [(1, "alice"), (2, "bob"), (3, "carol")] {
18317            e.execute_prepared(stmt.clone(), &[Value::Int(id), Value::Text(name.into())])
18318                .unwrap();
18319        }
18320        // Read back via simple-query SELECT.
18321        let rows_result = e.execute("SELECT id, name FROM t").unwrap();
18322        let QueryResult::Rows { rows, .. } = rows_result else {
18323            panic!("expected Rows")
18324        };
18325        assert_eq!(rows.len(), 3);
18326    }
18327
18328    #[test]
18329    fn prepared_select_with_placeholder_filters_rows() {
18330        let mut e = Engine::new();
18331        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
18332            .unwrap();
18333        for i in 0..10_i32 {
18334            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
18335                .unwrap();
18336        }
18337        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
18338        let QueryResult::Rows { rows, .. } = e.execute_prepared(stmt, &[Value::Int(35)]).unwrap()
18339        else {
18340            panic!("expected Rows")
18341        };
18342        // v = 35 means i*7 = 35 → i = 5.
18343        assert_eq!(rows.len(), 1);
18344        assert_eq!(rows[0].values[0], Value::Int(5));
18345    }
18346
18347    #[test]
18348    fn prepared_too_few_params_errors() {
18349        let mut e = Engine::new();
18350        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18351        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
18352        let err = e.execute_prepared(stmt, &[]).unwrap_err();
18353        assert!(
18354            matches!(
18355                &err,
18356                EngineError::Eval(EvalError::PlaceholderOutOfRange { n: 1, bound: 0 })
18357            ),
18358            "got: {err}"
18359        );
18360    }
18361
18362    #[test]
18363    fn bytea_cast_round_trips_text_input() {
18364        // v7.18 — `'hello'::bytea` produces the raw bytes. Closes
18365        // the mailrs D-pre #3 reverse-acceptance gap.
18366        let e = Engine::new();
18367        let r = e.execute_readonly("SELECT 'hello'::bytea").unwrap();
18368        let QueryResult::Rows { rows, .. } = r else {
18369            panic!("expected Rows")
18370        };
18371        assert_eq!(rows.len(), 1);
18372        assert_eq!(rows[0].values[0], Value::Bytes(b"hello".to_vec()));
18373    }
18374
18375    #[test]
18376    fn bytea_cast_pg_escape_hex_form() {
18377        // E'\\xdeadbeef'::bytea — E-string decodes to `\xdeadbeef`
18378        // (literal 10 chars), then ::bytea reads it as PG hex
18379        // form bytea literal → 4 bytes.
18380        let e = Engine::new();
18381        let r = e.execute_readonly(r"SELECT E'\\xdeadbeef'::bytea").unwrap();
18382        let QueryResult::Rows { rows, .. } = r else {
18383            panic!("expected Rows")
18384        };
18385        assert_eq!(
18386            rows[0].values[0],
18387            Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef])
18388        );
18389    }
18390
18391    #[test]
18392    fn bytea_cast_chains_through_octet_length() {
18393        // octet_length('hello'::bytea) → 5. Confirms the cast
18394        // composes inside larger expressions, not just at top
18395        // level.
18396        let e = Engine::new();
18397        let r = e
18398            .execute_readonly("SELECT octet_length('hello'::bytea)")
18399            .unwrap();
18400        let QueryResult::Rows { rows, .. } = r else {
18401            panic!("expected Rows")
18402        };
18403        match &rows[0].values[0] {
18404            Value::Int(n) => assert_eq!(*n, 5),
18405            Value::BigInt(n) => assert_eq!(*n, 5),
18406            other => panic!("expected integer length, got {other:?}"),
18407        }
18408    }
18409
18410    #[test]
18411    fn readonly_prepared_on_snapshot_select_with_placeholder() {
18412        // v7.18 — sqlx Pool fan-out relies on running prepared
18413        // SELECTs against a frozen snapshot without re-entering
18414        // the writer engine. Mirrors the simple-query SELECT path
18415        // in `execute_readonly_on_snapshot` but takes a Statement
18416        // + bound params (the shape sqlx's Execute path produces).
18417        let mut e = Engine::new();
18418        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
18419            .unwrap();
18420        for i in 0..10_i32 {
18421            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
18422                .unwrap();
18423        }
18424        let snapshot = e.clone_snapshot();
18425        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
18426        let QueryResult::Rows { rows, .. } =
18427            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(35)])
18428                .unwrap()
18429        else {
18430            panic!("expected Rows")
18431        };
18432        assert_eq!(rows.len(), 1);
18433        assert_eq!(rows[0].values[0], Value::Int(5));
18434    }
18435
18436    #[test]
18437    fn readonly_prepared_on_snapshot_rejects_writes() {
18438        // DDL / DML prepared statements on the readonly path must
18439        // surface `WriteRequired` so the spg-sqlx connection layer
18440        // routes them to the writer mutex instead of the snapshot.
18441        let mut e = Engine::new();
18442        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18443        let snapshot = e.clone_snapshot();
18444        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
18445        let err = Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(1)])
18446            .unwrap_err();
18447        assert!(matches!(&err, EngineError::WriteRequired), "got: {err}");
18448    }
18449
18450    #[test]
18451    fn readonly_prepared_on_snapshot_frozen_view() {
18452        // The snapshot reflects engine state at clone_snapshot()
18453        // time. Writes after the snapshot are NOT visible — caller
18454        // takes a fresh snapshot (or `AsyncReadHandle::refresh()`)
18455        // to see them. This is the contract the per-statement
18456        // refresh in spg-sqlx relies on.
18457        let mut e = Engine::new();
18458        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18459        e.execute("INSERT INTO t VALUES (1)").unwrap();
18460        let snapshot = e.clone_snapshot();
18461        e.execute("INSERT INTO t VALUES (2)").unwrap();
18462        let stmt = e.prepare("SELECT id FROM t WHERE id = $1").unwrap();
18463        let QueryResult::Rows { rows, .. } =
18464            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(2)])
18465                .unwrap()
18466        else {
18467            panic!("expected Rows")
18468        };
18469        assert!(rows.is_empty(), "id=2 was inserted after snapshot");
18470    }
18471
18472    #[test]
18473    fn describe_prepared_on_snapshot_resolves_columns() {
18474        // v7.18 — sqlx's Executor::describe path on the readonly
18475        // fan-out needs to resolve column names + types against
18476        // the snapshot's catalog (not the live engine's catalog,
18477        // which may have moved on).
18478        let mut e = Engine::new();
18479        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
18480            .unwrap();
18481        let snapshot = e.clone_snapshot();
18482        let stmt = e.prepare("SELECT id, name FROM t WHERE id = $1").unwrap();
18483        let (_params, cols) = Engine::describe_prepared_on_snapshot(&snapshot, &stmt);
18484        assert_eq!(cols.len(), 2);
18485        assert_eq!(cols[0].name, "id");
18486        assert_eq!(cols[0].ty, DataType::Int);
18487        assert_eq!(cols[1].name, "name");
18488        assert_eq!(cols[1].ty, DataType::Text);
18489    }
18490
18491    #[test]
18492    fn insert_into_half_column_dim_mismatch_errors() {
18493        let mut e = Engine::new();
18494        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
18495            .unwrap();
18496        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
18497        assert!(matches!(
18498            &err,
18499            EngineError::Storage(StorageError::TypeMismatch { .. })
18500        ));
18501    }
18502
18503    #[test]
18504    fn insert_into_sq8_column_dim_mismatch_errors() {
18505        // Dim mismatch falls through the `coerce_value` Vector→Sq8
18506        // arm's guard and surfaces as `TypeMismatch` — the same
18507        // error the F32 path produces today, so client error
18508        // handling stays uniform across encodings.
18509        let mut e = Engine::new();
18510        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
18511        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
18512        assert!(
18513            matches!(
18514                &err,
18515                EngineError::Storage(StorageError::TypeMismatch { .. })
18516            ),
18517            "got: {err}",
18518        );
18519    }
18520
18521    #[test]
18522    fn create_table_duplicate_errors() {
18523        let mut e = Engine::new();
18524        e.execute("CREATE TABLE foo (a INT)").unwrap();
18525        let err = e.execute("CREATE TABLE foo (a INT)").unwrap_err();
18526        assert!(matches!(
18527            err,
18528            EngineError::Storage(StorageError::DuplicateTable { ref name }) if name == "foo"
18529        ));
18530    }
18531
18532    #[test]
18533    fn insert_into_unknown_table_errors() {
18534        let mut e = Engine::new();
18535        let err = e.execute("INSERT INTO ghost VALUES (1)").unwrap_err();
18536        assert!(matches!(
18537            err,
18538            EngineError::Storage(StorageError::TableNotFound { ref name }) if name == "ghost"
18539        ));
18540    }
18541
18542    #[test]
18543    fn insert_happy_path_reports_one_affected() {
18544        let mut e = Engine::new();
18545        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18546        let r = e.execute("INSERT INTO foo VALUES (42)").unwrap();
18547        assert_eq!(unwrap_command_ok(&r), 1);
18548        assert_eq!(e.catalog().get("foo").unwrap().row_count(), 1);
18549    }
18550
18551    #[test]
18552    fn insert_arity_mismatch_propagates() {
18553        let mut e = Engine::new();
18554        e.execute("CREATE TABLE foo (a INT, b TEXT)").unwrap();
18555        let err = e.execute("INSERT INTO foo VALUES (1)").unwrap_err();
18556        assert!(matches!(
18557            err,
18558            EngineError::Storage(StorageError::ArityMismatch { .. })
18559        ));
18560    }
18561
18562    #[test]
18563    fn insert_negative_integer_via_unary_minus() {
18564        let mut e = Engine::new();
18565        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18566        e.execute("INSERT INTO foo VALUES (-7)").unwrap();
18567        let rows = e.catalog().get("foo").unwrap().rows();
18568        assert_eq!(rows[0].values[0], Value::Int(-7));
18569    }
18570
18571    #[test]
18572    fn insert_expression_evaluated_against_empty_context() {
18573        // PG-canonical: INSERT VALUES accepts an arbitrary scalar
18574        // expression. The engine evaluates against an empty row
18575        // context — column references would error, but pure
18576        // arithmetic / function calls are fine.
18577        let mut e = Engine::new();
18578        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18579        e.execute("INSERT INTO foo VALUES (1 + 2)").unwrap();
18580        let rows = e.catalog().get("foo").unwrap().rows();
18581        assert_eq!(rows[0].values[0], Value::Int(3));
18582    }
18583
18584    #[test]
18585    fn select_star_returns_all_rows_in_insertion_order() {
18586        let mut e = Engine::new();
18587        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT NOT NULL)")
18588            .unwrap();
18589        e.execute("INSERT INTO foo VALUES (1, 'one')").unwrap();
18590        e.execute("INSERT INTO foo VALUES (2, 'two')").unwrap();
18591        e.execute("INSERT INTO foo VALUES (3, 'three')").unwrap();
18592
18593        let r = e.execute("SELECT * FROM foo").unwrap();
18594        let QueryResult::Rows { columns, rows } = r else {
18595            panic!("expected Rows")
18596        };
18597        assert_eq!(columns.len(), 2);
18598        assert_eq!(columns[0].name, "a");
18599        assert_eq!(rows.len(), 3);
18600        assert_eq!(
18601            rows[1].values,
18602            vec![Value::Int(2), Value::Text("two".into())]
18603        );
18604    }
18605
18606    #[test]
18607    fn select_star_on_empty_table_returns_zero_rows() {
18608        let mut e = Engine::new();
18609        e.execute("CREATE TABLE foo (a INT)").unwrap();
18610        let r = e.execute("SELECT * FROM foo").unwrap();
18611        match r {
18612            QueryResult::Rows { rows, .. } => assert!(rows.is_empty()),
18613            QueryResult::CommandOk { .. } => panic!("expected Rows"),
18614        }
18615    }
18616
18617    // --- v0.4: WHERE + projection ------------------------------------------
18618
18619    fn make_three_row_users(e: &mut Engine) {
18620        e.execute("CREATE TABLE users (id INT NOT NULL, name TEXT NOT NULL, score INT)")
18621            .unwrap();
18622        e.execute("INSERT INTO users VALUES (1, 'alice', 90)")
18623            .unwrap();
18624        e.execute("INSERT INTO users VALUES (2, 'bob', NULL)")
18625            .unwrap();
18626        e.execute("INSERT INTO users VALUES (3, 'cara', 70)")
18627            .unwrap();
18628    }
18629
18630    fn unwrap_rows(r: QueryResult) -> (Vec<ColumnSchema>, Vec<Row>) {
18631        match r {
18632            QueryResult::Rows { columns, rows } => (columns, rows),
18633            QueryResult::CommandOk { .. } => panic!("expected Rows"),
18634        }
18635    }
18636
18637    #[test]
18638    fn where_filter_passes_only_true_rows() {
18639        let mut e = Engine::new();
18640        make_three_row_users(&mut e);
18641        let r = e.execute("SELECT * FROM users WHERE id > 1").unwrap();
18642        let (_, rows) = unwrap_rows(r);
18643        assert_eq!(rows.len(), 2);
18644        assert_eq!(rows[0].values[0], Value::Int(2));
18645        assert_eq!(rows[1].values[0], Value::Int(3));
18646    }
18647
18648    #[test]
18649    fn where_with_null_result_filters_out_row() {
18650        let mut e = Engine::new();
18651        make_three_row_users(&mut e);
18652        // score is NULL for bob → score > 80 is NULL → row excluded
18653        let r = e.execute("SELECT * FROM users WHERE score > 80").unwrap();
18654        let (_, rows) = unwrap_rows(r);
18655        assert_eq!(rows.len(), 1);
18656        assert_eq!(rows[0].values[1], Value::Text("alice".into()));
18657    }
18658
18659    #[test]
18660    fn projection_named_columns() {
18661        let mut e = Engine::new();
18662        make_three_row_users(&mut e);
18663        let r = e.execute("SELECT name, score FROM users").unwrap();
18664        let (cols, rows) = unwrap_rows(r);
18665        assert_eq!(cols.len(), 2);
18666        assert_eq!(cols[0].name, "name");
18667        assert_eq!(cols[1].name, "score");
18668        assert_eq!(rows.len(), 3);
18669        assert_eq!(
18670            rows[0].values,
18671            vec![Value::Text("alice".into()), Value::Int(90)]
18672        );
18673    }
18674
18675    #[test]
18676    fn projection_with_column_alias() {
18677        let mut e = Engine::new();
18678        make_three_row_users(&mut e);
18679        let r = e
18680            .execute("SELECT name AS who FROM users WHERE id = 1")
18681            .unwrap();
18682        let (cols, rows) = unwrap_rows(r);
18683        assert_eq!(cols[0].name, "who");
18684        assert_eq!(rows.len(), 1);
18685        assert_eq!(rows[0].values[0], Value::Text("alice".into()));
18686    }
18687
18688    #[test]
18689    fn qualified_column_with_table_alias_resolves() {
18690        let mut e = Engine::new();
18691        make_three_row_users(&mut e);
18692        let r = e
18693            .execute("SELECT u.id, u.name FROM users AS u WHERE u.id < 3")
18694            .unwrap();
18695        let (cols, rows) = unwrap_rows(r);
18696        assert_eq!(cols.len(), 2);
18697        assert_eq!(rows.len(), 2);
18698    }
18699
18700    #[test]
18701    fn qualified_column_with_wrong_alias_errors() {
18702        let mut e = Engine::new();
18703        make_three_row_users(&mut e);
18704        let err = e.execute("SELECT x.id FROM users AS u").unwrap_err();
18705        assert!(matches!(
18706            err,
18707            EngineError::Eval(EvalError::UnknownQualifier { ref qualifier }) if qualifier == "x"
18708        ));
18709    }
18710
18711    #[test]
18712    fn select_unknown_column_errors_in_projection() {
18713        let mut e = Engine::new();
18714        make_three_row_users(&mut e);
18715        let err = e.execute("SELECT ghost FROM users").unwrap_err();
18716        assert!(matches!(
18717            err,
18718            EngineError::Eval(EvalError::ColumnNotFound { ref name }) if name == "ghost"
18719        ));
18720    }
18721
18722    #[test]
18723    fn where_unknown_column_errors() {
18724        let mut e = Engine::new();
18725        make_three_row_users(&mut e);
18726        let err = e
18727            .execute("SELECT * FROM users WHERE ghost = 1")
18728            .unwrap_err();
18729        assert!(matches!(
18730            err,
18731            EngineError::Eval(EvalError::ColumnNotFound { .. })
18732        ));
18733    }
18734
18735    #[test]
18736    fn expression_projection_evaluates_and_renders() {
18737        // Compound expressions in the SELECT list are evaluated per row;
18738        // the output column is typed TEXT, name defaults to the expression.
18739        let mut e = Engine::new();
18740        e.execute("CREATE TABLE t (a INT NOT NULL)").unwrap();
18741        e.execute("INSERT INTO t VALUES (3)").unwrap();
18742        let (_, rows) = unwrap_rows(e.execute("SELECT 1 + 2 FROM t").unwrap());
18743        assert_eq!(rows.len(), 1);
18744        // The expression evaluates to integer 3; rendered as the cell value
18745        // (storage::Value::Int(3) since arithmetic kept ints).
18746        assert_eq!(rows[0].values[0], Value::Int(3));
18747    }
18748
18749    #[test]
18750    fn select_unknown_table_errors() {
18751        let mut e = Engine::new();
18752        let err = e.execute("SELECT * FROM ghost").unwrap_err();
18753        assert!(matches!(
18754            err,
18755            EngineError::Storage(StorageError::TableNotFound { .. })
18756        ));
18757    }
18758
18759    #[test]
18760    fn invalid_sql_returns_parse_error() {
18761        // v4.4: UPDATE is now real SQL, so use a true syntactic
18762        // garbage payload for the parse-error path.
18763        let mut e = Engine::new();
18764        let err = e.execute("THIS_IS_NOT_A_KEYWORD foo bar baz").unwrap_err();
18765        assert!(matches!(err, EngineError::Parse(_)));
18766    }
18767
18768    // --- v0.8 CREATE INDEX + index seek ------------------------------------
18769
18770    #[test]
18771    fn create_index_registers_on_table() {
18772        let mut e = Engine::new();
18773        make_three_row_users(&mut e);
18774        e.execute("CREATE INDEX by_name ON users (name)").unwrap();
18775        let t = e.catalog().get("users").unwrap();
18776        assert_eq!(t.indices().len(), 1);
18777        assert_eq!(t.indices()[0].name, "by_name");
18778    }
18779
18780    #[test]
18781    fn create_index_on_unknown_table_errors() {
18782        let mut e = Engine::new();
18783        let err = e.execute("CREATE INDEX i ON ghost (a)").unwrap_err();
18784        assert!(matches!(
18785            err,
18786            EngineError::Storage(StorageError::TableNotFound { .. })
18787        ));
18788    }
18789
18790    #[test]
18791    fn create_index_on_unknown_column_errors() {
18792        let mut e = Engine::new();
18793        make_three_row_users(&mut e);
18794        let err = e.execute("CREATE INDEX i ON users (ghost)").unwrap_err();
18795        assert!(matches!(
18796            err,
18797            EngineError::Storage(StorageError::ColumnNotFound { .. })
18798        ));
18799    }
18800
18801    #[test]
18802    fn select_eq_uses_index_returns_same_rows_as_scan() {
18803        // Build two engines: one with an index, one without. Same query →
18804        // same row set (index is a planner optimisation, not a semantic
18805        // change).
18806        let mut without = Engine::new();
18807        make_three_row_users(&mut without);
18808        let mut with = Engine::new();
18809        make_three_row_users(&mut with);
18810        with.execute("CREATE INDEX by_id ON users (id)").unwrap();
18811
18812        let q = "SELECT * FROM users WHERE id = 2";
18813        let (_, no_idx_rows) = unwrap_rows(without.execute(q).unwrap());
18814        let (_, idx_rows) = unwrap_rows(with.execute(q).unwrap());
18815        assert_eq!(no_idx_rows, idx_rows);
18816        assert_eq!(idx_rows.len(), 1);
18817    }
18818
18819    #[test]
18820    fn select_eq_with_no_matching_index_value_returns_empty() {
18821        let mut e = Engine::new();
18822        make_three_row_users(&mut e);
18823        e.execute("CREATE INDEX by_id ON users (id)").unwrap();
18824        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM users WHERE id = 999").unwrap());
18825        assert_eq!(rows.len(), 0);
18826    }
18827
18828    // --- v0.9 transactions -------------------------------------------------
18829
18830    #[test]
18831    fn begin_sets_in_transaction_flag() {
18832        let mut e = Engine::new();
18833        assert!(!e.in_transaction());
18834        e.execute("BEGIN").unwrap();
18835        assert!(e.in_transaction());
18836    }
18837
18838    #[test]
18839    fn double_begin_errors() {
18840        let mut e = Engine::new();
18841        e.execute("BEGIN").unwrap();
18842        let err = e.execute("BEGIN").unwrap_err();
18843        assert_eq!(err, EngineError::TransactionAlreadyOpen);
18844    }
18845
18846    #[test]
18847    fn commit_without_begin_errors() {
18848        let mut e = Engine::new();
18849        let err = e.execute("COMMIT").unwrap_err();
18850        assert_eq!(err, EngineError::NoActiveTransaction);
18851    }
18852
18853    #[test]
18854    fn rollback_without_begin_errors() {
18855        let mut e = Engine::new();
18856        let err = e.execute("ROLLBACK").unwrap_err();
18857        assert_eq!(err, EngineError::NoActiveTransaction);
18858    }
18859
18860    #[test]
18861    fn commit_applies_shadow_to_committed_catalog() {
18862        let mut e = Engine::new();
18863        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18864        e.execute("BEGIN").unwrap();
18865        e.execute("INSERT INTO t VALUES (1)").unwrap();
18866        e.execute("INSERT INTO t VALUES (2)").unwrap();
18867        e.execute("COMMIT").unwrap();
18868        assert!(!e.in_transaction());
18869        assert_eq!(e.catalog().get("t").unwrap().row_count(), 2);
18870    }
18871
18872    #[test]
18873    fn rollback_discards_shadow() {
18874        let mut e = Engine::new();
18875        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18876        e.execute("BEGIN").unwrap();
18877        e.execute("INSERT INTO t VALUES (1)").unwrap();
18878        e.execute("INSERT INTO t VALUES (2)").unwrap();
18879        e.execute("ROLLBACK").unwrap();
18880        assert!(!e.in_transaction());
18881        assert_eq!(e.catalog().get("t").unwrap().row_count(), 0);
18882    }
18883
18884    #[test]
18885    fn select_during_tx_sees_uncommitted_writes_own_session() {
18886        // The shadow catalog is read by SELECTs while a TX is open — the
18887        // session can see its own pending writes.
18888        let mut e = Engine::new();
18889        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18890        e.execute("BEGIN").unwrap();
18891        e.execute("INSERT INTO t VALUES (42)").unwrap();
18892        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM t").unwrap());
18893        assert_eq!(rows.len(), 1);
18894        assert_eq!(rows[0].values[0], Value::Int(42));
18895    }
18896
18897    #[test]
18898    fn snapshot_with_no_users_is_bare_catalog_format() {
18899        let mut e = Engine::new();
18900        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18901        let bytes = e.snapshot();
18902        assert_eq!(
18903            &bytes[..8],
18904            b"SPGDB001",
18905            "must be the bare v3.x catalog magic"
18906        );
18907        let e2 = Engine::restore_envelope(&bytes).unwrap();
18908        assert!(e2.users().is_empty());
18909        assert_eq!(e2.catalog().table_count(), 1);
18910    }
18911
18912    #[test]
18913    fn snapshot_with_users_round_trips_both_via_envelope() {
18914        let mut e = Engine::new();
18915        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18916        e.create_user("alice", "pw1", Role::Admin, [9; 16]).unwrap();
18917        e.create_user("bob", "pw2", Role::ReadOnly, [5; 16])
18918            .unwrap();
18919        let bytes = e.snapshot();
18920        assert_eq!(&bytes[..8], b"SPGENV01", "must be the v4.1 envelope magic");
18921        let e2 = Engine::restore_envelope(&bytes).unwrap();
18922        assert_eq!(e2.users().len(), 2);
18923        assert_eq!(e2.verify_user("alice", "pw1"), Some(Role::Admin));
18924        assert_eq!(e2.verify_user("bob", "pw2"), Some(Role::ReadOnly));
18925        assert_eq!(e2.verify_user("alice", "wrong"), None);
18926        assert_eq!(e2.catalog().table_count(), 1);
18927    }
18928
18929    #[test]
18930    fn ddl_inside_tx_also_rolled_back() {
18931        let mut e = Engine::new();
18932        e.execute("BEGIN").unwrap();
18933        e.execute("CREATE TABLE t (v INT)").unwrap();
18934        // Visible inside the TX.
18935        e.execute("SELECT * FROM t").unwrap();
18936        e.execute("ROLLBACK").unwrap();
18937        // Gone after rollback.
18938        let err = e.execute("SELECT * FROM t").unwrap_err();
18939        assert!(matches!(
18940            err,
18941            EngineError::Storage(StorageError::TableNotFound { .. })
18942        ));
18943    }
18944
18945    // ── v6.1.2: CREATE / DROP PUBLICATION (engine-side) ──────
18946
18947    #[test]
18948    fn create_publication_lands_in_catalog() {
18949        let mut e = Engine::new();
18950        assert!(e.publications().is_empty());
18951        e.execute("CREATE PUBLICATION pub_a").unwrap();
18952        assert_eq!(e.publications().len(), 1);
18953        assert!(e.publications().contains("pub_a"));
18954    }
18955
18956    #[test]
18957    fn create_publication_duplicate_errors() {
18958        let mut e = Engine::new();
18959        e.execute("CREATE PUBLICATION pub_a").unwrap();
18960        let err = e.execute("CREATE PUBLICATION pub_a").unwrap_err();
18961        assert!(
18962            alloc::format!("{err:?}").contains("DuplicateName"),
18963            "got {err:?}"
18964        );
18965    }
18966
18967    #[test]
18968    fn drop_publication_silent_when_absent() {
18969        let mut e = Engine::new();
18970        // PG-compatible: DROP a publication that doesn't exist
18971        // succeeds (no-op) but reports zero affected.
18972        let r = e.execute("DROP PUBLICATION nope").unwrap();
18973        match r {
18974            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
18975            other => panic!("expected CommandOk, got {other:?}"),
18976        }
18977    }
18978
18979    #[test]
18980    fn drop_publication_present_reports_one_affected() {
18981        let mut e = Engine::new();
18982        e.execute("CREATE PUBLICATION pub_a").unwrap();
18983        let r = e.execute("DROP PUBLICATION pub_a").unwrap();
18984        match r {
18985            QueryResult::CommandOk {
18986                affected,
18987                modified_catalog,
18988            } => {
18989                assert_eq!(affected, 1);
18990                assert!(modified_catalog);
18991            }
18992            other => panic!("expected CommandOk, got {other:?}"),
18993        }
18994        assert!(e.publications().is_empty());
18995    }
18996
18997    #[test]
18998    fn publications_persist_across_snapshot_restore() {
18999        // The persist-across-restart ship-gate at the engine layer —
19000        // snapshot → restore_envelope round trip must preserve the
19001        // publication catalog. The spg-server e2e covers the
19002        // process-restart variant.
19003        let mut e = Engine::new();
19004        e.execute("CREATE PUBLICATION pub_a").unwrap();
19005        e.execute("CREATE PUBLICATION pub_b FOR ALL TABLES")
19006            .unwrap();
19007        let snap = e.snapshot();
19008        let e2 = Engine::restore_envelope(&snap).unwrap();
19009        assert_eq!(e2.publications().len(), 2);
19010        assert!(e2.publications().contains("pub_a"));
19011        assert!(e2.publications().contains("pub_b"));
19012    }
19013
19014    #[test]
19015    fn create_publication_allowed_inside_transaction() {
19016        // v6.1.4 dropped the v6.1.2 in-TX guard — PG allows
19017        // CREATE PUBLICATION inside a TX and the auto-commit
19018        // wrap path needs the same allowance.
19019        let mut e = Engine::new();
19020        e.execute("BEGIN").unwrap();
19021        e.execute("CREATE PUBLICATION pub_a").unwrap();
19022        e.execute("COMMIT").unwrap();
19023        assert!(e.publications().contains("pub_a"));
19024    }
19025
19026    // ── v6.1.3: SHOW PUBLICATIONS + FOR-list variants ───────
19027
19028    #[test]
19029    fn create_publication_for_table_list_lands_with_scope() {
19030        let mut e = Engine::new();
19031        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
19032        e.execute("CREATE TABLE t2 (id INT NOT NULL)").unwrap();
19033        e.execute("CREATE PUBLICATION pub_a FOR TABLE t1, t2")
19034            .unwrap();
19035        let scope = e.publications().get("pub_a").cloned();
19036        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = scope else {
19037            panic!("expected ForTables scope, got {scope:?}")
19038        };
19039        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
19040    }
19041
19042    #[test]
19043    fn create_publication_all_tables_except_lands_with_scope() {
19044        let mut e = Engine::new();
19045        e.execute("CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t3")
19046            .unwrap();
19047        let scope = e.publications().get("pub_a").cloned();
19048        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = scope else {
19049            panic!("expected AllTablesExcept scope, got {scope:?}")
19050        };
19051        assert_eq!(ts, alloc::vec!["t3".to_string()]);
19052    }
19053
19054    #[test]
19055    fn show_publications_empty_returns_zero_rows() {
19056        let e = Engine::new();
19057        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
19058        let QueryResult::Rows { rows, columns } = r else {
19059            panic!()
19060        };
19061        assert!(rows.is_empty());
19062        assert_eq!(columns.len(), 3);
19063        assert_eq!(columns[0].name, "name");
19064        assert_eq!(columns[1].name, "scope");
19065        assert_eq!(columns[2].name, "table_count");
19066    }
19067
19068    #[test]
19069    fn show_publications_returns_one_row_per_publication_ordered_by_name() {
19070        let mut e = Engine::new();
19071        e.execute("CREATE PUBLICATION z_pub").unwrap();
19072        e.execute("CREATE PUBLICATION a_pub FOR TABLE t1, t2")
19073            .unwrap();
19074        e.execute("CREATE PUBLICATION m_pub FOR ALL TABLES EXCEPT bad")
19075            .unwrap();
19076        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
19077        let QueryResult::Rows { rows, .. } = r else {
19078            panic!()
19079        };
19080        assert_eq!(rows.len(), 3);
19081        // Alphabetical order: a_pub, m_pub, z_pub.
19082        let names: Vec<&str> = rows
19083            .iter()
19084            .map(|r| {
19085                if let Value::Text(s) = &r.values[0] {
19086                    s.as_str()
19087                } else {
19088                    panic!()
19089                }
19090            })
19091            .collect();
19092        assert_eq!(names, alloc::vec!["a_pub", "m_pub", "z_pub"]);
19093        // Row 0 — a_pub scope summary + table_count = 2.
19094        match &rows[0].values[1] {
19095            Value::Text(s) => assert_eq!(s, "FOR TABLE t1, t2"),
19096            other => panic!("expected Text, got {other:?}"),
19097        }
19098        assert_eq!(rows[0].values[2], Value::Int(2));
19099        // Row 1 — m_pub.
19100        match &rows[1].values[1] {
19101            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES EXCEPT bad"),
19102            other => panic!("expected Text, got {other:?}"),
19103        }
19104        assert_eq!(rows[1].values[2], Value::Int(1));
19105        // Row 2 — z_pub (AllTables → NULL count).
19106        match &rows[2].values[1] {
19107            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES"),
19108            other => panic!("expected Text, got {other:?}"),
19109        }
19110        assert_eq!(rows[2].values[2], Value::Null);
19111    }
19112
19113    #[test]
19114    fn for_list_scopes_persist_across_snapshot() {
19115        // The v6.1.2 envelope-v3 round-trip exercised AllTables;
19116        // v6.1.3 needs the scope-1 / scope-2 tags to survive too.
19117        let mut e = Engine::new();
19118        e.execute("CREATE PUBLICATION p1 FOR TABLE t1, t2").unwrap();
19119        e.execute("CREATE PUBLICATION p2 FOR ALL TABLES EXCEPT bad, worse")
19120            .unwrap();
19121        let snap = e.snapshot();
19122        let e2 = Engine::restore_envelope(&snap).unwrap();
19123        assert_eq!(e2.publications().len(), 2);
19124        let p1 = e2.publications().get("p1").cloned();
19125        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = p1 else {
19126            panic!("p1 scope lost: {p1:?}")
19127        };
19128        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
19129        let p2 = e2.publications().get("p2").cloned();
19130        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = p2 else {
19131            panic!("p2 scope lost: {p2:?}")
19132        };
19133        assert_eq!(ts, alloc::vec!["bad".to_string(), "worse".to_string()]);
19134    }
19135
19136    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW + envelope v4 ─
19137
19138    #[test]
19139    fn create_subscription_lands_in_catalog_with_defaults() {
19140        let mut e = Engine::new();
19141        e.execute(
19142            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
19143        )
19144        .unwrap();
19145        let s = e.subscriptions().get("sub_a").cloned().expect("present");
19146        assert_eq!(s.conn_str, "host=127.0.0.1 port=20002");
19147        assert_eq!(s.publications, alloc::vec!["pub_a".to_string()]);
19148        assert!(s.enabled);
19149        assert_eq!(s.last_received_pos, 0);
19150    }
19151
19152    #[test]
19153    fn create_subscription_duplicate_name_errors() {
19154        let mut e = Engine::new();
19155        e.execute("CREATE SUBSCRIPTION s CONNECTION 'host=x' PUBLICATION p")
19156            .unwrap();
19157        let err = e
19158            .execute("CREATE SUBSCRIPTION s CONNECTION 'host=y' PUBLICATION p")
19159            .unwrap_err();
19160        assert!(
19161            alloc::format!("{err:?}").contains("DuplicateName"),
19162            "got {err:?}"
19163        );
19164    }
19165
19166    #[test]
19167    fn drop_subscription_silent_when_absent() {
19168        let mut e = Engine::new();
19169        let r = e.execute("DROP SUBSCRIPTION never").unwrap();
19170        match r {
19171            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
19172            other => panic!("expected CommandOk, got {other:?}"),
19173        }
19174    }
19175
19176    #[test]
19177    fn subscription_advance_updates_last_pos_monotone() {
19178        let mut e = Engine::new();
19179        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
19180            .unwrap();
19181        assert!(e.subscription_advance("s", 100));
19182        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
19183        assert!(e.subscription_advance("s", 50)); // stale → ignored
19184        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
19185        assert!(e.subscription_advance("s", 200));
19186        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 200);
19187        assert!(!e.subscription_advance("missing", 1));
19188    }
19189
19190    #[test]
19191    fn show_subscriptions_returns_rows_ordered_by_name() {
19192        let mut e = Engine::new();
19193        e.execute("CREATE SUBSCRIPTION z_sub CONNECTION 'h=x' PUBLICATION p1, p2")
19194            .unwrap();
19195        e.execute("CREATE SUBSCRIPTION a_sub CONNECTION 'h=y' PUBLICATION p3")
19196            .unwrap();
19197        let r = e.execute_readonly("SHOW SUBSCRIPTIONS").unwrap();
19198        let QueryResult::Rows { rows, columns } = r else {
19199            panic!()
19200        };
19201        assert_eq!(rows.len(), 2);
19202        assert_eq!(columns.len(), 5);
19203        assert_eq!(columns[0].name, "name");
19204        assert_eq!(columns[4].name, "last_received_pos");
19205        // Alphabetical: a_sub, z_sub.
19206        let names: Vec<&str> = rows
19207            .iter()
19208            .map(|r| {
19209                if let Value::Text(s) = &r.values[0] {
19210                    s.as_str()
19211                } else {
19212                    panic!()
19213                }
19214            })
19215            .collect();
19216        assert_eq!(names, alloc::vec!["a_sub", "z_sub"]);
19217        // Row 0: a_sub
19218        assert_eq!(rows[0].values[1], Value::Text("h=y".to_string()));
19219        assert_eq!(rows[0].values[2], Value::Text("p3".to_string()));
19220        assert_eq!(rows[0].values[3], Value::Bool(true));
19221        assert_eq!(rows[0].values[4], Value::BigInt(0));
19222        // Row 1: z_sub — publications join with ", "
19223        assert_eq!(rows[1].values[2], Value::Text("p1, p2".to_string()));
19224    }
19225
19226    #[test]
19227    fn subscriptions_persist_across_snapshot_envelope_v4() {
19228        let mut e = Engine::new();
19229        e.execute("CREATE SUBSCRIPTION s1 CONNECTION 'h=A' PUBLICATION p1, p2")
19230            .unwrap();
19231        e.execute("CREATE SUBSCRIPTION s2 CONNECTION 'h=B' PUBLICATION p3")
19232            .unwrap();
19233        e.subscription_advance("s2", 42);
19234        let snap = e.snapshot();
19235        let e2 = Engine::restore_envelope(&snap).unwrap();
19236        assert_eq!(e2.subscriptions().len(), 2);
19237        let s1 = e2.subscriptions().get("s1").unwrap();
19238        assert_eq!(s1.conn_str, "h=A");
19239        assert_eq!(
19240            s1.publications,
19241            alloc::vec!["p1".to_string(), "p2".to_string()]
19242        );
19243        assert_eq!(s1.last_received_pos, 0);
19244        let s2 = e2.subscriptions().get("s2").unwrap();
19245        assert_eq!(s2.last_received_pos, 42);
19246    }
19247
19248    #[test]
19249    fn v3_envelope_loads_with_empty_subscriptions() {
19250        // v3 snapshot (publications-only). Forge it by hand so we
19251        // verify v6.1.4 readers don't panic — they must surface
19252        // empty subscriptions and a populated publication table.
19253        let mut e = Engine::new();
19254        e.execute("CREATE PUBLICATION pub_legacy").unwrap();
19255        let catalog = e.catalog.serialize();
19256        let users = crate::users::serialize_users(&e.users);
19257        let pubs = e.publications.serialize();
19258        let mut buf = Vec::new();
19259        buf.extend_from_slice(b"SPGENV01");
19260        buf.push(3u8); // v3
19261        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
19262        buf.extend_from_slice(&catalog);
19263        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
19264        buf.extend_from_slice(&users);
19265        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
19266        buf.extend_from_slice(&pubs);
19267        let crc = spg_crypto::crc32::crc32(&buf);
19268        buf.extend_from_slice(&crc.to_le_bytes());
19269
19270        let e2 = Engine::restore_envelope(&buf).expect("v3 envelope restores under v4 reader");
19271        assert!(e2.subscriptions().is_empty());
19272        assert!(e2.publications().contains("pub_legacy"));
19273    }
19274
19275    #[test]
19276    fn create_subscription_allowed_inside_transaction() {
19277        let mut e = Engine::new();
19278        e.execute("BEGIN").unwrap();
19279        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
19280            .unwrap();
19281        e.execute("COMMIT").unwrap();
19282        assert!(e.subscriptions().contains("s"));
19283    }
19284
19285    // ── v6.2.0: ANALYZE + spg_statistic + envelope v5 ──────────
19286    #[test]
19287    fn analyze_populates_histogram_bounds() {
19288        let mut e = Engine::new();
19289        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT)")
19290            .unwrap();
19291        for i in 0..50 {
19292            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'name{i}')"))
19293                .unwrap();
19294        }
19295        e.execute("ANALYZE t").unwrap();
19296        let stats = e.statistics();
19297        let id_stats = stats.get("t", "id").unwrap();
19298        assert!(id_stats.histogram_bounds.len() >= 2);
19299        assert_eq!(id_stats.histogram_bounds.first().unwrap(), "0");
19300        assert_eq!(id_stats.histogram_bounds.last().unwrap(), "49");
19301        assert!((id_stats.null_frac - 0.0).abs() < 1e-6);
19302        assert_eq!(id_stats.n_distinct, 50);
19303    }
19304
19305    #[test]
19306    fn reanalyze_overwrites_prior_stats() {
19307        let mut e = Engine::new();
19308        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19309        for i in 0..10 {
19310            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19311                .unwrap();
19312        }
19313        e.execute("ANALYZE t").unwrap();
19314        let n1 = e.statistics().get("t", "id").unwrap().n_distinct;
19315        assert_eq!(n1, 10);
19316        for i in 10..30 {
19317            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19318                .unwrap();
19319        }
19320        e.execute("ANALYZE t").unwrap();
19321        let n2 = e.statistics().get("t", "id").unwrap().n_distinct;
19322        assert_eq!(n2, 30);
19323    }
19324
19325    #[test]
19326    fn analyze_unknown_table_errors() {
19327        let mut e = Engine::new();
19328        let err = e.execute("ANALYZE nonexistent").unwrap_err();
19329        assert!(matches!(
19330            err,
19331            EngineError::Storage(StorageError::TableNotFound { .. })
19332        ));
19333    }
19334
19335    #[test]
19336    fn bare_analyze_covers_all_user_tables() {
19337        let mut e = Engine::new();
19338        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
19339        e.execute("CREATE TABLE t2 (name TEXT NOT NULL)").unwrap();
19340        e.execute("INSERT INTO t1 VALUES (1)").unwrap();
19341        e.execute("INSERT INTO t2 VALUES ('alice')").unwrap();
19342        let r = e.execute("ANALYZE").unwrap();
19343        match r {
19344            QueryResult::CommandOk {
19345                affected,
19346                modified_catalog,
19347            } => {
19348                assert_eq!(affected, 2);
19349                assert!(modified_catalog);
19350            }
19351            other => panic!("expected CommandOk, got {other:?}"),
19352        }
19353        assert!(e.statistics().get("t1", "id").is_some());
19354        assert!(e.statistics().get("t2", "name").is_some());
19355    }
19356
19357    #[test]
19358    fn select_from_spg_statistic_returns_rows_per_column() {
19359        let mut e = Engine::new();
19360        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
19361            .unwrap();
19362        e.execute("INSERT INTO t VALUES (1, 'a')").unwrap();
19363        e.execute("INSERT INTO t VALUES (2, 'b')").unwrap();
19364        e.execute("ANALYZE t").unwrap();
19365        let r = e.execute_readonly("SELECT * FROM spg_statistic").unwrap();
19366        let QueryResult::Rows { rows, columns } = r else {
19367            panic!()
19368        };
19369        // v6.7.0 — spg_statistic gained a `cold_row_count` column.
19370        assert_eq!(columns.len(), 6);
19371        assert_eq!(columns[0].name, "table_name");
19372        assert_eq!(columns[4].name, "histogram_bounds");
19373        assert_eq!(columns[5].name, "cold_row_count");
19374        assert_eq!(rows.len(), 2, "one row per column of t");
19375        // Sorted by (table_name, column_name).
19376        match (&rows[0].values[0], &rows[0].values[1]) {
19377            (Value::Text(t), Value::Text(c)) => {
19378                assert_eq!(t, "t");
19379                // BTreeMap orders (table, column); columns "id" < "label".
19380                assert_eq!(c, "id");
19381            }
19382            _ => panic!(),
19383        }
19384    }
19385
19386    #[test]
19387    fn analyze_skips_vector_columns() {
19388        // Vector columns have their own stats shape (HNSW graph);
19389        // ANALYZE leaves them out of spg_statistic.
19390        let mut e = Engine::new();
19391        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
19392            .unwrap();
19393        e.execute("INSERT INTO t VALUES (1, [1, 2, 3])").unwrap();
19394        e.execute("ANALYZE t").unwrap();
19395        assert!(e.statistics().get("t", "id").is_some());
19396        assert!(e.statistics().get("t", "v").is_none());
19397    }
19398
19399    #[test]
19400    fn statistics_persist_across_envelope_v5_round_trip() {
19401        let mut e = Engine::new();
19402        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19403        for i in 0..20 {
19404            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19405                .unwrap();
19406        }
19407        e.execute("ANALYZE").unwrap();
19408        let snap = e.snapshot();
19409        let e2 = Engine::restore_envelope(&snap).unwrap();
19410        let s = e2.statistics().get("t", "id").unwrap();
19411        assert_eq!(s.n_distinct, 20);
19412    }
19413
19414    // ── v6.2.1 auto-analyze threshold ───────────────────────────
19415
19416    #[test]
19417    fn auto_analyze_threshold_fires_after_10pct_of_min_rows_on_small_table() {
19418        // For a table with 0 rows then 10 inserts → modified=10,
19419        // row_count=10. Threshold = 0.1 × max(10, 100) = 10. So
19420        // after the 10th INSERT the threshold is met.
19421        let mut e = Engine::new();
19422        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19423        for i in 0..9 {
19424            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19425                .unwrap();
19426        }
19427        assert!(e.tables_needing_analyze().is_empty(), "9 < threshold");
19428        e.execute("INSERT INTO t VALUES (9)").unwrap();
19429        let needs = e.tables_needing_analyze();
19430        assert_eq!(needs, alloc::vec!["t".to_string()]);
19431    }
19432
19433    #[test]
19434    fn auto_analyze_threshold_uses_10pct_of_row_count_for_large_tables() {
19435        // After ANALYZE on 1000 rows, threshold = 0.1 × row_count.
19436        // Each new INSERT bumps both modified and row_count, so to
19437        // trigger from N=1000 we need modifications ≥ 0.1 × (1000+M),
19438        // i.e. M ≥ 112. The test inserts 50 (no fire), then 150
19439        // more (200 total mods, row_count=1200, threshold=120 → fire).
19440        let mut e = Engine::new();
19441        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19442        for i in 0..1000 {
19443            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19444                .unwrap();
19445        }
19446        e.execute("ANALYZE t").unwrap();
19447        assert!(e.tables_needing_analyze().is_empty(), "fresh ANALYZE");
19448        for i in 1000..1050 {
19449            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19450                .unwrap();
19451        }
19452        assert!(
19453            e.tables_needing_analyze().is_empty(),
19454            "50 inserts < threshold of ~105"
19455        );
19456        for i in 1050..1200 {
19457            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19458                .unwrap();
19459        }
19460        assert_eq!(
19461            e.tables_needing_analyze(),
19462            alloc::vec!["t".to_string()],
19463            "200 inserts > 0.1 × 1200 threshold"
19464        );
19465    }
19466
19467    #[test]
19468    fn auto_analyze_threshold_resets_after_analyze() {
19469        let mut e = Engine::new();
19470        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19471        for i in 0..200 {
19472            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19473                .unwrap();
19474        }
19475        assert!(!e.tables_needing_analyze().is_empty());
19476        e.execute("ANALYZE").unwrap();
19477        assert!(
19478            e.tables_needing_analyze().is_empty(),
19479            "ANALYZE must reset the counter"
19480        );
19481    }
19482
19483    #[test]
19484    fn auto_analyze_threshold_tracks_updates_and_deletes() {
19485        let mut e = Engine::new();
19486        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
19487            .unwrap();
19488        for i in 0..50 {
19489            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'x')"))
19490                .unwrap();
19491        }
19492        e.execute("ANALYZE t").unwrap();
19493        // UPDATE 20 rows + DELETE 5 → modified=25. Threshold = 0.1
19494        // × max(50, 100) = 10. So 25 >= 10 → trigger.
19495        e.execute("UPDATE t SET label = 'y' WHERE id < 20").unwrap();
19496        e.execute("DELETE FROM t WHERE id >= 45").unwrap();
19497        assert_eq!(e.tables_needing_analyze(), alloc::vec!["t".to_string()]);
19498    }
19499
19500    #[test]
19501    fn v4_envelope_loads_with_empty_statistics() {
19502        // Forge a v4 envelope by hand: catalog + users + pubs +
19503        // subs trailer, no statistics. A v6.2.0 reader must accept
19504        // it and surface an empty Statistics.
19505        let mut e = Engine::new();
19506        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
19507            .unwrap();
19508        let catalog = e.catalog.serialize();
19509        let users = crate::users::serialize_users(&e.users);
19510        let pubs = e.publications.serialize();
19511        let subs = e.subscriptions.serialize();
19512        let mut buf = Vec::new();
19513        buf.extend_from_slice(b"SPGENV01");
19514        buf.push(4u8);
19515        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
19516        buf.extend_from_slice(&catalog);
19517        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
19518        buf.extend_from_slice(&users);
19519        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
19520        buf.extend_from_slice(&pubs);
19521        buf.extend_from_slice(&u32::try_from(subs.len()).unwrap().to_le_bytes());
19522        buf.extend_from_slice(&subs);
19523        let crc = spg_crypto::crc32::crc32(&buf);
19524        buf.extend_from_slice(&crc.to_le_bytes());
19525        let e2 = Engine::restore_envelope(&buf).expect("v4 envelope restores");
19526        assert!(e2.statistics().is_empty());
19527    }
19528
19529    #[test]
19530    fn v1_v2_envelope_loads_with_empty_publications() {
19531        // A snapshot taken before v6.1.2 (no publication trailer,
19532        // envelope v2) must still deserialise — and the resulting
19533        // engine must report zero publications. Use the engine's own
19534        // round-trip with no publications: that emits v3 but with an
19535        // empty pubs block. Then forge a v2 envelope by hand to lock
19536        // the back-compat path.
19537        let mut e = Engine::new();
19538        // Force users to be non-empty so the snapshot takes the
19539        // envelope path rather than the bare-catalog fallback.
19540        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
19541            .unwrap();
19542
19543        // Forge an envelope v2: same shape as v3 but no pubs trailer.
19544        let catalog = e.catalog.serialize();
19545        let users = crate::users::serialize_users(&e.users);
19546        let mut buf = Vec::new();
19547        buf.extend_from_slice(b"SPGENV01");
19548        buf.push(2u8); // v2
19549        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
19550        buf.extend_from_slice(&catalog);
19551        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
19552        buf.extend_from_slice(&users);
19553        let crc = spg_crypto::crc32::crc32(&buf);
19554        buf.extend_from_slice(&crc.to_le_bytes());
19555
19556        let e2 = Engine::restore_envelope(&buf).expect("v2 envelope restores");
19557        assert!(e2.publications().is_empty());
19558    }
19559}