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 describe;
11pub mod eval;
12pub mod fts;
13pub mod json;
14pub mod memoize;
15pub mod plan_cache;
16pub mod publications;
17pub mod query_stats;
18pub mod reorder;
19pub mod selectivity;
20pub mod statistics;
21pub mod subscriptions;
22pub mod triggers;
23pub mod users;
24
25pub use crate::users::{Role, ScramSecrets, UserError, UserStore};
26
27use alloc::borrow::Cow;
28use alloc::boxed::Box;
29use alloc::collections::BTreeMap;
30use alloc::string::{String, ToString};
31use alloc::vec::Vec;
32use core::fmt;
33
34use spg_sql::ast::{
35    BinOp, ColumnDef, ColumnName, ColumnTypeName, CreateIndexStatement, CreatePublicationStatement,
36    CreateSubscriptionStatement, CreateTableStatement, CreateUserStatement, Expr, FrameBound,
37    FrameKind, FromClause, IndexMethod, InsertStatement, JoinKind, Literal, OrderBy, SelectItem,
38    SelectStatement, Statement, TableRef, UnOp, UnionKind, VecEncoding as SqlVecEncoding,
39    WindowFrame,
40};
41// v7.16.0 — re-export the parsed-statement AST so downstream
42// crates (spg-embedded → spg-sqlx) don't need a direct dep on
43// spg-sql for the prepare/bind handle.
44pub use spg_sql::ast::Statement as ParsedStatement;
45use spg_sql::parser::{self, ParseError};
46use spg_storage::{
47    Catalog, ColumnSchema, CompactReport, DataType, IndexKey, IndexKind, Row, StorageError, Table,
48    TableSchema, Value, VecEncoding,
49};
50
51use crate::eval::{EvalContext, EvalError};
52
53/// Result of executing one statement.
54#[derive(Debug, Clone, PartialEq)]
55#[non_exhaustive]
56pub enum QueryResult {
57    /// DDL or DML succeeded.
58    ///
59    /// `affected` is the row count for `INSERT` and 0 elsewhere.
60    /// `modified_catalog` tells the server whether this statement
61    /// caused the *committed* catalog to change — it's the signal to
62    /// snapshot/audit. False for `BEGIN`/`ROLLBACK`, false for writeful
63    /// statements executed inside a transaction (those only touch the
64    /// shadow), and true for `COMMIT` and for writes outside a TX.
65    CommandOk {
66        affected: usize,
67        modified_catalog: bool,
68    },
69    /// `SELECT` returned a (possibly empty) row set.
70    Rows {
71        columns: Vec<ColumnSchema>,
72        rows: Vec<Row>,
73    },
74}
75
76/// All errors the engine can return.
77///
78/// Marked `#[non_exhaustive]` from v7.5.0 onward: external `match`
79/// must include a `_` arm so new variants in subsequent v7.x releases
80/// are not breaking changes.
81#[derive(Debug, Clone, PartialEq)]
82#[non_exhaustive]
83pub enum EngineError {
84    Parse(ParseError),
85    Storage(StorageError),
86    Eval(EvalError),
87    /// Front-end accepted a construct that the v0.x executor doesn't support.
88    Unsupported(String),
89    /// `BEGIN` while another transaction is already open.
90    TransactionAlreadyOpen,
91    /// `COMMIT` / `ROLLBACK` with no active transaction.
92    NoActiveTransaction,
93    /// v4.0 sentinel: `execute_readonly` got a statement that
94    /// mutates engine state (INSERT / CREATE / BEGIN / COMMIT / …).
95    /// The caller should retake the write lock and dispatch through
96    /// `execute(&mut self)` instead.
97    WriteRequired,
98    /// v4.2: a SELECT would have returned more rows than the
99    /// configured `max_query_rows` cap. Carries the cap.
100    RowLimitExceeded(usize),
101    /// v4.5: cooperative cancellation — the host (server's
102    /// per-query watchdog) set the cancel flag while a long-running
103    /// SELECT / UPDATE / DELETE was scanning rows. The partial work
104    /// is discarded; the caller should surface this as a timeout
105    /// to the client.
106    Cancelled,
107}
108
109impl fmt::Display for EngineError {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        match self {
112            Self::Parse(e) => write!(f, "parse: {e}"),
113            Self::Storage(e) => write!(f, "storage: {e}"),
114            Self::Eval(e) => write!(f, "eval: {e}"),
115            Self::Unsupported(s) => write!(f, "unsupported: {s}"),
116            Self::TransactionAlreadyOpen => f.write_str("a transaction is already open"),
117            Self::NoActiveTransaction => f.write_str("no active transaction"),
118            Self::WriteRequired => {
119                f.write_str("statement requires a write lock (use execute, not execute_readonly)")
120            }
121            Self::RowLimitExceeded(n) => {
122                write!(f, "query exceeded max_query_rows={n}")
123            }
124            Self::Cancelled => f.write_str("query cancelled (timeout or client request)"),
125        }
126    }
127}
128
129impl From<ParseError> for EngineError {
130    fn from(e: ParseError) -> Self {
131        Self::Parse(e)
132    }
133}
134impl From<StorageError> for EngineError {
135    fn from(e: StorageError) -> Self {
136        Self::Storage(e)
137    }
138}
139impl From<EvalError> for EngineError {
140    fn from(e: EvalError) -> Self {
141        Self::Eval(e)
142    }
143}
144
145/// The execution engine. Holds the catalog and (later) other server-scope
146/// state. `Engine::new()` is intentionally cheap so callers can construct one
147/// per database, per test.
148/// Function pointer that returns "now" as microseconds since Unix
149/// epoch. The engine is `no_std`, so it can't reach for `std::time`
150/// itself — callers (`spg-server`, the sqllogictest runner) inject a
151/// concrete implementation. `None` means `NOW()` / `CURRENT_*` raise
152/// `Unsupported`.
153pub type ClockFn = fn() -> i64;
154
155/// Function pointer that produces 16 cryptographically random bytes.
156/// Like `ClockFn`, the engine is `no_std` and can't reach for /dev/urandom
157/// itself — host (`spg-server`) injects an OS-backed source. `None`
158/// means SQL-driven `CREATE USER` falls back to a deterministic salt
159/// derived from the username (acceptable in tests; the server always
160/// installs a real RNG so production paths never see this).
161pub type SaltFn = fn() -> [u8; 16];
162
163/// v4.5 cooperative cancellation token. A long-running SELECT /
164/// UPDATE / DELETE checks `is_cancelled` at row-loop checkpoints
165/// and bails with `EngineError::Cancelled`. The host
166/// (`spg-server`) creates an `AtomicBool` per query, spawns a
167/// watchdog thread that sets it after `SPG_QUERY_TIMEOUT_MS`,
168/// and passes it via `execute_with_cancel` / `execute_readonly_with_cancel`.
169///
170/// `CancelToken::none()` is a no-op — used by the legacy `execute`
171/// and `execute_readonly` entry points so existing callers don't
172/// change.
173/// v7.17.0 Phase 2.3 — monotonic time source for deadline-aware
174/// cancellation (PG `statement_timeout`). Returns microseconds
175/// since some host-stable monotonic origin (typically the first
176/// call into `Instant::now()` on the server). The engine never
177/// calls `Instant::now()` directly so the crate stays `#![no_std]`.
178pub type MonotonicNowFn = fn() -> u64;
179
180#[derive(Debug, Clone, Copy)]
181struct Deadline {
182    now_fn: MonotonicNowFn,
183    /// Absolute deadline in `now_fn()` units (microseconds).
184    deadline_us: u64,
185}
186
187#[derive(Debug, Clone, Copy)]
188pub struct CancelToken<'a> {
189    flag: Option<&'a core::sync::atomic::AtomicBool>,
190    // v7.17.0 Phase 2.3 — when set, every existing `cancel.check()`
191    // checkpoint also fires `EngineError::Cancelled` once
192    // `(now_fn)() >= deadline_us`. No new check sites, no thread
193    // spawn per query — the monotonic now-fn read is a vDSO
194    // `clock_gettime(CLOCK_MONOTONIC)` (~20ns) and only runs when
195    // the host actually wired a deadline (statement_timeout > 0).
196    deadline: Option<Deadline>,
197}
198
199impl<'a> CancelToken<'a> {
200    #[must_use]
201    pub const fn none() -> Self {
202        Self {
203            flag: None,
204            deadline: None,
205        }
206    }
207
208    #[must_use]
209    pub const fn from_flag(f: &'a core::sync::atomic::AtomicBool) -> Self {
210        Self {
211            flag: Some(f),
212            deadline: None,
213        }
214    }
215
216    /// v7.17.0 Phase 2.3 — attach a monotonic deadline. `now_fn`
217    /// must return microseconds since a stable origin; the token
218    /// trips when `now_fn() >= deadline_us`. Compose with
219    /// `from_flag(...)` when both a watchdog flag and a per-statement
220    /// timeout are in play (e.g. server-wide `SPG_QUERY_TIMEOUT_MS`
221    /// plus session `statement_timeout`); the tighter of the two
222    /// wins by virtue of either signaling first.
223    #[must_use]
224    pub const fn with_deadline(mut self, now_fn: MonotonicNowFn, deadline_us: u64) -> Self {
225        self.deadline = Some(Deadline {
226            now_fn,
227            deadline_us,
228        });
229        self
230    }
231
232    #[must_use]
233    pub fn is_cancelled(self) -> bool {
234        if self
235            .flag
236            .is_some_and(|f| f.load(core::sync::atomic::Ordering::Relaxed))
237        {
238            return true;
239        }
240        // Deadline check is the second branch so the "no timeout"
241        // hot path (`deadline: None`) elides the now-fn call —
242        // predicted-not-taken on the SLO INSERT loop.
243        if let Some(d) = self.deadline
244            && (d.now_fn)() >= d.deadline_us
245        {
246            return true;
247        }
248        false
249    }
250
251    /// Returns `Err(Cancelled)` if the token has been tripped.
252    /// Used at row-loop checkpoints to bail cooperatively without
253    /// scattering raw `is_cancelled` checks across the executor.
254    #[inline]
255    pub fn check(self) -> Result<(), EngineError> {
256        if self.is_cancelled() {
257            Err(EngineError::Cancelled)
258        } else {
259            Ok(())
260        }
261    }
262}
263
264// ---- snapshot envelope (v4.1, extended with CRC32 in v4.37,  ----
265// ----   publications in v6.1.2 v3, subscriptions in v6.1.4 v4) ----
266//
267// Wraps a catalog blob + a user blob behind a small header so the
268// server can persist both atomically without inventing a new file.
269// Bare catalog blobs (v3.x) still load via `restore_envelope` since
270// the magic check fails fast and the function falls back to
271// `Catalog::deserialize`.
272//
273// Layout — v1 (v4.1, no CRC):
274//   [8 bytes magic "SPGENV01"]
275//   [u8 version = 1]
276//   [u32 catalog_len][catalog bytes]
277//   [u32 users_len][users bytes]
278//
279// Layout — v2 (v4.37, CRC32 of body):
280//   [8 bytes magic "SPGENV01"]
281//   [u8 version = 2]
282//   [u32 catalog_len][catalog bytes]
283//   [u32 users_len][users bytes]
284//   [u32 crc32]                      ← CRC32 of every byte before it.
285//
286// Layout — v3 (v6.1.2, publications trailer):
287//   [8 bytes magic "SPGENV01"]
288//   [u8 version = 3]
289//   [u32 catalog_len][catalog bytes]
290//   [u32 users_len][users bytes]
291//   [u32 pubs_len][publications bytes]
292//   [u32 crc32]
293//
294// Layout — v4 (v6.1.4, subscriptions trailer):
295//   [8 bytes magic "SPGENV01"]
296//   [u8 version = 4]
297//   [u32 catalog_len][catalog bytes]
298//   [u32 users_len][users bytes]
299//   [u32 pubs_len][publications bytes]
300//   [u32 subs_len][subscriptions bytes]
301//   [u32 crc32]
302//
303// Layout — v5 (v6.2.0, statistics trailer):
304//   [8 bytes magic "SPGENV01"]
305//   [u8 version = 5]
306//   [u32 catalog_len][catalog bytes]
307//   [u32 users_len][users bytes]
308//   [u32 pubs_len][publications bytes]
309//   [u32 subs_len][subscriptions bytes]
310//   [u32 stats_len][statistics bytes]      ← NEW
311//   [u32 crc32]
312//
313// Writers emit v5 from v6.2.0 on. Readers accept all of {v1, v2,
314// v3, v4, v5}: v1/v2 load with empty publications / subscriptions /
315// statistics; v3 loads with empty subscriptions + statistics; v4
316// loads with empty statistics; v5 deserialises all three. Older
317// SPG versions reading a v5 envelope fall through the version
318// match to `EnvelopeParse::Bare` — pre-v6.2.0 binaries cannot
319// open v6.2.0+ snapshots (matches the v6.1.2 / v6.1.4 breaks).
320
321const ENVELOPE_MAGIC: &[u8; 8] = b"SPGENV01";
322const ENVELOPE_VERSION_V1: u8 = 1;
323const ENVELOPE_VERSION_V2: u8 = 2;
324const ENVELOPE_VERSION_V3: u8 = 3;
325const ENVELOPE_VERSION_V4: u8 = 4;
326const ENVELOPE_VERSION_V5: u8 = 5;
327
328fn build_envelope(catalog: &[u8], users: &[u8], pubs: &[u8], subs: &[u8], stats: &[u8]) -> Vec<u8> {
329    let mut out = Vec::with_capacity(
330        8 + 1
331            + 4
332            + catalog.len()
333            + 4
334            + users.len()
335            + 4
336            + pubs.len()
337            + 4
338            + subs.len()
339            + 4
340            + stats.len()
341            + 4,
342    );
343    out.extend_from_slice(ENVELOPE_MAGIC);
344    out.push(ENVELOPE_VERSION_V5);
345    out.extend_from_slice(
346        &u32::try_from(catalog.len())
347            .expect("≤ 4G catalog")
348            .to_le_bytes(),
349    );
350    out.extend_from_slice(catalog);
351    out.extend_from_slice(
352        &u32::try_from(users.len())
353            .expect("≤ 4G users")
354            .to_le_bytes(),
355    );
356    out.extend_from_slice(users);
357    out.extend_from_slice(
358        &u32::try_from(pubs.len())
359            .expect("≤ 4G publications")
360            .to_le_bytes(),
361    );
362    out.extend_from_slice(pubs);
363    out.extend_from_slice(
364        &u32::try_from(subs.len())
365            .expect("≤ 4G subscriptions")
366            .to_le_bytes(),
367    );
368    out.extend_from_slice(subs);
369    out.extend_from_slice(
370        &u32::try_from(stats.len())
371            .expect("≤ 4G statistics")
372            .to_le_bytes(),
373    );
374    out.extend_from_slice(stats);
375    let crc = spg_crypto::crc32::crc32(&out);
376    out.extend_from_slice(&crc.to_le_bytes());
377    out
378}
379
380/// Outcome of envelope parsing: either bare-catalog fallback, a
381/// successfully split section trio from a v1/v2/v3 envelope, or an
382/// explicit corruption error from a v2/v3 CRC mismatch. `Bare`
383/// (catalog-only fallback) preserves v3.x readability. v1/v2
384/// envelopes set `publications` to `None`; v3 sets it to the
385/// publications byte slice.
386enum EnvelopeParse<'a> {
387    Bare,
388    Pair {
389        catalog: &'a [u8],
390        users: &'a [u8],
391        publications: Option<&'a [u8]>,
392        subscriptions: Option<&'a [u8]>,
393        statistics: Option<&'a [u8]>,
394    },
395    CrcMismatch {
396        expected: u32,
397        computed: u32,
398    },
399}
400
401/// Returns `EnvelopeParse::Pair` for a valid v1 / v2 / v3 envelope,
402/// `Bare` for a buffer that doesn't look like an envelope (v3.x
403/// bare catalog fallback), and `CrcMismatch` for a v2/v3 envelope
404/// whose trailing CRC32 doesn't match the body.
405fn split_envelope(buf: &[u8]) -> EnvelopeParse<'_> {
406    if buf.len() < 8 + 1 + 4 || &buf[..8] != ENVELOPE_MAGIC {
407        return EnvelopeParse::Bare;
408    }
409    let version = buf[8];
410    if !matches!(
411        version,
412        ENVELOPE_VERSION_V1
413            | ENVELOPE_VERSION_V2
414            | ENVELOPE_VERSION_V3
415            | ENVELOPE_VERSION_V4
416            | ENVELOPE_VERSION_V5
417    ) {
418        return EnvelopeParse::Bare;
419    }
420    let mut p = 9usize;
421    let Some(cat_len_bytes) = buf.get(p..p + 4) else {
422        return EnvelopeParse::Bare;
423    };
424    let Ok(cat_len_arr) = cat_len_bytes.try_into() else {
425        return EnvelopeParse::Bare;
426    };
427    let cat_len = u32::from_le_bytes(cat_len_arr) as usize;
428    p += 4;
429    if p + cat_len + 4 > buf.len() {
430        return EnvelopeParse::Bare;
431    }
432    let catalog = &buf[p..p + cat_len];
433    p += cat_len;
434    let Some(user_len_bytes) = buf.get(p..p + 4) else {
435        return EnvelopeParse::Bare;
436    };
437    let Ok(user_len_arr) = user_len_bytes.try_into() else {
438        return EnvelopeParse::Bare;
439    };
440    let user_len = u32::from_le_bytes(user_len_arr) as usize;
441    p += 4;
442    if p + user_len > buf.len() {
443        return EnvelopeParse::Bare;
444    }
445    let users = &buf[p..p + user_len];
446    p += user_len;
447    let publications = if matches!(
448        version,
449        ENVELOPE_VERSION_V3 | ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5
450    ) {
451        // [u32 pubs_len][publications bytes]
452        let Some(pubs_len_bytes) = buf.get(p..p + 4) else {
453            return EnvelopeParse::Bare;
454        };
455        let Ok(pubs_len_arr) = pubs_len_bytes.try_into() else {
456            return EnvelopeParse::Bare;
457        };
458        let pubs_len = u32::from_le_bytes(pubs_len_arr) as usize;
459        p += 4;
460        if p + pubs_len > buf.len() {
461            return EnvelopeParse::Bare;
462        }
463        let pubs_slice = &buf[p..p + pubs_len];
464        p += pubs_len;
465        Some(pubs_slice)
466    } else {
467        None
468    };
469    let subscriptions = if matches!(version, ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5) {
470        // [u32 subs_len][subscriptions bytes]
471        let Some(subs_len_bytes) = buf.get(p..p + 4) else {
472            return EnvelopeParse::Bare;
473        };
474        let Ok(subs_len_arr) = subs_len_bytes.try_into() else {
475            return EnvelopeParse::Bare;
476        };
477        let subs_len = u32::from_le_bytes(subs_len_arr) as usize;
478        p += 4;
479        if p + subs_len > buf.len() {
480            return EnvelopeParse::Bare;
481        }
482        let subs_slice = &buf[p..p + subs_len];
483        p += subs_len;
484        Some(subs_slice)
485    } else {
486        None
487    };
488    let statistics = if version == ENVELOPE_VERSION_V5 {
489        // [u32 stats_len][statistics bytes]
490        let Some(stats_len_bytes) = buf.get(p..p + 4) else {
491            return EnvelopeParse::Bare;
492        };
493        let Ok(stats_len_arr) = stats_len_bytes.try_into() else {
494            return EnvelopeParse::Bare;
495        };
496        let stats_len = u32::from_le_bytes(stats_len_arr) as usize;
497        p += 4;
498        if p + stats_len > buf.len() {
499            return EnvelopeParse::Bare;
500        }
501        let stats_slice = &buf[p..p + stats_len];
502        p += stats_len;
503        Some(stats_slice)
504    } else {
505        None
506    };
507    if matches!(
508        version,
509        ENVELOPE_VERSION_V2 | ENVELOPE_VERSION_V3 | ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5
510    ) {
511        if p + 4 != buf.len() {
512            return EnvelopeParse::Bare;
513        }
514        let Ok(crc_arr) = buf[p..p + 4].try_into() else {
515            return EnvelopeParse::Bare;
516        };
517        let expected = u32::from_le_bytes(crc_arr);
518        let computed = spg_crypto::crc32::crc32(&buf[..p]);
519        if expected != computed {
520            return EnvelopeParse::CrcMismatch { expected, computed };
521        }
522    } else if p != buf.len() {
523        // v1: must end exactly at the users section.
524        return EnvelopeParse::Bare;
525    }
526    EnvelopeParse::Pair {
527        catalog,
528        users,
529        publications,
530        subscriptions,
531        statistics,
532    }
533}
534
535/// v4.41.1 opaque transaction handle. Returned by `Engine::alloc_tx_id`,
536/// threaded through `Engine::execute_in` so dispatch can identify which
537/// in-flight TX a statement belongs to. `IMPLICIT_TX` is the reserved
538/// slot every legacy caller — engine self-tests, spg-cli, spg-embedded,
539/// startup replay — implicitly uses through the unchanged
540/// `Engine::execute(sql)` API. v4.41.1 keeps at most one active slot at
541/// runtime (dispatch holds `engine.write()` across the wrap, same as
542/// v4.34); the map shape is here to let v4.42 turn on N in-flight
543/// implicit TXs without reshuffling the engine internals.
544#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
545pub struct TxId(pub u64);
546
547/// Reserved slot used by `Engine::execute(sql)` — the legacy single-
548/// global-shadow path. New `alloc_tx_id` handles start at 1.
549pub const IMPLICIT_TX: TxId = TxId(0);
550
551/// v6.7.3 — default segment-size threshold used by `COMPACT COLD
552/// SEGMENTS` when no explicit target is supplied. Segments whose
553/// `OwnedSegment::bytes().len()` is **strictly** less than this
554/// value are eligible to merge. spg-server reads
555/// `SPG_COMPACTION_TARGET_SEGMENT_BYTES` to override.
556pub const COMPACTION_TARGET_DEFAULT_BYTES: u64 = 4 * 1024 * 1024;
557
558/// Per-slot transaction state. Held inside `tx_catalogs[tx_id]` for the
559/// lifetime of a BEGIN..COMMIT (or BEGIN..ROLLBACK) window. Drops when
560/// the TX commits (its `catalog` is moved over `Engine.catalog`) or
561/// rolls back (slot removed, catalog discarded).
562#[derive(Debug, Default, Clone)]
563struct TxState {
564    /// The TX's shadow copy of the catalog. Started as a clone of
565    /// `Engine.catalog` at BEGIN time; writes flow into it; COMMIT
566    /// installs it over `Engine.catalog`. `Catalog::clone()` is O(1)
567    /// since v4.40 (`PersistentVec` rows + `PersistentBTreeMap` indices).
568    catalog: Catalog,
569    /// Per-TX savepoint stack. Each entry pairs the savepoint name with
570    /// a clone of `catalog` at the moment `SAVEPOINT <name>` fired.
571    /// `ROLLBACK TO <name>` restores from the entry and pops everything
572    /// after it; `RELEASE <name>` discards the entry and everything
573    /// after; COMMIT/ROLLBACK clears the whole stack.
574    savepoints: Vec<(String, Catalog)>,
575}
576
577/// v7.11.0 — frozen read-only view of the engine's committed state.
578/// Constructed via [`Engine::clone_snapshot`]. Holds clones of the
579/// catalog, statistics, clock function, and row-cap config — the
580/// four fields the `execute_readonly` path actually reads. Cheap to
581/// `Clone` (each clone shares the underlying `PersistentVec` row
582/// storage; only the trie root pointers copy). Send + Sync so a
583/// snapshot can be moved across `tokio::task::spawn_blocking`
584/// boundaries without coordination.
585///
586/// The contract: a snapshot reflects the engine's state at the
587/// moment `clone_snapshot()` returned. Subsequent writes to the
588/// engine are NOT visible. Callers who need fresher data take a
589/// new snapshot.
590#[derive(Debug, Clone)]
591pub struct CatalogSnapshot {
592    catalog: Catalog,
593    statistics: statistics::Statistics,
594    clock: Option<ClockFn>,
595    max_query_rows: Option<usize>,
596}
597
598#[derive(Debug, Default)]
599pub struct Engine {
600    /// Committed catalog — what survives `Engine::snapshot()` and what
601    /// outside-TX `SELECT`s read.
602    catalog: Catalog,
603    /// Active TX slots, keyed by `TxId`. Empty when no TX is in flight.
604    /// v4.41.1 runtime invariant: at most one entry (single-writer
605    /// model unchanged). v4.42 will let dispatch hold multiple entries
606    /// concurrently for group commit + engine MVCC.
607    tx_catalogs: BTreeMap<TxId, TxState>,
608    /// Which slot the next exec_* call should mutate. Set by
609    /// `execute_in(sql, tx_id)` at the entry point; legacy `execute(sql)`
610    /// sets it to `IMPLICIT_TX`. None when no TX is in flight (read /
611    /// write goes straight against `catalog`).
612    current_tx: Option<TxId>,
613    /// Monotonic counter for `alloc_tx_id`. Starts at 1 — slot 0 is
614    /// reserved for `IMPLICIT_TX`.
615    next_tx_id: u64,
616    /// Optional wall clock used to satisfy `NOW()` / `CURRENT_TIMESTAMP`
617    /// / `CURRENT_DATE`. Set by the host environment.
618    clock: Option<ClockFn>,
619    /// v4.1 cryptographic RNG for per-user password salt. Set by the
620    /// host. `None` means SQL-driven `CREATE USER` uses a
621    /// deterministic fallback — see `SaltFn`.
622    salt_fn: Option<SaltFn>,
623    /// v4.2 per-query row cap. `None` = unlimited. When set, a
624    /// SELECT that materialises more than `n` rows returns
625    /// `EngineError::RowLimitExceeded`. Enforced before the result
626    /// is shaped into wire frames so a runaway scan can't blow the
627    /// server's heap.
628    max_query_rows: Option<usize>,
629    /// v4.1 RBAC user table. Empty means "no RBAC configured yet" —
630    /// the server decides what that means at the auth boundary
631    /// (open mode vs legacy single-password mode). User CRUD goes
632    /// through `create_user`/`drop_user`/`verify_user`; persistence
633    /// rides the snapshot envelope alongside the catalog.
634    users: UserStore,
635    /// v6.1.2 logical-replication publication catalog. Empty until
636    /// `CREATE PUBLICATION` runs. Persistence rides the v3 envelope
637    /// trailer (see `build_envelope`).
638    publications: publications::Publications,
639    /// v6.1.4 logical-replication subscription catalog. Empty until
640    /// `CREATE SUBSCRIPTION` runs. Persistence rides the v4 envelope
641    /// trailer.
642    subscriptions: subscriptions::Subscriptions,
643    /// v6.2.0 — per-column statistics for the cost-based optimizer.
644    /// Populated by `ANALYZE`; queried via `spg_statistic` virtual
645    /// table. Persistence rides the v5 envelope trailer.
646    statistics: statistics::Statistics,
647    /// v6.3.0 — engine-level plan cache. Caches the post-`prepare()`
648    /// `Statement` keyed on SQL text. In-memory only — does NOT ride
649    /// the snapshot envelope (rebuilt on demand after restart).
650    plan_cache: plan_cache::PlanCache,
651    /// v6.5.1 — per-distinct-SQL execution stats. In-memory only,
652    /// surfaced via `spg_stat_query` virtual table. Updated by the
653    /// `execute_*` paths after a successful execute.
654    query_stats: query_stats::QueryStats,
655    /// v6.5.2 — connection-state provider callback. spg-server
656    /// registers a function at startup that snapshots its
657    /// per-pgwire-connection registry into `ActivityRow`s; engine
658    /// reads through it on every `SELECT * FROM spg_stat_activity`.
659    /// `None` ⇒ no-data (returns empty rows; matches the no_std
660    /// embedded callers that don't run pgwire).
661    activity_provider: Option<ActivityProvider>,
662    /// v6.5.3 — audit-chain provider + verifier. Same pattern as
663    /// activity_provider: spg-server registers both at startup;
664    /// engine reads through on `SELECT * FROM spg_audit_chain` and
665    /// `SELECT * FROM spg_audit_verify`. `None` ⇒ no-data.
666    audit_chain_provider: Option<AuditChainProvider>,
667    audit_verifier: Option<AuditVerifier>,
668    /// v6.5.6 — slow-query log threshold in microseconds. When set,
669    /// every successful execute whose elapsed exceeds the threshold
670    /// gets fed to the registered slow-query log callback (so
671    /// spg-server can emit a structured log line). Default `None`
672    /// = no slow-query logging.
673    slow_query_threshold_us: Option<u64>,
674    slow_query_logger: Option<SlowQueryLogger>,
675    /// v7.12.1 — session parameters set via `SET <name> = <value>`.
676    /// Only `default_text_search_config` is consumed by the engine
677    /// today (the FTS function dispatcher reads it when
678    /// `to_tsvector(text)` is called without an explicit config).
679    /// All other names are accepted + recorded so PG-dump output
680    /// loads, but have no behavioural effect.
681    session_params: BTreeMap<String, String>,
682    /// v7.12.7 — depth counter for trigger-emitted embedded SQL.
683    /// Each time the engine executes a `DeferredEmbeddedStmt` it
684    /// increments this; the recursive `execute_stmt_with_cancel`
685    /// inside that path checks against [`MAX_TRIGGER_RECURSION`]
686    /// to bound runaway cascades (trigger A's UPDATE on table B
687    /// fires trigger B which UPDATEs table A which fires trigger
688    /// A again…). Reset to 0 once the original DML returns.
689    trigger_recursion_depth: u32,
690    /// v7.14.0 — when `SET FOREIGN_KEY_CHECKS=0` is in effect
691    /// (mysqldump preamble), the FK existence + arity check at
692    /// CREATE TABLE time is deferred. FKs referencing a
693    /// not-yet-existing parent land in `pending_foreign_keys`
694    /// keyed by child table; `SET FOREIGN_KEY_CHECKS=1` drains
695    /// the queue and resolves each FK against the now-complete
696    /// catalog. Empty by default; the queue is drained on every
697    /// `RESET ALL` too.
698    foreign_key_checks: bool,
699    /// v7.16.2 — true on the temp Engine an outer
700    /// `exec_select_with_meta_views` builds, telling that
701    /// temp engine "stop short-circuiting into the meta-view
702    /// path — your catalog already has the materialised
703    /// tables; just run the regular SELECT." Without this we'd
704    /// infinite-loop since the meta-view name (e.g.
705    /// `__spg_info_columns`) still triggers
706    /// `select_references_meta_view`.
707    meta_views_materialised: bool,
708    pending_foreign_keys: Vec<(alloc::string::String, spg_sql::ast::ForeignKeyConstraint)>,
709}
710
711/// v7.12.7 — hard cap on nested trigger-emitted embedded SQL
712/// fires. 16 deep is well past anything a normal trigger graph
713/// uses while still preventing infinite-loop wedging.
714const MAX_TRIGGER_RECURSION: u32 = 16;
715
716/// v6.5.6 — callback signature for slow-query log emission. Called
717/// with `(sql, elapsed_us)` once per successful execute that crosses
718/// the threshold.
719pub type SlowQueryLogger = fn(&str, u64);
720
721/// v6.5.4 — synthesise a `CREATE TABLE` statement from catalog
722/// state. Round-trips through `Engine::execute` to recreate the
723/// same schema (sans data + indexes — indexes are emitted as a
724/// separate `CREATE INDEX` chain in `spg_database_ddl`).
725fn render_create_table(name: &str, columns: &[ColumnSchema]) -> String {
726    let mut out = alloc::format!("CREATE TABLE {name} (");
727    for (i, col) in columns.iter().enumerate() {
728        if i > 0 {
729            out.push_str(", ");
730        }
731        out.push_str(&col.name);
732        out.push(' ');
733        out.push_str(&render_data_type(col.ty));
734        if !col.nullable {
735            out.push_str(" NOT NULL");
736        }
737        if col.auto_increment {
738            out.push_str(" AUTO_INCREMENT");
739        }
740    }
741    out.push(')');
742    out
743}
744
745fn render_data_type(ty: DataType) -> String {
746    match ty {
747        DataType::SmallInt => "SMALLINT".into(),
748        DataType::Int => "INT".into(),
749        DataType::BigInt => "BIGINT".into(),
750        DataType::Float => "FLOAT".into(),
751        DataType::Text => "TEXT".into(),
752        DataType::Varchar(n) => alloc::format!("VARCHAR({n})"),
753        DataType::Char(n) => alloc::format!("CHAR({n})"),
754        DataType::Bool => "BOOL".into(),
755        DataType::Vector { dim, encoding } => match encoding {
756            spg_storage::VecEncoding::F32 => alloc::format!("VECTOR({dim})"),
757            spg_storage::VecEncoding::Sq8 => alloc::format!("VECTOR({dim}) USING SQ8"),
758            spg_storage::VecEncoding::F16 => alloc::format!("VECTOR({dim}) USING HALF"),
759        },
760        DataType::Numeric { precision, scale } => {
761            alloc::format!("NUMERIC({precision},{scale})")
762        }
763        DataType::Date => "DATE".into(),
764        DataType::Timestamp => "TIMESTAMP".into(),
765        DataType::Interval => "INTERVAL".into(),
766        DataType::Json => "JSON".into(),
767        DataType::Jsonb => "JSONB".into(),
768        DataType::Timestamptz => "TIMESTAMPTZ".into(),
769        DataType::Bytes => "BYTEA".into(),
770        DataType::TextArray => "TEXT[]".into(),
771        DataType::IntArray => "INT[]".into(),
772        DataType::BigIntArray => "BIGINT[]".into(),
773        DataType::TsVector => "TSVECTOR".into(),
774        DataType::TsQuery => "TSQUERY".into(),
775        DataType::Uuid => "UUID".into(),
776        DataType::Time => "TIME".into(),
777        DataType::Year => "YEAR".into(),
778        DataType::TimeTz => "TIMETZ".into(),
779        DataType::Money => "MONEY".into(),
780        DataType::Range(k) => k.keyword().into(),
781        DataType::Hstore => "HSTORE".into(),
782        DataType::IntArray2D => "INT[][]".into(),
783        DataType::BigIntArray2D => "BIGINT[][]".into(),
784        DataType::TextArray2D => "TEXT[][]".into(),
785    }
786}
787
788/// v6.5.2 — one row of `spg_stat_activity`. Engine-public so
789/// spg-server can construct rows without re-exporting internal
790/// dispatch types.
791#[derive(Debug, Clone)]
792pub struct ActivityRow {
793    pub pid: u32,
794    pub user: String,
795    pub started_at_us: i64,
796    pub current_sql: String,
797    pub wait_event: String,
798    pub elapsed_us: i64,
799    pub in_transaction: bool,
800    /// v7.17 Phase 2.4 — startup-param `application_name` (or the
801    /// last value the client sent via `SET application_name = '...'`).
802    /// Empty when the client never declared one.
803    pub application_name: String,
804}
805
806/// v6.5.2 — provider callback type. Fresh snapshot returned each
807/// call; engine doesn't cache the slice.
808pub type ActivityProvider = fn() -> Vec<ActivityRow>;
809
810/// v6.5.3 — one row of `spg_audit_chain`. Engine-public so
811/// spg-server can construct rows directly from `AuditEntry`.
812#[derive(Debug, Clone)]
813pub struct AuditRow {
814    pub seq: i64,
815    pub ts_ms: i64,
816    pub prev_hash_hex: String,
817    pub entry_hash_hex: String,
818    pub sql: String,
819}
820
821/// v6.5.3 — chain-table provider + verifier. spg-server registers
822/// fn pointers that snapshot / verify the audit log. `verify`
823/// returns `(verified_count, broken_at_seq)` — `broken_at_seq` is
824/// `-1` on a clean chain.
825pub type AuditChainProvider = fn() -> Vec<AuditRow>;
826pub type AuditVerifier = fn() -> (i64, i64);
827
828impl Engine {
829    pub fn new() -> Self {
830        Self {
831            catalog: Catalog::new(),
832            tx_catalogs: BTreeMap::new(),
833            current_tx: None,
834            next_tx_id: 1,
835            clock: None,
836            salt_fn: None,
837            max_query_rows: None,
838            users: UserStore::new(),
839            publications: publications::Publications::new(),
840            subscriptions: subscriptions::Subscriptions::new(),
841            statistics: statistics::Statistics::new(),
842            plan_cache: plan_cache::PlanCache::new(),
843            query_stats: query_stats::QueryStats::new(),
844            activity_provider: None,
845            audit_chain_provider: None,
846            audit_verifier: None,
847            slow_query_threshold_us: None,
848            slow_query_logger: None,
849            session_params: BTreeMap::new(),
850            trigger_recursion_depth: 0,
851            foreign_key_checks: true,
852            meta_views_materialised: false,
853            pending_foreign_keys: Vec::new(),
854        }
855    }
856
857    /// v7.11.0 — clone the engine's committed catalog + read-time
858    /// state into a frozen `CatalogSnapshot`. Cheap (`Catalog` is
859    /// backed by `PersistentVec`; cloning is O(log n) per table).
860    /// Subsequent writes to this engine are invisible to the
861    /// snapshot; the snapshot is self-contained and can be moved
862    /// to another thread for concurrent `execute_readonly_on_snapshot`
863    /// calls. The basis for [`AsyncReadHandle`] in spg-embedded-tokio
864    /// and any other read-fanout pattern.
865    #[must_use]
866    pub fn clone_snapshot(&self) -> CatalogSnapshot {
867        CatalogSnapshot {
868            catalog: self.active_catalog().clone(),
869            statistics: self.statistics.clone(),
870            clock: self.clock,
871            max_query_rows: self.max_query_rows,
872        }
873    }
874
875    /// v7.11.1 — execute a read-only SQL statement against a
876    /// `CatalogSnapshot` without touching this engine. Same
877    /// semantics as `execute_readonly` but parameterised on the
878    /// snapshot's catalog. Reject DDL/DML the same way
879    /// `execute_readonly` does. Static-on-Self so the caller can
880    /// dispatch without holding an `Engine` borrow alongside the
881    /// snapshot.
882    pub fn execute_readonly_on_snapshot(
883        snapshot: &CatalogSnapshot,
884        sql: &str,
885    ) -> Result<QueryResult, EngineError> {
886        Self::execute_readonly_on_snapshot_with_cancel(snapshot, sql, CancelToken::none())
887    }
888
889    /// v7.11.1 — `execute_readonly_on_snapshot` with cooperative
890    /// cancellation. Builds a transient `Engine` over the snapshot
891    /// state, runs `execute_readonly_with_cancel`, drops. The
892    /// transient engine is cheap to construct (no I/O; everything
893    /// is just struct moves) and lets the existing read path stay
894    /// untouched.
895    pub fn execute_readonly_on_snapshot_with_cancel(
896        snapshot: &CatalogSnapshot,
897        sql: &str,
898        cancel: CancelToken<'_>,
899    ) -> Result<QueryResult, EngineError> {
900        let transient = Engine {
901            catalog: snapshot.catalog.clone(),
902            statistics: snapshot.statistics.clone(),
903            clock: snapshot.clock,
904            max_query_rows: snapshot.max_query_rows,
905            ..Engine::default()
906        };
907        transient.execute_readonly_with_cancel(sql, cancel)
908    }
909
910    /// v7.18 — execute a previously-prepared `Statement` against a
911    /// `CatalogSnapshot` in read-only mode. Mirror of
912    /// [`Engine::execute_prepared`] for the fan-out read path:
913    /// substitutes `Expr::Placeholder(n)` nodes from `params`, then
914    /// dispatches through [`Engine::execute_readonly_stmt_with_cancel`]
915    /// (writes / DDL hit `EngineError::WriteRequired`). Static-on-Self
916    /// so multiple readonly threads can dispatch against the same
917    /// snapshot concurrently without an `Engine` borrow.
918    ///
919    /// **Schema drift contract**. The `Statement` was prepared against
920    /// some prior catalog. If the snapshot's catalog has since
921    /// diverged (DDL renamed / dropped a referenced column / table),
922    /// execution surfaces the normal `EngineError` — same shape as
923    /// PG's "cached plan must not change result type". Caller decides
924    /// whether to re-prepare; engine does NOT auto-retry.
925    pub fn execute_readonly_prepared_on_snapshot(
926        snapshot: &CatalogSnapshot,
927        stmt: Statement,
928        params: &[Value],
929    ) -> Result<QueryResult, EngineError> {
930        Self::execute_readonly_prepared_on_snapshot_with_cancel(
931            snapshot,
932            stmt,
933            params,
934            CancelToken::none(),
935        )
936    }
937
938    /// v7.18 — cancellable variant of
939    /// [`Engine::execute_readonly_prepared_on_snapshot`].
940    pub fn execute_readonly_prepared_on_snapshot_with_cancel(
941        snapshot: &CatalogSnapshot,
942        mut stmt: Statement,
943        params: &[Value],
944        cancel: CancelToken<'_>,
945    ) -> Result<QueryResult, EngineError> {
946        cancel.check()?;
947        substitute_placeholders(&mut stmt, params)?;
948        let transient = Engine {
949            catalog: snapshot.catalog.clone(),
950            statistics: snapshot.statistics.clone(),
951            clock: snapshot.clock,
952            max_query_rows: snapshot.max_query_rows,
953            ..Engine::default()
954        };
955        transient.execute_readonly_stmt_with_cancel(stmt, cancel)
956    }
957
958    /// v7.18 — describe a prepared `Statement` against a
959    /// `CatalogSnapshot`. Same `(parameter_oids, output_columns)`
960    /// shape as [`Engine::describe_prepared`]; resolves names
961    /// against the snapshot's catalog instead of `self`. Pure
962    /// function — no engine state read.
963    pub fn describe_prepared_on_snapshot(
964        snapshot: &CatalogSnapshot,
965        stmt: &Statement,
966    ) -> (Vec<u32>, Vec<ColumnSchema>) {
967        describe::describe_prepared(stmt, &snapshot.catalog)
968    }
969
970    /// v7.18 — does this SQL string classify as read-only? Parses
971    /// `sql` with the engine parser and consults
972    /// `Statement::is_readonly()`. A parse error returns `false`
973    /// (route to the writer path so the user sees the canonical
974    /// parse error from the writer's simple-query dispatch).
975    /// Static-on-Self so the spg-sqlx connection layer can ask
976    /// without an `Engine` borrow.
977    #[must_use]
978    pub fn is_readonly_sql(sql: &str) -> bool {
979        parser::parse_statement(sql)
980            .as_ref()
981            .map(spg_sql::ast::Statement::is_readonly)
982            .unwrap_or(false)
983    }
984
985    /// v7.18 — parse + plan a SQL string against a
986    /// `CatalogSnapshot`. Mirror of [`Engine::prepare`] for the
987    /// readonly fan-out path: applies the same prepare-time
988    /// transforms (clock rewrite, `GROUP BY ALL` expansion, ORDER
989    /// BY position resolve, cost-based JOIN reorder) but resolves
990    /// catalog + statistics against the snapshot, not a live
991    /// engine. Static-on-Self — `AsyncReadHandle::prepare` calls
992    /// this without taking the writer lock so multiple read
993    /// handles can prepare concurrently against frozen views.
994    ///
995    /// # Errors
996    /// Propagates [`ParseError`] from the parser. Schema
997    /// validation deferred to execute time, same as
998    /// [`Engine::prepare`].
999    pub fn prepare_on_snapshot(
1000        snapshot: &CatalogSnapshot,
1001        sql: &str,
1002    ) -> Result<Statement, ParseError> {
1003        let mut stmt = parser::parse_statement(sql)?;
1004        let now_micros = snapshot.clock.map(|f| f());
1005        rewrite_clock_calls(&mut stmt, now_micros);
1006        if let Statement::Select(s) = &mut stmt {
1007            expand_group_by_all(s);
1008            resolve_order_by_position(s);
1009            reorder::reorder_joins(s, &snapshot.catalog, &snapshot.statistics);
1010        }
1011        Ok(stmt)
1012    }
1013
1014    /// Construct an engine restored from a previously-snapshotted catalog
1015    /// (see `snapshot()`).
1016    pub fn restore(catalog: Catalog) -> Self {
1017        Self {
1018            catalog,
1019            tx_catalogs: BTreeMap::new(),
1020            current_tx: None,
1021            next_tx_id: 1,
1022            clock: None,
1023            salt_fn: None,
1024            max_query_rows: None,
1025            users: UserStore::new(),
1026            publications: publications::Publications::new(),
1027            subscriptions: subscriptions::Subscriptions::new(),
1028            statistics: statistics::Statistics::new(),
1029            plan_cache: plan_cache::PlanCache::new(),
1030            query_stats: query_stats::QueryStats::new(),
1031            activity_provider: None,
1032            audit_chain_provider: None,
1033            audit_verifier: None,
1034            slow_query_threshold_us: None,
1035            slow_query_logger: None,
1036            session_params: BTreeMap::new(),
1037            trigger_recursion_depth: 0,
1038            foreign_key_checks: true,
1039            meta_views_materialised: false,
1040            pending_foreign_keys: Vec::new(),
1041        }
1042    }
1043
1044    /// Restore an engine + user table from a v4.1 envelope produced
1045    /// by `snapshot_with_users()`. Falls back to plain catalog-only
1046    /// restore if the envelope magic isn't present (so v3.x snapshot
1047    /// files still load). v6.1.2 adds the optional publications
1048    /// trailer (envelope v3); a v1/v2 envelope deserialises to an
1049    /// empty publication table.
1050    pub fn restore_envelope(buf: &[u8]) -> Result<Self, EngineError> {
1051        match split_envelope(buf) {
1052            EnvelopeParse::Pair {
1053                catalog: catalog_bytes,
1054                users: user_bytes,
1055                publications: pub_bytes,
1056                subscriptions: sub_bytes,
1057                statistics: stats_bytes,
1058            } => {
1059                let catalog = Catalog::deserialize(catalog_bytes).map_err(EngineError::Storage)?;
1060                let users = users::deserialize_users(user_bytes)
1061                    .map_err(|e| EngineError::Unsupported(alloc::format!("users restore: {e}")))?;
1062                let publications = match pub_bytes {
1063                    Some(b) => publications::Publications::deserialize(b).map_err(|e| {
1064                        EngineError::Unsupported(alloc::format!("publications restore: {e:?}"))
1065                    })?,
1066                    None => publications::Publications::new(),
1067                };
1068                let subscriptions = match sub_bytes {
1069                    Some(b) => subscriptions::Subscriptions::deserialize(b).map_err(|e| {
1070                        EngineError::Unsupported(alloc::format!("subscriptions restore: {e:?}"))
1071                    })?,
1072                    None => subscriptions::Subscriptions::new(),
1073                };
1074                let statistics = match stats_bytes {
1075                    Some(b) => statistics::Statistics::deserialize(b).map_err(|e| {
1076                        EngineError::Unsupported(alloc::format!("statistics restore: {e:?}"))
1077                    })?,
1078                    None => statistics::Statistics::new(),
1079                };
1080                Ok(Self {
1081                    catalog,
1082                    tx_catalogs: BTreeMap::new(),
1083                    current_tx: None,
1084                    next_tx_id: 1,
1085                    clock: None,
1086                    salt_fn: None,
1087                    max_query_rows: None,
1088                    users,
1089                    publications,
1090                    subscriptions,
1091                    statistics,
1092                    plan_cache: plan_cache::PlanCache::new(),
1093                    query_stats: query_stats::QueryStats::new(),
1094                    activity_provider: None,
1095                    audit_chain_provider: None,
1096                    audit_verifier: None,
1097                    slow_query_threshold_us: None,
1098                    slow_query_logger: None,
1099                    session_params: BTreeMap::new(),
1100                    trigger_recursion_depth: 0,
1101                    foreign_key_checks: true,
1102                    meta_views_materialised: false,
1103                    pending_foreign_keys: Vec::new(),
1104                })
1105            }
1106            EnvelopeParse::CrcMismatch { expected, computed } => {
1107                Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
1108                    "snapshot envelope CRC32 mismatch (expected={expected:#010x}, computed={computed:#010x})"
1109                ))))
1110            }
1111            EnvelopeParse::Bare => {
1112                let catalog = Catalog::deserialize(buf).map_err(EngineError::Storage)?;
1113                Ok(Self::restore(catalog))
1114            }
1115        }
1116    }
1117
1118    pub const fn users(&self) -> &UserStore {
1119        &self.users
1120    }
1121
1122    /// `salt` is supplied by the caller (the host has a random
1123    /// source; the engine is `no_std`). Caller should pass a fresh
1124    /// 16-byte random value per user.
1125    pub fn create_user(
1126        &mut self,
1127        name: &str,
1128        password: &str,
1129        role: Role,
1130        salt: [u8; 16],
1131    ) -> Result<(), UserError> {
1132        self.users.create(name, password, role, salt)?;
1133        // v4.8: also derive SCRAM-SHA-256 secrets so PG-wire SASL
1134        // auth can verify without re-running PBKDF2 per attempt.
1135        // Uses a fresh salt from the host RNG (falls back to a
1136        // deterministic per-username salt when no RNG is wired, same
1137        // as the legacy hash path).
1138        let scram_salt = self.salt_fn.map_or_else(
1139            || {
1140                let mut s = [0u8; users::SCRAM_SALT_LEN];
1141                let digest = spg_crypto::hash(name.as_bytes());
1142                // Use bytes 16..32 of BLAKE3 so we don't reuse the
1143                // exact same fallback salt as the BLAKE3 hash path.
1144                s.copy_from_slice(&digest[16..32]);
1145                s
1146            },
1147            |f| f(),
1148        );
1149        self.users
1150            .enable_scram(name, password, scram_salt, users::SCRAM_DEFAULT_ITERS)?;
1151        Ok(())
1152    }
1153
1154    pub fn drop_user(&mut self, name: &str) -> Result<(), UserError> {
1155        self.users.drop(name)
1156    }
1157
1158    pub fn verify_user(&self, name: &str, password: &str) -> Option<Role> {
1159        self.users.verify(name, password)
1160    }
1161
1162    /// Builder: attach a wall clock so `NOW()` / `CURRENT_TIMESTAMP` /
1163    /// `CURRENT_DATE` evaluate to a real value instead of erroring out.
1164    #[must_use]
1165    pub const fn with_clock(mut self, clock: ClockFn) -> Self {
1166        self.clock = Some(clock);
1167        self
1168    }
1169
1170    /// Builder: attach an OS-backed RNG for per-user password salts.
1171    /// The host (`spg-server`) typically wires this to `/dev/urandom`.
1172    #[must_use]
1173    pub const fn with_salt_fn(mut self, f: SaltFn) -> Self {
1174        self.salt_fn = Some(f);
1175        self
1176    }
1177
1178    /// Builder: cap the number of rows a single SELECT may return.
1179    /// Exceeding the cap raises `EngineError::RowLimitExceeded` —
1180    /// the bound is checked inside the executor so a runaway
1181    /// catalog scan can't allocate millions of rows before the
1182    /// server gets a chance to reject the result.
1183    #[must_use]
1184    pub const fn with_max_query_rows(mut self, n: usize) -> Self {
1185        self.max_query_rows = Some(n);
1186        self
1187    }
1188
1189    /// The *committed* catalog. Note: during a transaction this returns the
1190    /// pre-TX state — `SELECT` inside a TX goes through `execute()` and reads
1191    /// the shadow. Tests that inspect outside-TX state should use this.
1192    pub const fn catalog(&self) -> &Catalog {
1193        &self.catalog
1194    }
1195
1196    /// Serialize the *committed* catalog to bytes. v0.6 was full-snapshot; v0.9
1197    /// adds the rule that an open TX's shadow is never snapshotted — only the
1198    /// post-COMMIT state is persisted. v4.1 wraps the catalog in an envelope
1199    /// when there are users to persist; an empty user table snapshots as the
1200    /// bare catalog format (backwards-compat with v3.x readers). v6.1.2
1201    /// adds publications to the envelope condition: either non-empty
1202    /// users OR non-empty publications now triggers the envelope path.
1203    pub fn snapshot(&self) -> Vec<u8> {
1204        if self.users.is_empty()
1205            && self.publications.is_empty()
1206            && self.subscriptions.is_empty()
1207            && self.statistics.is_empty()
1208        {
1209            self.catalog.serialize()
1210        } else {
1211            build_envelope(
1212                &self.catalog.serialize(),
1213                &users::serialize_users(&self.users),
1214                &self.publications.serialize(),
1215                &self.subscriptions.serialize(),
1216                &self.statistics.serialize(),
1217            )
1218        }
1219    }
1220
1221    /// True when at least one TX slot is in flight. v4.41.1 runtime
1222    /// invariant: at most one slot active at a time (dispatch holds
1223    /// `engine.write()` across the entire wrap). v4.42 will let this
1224    /// return true with multiple slots concurrently.
1225    pub fn in_transaction(&self) -> bool {
1226        !self.tx_catalogs.is_empty()
1227    }
1228
1229    /// v4.41.1 allocate a fresh TX handle. Used by spg-server dispatch
1230    /// to scope each implicit-wrap BEGIN..stmt..COMMIT to its own slot
1231    /// in `tx_catalogs`. v4.42 — the commit-barrier leader allocates
1232    /// one of these per task in its group, runs `BEGIN`+sql+`COMMIT`
1233    /// sequentially under a single `engine.write()` so each task's
1234    /// mutations accumulate into shared state, then either keeps the
1235    /// accumulated state (fsync OK) or restores the pre-image via
1236    /// `replace_catalog` (fsync err).
1237    pub fn alloc_tx_id(&mut self) -> TxId {
1238        let id = TxId(self.next_tx_id);
1239        self.next_tx_id = self.next_tx_id.saturating_add(1);
1240        id
1241    }
1242
1243    /// v4.42 — atomically replace the live catalog. Used by the
1244    /// commit-barrier leader to roll back a group whose batched
1245    /// fsync failed: the leader snapshots `engine.catalog().clone()`
1246    /// (O(1) Arc bump after the v4.39/v4.40 persistent migration)
1247    /// at group start, sequentially applies each task's BEGIN+sql+
1248    /// COMMIT under the same write lock to accumulate mutations
1249    /// into shared state, batches the WAL bytes, fsyncs once, and
1250    /// on failure calls this with the pre-image to undo every
1251    /// task in the group at once.
1252    ///
1253    /// **Does NOT touch `tx_catalogs` / `current_tx`.** Any
1254    /// explicit-TX slot from a concurrent client (created via the
1255    /// legacy `IMPLICIT_TX`-less dispatch path or via the future
1256    /// MVCC-readers v5+ work) has its own snapshot baked into the
1257    /// slot — restoring `self.catalog` to the pre-image leaves
1258    /// those slots untouched, exactly as they were when the leader
1259    /// took the lock. The leader's own implicit-TX slots are all
1260    /// already discarded (`exec_commit` removed them as each
1261    /// task's COMMIT ran) by the time this is reached.
1262    pub fn replace_catalog(&mut self, catalog: Catalog) {
1263        self.catalog = catalog;
1264    }
1265
1266    /// v6.7.0 — public shim around `Catalog::freeze_oldest_to_cold`
1267    /// so tests + the spg-server freezer can drive a freeze without
1268    /// reaching into the private `active_catalog_mut`. v6.7.4
1269    /// parallel freezer will build on this surface.
1270    ///
1271    /// Marks the table's cached `cold_row_count` stale because the
1272    /// freeze added cold locators that ANALYZE hasn't yet refreshed.
1273    pub fn freeze_oldest_to_cold(
1274        &mut self,
1275        table_name: &str,
1276        index_name: &str,
1277        max_rows: usize,
1278    ) -> Result<spg_storage::FreezeReport, EngineError> {
1279        let report = self
1280            .active_catalog_mut()
1281            .freeze_oldest_to_cold(table_name, index_name, max_rows)
1282            .map_err(EngineError::Storage)?;
1283        if let Some(t) = self.active_catalog_mut().get_mut(table_name) {
1284            t.mark_cold_row_count_stale();
1285        }
1286        Ok(report)
1287    }
1288
1289    /// v6.7.5 — public shim used by the spg-server follower's
1290    /// segment-forwarding receiver. Registers a cold-tier segment
1291    /// at a specific id (the master's id, as transmitted on the
1292    /// wire) so the follower's BTree-Cold locators stay byte-
1293    /// identical with the master's. Wraps
1294    /// `Catalog::load_segment_bytes_at` under the standard
1295    /// clone-mutate-replace pattern.
1296    ///
1297    /// Returns `Ok(())` on success **and** on the "slot already
1298    /// occupied" case — a follower mid-reconnect may receive a
1299    /// segment chunk for a segment_id it already has on disk
1300    /// (forwarded last session); the caller should treat that
1301    /// path as a no-op rather than a fatal error.
1302    pub fn receive_cold_segment(
1303        &mut self,
1304        segment_id: u32,
1305        bytes: Vec<u8>,
1306    ) -> Result<(), EngineError> {
1307        let mut new_cat = self.catalog.clone();
1308        match new_cat.load_segment_bytes_at(segment_id, bytes) {
1309            Ok(()) => {
1310                self.replace_catalog(new_cat);
1311                Ok(())
1312            }
1313            Err(StorageError::Corrupt(msg)) if msg.contains("already occupied") => Ok(()),
1314            Err(e) => Err(EngineError::Storage(e)),
1315        }
1316    }
1317
1318    /// v6.7.3 — public shim around `Catalog::compact_cold_segments`
1319    /// driving every BTree index on every user table. Returns one
1320    /// `(table, index, report)` triple for each merge that
1321    /// actually happened (no-op (table, index) pairs are filtered
1322    /// out so callers can size persist-side work to the live
1323    /// merges). Caller is responsible for persisting each
1324    /// `report.merged_segment_bytes` and updating the on-disk
1325    /// segment registry; engine layer is no_std and never
1326    /// touches disk.
1327    ///
1328    /// Marks every touched table's cached `cold_row_count` stale
1329    /// — compaction GC'd some shadowed rows, so the count must be
1330    /// re-derived on the next ANALYZE.
1331    pub fn compact_cold_segments_with_target(
1332        &mut self,
1333        target_segment_bytes: u64,
1334    ) -> Result<Vec<(String, String, CompactReport)>, EngineError> {
1335        let table_names = self.active_catalog().table_names();
1336        let mut reports: Vec<(String, String, CompactReport)> = Vec::new();
1337        for tname in table_names {
1338            if is_internal_table_name(&tname) {
1339                continue;
1340            }
1341            let idx_names: Vec<String> = {
1342                let Some(t) = self.active_catalog().get(&tname) else {
1343                    continue;
1344                };
1345                t.indices()
1346                    .iter()
1347                    .filter(|i| matches!(i.kind, IndexKind::BTree(_)))
1348                    .map(|i| i.name.clone())
1349                    .collect()
1350            };
1351            for iname in idx_names {
1352                let report = self
1353                    .active_catalog_mut()
1354                    .compact_cold_segments(&tname, &iname, target_segment_bytes)
1355                    .map_err(EngineError::Storage)?;
1356                if report.merged_segment_id.is_some() {
1357                    if let Some(t) = self.active_catalog_mut().get_mut(&tname) {
1358                        t.mark_cold_row_count_stale();
1359                    }
1360                    reports.push((tname.clone(), iname, report));
1361                }
1362            }
1363        }
1364        Ok(reports)
1365    }
1366
1367    fn active_catalog(&self) -> &Catalog {
1368        match self.current_tx {
1369            Some(t) => self
1370                .tx_catalogs
1371                .get(&t)
1372                .map_or(&self.catalog, |s| &s.catalog),
1373            None => &self.catalog,
1374        }
1375    }
1376
1377    /// v7.12.4 — snapshot every row-level trigger on `table` that
1378    /// fires for `event` (`"INSERT"` / `"UPDATE"` / `"DELETE"`) at
1379    /// the given `timing` (`"BEFORE"` / `"AFTER"`), and clone its
1380    /// referenced function definition. Returned as a vec of owned
1381    /// `FunctionDef` so the row-write loop can fire them without
1382    /// holding a borrow on the catalog (which would conflict with
1383    /// the table.insert / update_row / delete mutable borrows).
1384    /// v7.16.2 — top-level DO block executor. Walks the
1385    /// PlPgSqlBlock via [`triggers::execute_do_block_top_level`],
1386    /// then runs each collected EmbeddedSql statement through
1387    /// the engine's regular execute path (NOT deferred — DO is
1388    /// outside any row-write borrow). Errors from any step
1389    /// abort the block and propagate verbatim.
1390    /// v7.16.2 — resolve every subquery inside a PlPgSqlBlock's
1391    /// expression slots so the downstream trigger-flavoured
1392    /// evaluator (which expects pre-resolved Expr::Literal /
1393    /// Binary chains) doesn't trip on raw Exists/ScalarSubquery
1394    /// nodes. Walks IF conditions, Assign values, RAISE args.
1395    /// EmbeddedSql statements re-enter the engine for execution
1396    /// later so their subqueries get the normal SELECT-side
1397    /// resolution.
1398    fn resolve_plpgsql_block_subqueries(
1399        &self,
1400        block: &mut spg_sql::ast::PlPgSqlBlock,
1401        cancel: CancelToken<'_>,
1402    ) -> Result<(), EngineError> {
1403        for d in &mut block.declarations {
1404            if let Some(e) = &mut d.default {
1405                self.resolve_expr_subqueries(e, cancel)?;
1406            }
1407        }
1408        self.resolve_plpgsql_stmts_subqueries(&mut block.statements, cancel)
1409    }
1410
1411    fn resolve_plpgsql_stmts_subqueries(
1412        &self,
1413        stmts: &mut [spg_sql::ast::PlPgSqlStmt],
1414        cancel: CancelToken<'_>,
1415    ) -> Result<(), EngineError> {
1416        use spg_sql::ast::PlPgSqlStmt;
1417        for stmt in stmts {
1418            match stmt {
1419                PlPgSqlStmt::Assign { value, .. } => {
1420                    self.resolve_expr_subqueries(value, cancel)?;
1421                }
1422                PlPgSqlStmt::Return(spg_sql::ast::ReturnTarget::Expr(e)) => {
1423                    self.resolve_expr_subqueries(e, cancel)?;
1424                }
1425                PlPgSqlStmt::Return(_) => {}
1426                PlPgSqlStmt::If {
1427                    branches,
1428                    else_branch,
1429                } => {
1430                    for (cond, body) in branches.iter_mut() {
1431                        self.resolve_expr_subqueries(cond, cancel)?;
1432                        self.resolve_plpgsql_stmts_subqueries(body, cancel)?;
1433                    }
1434                    self.resolve_plpgsql_stmts_subqueries(else_branch, cancel)?;
1435                }
1436                PlPgSqlStmt::Raise { args, .. } => {
1437                    for a in args {
1438                        self.resolve_expr_subqueries(a, cancel)?;
1439                    }
1440                }
1441                PlPgSqlStmt::EmbeddedSql(_) => {
1442                    // Embedded SQL goes back through execute_stmt
1443                    // _with_cancel which runs the SELECT-side
1444                    // resolver itself; nothing to do here.
1445                }
1446                PlPgSqlStmt::SelectInto { body, .. } => {
1447                    // SELECT INTO runs through Engine::execute
1448                    // when reached, so subquery resolution
1449                    // happens via the normal SELECT-side path.
1450                    // Still walk for nested subqueries inside
1451                    // the SELECT body so eval doesn't trip.
1452                    self.resolve_select_subqueries(body, cancel)?;
1453                }
1454            }
1455        }
1456        Ok(())
1457    }
1458
1459    fn exec_do_block(
1460        &mut self,
1461        body: spg_sql::ast::PlPgSqlBlock,
1462    ) -> Result<QueryResult, EngineError> {
1463        // v7.16.2 — pre-resolve every subquery the body's
1464        // expressions reach. `eval::eval_expr` errors on
1465        // unresolved Exists/ScalarSubquery/InSubquery; the
1466        // top-level SELECT path runs `resolve_select_subqueries`
1467        // for the caller — for plpgsql we have to do the
1468        // equivalent before the body walker runs. Catches the
1469        // mailrs idiom `IF EXISTS (SELECT 1 FROM
1470        // information_schema.columns WHERE …) THEN …`.
1471        let mut body = body;
1472        self.resolve_plpgsql_block_subqueries(&mut body, CancelToken::none())?;
1473        let dts = self
1474            .session_param("default_text_search_config")
1475            .map(String::from);
1476        // v7.16.2 — SELECT … INTO resolver. The walker calls
1477        // this synchronously when it hits a SelectInto stmt
1478        // so the IF / locals scope sees the result before the
1479        // next statement. Body walks for trigger paths (no
1480        // resolver) error loudly on SelectInto.
1481        // SAFETY: the closure shares this engine borrow with
1482        // the walker, but the walker only borrows for the
1483        // duration of `execute_do_block_top_level` and doesn't
1484        // reach back into the engine through any other path —
1485        // so the recursive `&mut` is sound. We use a `RefCell`
1486        // for interior mutability since the closure is
1487        // Fn-shaped.
1488        let engine_cell = core::cell::RefCell::new(&mut *self);
1489        let resolver_fn =
1490            |stmt: &spg_sql::ast::Statement| -> Result<Value, triggers::TriggerError> {
1491                let mut eng = engine_cell.borrow_mut();
1492                let r = eng
1493                    .execute_stmt_with_cancel(stmt.clone(), CancelToken::none())
1494                    .map_err(|e| triggers::TriggerError::EvalFailed {
1495                        function: "DO".into(),
1496                        cause: eval::EvalError::TypeMismatch {
1497                            detail: alloc::format!("SELECT … INTO failed: {e}"),
1498                        },
1499                    })?;
1500                match r {
1501                    QueryResult::Rows { rows, .. } => match rows.into_iter().next() {
1502                        Some(row) => Ok(row.values.into_iter().next().unwrap_or(Value::Null)),
1503                        None => Ok(Value::Null),
1504                    },
1505                    _ => Err(triggers::TriggerError::EvalFailed {
1506                        function: "DO".into(),
1507                        cause: eval::EvalError::TypeMismatch {
1508                            detail: "SELECT … INTO body must be a SELECT".into(),
1509                        },
1510                    }),
1511                }
1512            };
1513        let collected =
1514            triggers::execute_do_block_top_level(&body, dts.as_deref(), Some(&resolver_fn))
1515                .map_err(|e| {
1516                    EngineError::Storage(StorageError::Corrupt(alloc::format!("DO: {e}")))
1517                })?;
1518        // engine_cell goes out of scope here, releasing the &mut self borrow
1519        // Run each embedded statement against the engine. The
1520        // statements were already substitute-walked for NEW/OLD/
1521        // locals (those evaluate to engine literals before they
1522        // land here) so dispatch is plain execute_stmt_with_cancel.
1523        for stmt in collected {
1524            // v7.16.2 — preserve current_tx wrap so an outer
1525            // BEGIN/COMMIT around a DO block keeps the
1526            // EmbeddedSql writes inside that same tx slot.
1527            self.execute_stmt_with_cancel(stmt, CancelToken::none())?;
1528        }
1529        Ok(QueryResult::CommandOk {
1530            affected: 0,
1531            modified_catalog: !self.in_transaction(),
1532        })
1533    }
1534
1535    fn snapshot_row_triggers(
1536        &self,
1537        table: &str,
1538        event: &str,
1539        timing: &str,
1540    ) -> Vec<spg_storage::FunctionDef> {
1541        let cat = self.active_catalog();
1542        cat.triggers()
1543            .iter()
1544            .filter(|t| {
1545                // v7.16.1 — skip disabled triggers (mailrs
1546                // round-9 A.2.b — pg_dump --disable-triggers).
1547                t.enabled
1548                    && t.table == table
1549                    && t.timing.eq_ignore_ascii_case(timing)
1550                    && t.for_each.eq_ignore_ascii_case("row")
1551                    && t.events.iter().any(|e| e.eq_ignore_ascii_case(event))
1552            })
1553            .filter_map(|t| cat.functions().get(&t.function).cloned())
1554            .collect()
1555    }
1556
1557    /// v7.13.0 — UPDATE-side snapshot that pairs each trigger's
1558    /// function with its `UPDATE OF cols` filter (mailrs round-5
1559    /// G7). Empty filter Vec means "fire unconditionally", matching
1560    /// the v7.12 behaviour.
1561    fn snapshot_update_row_triggers(
1562        &self,
1563        table: &str,
1564        timing: &str,
1565    ) -> Vec<(spg_storage::FunctionDef, Vec<String>)> {
1566        let cat = self.active_catalog();
1567        cat.triggers()
1568            .iter()
1569            .filter(|t| {
1570                // v7.16.1 — skip disabled triggers.
1571                t.enabled
1572                    && t.table == table
1573                    && t.timing.eq_ignore_ascii_case(timing)
1574                    && t.for_each.eq_ignore_ascii_case("row")
1575                    && t.events.iter().any(|e| e.eq_ignore_ascii_case("UPDATE"))
1576            })
1577            .filter_map(|t| {
1578                cat.functions()
1579                    .get(&t.function)
1580                    .cloned()
1581                    .map(|fd| (fd, t.update_columns.clone()))
1582            })
1583            .collect()
1584    }
1585
1586    /// v7.12.7 — drain the trigger-emitted embedded SQL queue.
1587    /// Called by the INSERT / UPDATE / DELETE executors after
1588    /// their main row-write loop returns. Each statement runs
1589    /// inside the same cancel scope as the firing DML and bumps
1590    /// the recursion counter; nested embedded SQL beyond
1591    /// [`MAX_TRIGGER_RECURSION`] errors with a clear message so
1592    /// a trigger-graph cycle surfaces as a query failure instead
1593    /// of stack-blowing the engine.
1594    fn execute_deferred_trigger_stmts(
1595        &mut self,
1596        deferred: Vec<triggers::DeferredEmbeddedStmt>,
1597        cancel: CancelToken<'_>,
1598    ) -> Result<(), EngineError> {
1599        for d in deferred {
1600            if self.trigger_recursion_depth >= MAX_TRIGGER_RECURSION {
1601                return Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
1602                    "trigger embedded SQL recursion depth {} exceeded (trigger function \
1603                     {:?} would push past the {} cap — check for trigger cycles)",
1604                    self.trigger_recursion_depth,
1605                    d.function,
1606                    MAX_TRIGGER_RECURSION,
1607                ))));
1608            }
1609            self.trigger_recursion_depth += 1;
1610            let res = self.execute_stmt_with_cancel(d.stmt, cancel);
1611            self.trigger_recursion_depth -= 1;
1612            res?;
1613        }
1614        Ok(())
1615    }
1616
1617    fn active_catalog_mut(&mut self) -> &mut Catalog {
1618        let tx = self.current_tx;
1619        match tx {
1620            Some(t) => match self.tx_catalogs.get_mut(&t) {
1621                Some(s) => &mut s.catalog,
1622                None => &mut self.catalog,
1623            },
1624            None => &mut self.catalog,
1625        }
1626    }
1627
1628    /// Read-only execute path. Succeeds for `SELECT` / `SHOW TABLES`
1629    /// / `SHOW COLUMNS`; returns `EngineError::WriteRequired` for
1630    /// every other statement, so the caller can fall through to the
1631    /// `&mut self` `execute` path under a write lock. Engine state is
1632    /// not mutated even on the success path (`rewrite_clock_calls`
1633    /// and `resolve_order_by_position` both mutate the locally-owned
1634    /// AST, not `self`).
1635    ///
1636    /// **v4.0 concurrency**: this is the entry point the server takes
1637    /// under an `RwLock::read()` so multiple `SELECT` clients run in
1638    /// parallel without serialising on a single mutex.
1639    pub fn execute_readonly(&self, sql: &str) -> Result<QueryResult, EngineError> {
1640        self.execute_readonly_with_cancel(sql, CancelToken::none())
1641    }
1642
1643    /// v4.5 — read path with cooperative cancellation. Token's
1644    /// `is_cancelled` is checked at the start (so a watchdog that
1645    /// already fired returns Cancelled immediately) and at row-loop
1646    /// checkpoints inside `exec_select`. SHOW paths are O(small) and
1647    /// don't bother checking.
1648    pub fn execute_readonly_with_cancel(
1649        &self,
1650        sql: &str,
1651        cancel: CancelToken<'_>,
1652    ) -> Result<QueryResult, EngineError> {
1653        cancel.check()?;
1654        let mut stmt = parser::parse_statement(sql)?;
1655        let now_micros = self.clock.map(|f| f());
1656        rewrite_clock_calls(&mut stmt, now_micros);
1657        if let Statement::Select(s) = &mut stmt {
1658            resolve_order_by_position(s);
1659            // v6.2.3 — cost-based JOIN reorder (read path).
1660            reorder::reorder_joins(s, &self.catalog, &self.statistics);
1661        }
1662        self.execute_readonly_stmt_with_cancel(stmt, cancel)
1663    }
1664
1665    /// v7.18 — readonly dispatch on a pre-parsed `Statement`.
1666    /// Internal helper shared by the SQL-string path
1667    /// ([`Engine::execute_readonly_with_cancel`]) and the prepared-
1668    /// statement path ([`Engine::execute_readonly_prepared_on_snapshot_with_cancel`]).
1669    /// Statement-level transforms (clock rewrite, ORDER BY position,
1670    /// JOIN reorder, placeholder substitution) are the caller's
1671    /// responsibility — this helper assumes the AST is already
1672    /// execution-ready. Writes / DDL hit
1673    /// [`EngineError::WriteRequired`] the same way the SQL path does.
1674    fn execute_readonly_stmt_with_cancel(
1675        &self,
1676        stmt: Statement,
1677        cancel: CancelToken<'_>,
1678    ) -> Result<QueryResult, EngineError> {
1679        let result = match stmt {
1680            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
1681            Statement::ShowTables => Ok(self.exec_show_tables()),
1682            Statement::ShowDatabases => Ok(self.exec_show_databases()),
1683            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
1684            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
1685            Statement::ShowStatus => Ok(self.exec_show_status()),
1686            Statement::ShowVariables => Ok(self.exec_show_variables()),
1687            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
1688            Statement::ShowColumns(table) => self.exec_show_columns(&table),
1689            Statement::ShowUsers => Ok(self.exec_show_users()),
1690            Statement::ShowPublications => Ok(self.exec_show_publications()),
1691            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
1692            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
1693                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
1694            )),
1695            Statement::Explain(e) => self.exec_explain(&e, cancel),
1696            _ => Err(EngineError::WriteRequired),
1697        };
1698        self.enforce_row_limit(result)
1699    }
1700
1701    /// v4.2: cap result-set size. Applied after the executor
1702    /// materialises rows but before they leave the engine — wrapping
1703    /// every Rows-returning exec_* function would scatter the check.
1704    fn enforce_row_limit(
1705        &self,
1706        result: Result<QueryResult, EngineError>,
1707    ) -> Result<QueryResult, EngineError> {
1708        if let (Ok(QueryResult::Rows { rows, .. }), Some(cap)) = (&result, self.max_query_rows)
1709            && rows.len() > cap
1710        {
1711            return Err(EngineError::RowLimitExceeded(cap));
1712        }
1713        result
1714    }
1715
1716    pub fn execute(&mut self, sql: &str) -> Result<QueryResult, EngineError> {
1717        self.execute_in_with_cancel(sql, IMPLICIT_TX, CancelToken::none())
1718    }
1719
1720    /// v4.5 — write path with cooperative cancellation. Same dispatch
1721    /// as `execute_in_with_cancel(sql, IMPLICIT_TX, cancel)`. Kept as
1722    /// a separate entry point for backward-compat with the v4.5
1723    /// public API.
1724    pub fn execute_with_cancel(
1725        &mut self,
1726        sql: &str,
1727        cancel: CancelToken<'_>,
1728    ) -> Result<QueryResult, EngineError> {
1729        self.execute_in_with_cancel(sql, IMPLICIT_TX, cancel)
1730    }
1731
1732    /// v4.41.1 multi-slot write entry. Routes `sql` through the TX
1733    /// slot identified by `tx_id` so spg-server dispatch can scope
1734    /// each implicit-wrap BEGIN..stmt..COMMIT to its own slot in
1735    /// `tx_catalogs`. `IMPLICIT_TX` is the legacy single-slot path
1736    /// every other caller (engine self-tests, replay, spg-embedded)
1737    /// implicitly takes via `execute()` / `execute_with_cancel()`.
1738    pub fn execute_in(&mut self, sql: &str, tx_id: TxId) -> Result<QueryResult, EngineError> {
1739        self.execute_in_with_cancel(sql, tx_id, CancelToken::none())
1740    }
1741
1742    /// v4.41.1 write path with cooperative cancellation + explicit TX
1743    /// scope. Sets `self.current_tx` for the duration of the call so
1744    /// every `exec_*` helper transparently sees its TX's shadow
1745    /// catalog and savepoint stack; restores on exit so the field is
1746    /// only valid mid-call (no leakage across calls).
1747    pub fn execute_in_with_cancel(
1748        &mut self,
1749        sql: &str,
1750        tx_id: TxId,
1751        cancel: CancelToken<'_>,
1752    ) -> Result<QueryResult, EngineError> {
1753        let saved = self.current_tx;
1754        self.current_tx = Some(tx_id);
1755        let result = self.execute_inner_with_cancel(sql, cancel);
1756        self.current_tx = saved;
1757        result
1758    }
1759
1760    /// v6.1.1 — parse and pre-process a SQL string ONCE so the
1761    /// resulting [`Statement`] can be cached and re-executed via
1762    /// [`Engine::execute_prepared`]. Returns the same `Statement`
1763    /// the simple-query path would synthesise internally (clock
1764    /// rewrites + ORDER BY position-ref resolution applied at
1765    /// prepare time, since both are session-independent). The
1766    /// `$N` placeholders in the SQL stay as `Expr::Placeholder(n)`
1767    /// nodes; they're resolved to concrete values per-call by
1768    /// `execute_prepared`'s substitution walk.
1769    ///
1770    /// Pgwire's `Parse` (P) message lands here.
1771    pub fn prepare(&self, sql: &str) -> Result<Statement, ParseError> {
1772        let mut stmt = parser::parse_statement(sql)?;
1773        let now_micros = self.clock.map(|f| f());
1774        rewrite_clock_calls(&mut stmt, now_micros);
1775        if let Statement::Select(s) = &mut stmt {
1776            // v6.4.1 — expand `GROUP BY ALL` to every non-aggregate
1777            // SELECT-list item BEFORE position / alias resolution so
1778            // downstream passes see the explicit list.
1779            expand_group_by_all(s);
1780            resolve_order_by_position(s);
1781            // v6.2.3 — cost-based JOIN reorder. No-op for
1782            // single-table FROMs or any non-INNER join shape.
1783            reorder::reorder_joins(s, &self.catalog, &self.statistics);
1784        }
1785        Ok(stmt)
1786    }
1787
1788    /// v6.3.0 — cached prepare. Returns a cloned `Statement` from
1789    /// the plan cache on hit, runs the full `prepare()` path on miss
1790    /// and inserts the resulting plan before returning. Skipping the
1791    /// parse + JOIN-reorder pipeline on hit is the dominant win for
1792    /// JDBC / sqlx / pgx clients that reuse the same SQL string.
1793    ///
1794    /// Returns a cloned `Statement` (not a borrow) because the
1795    /// pgwire layer owns its `PreparedStmt` map per-session and the
1796    /// engine-level cache must stay available for other sessions.
1797    /// Clone cost on a 5-table JOIN AST is well under the parse cost
1798    /// it replaces.
1799    pub fn prepare_cached(&mut self, sql: &str) -> Result<Statement, ParseError> {
1800        // v6.3.1 — version-aware lookup. If the cached plan was
1801        // prepared before the most recent ANALYZE, evict and replan.
1802        let current_version = self.statistics.version();
1803        if let Some(plan) = self.plan_cache.get(sql) {
1804            if plan.statistics_version == current_version {
1805                return Ok(plan.stmt.clone());
1806            }
1807            // Stale entry — fall through to evict + re-prepare.
1808        }
1809        self.plan_cache.evict(sql);
1810        let stmt = self.prepare(sql)?;
1811        let source_tables = plan_cache::collect_source_tables(&stmt);
1812        let plan = plan_cache::PreparedPlan {
1813            stmt: stmt.clone(),
1814            statistics_version: current_version,
1815            source_tables,
1816            describe_columns: alloc::vec::Vec::new(),
1817        };
1818        self.plan_cache.insert(String::from(sql), plan);
1819        Ok(stmt)
1820    }
1821
1822    /// v6.3.0 — read-only accessor for tests and v6.3.1 invalidation.
1823    pub fn plan_cache(&self) -> &plan_cache::PlanCache {
1824        &self.plan_cache
1825    }
1826
1827    /// v6.3.0 — mutable accessor for v6.3.1 invalidation hooks.
1828    pub fn plan_cache_mut(&mut self) -> &mut plan_cache::PlanCache {
1829        &mut self.plan_cache
1830    }
1831
1832    /// v6.3.3 — Describe a prepared `Statement` without executing.
1833    /// Returns `(parameter_oids, output_columns)`. Empty
1834    /// `output_columns` means the statement has no row-producing
1835    /// shape we could resolve here (JOIN, subquery, non-SELECT, …)
1836    /// — pgwire layer maps that to a `NoData` reply.
1837    pub fn describe_prepared(&self, stmt: &Statement) -> (Vec<u32>, Vec<ColumnSchema>) {
1838        describe::describe_prepared(stmt, self.active_catalog())
1839    }
1840
1841    /// v6.1.1 — execute a [`Statement`] previously returned by
1842    /// [`Engine::prepare`], substituting `Expr::Placeholder(n)`
1843    /// nodes for the corresponding [`Value`] in `params` (1-based
1844    /// per PG: `$1` → `params[0]`). Bind-time string parameters
1845    /// are decoded into typed `Value`s by the pgwire layer before
1846    /// this call so the resulting AST hits the same execution
1847    /// path as a simple query — no SQL re-parse.
1848    ///
1849    /// Pgwire's `Execute` (E) message after a `Bind` (B) lands here.
1850    pub fn execute_prepared(
1851        &mut self,
1852        stmt: Statement,
1853        params: &[Value],
1854    ) -> Result<QueryResult, EngineError> {
1855        self.execute_prepared_with_cancel(stmt, params, CancelToken::none())
1856    }
1857
1858    /// v7.17.0 Phase 2.3 — prepared-statement entry that honors a
1859    /// caller-supplied `CancelToken`. Mirrors `execute_prepared`'s
1860    /// `current_tx` save/restore so the extended-query path stays
1861    /// transactionally consistent with the simple-query path.
1862    pub fn execute_prepared_with_cancel(
1863        &mut self,
1864        mut stmt: Statement,
1865        params: &[Value],
1866        cancel: CancelToken<'_>,
1867    ) -> Result<QueryResult, EngineError> {
1868        substitute_placeholders(&mut stmt, params)?;
1869        // v7.16.0 — set `current_tx` for the duration of the
1870        // dispatch so the `exec_*` helpers see the right TX
1871        // slot (matches what `execute_in_with_cancel` does for
1872        // simple-query). Pre-v7.16 the simple-query path
1873        // worked because every public entry point routed
1874        // through `execute_in_with_cancel`; the prepared path
1875        // skipped the wrap and so its INSERTs/UPDATEs landed
1876        // in the no-tx default slot, silently invisible to a
1877        // BEGIN/COMMIT-bracketed flow. Caught by spg-sqlx's
1878        // first transaction-visibility test.
1879        let saved = self.current_tx;
1880        self.current_tx = Some(IMPLICIT_TX);
1881        let result = self.execute_stmt_with_cancel(stmt, cancel);
1882        self.current_tx = saved;
1883        result
1884    }
1885
1886    fn execute_inner_with_cancel(
1887        &mut self,
1888        sql: &str,
1889        cancel: CancelToken<'_>,
1890    ) -> Result<QueryResult, EngineError> {
1891        cancel.check()?;
1892        let stmt = self.prepare(sql)?;
1893        // v6.5.1 — wrap the executor with a wall-clock window so we
1894        // can record into spg_stat_query. Skip when the engine has
1895        // no clock attached (no_std embedded callers).
1896        let start_us = self.clock.map(|f| f());
1897        let result = self.execute_stmt_with_cancel(stmt, cancel);
1898        if let (Some(t0), Ok(_)) = (start_us, &result) {
1899            let now = self.clock.map_or(t0, |f| f());
1900            let elapsed = now.saturating_sub(t0).max(0) as u64;
1901            self.query_stats.record(sql, elapsed, now as u64);
1902            // v6.5.6 — slow-query log: fire callback when elapsed
1903            // exceeds the configured floor.
1904            if let (Some(threshold), Some(logger)) =
1905                (self.slow_query_threshold_us, self.slow_query_logger)
1906                && elapsed >= threshold
1907            {
1908                logger(sql, elapsed);
1909            }
1910        }
1911        result
1912    }
1913
1914    fn execute_stmt_with_cancel(
1915        &mut self,
1916        stmt: Statement,
1917        cancel: CancelToken<'_>,
1918    ) -> Result<QueryResult, EngineError> {
1919        cancel.check()?;
1920        // v7.17.0 Phase 1.1 — pre-resolve nextval / currval /
1921        // setval calls in the statement tree. Walks SELECT
1922        // projection, INSERT VALUES, UPDATE SET, DELETE WHERE,
1923        // and DEFAULT exprs; replaces sequence FunctionCall
1924        // nodes with concrete Literal values minted against the
1925        // catalog. This is the only place that mutates sequence
1926        // state from a SELECT-shaped path (exec_select_cancel is
1927        // `&self` and can't reach the catalog mutably).
1928        //
1929        // Fast-path: when no sequences exist anywhere in the
1930        // catalog (the typical hot-path INSERT load), skip the
1931        // walker entirely. Single map-emptiness check on the
1932        // catalog beats walking every expression on every call.
1933        let mut stmt = stmt;
1934        // v7.17 dump-compat — the fast-path check
1935        // `sequences().is_empty()` skips pre-resolve when no
1936        // sequence exists in the *currently active* catalog
1937        // snapshot. The committed catalog or the implicit-TX
1938        // catalog may legitimately disagree on this between
1939        // CREATE SEQUENCE and a later setval(): always run the
1940        // resolver — the walk is O(expr-count) and dwarfed by
1941        // the parse cost we just paid.
1942        self.pre_resolve_sequence_calls_in_statement(&mut stmt)?;
1943        let result = match stmt {
1944            Statement::CreateTable(s) => self.exec_create_table(s),
1945            // v7.9.15 — CREATE EXTENSION is a no-op on SPG. Returns
1946            // CommandOk with affected=0; modified_catalog=false so
1947            // the WAL doesn't grow a useless entry. mailrs F3.
1948            Statement::CreateExtension(_) => Ok(QueryResult::CommandOk {
1949                affected: 0,
1950                modified_catalog: false,
1951            }),
1952            // v7.16.2 — DO $$ ... $$ block. mailrs round-10 A.2
1953            // — the pre-v7.9.27 no-op SILENTLY swallowed every
1954            // mailrs migrate-038/-040/-042 idempotent rename
1955            // (the IF EXISTS … THEN ALTER … END block never
1956            // ran). v7.16.2 dispatches to exec_do_block which
1957            // runs the PlPgSqlBlock at top level via the same
1958            // execute_stmts machinery the trigger executor
1959            // uses (NEW=None, OLD=None — DO blocks have no
1960            // row context).
1961            Statement::DoBlock(body) => self.exec_do_block(body),
1962            // v7.14.0 — empty-statement no-op for pg_dump /
1963            // mysqldump preamble lines that collapse to nothing
1964            // after comment-stripping.
1965            Statement::Empty => Ok(QueryResult::CommandOk {
1966                affected: 0,
1967                modified_catalog: false,
1968            }),
1969            Statement::DropTable { names, if_exists } => self.exec_drop_table(names, if_exists),
1970            Statement::DropIndex { name, if_exists } => self.exec_drop_index(name, if_exists),
1971            Statement::CreateIndex(s) => self.exec_create_index(s),
1972            Statement::Insert(s) => self.exec_insert(s),
1973            Statement::Update(s) => self.exec_update_cancel(&s, cancel),
1974            Statement::Delete(s) => self.exec_delete_cancel(&s, cancel),
1975            Statement::Merge(s) => self.exec_merge_cancel(&s, cancel),
1976            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
1977            Statement::Begin => self.exec_begin(),
1978            Statement::Commit => self.exec_commit(),
1979            Statement::Rollback => self.exec_rollback(),
1980            Statement::Savepoint(name) => self.exec_savepoint(name),
1981            Statement::RollbackToSavepoint(name) => self.exec_rollback_to_savepoint(&name),
1982            Statement::ReleaseSavepoint(name) => self.exec_release_savepoint(&name),
1983            Statement::ShowTables => Ok(self.exec_show_tables()),
1984            Statement::ShowDatabases => Ok(self.exec_show_databases()),
1985            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
1986            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
1987            Statement::ShowStatus => Ok(self.exec_show_status()),
1988            Statement::ShowVariables => Ok(self.exec_show_variables()),
1989            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
1990            Statement::ShowColumns(table) => self.exec_show_columns(&table),
1991            Statement::ShowUsers => Ok(self.exec_show_users()),
1992            Statement::ShowPublications => Ok(self.exec_show_publications()),
1993            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
1994            Statement::CreateUser(s) => self.exec_create_user(&s),
1995            Statement::DropUser(name) => self.exec_drop_user(&name),
1996            Statement::Explain(e) => self.exec_explain(&e, cancel),
1997            Statement::AlterIndex(s) => self.exec_alter_index(s),
1998            Statement::AlterTable(s) => self.exec_alter_table(s),
1999            Statement::CreatePublication(s) => self.exec_create_publication(s),
2000            Statement::DropPublication(name) => self.exec_drop_publication(&name),
2001            Statement::CreateSubscription(s) => self.exec_create_subscription(s),
2002            Statement::DropSubscription(name) => self.exec_drop_subscription(&name),
2003            // v6.1.7 — WAIT FOR WAL POSITION needs `lag_state`,
2004            // which lives in spg-server's ServerState. The engine
2005            // surfaces a clear error; the server-layer dispatch
2006            // intercepts the SQL before it reaches the engine on
2007            // a server build, so this arm only fires for
2008            // engine-only callers (spg-embedded, lib tests).
2009            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
2010                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
2011            )),
2012            // v6.2.0 — ANALYZE recomputes per-column histograms.
2013            Statement::Analyze(target) => self.exec_analyze(target.as_deref()),
2014            // v6.7.3 — COMPACT COLD SEGMENTS.
2015            Statement::CompactColdSegments => self.exec_compact_cold_segments(),
2016            // v7.12.1 — SET / RESET session parameter. Engine
2017            // tracks the value in `session_params`; FTS dispatcher
2018            // reads `default_text_search_config`. Everything else
2019            // is a recorded no-op (PG dump compat).
2020            Statement::SetParameter { name, value } => {
2021                self.set_session_param(name, value);
2022                Ok(QueryResult::CommandOk {
2023                    affected: 0,
2024                    modified_catalog: false,
2025                })
2026            }
2027            // v7.14.0 — MySQL multi-assignment SET. Each pair runs
2028            // through `set_session_param` so engine-known params
2029            // (FOREIGN_KEY_CHECKS, session_replication_role, …) take
2030            // effect; unknown pairs (including `@VAR` LHS from the
2031            // mysqldump preamble) are recorded then ignored.
2032            Statement::SetParameterList(pairs) => {
2033                for (name, value) in pairs {
2034                    self.set_session_param(name, value);
2035                }
2036                Ok(QueryResult::CommandOk {
2037                    affected: 0,
2038                    modified_catalog: false,
2039                })
2040            }
2041            // v7.12.4 — CREATE FUNCTION / CREATE TRIGGER / DROP …
2042            // for the PL/pgSQL trigger surface. exec_* methods are
2043            // defined alongside the existing CREATE handlers below.
2044            Statement::CreateFunction(s) => self.exec_create_function(s),
2045            Statement::CreateTrigger(s) => self.exec_create_trigger(s),
2046            Statement::DropTrigger {
2047                name,
2048                table,
2049                if_exists,
2050            } => self.exec_drop_trigger(&name, &table, if_exists),
2051            Statement::DropFunction { name, if_exists } => {
2052                self.exec_drop_function(&name, if_exists)
2053            }
2054            Statement::CreateSequence(s) => self.exec_create_sequence(s),
2055            Statement::AlterSequence(s) => self.exec_alter_sequence(s),
2056            Statement::DropSequence { names, if_exists } => {
2057                self.exec_drop_sequence(&names, if_exists)
2058            }
2059            Statement::CreateView(s) => self.exec_create_view(s),
2060            Statement::DropView { names, if_exists } => self.exec_drop_view(&names, if_exists),
2061            Statement::CreateMaterializedView(s) => self.exec_create_materialized_view(s),
2062            Statement::RefreshMaterializedView { name, with_data } => {
2063                self.exec_refresh_materialized_view(&name, with_data)
2064            }
2065            Statement::DropMaterializedView { names, if_exists } => {
2066                self.exec_drop_materialized_view(&names, if_exists)
2067            }
2068            Statement::CreateType(s) => self.exec_create_type(s),
2069            Statement::DropType { names, if_exists } => self.exec_drop_type(&names, if_exists),
2070            Statement::CreateDomain(s) => self.exec_create_domain(s),
2071            Statement::DropDomain { names, if_exists } => self.exec_drop_domain(&names, if_exists),
2072            Statement::CreateSchema {
2073                name,
2074                if_not_exists,
2075            } => self.exec_create_schema(name, if_not_exists),
2076            Statement::DropSchema { names, if_exists } => self.exec_drop_schema(&names, if_exists),
2077            Statement::ResetParameter(target) => {
2078                match target {
2079                    None => self.session_params.clear(),
2080                    Some(name) => {
2081                        self.session_params.remove(&name.to_ascii_lowercase());
2082                    }
2083                }
2084                Ok(QueryResult::CommandOk {
2085                    affected: 0,
2086                    modified_catalog: false,
2087                })
2088            }
2089        };
2090        self.enforce_row_limit(result)
2091    }
2092
2093    /// v6.1.2 — `CREATE PUBLICATION` runtime path. Duplicate names
2094    /// surface as `EngineError::Unsupported` so the existing PG-wire
2095    /// error mapping stays uniform; the message carries the name so
2096    /// operators can grep replication-log noise. Inside-transaction
2097    /// invocation is rejected (matches `CREATE USER` / `DROP USER`
2098    /// stance) — replication-catalog mutation is a connection-level
2099    /// administrative op, not a transactional one.
2100    fn exec_create_publication(
2101        &mut self,
2102        s: CreatePublicationStatement,
2103    ) -> Result<QueryResult, EngineError> {
2104        // v6.1.4 — the v6.1.2 "no DDL inside a transaction" guard
2105        // was over-cautious: it also blocked the auto-commit wrap
2106        // path (which begins an internal TX around every WAL-
2107        // logged statement). PG itself allows CREATE PUBLICATION
2108        // inside a transaction (it rolls back with the TX).
2109        self.publications
2110            .create(s.name, s.scope)
2111            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE PUBLICATION: {e:?}")))?;
2112        Ok(QueryResult::CommandOk {
2113            affected: 1,
2114            modified_catalog: true,
2115        })
2116    }
2117
2118    /// v6.1.2 — `DROP PUBLICATION` runtime path. PG-compatible silent
2119    /// no-op when the publication doesn't exist (returns `affected=0`
2120    /// in that case so the wire-level command tag distinguishes
2121    /// "dropped" from "no-op", though both succeed).
2122    fn exec_drop_publication(&mut self, name: &str) -> Result<QueryResult, EngineError> {
2123        let removed = self.publications.drop(name);
2124        Ok(QueryResult::CommandOk {
2125            affected: usize::from(removed),
2126            modified_catalog: removed,
2127        })
2128    }
2129
2130    /// v6.1.2 — read access to the publication catalog. Used by
2131    /// the v6.1.5 publisher-side WAL filter, by `SHOW PUBLICATIONS`
2132    /// (v6.1.3+), and by e2e tests that need to assert state without
2133    /// going through the wire.
2134    pub const fn publications(&self) -> &publications::Publications {
2135        &self.publications
2136    }
2137
2138    /// v6.1.4 — `CREATE SUBSCRIPTION` runtime path. Defaults
2139    /// `enabled = true` and `last_received_pos = 0` for a freshly-
2140    /// created subscription. The actual worker thread is spawned
2141    /// by spg-server once the engine returns success.
2142    fn exec_create_subscription(
2143        &mut self,
2144        s: CreateSubscriptionStatement,
2145    ) -> Result<QueryResult, EngineError> {
2146        // See exec_create_publication — the in_transaction gate
2147        // was over-cautious; the auto-commit wrap path holds an
2148        // internal TX that this check was incorrectly blocking.
2149        let sub = subscriptions::Subscription {
2150            conn_str: s.conn_str,
2151            publications: s.publications,
2152            enabled: true,
2153            last_received_pos: 0,
2154        };
2155        self.subscriptions
2156            .create(s.name, sub)
2157            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE SUBSCRIPTION: {e:?}")))?;
2158        Ok(QueryResult::CommandOk {
2159            affected: 1,
2160            modified_catalog: true,
2161        })
2162    }
2163
2164    /// v6.1.4 — `DROP SUBSCRIPTION`. Silent no-op when the name
2165    /// doesn't exist (PG-compatible). The associated worker is
2166    /// torn down by spg-server when it observes the catalog
2167    /// change at the next snapshot or via the engine's
2168    /// subscriptions accessor (the worker polls the catalog on
2169    /// reconnect; v6.1.5's filter-side will tighten this to an
2170    /// explicit signal).
2171    fn exec_drop_subscription(&mut self, name: &str) -> Result<QueryResult, EngineError> {
2172        let removed = self.subscriptions.drop(name);
2173        Ok(QueryResult::CommandOk {
2174            affected: usize::from(removed),
2175            modified_catalog: removed,
2176        })
2177    }
2178
2179    /// v6.1.4 — read access to the subscription catalog. Used by
2180    /// the subscription worker (read its own row to find its
2181    /// publications + last applied position), by SHOW SUBSCRIPTIONS,
2182    /// and by e2e tests asserting state directly.
2183    pub const fn subscriptions(&self) -> &subscriptions::Subscriptions {
2184        &self.subscriptions
2185    }
2186
2187    /// v6.1.4 — write access to `last_received_pos`. Worker
2188    /// calls this after each apply batch (under the engine's
2189    /// write-lock). Returns `false` when the subscription was
2190    /// dropped between when the worker received the record and
2191    /// when this call landed.
2192    pub fn subscription_advance(&mut self, name: &str, pos: u64) -> bool {
2193        self.subscriptions.update_last_received_pos(name, pos)
2194    }
2195
2196    /// v6.1.4 — `SHOW SUBSCRIPTIONS` row materialisation. Returns
2197    /// `(name, conn_str, publications, enabled, last_received_pos)`
2198    /// ordered by subscription name. The `publications` column is
2199    /// the comma-joined list ("p1, p2") for ergonomic SHOW output;
2200    /// callers wanting structured access read `Engine::subscriptions`.
2201    fn exec_show_subscriptions(&self) -> QueryResult {
2202        let columns = alloc::vec![
2203            ColumnSchema::new("name", DataType::Text, false),
2204            ColumnSchema::new("conn_str", DataType::Text, false),
2205            ColumnSchema::new("publications", DataType::Text, false),
2206            ColumnSchema::new("enabled", DataType::Bool, false),
2207            ColumnSchema::new("last_received_pos", DataType::BigInt, false),
2208        ];
2209        let rows: Vec<Row> = self
2210            .subscriptions
2211            .iter()
2212            .map(|(name, sub)| {
2213                Row::new(alloc::vec![
2214                    Value::Text(name.clone()),
2215                    Value::Text(sub.conn_str.clone()),
2216                    Value::Text(sub.publications.join(", ")),
2217                    Value::Bool(sub.enabled),
2218                    Value::BigInt(i64::try_from(sub.last_received_pos).unwrap_or(i64::MAX)),
2219                ])
2220            })
2221            .collect();
2222        QueryResult::Rows { columns, rows }
2223    }
2224
2225    /// v6.2.0 — materialise `spg_statistic` rows. One row per
2226    /// `(table, column)` pair tracked in `Statistics`, with
2227    /// `histogram_bounds` rendered as a `[v0, v1, ...]` string —
2228    /// the same canonical form vector literals use for round-trip.
2229    fn exec_spg_statistic(&self) -> QueryResult {
2230        let columns = alloc::vec![
2231            ColumnSchema::new("table_name", DataType::Text, false),
2232            ColumnSchema::new("column_name", DataType::Text, false),
2233            ColumnSchema::new("null_frac", DataType::Float, false),
2234            ColumnSchema::new("n_distinct", DataType::BigInt, false),
2235            ColumnSchema::new("histogram_bounds", DataType::Text, false),
2236            // v6.7.0 — appended column (v6.2.0 stability contract
2237            // allows APPEND to spg_statistic, not reorder/rename).
2238            // Reports the cached per-table cold-row count; same
2239            // value across every column row of the same table.
2240            ColumnSchema::new("cold_row_count", DataType::BigInt, false),
2241        ];
2242        let rows: Vec<Row> = self
2243            .statistics
2244            .iter()
2245            .map(|((t, c), s)| {
2246                let cold = self
2247                    .catalog
2248                    .get(t)
2249                    .map_or(0, |table| table.cold_row_count());
2250                Row::new(alloc::vec![
2251                    Value::Text(t.clone()),
2252                    Value::Text(c.clone()),
2253                    Value::Float(f64::from(s.null_frac)),
2254                    Value::BigInt(i64::try_from(s.n_distinct).unwrap_or(i64::MAX)),
2255                    Value::Text(render_histogram_bounds(&s.histogram_bounds)),
2256                    Value::BigInt(i64::try_from(cold).unwrap_or(i64::MAX)),
2257                ])
2258            })
2259            .collect();
2260        QueryResult::Rows { columns, rows }
2261    }
2262
2263    /// v6.5.0 — materialise `spg_stat_replication` rows. One row
2264    /// per subscription with `(name, conn_str, publications,
2265    /// last_received_pos, enabled)`. Surface mirrors
2266    /// `SHOW SUBSCRIPTIONS` but follows the virtual-table dispatch
2267    /// shape so it composes with SELECT clauses (WHERE, projection
2268    /// onto specific columns, etc).
2269    fn exec_spg_stat_replication(&self) -> QueryResult {
2270        let columns = alloc::vec![
2271            ColumnSchema::new("name", DataType::Text, false),
2272            ColumnSchema::new("conn_str", DataType::Text, false),
2273            ColumnSchema::new("publications", DataType::Text, false),
2274            ColumnSchema::new("last_received_pos", DataType::BigInt, false),
2275            ColumnSchema::new("enabled", DataType::Bool, false),
2276        ];
2277        let rows: Vec<Row> = self
2278            .subscriptions
2279            .iter()
2280            .map(|(name, sub)| {
2281                Row::new(alloc::vec![
2282                    Value::Text(name.clone()),
2283                    Value::Text(sub.conn_str.clone()),
2284                    Value::Text(sub.publications.join(",")),
2285                    Value::BigInt(i64::try_from(sub.last_received_pos).unwrap_or(i64::MAX)),
2286                    Value::Bool(sub.enabled),
2287                ])
2288            })
2289            .collect();
2290        QueryResult::Rows { columns, rows }
2291    }
2292
2293    /// v6.5.0 — materialise `spg_stat_segment` rows. One row per
2294    /// cold-tier segment with `(segment_id, num_rows, num_pages,
2295    /// total_bytes)`.
2296    ///
2297    /// v6.7.0 — appended `table_name` column resolves the v6.5.0
2298    /// carve-out. Walks every user table's BTree indices to find
2299    /// which table's Cold locators point at each segment. Empty
2300    /// string for orphan segments (loaded via SPG_PRELOAD_COLD_SEGMENT
2301    /// before any index registered a locator). The walk is
2302    /// O(tables × indices × keys); cached per call, not across
2303    /// calls — re-walked on every `SELECT * FROM spg_stat_segment`.
2304    fn exec_spg_stat_segment(&self) -> QueryResult {
2305        let columns = alloc::vec![
2306            ColumnSchema::new("segment_id", DataType::BigInt, false),
2307            ColumnSchema::new("table_name", DataType::Text, false),
2308            ColumnSchema::new("num_rows", DataType::BigInt, false),
2309            ColumnSchema::new("num_pages", DataType::BigInt, false),
2310            ColumnSchema::new("total_bytes", DataType::BigInt, false),
2311        ];
2312        // v6.7.0 — build a segment_id → table_name map by walking
2313        // every user table's BTree indices once. O(tables × indices
2314        // × keys) for the v6.5.0 carve-out resolution; acceptable
2315        // because spg_stat_segment is operator-facing (not on a
2316        // hot-loop path).
2317        let mut segment_owners: alloc::collections::BTreeMap<u32, String> = BTreeMap::new();
2318        for tname in self.catalog.table_names() {
2319            if is_internal_table_name(&tname) {
2320                continue;
2321            }
2322            let Some(t) = self.catalog.get(&tname) else {
2323                continue;
2324            };
2325            for idx in t.indices() {
2326                if let spg_storage::IndexKind::BTree(map) = &idx.kind {
2327                    for (_, locs) in map.iter() {
2328                        for loc in locs {
2329                            if let spg_storage::RowLocator::Cold { segment_id, .. } = loc {
2330                                segment_owners
2331                                    .entry(*segment_id)
2332                                    .or_insert_with(|| tname.clone());
2333                            }
2334                        }
2335                    }
2336                }
2337            }
2338        }
2339        let rows: Vec<Row> = self
2340            .catalog
2341            .cold_segment_ids_global()
2342            .iter()
2343            .filter_map(|&id| {
2344                let seg = self.catalog.cold_segment(id)?;
2345                let meta = seg.meta();
2346                let owner = segment_owners.get(&id).cloned().unwrap_or_default();
2347                Some(Row::new(alloc::vec![
2348                    Value::BigInt(i64::from(id)),
2349                    Value::Text(owner),
2350                    Value::BigInt(i64::try_from(meta.num_rows).unwrap_or(i64::MAX)),
2351                    Value::BigInt(i64::from(meta.num_pages)),
2352                    Value::BigInt(i64::try_from(meta.total_bytes).unwrap_or(i64::MAX)),
2353                ]))
2354            })
2355            .collect();
2356        QueryResult::Rows { columns, rows }
2357    }
2358
2359    /// v6.5.1 — materialise `spg_stat_query` rows. One row per
2360    /// distinct SQL text recorded since the engine booted, capped
2361    /// at `QUERY_STATS_MAX` (1024). Columns:
2362    ///   sql, exec_count, total_us, mean_us, max_us, last_seen_us
2363    /// mean_us = total_us / exec_count (saturating).
2364    fn exec_spg_stat_query(&self) -> QueryResult {
2365        let columns = alloc::vec![
2366            ColumnSchema::new("sql", DataType::Text, false),
2367            ColumnSchema::new("exec_count", DataType::BigInt, false),
2368            ColumnSchema::new("total_us", DataType::BigInt, false),
2369            ColumnSchema::new("mean_us", DataType::BigInt, false),
2370            ColumnSchema::new("max_us", DataType::BigInt, false),
2371            ColumnSchema::new("last_seen_us", DataType::BigInt, false),
2372        ];
2373        let rows: Vec<Row> = self
2374            .query_stats
2375            .snapshot()
2376            .into_iter()
2377            .map(|(sql, s)| {
2378                let mean = if s.exec_count == 0 {
2379                    0
2380                } else {
2381                    s.total_us / s.exec_count
2382                };
2383                Row::new(alloc::vec![
2384                    Value::Text(sql),
2385                    Value::BigInt(i64::try_from(s.exec_count).unwrap_or(i64::MAX)),
2386                    Value::BigInt(i64::try_from(s.total_us).unwrap_or(i64::MAX)),
2387                    Value::BigInt(i64::try_from(mean).unwrap_or(i64::MAX)),
2388                    Value::BigInt(i64::try_from(s.max_us).unwrap_or(i64::MAX)),
2389                    Value::BigInt(i64::try_from(s.last_seen_us).unwrap_or(i64::MAX)),
2390                ])
2391            })
2392            .collect();
2393        QueryResult::Rows { columns, rows }
2394    }
2395
2396    /// v6.5.2 — register a connection-state provider. spg-server
2397    /// calls this at startup with a function that snapshots its
2398    /// per-pgwire-connection registry. Engine reads through the
2399    /// callback on `SELECT * FROM spg_stat_activity`.
2400    #[must_use]
2401    pub const fn with_activity_provider(mut self, f: ActivityProvider) -> Self {
2402        self.activity_provider = Some(f);
2403        self
2404    }
2405
2406    /// v6.5.3 — register audit chain provider + verifier.
2407    #[must_use]
2408    pub const fn with_audit_providers(
2409        mut self,
2410        chain: AuditChainProvider,
2411        verify: AuditVerifier,
2412    ) -> Self {
2413        self.audit_chain_provider = Some(chain);
2414        self.audit_verifier = Some(verify);
2415        self
2416    }
2417
2418    /// v6.5.6 — register a slow-query log callback. `threshold_us`
2419    /// is the floor (in microseconds); only executes above the floor
2420    /// fire the callback. spg-server wires this from
2421    /// `SPG_SLOW_QUERY_THRESHOLD_MS` (default 100 ms).
2422    #[must_use]
2423    pub const fn with_slow_query_log(mut self, threshold_us: u64, logger: SlowQueryLogger) -> Self {
2424        self.slow_query_threshold_us = Some(threshold_us);
2425        self.slow_query_logger = Some(logger);
2426        self
2427    }
2428
2429    /// v6.5.6 — operator knob for plan cache cap. spg-server reads
2430    /// `SPG_PLAN_CACHE_MAX` env at startup; uses this to override
2431    /// the compile-time default of 256.
2432    pub fn set_plan_cache_max(&mut self, n: usize) {
2433        self.plan_cache.set_max_entries(n);
2434    }
2435
2436    /// v6.5.2 — materialise `spg_stat_activity` rows. Pulls a fresh
2437    /// snapshot from the registered `ActivityProvider`. Returns an
2438    /// empty result set when no provider is registered (the no_std
2439    /// embedded path with no pgwire layer).
2440    fn exec_spg_stat_activity(&self) -> QueryResult {
2441        let columns = alloc::vec![
2442            ColumnSchema::new("pid", DataType::Int, false),
2443            ColumnSchema::new("user", DataType::Text, false),
2444            ColumnSchema::new("started_at_us", DataType::BigInt, false),
2445            ColumnSchema::new("current_sql", DataType::Text, false),
2446            ColumnSchema::new("wait_event", DataType::Text, false),
2447            ColumnSchema::new("elapsed_us", DataType::BigInt, false),
2448            ColumnSchema::new("in_transaction", DataType::Bool, false),
2449            ColumnSchema::new("application_name", DataType::Text, false),
2450        ];
2451        let rows: Vec<Row> = self
2452            .activity_provider
2453            .map(|f| f())
2454            .unwrap_or_default()
2455            .into_iter()
2456            .map(|r| {
2457                Row::new(alloc::vec![
2458                    Value::Int(i32::try_from(r.pid).unwrap_or(i32::MAX)),
2459                    Value::Text(r.user),
2460                    Value::BigInt(r.started_at_us),
2461                    Value::Text(r.current_sql),
2462                    Value::Text(r.wait_event),
2463                    Value::BigInt(r.elapsed_us),
2464                    Value::Bool(r.in_transaction),
2465                    Value::Text(r.application_name),
2466                ])
2467            })
2468            .collect();
2469        QueryResult::Rows { columns, rows }
2470    }
2471
2472    /// v6.5.4 — materialise `spg_table_ddl` rows. One row per user
2473    /// table with `(table_name, ddl)`. Reconstructed from catalog
2474    /// state on demand.
2475    fn exec_spg_table_ddl(&self) -> QueryResult {
2476        let columns = alloc::vec![
2477            ColumnSchema::new("table_name", DataType::Text, false),
2478            ColumnSchema::new("ddl", DataType::Text, false),
2479        ];
2480        let rows: Vec<Row> = self
2481            .catalog
2482            .table_names()
2483            .into_iter()
2484            .filter(|n| !is_internal_table_name(n))
2485            .filter_map(|name| {
2486                let table = self.catalog.get(&name)?;
2487                let ddl = render_create_table(&name, &table.schema().columns);
2488                Some(Row::new(alloc::vec![Value::Text(name), Value::Text(ddl),]))
2489            })
2490            .collect();
2491        QueryResult::Rows { columns, rows }
2492    }
2493
2494    /// v6.5.4 — materialise `spg_role_ddl` rows. One row per user
2495    /// with `(role_name, ddl)`. Password is redacted (matches the
2496    /// `Statement::CreateUser` Display which prints `'<redacted>'`).
2497    fn exec_spg_role_ddl(&self) -> QueryResult {
2498        let columns = alloc::vec![
2499            ColumnSchema::new("role_name", DataType::Text, false),
2500            ColumnSchema::new("ddl", DataType::Text, false),
2501        ];
2502        let rows: Vec<Row> = self
2503            .users
2504            .iter()
2505            .map(|(name, rec)| {
2506                let ddl = alloc::format!(
2507                    "CREATE USER {name} WITH PASSWORD '<redacted>' ROLE '{}'",
2508                    rec.role.as_str(),
2509                );
2510                Row::new(alloc::vec![
2511                    Value::Text(String::from(name)),
2512                    Value::Text(ddl)
2513                ])
2514            })
2515            .collect();
2516        QueryResult::Rows { columns, rows }
2517    }
2518
2519    /// v6.5.4 — materialise `spg_database_ddl`: single row whose
2520    /// `ddl` column concatenates every user table's CREATE +
2521    /// every role's CREATE in deterministic catalog order. Suitable
2522    /// for piping back through `Engine::execute` to recreate a
2523    /// schema-equivalent database.
2524    fn exec_spg_database_ddl(&self) -> QueryResult {
2525        let columns = alloc::vec![ColumnSchema::new("ddl", DataType::Text, false)];
2526        let mut out = String::new();
2527        for (name, rec) in self.users.iter() {
2528            out.push_str(&alloc::format!(
2529                "CREATE USER {name} WITH PASSWORD '<redacted>' ROLE '{}';\n",
2530                rec.role.as_str(),
2531            ));
2532        }
2533        for name in self.catalog.table_names() {
2534            if is_internal_table_name(&name) {
2535                continue;
2536            }
2537            if let Some(table) = self.catalog.get(&name) {
2538                out.push_str(&render_create_table(&name, &table.schema().columns));
2539                out.push_str(";\n");
2540            }
2541        }
2542        QueryResult::Rows {
2543            columns,
2544            rows: alloc::vec![Row::new(alloc::vec![Value::Text(out)])],
2545        }
2546    }
2547
2548    /// v6.5.3 — materialise `spg_audit_chain` rows. Pulls a fresh
2549    /// snapshot from the registered provider; empty when no
2550    /// provider is set.
2551    fn exec_spg_audit_chain(&self) -> QueryResult {
2552        let columns = alloc::vec![
2553            ColumnSchema::new("seq", DataType::BigInt, false),
2554            ColumnSchema::new("ts_ms", DataType::BigInt, false),
2555            ColumnSchema::new("prev_hash", DataType::Text, false),
2556            ColumnSchema::new("entry_hash", DataType::Text, false),
2557            ColumnSchema::new("sql", DataType::Text, false),
2558        ];
2559        let rows: Vec<Row> = self
2560            .audit_chain_provider
2561            .map(|f| f())
2562            .unwrap_or_default()
2563            .into_iter()
2564            .map(|r| {
2565                Row::new(alloc::vec![
2566                    Value::BigInt(r.seq),
2567                    Value::BigInt(r.ts_ms),
2568                    Value::Text(r.prev_hash_hex),
2569                    Value::Text(r.entry_hash_hex),
2570                    Value::Text(r.sql),
2571                ])
2572            })
2573            .collect();
2574        QueryResult::Rows { columns, rows }
2575    }
2576
2577    /// v6.5.3 — materialise `spg_audit_verify` single-row result.
2578    /// `(verified_count, broken_at_seq)` — broken_at_seq is `-1`
2579    /// on a clean chain. Returns one row with both values 0 when
2580    /// no verifier is registered (no-data fallback for embedded
2581    /// callers).
2582    fn exec_spg_audit_verify(&self) -> QueryResult {
2583        let columns = alloc::vec![
2584            ColumnSchema::new("verified_count", DataType::BigInt, false),
2585            ColumnSchema::new("broken_at_seq", DataType::BigInt, false),
2586        ];
2587        let (verified, broken) = self.audit_verifier.map(|f| f()).unwrap_or((0, -1));
2588        let row = Row::new(alloc::vec![Value::BigInt(verified), Value::BigInt(broken),]);
2589        QueryResult::Rows {
2590            columns,
2591            rows: alloc::vec![row],
2592        }
2593    }
2594
2595    /// v6.5.1 — read-only accessor for tests + v6.5.6 ops resets.
2596    pub fn query_stats(&self) -> &query_stats::QueryStats {
2597        &self.query_stats
2598    }
2599
2600    /// v6.5.1 — mutable accessor (clear, etc).
2601    pub fn query_stats_mut(&mut self) -> &mut query_stats::QueryStats {
2602        &mut self.query_stats
2603    }
2604
2605    /// v6.2.0 — read access to the per-column statistics table.
2606    /// Used by the planner (v6.2.2 selectivity functions read this),
2607    /// by `SELECT * FROM spg_statistic`, and by e2e tests.
2608    pub const fn statistics(&self) -> &statistics::Statistics {
2609        &self.statistics
2610    }
2611
2612    /// v6.2.1 — return tables whose modified-row count crossed the
2613    /// auto-analyze threshold since the last ANALYZE on that table.
2614    /// The threshold is `0.1 × max(row_count, MIN_ROWS_FOR_AUTO_
2615    /// ANALYZE)` — combines PG-style fractional + absolute lower
2616    /// bound so a fresh / tiny table doesn't get hammered on every
2617    /// INSERT.
2618    ///
2619    /// Designed to be cheap: walks every user table's
2620    /// `Catalog::table_names()` + reads `statistics::modified_
2621    /// since_last_analyze()` (BTreeMap lookup). The background
2622    /// worker calls this under `engine.read()` then drops the lock
2623    /// before re-acquiring `engine.write()` for the actual ANALYZE.
2624    pub fn tables_needing_analyze(&self) -> Vec<String> {
2625        const MIN_ROWS: u64 = 100;
2626        let mut out = Vec::new();
2627        for name in self.catalog.table_names() {
2628            if is_internal_table_name(&name) {
2629                continue;
2630            }
2631            let Some(table) = self.catalog.get(&name) else {
2632                continue;
2633            };
2634            let row_count = table.rows().len() as u64;
2635            let modified = self.statistics.modified_since_last_analyze(&name);
2636            // Threshold: ceil(0.1 × max(row_count, MIN_ROWS)),
2637            // computed in integer arithmetic so spg-engine stays
2638            // no_std without pulling in libm. `(n + 9) / 10` is
2639            // `ceil(n / 10)` for non-negative `n`.
2640            let base = row_count.max(MIN_ROWS);
2641            let threshold = base.saturating_add(9) / 10;
2642            if modified >= threshold {
2643                out.push(name);
2644            }
2645        }
2646        out
2647    }
2648
2649    /// v6.2.0 — `ANALYZE [<table>]` runtime. Bare `ANALYZE` walks
2650    /// every user table; `ANALYZE <name>` re-stats one. For each
2651    /// target table, single-pass scan + per-column histogram +
2652    /// `null_frac` + `n_distinct`. Replaces the table's prior
2653    /// stats; resets the modified-row counter.
2654    ///
2655    /// v6.2.0 doesn't sample — it scans the full table. v6.2.x
2656    /// can add reservoir sampling at the > 100 K-row mark; not a
2657    /// scope blocker for the current commit since rows ≤ 100 K
2658    /// analyse in milliseconds.
2659    fn exec_analyze(&mut self, target: Option<&str>) -> Result<QueryResult, EngineError> {
2660        let names: Vec<String> = if let Some(name) = target {
2661            // Verify the table exists; surface a clear error if not.
2662            if self.catalog.get(name).is_none() {
2663                return Err(EngineError::Storage(StorageError::TableNotFound {
2664                    name: name.to_string(),
2665                }));
2666            }
2667            alloc::vec![name.to_string()]
2668        } else {
2669            self.catalog
2670                .table_names()
2671                .into_iter()
2672                .filter(|n| !is_internal_table_name(n))
2673                .collect()
2674        };
2675        let mut analysed = 0usize;
2676        for table_name in &names {
2677            self.analyze_one_table(table_name)?;
2678            analysed += 1;
2679        }
2680        // v6.3.1 — plan cache invalidation. Bump stats version so
2681        // future lookups see the new generation, and selectively
2682        // evict every plan whose `source_tables` overlap with the
2683        // ANALYZE target set. Bare ANALYZE (all tables) clears the
2684        // whole cache.
2685        if analysed > 0 {
2686            self.statistics.bump_version();
2687            if target.is_some() {
2688                for t in &names {
2689                    self.plan_cache.evict_referencing(t);
2690                }
2691            } else {
2692                self.plan_cache.clear();
2693            }
2694        }
2695        Ok(QueryResult::CommandOk {
2696            affected: analysed,
2697            modified_catalog: true,
2698        })
2699    }
2700
2701    /// v6.7.3 — `COMPACT COLD SEGMENTS` runtime path. Drives the
2702    /// engine-layer compaction shim with the default
2703    /// 4 MiB segment-size threshold. spg-server intercepts the
2704    /// SQL before it reaches the engine on a server build —
2705    /// it reads `SPG_COMPACTION_TARGET_SEGMENT_BYTES`, calls
2706    /// `Engine::compact_cold_segments_with_target` directly with
2707    /// the env value, and persists every merged segment to
2708    /// v7.12.1 — record a `SET <name> = <value>` parameter. Names
2709    /// are case-folded to lowercase to match PG; values keep their
2710    /// caller-supplied form so observability paths see what was
2711    /// requested. Only `default_text_search_config` is consulted by
2712    /// the engine today.
2713    fn set_session_param(&mut self, name: String, value: spg_sql::ast::SetValue) {
2714        let normalised = match value {
2715            spg_sql::ast::SetValue::String(s) => s,
2716            spg_sql::ast::SetValue::Ident(s) => s,
2717            spg_sql::ast::SetValue::Number(s) => s,
2718            spg_sql::ast::SetValue::Default => String::new(),
2719        };
2720        let key = name.to_ascii_lowercase();
2721        // v7.14.0 — mysqldump preamble emits
2722        // `SET FOREIGN_KEY_CHECKS=0` so it can CREATE TABLE in any
2723        // order despite cross-table FK references; the closing
2724        // section emits `SET FOREIGN_KEY_CHECKS=1` (or
2725        // `=@OLD_FOREIGN_KEY_CHECKS` which resolves to "ON" in our
2726        // session-variable-aware path). Match both shapes.
2727        // Also accept PG's `session_replication_role = 'replica'`
2728        // which suppresses trigger + FK enforcement during a
2729        // logical replication apply (pg_dump preserves this for
2730        // schema-only mode but it shows up in some restores).
2731        let value_off = matches!(
2732            normalised.to_ascii_lowercase().as_str(),
2733            "0" | "off" | "false"
2734        );
2735        let value_on = matches!(
2736            normalised.to_ascii_lowercase().as_str(),
2737            "1" | "on" | "true"
2738        );
2739        if key == "foreign_key_checks"
2740            || key == "session_replication_role" && normalised.eq_ignore_ascii_case("replica")
2741        {
2742            if value_off || key == "session_replication_role" {
2743                self.foreign_key_checks = false;
2744            } else if value_on
2745                || (key == "session_replication_role" && normalised.eq_ignore_ascii_case("origin"))
2746            {
2747                self.foreign_key_checks = true;
2748                // Drain pending FK queue against the now-complete
2749                // catalog. Errors here surface as the SET reply —
2750                // caller knows enabling checks revealed orphans.
2751                let _ = self.drain_pending_foreign_keys();
2752            }
2753        }
2754        self.session_params.insert(key, normalised);
2755    }
2756
2757    /// v7.14.0 — resolve every queued FK whose installation was
2758    /// deferred (`SET FOREIGN_KEY_CHECKS=0` window). Called by
2759    /// `set_session_param` when checks flip back on and by the
2760    /// drop-import release gate. Each FK is resolved against the
2761    /// current catalog; remaining missing-parent errors propagate
2762    /// up so the caller knows the import was incomplete.
2763    fn drain_pending_foreign_keys(&mut self) -> Result<(), EngineError> {
2764        let pending = core::mem::take(&mut self.pending_foreign_keys);
2765        for (child, fk) in pending {
2766            // Resolve against the current catalog. Skip silently
2767            // when the child table itself was dropped between
2768            // queue + drain.
2769            let cols_snapshot = match self.active_catalog().get(&child) {
2770                Some(t) => t.schema().columns.clone(),
2771                None => continue,
2772            };
2773            let storage_fk =
2774                resolve_foreign_key(&child, &cols_snapshot, fk, self.active_catalog())?;
2775            let table = self
2776                .active_catalog_mut()
2777                .get_mut(&child)
2778                .expect("checked above");
2779            table.schema_mut().foreign_keys.push(storage_fk);
2780        }
2781        Ok(())
2782    }
2783
2784    /// v7.12.1 — read a session parameter set via `SET`. Used by
2785    /// the FTS function dispatcher to resolve the default config
2786    /// for `to_tsvector(text)` / `plainto_tsquery(text)` etc.
2787    #[must_use]
2788    pub fn session_param(&self, name: &str) -> Option<&str> {
2789        self.session_params
2790            .get(&name.to_ascii_lowercase())
2791            .map(String::as_str)
2792    }
2793
2794    /// v7.12.1 — build an `EvalContext` chained with the session's
2795    /// `default_text_search_config`. Engine-internal callers use
2796    /// this instead of `EvalContext::new` so the FTS function
2797    /// dispatcher sees the SET configuration.
2798    fn ev_ctx<'a>(
2799        &'a self,
2800        columns: &'a [ColumnSchema],
2801        alias: Option<&'a str>,
2802    ) -> EvalContext<'a> {
2803        EvalContext::new(columns, alias)
2804            .with_default_text_search_config(self.session_param("default_text_search_config"))
2805    }
2806
2807    /// `<db>.spg/segments/`. This arm only fires for engine-only
2808    /// callers (spg-embedded, lib tests); in that mode merged
2809    /// segments live in memory and are dropped at process exit.
2810    fn exec_compact_cold_segments(&mut self) -> Result<QueryResult, EngineError> {
2811        let target = COMPACTION_TARGET_DEFAULT_BYTES;
2812        let reports = self.compact_cold_segments_with_target(target)?;
2813        let columns = alloc::vec![
2814            ColumnSchema::new("table_name", DataType::Text, false),
2815            ColumnSchema::new("index_name", DataType::Text, false),
2816            ColumnSchema::new("sources_merged", DataType::BigInt, false),
2817            ColumnSchema::new("merged_segment_id", DataType::BigInt, false),
2818            ColumnSchema::new("merged_rows", DataType::BigInt, false),
2819            ColumnSchema::new("deleted_rows_pruned", DataType::BigInt, false),
2820            ColumnSchema::new("bytes_reclaimed_estimate", DataType::BigInt, false),
2821        ];
2822        let rows: Vec<Row> = reports
2823            .into_iter()
2824            .map(|(tname, iname, report)| {
2825                Row::new(alloc::vec![
2826                    Value::Text(tname),
2827                    Value::Text(iname),
2828                    Value::BigInt(i64::try_from(report.sources.len()).unwrap_or(i64::MAX)),
2829                    Value::BigInt(i64::from(report.merged_segment_id.unwrap_or(0))),
2830                    Value::BigInt(i64::try_from(report.merged_rows).unwrap_or(i64::MAX)),
2831                    Value::BigInt(i64::try_from(report.deleted_rows_pruned).unwrap_or(i64::MAX),),
2832                    Value::BigInt(
2833                        i64::try_from(report.bytes_reclaimed_estimate).unwrap_or(i64::MAX),
2834                    ),
2835                ])
2836            })
2837            .collect();
2838        Ok(QueryResult::Rows { columns, rows })
2839    }
2840
2841    /// Walk a single table's rows once and (re-)populate per-column
2842    /// stats. Drops the existing stats for `table` first so columns
2843    /// that have been DROP-ed between ANALYZEs don't leave stale
2844    /// rows.
2845    fn analyze_one_table(&mut self, table_name: &str) -> Result<(), EngineError> {
2846        let table = self.catalog.get(table_name).ok_or_else(|| {
2847            EngineError::Storage(StorageError::TableNotFound {
2848                name: table_name.to_string(),
2849            })
2850        })?;
2851        let schema = table.schema().clone();
2852        let row_count = table.rows().len();
2853        // For each column, collect (sorted) non-NULL textual values
2854        // + count NULLs; then ask `statistics::build_histogram` to
2855        // produce the 101 bounds and `estimate_n_distinct` the
2856        // distinct count.
2857        self.statistics.clear_table(table_name);
2858        for (col_pos, col_schema) in schema.columns.iter().enumerate() {
2859            // v6.2.0 skip: vector columns have their own stats
2860            // shape (HNSW graph topology). v6.2 deliberation #1.
2861            if matches!(col_schema.ty, DataType::Vector { .. }) {
2862                continue;
2863            }
2864            let mut non_null_values: Vec<Value> = Vec::with_capacity(row_count);
2865            let mut nulls: u64 = 0;
2866            for row in table.rows() {
2867                match row.values.get(col_pos) {
2868                    Some(Value::Null) | None => nulls += 1,
2869                    Some(v) => non_null_values.push(v.clone()),
2870                }
2871            }
2872            // Sort by type-aware ordering (Int as int, Text as
2873            // lex, etc.) so histogram bounds reflect the column's
2874            // natural order — not lexicographic on the string
2875            // representation, which would put "9" after "49".
2876            non_null_values.sort_by(|a, b| sort_values_for_histogram(a, b));
2877            let non_null: Vec<String> = non_null_values.iter().map(canonical_value_repr).collect();
2878            let null_frac = if row_count == 0 {
2879                0.0
2880            } else {
2881                #[allow(clippy::cast_precision_loss)]
2882                let f = nulls as f32 / row_count as f32;
2883                f
2884            };
2885            let n_distinct = statistics::estimate_n_distinct(&non_null);
2886            let histogram_bounds = statistics::build_histogram(&non_null);
2887            self.statistics.set(
2888                table_name.to_string(),
2889                col_schema.name.clone(),
2890                statistics::ColumnStats {
2891                    null_frac,
2892                    n_distinct,
2893                    histogram_bounds,
2894                },
2895            );
2896        }
2897        self.statistics.reset_modified(table_name);
2898        // v6.7.0 — refresh the per-table cold_rows cache. Walk the
2899        // BTree indices and count Cold locators (MAX across
2900        // indices); store the result on the table. Surfaced via
2901        // `spg_statistic.cold_row_count` (new column) and
2902        // `spg_stat_segment.table_name` (new column).
2903        let cold_count = {
2904            let table = self
2905                .active_catalog()
2906                .get(table_name)
2907                .expect("table still present");
2908            table.count_cold_locators()
2909        };
2910        let table_mut = self
2911            .active_catalog_mut()
2912            .get_mut(table_name)
2913            .expect("table still present");
2914        table_mut.set_cold_row_count(cold_count);
2915        Ok(())
2916    }
2917
2918    /// v6.1.3 — `SHOW PUBLICATIONS` row materialisation. Returns
2919    /// `(name, scope, table_count)` ordered by publication name.
2920    ///   - `scope` is the human-readable string:
2921    ///       `"FOR ALL TABLES"` /
2922    ///       `"FOR TABLE t1, t2"` /
2923    ///       `"FOR ALL TABLES EXCEPT t1, t2"`.
2924    ///   - `table_count` is NULL for `AllTables`, the list length
2925    ///     otherwise. NULLability lets clients distinguish "publish
2926    ///     everything" from "publish exactly 0 tables" (the v6.1.3
2927    ///     parser forbids the empty list, but the column shape is
2928    ///     ready for the v6.1.5 publisher-side semantics).
2929    fn exec_show_publications(&self) -> QueryResult {
2930        let columns = alloc::vec![
2931            ColumnSchema::new("name", DataType::Text, false),
2932            ColumnSchema::new("scope", DataType::Text, false),
2933            ColumnSchema::new("table_count", DataType::Int, true),
2934        ];
2935        let rows: Vec<Row> = self
2936            .publications
2937            .iter()
2938            .map(|(name, scope)| {
2939                let (scope_str, count_val) = match scope {
2940                    spg_sql::ast::PublicationScope::AllTables => {
2941                        ("FOR ALL TABLES".to_string(), Value::Null)
2942                    }
2943                    spg_sql::ast::PublicationScope::ForTables(ts) => (
2944                        alloc::format!("FOR TABLE {}", ts.join(", ")),
2945                        Value::Int(i32::try_from(ts.len()).unwrap_or(i32::MAX)),
2946                    ),
2947                    spg_sql::ast::PublicationScope::AllTablesExcept(ts) => (
2948                        alloc::format!("FOR ALL TABLES EXCEPT {}", ts.join(", ")),
2949                        Value::Int(i32::try_from(ts.len()).unwrap_or(i32::MAX)),
2950                    ),
2951                };
2952                Row::new(alloc::vec![
2953                    Value::Text(name.clone()),
2954                    Value::Text(scope_str),
2955                    count_val,
2956                ])
2957            })
2958            .collect();
2959        QueryResult::Rows { columns, rows }
2960    }
2961
2962    /// v4.1 `SHOW USERS` — `(name, role)` per row, ordered by name.
2963    fn exec_show_users(&self) -> QueryResult {
2964        let columns = alloc::vec![
2965            ColumnSchema::new("name", DataType::Text, false),
2966            ColumnSchema::new("role", DataType::Text, false),
2967        ];
2968        let rows: Vec<Row> = self
2969            .users
2970            .iter()
2971            .map(|(name, rec)| {
2972                Row::new(alloc::vec![
2973                    Value::Text(name.to_string()),
2974                    Value::Text(rec.role.as_str().to_string()),
2975                ])
2976            })
2977            .collect();
2978        QueryResult::Rows { columns, rows }
2979    }
2980
2981    fn exec_create_user(&mut self, s: &CreateUserStatement) -> Result<QueryResult, EngineError> {
2982        if self.in_transaction() {
2983            return Err(EngineError::Unsupported(
2984                "CREATE USER is not allowed inside a transaction".into(),
2985            ));
2986        }
2987        let role = users::Role::parse(&s.role).ok_or_else(|| {
2988            EngineError::Unsupported(alloc::format!("invalid role: {:?}", s.role))
2989        })?;
2990        // Prefer the host-injected RNG. Falls back to a deterministic
2991        // salt derived from the username only when no RNG is wired —
2992        // acceptable for tests; the server always installs one.
2993        let salt = self.salt_fn.map_or_else(
2994            || {
2995                let mut s_bytes = [0u8; 16];
2996                let digest = spg_crypto::hash(s.name.as_bytes());
2997                s_bytes.copy_from_slice(&digest[..16]);
2998                s_bytes
2999            },
3000            |f| f(),
3001        );
3002        self.users
3003            .create(&s.name, &s.password, role, salt)
3004            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE USER: {e}")))?;
3005        Ok(QueryResult::CommandOk {
3006            affected: 1,
3007            modified_catalog: true,
3008        })
3009    }
3010
3011    fn exec_drop_user(&mut self, name: &str) -> Result<QueryResult, EngineError> {
3012        if self.in_transaction() {
3013            return Err(EngineError::Unsupported(
3014                "DROP USER is not allowed inside a transaction".into(),
3015            ));
3016        }
3017        self.users
3018            .drop(name)
3019            .map_err(|e| EngineError::Unsupported(alloc::format!("DROP USER: {e}")))?;
3020        Ok(QueryResult::CommandOk {
3021            affected: 1,
3022            modified_catalog: true,
3023        })
3024    }
3025
3026    /// v7.12.4 — `CREATE [OR REPLACE] FUNCTION`. Stores the
3027    /// function metadata in the catalog. PL/pgSQL bodies are
3028    /// already parsed by the SQL parser; we re-canonicalise the
3029    /// body to source text for storage (the executor re-parses
3030    /// it at trigger fire time — see the trigger fire path).
3031    fn exec_create_function(
3032        &mut self,
3033        s: spg_sql::ast::CreateFunctionStatement,
3034    ) -> Result<QueryResult, EngineError> {
3035        let args_repr = render_function_args(&s.args);
3036        let returns = match &s.returns {
3037            spg_sql::ast::FunctionReturn::Trigger => alloc::string::String::from("TRIGGER"),
3038            spg_sql::ast::FunctionReturn::Void => alloc::string::String::from("VOID"),
3039            spg_sql::ast::FunctionReturn::Type(t) => alloc::format!("{t}"),
3040            spg_sql::ast::FunctionReturn::Other(s) => s.clone(),
3041        };
3042        let body_text = match &s.body {
3043            spg_sql::ast::FunctionBody::PlPgSql(b) => alloc::format!("{b}"),
3044            spg_sql::ast::FunctionBody::Raw(s) => s.clone(),
3045        };
3046        let def = spg_storage::FunctionDef {
3047            name: s.name.clone(),
3048            args_repr,
3049            returns,
3050            language: s.language.clone(),
3051            body: body_text,
3052        };
3053        self.active_catalog_mut()
3054            .create_function(def, s.or_replace)
3055            .map_err(EngineError::Storage)?;
3056        Ok(QueryResult::CommandOk {
3057            affected: 0,
3058            modified_catalog: true,
3059        })
3060    }
3061
3062    /// v7.12.4 — `CREATE [OR REPLACE] TRIGGER`. The referenced
3063    /// function must already exist in the catalog (forward
3064    /// references defer to a later release). Persists the
3065    /// trigger metadata for the row-write hooks below to consult.
3066    fn exec_create_trigger(
3067        &mut self,
3068        s: spg_sql::ast::CreateTriggerStatement,
3069    ) -> Result<QueryResult, EngineError> {
3070        let timing = match s.timing {
3071            spg_sql::ast::TriggerTiming::Before => "BEFORE",
3072            spg_sql::ast::TriggerTiming::After => "AFTER",
3073            spg_sql::ast::TriggerTiming::InsteadOf => "INSTEAD OF",
3074        };
3075        let events: Vec<alloc::string::String> = s
3076            .events
3077            .iter()
3078            .map(|e| match e {
3079                spg_sql::ast::TriggerEvent::Insert => alloc::string::String::from("INSERT"),
3080                spg_sql::ast::TriggerEvent::Update => alloc::string::String::from("UPDATE"),
3081                spg_sql::ast::TriggerEvent::Delete => alloc::string::String::from("DELETE"),
3082                spg_sql::ast::TriggerEvent::Truncate => alloc::string::String::from("TRUNCATE"),
3083            })
3084            .collect();
3085        let for_each = match s.for_each {
3086            spg_sql::ast::TriggerForEach::Row => "ROW",
3087            spg_sql::ast::TriggerForEach::Statement => "STATEMENT",
3088        };
3089        let def = spg_storage::TriggerDef {
3090            name: s.name.clone(),
3091            table: s.table.clone(),
3092            timing: alloc::string::String::from(timing),
3093            events,
3094            for_each: alloc::string::String::from(for_each),
3095            function: s.function.clone(),
3096            update_columns: s.update_columns.clone(),
3097            // v7.16.1 — every trigger is born enabled. Toggled
3098            // by ALTER TABLE … { ENABLE | DISABLE } TRIGGER.
3099            enabled: true,
3100        };
3101        self.active_catalog_mut()
3102            .create_trigger(def, s.or_replace)
3103            .map_err(EngineError::Storage)?;
3104        Ok(QueryResult::CommandOk {
3105            affected: 0,
3106            modified_catalog: true,
3107        })
3108    }
3109
3110    fn exec_drop_trigger(
3111        &mut self,
3112        name: &str,
3113        table: &str,
3114        if_exists: bool,
3115    ) -> Result<QueryResult, EngineError> {
3116        let removed = self.active_catalog_mut().drop_trigger(name, table);
3117        if !removed && !if_exists {
3118            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3119                alloc::format!("trigger {name:?} on {table:?} does not exist"),
3120            )));
3121        }
3122        Ok(QueryResult::CommandOk {
3123            affected: usize::from(removed),
3124            modified_catalog: removed,
3125        })
3126    }
3127
3128    fn exec_drop_function(
3129        &mut self,
3130        name: &str,
3131        if_exists: bool,
3132    ) -> Result<QueryResult, EngineError> {
3133        let removed = self.active_catalog_mut().drop_function(name);
3134        if !removed && !if_exists {
3135            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3136                alloc::format!("function {name:?} does not exist"),
3137            )));
3138        }
3139        Ok(QueryResult::CommandOk {
3140            affected: usize::from(removed),
3141            modified_catalog: removed,
3142        })
3143    }
3144
3145    /// v7.17.0 — `CREATE SEQUENCE` engine path. Resolves
3146    /// `min_value` / `max_value` / `start` against PG defaults
3147    /// when omitted, then installs the SequenceDef in the catalog.
3148    fn exec_create_sequence(
3149        &mut self,
3150        s: spg_sql::ast::CreateSequenceStatement,
3151    ) -> Result<QueryResult, EngineError> {
3152        use spg_sql::ast::{SeqBound, SequenceDataType as AstDt};
3153        use spg_storage::{SequenceDataType, SequenceDef};
3154        let dt = match s.data_type {
3155            None => SequenceDataType::BigInt,
3156            Some(AstDt::SmallInt) => SequenceDataType::SmallInt,
3157            Some(AstDt::Int) => SequenceDataType::Int,
3158            Some(AstDt::BigInt) => SequenceDataType::BigInt,
3159        };
3160        let increment = s.options.increment.unwrap_or(1);
3161        if increment == 0 {
3162            return Err(EngineError::Unsupported(
3163                "INCREMENT must not be zero".into(),
3164            ));
3165        }
3166        let (def_min, def_max) = dt.default_bounds(increment > 0);
3167        let min_value = match s.options.min_value {
3168            None | Some(SeqBound::NoBound) => def_min,
3169            Some(SeqBound::Value(n)) => n,
3170        };
3171        let max_value = match s.options.max_value {
3172            None | Some(SeqBound::NoBound) => def_max,
3173            Some(SeqBound::Value(n)) => n,
3174        };
3175        if min_value > max_value {
3176            return Err(EngineError::Unsupported(alloc::format!(
3177                "MINVALUE ({min_value}) must be <= MAXVALUE ({max_value})"
3178            )));
3179        }
3180        let start = s
3181            .options
3182            .start
3183            .unwrap_or(if increment > 0 { min_value } else { max_value });
3184        if start < min_value || start > max_value {
3185            return Err(EngineError::Unsupported(alloc::format!(
3186                "START WITH ({start}) is outside MINVALUE..MAXVALUE ({min_value}..{max_value})"
3187            )));
3188        }
3189        let cache = s.options.cache.unwrap_or(1);
3190        if cache < 1 {
3191            return Err(EngineError::Unsupported("CACHE must be >= 1".into()));
3192        }
3193        let cycle = s.options.cycle.unwrap_or(false);
3194        let owned_by = match s.options.owned_by {
3195            None | Some(spg_sql::ast::SequenceOwnedBy::None) => None,
3196            Some(spg_sql::ast::SequenceOwnedBy::Column { table, column }) => Some((table, column)),
3197        };
3198        let def = SequenceDef {
3199            name: s.name.clone(),
3200            data_type: dt,
3201            start,
3202            increment,
3203            min_value,
3204            max_value,
3205            cache,
3206            cycle,
3207            owned_by,
3208            last_value: start,
3209            is_called: false,
3210        };
3211        self.active_catalog_mut()
3212            .create_sequence(def, s.if_not_exists)
3213            .map_err(EngineError::Storage)?;
3214        Ok(QueryResult::CommandOk {
3215            affected: 0,
3216            modified_catalog: !self.in_transaction(),
3217        })
3218    }
3219
3220    /// v7.17.0 — `ALTER SEQUENCE` engine path. Re-uses the catalog
3221    /// `alter_sequence` merge helper.
3222    fn exec_alter_sequence(
3223        &mut self,
3224        s: spg_sql::ast::AlterSequenceStatement,
3225    ) -> Result<QueryResult, EngineError> {
3226        use spg_sql::ast::SeqBound;
3227        let cat = self.active_catalog_mut();
3228        if !cat.sequences().contains_key(&s.name) {
3229            if s.if_exists {
3230                return Ok(QueryResult::CommandOk {
3231                    affected: 0,
3232                    modified_catalog: false,
3233                });
3234            }
3235            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3236                alloc::format!("sequence {:?} does not exist", s.name),
3237            )));
3238        }
3239        let min_value = match s.options.min_value {
3240            None => None,
3241            Some(SeqBound::NoBound) => None, // NO MINVALUE → keep current
3242            Some(SeqBound::Value(n)) => Some(n),
3243        };
3244        let max_value = match s.options.max_value {
3245            None => None,
3246            Some(SeqBound::NoBound) => None,
3247            Some(SeqBound::Value(n)) => Some(n),
3248        };
3249        let owned_by = s.options.owned_by.map(|ob| match ob {
3250            spg_sql::ast::SequenceOwnedBy::None => None,
3251            spg_sql::ast::SequenceOwnedBy::Column { table, column } => Some((table, column)),
3252        });
3253        cat.alter_sequence(
3254            &s.name,
3255            s.options.increment,
3256            min_value,
3257            max_value,
3258            s.options.start,
3259            s.options.restart,
3260            s.options.cache,
3261            s.options.cycle,
3262            owned_by,
3263        )
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 Phase 1.1 — walk a Statement tree and pre-resolve
3272    /// any sequence FunctionCall nodes inside its Expr slots.
3273    /// Delegates per-statement-kind: SELECT projection +
3274    /// WHERE, INSERT VALUES, UPDATE SET, DELETE WHERE.
3275    fn pre_resolve_sequence_calls_in_statement(
3276        &mut self,
3277        stmt: &mut Statement,
3278    ) -> Result<(), EngineError> {
3279        match stmt {
3280            Statement::Select(s) => self.pre_resolve_sequence_calls_in_select(s),
3281            Statement::Insert(s) => {
3282                for tuple in &mut s.rows {
3283                    for cell in tuple.iter_mut() {
3284                        self.resolve_sequence_calls_in_expr(cell)?;
3285                    }
3286                }
3287                Ok(())
3288            }
3289            Statement::Update(s) => {
3290                for (_col, expr) in &mut s.assignments {
3291                    self.resolve_sequence_calls_in_expr(expr)?;
3292                }
3293                if let Some(w) = &mut s.where_ {
3294                    self.resolve_sequence_calls_in_expr(w)?;
3295                }
3296                Ok(())
3297            }
3298            Statement::Delete(s) => {
3299                if let Some(w) = &mut s.where_ {
3300                    self.resolve_sequence_calls_in_expr(w)?;
3301                }
3302                Ok(())
3303            }
3304            _ => Ok(()),
3305        }
3306    }
3307
3308    fn pre_resolve_sequence_calls_in_select(
3309        &mut self,
3310        s: &mut spg_sql::ast::SelectStatement,
3311    ) -> Result<(), EngineError> {
3312        for item in &mut s.items {
3313            match item {
3314                spg_sql::ast::SelectItem::Expr { expr, .. } => {
3315                    self.resolve_sequence_calls_in_expr(expr)?;
3316                }
3317                spg_sql::ast::SelectItem::Wildcard => {}
3318            }
3319        }
3320        if let Some(w) = &mut s.where_ {
3321            self.resolve_sequence_calls_in_expr(w)?;
3322        }
3323        Ok(())
3324    }
3325
3326    /// v7.17.0 Phase 1.1 — walk an Expr tree and pre-resolve any
3327    /// `nextval(name)` / `currval(name)` / `setval(name, value[,
3328    /// is_called])` FunctionCall nodes by calling the catalog and
3329    /// replacing the node with the resulting `Expr::Literal`.
3330    /// Used by INSERT VALUES / UPDATE SET / DEFAULT eval so the
3331    /// row-eval path sees pre-computed sequence values instead of
3332    /// needing mutable catalog access mid-eval.
3333    #[allow(clippy::too_many_lines)]
3334    fn resolve_sequence_calls_in_expr(&mut self, expr: &mut Expr) -> Result<(), EngineError> {
3335        match expr {
3336            Expr::Literal(_) | Expr::Column(_) | Expr::Placeholder(_) => Ok(()),
3337            Expr::FunctionCall { name, args } => {
3338                // Descend first so nested calls — e.g.
3339                // setval('seq', currval('other')) — resolve
3340                // innermost-first.
3341                for a in args.iter_mut() {
3342                    self.resolve_sequence_calls_in_expr(a)?;
3343                }
3344                let lc = name.to_ascii_lowercase();
3345                if lc == "nextval" || lc == "currval" || lc == "setval" {
3346                    let v = self.eval_sequence_call(&lc, args)?;
3347                    *expr = Expr::Literal(value_to_literal(v));
3348                }
3349                Ok(())
3350            }
3351            Expr::Binary { lhs, rhs, .. } => {
3352                self.resolve_sequence_calls_in_expr(lhs)?;
3353                self.resolve_sequence_calls_in_expr(rhs)
3354            }
3355            Expr::Unary { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3356            Expr::Cast { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3357            Expr::IsNull { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3358            Expr::Like { expr, pattern, .. } => {
3359                self.resolve_sequence_calls_in_expr(expr)?;
3360                self.resolve_sequence_calls_in_expr(pattern)
3361            }
3362            Expr::Extract { source, .. } => self.resolve_sequence_calls_in_expr(source),
3363            Expr::Array(items) => {
3364                for it in items.iter_mut() {
3365                    self.resolve_sequence_calls_in_expr(it)?;
3366                }
3367                Ok(())
3368            }
3369            // Window / subquery / etc — sequence calls inside these
3370            // are uncommon and require separate row-eval; leave
3371            // untouched for now and rely on the eval-time error
3372            // (no sequence_resolver attached).
3373            _ => Ok(()),
3374        }
3375    }
3376
3377    /// v7.17.0 Phase 1.1 — evaluate a single nextval/currval/
3378    /// setval call. `args` are already pre-resolved Expr nodes
3379    /// (literals) — we extract their constant values.
3380    fn eval_sequence_call(&mut self, op: &str, args: &[Expr]) -> Result<Value, EngineError> {
3381        if args.is_empty() {
3382            return Err(EngineError::Unsupported(alloc::format!(
3383                "{op}() takes at least one argument"
3384            )));
3385        }
3386        let seq_name = match &args[0] {
3387            Expr::Literal(spg_sql::ast::Literal::String(s)) => {
3388                // v7.17 dump-compat — pg_dump emits sequence
3389                // names schema-qualified (`'public.posts_id_seq'`).
3390                // SPG is single-schema; strip a leading
3391                // `public.` / `pg_catalog.` so the catalog lookup
3392                // matches the bare-name CREATE SEQUENCE used.
3393                let trimmed = s
3394                    .strip_prefix("public.")
3395                    .or_else(|| s.strip_prefix("pg_catalog."))
3396                    .unwrap_or(s);
3397                trimmed.to_string()
3398            }
3399            // v7.17 dump-compat — pg_dump also emits
3400            // `nextval('public.posts_id_seq'::regclass)`
3401            // where the cast wraps the literal. Peel the cast
3402            // and continue.
3403            Expr::Cast { expr, .. } => {
3404                if let Expr::Literal(spg_sql::ast::Literal::String(s)) = expr.as_ref() {
3405                    let trimmed = s
3406                        .strip_prefix("public.")
3407                        .or_else(|| s.strip_prefix("pg_catalog."))
3408                        .unwrap_or(s);
3409                    trimmed.to_string()
3410                } else {
3411                    return Err(EngineError::Unsupported(alloc::format!(
3412                        "{op}() first argument must be a literal sequence name"
3413                    )));
3414                }
3415            }
3416            other => {
3417                return Err(EngineError::Unsupported(alloc::format!(
3418                    "{op}() first argument must be a literal sequence name, got {other:?}"
3419                )));
3420            }
3421        };
3422        match op {
3423            "nextval" => {
3424                let v = self
3425                    .active_catalog_mut()
3426                    .sequence_next_value(&seq_name)
3427                    .map_err(EngineError::Storage)?;
3428                Ok(Value::BigInt(v))
3429            }
3430            "currval" => {
3431                let v = self
3432                    .active_catalog()
3433                    .sequence_current_value(&seq_name)
3434                    .map_err(EngineError::Storage)?;
3435                Ok(Value::BigInt(v))
3436            }
3437            "setval" => {
3438                if args.len() < 2 || args.len() > 3 {
3439                    return Err(EngineError::Unsupported(alloc::format!(
3440                        "setval() takes 2 or 3 arguments, got {}",
3441                        args.len()
3442                    )));
3443                }
3444                let value = match &args[1] {
3445                    Expr::Literal(spg_sql::ast::Literal::Integer(n)) => *n,
3446                    other => {
3447                        return Err(EngineError::Unsupported(alloc::format!(
3448                            "setval() value argument must be a literal integer, got {other:?}"
3449                        )));
3450                    }
3451                };
3452                let is_called = if args.len() == 3 {
3453                    match &args[2] {
3454                        Expr::Literal(spg_sql::ast::Literal::Bool(b)) => *b,
3455                        other => {
3456                            return Err(EngineError::Unsupported(alloc::format!(
3457                                "setval() is_called argument must be a literal BOOL, got {other:?}"
3458                            )));
3459                        }
3460                    }
3461                } else {
3462                    true
3463                };
3464                let v = self
3465                    .active_catalog_mut()
3466                    .sequence_set_value(&seq_name, value, is_called)
3467                    .map_err(EngineError::Storage)?;
3468                Ok(Value::BigInt(v))
3469            }
3470            other => Err(EngineError::Unsupported(alloc::format!(
3471                "unknown sequence op {other:?}"
3472            ))),
3473        }
3474    }
3475
3476    /// v7.17.0 Phase 1.2 — find every catalog VIEW referenced in
3477    /// the SELECT's FROM / JOIN graph, re-parse each view's body
3478    /// source, and prepend it as a synthetic CTE on the
3479    /// returned SelectStatement. Returns `None` when no view
3480    /// references are found (caller proceeds with the original
3481    /// statement); returns `Some(rewritten)` otherwise (caller
3482    /// re-runs exec_select_cancel on the rewritten form so the
3483    /// regular CTE materialiser handles it).
3484    fn expand_views_in_select(
3485        &self,
3486        stmt: &SelectStatement,
3487    ) -> Result<Option<SelectStatement>, EngineError> {
3488        let cat = self.active_catalog();
3489        let mut referenced: Vec<String> = Vec::new();
3490        if let Some(from) = &stmt.from {
3491            collect_view_refs(&from.primary, cat, &mut referenced);
3492            for j in &from.joins {
3493                collect_view_refs(&j.table, cat, &mut referenced);
3494            }
3495        }
3496        // Don't expand a view name that's already shadowed by a
3497        // CTE on the same SELECT — the CTE wins per PG.
3498        referenced.retain(|n| !stmt.ctes.iter().any(|c| c.name == *n));
3499        if referenced.is_empty() {
3500            return Ok(None);
3501        }
3502        let mut new_ctes: Vec<spg_sql::ast::Cte> = Vec::with_capacity(referenced.len());
3503        for name in &referenced {
3504            let view = cat.views().get(name).ok_or_else(|| {
3505                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3506                    "view {name:?} disappeared mid-expansion"
3507                )))
3508            })?;
3509            let parsed = spg_sql::parser::parse_statement(&view.body).map_err(|e| {
3510                EngineError::Unsupported(alloc::format!("view {name:?} body re-parse failed: {e}"))
3511            })?;
3512            let Statement::Select(body) = parsed else {
3513                return Err(EngineError::Unsupported(alloc::format!(
3514                    "view {name:?} body is not a SELECT (catalog corruption)"
3515                )));
3516            };
3517            new_ctes.push(spg_sql::ast::Cte {
3518                name: name.clone(),
3519                body,
3520                recursive: false,
3521                column_overrides: view.columns.clone(),
3522            });
3523        }
3524        let mut out = stmt.clone();
3525        // Prepend so view CTEs are visible to caller-supplied CTEs.
3526        new_ctes.extend(out.ctes);
3527        out.ctes = new_ctes;
3528        Ok(Some(out))
3529    }
3530
3531    /// v7.17.0 Phase 1.2 — `CREATE VIEW` engine path. Stores the
3532    /// Display-rendered body verbatim in the catalog; SELECT-from-
3533    /// view at exec time re-parses + prepends as a synthetic CTE.
3534    fn exec_create_view(
3535        &mut self,
3536        s: spg_sql::ast::CreateViewStatement,
3537    ) -> Result<QueryResult, EngineError> {
3538        // Render the SELECT body to canonical form so the catalog
3539        // round-trips a deterministic source (no whitespace /
3540        // comment surprises in the on-disk snapshot).
3541        let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body));
3542        let def = spg_storage::ViewDef {
3543            name: s.name.clone(),
3544            columns: s.columns,
3545            body: body_repr,
3546        };
3547        self.active_catalog_mut()
3548            .create_view(def, s.or_replace, s.if_not_exists)
3549            .map_err(EngineError::Storage)?;
3550        Ok(QueryResult::CommandOk {
3551            affected: 0,
3552            modified_catalog: !self.in_transaction(),
3553        })
3554    }
3555
3556    /// v7.17.0 Phase 1.4 — `CREATE TYPE name AS ENUM (…)` engine
3557    /// path. Registers the enum in the catalog with order-
3558    /// preserving labels. PG semantics: CREATE TYPE errors if the
3559    /// name is taken (no IF NOT EXISTS).
3560    fn exec_create_type(
3561        &mut self,
3562        s: spg_sql::ast::CreateTypeStatement,
3563    ) -> Result<QueryResult, EngineError> {
3564        // Name-collision check against tables / sequences / views /
3565        // materialized views.
3566        let cat = self.active_catalog();
3567        if cat.get(&s.name).is_some() {
3568            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3569                alloc::format!("type {:?} would shadow an existing table", s.name),
3570            )));
3571        }
3572        if cat.sequences().contains_key(&s.name) {
3573            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3574                alloc::format!("type {:?} would shadow an existing sequence", s.name),
3575            )));
3576        }
3577        if cat.views().contains_key(&s.name) {
3578            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3579                alloc::format!("type {:?} would shadow an existing view", s.name),
3580            )));
3581        }
3582        let def = match s.kind {
3583            spg_sql::ast::TypeKind::Enum { labels } => {
3584                if labels.is_empty() {
3585                    return Err(EngineError::Unsupported(
3586                        "CREATE TYPE … AS ENUM requires at least one label".into(),
3587                    ));
3588                }
3589                // Reject duplicate labels per PG.
3590                for i in 0..labels.len() {
3591                    for j in (i + 1)..labels.len() {
3592                        if labels[i] == labels[j] {
3593                            return Err(EngineError::Unsupported(alloc::format!(
3594                                "CREATE TYPE {:?}: duplicate ENUM label {:?}",
3595                                s.name,
3596                                labels[i]
3597                            )));
3598                        }
3599                    }
3600                }
3601                spg_storage::EnumDef {
3602                    name: s.name.clone(),
3603                    labels,
3604                }
3605            }
3606        };
3607        self.active_catalog_mut()
3608            .create_enum_type(def)
3609            .map_err(EngineError::Storage)?;
3610        Ok(QueryResult::CommandOk {
3611            affected: 0,
3612            modified_catalog: !self.in_transaction(),
3613        })
3614    }
3615
3616    /// v7.17.0 Phase 1.5 — `CREATE DOMAIN name AS base [DEFAULT
3617    /// expr] [NOT NULL] [CHECK (expr)]*` engine path. Stores the
3618    /// base type + Display-rendered CHECK / DEFAULT sources so
3619    /// INSERT/UPDATE on bound columns can re-eval the checks.
3620    fn exec_create_domain(
3621        &mut self,
3622        s: spg_sql::ast::CreateDomainStatement,
3623    ) -> Result<QueryResult, EngineError> {
3624        let cat = self.active_catalog();
3625        if cat.domain_types().contains_key(&s.name) {
3626            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3627                alloc::format!("domain {:?} already exists", s.name),
3628            )));
3629        }
3630        if cat.get(&s.name).is_some()
3631            || cat.sequences().contains_key(&s.name)
3632            || cat.views().contains_key(&s.name)
3633            || cat.enum_types().contains_key(&s.name)
3634        {
3635            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3636                alloc::format!("domain {:?} would shadow an existing object", s.name),
3637            )));
3638        }
3639        let base_type = column_type_to_data_type(s.base_type);
3640        let default = s.default.as_ref().map(|e| alloc::format!("{e}"));
3641        let checks = s
3642            .checks
3643            .iter()
3644            .map(|e| alloc::format!("{e}"))
3645            .collect::<Vec<_>>();
3646        let def = spg_storage::DomainDef {
3647            name: s.name.clone(),
3648            base_type,
3649            nullable: !s.not_null,
3650            default,
3651            checks,
3652        };
3653        self.active_catalog_mut()
3654            .create_domain_type(def)
3655            .map_err(EngineError::Storage)?;
3656        Ok(QueryResult::CommandOk {
3657            affected: 0,
3658            modified_catalog: !self.in_transaction(),
3659        })
3660    }
3661
3662    /// v7.17.0 Phase 1.5 — `DROP DOMAIN [IF EXISTS] names`.
3663    fn exec_drop_domain(
3664        &mut self,
3665        names: &[String],
3666        if_exists: bool,
3667    ) -> Result<QueryResult, EngineError> {
3668        let mut removed = 0usize;
3669        for name in names {
3670            let was_present = self.active_catalog_mut().drop_domain_type(name);
3671            if was_present {
3672                removed += 1;
3673            } else if !if_exists {
3674                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3675                    alloc::format!("domain {name:?} does not exist"),
3676                )));
3677            }
3678        }
3679        Ok(QueryResult::CommandOk {
3680            affected: removed,
3681            modified_catalog: removed > 0 && !self.in_transaction(),
3682        })
3683    }
3684
3685    /// v7.17.0 Phase 1.6 — `CREATE SCHEMA [IF NOT EXISTS] name`.
3686    /// Registers the schema in the catalog. Schema-qualified
3687    /// table references continue to strip the prefix at lookup
3688    /// time (prefix routing, not isolation — see project-next-
3689    /// docket for the v7.18+ real-isolation tracking).
3690    fn exec_create_schema(
3691        &mut self,
3692        name: String,
3693        if_not_exists: bool,
3694    ) -> Result<QueryResult, EngineError> {
3695        self.active_catalog_mut()
3696            .create_schema(name, if_not_exists)
3697            .map_err(EngineError::Storage)?;
3698        Ok(QueryResult::CommandOk {
3699            affected: 0,
3700            modified_catalog: !self.in_transaction(),
3701        })
3702    }
3703
3704    /// v7.17.0 Phase 1.6 — `DROP SCHEMA [IF EXISTS] names`.
3705    /// Built-in schemas always reject the drop with a clear
3706    /// error.
3707    fn exec_drop_schema(
3708        &mut self,
3709        names: &[String],
3710        if_exists: bool,
3711    ) -> Result<QueryResult, EngineError> {
3712        let mut removed = 0usize;
3713        for name in names {
3714            let was_present = self
3715                .active_catalog_mut()
3716                .drop_schema(name)
3717                .map_err(EngineError::Storage)?;
3718            if was_present {
3719                removed += 1;
3720            } else if !if_exists {
3721                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3722                    alloc::format!("schema {name:?} does not exist"),
3723                )));
3724            }
3725        }
3726        Ok(QueryResult::CommandOk {
3727            affected: removed,
3728            modified_catalog: removed > 0 && !self.in_transaction(),
3729        })
3730    }
3731
3732    /// v7.17.0 Phase 1.4 — `DROP TYPE [IF EXISTS] names`. Only
3733    /// ENUM types are catalogued today; other types silently
3734    /// no-op even outside IF EXISTS to mirror the prior
3735    /// "everything's text" lax stance.
3736    fn exec_drop_type(
3737        &mut self,
3738        names: &[String],
3739        if_exists: bool,
3740    ) -> Result<QueryResult, EngineError> {
3741        let mut removed = 0usize;
3742        for name in names {
3743            let was_present = self.active_catalog_mut().drop_enum_type(name);
3744            if was_present {
3745                removed += 1;
3746            } else if !if_exists {
3747                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3748                    alloc::format!("type {name:?} does not exist"),
3749                )));
3750            }
3751        }
3752        Ok(QueryResult::CommandOk {
3753            affected: removed,
3754            modified_catalog: removed > 0 && !self.in_transaction(),
3755        })
3756    }
3757
3758    /// v7.17.0 Phase 1.3 — `CREATE MATERIALIZED VIEW` engine path.
3759    /// Materialises the body at CREATE time (unless WITH NO DATA),
3760    /// stores the result as a regular `Table`, and registers the
3761    /// body source in the catalog so REFRESH can re-run it.
3762    fn exec_create_materialized_view(
3763        &mut self,
3764        s: spg_sql::ast::CreateMaterializedViewStatement,
3765    ) -> Result<QueryResult, EngineError> {
3766        // Name-collision check (table / view / sequence / mat-view).
3767        let cat = self.active_catalog();
3768        if cat.materialized_views().contains_key(&s.name) || cat.get(&s.name).is_some() {
3769            if s.if_not_exists {
3770                return Ok(QueryResult::CommandOk {
3771                    affected: 0,
3772                    modified_catalog: false,
3773                });
3774            }
3775            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3776                alloc::format!("materialized view {:?} already exists", s.name),
3777            )));
3778        }
3779        if cat.views().contains_key(&s.name) {
3780            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3781                alloc::format!(
3782                    "materialized view {:?} would shadow an existing view",
3783                    s.name
3784                ),
3785            )));
3786        }
3787        if cat.sequences().contains_key(&s.name) {
3788            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3789                alloc::format!(
3790                    "materialized view {:?} would shadow an existing sequence",
3791                    s.name
3792                ),
3793            )));
3794        }
3795        // Render the body to canonical form for the registry.
3796        let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body.clone()));
3797        // Execute the body to learn the columns. With WITH DATA we
3798        // also materialise the rows; with WITH NO DATA we only need
3799        // the schema, so re-use a LIMIT 0 wrap to keep the column
3800        // inference path uniform without paying for the rows.
3801        let result = self.exec_select_cancel(&s.body, CancelToken::none())?;
3802        let (mut cols, rows) = match result {
3803            QueryResult::Rows { columns, rows } => (columns, rows),
3804            other => {
3805                return Err(EngineError::Unsupported(alloc::format!(
3806                    "CREATE MATERIALIZED VIEW body did not return rows: {other:?}"
3807                )));
3808            }
3809        };
3810        // Apply the column-rename list per PG semantics.
3811        if !s.columns.is_empty() {
3812            if s.columns.len() != cols.len() {
3813                return Err(EngineError::Unsupported(alloc::format!(
3814                    "CREATE MATERIALIZED VIEW {:?}: column list has {} names but body returns {}",
3815                    s.name,
3816                    s.columns.len(),
3817                    cols.len()
3818                )));
3819            }
3820            for (c, name) in cols.iter_mut().zip(s.columns.iter()) {
3821                c.name.clone_from(name);
3822            }
3823        }
3824        // Promote any synthetic-Text projections to their actual
3825        // observed types so the backing table accepts the rows.
3826        cols = infer_column_types(&cols, &rows);
3827        let schema = spg_storage::TableSchema::new(s.name.clone(), cols);
3828        let cat = self.active_catalog_mut();
3829        cat.create_table(schema).map_err(EngineError::Storage)?;
3830        if s.with_data {
3831            let table = cat
3832                .get_mut(&s.name)
3833                .expect("just-created materialized-view backing table must exist");
3834            for row in rows {
3835                table.insert(row).map_err(EngineError::Storage)?;
3836            }
3837        }
3838        cat.register_materialized_view(s.name.clone(), body_repr);
3839        Ok(QueryResult::CommandOk {
3840            affected: 0,
3841            modified_catalog: !self.in_transaction(),
3842        })
3843    }
3844
3845    /// v7.17.0 Phase 1.3 — `REFRESH MATERIALIZED VIEW name [WITH
3846    /// [NO] DATA]`. Looks up the source, re-runs it, replaces the
3847    /// backing table's rows.
3848    fn exec_refresh_materialized_view(
3849        &mut self,
3850        name: &str,
3851        with_data: bool,
3852    ) -> Result<QueryResult, EngineError> {
3853        let source = self
3854            .active_catalog()
3855            .materialized_views()
3856            .get(name)
3857            .cloned()
3858            .ok_or_else(|| {
3859                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3860                    "materialized view {name:?} does not exist"
3861                )))
3862            })?;
3863        // Wipe the existing rows first (PG truncates the matview
3864        // and rebuilds; we approximate with an empty INSERT loop).
3865        {
3866            let cat = self.active_catalog_mut();
3867            let table = cat.get_mut(name).ok_or_else(|| {
3868                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3869                    "materialized view {name:?} backing table missing"
3870                )))
3871            })?;
3872            table.truncate();
3873        }
3874        if !with_data {
3875            return Ok(QueryResult::CommandOk {
3876                affected: 0,
3877                modified_catalog: !self.in_transaction(),
3878            });
3879        }
3880        let parsed = spg_sql::parser::parse_statement(&source).map_err(|e| {
3881            EngineError::Unsupported(alloc::format!(
3882                "materialized view {name:?} body re-parse failed: {e}"
3883            ))
3884        })?;
3885        let Statement::Select(body) = parsed else {
3886            return Err(EngineError::Unsupported(alloc::format!(
3887                "materialized view {name:?} body is not a SELECT (catalog corruption)"
3888            )));
3889        };
3890        let rows = match self.exec_select_cancel(&body, CancelToken::none())? {
3891            QueryResult::Rows { rows, .. } => rows,
3892            other => {
3893                return Err(EngineError::Unsupported(alloc::format!(
3894                    "REFRESH MATERIALIZED VIEW {name:?} body did not return rows: {other:?}"
3895                )));
3896            }
3897        };
3898        let cat = self.active_catalog_mut();
3899        let table = cat.get_mut(name).expect("backing table verified above");
3900        let affected = rows.len();
3901        for row in rows {
3902            table.insert(row).map_err(EngineError::Storage)?;
3903        }
3904        Ok(QueryResult::CommandOk {
3905            affected,
3906            modified_catalog: !self.in_transaction(),
3907        })
3908    }
3909
3910    /// v7.17.0 Phase 1.3 — `DROP MATERIALIZED VIEW [IF EXISTS]
3911    /// names`. Drops the backing table + unregisters the source.
3912    fn exec_drop_materialized_view(
3913        &mut self,
3914        names: &[String],
3915        if_exists: bool,
3916    ) -> Result<QueryResult, EngineError> {
3917        let mut removed = 0usize;
3918        for name in names {
3919            let was_present = self
3920                .active_catalog_mut()
3921                .drop_materialized_view_source(name);
3922            if was_present {
3923                // Drop the backing table too.
3924                self.active_catalog_mut().drop_table(name);
3925                removed += 1;
3926            } else if !if_exists {
3927                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3928                    alloc::format!("materialized view {name:?} does not exist"),
3929                )));
3930            }
3931        }
3932        Ok(QueryResult::CommandOk {
3933            affected: removed,
3934            modified_catalog: removed > 0 && !self.in_transaction(),
3935        })
3936    }
3937
3938    /// v7.17.0 Phase 1.2 — `DROP VIEW [IF EXISTS] name [, name…]`.
3939    fn exec_drop_view(
3940        &mut self,
3941        names: &[String],
3942        if_exists: bool,
3943    ) -> Result<QueryResult, EngineError> {
3944        let mut removed = 0usize;
3945        for name in names {
3946            let was_present = self.active_catalog_mut().drop_view(name);
3947            if !was_present && !if_exists {
3948                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3949                    alloc::format!("view {name:?} does not exist"),
3950                )));
3951            }
3952            if was_present {
3953                removed += 1;
3954            }
3955        }
3956        Ok(QueryResult::CommandOk {
3957            affected: removed,
3958            modified_catalog: removed > 0 && !self.in_transaction(),
3959        })
3960    }
3961
3962    /// v7.17.0 — `DROP SEQUENCE [IF EXISTS] name [, name…]`.
3963    fn exec_drop_sequence(
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.active_catalog_mut().drop_sequence(name);
3971            if !was_present && !if_exists {
3972                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3973                    alloc::format!("sequence {name:?} does not exist"),
3974                )));
3975            }
3976            if was_present {
3977                removed += 1;
3978            }
3979        }
3980        Ok(QueryResult::CommandOk {
3981            affected: removed,
3982            modified_catalog: removed > 0 && !self.in_transaction(),
3983        })
3984    }
3985
3986    /// v4.4 `UPDATE <table> SET col = expr [, ...] [WHERE cond]`.
3987    /// Filter pass uses the same WHERE eval as `exec_select`. Per
3988    /// matched row, evaluate each RHS expression against the *old*
3989    /// row, then call `Table::update_row` which rebuilds indices.
3990    /// Indexed columns are correctly reflected because rebuild
3991    /// happens after the cell rewrite.
3992    fn exec_update_cancel(
3993        &mut self,
3994        stmt: &spg_sql::ast::UpdateStatement,
3995        cancel: CancelToken<'_>,
3996    ) -> Result<QueryResult, EngineError> {
3997        // v7.12.5 — snapshot BEFORE/AFTER UPDATE row triggers + the
3998        // session FTS config before the table mut-borrow opens (the
3999        // INSERT path uses the same pattern). Empty vecs are the
4000        // common "no triggers on this table" fast path.
4001        // v7.13.0 — UPDATE triggers carry an optional `UPDATE OF
4002        // cols` filter. The filter is paired with each function so
4003        // the per-row fire loop can skip when no listed column
4004        // actually differs between OLD and NEW.
4005        let before_update_triggers = self.snapshot_update_row_triggers(&stmt.table, "BEFORE");
4006        let after_update_triggers = self.snapshot_update_row_triggers(&stmt.table, "AFTER");
4007        let trigger_session_cfg: Option<String> = self
4008            .session_params
4009            .get("default_text_search_config")
4010            .cloned();
4011        // v5.2.3: if the WHERE is a PK equality and matches a cold-
4012        // tier row, promote it back to the hot tier *before* the
4013        // hot-row walk. The promote pushes the row to the end of
4014        // `table.rows`, where the upcoming SET-evaluation loop will
4015        // pick it up and apply the assignments. Lookups for the key
4016        // never observe a gap because `promote_cold_row` inserts the
4017        // hot row before retiring the cold locator.
4018        if let Some(w) = &stmt.where_ {
4019            let schema_cols = self
4020                .active_catalog()
4021                .get(&stmt.table)
4022                .ok_or_else(|| {
4023                    EngineError::Storage(StorageError::TableNotFound {
4024                        name: stmt.table.clone(),
4025                    })
4026                })?
4027                .schema()
4028                .columns
4029                .clone();
4030            if let Some((col_pos, key)) = try_pk_predicate(w, &schema_cols, stmt.table.as_str())
4031                && let Some(idx_name) = self
4032                    .active_catalog()
4033                    .get(&stmt.table)
4034                    .and_then(|t| t.index_on(col_pos).map(|i| i.name.clone()))
4035            {
4036                // Promote may be a no-op (key is hot-only or absent);
4037                // we don't care about the return value here — the
4038                // subsequent hot walk will either match or not.
4039                let _ = self
4040                    .active_catalog_mut()
4041                    .promote_cold_row(&stmt.table, &idx_name, &key);
4042            }
4043        }
4044
4045        // v7.12.1 — cache session FTS config before the table
4046        // mut-borrow (same reason as exec_delete).
4047        let ts_cfg: Option<String> = self
4048            .session_param("default_text_search_config")
4049            .map(String::from);
4050        // v7.17.0 Phase 2.1 — snapshot the clock pointer before
4051        // we hold the catalog mutably so ON UPDATE runtime
4052        // overrides see the engine wall clock.
4053        let clock_for_on_update = self.clock;
4054        let table = self
4055            .active_catalog_mut()
4056            .get_mut(&stmt.table)
4057            .ok_or_else(|| {
4058                EngineError::Storage(StorageError::TableNotFound {
4059                    name: stmt.table.clone(),
4060                })
4061            })?;
4062        let schema_cols: Vec<ColumnSchema> = table.schema().columns.clone();
4063        // Resolve each SET target to a column position once, validate
4064        // up front so a typo'd column doesn't leave a partial mutation
4065        // behind.
4066        let mut targets: Vec<(usize, &Expr)> = Vec::with_capacity(stmt.assignments.len());
4067        for (col, expr) in &stmt.assignments {
4068            let pos = schema_cols
4069                .iter()
4070                .position(|c| c.name == *col)
4071                .ok_or_else(|| {
4072                    EngineError::Eval(EvalError::ColumnNotFound { name: col.clone() })
4073                })?;
4074            targets.push((pos, expr));
4075        }
4076        // v7.17.0 Phase 2.1 — for every column with an
4077        // `ON UPDATE CURRENT_TIMESTAMP` binding that the caller
4078        // did NOT explicitly set, schedule an automatic override.
4079        // Reuses `eval_runtime_default_free` so the same
4080        // canonical runtime-expression whitelist (now /
4081        // current_timestamp / current_date / …) governs both
4082        // DEFAULT and ON UPDATE.
4083        let mut on_update_overrides: Vec<(usize, String)> = Vec::new();
4084        for (i, col) in schema_cols.iter().enumerate() {
4085            if targets.iter().any(|(p, _)| *p == i) {
4086                continue;
4087            }
4088            if let Some(src) = &col.on_update_runtime {
4089                on_update_overrides.push((i, src.clone()));
4090            }
4091        }
4092        let ctx = EvalContext::new(&schema_cols, Some(stmt.table.as_str()))
4093            .with_default_text_search_config(ts_cfg.as_deref());
4094        // Walk candidate rows, evaluate WHERE then SET
4095        // expressions. We gather (position, new_values) tuples
4096        // first and apply them afterwards so the WHERE/RHS
4097        // evaluation reads the original row state — matches PG
4098        // semantics (UPDATE doesn't see its own writes).
4099        //
4100        // v7.20 P4 — index seek: a single-column equality WHERE
4101        // on an indexed column narrows the walk from
4102        // O(table.rows()) to O(matches). The full WHERE still
4103        // re-evaluates per candidate (the seek may be an
4104        // over-approximation under AND-composites), so semantics
4105        // are unchanged. profile: the bench's `UPDATE … WHERE
4106        // id = $1` on a 5 000-row table was a ~1.3 ms full scan
4107        // per statement; with the seek it's ~2 µs.
4108        let seek_positions: Option<Vec<usize>> = stmt
4109            .where_
4110            .as_ref()
4111            .and_then(|w| try_index_seek_positions(w, &schema_cols, table, stmt.table.as_str()));
4112        let mut planned: Vec<(usize, Vec<Value>)> = Vec::new();
4113        let candidate_positions: Vec<usize> = match &seek_positions {
4114            Some(list) => list.clone(),
4115            None => (0..table.row_count()).collect(),
4116        };
4117        for (loop_n, &i) in candidate_positions.iter().enumerate() {
4118            // v4.5: cooperative cancel checkpoint every 256 rows so
4119            // a runaway UPDATE without WHERE doesn't drag past the
4120            // server's query-timeout watchdog.
4121            if loop_n.is_multiple_of(256) {
4122                cancel.check()?;
4123            }
4124            let Some(row) = table.rows().get(i) else {
4125                continue;
4126            };
4127            if let Some(w) = &stmt.where_ {
4128                let cond = eval::eval_expr(w, row, &ctx)?;
4129                if !matches!(cond, Value::Bool(true)) {
4130                    continue;
4131                }
4132            }
4133            let mut new_vals = row.values.clone();
4134            for (pos, expr) in &targets {
4135                let v = eval::eval_expr(expr, row, &ctx)?;
4136                let coerced = coerce_value(v, schema_cols[*pos].ty, &schema_cols[*pos].name, *pos)?;
4137                check_unsigned_range(&coerced, &schema_cols[*pos], *pos)?;
4138                new_vals[*pos] = coerced;
4139            }
4140            // v7.17.0 Phase 2.1 — apply ON UPDATE overrides for
4141            // any column the SET clause didn't touch.
4142            for (pos, src) in &on_update_overrides {
4143                let v = eval_runtime_default_free(src, schema_cols[*pos].ty, clock_for_on_update)?;
4144                new_vals[*pos] = v;
4145            }
4146            planned.push((i, new_vals));
4147        }
4148        // planned must stay position-sorted: downstream passes
4149        // (FK pairing, trigger walks, the apply loop) iterate it
4150        // assuming ascending row order, which the full-scan path
4151        // guaranteed implicitly.
4152        planned.sort_by_key(|(i, _)| *i);
4153        // v7.6.6 — capture pre-update row values for the FK
4154        // enforcement passes below. `planned` carries new values
4155        // only; pair them with the old row.
4156        let plan_with_old: Vec<(usize, Vec<Value>, Vec<Value>)> = planned
4157            .iter()
4158            .map(|(pos, new_vals)| (*pos, table.rows()[*pos].values.clone(), new_vals.clone()))
4159            .collect();
4160        let self_fks = table.schema().foreign_keys.clone();
4161        // v7.12.5 — `affected` is computed post-BEFORE-trigger
4162        // below (triggers may RETURN NULL to skip individual
4163        // rows). The pre-trigger len shape is no longer accurate.
4164        // Release mutable borrow on `table` for the FK passes.
4165        let _ = table;
4166        // v7.6.6 — Stage 2a: outbound FK check. For every row whose
4167        // local FK columns changed, the new value must exist in the
4168        // parent.
4169        if !self_fks.is_empty() {
4170            let new_rows: Vec<Vec<Value>> = planned
4171                .iter()
4172                .map(|(_pos, new_vals)| new_vals.clone())
4173                .collect();
4174            enforce_fk_inserts(self.active_catalog(), &stmt.table, &self_fks, &new_rows)?;
4175        }
4176        // v7.13.0 — CHECK constraint enforcement on UPDATE
4177        // (mailrs round-5 G3). Predicates evaluated against the
4178        // candidate post-UPDATE row; false rejects the UPDATE.
4179        {
4180            let new_rows: Vec<Vec<Value>> = planned
4181                .iter()
4182                .map(|(_pos, new_vals)| new_vals.clone())
4183                .collect();
4184            enforce_check_constraints(self.active_catalog(), &stmt.table, &new_rows)?;
4185        }
4186        // v7.6.6 — Stage 2b: inbound FK check. For every row that
4187        // changed value in a column that *some other table* uses as
4188        // a FK parent column, react per `on_update` action.
4189        let child_plan =
4190            plan_fk_parent_updates(self.active_catalog(), &stmt.table, &plan_with_old)?;
4191        // Stage 3a — apply each child-side action.
4192        for step in &child_plan {
4193            apply_fk_child_step(self.active_catalog_mut(), step)?;
4194        }
4195        // Stage 3b — apply the original UPDATE.
4196        let table = self
4197            .active_catalog_mut()
4198            .get_mut(&stmt.table)
4199            .ok_or_else(|| {
4200                EngineError::Storage(StorageError::TableNotFound {
4201                    name: stmt.table.clone(),
4202                })
4203            })?;
4204        // v7.12.5 — fire BEFORE/AFTER UPDATE row-level triggers
4205        // around the apply loop. BEFORE sees NEW=candidate +
4206        // OLD=current; may rewrite NEW or RETURN NULL to skip.
4207        // AFTER sees NEW=post-write + OLD=pre-write (both read-
4208        // only).
4209        //
4210        // Filter `planned` through the BEFORE pass first so the
4211        // RETURNING snapshot reflects what actually got written
4212        // (triggers may rewrite cells, including a cancellation).
4213        let mut applied_after_before: Vec<(usize, Row, Row)> = Vec::with_capacity(planned.len());
4214        // v7.12.7 — embedded SQL queue.
4215        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
4216        for (pos, new_vals) in &planned {
4217            let old_row = table.rows()[*pos].clone();
4218            let mut new_row = Row::new(new_vals.clone());
4219            let mut skip = false;
4220            for (fd, filter) in &before_update_triggers {
4221                // v7.13.0 — `UPDATE OF cols` filter (mailrs round-5
4222                // G7). Skip this trigger when the filter is set and
4223                // no listed column actually differs between OLD and
4224                // NEW for this row.
4225                if !filter.is_empty()
4226                    && !any_column_changed(filter, &schema_cols, &old_row, &new_row)
4227                {
4228                    continue;
4229                }
4230                let (outcome, deferred) = triggers::fire_row_trigger(
4231                    fd,
4232                    Some(new_row.clone()),
4233                    Some(&old_row),
4234                    &stmt.table,
4235                    &schema_cols,
4236                    &[],
4237                    trigger_session_cfg.as_deref(),
4238                    false,
4239                )
4240                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
4241                deferred_embedded.extend(deferred);
4242                match outcome {
4243                    triggers::TriggerOutcome::Row(r) => new_row = r,
4244                    triggers::TriggerOutcome::Skip => {
4245                        skip = true;
4246                        break;
4247                    }
4248                }
4249            }
4250            if !skip {
4251                applied_after_before.push((*pos, new_row, old_row));
4252            }
4253        }
4254        // v7.9.4 — snapshot post-update values for RETURNING (post-
4255        // BEFORE-trigger because triggers can rewrite cells).
4256        let updated_for_returning: Vec<Vec<Value>> = if stmt.returning.is_some() {
4257            applied_after_before
4258                .iter()
4259                .map(|(_pos, new_row, _old)| new_row.values.clone())
4260                .collect()
4261        } else {
4262            Vec::new()
4263        };
4264        let affected = applied_after_before.len();
4265        // Apply, then fire AFTER triggers per row. AFTER runs read-
4266        // only against the freshly-written row; v7.12.4-shape
4267        // assignment errors with a clear message.
4268        for (pos, new_row, old_row) in applied_after_before {
4269            table.update_row(pos, new_row.values.clone())?;
4270            for (fd, filter) in &after_update_triggers {
4271                if !filter.is_empty()
4272                    && !any_column_changed(filter, &schema_cols, &old_row, &new_row)
4273                {
4274                    continue;
4275                }
4276                let (_outcome, deferred) = triggers::fire_row_trigger(
4277                    fd,
4278                    Some(new_row.clone()),
4279                    Some(&old_row),
4280                    &stmt.table,
4281                    &schema_cols,
4282                    &[],
4283                    trigger_session_cfg.as_deref(),
4284                    true,
4285                )
4286                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
4287                deferred_embedded.extend(deferred);
4288            }
4289        }
4290        let _ = table;
4291        // v7.12.7 — drain trigger-emitted embedded SQL for this UPDATE.
4292        self.execute_deferred_trigger_stmts(deferred_embedded, cancel)?;
4293        // v6.2.1 — auto-analyze modified-row tracking for UPDATE.
4294        if !self.in_transaction() && affected > 0 {
4295            self.statistics
4296                .record_modifications(&stmt.table, affected as u64);
4297        }
4298        // v7.9.4 — RETURNING projection.
4299        if let Some(items) = &stmt.returning {
4300            return self.build_returning_rows(&stmt.table, items, updated_for_returning);
4301        }
4302        Ok(QueryResult::CommandOk {
4303            affected,
4304            modified_catalog: !self.in_transaction(),
4305        })
4306    }
4307
4308    /// v4.4 `DELETE FROM <table> [WHERE cond]`. Collects matching
4309    /// positions then delegates to `Table::delete_rows` (single index
4310    /// rebuild for the batch).
4311    /// v7.17.0 Phase 3.P0-42 — SQL:2003 / PG 15+ `MERGE` execution.
4312    ///
4313    /// Semantics:
4314    ///   * Resolve `target` and `source` tables (catalog reads).
4315    ///   * Build a combined `(target_alias.col, source_alias.col)`
4316    ///     schema so the ON / WHEN AND / SET / VALUES expressions
4317    ///     resolve through the standard qualifier-aware resolver.
4318    ///   * Pass 1: walk every source row × every target hot row,
4319    ///     evaluate ON, then pick the first WHEN clause that fits
4320    ///     (`Matched` if any target row matched, `NotMatched`
4321    ///     otherwise; AND-condition must hold). Collect the action
4322    ///     plan as `(deletes, updates, inserts)` so the apply pass
4323    ///     reads the original target row state.
4324    ///   * Pass 2: apply the plan against the target's mutable row
4325    ///     vector. Deletes execute by index in descending order so
4326    ///     earlier indices remain stable; updates next; inserts
4327    ///     last (matching PG's "INSERT branch sees the post-delete
4328    ///     state" behaviour for the common upsert shape).
4329    ///
4330    /// v7.17 simplifications (documented limitations):
4331    ///   * No triggers / WAL plumbing (MVP); MERGE rows don't fire
4332    ///     INSERT / UPDATE / DELETE row triggers in v7.17.
4333    ///   * No cardinality check (PG-canonical: "MERGE command
4334    ///     cannot affect row a second time" — SPG silently applies
4335    ///     the last action for a target row covered twice).
4336    ///   * Source must be a catalog-resolvable table (no subquery
4337    ///     source); RETURNING / BY SOURCE / BY TARGET unsupported.
4338    fn exec_merge_cancel(
4339        &mut self,
4340        stmt: &spg_sql::ast::MergeStatement,
4341        cancel: CancelToken<'_>,
4342    ) -> Result<QueryResult, EngineError> {
4343        let target_alias = stmt
4344            .target_alias
4345            .clone()
4346            .unwrap_or_else(|| stmt.target.clone());
4347        let source_alias = stmt
4348            .source_alias
4349            .clone()
4350            .unwrap_or_else(|| stmt.source.clone());
4351        let (target_cols, target_rows_snapshot) = {
4352            let t = self.active_catalog().get(&stmt.target).ok_or_else(|| {
4353                EngineError::Storage(StorageError::TableNotFound {
4354                    name: stmt.target.clone(),
4355                })
4356            })?;
4357            (
4358                t.schema().columns.clone(),
4359                t.rows().iter().cloned().collect::<Vec<Row>>(),
4360            )
4361        };
4362        let (source_cols, source_rows) = {
4363            let s = self.active_catalog().get(&stmt.source).ok_or_else(|| {
4364                EngineError::Storage(StorageError::TableNotFound {
4365                    name: stmt.source.clone(),
4366                })
4367            })?;
4368            (
4369                s.schema().columns.clone(),
4370                s.rows().iter().cloned().collect::<Vec<Row>>(),
4371            )
4372        };
4373        // Composite schema: target_alias.col ... source_alias.col ...
4374        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
4375        for col in &target_cols {
4376            combined_schema.push(ColumnSchema::new(
4377                alloc::format!("{target_alias}.{}", col.name),
4378                col.ty,
4379                col.nullable,
4380            ));
4381        }
4382        for col in &source_cols {
4383            combined_schema.push(ColumnSchema::new(
4384                alloc::format!("{source_alias}.{}", col.name),
4385                col.ty,
4386                col.nullable,
4387            ));
4388        }
4389        let combined_ctx = EvalContext::new(&combined_schema, None);
4390        // Source-only context for WHEN NOT MATCHED actions (no
4391        // matched target row exists — the source-side qualified
4392        // columns must still resolve).
4393        let mut source_only_schema: Vec<ColumnSchema> = Vec::new();
4394        for col in &target_cols {
4395            source_only_schema.push(ColumnSchema::new(
4396                alloc::format!("{target_alias}.{}", col.name),
4397                col.ty,
4398                col.nullable,
4399            ));
4400        }
4401        for col in &source_cols {
4402            source_only_schema.push(ColumnSchema::new(
4403                alloc::format!("{source_alias}.{}", col.name),
4404                col.ty,
4405                col.nullable,
4406            ));
4407        }
4408        let source_only_ctx = EvalContext::new(&source_only_schema, None);
4409        let target_arity = target_cols.len();
4410        let source_arity = source_cols.len();
4411
4412        // Resolve INSERT column positions once (validate names).
4413        // For each clause that's an INSERT, map column names → target positions.
4414        let mut delete_indices: Vec<usize> = Vec::new();
4415        let mut updates: Vec<(usize, Vec<Value>)> = Vec::new();
4416        let mut inserts: Vec<Vec<Value>> = Vec::new();
4417        let mut affected: usize = 0;
4418
4419        for (src_idx, src_row) in source_rows.iter().enumerate() {
4420            if src_idx.is_multiple_of(256) {
4421                cancel.check()?;
4422            }
4423            // Find every matched target index (per the ON predicate).
4424            let mut matched_targets: Vec<usize> = Vec::new();
4425            for (t_idx, t_row) in target_rows_snapshot.iter().enumerate() {
4426                let mut combined_vals = t_row.values.clone();
4427                combined_vals.extend(src_row.values.iter().cloned());
4428                let combined_row = Row::new(combined_vals);
4429                let cond = eval::eval_expr(&stmt.on, &combined_row, &combined_ctx)?;
4430                if matches!(cond, Value::Bool(true)) {
4431                    matched_targets.push(t_idx);
4432                }
4433            }
4434            let is_matched = !matched_targets.is_empty();
4435            // Pick the first WHEN clause whose kind agrees with
4436            // `is_matched` and whose AND condition (if any) holds.
4437            // AND condition for MATCHED: evaluated against the
4438            // first matched target row × source. For NOT MATCHED:
4439            // evaluated with target side NULL-padded.
4440            let fired_clause = stmt.clauses.iter().find(|c| {
4441                let kind_ok = match c.matched {
4442                    spg_sql::ast::MergeMatched::Matched => is_matched,
4443                    spg_sql::ast::MergeMatched::NotMatched => !is_matched,
4444                };
4445                if !kind_ok {
4446                    return false;
4447                }
4448                let Some(cond_expr) = &c.condition else {
4449                    return true;
4450                };
4451                let row = if is_matched {
4452                    let t = &target_rows_snapshot[matched_targets[0]];
4453                    let mut vals = t.values.clone();
4454                    vals.extend(src_row.values.iter().cloned());
4455                    Row::new(vals)
4456                } else {
4457                    let mut vals: Vec<Value> = (0..target_arity).map(|_| Value::Null).collect();
4458                    vals.extend(src_row.values.iter().cloned());
4459                    Row::new(vals)
4460                };
4461                let ctx_ref = if is_matched {
4462                    &combined_ctx
4463                } else {
4464                    &source_only_ctx
4465                };
4466                matches!(
4467                    eval::eval_expr(cond_expr, &row, ctx_ref),
4468                    Ok(Value::Bool(true))
4469                )
4470            });
4471            let Some(clause) = fired_clause else { continue };
4472            match &clause.action {
4473                spg_sql::ast::MergeAction::DoNothing => {}
4474                spg_sql::ast::MergeAction::Delete => {
4475                    for &t_idx in &matched_targets {
4476                        if !delete_indices.contains(&t_idx) {
4477                            delete_indices.push(t_idx);
4478                            affected += 1;
4479                        }
4480                    }
4481                }
4482                spg_sql::ast::MergeAction::Update { assignments } => {
4483                    // Pre-resolve SET targets to target column positions.
4484                    let mut planned_sets: Vec<(usize, &Expr)> =
4485                        Vec::with_capacity(assignments.len());
4486                    for (col, expr) in assignments {
4487                        let pos =
4488                            target_cols
4489                                .iter()
4490                                .position(|c| c.name == *col)
4491                                .ok_or_else(|| {
4492                                    EngineError::Eval(EvalError::ColumnNotFound {
4493                                        name: col.clone(),
4494                                    })
4495                                })?;
4496                        planned_sets.push((pos, expr));
4497                    }
4498                    for &t_idx in &matched_targets {
4499                        let t_row = &target_rows_snapshot[t_idx];
4500                        let mut new_values = t_row.values.clone();
4501                        let mut combined_vals = t_row.values.clone();
4502                        combined_vals.extend(src_row.values.iter().cloned());
4503                        let combined_row = Row::new(combined_vals);
4504                        for (pos, expr) in &planned_sets {
4505                            let raw = eval::eval_expr(expr, &combined_row, &combined_ctx)?;
4506                            let coerced = coerce_value(
4507                                raw,
4508                                target_cols[*pos].ty,
4509                                &target_cols[*pos].name,
4510                                *pos,
4511                            )?;
4512                            new_values[*pos] = coerced;
4513                        }
4514                        updates.push((t_idx, new_values));
4515                        affected += 1;
4516                    }
4517                }
4518                spg_sql::ast::MergeAction::Insert { columns, values } => {
4519                    // For INSERT NOT MATCHED, target side is NULL-padded.
4520                    let mut vals: Vec<Value> = (0..target_arity).map(|_| Value::Null).collect();
4521                    vals.extend(src_row.values.iter().cloned());
4522                    let synth_row = Row::new(vals);
4523                    let mut new_row_values: Vec<Value> =
4524                        (0..target_arity).map(|_| Value::Null).collect();
4525                    for (col, expr) in columns.iter().zip(values.iter()) {
4526                        let pos =
4527                            target_cols
4528                                .iter()
4529                                .position(|c| c.name == *col)
4530                                .ok_or_else(|| {
4531                                    EngineError::Eval(EvalError::ColumnNotFound {
4532                                        name: col.clone(),
4533                                    })
4534                                })?;
4535                        let raw = eval::eval_expr(expr, &synth_row, &source_only_ctx)?;
4536                        let coerced =
4537                            coerce_value(raw, target_cols[pos].ty, &target_cols[pos].name, pos)?;
4538                        new_row_values[pos] = coerced;
4539                    }
4540                    inserts.push(new_row_values);
4541                    affected += 1;
4542                }
4543            }
4544        }
4545        let _ = source_arity; // captured for symmetry; cancellation cost negligible.
4546
4547        // Apply the plan to the target table.
4548        let table = self
4549            .active_catalog_mut()
4550            .get_mut(&stmt.target)
4551            .ok_or_else(|| {
4552                EngineError::Storage(StorageError::TableNotFound {
4553                    name: stmt.target.clone(),
4554                })
4555            })?;
4556        // Apply updates first (in-place), then deletes (one batch),
4557        // then inserts. The storage API uses `update_row(pos,
4558        // new_values)`, `delete_rows(&[positions])`, and `insert(row)`.
4559        for (idx, new_vals) in &updates {
4560            table
4561                .update_row(*idx, new_vals.clone())
4562                .map_err(EngineError::Storage)?;
4563        }
4564        if !delete_indices.is_empty() {
4565            table.delete_rows(&delete_indices);
4566        }
4567        for vals in inserts {
4568            table.insert(Row::new(vals)).map_err(EngineError::Storage)?;
4569        }
4570        Ok(QueryResult::CommandOk {
4571            affected,
4572            modified_catalog: affected > 0,
4573        })
4574    }
4575
4576    fn exec_delete_cancel(
4577        &mut self,
4578        stmt: &spg_sql::ast::DeleteStatement,
4579        cancel: CancelToken<'_>,
4580    ) -> Result<QueryResult, EngineError> {
4581        // v7.12.5 — snapshot BEFORE/AFTER DELETE row triggers + the
4582        // session FTS config before the mut borrow (same shape as
4583        // INSERT / UPDATE).
4584        let before_delete_triggers = self.snapshot_row_triggers(&stmt.table, "DELETE", "BEFORE");
4585        let after_delete_triggers = self.snapshot_row_triggers(&stmt.table, "DELETE", "AFTER");
4586        let trigger_session_cfg: Option<String> = self
4587            .session_params
4588            .get("default_text_search_config")
4589            .cloned();
4590        // v5.2.3: PK-targeted DELETE → first retire any cold-tier
4591        // locator for the key. The cold row body stays in the
4592        // segment (becoming shadowed garbage that a future
4593        // compaction pass reclaims) but the index no longer
4594        // resolves it. The shadow count contributes to the
4595        // affected total; the subsequent hot walk handles any hot
4596        // rows for the same key.
4597        let mut cold_shadow_count: usize = 0;
4598        if let Some(w) = &stmt.where_ {
4599            let schema_cols = self
4600                .active_catalog()
4601                .get(&stmt.table)
4602                .ok_or_else(|| {
4603                    EngineError::Storage(StorageError::TableNotFound {
4604                        name: stmt.table.clone(),
4605                    })
4606                })?
4607                .schema()
4608                .columns
4609                .clone();
4610            if let Some((col_pos, key)) = try_pk_predicate(w, &schema_cols, stmt.table.as_str())
4611                && let Some(idx_name) = self
4612                    .active_catalog()
4613                    .get(&stmt.table)
4614                    .and_then(|t| t.index_on(col_pos).map(|i| i.name.clone()))
4615            {
4616                cold_shadow_count = self
4617                    .active_catalog_mut()
4618                    .shadow_cold_row(&stmt.table, &idx_name, &key)
4619                    .unwrap_or(0);
4620            }
4621        }
4622
4623        // v7.12.1 — cache the session FTS config as an owned
4624        // String before the mutable table borrow below; the
4625        // ctx-builder then references it via `as_deref` so the
4626        // immutable read of `session_params` doesn't conflict
4627        // with the mut borrow chain.
4628        let ts_cfg: Option<String> = self
4629            .session_param("default_text_search_config")
4630            .map(String::from);
4631        let table = self
4632            .active_catalog_mut()
4633            .get_mut(&stmt.table)
4634            .ok_or_else(|| {
4635                EngineError::Storage(StorageError::TableNotFound {
4636                    name: stmt.table.clone(),
4637                })
4638            })?;
4639        let schema_cols: Vec<ColumnSchema> = table.schema().columns.clone();
4640        let ctx = EvalContext::new(&schema_cols, Some(stmt.table.as_str()))
4641            .with_default_text_search_config(ts_cfg.as_deref());
4642        let mut positions: Vec<usize> = Vec::new();
4643        // v7.6.3 — collect every to-delete row's full Value tuple
4644        // alongside its position, so the FK enforcement pass can
4645        // run after the mut borrow drops.
4646        let mut to_delete_rows: Vec<Vec<Value>> = Vec::new();
4647        // v7.20 P4 — index seek (same shape as exec_update_cancel):
4648        // an equality WHERE on an indexed column narrows the walk
4649        // to the matching hot positions; the full WHERE still
4650        // re-evaluates per candidate. Downstream passes assume
4651        // ascending position order, so the seek result is sorted.
4652        let seek_positions: Option<Vec<usize>> = stmt
4653            .where_
4654            .as_ref()
4655            .and_then(|w| try_index_seek_positions(w, &schema_cols, table, stmt.table.as_str()));
4656        let candidate_positions: Vec<usize> = match seek_positions {
4657            Some(mut list) => {
4658                list.sort_unstable();
4659                list
4660            }
4661            None => (0..table.row_count()).collect(),
4662        };
4663        for (loop_n, &i) in candidate_positions.iter().enumerate() {
4664            if loop_n.is_multiple_of(256) {
4665                cancel.check()?;
4666            }
4667            let Some(row) = table.rows().get(i) else {
4668                continue;
4669            };
4670            let keep = if let Some(w) = &stmt.where_ {
4671                let cond = eval::eval_expr(w, row, &ctx)?;
4672                !matches!(cond, Value::Bool(true))
4673            } else {
4674                false
4675            };
4676            if !keep {
4677                positions.push(i);
4678                to_delete_rows.push(row.values.clone());
4679            }
4680        }
4681        // v7.6.3 / v7.6.4 — Stage 2: FK enforcement on the immutable
4682        // catalog. Release the mut borrow and run reverse-scan
4683        // against every child table whose FK targets this table.
4684        // RESTRICT / NoAction raise an error; CASCADE returns a
4685        // cascade plan that stage 3 applies after the primary delete.
4686        // SET NULL / SET DEFAULT remain Unsupported until v7.6.5.
4687        let _ = table;
4688        // v7.12.5 — BEFORE DELETE row-level triggers. Each fires
4689        // with NEW=None / OLD=pre-delete row; RETURN OLD (or NEW)
4690        // = proceed, RETURN NULL = skip the row entirely. The
4691        // filter must run BEFORE the FK cascade plan so cascaded
4692        // child rows track the trigger's skip-decision on the
4693        // parent.
4694        // v7.12.7 — embedded SQL queue.
4695        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
4696        if !before_delete_triggers.is_empty() {
4697            let mut filtered_positions: Vec<usize> = Vec::with_capacity(positions.len());
4698            let mut filtered_old_rows: Vec<Vec<Value>> = Vec::with_capacity(to_delete_rows.len());
4699            for (pos, old_vals) in positions.iter().zip(to_delete_rows.iter()) {
4700                let old_row = Row::new(old_vals.clone());
4701                let mut cancel_this = false;
4702                for fd in &before_delete_triggers {
4703                    let (outcome, deferred) = triggers::fire_row_trigger(
4704                        fd,
4705                        None,
4706                        Some(&old_row),
4707                        &stmt.table,
4708                        &schema_cols,
4709                        &[],
4710                        trigger_session_cfg.as_deref(),
4711                        false,
4712                    )
4713                    .map_err(|e| {
4714                        EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}")))
4715                    })?;
4716                    deferred_embedded.extend(deferred);
4717                    if matches!(outcome, triggers::TriggerOutcome::Skip) {
4718                        cancel_this = true;
4719                        break;
4720                    }
4721                }
4722                if !cancel_this {
4723                    filtered_positions.push(*pos);
4724                    filtered_old_rows.push(old_vals.clone());
4725                }
4726            }
4727            positions = filtered_positions;
4728            to_delete_rows = filtered_old_rows;
4729        }
4730        let cascade_plan = plan_fk_parent_deletions(
4731            self.active_catalog(),
4732            &stmt.table,
4733            &positions,
4734            &to_delete_rows,
4735        )?;
4736        // Stage 3a — apply each FK child step (SET NULL / SET
4737        // DEFAULT / CASCADE delete) before deleting the parent.
4738        // The plan is already ordered: nulls/defaults first, then
4739        // cascade deletes (so a row mutated and later deleted
4740        // surfaces as deleted — though v7.6.5 doesn't produce
4741        // that overlap today).
4742        for step in &cascade_plan {
4743            apply_fk_child_step(self.active_catalog_mut(), step)?;
4744        }
4745        // Stage 3b — actually delete the original target rows.
4746        let table = self
4747            .active_catalog_mut()
4748            .get_mut(&stmt.table)
4749            .ok_or_else(|| {
4750                EngineError::Storage(StorageError::TableNotFound {
4751                    name: stmt.table.clone(),
4752                })
4753            })?;
4754        let affected = table.delete_rows(&positions) + cold_shadow_count;
4755        let _ = table;
4756        // v7.12.5 — AFTER DELETE row-level triggers fire post-write
4757        // with NEW=None / OLD=pre-delete row (each from the
4758        // already-snapshotted to_delete_rows). Return value is
4759        // ignored (matches PG AFTER semantics).
4760        if !after_delete_triggers.is_empty() {
4761            for old_vals in &to_delete_rows {
4762                let old_row = Row::new(old_vals.clone());
4763                for fd in &after_delete_triggers {
4764                    let (_outcome, deferred) = triggers::fire_row_trigger(
4765                        fd,
4766                        None,
4767                        Some(&old_row),
4768                        &stmt.table,
4769                        &schema_cols,
4770                        &[],
4771                        trigger_session_cfg.as_deref(),
4772                        true,
4773                    )
4774                    .map_err(|e| {
4775                        EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}")))
4776                    })?;
4777                    deferred_embedded.extend(deferred);
4778                }
4779            }
4780        }
4781        // v7.12.7 — drain trigger-emitted embedded SQL for this DELETE.
4782        self.execute_deferred_trigger_stmts(deferred_embedded, cancel)?;
4783        // v6.2.1 — auto-analyze modified-row tracking for DELETE.
4784        if !self.in_transaction() && affected > 0 {
4785            self.statistics
4786                .record_modifications(&stmt.table, affected as u64);
4787        }
4788        // v7.9.4 — RETURNING projection over the soon-to-be-gone
4789        // rows. `to_delete_rows` was snapshotted in stage 1 before
4790        // mutation, so the projection sees the pre-delete state
4791        // (matches PG semantics: DELETE RETURNING returns the row
4792        // as it was just before removal).
4793        if let Some(items) = &stmt.returning {
4794            return self.build_returning_rows(&stmt.table, items, to_delete_rows);
4795        }
4796        Ok(QueryResult::CommandOk {
4797            affected,
4798            modified_catalog: !self.in_transaction(),
4799        })
4800    }
4801
4802    /// `SHOW TABLES` — one row per table in the active catalog.
4803    /// Column name is `name` so result-set consumers can downstream
4804    /// `SELECT name FROM ...` style logic if needed.
4805    /// v4.26: `EXPLAIN [ANALYZE] <select>`. Returns a single-column
4806    /// `QUERY PLAN` text table — first line names the top operator
4807    /// (Scan / Aggregate / Window / etc.), indented children list
4808    /// FROM joins, WHERE filters, ORDER BY / LIMIT, projection
4809    /// shape, and any active index hits. `ANALYZE` execs the inner
4810    /// SELECT and appends actual-row + elapsed-micros annotations.
4811    #[allow(clippy::format_push_string)]
4812    fn exec_explain(
4813        &self,
4814        e: &spg_sql::ast::ExplainStatement,
4815        cancel: CancelToken<'_>,
4816    ) -> Result<QueryResult, EngineError> {
4817        let mut lines = Vec::<String>::new();
4818        explain_select(&e.inner, self, 0, &mut lines);
4819        if e.suggest {
4820            // v6.8.3 — index advisor. Walks the SELECT's FROM
4821            // tables + WHERE column refs; for each (table, column)
4822            // pair that lacks an index, append a SUGGEST line with
4823            // a copy-pastable `CREATE INDEX` statement. This is a
4824            // pure-syntax heuristic — no cardinality estimation —
4825            // matching the v6.8.3 design intent of "tell the
4826            // operator where indexes are missing", not "give the
4827            // mathematically optimal index set".
4828            let suggestions = build_index_suggestions(&e.inner, self);
4829            for s in suggestions {
4830                lines.push(s);
4831            }
4832        } else if e.analyze {
4833            // v6.2.4 — EXPLAIN ANALYZE annotates each operator line
4834            // with `(rows=N)` where the row count is computable
4835            // without re-executing the full query:
4836            //   - Top-level operator (first non-indented line):
4837            //     rows = final result.len()
4838            //   - "From: <table> [full scan]" lines: rows =
4839            //     table.rows().len() (catalog read; no execution)
4840            //   - "From: <table> [index seek]": indeterminate —
4841            //     the index step would need re-execution; v6.2.5
4842            //     adds per-operator wall-clock + hot/cold rows
4843            //     instrumentation that makes this concrete.
4844            //   - Everything else: marked `(—)` so the surface
4845            //     stays well-defined without silently dropping
4846            //     stats. v6.2.5 fills in via inline executor
4847            //     instrumentation.
4848            // Total elapsed lands on a trailing `Total: …` line.
4849            let started = self.clock.map(|f| f());
4850            let exec = self.exec_select_cancel(&e.inner, cancel)?;
4851            let elapsed_micros = match (self.clock, started) {
4852                (Some(f), Some(s)) => Some(f().saturating_sub(s)),
4853                _ => None,
4854            };
4855            let row_count = if let QueryResult::Rows { rows, .. } = &exec {
4856                rows.len()
4857            } else {
4858                0
4859            };
4860            annotate_explain_lines(&mut lines, row_count, self);
4861            let mut total = alloc::format!("Total: rows={row_count}");
4862            if let Some(us) = elapsed_micros {
4863                total.push_str(&alloc::format!(" elapsed={us}us"));
4864            }
4865            lines.push(total);
4866        }
4867        let columns = alloc::vec![ColumnSchema::new("QUERY PLAN", DataType::Text, false)];
4868        let rows: Vec<Row> = lines
4869            .into_iter()
4870            .map(|l| Row::new(alloc::vec![Value::Text(l)]))
4871            .collect();
4872        Ok(QueryResult::Rows { columns, rows })
4873    }
4874
4875    fn exec_show_tables(&self) -> QueryResult {
4876        let columns = alloc::vec![ColumnSchema::new("name", DataType::Text, false)];
4877        let rows: Vec<Row> = self
4878            .active_catalog()
4879            .table_names()
4880            .into_iter()
4881            .map(|n| Row::new(alloc::vec![Value::Text(n)]))
4882            .collect();
4883        QueryResult::Rows { columns, rows }
4884    }
4885
4886    /// v7.17.0 Phase 3.P0-59 — `SHOW CREATE TABLE <t>`. Synthesise
4887    /// a minimal MySQL-flavoured CREATE TABLE DDL from the
4888    /// catalog's TableSchema so mysqldump round-trips load against
4889    /// SPG without splitting init scripts.
4890    fn exec_show_create_table(&self, name: &str) -> Result<QueryResult, EngineError> {
4891        let t = self.active_catalog().get(name).ok_or_else(|| {
4892            EngineError::Storage(StorageError::TableNotFound { name: name.into() })
4893        })?;
4894        let cols: Vec<String> = t
4895            .schema()
4896            .columns
4897            .iter()
4898            .map(|c| {
4899                let ty = render_data_type(c.ty);
4900                let nullable = if c.nullable { "" } else { " NOT NULL" };
4901                alloc::format!("  `{}` {}{}", c.name, ty, nullable)
4902            })
4903            .collect();
4904        let mut body = cols.join(",\n");
4905        // Append UNIQUE / PRIMARY KEY clauses.
4906        for uc in &t.schema().uniqueness_constraints {
4907            let col_names: Vec<String> = uc
4908                .columns
4909                .iter()
4910                .map(|&p| {
4911                    t.schema().columns.get(p).map_or_else(
4912                        || alloc::format!("col{p}"),
4913                        |c| alloc::format!("`{}`", c.name),
4914                    )
4915                })
4916                .collect();
4917            let kw = if uc.is_primary_key {
4918                "PRIMARY KEY"
4919            } else {
4920                "UNIQUE KEY"
4921            };
4922            body.push_str(",\n  ");
4923            body.push_str(&alloc::format!("{kw} ({})", col_names.join(", ")));
4924        }
4925        // Foreign keys.
4926        for fk in &t.schema().foreign_keys {
4927            let local: Vec<String> = fk
4928                .local_columns
4929                .iter()
4930                .map(|&p| {
4931                    t.schema().columns.get(p).map_or_else(
4932                        || alloc::format!("col{p}"),
4933                        |c| alloc::format!("`{}`", c.name),
4934                    )
4935                })
4936                .collect();
4937            let parent_cols: Vec<String> =
4938                if let Some(parent) = self.active_catalog().get(&fk.parent_table) {
4939                    fk.parent_columns
4940                        .iter()
4941                        .map(|&p| {
4942                            parent.schema().columns.get(p).map_or_else(
4943                                || alloc::format!("col{p}"),
4944                                |c| alloc::format!("`{}`", c.name),
4945                            )
4946                        })
4947                        .collect()
4948                } else {
4949                    fk.parent_columns
4950                        .iter()
4951                        .map(|p| alloc::format!("col{p}"))
4952                        .collect()
4953                };
4954            body.push_str(",\n  ");
4955            body.push_str(&alloc::format!(
4956                "FOREIGN KEY ({}) REFERENCES `{}` ({})",
4957                local.join(", "),
4958                fk.parent_table,
4959                parent_cols.join(", ")
4960            ));
4961        }
4962        let ddl = alloc::format!(
4963            "CREATE TABLE `{}` (\n{}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
4964            name,
4965            body
4966        );
4967        let columns = alloc::vec![
4968            ColumnSchema::new("Table", DataType::Text, false),
4969            ColumnSchema::new("Create Table", DataType::Text, false),
4970        ];
4971        let rows = alloc::vec![Row::new(alloc::vec![
4972            Value::Text(name.into()),
4973            Value::Text(ddl),
4974        ])];
4975        Ok(QueryResult::Rows { columns, rows })
4976    }
4977
4978    /// v7.17.0 Phase 3.P0-60 — `SHOW INDEXES FROM <t>`. MySQL
4979    /// surface returns one row per (index × column) with 14
4980    /// columns; v7.17 ships the columns admin probes actually
4981    /// filter on: Table, Non_unique, Key_name, Seq_in_index,
4982    /// Column_name, Null, Index_type.
4983    fn exec_show_indexes(&self, name: &str) -> Result<QueryResult, EngineError> {
4984        let t = self.active_catalog().get(name).ok_or_else(|| {
4985            EngineError::Storage(StorageError::TableNotFound { name: name.into() })
4986        })?;
4987        let columns = alloc::vec![
4988            ColumnSchema::new("Table", DataType::Text, false),
4989            ColumnSchema::new("Non_unique", DataType::Int, false),
4990            ColumnSchema::new("Key_name", DataType::Text, false),
4991            ColumnSchema::new("Seq_in_index", DataType::Int, false),
4992            ColumnSchema::new("Column_name", DataType::Text, false),
4993            ColumnSchema::new("Null", DataType::Text, false),
4994            ColumnSchema::new("Index_type", DataType::Text, false),
4995        ];
4996        let mut rows: Vec<Row> = Vec::new();
4997        for idx in t.indices() {
4998            let col = t
4999                .schema()
5000                .columns
5001                .get(idx.column_position)
5002                .map_or("?".into(), |c| c.name.clone());
5003            let nullable = t
5004                .schema()
5005                .columns
5006                .get(idx.column_position)
5007                .map_or(true, |c| c.nullable);
5008            rows.push(Row::new(alloc::vec![
5009                Value::Text(name.into()),
5010                Value::Int(i32::from(!idx.is_unique)),
5011                Value::Text(idx.name.clone()),
5012                Value::Int(1),
5013                Value::Text(col),
5014                Value::Text(if nullable {
5015                    "YES".into()
5016                } else {
5017                    String::new()
5018                }),
5019                Value::Text("BTREE".into()),
5020            ]));
5021        }
5022        Ok(QueryResult::Rows { columns, rows })
5023    }
5024
5025    /// v7.17.0 Phase 3.P0-61 — `SHOW STATUS`. Returns canonical
5026    /// MySQL server-status counters (2-column `(Variable_name,
5027    /// Value)`).
5028    fn exec_show_status(&self) -> QueryResult {
5029        let columns = alloc::vec![
5030            ColumnSchema::new("Variable_name", DataType::Text, false),
5031            ColumnSchema::new("Value", DataType::Text, false),
5032        ];
5033        let pairs: &[(&str, &str)] = &[
5034            ("Uptime", "0"),
5035            ("Threads_connected", "1"),
5036            ("Threads_running", "1"),
5037            ("Questions", "0"),
5038            ("Slow_queries", "0"),
5039            ("Opened_tables", "0"),
5040            ("Innodb_buffer_pool_pages_total", "0"),
5041        ];
5042        let rows: Vec<Row> = pairs
5043            .iter()
5044            .map(|(k, v)| {
5045                Row::new(alloc::vec![
5046                    Value::Text((*k).into()),
5047                    Value::Text((*v).into())
5048                ])
5049            })
5050            .collect();
5051        QueryResult::Rows { columns, rows }
5052    }
5053
5054    /// v7.17.0 Phase 3.P0-61 — `SHOW VARIABLES`. Returns server-side
5055    /// variables MySQL/MariaDB clients probe at connect time.
5056    fn exec_show_variables(&self) -> QueryResult {
5057        let columns = alloc::vec![
5058            ColumnSchema::new("Variable_name", DataType::Text, false),
5059            ColumnSchema::new("Value", DataType::Text, false),
5060        ];
5061        let mut rows: Vec<Row> = Vec::new();
5062        let canonical: &[(&str, &str)] = &[
5063            ("version", "8.0.35-spg"),
5064            ("version_comment", "SPG dual-stack engine"),
5065            ("character_set_server", "utf8mb4"),
5066            ("collation_server", "utf8mb4_0900_ai_ci"),
5067            ("max_allowed_packet", "67108864"),
5068            ("autocommit", "ON"),
5069            ("sql_mode", "STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION"),
5070            ("time_zone", "SYSTEM"),
5071            ("transaction_isolation", "REPEATABLE-READ"),
5072        ];
5073        for &(k, v) in canonical {
5074            rows.push(Row::new(alloc::vec![
5075                Value::Text(k.into()),
5076                Value::Text(v.into()),
5077            ]));
5078        }
5079        // Session-set parameters surface here too.
5080        for (k, v) in &self.session_params {
5081            if !canonical.iter().any(|(n, _)| (*n).eq_ignore_ascii_case(k)) {
5082                rows.push(Row::new(alloc::vec![
5083                    Value::Text(k.clone()),
5084                    Value::Text(v.clone()),
5085                ]));
5086            }
5087        }
5088        QueryResult::Rows { columns, rows }
5089    }
5090
5091    /// v7.17.0 Phase 3.P0-62 — `SHOW PROCESSLIST`. SPG is
5092    /// single-process so the surface returns one synthetic row
5093    /// describing the current connection (Id, User, Host, db,
5094    /// Command, Time, State, Info).
5095    fn exec_show_processlist(&self) -> QueryResult {
5096        let columns = alloc::vec![
5097            ColumnSchema::new("Id", DataType::Int, false),
5098            ColumnSchema::new("User", DataType::Text, false),
5099            ColumnSchema::new("Host", DataType::Text, false),
5100            ColumnSchema::new("db", DataType::Text, true),
5101            ColumnSchema::new("Command", DataType::Text, false),
5102            ColumnSchema::new("Time", DataType::Int, false),
5103            ColumnSchema::new("State", DataType::Text, true),
5104            ColumnSchema::new("Info", DataType::Text, true),
5105        ];
5106        let rows = alloc::vec![Row::new(alloc::vec![
5107            Value::Int(1),
5108            Value::Text("postgres".into()),
5109            Value::Text("localhost".into()),
5110            Value::Text("postgres".into()),
5111            Value::Text("Query".into()),
5112            Value::Int(0),
5113            Value::Text("executing".into()),
5114            Value::Text("SHOW PROCESSLIST".into()),
5115        ])];
5116        QueryResult::Rows { columns, rows }
5117    }
5118
5119    /// v7.17.0 Phase 3.P0-58 — `SHOW DATABASES` / `SHOW SCHEMAS`.
5120    /// SPG is single-database so the result is the canonical MySQL
5121    /// set every mysql/MariaDB client expects at connect time:
5122    /// `information_schema`, `mysql`, `performance_schema`, `sys`,
5123    /// plus a `postgres` slot so dual-stack callers find their
5124    /// PG-compatible database too.
5125    fn exec_show_databases(&self) -> QueryResult {
5126        let columns = alloc::vec![ColumnSchema::new("Database", DataType::Text, false)];
5127        let names = [
5128            "information_schema",
5129            "mysql",
5130            "performance_schema",
5131            "sys",
5132            "postgres",
5133        ];
5134        let rows: Vec<Row> = names
5135            .iter()
5136            .map(|n| Row::new(alloc::vec![Value::Text((*n).into())]))
5137            .collect();
5138        QueryResult::Rows { columns, rows }
5139    }
5140
5141    /// `SHOW COLUMNS FROM <table>` — one row per column with the
5142    /// declared name, SQL type rendering, and nullability flag.
5143    fn exec_show_columns(&self, table_name: &str) -> Result<QueryResult, EngineError> {
5144        let table =
5145            self.active_catalog()
5146                .get(table_name)
5147                .ok_or_else(|| StorageError::TableNotFound {
5148                    name: table_name.into(),
5149                })?;
5150        let columns = alloc::vec![
5151            ColumnSchema::new("name", DataType::Text, false),
5152            ColumnSchema::new("type", DataType::Text, false),
5153            ColumnSchema::new("nullable", DataType::Bool, false),
5154        ];
5155        let rows: Vec<Row> = table
5156            .schema()
5157            .columns
5158            .iter()
5159            .map(|c| {
5160                Row::new(alloc::vec![
5161                    Value::Text(c.name.clone()),
5162                    Value::Text(alloc::format!("{}", c.ty)),
5163                    Value::Bool(c.nullable),
5164                ])
5165            })
5166            .collect();
5167        Ok(QueryResult::Rows { columns, rows })
5168    }
5169
5170    fn exec_begin(&mut self) -> Result<QueryResult, EngineError> {
5171        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5172        if self.tx_catalogs.contains_key(&tx_id) {
5173            return Err(EngineError::TransactionAlreadyOpen);
5174        }
5175        self.tx_catalogs.insert(
5176            tx_id,
5177            TxState {
5178                catalog: self.catalog.clone(),
5179                savepoints: Vec::new(),
5180            },
5181        );
5182        Ok(QueryResult::CommandOk {
5183            affected: 0,
5184            modified_catalog: false,
5185        })
5186    }
5187
5188    fn exec_commit(&mut self) -> Result<QueryResult, EngineError> {
5189        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5190        let state = self
5191            .tx_catalogs
5192            .remove(&tx_id)
5193            .ok_or(EngineError::NoActiveTransaction)?;
5194        self.catalog = state.catalog;
5195        // All savepoints become permanent at COMMIT and the stack
5196        // resets for the next TX (`state.savepoints` is discarded with
5197        // `state`).
5198        Ok(QueryResult::CommandOk {
5199            affected: 0,
5200            modified_catalog: true,
5201        })
5202    }
5203
5204    fn exec_rollback(&mut self) -> Result<QueryResult, EngineError> {
5205        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5206        if self.tx_catalogs.remove(&tx_id).is_none() {
5207            return Err(EngineError::NoActiveTransaction);
5208        }
5209        // savepoints discarded with the TxState
5210        Ok(QueryResult::CommandOk {
5211            affected: 0,
5212            modified_catalog: false,
5213        })
5214    }
5215
5216    fn exec_savepoint(&mut self, name: String) -> Result<QueryResult, EngineError> {
5217        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5218        let state = self
5219            .tx_catalogs
5220            .get_mut(&tx_id)
5221            .ok_or(EngineError::NoActiveTransaction)?;
5222        // PG re-uses an existing savepoint name by dropping the older
5223        // entry and pushing a fresh one — match that behaviour so
5224        // application code can `SAVEPOINT sp; ...; SAVEPOINT sp` freely.
5225        state.savepoints.retain(|(n, _)| n != &name);
5226        let snapshot = state.catalog.clone();
5227        state.savepoints.push((name, snapshot));
5228        Ok(QueryResult::CommandOk {
5229            affected: 0,
5230            modified_catalog: false,
5231        })
5232    }
5233
5234    fn exec_rollback_to_savepoint(&mut self, name: &str) -> Result<QueryResult, EngineError> {
5235        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5236        let state = self
5237            .tx_catalogs
5238            .get_mut(&tx_id)
5239            .ok_or(EngineError::NoActiveTransaction)?;
5240        let pos = state
5241            .savepoints
5242            .iter()
5243            .rposition(|(n, _)| n == name)
5244            .ok_or_else(|| {
5245                EngineError::Unsupported(alloc::format!("savepoint not found: {name}"))
5246            })?;
5247        // The savepoint stays on the stack (PG semantics): a later
5248        // `RELEASE` or further `ROLLBACK TO` is still allowed. Everything
5249        // after it is discarded.
5250        let snapshot = state.savepoints[pos].1.clone();
5251        state.savepoints.truncate(pos + 1);
5252        state.catalog = snapshot;
5253        Ok(QueryResult::CommandOk {
5254            affected: 0,
5255            modified_catalog: false,
5256        })
5257    }
5258
5259    fn exec_release_savepoint(&mut self, name: &str) -> Result<QueryResult, EngineError> {
5260        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5261        let state = self
5262            .tx_catalogs
5263            .get_mut(&tx_id)
5264            .ok_or(EngineError::NoActiveTransaction)?;
5265        let pos = state
5266            .savepoints
5267            .iter()
5268            .rposition(|(n, _)| n == name)
5269            .ok_or_else(|| {
5270                EngineError::Unsupported(alloc::format!("savepoint not found: {name}"))
5271            })?;
5272        // RELEASE keeps the work since the savepoint, just discards the
5273        // bookmark plus everything nested under it.
5274        state.savepoints.truncate(pos);
5275        Ok(QueryResult::CommandOk {
5276            affected: 0,
5277            modified_catalog: false,
5278        })
5279    }
5280
5281    /// v6.0.4 — synchronous `ALTER INDEX <name> REBUILD [WITH
5282    /// (encoding = …)]`. Walks every table in the active catalog
5283    /// looking for an index matching `stmt.name`, then delegates the
5284    /// rebuild (including any encoding switch) to
5285    /// `Table::rebuild_nsw_index`. The "live" non-blocking
5286    /// optimisation is v6.0.4.1 / v6.1.x territory.
5287    /// v6.7.2 — `ALTER TABLE t SET hot_tier_bytes = X`. Dispatch
5288    /// arm. Currently the only setting is `hot_tier_bytes`; later
5289    /// v6.7.x can extend `AlterTableTarget` without touching this
5290    /// arm structure.
5291    fn exec_alter_table(
5292        &mut self,
5293        s: spg_sql::ast::AlterTableStatement,
5294    ) -> Result<QueryResult, EngineError> {
5295        // v7.13.2 — mailrs round-6 S1: apply each subaction in order.
5296        // On first error the statement aborts; subactions already
5297        // applied stay (no transactional rollback in v7.13 — wrap in
5298        // BEGIN/COMMIT if atomicity matters).
5299        let table_name = s.name.clone();
5300        for target in s.targets {
5301            self.exec_alter_table_subaction(&table_name, target)?;
5302        }
5303        Ok(QueryResult::CommandOk {
5304            affected: 0,
5305            modified_catalog: !self.in_transaction(),
5306        })
5307    }
5308
5309    fn exec_alter_table_subaction(
5310        &mut self,
5311        table_name_outer: &str,
5312        target: spg_sql::ast::AlterTableTarget,
5313    ) -> Result<(), EngineError> {
5314        // Inner helper retains the s.name closure shape; alias to `s`
5315        // for minimal diff against the v7.13.0 body.
5316        struct S<'a> {
5317            name: &'a str,
5318        }
5319        let s = S {
5320            name: table_name_outer,
5321        };
5322        match target {
5323            spg_sql::ast::AlterTableTarget::SetHotTierBytes(n) => {
5324                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5325                    EngineError::Storage(StorageError::TableNotFound {
5326                        name: s.name.into(),
5327                    })
5328                })?;
5329                table.schema_mut().hot_tier_bytes = Some(n);
5330            }
5331            spg_sql::ast::AlterTableTarget::AddForeignKey(fk) => {
5332                // v7.6.8 — resolve FK against the live catalog first
5333                // (validates parent table, columns, indices). Then
5334                // verify every existing row in the child table
5335                // satisfies the new constraint. Then install it.
5336                let cols_snapshot = self
5337                    .active_catalog()
5338                    .get(s.name)
5339                    .ok_or_else(|| {
5340                        EngineError::Storage(StorageError::TableNotFound {
5341                            name: s.name.into(),
5342                        })
5343                    })?
5344                    .schema()
5345                    .columns
5346                    .clone();
5347                let storage_fk =
5348                    resolve_foreign_key(s.name, &cols_snapshot, fk, self.active_catalog())?;
5349                // Verify existing rows. Treat them as a virtual
5350                // INSERT batch — reusing the v7.6.2 enforce helper.
5351                let existing_rows: Vec<Vec<Value>> = self
5352                    .active_catalog()
5353                    .get(s.name)
5354                    .expect("checked above")
5355                    .rows()
5356                    .iter()
5357                    .map(|r| r.values.clone())
5358                    .collect();
5359                enforce_fk_inserts(
5360                    self.active_catalog(),
5361                    s.name,
5362                    core::slice::from_ref(&storage_fk),
5363                    &existing_rows,
5364                )?;
5365                // Reject duplicate constraint name.
5366                let table = self
5367                    .active_catalog_mut()
5368                    .get_mut(s.name)
5369                    .expect("checked above");
5370                if let Some(name) = &storage_fk.name
5371                    && table
5372                        .schema()
5373                        .foreign_keys
5374                        .iter()
5375                        .any(|f| f.name.as_ref() == Some(name))
5376                {
5377                    return Err(EngineError::Unsupported(alloc::format!(
5378                        "ALTER TABLE ADD CONSTRAINT: a constraint named {name:?} already exists"
5379                    )));
5380                }
5381                table.schema_mut().foreign_keys.push(storage_fk);
5382            }
5383            spg_sql::ast::AlterTableTarget::DropForeignKey { name, if_exists } => {
5384                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5385                    EngineError::Storage(StorageError::TableNotFound {
5386                        name: s.name.into(),
5387                    })
5388                })?;
5389                let fks = &mut table.schema_mut().foreign_keys;
5390                let before = fks.len();
5391                fks.retain(|f| f.name.as_ref() != Some(&name));
5392                if fks.len() == before && !if_exists {
5393                    return Err(EngineError::Unsupported(alloc::format!(
5394                        "ALTER TABLE DROP CONSTRAINT: no FK named {name:?} on {:?}",
5395                        s.name
5396                    )));
5397                }
5398                // v7.13.2 mailrs round-6 S7: IF EXISTS silences the miss.
5399            }
5400            spg_sql::ast::AlterTableTarget::AddColumn {
5401                column,
5402                if_not_exists,
5403            } => {
5404                // v7.13.0 — mailrs round-5 G1. Append-only column add
5405                // with back-fill of the DEFAULT (or NULL) into every
5406                // existing row. Column positions don't shift, so we
5407                // skip index rebuild.
5408                let clock = self.clock;
5409                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5410                    EngineError::Storage(StorageError::TableNotFound {
5411                        name: s.name.into(),
5412                    })
5413                })?;
5414                if table
5415                    .schema()
5416                    .columns
5417                    .iter()
5418                    .any(|c| c.name.eq_ignore_ascii_case(&column.name))
5419                {
5420                    if if_not_exists {
5421                        return Ok(());
5422                    }
5423                    return Err(EngineError::Unsupported(alloc::format!(
5424                        "ALTER TABLE ADD COLUMN: column {:?} already exists on {:?}",
5425                        column.name,
5426                        s.name
5427                    )));
5428                }
5429                let col_name = column.name.clone();
5430                let nullable = column.nullable;
5431                let has_default = column.default.is_some() || column.auto_increment;
5432                let col_schema = column_def_to_schema(column)?;
5433                let row_count = table.row_count();
5434                // Compute the back-fill value. Literal / runtime DEFAULT
5435                // funnels through the same resolver that INSERT uses
5436                // (v7.9.21 `resolve_column_default_free`). NULL when
5437                // the column is nullable and has no DEFAULT. NOT NULL
5438                // without DEFAULT errors when the table has existing
5439                // rows — same as PG.
5440                let fill_value: Value = if has_default || col_schema.runtime_default.is_some() {
5441                    resolve_column_default_free(&col_schema, clock)?
5442                } else if nullable || row_count == 0 {
5443                    Value::Null
5444                } else {
5445                    return Err(EngineError::Unsupported(alloc::format!(
5446                        "ALTER TABLE ADD COLUMN {col_name:?}: NOT NULL column requires DEFAULT \
5447                         when the table has existing rows"
5448                    )));
5449                };
5450                table.add_column(col_schema, fill_value);
5451            }
5452            spg_sql::ast::AlterTableTarget::AlterColumnType {
5453                column,
5454                new_type,
5455                using,
5456            } => {
5457                // v7.13.0 — mailrs round-5 G8. Re-evaluate each
5458                // row's column value (either through the USING
5459                // expression if supplied, or as a direct CAST of
5460                // the existing value) and re-coerce to the new
5461                // type. Indices on the column get rebuilt.
5462                let new_data_type = column_type_to_data_type(new_type);
5463                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5464                    EngineError::Storage(StorageError::TableNotFound {
5465                        name: s.name.into(),
5466                    })
5467                })?;
5468                let col_pos = table
5469                    .schema()
5470                    .columns
5471                    .iter()
5472                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5473                    .ok_or_else(|| {
5474                        EngineError::Unsupported(alloc::format!(
5475                            "ALTER COLUMN TYPE: column {column:?} not found on {:?}",
5476                            s.name
5477                        ))
5478                    })?;
5479                let schema_cols = table.schema().columns.clone();
5480                let ctx = eval::EvalContext::new(&schema_cols, None);
5481                let mut new_values: alloc::vec::Vec<Value> =
5482                    alloc::vec::Vec::with_capacity(table.row_count());
5483                for row in table.rows().iter() {
5484                    let raw = match &using {
5485                        Some(expr) => eval::eval_expr(expr, row, &ctx).map_err(|e| {
5486                            EngineError::Unsupported(alloc::format!(
5487                                "ALTER COLUMN TYPE: USING expression failed: {e:?}"
5488                            ))
5489                        })?,
5490                        None => row.values.get(col_pos).cloned().unwrap_or(Value::Null),
5491                    };
5492                    let coerced = coerce_value(raw, new_data_type, &column, col_pos)?;
5493                    new_values.push(coerced);
5494                }
5495                table.schema_mut().columns[col_pos].ty = new_data_type;
5496                for (i, v) in new_values.into_iter().enumerate() {
5497                    let mut row_values = table
5498                        .rows()
5499                        .get(i)
5500                        .expect("bounds-checked above")
5501                        .values
5502                        .clone();
5503                    row_values[col_pos] = v;
5504                    table.update_row(i, row_values)?;
5505                }
5506            }
5507            spg_sql::ast::AlterTableTarget::AddTableConstraint(tc) => {
5508                // v7.14.0 — pg_dump emits PKs as a separate
5509                // ALTER TABLE ADD CONSTRAINT post-CREATE-TABLE.
5510                // For PRIMARY KEY / UNIQUE, install a UC entry
5511                // and the implicit BTree index on the leading
5512                // column. CHECK: append predicate to schema.
5513                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5514                    EngineError::Storage(StorageError::TableNotFound {
5515                        name: s.name.into(),
5516                    })
5517                })?;
5518                let is_pk = matches!(tc, spg_sql::ast::TableConstraint::PrimaryKey { .. });
5519                match tc {
5520                    spg_sql::ast::TableConstraint::PrimaryKey { columns, .. }
5521                    | spg_sql::ast::TableConstraint::Unique { columns, .. } => {
5522                        let positions: Vec<usize> = columns
5523                            .iter()
5524                            .map(|c| {
5525                                table
5526                                    .schema()
5527                                    .columns
5528                                    .iter()
5529                                    .position(|sc| sc.name.eq_ignore_ascii_case(c))
5530                                    .ok_or_else(|| {
5531                                        EngineError::Unsupported(alloc::format!(
5532                                            "ALTER TABLE ADD CONSTRAINT: column {c:?} not found on {:?}",
5533                                            s.name
5534                                        ))
5535                                    })
5536                            })
5537                            .collect::<Result<Vec<_>, _>>()?;
5538                        // Skip if an equivalent UC is already there
5539                        // (idempotent — pg_dump's PK + a prior inline
5540                        // PK shouldn't double-install).
5541                        let already = table
5542                            .schema()
5543                            .uniqueness_constraints
5544                            .iter()
5545                            .any(|u| u.columns == positions);
5546                        if !already {
5547                            table.schema_mut().uniqueness_constraints.push(
5548                                spg_storage::UniquenessConstraint {
5549                                    is_primary_key: is_pk,
5550                                    columns: positions.clone(),
5551                                    nulls_not_distinct: false,
5552                                },
5553                            );
5554                            // PK implies NOT NULL on referenced cols.
5555                            if is_pk {
5556                                for p in &positions {
5557                                    if let Some(c) = table.schema_mut().columns.get_mut(*p) {
5558                                        c.nullable = false;
5559                                    }
5560                                }
5561                            }
5562                            // Add a BTree index on the leading
5563                            // column for INSERT-side enforcement.
5564                            let leading = &columns[0];
5565                            let already_idx = table.indices().iter().any(|idx| {
5566                                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
5567                                    && table.schema().columns[idx.column_position].name == *leading
5568                            });
5569                            if !already_idx {
5570                                let suffix = if is_pk { "pkey" } else { "key" };
5571                                let idx_name = alloc::format!("{}_{leading}_{suffix}", s.name);
5572                                let _ = table.add_index(idx_name, leading);
5573                            }
5574                        }
5575                    }
5576                    spg_sql::ast::TableConstraint::Check { expr, .. } => {
5577                        table.schema_mut().checks.push(alloc::format!("{expr}"));
5578                    }
5579                    spg_sql::ast::TableConstraint::Index { name, columns } => {
5580                        // v7.15.0 — ALTER TABLE ADD KEY (cols).
5581                        // mysqldump occasionally emits this
5582                        // post-CREATE-TABLE shape; build a BTree
5583                        // on the leading column using the
5584                        // user-supplied or synthesised name.
5585                        let leading = &columns[0];
5586                        let already_idx = table.indices().iter().any(|idx| {
5587                            matches!(idx.kind, spg_storage::IndexKind::BTree(_))
5588                                && table.schema().columns[idx.column_position].name == *leading
5589                        });
5590                        if !already_idx {
5591                            let idx_name = name
5592                                .clone()
5593                                .unwrap_or_else(|| alloc::format!("{}_{leading}_idx", s.name));
5594                            let _ = table.add_index(idx_name, leading);
5595                        }
5596                    }
5597                    spg_sql::ast::TableConstraint::FulltextIndex { name, columns } => {
5598                        // v7.17.0 Phase 2.2 — ALTER TABLE ADD
5599                        // FULLTEXT KEY (cols). Builds one
5600                        // fulltext-GIN per named column so MATCH
5601                        // AGAINST gets a real inverted index.
5602                        // Multi-column declarations expand to
5603                        // per-column GINs (the leading column
5604                        // drives MATCH AGAINST planning).
5605                        for (k, col) in columns.iter().enumerate() {
5606                            let already_idx = table.indices().iter().any(|idx| {
5607                                matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
5608                                    && table.schema().columns[idx.column_position].name == *col
5609                            });
5610                            if already_idx {
5611                                continue;
5612                            }
5613                            let idx_name = match (&name, columns.len(), k) {
5614                                (Some(n), 1, _) => n.clone(),
5615                                (Some(n), _, k) => alloc::format!("{n}_{k}"),
5616                                (None, _, _) => {
5617                                    alloc::format!("{}_{col}_ftidx", s.name)
5618                                }
5619                            };
5620                            let _ = table.add_gin_fulltext_index(idx_name, col);
5621                        }
5622                    }
5623                }
5624            }
5625            spg_sql::ast::AlterTableTarget::DropColumn {
5626                column,
5627                if_exists,
5628                cascade,
5629            } => {
5630                // v7.13.3 — mailrs round-7 S8. Remove the column +
5631                // every row's value at that position; drop any index
5632                // on the column. RESTRICT (default) rejects when an
5633                // FK on this table or partial-index predicate
5634                // references the column; CASCADE removes those
5635                // dependents first.
5636                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5637                    EngineError::Storage(StorageError::TableNotFound {
5638                        name: s.name.into(),
5639                    })
5640                })?;
5641                let col_pos = match table
5642                    .schema()
5643                    .columns
5644                    .iter()
5645                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5646                {
5647                    Some(p) => p,
5648                    None => {
5649                        if if_exists {
5650                            return Ok(());
5651                        }
5652                        return Err(EngineError::Unsupported(alloc::format!(
5653                            "ALTER TABLE DROP COLUMN: column {column:?} not found on {:?}",
5654                            s.name
5655                        )));
5656                    }
5657                };
5658                // Dependent check: FKs whose local columns include
5659                // col_pos. CASCADE drops them; otherwise reject.
5660                let dependent_fks: Vec<usize> = table
5661                    .schema()
5662                    .foreign_keys
5663                    .iter()
5664                    .enumerate()
5665                    .filter_map(|(i, fk)| {
5666                        if fk.local_columns.contains(&col_pos) {
5667                            Some(i)
5668                        } else {
5669                            None
5670                        }
5671                    })
5672                    .collect();
5673                if !dependent_fks.is_empty() && !cascade {
5674                    return Err(EngineError::Unsupported(alloc::format!(
5675                        "ALTER TABLE DROP COLUMN {column:?}: column has FK dependents; \
5676                         use DROP COLUMN ... CASCADE to remove them"
5677                    )));
5678                }
5679                // CASCADE the FK removals first.
5680                if cascade {
5681                    // Drop in reverse so indices stay valid.
5682                    let mut sorted = dependent_fks.clone();
5683                    sorted.sort();
5684                    sorted.reverse();
5685                    let fks = &mut table.schema_mut().foreign_keys;
5686                    for i in sorted {
5687                        fks.remove(i);
5688                    }
5689                }
5690                // Drop the column. New helper on Table does the
5691                // row + schema + index shift atomically.
5692                table.drop_column(col_pos);
5693            }
5694            spg_sql::ast::AlterTableTarget::SetTriggerEnabled { which, enabled } => {
5695                // v7.16.1 — mailrs round-9 A.2.b. pg_dump
5696                // --disable-triggers wraps each table's data
5697                // block with `ALTER TABLE … DISABLE TRIGGER ALL`
5698                // / `… ENABLE TRIGGER ALL`. Toggle the enabled
5699                // flag on every matching trigger so the row-
5700                // write paths skip them; the catalog snapshot
5701                // persists the new state across restarts.
5702                let table_name = s.name.to_string();
5703                let trigs = self.active_catalog_mut().triggers_mut();
5704                let mut touched = false;
5705                for t in trigs.iter_mut() {
5706                    if !t.table.eq_ignore_ascii_case(&table_name) {
5707                        continue;
5708                    }
5709                    match &which {
5710                        spg_sql::ast::TriggerSelector::All => {
5711                            t.enabled = enabled;
5712                            touched = true;
5713                        }
5714                        spg_sql::ast::TriggerSelector::Named(name) => {
5715                            if t.name.eq_ignore_ascii_case(name) {
5716                                t.enabled = enabled;
5717                                touched = true;
5718                            }
5719                        }
5720                    }
5721                }
5722                // PG semantics: `ALL` on a table with no
5723                // triggers is a no-op (no error). A `Named`
5724                // form pointing at a non-existent trigger
5725                // raises in PG; v7.16.1 also raises so we
5726                // don't silently lose state.
5727                if !touched {
5728                    if let spg_sql::ast::TriggerSelector::Named(name) = &which {
5729                        return Err(EngineError::Unsupported(alloc::format!(
5730                            "ALTER TABLE {table_name:?} {} TRIGGER {name:?}: no such trigger on table",
5731                            if enabled { "ENABLE" } else { "DISABLE" },
5732                        )));
5733                    }
5734                }
5735            }
5736            spg_sql::ast::AlterTableTarget::RenameTable { new } => {
5737                // v7.16.2 — table-level rename (mailrs round-10
5738                // A.5 — used by migrate-042's `ALTER TABLE
5739                // contacts RENAME TO email_contacts`). Storage
5740                // helper updates the schema + by_name index +
5741                // dangling FK / trigger references in one
5742                // atomic step.
5743                let old = s.name.to_string();
5744                self.active_catalog_mut()
5745                    .rename_table(&old, &new)
5746                    .map_err(EngineError::Storage)?;
5747            }
5748            spg_sql::ast::AlterTableTarget::RenameColumn { old, new } => {
5749                // v7.15.0 — `ALTER TABLE t RENAME [COLUMN] old TO
5750                // new`. Rename the column in the schema; rewrite
5751                // every stored source string on this table that
5752                // references it as a (potentially-qualified)
5753                // column identifier: CHECK predicates, partial-
5754                // index predicates, runtime DEFAULT expressions.
5755                // Then walk catalog triggers on this table and
5756                // patch any `UPDATE OF` column list. Function and
5757                // trigger bodies are NOT auto-rewritten — that
5758                // surface is dynamic SQL territory; users update
5759                // those separately (matches PG plpgsql behavior:
5760                // a column rename invalidates name-referencing
5761                // plpgsql at call time, not rename time).
5762                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5763                    EngineError::Storage(StorageError::TableNotFound {
5764                        name: s.name.into(),
5765                    })
5766                })?;
5767                let col_pos = table
5768                    .schema()
5769                    .columns
5770                    .iter()
5771                    .position(|c| c.name.eq_ignore_ascii_case(&old))
5772                    .ok_or_else(|| {
5773                        EngineError::Unsupported(alloc::format!(
5774                            "ALTER TABLE RENAME COLUMN: column {old:?} not found on {:?}",
5775                            s.name
5776                        ))
5777                    })?;
5778                // Reject same-name (case-insensitive) collision.
5779                if table
5780                    .schema()
5781                    .columns
5782                    .iter()
5783                    .enumerate()
5784                    .any(|(i, c)| i != col_pos && c.name.eq_ignore_ascii_case(&new))
5785                {
5786                    return Err(EngineError::Unsupported(alloc::format!(
5787                        "ALTER TABLE RENAME COLUMN: column {new:?} already exists on {:?}",
5788                        s.name
5789                    )));
5790                }
5791                // Schema rename first — even idempotent same-name
5792                // rename (`ALTER TABLE t RENAME a TO a`) needs to
5793                // be a no-op, not an error.
5794                if old.eq_ignore_ascii_case(&new) {
5795                    return Ok(());
5796                }
5797                table.rename_column(col_pos, &new);
5798                // Rewrite per-column runtime_default sources on
5799                // every column of this table — a DEFAULT expression
5800                // on column X may reference column Y by name (rare,
5801                // but legal in PG when the value is supplied via a
5802                // function that takes the row).
5803                let n_cols = table.schema().columns.len();
5804                for i in 0..n_cols {
5805                    let rt = table.schema().columns[i].runtime_default.clone();
5806                    if let Some(src) = rt {
5807                        let rewritten = rewrite_column_in_source(&src, &old, &new)?;
5808                        table.schema_mut().columns[i].runtime_default = Some(rewritten);
5809                    }
5810                }
5811                // Rewrite table-level CHECK predicates.
5812                let checks = table.schema().checks.clone();
5813                let mut new_checks = Vec::with_capacity(checks.len());
5814                for chk in checks {
5815                    new_checks.push(rewrite_column_in_source(&chk, &old, &new)?);
5816                }
5817                table.schema_mut().checks = new_checks;
5818                // Rewrite per-index partial_predicate sources.
5819                let n_idx = table.indices().len();
5820                for i in 0..n_idx {
5821                    let pred = table.indices()[i].partial_predicate.clone();
5822                    if let Some(src) = pred {
5823                        let rewritten = rewrite_column_in_source(&src, &old, &new)?;
5824                        // SAFETY: indices_mut would be cleanest, but
5825                        // partial_predicate is the only mutable field
5826                        // here; reach in via the public mut accessor.
5827                        table.set_partial_predicate(i, Some(rewritten));
5828                    }
5829                }
5830                // Walk catalog triggers; patch `update_columns` on
5831                // triggers attached to this table.
5832                let table_name = s.name.to_string();
5833                for trig in self.active_catalog_mut().triggers_mut() {
5834                    if !trig.table.eq_ignore_ascii_case(&table_name) {
5835                        continue;
5836                    }
5837                    for c in &mut trig.update_columns {
5838                        if c.eq_ignore_ascii_case(&old) {
5839                            *c = new.clone();
5840                        }
5841                    }
5842                }
5843            }
5844        }
5845        Ok(())
5846    }
5847
5848    fn exec_alter_index(
5849        &mut self,
5850        stmt: spg_sql::ast::AlterIndexStatement,
5851    ) -> Result<QueryResult, EngineError> {
5852        // Translate the optional SQL-side encoding choice into the
5853        // storage-side enum; the same SqlVecEncoding -> VecEncoding
5854        // bridge `column_type_to_data_type` uses.
5855        let spg_sql::ast::AlterIndexStatement {
5856            name: idx_name,
5857            target,
5858        } = stmt;
5859        // v7.16.2 — RENAME TO branch (mailrs round-10 migrate-042).
5860        // IF EXISTS makes a missing index a no-op rather than an
5861        // error, mirroring PG semantics.
5862        if let spg_sql::ast::AlterIndexTarget::Rename { new, if_exists } = target {
5863            let renamed = self.active_catalog_mut().rename_index(&idx_name, &new);
5864            return match renamed {
5865                Ok(()) => Ok(QueryResult::CommandOk {
5866                    affected: 0,
5867                    modified_catalog: !self.in_transaction(),
5868                }),
5869                Err(StorageError::IndexNotFound { .. }) if if_exists => {
5870                    Ok(QueryResult::CommandOk {
5871                        affected: 0,
5872                        modified_catalog: false,
5873                    })
5874                }
5875                Err(e) => Err(EngineError::Storage(e)),
5876            };
5877        }
5878        let spg_sql::ast::AlterIndexTarget::Rebuild { encoding } = target else {
5879            unreachable!("Rename branch returned above");
5880        };
5881        let target = encoding.map(|e| match e {
5882            SqlVecEncoding::F32 => VecEncoding::F32,
5883            SqlVecEncoding::Sq8 => VecEncoding::Sq8,
5884            SqlVecEncoding::F16 => VecEncoding::F16,
5885        });
5886        // Linear scan: index names are globally unique within a
5887        // catalog (enforced by add_nsw_index_inner) so the first
5888        // match is the only one. Save the table name to avoid
5889        // borrowing while we then take a mut borrow.
5890        let table_name = {
5891            let cat = self.active_catalog();
5892            let mut found: Option<String> = None;
5893            for tname in cat.table_names() {
5894                if let Some(t) = cat.get(&tname)
5895                    && t.indices().iter().any(|i| i.name == idx_name)
5896                {
5897                    found = Some(tname);
5898                    break;
5899                }
5900            }
5901            found.ok_or_else(|| {
5902                EngineError::Storage(StorageError::IndexNotFound {
5903                    name: idx_name.clone(),
5904                })
5905            })?
5906        };
5907        let table = self
5908            .active_catalog_mut()
5909            .get_mut(&table_name)
5910            .expect("table found above");
5911        table.rebuild_nsw_index(&idx_name, target)?;
5912        // v6.3.1 — ALTER INDEX REBUILD potentially with new encoding
5913        // changes cost characteristics; evict any cached plans.
5914        self.plan_cache.evict_referencing(&table_name);
5915        Ok(QueryResult::CommandOk {
5916            affected: 0,
5917            modified_catalog: !self.in_transaction(),
5918        })
5919    }
5920
5921    fn exec_create_index(
5922        &mut self,
5923        stmt: CreateIndexStatement,
5924    ) -> Result<QueryResult, EngineError> {
5925        let table = self
5926            .active_catalog_mut()
5927            .get_mut(&stmt.table)
5928            .ok_or_else(|| {
5929                EngineError::Storage(StorageError::TableNotFound {
5930                    name: stmt.table.clone(),
5931                })
5932            })?;
5933        // `IF NOT EXISTS` reduces DuplicateIndex to a no-op CommandOk.
5934        if stmt.if_not_exists && table.indices().iter().any(|i| i.name == stmt.name) {
5935            return Ok(QueryResult::CommandOk {
5936                affected: 0,
5937                modified_catalog: false,
5938            });
5939        }
5940        // v7.9.14 — multi-column index parses through; engine
5941        // builds a single-column BTree on the leading column only.
5942        // The extras live on the AST so spg-server's dispatcher
5943        // can emit a PG-wire NoticeResponse / log line. Composite
5944        // BTree keys land in v7.10.
5945        let _ = &stmt.extra_columns; // intentional drop on engine side
5946        let table_name = stmt.table.clone();
5947        // v6.8.0 — resolve INCLUDE column names to positions. Done
5948        // before `add_index` so a typo error surfaces before any
5949        // catalog mutation lands.
5950        let included_positions: Vec<usize> = if stmt.included_columns.is_empty() {
5951            Vec::new()
5952        } else {
5953            let schema = table.schema();
5954            stmt.included_columns
5955                .iter()
5956                .map(|c| {
5957                    schema.column_position(c).ok_or_else(|| {
5958                        EngineError::Storage(StorageError::ColumnNotFound { column: c.clone() })
5959                    })
5960                })
5961                .collect::<Result<Vec<_>, _>>()?
5962        };
5963        match stmt.method {
5964            IndexMethod::BTree => table.add_index(stmt.name.clone(), &stmt.column)?,
5965            IndexMethod::Hnsw => {
5966                if !included_positions.is_empty() {
5967                    return Err(EngineError::Unsupported(
5968                        "INCLUDE columns are not supported on HNSW indexes".into(),
5969                    ));
5970                }
5971                table.add_nsw_index(stmt.name.clone(), &stmt.column, spg_storage::NSW_DEFAULT_M)?;
5972            }
5973            // v6.7.1 — BRIN. Pure metadata; no in-memory data.
5974            IndexMethod::Brin => {
5975                if !included_positions.is_empty() {
5976                    return Err(EngineError::Unsupported(
5977                        "INCLUDE columns are not supported on BRIN indexes".into(),
5978                    ));
5979                }
5980                table.add_brin_index(stmt.name.clone(), &stmt.column)?;
5981            }
5982            // v7.12.3 — GIN inverted index. Real posting-list-backed
5983            // GIN when the indexed column is `tsvector`; falls back
5984            // to a BTree on the leading column for any other column
5985            // type so v7.9.26b's `pg_dump` compatibility (GIN on
5986            // JSONB etc. silently loading as BTree) is preserved.
5987            // Operators see the real GIN only where it matters; old
5988            // schemas keep loading.
5989            IndexMethod::Gin => {
5990                if !included_positions.is_empty() {
5991                    return Err(EngineError::Unsupported(
5992                        "INCLUDE columns are not supported on GIN indexes".into(),
5993                    ));
5994                }
5995                let col_pos = table
5996                    .schema()
5997                    .column_position(&stmt.column)
5998                    .ok_or_else(|| {
5999                        EngineError::Storage(StorageError::ColumnNotFound {
6000                            column: stmt.column.clone(),
6001                        })
6002                    })?;
6003                let col_ty = table.schema().columns[col_pos].ty;
6004                // v7.15.0 — `gin_trgm_ops` on a TEXT/VARCHAR
6005                // column dispatches to the real trigram-shingle
6006                // GIN build (LIKE / similarity acceleration).
6007                // Other GIN opclasses fall through to the regular
6008                // tsvector-vs-BTree split below.
6009                let is_trgm = stmt
6010                    .opclass
6011                    .as_deref()
6012                    .is_some_and(|op| op.eq_ignore_ascii_case("gin_trgm_ops"));
6013                if is_trgm
6014                    && matches!(
6015                        col_ty,
6016                        spg_storage::DataType::Text | spg_storage::DataType::Varchar(_)
6017                    )
6018                {
6019                    table
6020                        .add_gin_trgm_index(stmt.name.clone(), &stmt.column)
6021                        .map_err(EngineError::Storage)?;
6022                } else if col_ty == spg_storage::DataType::TsVector {
6023                    table
6024                        .add_gin_index(stmt.name.clone(), &stmt.column)
6025                        .map_err(EngineError::Storage)?;
6026                } else {
6027                    // v7.9.26b BTree fallback — the catalog still
6028                    // gets an index entry on the leading column so
6029                    // pg_dump scripts that name GIN on JSONB / etc.
6030                    // load clean; query-time gain stays opt-in for
6031                    // tsvector callers.
6032                    table.add_index(stmt.name.clone(), &stmt.column)?;
6033                }
6034            }
6035        }
6036        if !included_positions.is_empty()
6037            && let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name)
6038        {
6039            idx.included_columns = included_positions;
6040        }
6041        // v6.8.1 — persist partial-index predicate. Stored as the
6042        // expression's Display form so the catalog snapshot stays
6043        // pure (storage has no spg-sql dependency). The runtime
6044        // maintenance path treats partial indexes identically to
6045        // full indexes for v6.8.1 (over-maintenance is safe; the
6046        // planner-side "use partial when query WHERE implies the
6047        // predicate" pass is STABILITY carve-out).
6048        if let Some(pred_expr) = &stmt.partial_predicate {
6049            let canonical = pred_expr.to_string();
6050            // v7.13.2 — mailrs round-6 S2. PG's `pg_trgm` uses
6051            // `CREATE INDEX … USING gin(col gin_trgm_ops) WHERE …`
6052            // routinely to slim trigram indexes. SPG now persists
6053            // the predicate for GIN / BRIN / HNSW the same way it
6054            // already does for BTree — same v6.8.1 "over-maintain
6055            // is safe; planner-side partial routing is STABILITY
6056            // carve-out" semantics. HNSW carries an additional
6057            // caveat: the predicate isn't applied at index build
6058            // time (would require per-row eval inside the NSW
6059            // construction loop), so the index oversamples; query
6060            // time the WHERE clause still filters correctly.
6061            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6062                idx.partial_predicate = Some(canonical);
6063            }
6064        }
6065        // v6.8.2 — persist expression index key. Same Display-form
6066        // storage; the runtime maintenance pass evaluates each
6067        // row's expression to derive the index key, but for v6.8.2
6068        // the engine falls through to the bare-column-reference
6069        // path and the expression is preserved for format-layer
6070        // round-trip + future planner work. Carved-out in
6071        // STABILITY § "Out of v6.8".
6072        if let Some(key_expr) = &stmt.expression {
6073            if matches!(
6074                stmt.method,
6075                IndexMethod::Hnsw | IndexMethod::Brin | IndexMethod::Gin
6076            ) {
6077                return Err(EngineError::Unsupported(
6078                    "Expression keys are not supported on HNSW or BRIN indexes".into(),
6079                ));
6080            }
6081            let canonical = key_expr.to_string();
6082            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6083                idx.expression = Some(canonical);
6084            }
6085        }
6086        // v7.9.29 — persist `is_unique` flag on the storage Index.
6087        // Combined with `partial_predicate`, INSERT enforcement
6088        // checks that no other row whose predicate evaluates true
6089        // shares the same indexed key. Parser already rejected
6090        // `UNIQUE` on HNSW / BRIN, so plain BTree here.
6091        // For multi-column UNIQUE INDEX the extras matter (the
6092        // full tuple is the uniqueness key), so resolve them to
6093        // column positions and persist on the index too.
6094        if stmt.is_unique {
6095            let mut extra_positions: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
6096            for col_name in &stmt.extra_columns {
6097                let pos = table
6098                    .schema()
6099                    .columns
6100                    .iter()
6101                    .position(|c| c.name.eq_ignore_ascii_case(col_name))
6102                    .ok_or_else(|| {
6103                        EngineError::Unsupported(alloc::format!(
6104                            "UNIQUE INDEX {:?}: extra column {col_name:?} not in table {:?}",
6105                            stmt.name,
6106                            stmt.table
6107                        ))
6108                    })?;
6109                extra_positions.push(pos);
6110            }
6111            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6112                idx.is_unique = true;
6113                idx.extra_column_positions = extra_positions;
6114            }
6115            // At index-creation time, check the existing rows for
6116            // pre-existing duplicates that would have violated the
6117            // new constraint — otherwise CREATE UNIQUE INDEX would
6118            // silently leave duplicates in place.
6119            let snapshot_indices = table.indices().to_vec();
6120            let snapshot_rows: alloc::vec::Vec<spg_storage::Row> =
6121                table.rows().iter().cloned().collect();
6122            let snapshot_schema = table.schema().clone();
6123            let idx_ref = snapshot_indices
6124                .iter()
6125                .find(|i| i.name == stmt.name)
6126                .expect("just-added index");
6127            check_existing_unique_violation(idx_ref, &snapshot_schema, &snapshot_rows)?;
6128        }
6129        // v6.3.1 — adding an index can change the optimal plan for
6130        // any cached query that references this table.
6131        self.plan_cache.evict_referencing(&table_name);
6132        Ok(QueryResult::CommandOk {
6133            affected: 0,
6134            modified_catalog: !self.in_transaction(),
6135        })
6136    }
6137
6138    /// v7.13.3 — mailrs round-7 S9. SPG-specific reconciliation
6139    /// for `CREATE TABLE IF NOT EXISTS` when the table already
6140    /// exists. Adds missing columns + inline FKs from the new
6141    /// definition; existing columns / constraints stay untouched.
6142    /// New columns with a `NOT NULL` declaration without a
6143    /// `DEFAULT` are reported as a clear error rather than
6144    /// silently dropped — this is the "fail loud on real
6145    /// incompatibility, fail silent on schema-superset" tradeoff.
6146    fn reconcile_table_if_not_exists(
6147        &mut self,
6148        stmt: CreateTableStatement,
6149    ) -> Result<QueryResult, EngineError> {
6150        let table_name = stmt.name.clone();
6151        let clock = self.clock;
6152        let existing_col_names: alloc::collections::BTreeSet<String> = self
6153            .active_catalog()
6154            .get(&table_name)
6155            .expect("checked above")
6156            .schema()
6157            .columns
6158            .iter()
6159            .map(|c| c.name.to_ascii_lowercase())
6160            .collect();
6161        let row_count = self
6162            .active_catalog()
6163            .get(&table_name)
6164            .expect("checked above")
6165            .row_count();
6166        // Collect missing column defs in source order.
6167        let new_columns: alloc::vec::Vec<spg_sql::ast::ColumnDef> = stmt
6168            .columns
6169            .iter()
6170            .filter(|c| !existing_col_names.contains(&c.name.to_ascii_lowercase()))
6171            .cloned()
6172            .collect();
6173        for col_def in new_columns {
6174            let col_name = col_def.name.clone();
6175            let nullable = col_def.nullable;
6176            let has_default = col_def.default.is_some() || col_def.auto_increment;
6177            let col_schema = column_def_to_schema(col_def)?;
6178            let fill_value: Value = if has_default || col_schema.runtime_default.is_some() {
6179                resolve_column_default_free(&col_schema, clock)?
6180            } else if nullable || row_count == 0 {
6181                Value::Null
6182            } else {
6183                return Err(EngineError::Unsupported(alloc::format!(
6184                    "CREATE TABLE IF NOT EXISTS {table_name:?}: reconciling \
6185                     column {col_name:?} requires DEFAULT (existing rows would violate NOT NULL)"
6186                )));
6187            };
6188            let table = self
6189                .active_catalog_mut()
6190                .get_mut(&table_name)
6191                .expect("checked above");
6192            table.add_column(col_schema, fill_value);
6193        }
6194        // Resolve any newly-added inline FKs (column-level
6195        // REFERENCES forms) and install. Skip FKs whose local
6196        // columns we didn't have in the existing table.
6197        let table_cols_now = self
6198            .active_catalog()
6199            .get(&table_name)
6200            .expect("checked above")
6201            .schema()
6202            .columns
6203            .clone();
6204        for fk in stmt.foreign_keys {
6205            // Only install FKs whose every local column resolves
6206            // — older catalogs may have a column the new FK
6207            // references but not the column the new FK declares.
6208            let all_resolved = fk.columns.iter().all(|c| {
6209                table_cols_now
6210                    .iter()
6211                    .any(|sc| sc.name.eq_ignore_ascii_case(c))
6212            });
6213            if !all_resolved {
6214                continue;
6215            }
6216            let already_present = {
6217                let table = self
6218                    .active_catalog()
6219                    .get(&table_name)
6220                    .expect("checked above");
6221                table.schema().foreign_keys.iter().any(|f| {
6222                    f.parent_table.eq_ignore_ascii_case(&fk.parent_table)
6223                        && f.local_columns.len() == fk.columns.len()
6224                })
6225            };
6226            if already_present {
6227                continue;
6228            }
6229            let storage_fk =
6230                resolve_foreign_key(&table_name, &table_cols_now, fk, self.active_catalog())?;
6231            let table = self
6232                .active_catalog_mut()
6233                .get_mut(&table_name)
6234                .expect("checked above");
6235            table.schema_mut().foreign_keys.push(storage_fk);
6236        }
6237        Ok(QueryResult::CommandOk {
6238            affected: 0,
6239            modified_catalog: !self.in_transaction(),
6240        })
6241    }
6242
6243    /// v7.14.0 — DROP TABLE handler (pg_dump / mysqldump preamble).
6244    fn exec_drop_table(
6245        &mut self,
6246        names: Vec<String>,
6247        if_exists: bool,
6248    ) -> Result<QueryResult, EngineError> {
6249        for name in names {
6250            let dropped = self.active_catalog_mut().drop_table(&name);
6251            if !dropped && !if_exists {
6252                return Err(EngineError::Storage(StorageError::TableNotFound { name }));
6253            }
6254        }
6255        Ok(QueryResult::CommandOk {
6256            affected: 0,
6257            modified_catalog: !self.in_transaction(),
6258        })
6259    }
6260
6261    /// v7.14.0 — DROP INDEX handler.
6262    fn exec_drop_index(
6263        &mut self,
6264        name: String,
6265        if_exists: bool,
6266    ) -> Result<QueryResult, EngineError> {
6267        let dropped = self.active_catalog_mut().drop_named_index(&name);
6268        if !dropped && !if_exists {
6269            return Err(EngineError::Storage(StorageError::IndexNotFound { name }));
6270        }
6271        Ok(QueryResult::CommandOk {
6272            affected: 0,
6273            modified_catalog: !self.in_transaction(),
6274        })
6275    }
6276
6277    fn exec_create_table(
6278        &mut self,
6279        stmt: CreateTableStatement,
6280    ) -> Result<QueryResult, EngineError> {
6281        if stmt.if_not_exists && self.active_catalog().get(&stmt.name).is_some() {
6282            // v7.16.2 — PG-strict silent no-op (mailrs round-10
6283            // surfaced this). v7.13.3's "reconcile by adding
6284            // missing columns" was friendly for mailrs round-7
6285            // where init-schema's `contacts` and migrate-023's
6286            // CardDAV `contacts` collided; but it ALSO silently
6287            // added columns to existing tables when later
6288            // migrations had a duplicate `CREATE TABLE IF NOT
6289            // EXISTS <t> (different-shape-cols)` shape. mailrs's
6290            // migrate-030 has exactly that — re-declares
6291            // system_config with `key` even though init-schema
6292            // already created it with `config_key`. PG's silent
6293            // no-op leaves system_config at `config_key`;
6294            // v7.13.3 added a phantom `key` column that then
6295            // tripped migrate-040's idempotent rename guard.
6296            // mailrs v1.7.106 ships the proper PG-style
6297            // contacts rename via DO + IF EXISTS, so SPG can
6298            // revert to PG-strict here without re-breaking the
6299            // round-7 case.
6300            return Ok(QueryResult::CommandOk {
6301                affected: 0,
6302                modified_catalog: false,
6303            });
6304        }
6305        let table_name = stmt.name.clone();
6306        // v7.9.13 — pluck the names of any columns marked
6307        // `PRIMARY KEY` inline so the post-create-table pass can
6308        // build an implicit BTree index. mailrs F1.
6309        let inline_pk_columns: Vec<String> = stmt
6310            .columns
6311            .iter()
6312            .filter(|c| c.is_primary_key)
6313            .map(|c| c.name.clone())
6314            .collect();
6315        // v7.9.19 — table-level constraints: PRIMARY KEY (a, b, ...)
6316        // and UNIQUE (a, b, ...). Each builds a BTree index on the
6317        // leading column (the existing single-column storage tier)
6318        // and registers a UniquenessConstraint on the schema for
6319        // INSERT-time enforcement of the full tuple. mailrs G1/G6.
6320        let cols = stmt
6321            .columns
6322            .into_iter()
6323            .map(column_def_to_schema)
6324            .collect::<Result<Vec<_>, _>>()?;
6325        // v7.17.0 Phase 1.4 + 1.5 — classify every raw
6326        // user_type_ref (parked as user_enum_type by
6327        // column_def_to_schema) into either an enum binding or a
6328        // domain binding. For domains, also rewrite the column's
6329        // base DataType from the placeholder Text to the domain's
6330        // declared base. Unknown idents are still a hard error
6331        // here (same as Phase 1.4) so silent acceptance never
6332        // happens.
6333        let mut cols = cols;
6334        for col in cols.iter_mut() {
6335            let Some(name) = col.user_enum_type.take() else {
6336                continue;
6337            };
6338            let cat = self.active_catalog();
6339            if cat.enum_types().contains_key(&name) {
6340                col.user_enum_type = Some(name);
6341                continue;
6342            }
6343            if let Some(dom) = cat.domain_types().get(&name) {
6344                col.ty = dom.base_type;
6345                col.user_domain_type = Some(name);
6346                if !dom.nullable {
6347                    col.nullable = false;
6348                }
6349                continue;
6350            }
6351            return Err(EngineError::Unsupported(alloc::format!(
6352                "column {:?}: unknown column type {:?} (not a built-in, ENUM, or DOMAIN)",
6353                col.name,
6354                name
6355            )));
6356        }
6357        for tc in &stmt.table_constraints {
6358            if let spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } = tc {
6359                for col_name in columns {
6360                    if let Some(col) = cols.iter_mut().find(|c| c.name == *col_name) {
6361                        col.nullable = false;
6362                    }
6363                }
6364            }
6365        }
6366        // v7.6.1 — resolve every FK in the statement against the
6367        // already-known catalog. Validates: parent table exists,
6368        // parent column names exist, arity matches, parent columns
6369        // have a PK / UNIQUE index. Self-referencing FKs (parent
6370        // table == this table) resolve against the column list we
6371        // just built — they don't need the catalog yet.
6372        let mut fks: Vec<spg_storage::ForeignKeyConstraint> =
6373            Vec::with_capacity(stmt.foreign_keys.len());
6374        for fk in stmt.foreign_keys {
6375            // v7.14.0 — when SET FOREIGN_KEY_CHECKS=0 is in effect
6376            // (mysqldump preamble + bulk imports), defer FK
6377            // resolution if the parent table isn't in the catalog
6378            // yet. The FK is queued and resolved when checks flip
6379            // back on. Self-references stay in-band (the parent is
6380            // the same as the child we're building).
6381            let needs_parent = !fk.parent_table.eq_ignore_ascii_case(&table_name);
6382            if !self.foreign_key_checks
6383                && needs_parent
6384                && self.active_catalog().get(&fk.parent_table).is_none()
6385            {
6386                self.pending_foreign_keys.push((table_name.clone(), fk));
6387                continue;
6388            }
6389            fks.push(resolve_foreign_key(
6390                &table_name,
6391                &cols,
6392                fk,
6393                self.active_catalog(),
6394            )?);
6395        }
6396        let mut schema = TableSchema::new(table_name.clone(), cols);
6397        schema.foreign_keys = fks;
6398        // v7.9.19 — translate AST table_constraints to storage
6399        // UniquenessConstraints (column name → position) so the
6400        // INSERT enforcement helper sees positions directly.
6401        let mut uc_storage: Vec<spg_storage::UniquenessConstraint> = Vec::new();
6402        let mut check_exprs: Vec<String> = Vec::new();
6403        for tc in &stmt.table_constraints {
6404            let (is_pk, names, nnd) = match tc {
6405                spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
6406                    (true, columns.clone(), false)
6407                }
6408                spg_sql::ast::TableConstraint::Unique {
6409                    columns,
6410                    nulls_not_distinct,
6411                    ..
6412                } => (false, columns.clone(), *nulls_not_distinct),
6413                spg_sql::ast::TableConstraint::Check { expr, .. } => {
6414                    // v7.13.0 — collect CHECK predicate sources;
6415                    // they get attached to the schema below.
6416                    check_exprs.push(alloc::format!("{expr}"));
6417                    continue;
6418                }
6419                // v7.15.0 — plain `KEY (cols)` from MySQL inline
6420                // is NOT a uniqueness constraint; skip the UC
6421                // build path entirely. The BTree index lands in
6422                // the post-create loop below alongside the PK/UQ
6423                // implicit indexes.
6424                spg_sql::ast::TableConstraint::Index { .. } => continue,
6425                // v7.17.0 Phase 2.2 — MySQL FULLTEXT KEY is not
6426                // a uniqueness constraint either; its GIN gets
6427                // built in the post-create loop below.
6428                spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
6429            };
6430            let mut positions = Vec::with_capacity(names.len());
6431            for n in &names {
6432                let pos = schema
6433                    .columns
6434                    .iter()
6435                    .position(|c| c.name == *n)
6436                    .ok_or_else(|| {
6437                        EngineError::Unsupported(alloc::format!(
6438                            "table constraint references unknown column {n:?}"
6439                        ))
6440                    })?;
6441                positions.push(pos);
6442            }
6443            uc_storage.push(spg_storage::UniquenessConstraint {
6444                is_primary_key: is_pk,
6445                columns: positions,
6446                nulls_not_distinct: nnd,
6447            });
6448        }
6449        schema.uniqueness_constraints = uc_storage.clone();
6450        schema.checks = check_exprs;
6451        self.active_catalog_mut().create_table(schema)?;
6452        // v7.9.13 — implicit BTree per inline PK column +
6453        // v7.9.19 — implicit BTree on the leading column of every
6454        // table-level PRIMARY KEY / UNIQUE constraint.
6455        let table = self
6456            .active_catalog_mut()
6457            .get_mut(&table_name)
6458            .expect("just created");
6459        for (i, col_name) in inline_pk_columns.iter().enumerate() {
6460            let idx_name = if inline_pk_columns.len() == 1 {
6461                alloc::format!("{table_name}_pkey")
6462            } else {
6463                alloc::format!("{table_name}_pkey_{i}")
6464            };
6465            if let Err(e) = table.add_index(idx_name, col_name) {
6466                return Err(EngineError::Storage(e));
6467            }
6468        }
6469        for (i, tc) in stmt.table_constraints.iter().enumerate() {
6470            // v7.17.0 Phase 2.2 — FULLTEXT KEY lands a real
6471            // tsvector-GIN per declared column instead of the
6472            // BTree the PK / UQ / KEY paths build. Branch early
6473            // so the BTree loop never sees the FULLTEXT shape.
6474            if let spg_sql::ast::TableConstraint::FulltextIndex { name, columns } = tc {
6475                for (k, col) in columns.iter().enumerate() {
6476                    let already = table.indices().iter().any(|idx| {
6477                        matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
6478                            && table.schema().columns[idx.column_position].name == *col
6479                    });
6480                    if already {
6481                        continue;
6482                    }
6483                    let idx_name = match (name.as_ref(), columns.len(), k) {
6484                        (Some(n), 1, _) => n.clone(),
6485                        (Some(n), _, k) => alloc::format!("{n}_{k}"),
6486                        (None, _, _) => {
6487                            alloc::format!("{table_name}_{col}_ftidx")
6488                        }
6489                    };
6490                    if let Err(e) = table.add_gin_fulltext_index(idx_name, col) {
6491                        return Err(EngineError::Storage(e));
6492                    }
6493                }
6494                continue;
6495            }
6496            // v7.15.0 — plain KEY/INDEX rides this same loop so
6497            // the implicit BTree gets built. It carries its own
6498            // user-supplied name; PK/UQ still synthesise.
6499            let (suffix, names, explicit_name): (&str, &Vec<String>, Option<&String>) = match tc {
6500                spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
6501                    ("pkey", columns, None)
6502                }
6503                spg_sql::ast::TableConstraint::Unique { columns, .. } => ("key", columns, None),
6504                spg_sql::ast::TableConstraint::Index { name, columns } => {
6505                    ("idx", columns, name.as_ref())
6506                }
6507                spg_sql::ast::TableConstraint::Check { .. } => continue,
6508                // Handled by the early-branch above.
6509                spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
6510            };
6511            let leading = &names[0];
6512            // Skip if a same-column BTree already exists (e.g.
6513            // inline PK on the leading column).
6514            let already = table.indices().iter().any(|idx| {
6515                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
6516                    && table.schema().columns[idx.column_position].name == *leading
6517            });
6518            if already {
6519                continue;
6520            }
6521            let idx_name = if let Some(n) = explicit_name {
6522                n.clone()
6523            } else if names.len() == 1 {
6524                alloc::format!("{table_name}_{leading}_{suffix}")
6525            } else {
6526                alloc::format!("{table_name}_{leading}_{suffix}_{i}")
6527            };
6528            if let Err(e) = table.add_index(idx_name, leading) {
6529                return Err(EngineError::Storage(e));
6530            }
6531        }
6532        Ok(QueryResult::CommandOk {
6533            affected: 0,
6534            modified_catalog: !self.in_transaction(),
6535        })
6536    }
6537
6538    fn exec_insert(&mut self, mut stmt: InsertStatement) -> Result<QueryResult, EngineError> {
6539        // v7.17.0 Phase 1.1 — pre-resolve any nextval / currval /
6540        // setval calls against the catalog before the row loop. We
6541        // walk each tuple expression and replace matching
6542        // FunctionCall nodes with their concrete Literal. This
6543        // keeps `literal_expr_to_value` free of `&mut self` and
6544        // lets multi-row INSERT VALUES (… nextval('seq') …)
6545        // mint a separate sequence value per row.
6546        for tuple in &mut stmt.rows {
6547            for cell in tuple.iter_mut() {
6548                self.resolve_sequence_calls_in_expr(cell)?;
6549            }
6550        }
6551        // v7.13.0 — `INSERT INTO t [(cols)] SELECT …` (mailrs
6552        // round-5 G4). Execute the inner SELECT first, then route
6553        // back through the regular VALUES code path with the
6554        // materialised rows.
6555        if let Some(select) = stmt.select_source.clone() {
6556            let select_result = self.exec_select_cancel(&select, CancelToken::none())?;
6557            let rows = match select_result {
6558                QueryResult::Rows { rows, .. } => rows,
6559                other => {
6560                    return Err(EngineError::Unsupported(alloc::format!(
6561                        "INSERT … SELECT: inner statement produced {other:?} instead of a row set"
6562                    )));
6563                }
6564            };
6565            let mut materialised: Vec<Vec<Expr>> = Vec::with_capacity(rows.len());
6566            for row in rows {
6567                let mut tuple: Vec<Expr> = Vec::with_capacity(row.values.len());
6568                for v in row.values {
6569                    tuple.push(value_to_literal_expr_permissive(v)?);
6570                }
6571                materialised.push(tuple);
6572            }
6573            let recurse = InsertStatement {
6574                table: stmt.table,
6575                columns: stmt.columns,
6576                rows: materialised,
6577                select_source: None,
6578                on_conflict: stmt.on_conflict,
6579                returning: stmt.returning,
6580            };
6581            return self.exec_insert(recurse);
6582        }
6583        // v7.9.21 — snapshot the clock fn pointer before the mut
6584        // borrow on the catalog opens; runtime DEFAULT eval needs
6585        // it inside the row hot loop.
6586        let clock = self.clock;
6587        // v7.12.4 — snapshot row-level triggers + their referenced
6588        // functions before the mut borrow on the catalog opens.
6589        // Cloned out so the row hot loop can fire them without
6590        // re-borrowing the catalog (which would conflict with
6591        // table.insert's mutable borrow).
6592        let before_insert_triggers = self.snapshot_row_triggers(&stmt.table, "INSERT", "BEFORE");
6593        let after_insert_triggers = self.snapshot_row_triggers(&stmt.table, "INSERT", "AFTER");
6594        let trigger_session_cfg: Option<alloc::string::String> = self
6595            .session_params
6596            .get("default_text_search_config")
6597            .cloned();
6598        // v7.17.0 Phase 1.4 — snapshot the enum label lookup BEFORE
6599        // opening the mutable borrow on the table below. We need
6600        // catalog-level read access (enum_types lives at the
6601        // catalog level, not the table) and the upcoming mutable
6602        // borrow shadows it.
6603        let pre_borrow_column_meta: Vec<ColumnSchema> = {
6604            let preview_table = self.active_catalog().get(&stmt.table).ok_or_else(|| {
6605                EngineError::Storage(StorageError::TableNotFound {
6606                    name: stmt.table.clone(),
6607                })
6608            })?;
6609            preview_table.schema().columns.clone()
6610        };
6611        let enum_label_lookup: alloc::collections::BTreeMap<usize, Vec<String>> =
6612            pre_borrow_column_meta
6613                .iter()
6614                .enumerate()
6615                .filter_map(|(i, col)| {
6616                    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM
6617                    // variant lists take priority over the PG
6618                    // catalog enum_types lookup (they're
6619                    // column-local and authoritative when set).
6620                    if let Some(inline) = &col.inline_enum_variants {
6621                        return Some((i, inline.clone()));
6622                    }
6623                    col.user_enum_type.as_ref().and_then(|ename| {
6624                        self.active_catalog()
6625                            .enum_types()
6626                            .get(ename)
6627                            .map(|e| (i, e.labels.clone()))
6628                    })
6629                })
6630                .collect();
6631        // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant lists.
6632        // Distinct from enum_label_lookup: SET validates that
6633        // every comma-separated token is in the variant list, and
6634        // canonicalises the cell to definition-order de-duped text.
6635        let set_variant_lookup: alloc::collections::BTreeMap<usize, Vec<String>> =
6636            pre_borrow_column_meta
6637                .iter()
6638                .enumerate()
6639                .filter_map(|(i, col)| col.inline_set_variants.as_ref().map(|vs| (i, vs.clone())))
6640                .collect();
6641        let table = self
6642            .active_catalog_mut()
6643            .get_mut(&stmt.table)
6644            .ok_or_else(|| {
6645                EngineError::Storage(StorageError::TableNotFound {
6646                    name: stmt.table.clone(),
6647                })
6648            })?;
6649        // v3.1.5: clone the columns vector only (not the whole
6650        // TableSchema — saves one String alloc for the table name).
6651        // We need an owned snapshot because we'll call `table.insert`
6652        // (mutable borrow on `table`) inside the row loop while
6653        // reading schema fields.
6654        let column_meta: Vec<ColumnSchema> = table.schema().columns.clone();
6655        let schema_cols_len = column_meta.len();
6656        // Build a permutation `tuple_pos[c] = Some(j)` meaning schema
6657        // column `c` is filled from the `j`-th tuple slot; `None` means
6658        // "fill with NULL". Validated once and reused for every row.
6659        let tuple_pos: Option<Vec<Option<usize>>> = match &stmt.columns {
6660            None => None, // 1-1 mapping, fast path
6661            Some(cols) => {
6662                let mut map = alloc::vec![None; schema_cols_len];
6663                for (j, name) in cols.iter().enumerate() {
6664                    let idx = column_meta
6665                        .iter()
6666                        .position(|c| c.name == *name)
6667                        .ok_or_else(|| {
6668                            EngineError::Eval(EvalError::ColumnNotFound { name: name.clone() })
6669                        })?;
6670                    if map[idx].is_some() {
6671                        return Err(EngineError::Storage(StorageError::ArityMismatch {
6672                            expected: schema_cols_len,
6673                            actual: cols.len(),
6674                        }));
6675                    }
6676                    map[idx] = Some(j);
6677                }
6678                // Omitted columns must either be nullable, carry a
6679                // DEFAULT, or be AUTO_INCREMENT. Catch NOT NULL
6680                // omissions up front so the WAL stays clean.
6681                for (i, col) in column_meta.iter().enumerate() {
6682                    if map[i].is_none()
6683                        && !col.nullable
6684                        && col.default.is_none()
6685                        && col.runtime_default.is_none()
6686                        && !col.auto_increment
6687                    {
6688                        return Err(EngineError::Storage(StorageError::NullInNotNull {
6689                            column: col.name.clone(),
6690                        }));
6691                    }
6692                }
6693                Some(map)
6694            }
6695        };
6696        let expected_tuple_len = stmt.columns.as_ref().map_or(schema_cols_len, Vec::len);
6697        // v7.6.2 — snapshot this table's FK list before the
6698        // mutable-borrow window so we can run parent lookups
6699        // against the immutable catalog after parsing. Empty vec is
6700        // the no-FK fast path; clone cost is O(fks * arity) which
6701        // is < 100 ns for typical schemas.
6702        let fks = table.schema().foreign_keys.clone();
6703        let mut affected = 0usize;
6704        // Stage 1 — parse + AUTO_INC + coerce all rows under the
6705        // single mutable borrow.
6706        let mut all_values: Vec<Vec<Value>> = Vec::with_capacity(stmt.rows.len());
6707        for tuple in stmt.rows {
6708            if tuple.len() != expected_tuple_len {
6709                return Err(EngineError::Storage(StorageError::ArityMismatch {
6710                    expected: expected_tuple_len,
6711                    actual: tuple.len(),
6712                }));
6713            }
6714            // Fast path: no column-list permutation → tuple slot j
6715            // maps to schema column j. We can zip schema with tuple
6716            // and skip the `raw_tuple` staging allocation entirely.
6717            let values: Vec<Value> = if let Some(map) = &tuple_pos {
6718                // Permuted path: still need raw_tuple to index by `map[i]`.
6719                let raw_tuple: Vec<Value> = tuple
6720                    .into_iter()
6721                    .map(literal_expr_to_value)
6722                    .collect::<Result<_, _>>()?;
6723                let mut out = Vec::with_capacity(schema_cols_len);
6724                for (i, col) in column_meta.iter().enumerate() {
6725                    let mut raw = match map[i] {
6726                        Some(j) => raw_tuple[j].clone(),
6727                        None => resolve_column_default_free(col, clock)?,
6728                    };
6729                    if col.auto_increment && raw.is_null() {
6730                        let next = table.next_auto_value(i).ok_or_else(|| {
6731                            EngineError::Unsupported(alloc::format!(
6732                                "AUTO_INCREMENT applies to integer columns only (column `{}`)",
6733                                col.name
6734                            ))
6735                        })?;
6736                        raw = Value::BigInt(next);
6737                    }
6738                    let coerced = coerce_value(raw, col.ty, &col.name, i)?;
6739                    enforce_enum_label(&enum_label_lookup, i, &col.name, &coerced)?;
6740                    let coerced =
6741                        canonicalize_set_value(&set_variant_lookup, i, &col.name, coerced)?;
6742                    check_unsigned_range(&coerced, col, i)?;
6743                    out.push(coerced);
6744                }
6745                out
6746            } else {
6747                // 1-1 mapping fast path: single Vec alloc, no raw_tuple.
6748                let mut out = Vec::with_capacity(schema_cols_len);
6749                for (i, (col, expr)) in column_meta.iter().zip(tuple).enumerate() {
6750                    let mut raw = literal_expr_to_value(expr)?;
6751                    if col.auto_increment && raw.is_null() {
6752                        let next = table.next_auto_value(i).ok_or_else(|| {
6753                            EngineError::Unsupported(alloc::format!(
6754                                "AUTO_INCREMENT applies to integer columns only (column `{}`)",
6755                                col.name
6756                            ))
6757                        })?;
6758                        raw = Value::BigInt(next);
6759                    }
6760                    let coerced = coerce_value(raw, col.ty, &col.name, i)?;
6761                    enforce_enum_label(&enum_label_lookup, i, &col.name, &coerced)?;
6762                    let coerced =
6763                        canonicalize_set_value(&set_variant_lookup, i, &col.name, coerced)?;
6764                    check_unsigned_range(&coerced, col, i)?;
6765                    out.push(coerced);
6766                }
6767                out
6768            };
6769            all_values.push(values);
6770        }
6771        // Stage 2 — FK enforcement on the immutable catalog.
6772        // Non-lexical lifetimes release the mutable borrow on
6773        // `table` here since stage 1 was the last use. The
6774        // parent-table lookup runs before any row is committed.
6775        let uniqueness = table.schema().uniqueness_constraints.clone();
6776        let _ = table;
6777        if !fks.is_empty() {
6778            enforce_fk_inserts(self.active_catalog(), &stmt.table, &fks, &all_values)?;
6779        }
6780        // v7.13.0 — CHECK constraint enforcement (mailrs round-5 G3).
6781        enforce_check_constraints(self.active_catalog(), &stmt.table, &all_values)?;
6782        // v7.9.19 — composite UNIQUE / PRIMARY KEY enforcement.
6783        enforce_uniqueness_inserts(self.active_catalog(), &stmt.table, &uniqueness, &all_values)?;
6784        // v7.9.29 — CREATE UNIQUE INDEX [WHERE pred] enforcement.
6785        // Independent of table-level UniquenessConstraint (which
6786        // can't carry a predicate). Walks the table's indexes;
6787        // for each `is_unique` index, only rows whose
6788        // partial_predicate evaluates truthy are checked for
6789        // collision. mailrs K1.
6790        enforce_unique_index_inserts(self.active_catalog(), &stmt.table, &all_values)?;
6791        // v7.9.8 / v7.9.9 — ON CONFLICT handling.
6792        //   - `DO NOTHING` filters `all_values` to non-conflicting
6793        //     rows + drops within-batch duplicates.
6794        //   - `DO UPDATE SET …` ALSO filters, but for each
6795        //     conflicting row it queues an UPDATE on the existing
6796        //     row using the incoming row's values as `EXCLUDED.*`.
6797        let mut pending_updates: Vec<(usize, Vec<Value>)> = Vec::new();
6798        let mut skipped_count = 0usize;
6799        if let Some(clause) = &stmt.on_conflict {
6800            let conflict_cols = resolve_on_conflict_columns(
6801                self.active_catalog(),
6802                &stmt.table,
6803                clause.target_columns.as_slice(),
6804            )?;
6805            let mut kept: Vec<Vec<Value>> = Vec::with_capacity(all_values.len());
6806            let mut seen_keys: Vec<Vec<Value>> = Vec::new();
6807            for values in all_values {
6808                let key_tuple: Vec<&Value> = conflict_cols.iter().map(|&c| &values[c]).collect();
6809                // SQL spec: NULL in any conflict column means "no
6810                // conflict possible" (NULL ≠ NULL for uniqueness).
6811                let has_null_key = key_tuple.iter().any(|v| matches!(v, Value::Null));
6812                let collides_with_table = !has_null_key
6813                    && on_conflict_keys_exist(
6814                        self.active_catalog(),
6815                        &stmt.table,
6816                        &conflict_cols,
6817                        &key_tuple,
6818                    );
6819                let key_tuple_owned: Vec<Value> = key_tuple.iter().map(|v| (*v).clone()).collect();
6820                let collides_with_batch =
6821                    !has_null_key && seen_keys.iter().any(|k| k == &key_tuple_owned);
6822                let collides = collides_with_table || collides_with_batch;
6823                match (&clause.action, collides) {
6824                    (_, false) => {
6825                        seen_keys.push(key_tuple_owned);
6826                        kept.push(values);
6827                    }
6828                    (spg_sql::ast::OnConflictAction::Nothing, true) => {
6829                        skipped_count += 1;
6830                    }
6831                    (
6832                        spg_sql::ast::OnConflictAction::Update {
6833                            assignments,
6834                            where_,
6835                        },
6836                        true,
6837                    ) => {
6838                        if !collides_with_table {
6839                            skipped_count += 1;
6840                            continue;
6841                        }
6842                        let target_pos = lookup_row_position_by_keys(
6843                            self.active_catalog(),
6844                            &stmt.table,
6845                            &conflict_cols,
6846                            &key_tuple,
6847                        )
6848                        .ok_or_else(|| {
6849                            EngineError::Unsupported(
6850                                "ON CONFLICT DO UPDATE: conflict detected but row \
6851                                 position could not be resolved (cold-tier row?)"
6852                                    .into(),
6853                            )
6854                        })?;
6855                        let updated = apply_on_conflict_assignments(
6856                            self.active_catalog(),
6857                            &stmt.table,
6858                            target_pos,
6859                            &values,
6860                            assignments,
6861                            where_.as_ref(),
6862                        )?;
6863                        if let Some(new_row) = updated {
6864                            pending_updates.push((target_pos, new_row));
6865                        } else {
6866                            skipped_count += 1;
6867                        }
6868                    }
6869                }
6870            }
6871            all_values = kept;
6872        }
6873        // Stage 3 — insert all rows under a fresh mutable borrow.
6874        let table = self
6875            .active_catalog_mut()
6876            .get_mut(&stmt.table)
6877            .ok_or_else(|| {
6878                EngineError::Storage(StorageError::TableNotFound {
6879                    name: stmt.table.clone(),
6880                })
6881            })?;
6882        // v7.9.4 — keep RETURNING projection rows separate per
6883        // INSERT and per UPDATE branch so DO UPDATE pushes the new
6884        // post-update state, not the incoming-only values.
6885        let mut returning_rows: Vec<Vec<Value>> = Vec::new();
6886        // v7.12.7 — collect embedded SQL emitted by any trigger
6887        // fire across the row loop; engine drains the queue after
6888        // the table mut borrow drops.
6889        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
6890        'rowloop: for values in all_values {
6891            let mut row = Row::new(values);
6892            // v7.12.4 — BEFORE INSERT row-level triggers. Each
6893            // trigger may rewrite NEW cells (e.g. populate
6894            // `search_vector := to_tsvector(...)`) and may return
6895            // NULL to skip the row entirely.
6896            for fd in &before_insert_triggers {
6897                let (outcome, deferred) = triggers::fire_row_trigger(
6898                    fd,
6899                    Some(row.clone()),
6900                    None,
6901                    &stmt.table,
6902                    &column_meta,
6903                    &[],
6904                    trigger_session_cfg.as_deref(),
6905                    false,
6906                )
6907                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
6908                deferred_embedded.extend(deferred);
6909                match outcome {
6910                    triggers::TriggerOutcome::Row(r) => row = r,
6911                    triggers::TriggerOutcome::Skip => continue 'rowloop,
6912                }
6913            }
6914            if stmt.returning.is_some() {
6915                returning_rows.push(row.values.clone());
6916            }
6917            // v7.12.4 — clone for the AFTER trigger view; insert
6918            // moves the row into the table.
6919            let inserted = row.clone();
6920            table.insert(row)?;
6921            affected += 1;
6922            // v7.12.4 — AFTER INSERT row-level triggers fire post-
6923            // write. Return value is ignored (PG semantics); we
6924            // surface any error from the body up to the caller.
6925            for fd in &after_insert_triggers {
6926                let (_outcome, deferred) = triggers::fire_row_trigger(
6927                    fd,
6928                    Some(inserted.clone()),
6929                    None,
6930                    &stmt.table,
6931                    &column_meta,
6932                    &[],
6933                    trigger_session_cfg.as_deref(),
6934                    true,
6935                )
6936                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
6937                deferred_embedded.extend(deferred);
6938            }
6939        }
6940        // v7.9.9 — apply ON CONFLICT DO UPDATE rewrites collected
6941        // in the conflict-resolution pass. update_row handles
6942        // index maintenance + body re-encoding.
6943        for (pos, new_row) in pending_updates {
6944            if stmt.returning.is_some() {
6945                returning_rows.push(new_row.clone());
6946            }
6947            table.update_row(pos, new_row)?;
6948            affected += 1;
6949        }
6950        let _ = skipped_count;
6951        // v7.12.7 — drop the table mut borrow and drain any
6952        // trigger-emitted embedded SQL queued during this INSERT.
6953        // The borrow has to release first because each deferred
6954        // stmt may UPDATE / INSERT / DELETE the same (or another)
6955        // table — including, in principle, this one.
6956        let _ = table;
6957        self.execute_deferred_trigger_stmts(deferred_embedded, CancelToken::none())?;
6958        // v7.9.4/v7.9.9 — RETURNING streams the rows that ended
6959        // up in the table after this statement (insert or
6960        // post-update on conflict).
6961        if let Some(items) = &stmt.returning {
6962            return self.build_returning_rows(&stmt.table, items, returning_rows);
6963        }
6964        // v6.2.1 — auto-analyze: track per-table modified-row
6965        // counter so the background sweep can decide when to
6966        // re-ANALYZE. Cheap path on the autocommit-wrap hot loop
6967        // — one BTreeMap entry update per INSERT batch.
6968        if !self.in_transaction() && affected > 0 {
6969            self.statistics
6970                .record_modifications(&stmt.table, affected as u64);
6971        }
6972        Ok(QueryResult::CommandOk {
6973            affected,
6974            modified_catalog: !self.in_transaction(),
6975        })
6976    }
6977
6978    /// v4.5: SELECT with cooperative cancellation. The token is
6979    /// honoured between UNION peers and inside the bare-SELECT row
6980    /// loop; HNSW kNN graph walks and the aggregate executor don't
6981    /// honour it yet (deferred — those paths bound their work
6982    /// internally by `LIMIT k` and `GROUP BY` cardinality).
6983    /// v6.10.2 — cold-tier time-travel scan. Resolves the segment
6984    /// by id, decodes each row body against the table's current
6985    /// schema, applies the SELECT's projection + optional WHERE +
6986    /// optional LIMIT, returns a `Rows` result. JOINs / aggregates
6987    /// / ORDER BY are unsupported on this path (STABILITY carve-
6988    /// out); operators wanting them should restore the segment
6989    /// into a regular table first.
6990    fn exec_select_as_of_segment(
6991        &self,
6992        stmt: &SelectStatement,
6993        from: &spg_sql::ast::FromClause,
6994        segment_id: u32,
6995    ) -> Result<QueryResult, EngineError> {
6996        // v6.10.2 scope: no joins, no aggregates, no ORDER BY,
6997        // no GROUP BY / HAVING / UNION / OFFSET / DISTINCT.
6998        if !from.joins.is_empty()
6999            || stmt.group_by.is_some()
7000            || stmt.having.is_some()
7001            || !stmt.unions.is_empty()
7002            || !stmt.order_by.is_empty()
7003            || stmt.offset.is_some()
7004            || stmt.distinct
7005            || aggregate::uses_aggregate(stmt)
7006        {
7007            return Err(EngineError::Unsupported(
7008                "AS OF SEGMENT supports SELECT projection + WHERE + LIMIT only \
7009                 (joins / aggregates / ORDER BY are STABILITY § \"Out of v6.10\")"
7010                    .into(),
7011            ));
7012        }
7013        let table = self
7014            .active_catalog()
7015            .get(&from.primary.name)
7016            .ok_or_else(|| StorageError::TableNotFound {
7017                name: from.primary.name.clone(),
7018            })?;
7019        let schema = table.schema().clone();
7020        let schema_cols = &schema.columns;
7021        let alias = from
7022            .primary
7023            .alias
7024            .as_deref()
7025            .unwrap_or(from.primary.name.as_str());
7026        let ctx = EvalContext::new(schema_cols, Some(alias));
7027        let seg = self
7028            .active_catalog()
7029            .cold_segment(segment_id)
7030            .ok_or_else(|| {
7031                EngineError::Unsupported(alloc::format!(
7032                    "AS OF SEGMENT: cold segment {segment_id} not registered"
7033                ))
7034            })?;
7035        let mut out_rows: Vec<Row> = Vec::new();
7036        let mut limit_remaining: Option<usize> =
7037            stmt.limit_literal().and_then(|n| usize::try_from(n).ok());
7038        for (_key, body) in seg.scan() {
7039            let (row, _consumed) =
7040                spg_storage::decode_row_body_dense(&body, &schema).map_err(EngineError::Storage)?;
7041            if let Some(where_expr) = &stmt.where_ {
7042                let cond = self.eval_expr_simple(where_expr, &row, &ctx)?;
7043                if !matches!(cond, Value::Bool(true)) {
7044                    continue;
7045                }
7046            }
7047            // Projection.
7048            let projected = self.project_row_simple(&row, &stmt.items, schema_cols, alias)?;
7049            out_rows.push(projected);
7050            if let Some(rem) = limit_remaining.as_mut() {
7051                if *rem == 0 {
7052                    out_rows.pop();
7053                    break;
7054                }
7055                *rem -= 1;
7056            }
7057        }
7058        // Output column schema: derive from SELECT items.
7059        let columns = self.derive_output_columns(&stmt.items, schema_cols, alias);
7060        Ok(QueryResult::Rows {
7061            columns,
7062            rows: out_rows,
7063        })
7064    }
7065
7066    /// v6.10.2 — simple-path WHERE eval that doesn't go through
7067    /// the correlated-subquery / Memoize machinery. AS OF SEGMENT
7068    /// scan paths predicate against a snapshot frozen segment, no
7069    /// cross-row state.
7070    fn eval_expr_simple(
7071        &self,
7072        expr: &Expr,
7073        row: &Row,
7074        ctx: &EvalContext,
7075    ) -> Result<Value, EngineError> {
7076        let cancel = CancelToken::none();
7077        self.eval_expr_with_correlated(expr, row, ctx, cancel, None)
7078    }
7079
7080    /// v7.9.4 — INSERT / UPDATE / DELETE RETURNING projector.
7081    /// Given the table name, the user-supplied projection items,
7082    /// and the mutated rows (post-insert / post-update values, or
7083    /// pre-delete snapshot), build a `QueryResult::Rows` whose
7084    /// schema describes the projected columns. Mailrs migration
7085    /// blocker #1.
7086    fn build_returning_rows(
7087        &self,
7088        table_name: &str,
7089        items: &[SelectItem],
7090        mutated_rows: Vec<Vec<Value>>,
7091    ) -> Result<QueryResult, EngineError> {
7092        let table = self.active_catalog().get(table_name).ok_or_else(|| {
7093            EngineError::Storage(StorageError::TableNotFound {
7094                name: table_name.into(),
7095            })
7096        })?;
7097        let schema_cols = table.schema().columns.clone();
7098        let columns = self.derive_output_columns(items, &schema_cols, table_name);
7099        let mut out_rows: Vec<Row> = Vec::with_capacity(mutated_rows.len());
7100        for values in mutated_rows {
7101            let row = Row::new(values);
7102            let projected = self.project_row_simple(&row, items, &schema_cols, table_name)?;
7103            out_rows.push(projected);
7104        }
7105        Ok(QueryResult::Rows {
7106            columns,
7107            rows: out_rows,
7108        })
7109    }
7110
7111    /// v6.10.2 — projection for AS OF SEGMENT. Resolves
7112    /// `SelectItem::Wildcard` to all schema columns and
7113    /// `SelectItem::Expr` via the regular eval path.
7114    fn project_row_simple(
7115        &self,
7116        row: &Row,
7117        items: &[SelectItem],
7118        schema_cols: &[ColumnSchema],
7119        alias: &str,
7120    ) -> Result<Row, EngineError> {
7121        let ctx = EvalContext::new(schema_cols, Some(alias));
7122        let cancel = CancelToken::none();
7123        let mut out_vals = Vec::new();
7124        for item in items {
7125            match item {
7126                SelectItem::Wildcard => {
7127                    out_vals.extend(row.values.iter().cloned());
7128                }
7129                SelectItem::Expr { expr, .. } => {
7130                    let v = self.eval_expr_with_correlated(expr, row, &ctx, cancel, None)?;
7131                    out_vals.push(v);
7132                }
7133            }
7134        }
7135        Ok(Row::new(out_vals))
7136    }
7137
7138    /// v6.10.2 — derive the output `ColumnSchema` list for an
7139    /// AS OF SEGMENT projection. Wildcards take the full schema;
7140    /// expressions take the alias if present or a synthetic
7141    /// `?column?` (PG convention) otherwise.
7142    fn derive_output_columns(
7143        &self,
7144        items: &[SelectItem],
7145        schema_cols: &[ColumnSchema],
7146        _alias: &str,
7147    ) -> Vec<ColumnSchema> {
7148        let mut out = Vec::new();
7149        for item in items {
7150            match item {
7151                SelectItem::Wildcard => {
7152                    out.extend(schema_cols.iter().cloned());
7153                }
7154                SelectItem::Expr { alias, .. } => {
7155                    let name = alias.clone().unwrap_or_else(|| "?column?".to_string());
7156                    // Default to Text; the caller's row values
7157                    // carry the actual type. v6.10.2 scope.
7158                    out.push(ColumnSchema::new(name, DataType::Text, true));
7159                }
7160            }
7161        }
7162        out
7163    }
7164
7165    fn exec_select_cancel(
7166        &self,
7167        stmt: &SelectStatement,
7168        cancel: CancelToken<'_>,
7169    ) -> Result<QueryResult, EngineError> {
7170        cancel.check()?;
7171        // v7.17.0 Phase 1.2 — user-defined VIEW expansion. If the
7172        // FROM / JOIN graph references any catalogued view name,
7173        // re-parse the view body and prepend it as a synthetic
7174        // CTE. Recurses on views-in-views via the regular CTE
7175        // dispatch below. Fast-path: skip the walker entirely when
7176        // the catalog has no views (the typical OLTP load).
7177        if !self.active_catalog().views().is_empty() {
7178            if let Some(rewritten) = self.expand_views_in_select(stmt)? {
7179                return self.exec_select_cancel(&rewritten, cancel);
7180            }
7181        }
7182        // v7.16.2 — information_schema / pg_catalog virtual
7183        // views (mailrs round-10 A.3). If the SELECT touches a
7184        // synthetic meta-table name (`__spg_info_*` /
7185        // `__spg_pg_*` — produced by the parser for
7186        // `information_schema.X` / `pg_catalog.X`), clone the
7187        // catalog, materialise the requested view as a real
7188        // temporary table, and re-execute against an enriched
7189        // engine. Same pattern as `exec_with_ctes` for CTEs.
7190        if !self.meta_views_materialised && select_references_meta_view(stmt) {
7191            return self.exec_select_with_meta_views(stmt, cancel);
7192        }
7193        // v6.10.2 — cold-tier time-travel short-circuit. When the
7194        // primary TableRef carries `AS OF SEGMENT '<id>'`, run a
7195        // dedicated cold-segment scan instead of the regular
7196        // hot+index path. The scope is intentionally narrow for
7197        // v6.10.2 — bare `SELECT * FROM <t> AS OF SEGMENT 'id'`,
7198        // optionally with a single-column-equality WHERE. JOINs /
7199        // aggregates / ORDER BY / subqueries on top of a time-
7200        // travelled scan are STABILITY § "Out of v6.10".
7201        if let Some(from) = &stmt.from
7202            && let Some(seg_id) = from.primary.as_of_segment
7203        {
7204            return self.exec_select_as_of_segment(stmt, from, seg_id);
7205        }
7206        // v6.2.0 / v6.5.0 — virtual-table short-circuits. Detected
7207        // pre-CTE because they don't read from the catalog and
7208        // shouldn't participate in regular FROM resolution.
7209        if let Some(from) = &stmt.from
7210            && from.joins.is_empty()
7211            && stmt.where_.is_none()
7212            && stmt.group_by.is_none()
7213            && stmt.having.is_none()
7214            && stmt.unions.is_empty()
7215            && stmt.order_by.is_empty()
7216            && stmt.limit.is_none()
7217            && stmt.offset.is_none()
7218            && !stmt.distinct
7219            && stmt.items.iter().all(|i| matches!(i, SelectItem::Wildcard))
7220        {
7221            let lower = from.primary.name.to_ascii_lowercase();
7222            match lower.as_str() {
7223                "spg_statistic" => return Ok(self.exec_spg_statistic()),
7224                // v6.5.0 — observability v2 virtual tables.
7225                "spg_stat_replication" => return Ok(self.exec_spg_stat_replication()),
7226                "spg_stat_segment" => return Ok(self.exec_spg_stat_segment()),
7227                "spg_stat_query" => return Ok(self.exec_spg_stat_query()),
7228                "spg_stat_activity" => return Ok(self.exec_spg_stat_activity()),
7229                "spg_audit_chain" => return Ok(self.exec_spg_audit_chain()),
7230                "spg_audit_verify" => return Ok(self.exec_spg_audit_verify()),
7231                "spg_table_ddl" => return Ok(self.exec_spg_table_ddl()),
7232                "spg_role_ddl" => return Ok(self.exec_spg_role_ddl()),
7233                "spg_database_ddl" => return Ok(self.exec_spg_database_ddl()),
7234                _ => {}
7235            }
7236        }
7237        // v4.11: CTEs materialise into a temporary enriched catalog
7238        // *before* anything else — the body SELECT can then refer
7239        // to CTE names via the regular FROM-clause resolution.
7240        // Uncorrelated only: each CTE body runs once against the
7241        // current catalog, not against later CTEs' results (left-
7242        // to-right materialisation would relax this, but we keep
7243        // it simple for v4.11 MVP).
7244        if !stmt.ctes.is_empty() {
7245            return self.exec_with_ctes(stmt, cancel);
7246        }
7247        // v4.10: subqueries (uncorrelated) are resolved here, before
7248        // the executor sees the row loop. We clone the statement so
7249        // we can mutate without disturbing the caller's AST — most
7250        // queries pass through with no subquery nodes and the clone
7251        // is cheap; with subqueries the materialisation cost
7252        // dominates anyway.
7253        let mut stmt_owned;
7254        let stmt_ref: &SelectStatement = if expr_tree_has_subquery(stmt) {
7255            stmt_owned = stmt.clone();
7256            self.resolve_select_subqueries(&mut stmt_owned, cancel)?;
7257            &stmt_owned
7258        } else {
7259            stmt
7260        };
7261        if stmt_ref.unions.is_empty() {
7262            return self.exec_bare_select_cancel(stmt_ref, cancel);
7263        }
7264        // UNION path: clone-strip the head into a bare block (its own
7265        // DISTINCT and any inner ORDER BY are dropped by parser rule —
7266        // the wrapper SelectStatement carries them), execute, then chain
7267        // peers with left-associative dedup semantics.
7268        let mut head = stmt_ref.clone();
7269        head.unions = Vec::new();
7270        head.order_by = Vec::new();
7271        head.limit = None;
7272        let QueryResult::Rows { columns, mut rows } =
7273            self.exec_bare_select_cancel(&head, cancel)?
7274        else {
7275            unreachable!("bare SELECT cannot return CommandOk")
7276        };
7277        for (kind, peer) in &stmt_ref.unions {
7278            let QueryResult::Rows {
7279                columns: peer_cols,
7280                rows: peer_rows,
7281            } = self.exec_bare_select_cancel(peer, cancel)?
7282            else {
7283                unreachable!("bare SELECT cannot return CommandOk")
7284            };
7285            if peer_cols.len() != columns.len() {
7286                return Err(EngineError::Unsupported(alloc::format!(
7287                    "UNION arity mismatch: head has {} columns, peer has {}",
7288                    columns.len(),
7289                    peer_cols.len()
7290                )));
7291            }
7292            rows.extend(peer_rows);
7293            if matches!(kind, UnionKind::Distinct) {
7294                rows = dedup_rows(rows);
7295            }
7296        }
7297        // ORDER BY at the top of a UNION applies to the combined result.
7298        // Eval against the projected schema (NOT the source table).
7299        if !stmt.order_by.is_empty() {
7300            let synth_ctx = EvalContext::new(&columns, None);
7301            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
7302            let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(rows.len());
7303            for r in rows {
7304                let keys = build_order_keys(&stmt.order_by, &r, &synth_ctx)?;
7305                tagged.push((keys, r));
7306            }
7307            sort_by_keys(&mut tagged, &descs);
7308            rows = tagged.into_iter().map(|(_, r)| r).collect();
7309        }
7310        apply_offset_and_limit(&mut rows, stmt.offset_literal(), stmt.limit_literal());
7311        Ok(QueryResult::Rows { columns, rows })
7312    }
7313
7314    #[allow(clippy::too_many_lines)]
7315    #[allow(clippy::too_many_lines)] // huge match — splitting fragments the planner
7316    /// v7.11.7 — execute `SELECT … FROM unnest(expr) [AS] alias …`.
7317    /// Synthesises a single-column virtual table whose column type
7318    /// is TEXT and whose rows are the array elements. Routes
7319    /// through the regular projection / WHERE / ORDER BY / LIMIT
7320    /// machinery so set-returning UNNEST composes naturally with
7321    /// the rest of the SELECT surface.
7322    fn exec_select_unnest(
7323        &self,
7324        stmt: &SelectStatement,
7325        primary: &TableRef,
7326        cancel: CancelToken<'_>,
7327    ) -> Result<QueryResult, EngineError> {
7328        let expr = primary
7329            .unnest_expr
7330            .as_deref()
7331            .expect("caller guards unnest_expr.is_some()");
7332        // Evaluate the array expression once. Empty schema / empty
7333        // row — uncorrelated UNNEST cannot reference outer columns.
7334        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7335        let ctx = EvalContext::new(&empty_schema, None);
7336        let dummy_row = Row::new(alloc::vec::Vec::new());
7337        // v7.11.13 — unnest dispatches per array element type so
7338        // INT[] / BIGINT[] surface their PG types in projection.
7339        let (elem_dtype, rows): (DataType, alloc::vec::Vec<Row>) =
7340            match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
7341                Value::Null => (DataType::Text, alloc::vec::Vec::new()),
7342                Value::TextArray(items) => {
7343                    let rows = items
7344                        .into_iter()
7345                        .map(|item| {
7346                            Row::new(alloc::vec![match item {
7347                                Some(s) => Value::Text(s),
7348                                None => Value::Null,
7349                            }])
7350                        })
7351                        .collect();
7352                    (DataType::Text, rows)
7353                }
7354                Value::IntArray(items) => {
7355                    let rows = items
7356                        .into_iter()
7357                        .map(|item| {
7358                            Row::new(alloc::vec![match item {
7359                                Some(n) => Value::Int(n),
7360                                None => Value::Null,
7361                            }])
7362                        })
7363                        .collect();
7364                    (DataType::Int, rows)
7365                }
7366                Value::BigIntArray(items) => {
7367                    let rows = items
7368                        .into_iter()
7369                        .map(|item| {
7370                            Row::new(alloc::vec![match item {
7371                                Some(n) => Value::BigInt(n),
7372                                None => Value::Null,
7373                            }])
7374                        })
7375                        .collect();
7376                    (DataType::BigInt, rows)
7377                }
7378                other => {
7379                    return Err(EngineError::Unsupported(alloc::format!(
7380                        "unnest() expects an array argument, got {:?}",
7381                        other.data_type()
7382                    )));
7383                }
7384            };
7385        let alias = primary
7386            .alias
7387            .clone()
7388            .unwrap_or_else(|| "unnest".to_string());
7389        // v7.13.2 — mailrs round-6 S5. Honour PG-standard
7390        // `UNNEST(arr) AS p(col_name)` column-list aliasing: the
7391        // first entry overrides the projected column's name.
7392        // Without the column list, fall back to the table alias
7393        // (pre-v7.13.2 behaviour).
7394        let col_name = primary
7395            .unnest_column_aliases
7396            .first()
7397            .cloned()
7398            .unwrap_or_else(|| alias.clone());
7399        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7400        let schema_cols = alloc::vec![col_schema.clone()];
7401        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7402        // Apply WHERE.
7403        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7404            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7405            for row in rows {
7406                cancel.check()?;
7407                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7408                if matches!(v, Value::Bool(true)) {
7409                    out.push(row);
7410                }
7411            }
7412            out
7413        } else {
7414            rows
7415        };
7416        // v7.17.0 Phase 3.P0-48 — aggregate dispatch over the
7417        // unnest source. Same routing the relational scan path
7418        // already takes — without it `SELECT COUNT(*) FROM
7419        // unnest(ARRAY[…])` either errored at projection time or
7420        // returned the wrong shape.
7421        if aggregate::uses_aggregate(stmt) {
7422            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7423            let mut agg = aggregate::run(stmt, &filtered_refs, &schema_cols, Some(&alias))?;
7424            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7425            return Ok(QueryResult::Rows {
7426                columns: agg.columns,
7427                rows: agg.rows,
7428            });
7429        }
7430        // Projection.
7431        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7432        let mut projected_rows: alloc::vec::Vec<Row> =
7433            alloc::vec::Vec::with_capacity(filtered.len());
7434        // v7.19 P5 — Set-Returning-Function in projection
7435        // position (PG `SELECT unnest(arr) FROM t` shape). When a
7436        // SELECT item evaluates to a top-level unnest(arr) call,
7437        // expand it: for each input row, evaluate the array, emit
7438        // one output row per element, broadcasting non-SRF
7439        // projections from the same input row. Multi-SRF + LCM
7440        // padding stays a documented carve-out; mailrs uses
7441        // single-SRF for redirect_uris.
7442        let srf_position = projection.iter().position(|p| is_top_level_unnest(&p.expr));
7443        if let Some(srf_idx) = srf_position {
7444            let srf_arg = top_level_unnest_arg(&projection[srf_idx].expr)
7445                .expect("checked by is_top_level_unnest above");
7446            for row in &filtered {
7447                let arr_val =
7448                    eval::eval_expr(srf_arg, row, &scan_ctx).map_err(EngineError::Eval)?;
7449                let elements = array_value_to_elements(&arr_val)?;
7450                // Empty array → zero rows for this input row (PG
7451                // semantics: `SELECT unnest('{}'::int[])` returns
7452                // 0 rows, not a single NULL row).
7453                for elem in elements {
7454                    let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7455                    for (i, p) in projection.iter().enumerate() {
7456                        if i == srf_idx {
7457                            vals.push(elem.clone());
7458                        } else {
7459                            vals.push(
7460                                eval::eval_expr(&p.expr, row, &scan_ctx)
7461                                    .map_err(EngineError::Eval)?,
7462                            );
7463                        }
7464                    }
7465                    projected_rows.push(Row::new(vals));
7466                }
7467            }
7468        } else {
7469            for row in &filtered {
7470                let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7471                for p in &projection {
7472                    vals.push(eval::eval_expr(&p.expr, row, &scan_ctx).map_err(EngineError::Eval)?);
7473                }
7474                projected_rows.push(Row::new(vals));
7475            }
7476        }
7477        // ORDER BY / LIMIT — apply on the projected rows (cheap;
7478        // unnest result sets are small by design).
7479        let columns: alloc::vec::Vec<ColumnSchema> = projection
7480            .iter()
7481            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7482            .collect();
7483        // Re-evaluate ORDER BY against the source schema (pre-projection
7484        // so col refs by name still resolve through `scan_ctx`).
7485        if !stmt.order_by.is_empty() {
7486            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7487                .iter()
7488                .enumerate()
7489                .map(|(i, r)| -> Result<_, EngineError> {
7490                    let keys: Result<Vec<Value>, EngineError> = stmt
7491                        .order_by
7492                        .iter()
7493                        .map(|ob| {
7494                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7495                        })
7496                        .collect();
7497                    Ok((i, keys?))
7498                })
7499                .collect::<Result<_, _>>()?;
7500            indexed.sort_by(|a, b| {
7501                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7502                    let mut cmp = value_cmp(ka, kb);
7503                    if stmt.order_by[idx].desc {
7504                        cmp = cmp.reverse();
7505                    }
7506                    if cmp != core::cmp::Ordering::Equal {
7507                        return cmp;
7508                    }
7509                }
7510                core::cmp::Ordering::Equal
7511            });
7512            projected_rows = indexed
7513                .into_iter()
7514                .map(|(i, _)| projected_rows[i].clone())
7515                .collect();
7516        }
7517        // LIMIT / OFFSET — apply at the tail.
7518        if let Some(offset) = stmt.offset_literal() {
7519            let off = (offset as usize).min(projected_rows.len());
7520            projected_rows.drain(..off);
7521        }
7522        if let Some(limit) = stmt.limit_literal() {
7523            projected_rows.truncate(limit as usize);
7524        }
7525        Ok(QueryResult::Rows {
7526            columns,
7527            rows: projected_rows,
7528        })
7529    }
7530
7531    /// v7.17.0 Phase 3.10 — `FROM generate_series(start, stop [,
7532    /// step])` set-returning source. Mirrors `exec_select_unnest`'s
7533    /// shape: evaluate the arg list once against an empty row,
7534    /// materialise the row stream by stepping start → stop, then
7535    /// route through the standard WHERE / projection / ORDER BY /
7536    /// LIMIT pipeline. Two arg-type combos in v7.17:
7537    ///   * integer / integer [/ integer] — SmallInt, Int, BigInt
7538    ///     (widened to BigInt internally; step defaults to 1)
7539    ///   * timestamp / timestamp / interval — date-range
7540    ///     iteration (mailrs's daily-report pattern)
7541    fn exec_select_generate_series(
7542        &self,
7543        stmt: &SelectStatement,
7544        primary: &TableRef,
7545        cancel: CancelToken<'_>,
7546    ) -> Result<QueryResult, EngineError> {
7547        let args = primary
7548            .generate_series_args
7549            .as_ref()
7550            .expect("caller guards generate_series_args.is_some()");
7551        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7552        let ctx = EvalContext::new(&empty_schema, None);
7553        let dummy_row = Row::new(alloc::vec::Vec::new());
7554        let mut arg_values: alloc::vec::Vec<Value> = alloc::vec::Vec::with_capacity(args.len());
7555        for a in args {
7556            arg_values.push(eval::eval_expr(a, &dummy_row, &ctx).map_err(EngineError::Eval)?);
7557        }
7558        // Dispatch on the start value's shape. Reject mixed-shape
7559        // calls early (e.g. start = timestamp, stop = integer) so
7560        // the caller gets a clean error rather than a panic.
7561        let (elem_dtype, rows) = match arg_values.as_slice() {
7562            [Value::Timestamp(start), Value::Timestamp(stop), step] => {
7563                let interval_step = match step {
7564                    Value::Interval { .. } => step.clone(),
7565                    other => {
7566                        return Err(EngineError::Unsupported(alloc::format!(
7567                            "generate_series(timestamp, timestamp, …): \
7568                             step must be INTERVAL, got {:?}",
7569                            other.data_type()
7570                        )));
7571                    }
7572                };
7573                let rows = generate_series_timestamps(*start, *stop, interval_step, &cancel)?;
7574                (DataType::Timestamp, rows)
7575            }
7576            [start, stop, step]
7577                if value_is_integer(start) && value_is_integer(stop) && value_is_integer(step) =>
7578            {
7579                let s = value_to_i64(start);
7580                let e = value_to_i64(stop);
7581                let st = value_to_i64(step);
7582                let rows = generate_series_integers(s, e, st, &cancel)?;
7583                (DataType::BigInt, rows)
7584            }
7585            [start, stop] if value_is_integer(start) && value_is_integer(stop) => {
7586                let s = value_to_i64(start);
7587                let e = value_to_i64(stop);
7588                let rows = generate_series_integers(s, e, 1, &cancel)?;
7589                (DataType::BigInt, rows)
7590            }
7591            _ => {
7592                return Err(EngineError::Unsupported(alloc::format!(
7593                    "generate_series(): v7.17 supports integer or (timestamp, timestamp, interval) \
7594                     argument shapes; got {:?}",
7595                    arg_values
7596                        .iter()
7597                        .map(|v| v.data_type())
7598                        .collect::<alloc::vec::Vec<_>>()
7599                )));
7600            }
7601        };
7602        let alias = primary
7603            .alias
7604            .clone()
7605            .unwrap_or_else(|| "generate_series".to_string());
7606        let col_name = alias.clone();
7607        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7608        let schema_cols = alloc::vec![col_schema.clone()];
7609        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7610        // WHERE.
7611        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7612            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7613            for row in rows {
7614                cancel.check()?;
7615                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7616                if matches!(v, Value::Bool(true)) {
7617                    out.push(row);
7618                }
7619            }
7620            out
7621        } else {
7622            rows
7623        };
7624        // v7.17.0 Phase 3.P0-48 — aggregate dispatch for set-
7625        // returning sources. When the SELECT projection contains
7626        // aggregate functions (COUNT/SUM/MIN/MAX/AVG/string_agg/
7627        // …) we route the filtered row stream through the same
7628        // aggregate executor the relational scan path uses, so
7629        // `SELECT COUNT(*) FROM generate_series(1, 100)` returns
7630        // a single 100 row instead of erroring at projection
7631        // time. GROUP BY / HAVING / ORDER BY over the aggregate
7632        // output all ride through `aggregate::run`.
7633        if aggregate::uses_aggregate(stmt) {
7634            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7635            let mut agg = aggregate::run(stmt, &filtered_refs, &schema_cols, Some(&alias))?;
7636            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7637            return Ok(QueryResult::Rows {
7638                columns: agg.columns,
7639                rows: agg.rows,
7640            });
7641        }
7642        // Projection.
7643        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7644        let mut projected_rows: alloc::vec::Vec<Row> =
7645            alloc::vec::Vec::with_capacity(filtered.len());
7646        for row in &filtered {
7647            let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7648            for p in &projection {
7649                vals.push(eval::eval_expr(&p.expr, row, &scan_ctx).map_err(EngineError::Eval)?);
7650            }
7651            projected_rows.push(Row::new(vals));
7652        }
7653        let columns: alloc::vec::Vec<ColumnSchema> = projection
7654            .iter()
7655            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7656            .collect();
7657        // ORDER BY against the source schema.
7658        if !stmt.order_by.is_empty() {
7659            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7660                .iter()
7661                .enumerate()
7662                .map(|(i, r)| -> Result<_, EngineError> {
7663                    let keys: Result<Vec<Value>, EngineError> = stmt
7664                        .order_by
7665                        .iter()
7666                        .map(|ob| {
7667                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7668                        })
7669                        .collect();
7670                    Ok((i, keys?))
7671                })
7672                .collect::<Result<_, _>>()?;
7673            indexed.sort_by(|a, b| {
7674                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7675                    let mut cmp = value_cmp(ka, kb);
7676                    if stmt.order_by[idx].desc {
7677                        cmp = cmp.reverse();
7678                    }
7679                    if cmp != core::cmp::Ordering::Equal {
7680                        return cmp;
7681                    }
7682                }
7683                core::cmp::Ordering::Equal
7684            });
7685            projected_rows = indexed
7686                .into_iter()
7687                .map(|(i, _)| projected_rows[i].clone())
7688                .collect();
7689        }
7690        if let Some(offset) = stmt.offset_literal() {
7691            let off = (offset as usize).min(projected_rows.len());
7692            projected_rows.drain(..off);
7693        }
7694        if let Some(limit) = stmt.limit_literal() {
7695            projected_rows.truncate(limit as usize);
7696        }
7697        Ok(QueryResult::Rows {
7698            columns,
7699            rows: projected_rows,
7700        })
7701    }
7702
7703    fn exec_bare_select_cancel(
7704        &self,
7705        stmt: &SelectStatement,
7706        cancel: CancelToken<'_>,
7707    ) -> Result<QueryResult, EngineError> {
7708        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST N ROWS WITH TIES`
7709        // is meaningless without an ORDER BY; PG raises a hard
7710        // error and SPG mirrors the surface so the same DDL/app
7711        // path behaves identically on cutover.
7712        check_with_ties_requires_order_by(stmt)?;
7713        // v7.16.2 — same meta-view dispatch as
7714        // `exec_select_cancel`, applied here too because
7715        // `subquery_replacement` enters this function directly
7716        // for Exists / ScalarSubquery / InSubquery resolution
7717        // (bypassing the top-level entry to avoid double
7718        // subquery walking). Without this dispatch the subquery
7719        // hits `__spg_info_columns` and reports TableNotFound.
7720        if !self.meta_views_materialised && select_references_meta_view(stmt) {
7721            return self.exec_select_with_meta_views(stmt, cancel);
7722        }
7723        // v4.12: window-function path. When the projection contains
7724        // any `name(args) OVER (...)` we route to the dedicated
7725        // executor — partition + sort + per-row window value before
7726        // the regular projection.
7727        if select_has_window(stmt) {
7728            return self.exec_select_with_window(stmt, cancel);
7729        }
7730        // Constant SELECT (no FROM) — evaluate each item once against an
7731        // empty dummy row. Useful for `SELECT 1`, `SELECT coalesce(...)`,
7732        // `SELECT '7'::INT`. Column references will surface as
7733        // ColumnNotFound on eval since the schema is empty.
7734        let Some(from) = &stmt.from else {
7735            let empty_schema: Vec<ColumnSchema> = Vec::new();
7736            let ctx = self.ev_ctx(&empty_schema, None);
7737            let projection = build_projection(&stmt.items, &empty_schema, "")?;
7738            let dummy_row = Row::new(Vec::new());
7739            let mut values = Vec::with_capacity(projection.len());
7740            for p in &projection {
7741                values.push(eval::eval_expr(&p.expr, &dummy_row, &ctx)?);
7742            }
7743            let columns: Vec<ColumnSchema> = projection
7744                .into_iter()
7745                .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
7746                .collect();
7747            return Ok(QueryResult::Rows {
7748                columns,
7749                rows: alloc::vec![Row::new(values)],
7750            });
7751        };
7752        // Multi-table FROM (one or more joined peers) goes through the
7753        // nested-loop join executor. Single-table FROM stays on the
7754        // existing scan + index-seek path.
7755        if !from.joins.is_empty() {
7756            return self.exec_joined_select(stmt, from);
7757        }
7758        // v7.11.7 — `FROM unnest(<expr>) [AS] <alias>`. Synthesise a
7759        // single-column table at SELECT entry by evaluating the
7760        // expression once against the empty row (UNNEST is
7761        // uncorrelated in v7.11; correlated / LATERAL unnest is a
7762        // v7.12 carve-out). Build a virtual `Table` in a heap-only
7763        // catalog, then route to the regular scan path.
7764        if from.primary.unnest_expr.is_some() {
7765            return self.exec_select_unnest(stmt, &from.primary, cancel);
7766        }
7767        // v7.17.0 Phase 3.10 — `FROM generate_series(start, stop
7768        // [, step])` set-returning source. Dispatch mirrors UNNEST:
7769        // materialise the row stream from a single eval pass, then
7770        // run the regular projection / WHERE / ORDER BY / LIMIT
7771        // pipeline over the synthetic single-column table.
7772        if from.primary.generate_series_args.is_some() {
7773            return self.exec_select_generate_series(stmt, &from.primary, cancel);
7774        }
7775        let primary = &from.primary;
7776        let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
7777            StorageError::TableNotFound {
7778                name: primary.name.clone(),
7779            }
7780        })?;
7781        let schema_cols = &table.schema().columns;
7782        // The qualifier accepted on column refs is the alias (if any) else the
7783        // bare table name.
7784        let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
7785        let ctx = self.ev_ctx(schema_cols, Some(alias));
7786
7787        // NSW kNN planner: `ORDER BY col <-> literal LIMIT k` with no
7788        // WHERE and an NSW index on `col` skips the full scan. The
7789        // walk returns rows already in ascending-distance order, so
7790        // ORDER BY / LIMIT are honoured implicitly.
7791        if let Some(nsw_rows) = try_nsw_knn(stmt, table, schema_cols, alias) {
7792            return materialise_in_order(stmt, table, schema_cols, alias, &nsw_rows);
7793        }
7794
7795        // Index seek: if WHERE is `col = literal` (or commuted) and the
7796        // referenced column has an index, dispatch each locator through
7797        // the catalog (hot tier → borrow, cold tier → page-read +
7798        // decode) and iterate just those rows. Otherwise fall back to a
7799        // full scan over the hot tier (cold-tier rows are only reached
7800        // via index seek in v5.1 — full table scans against cold-tier
7801        // data ship in v5.2 with the freezer's per-segment scan API).
7802        let indexed_rows: Option<Vec<Cow<'_, Row>>> = stmt.where_.as_ref().and_then(|w| {
7803            // BTree / col=literal seek first — covers the v7.11.3 multi-
7804            // column AND case and the leading-column equality lookup.
7805            try_index_seek(w, schema_cols, self.active_catalog(), table, alias)
7806                .or_else(|| {
7807                    // v7.12.3 — GIN-accelerated `WHERE col @@
7808                    // tsquery` when the column has a `USING gin`
7809                    // index. Returns an over-approximate candidate
7810                    // set; the WHERE re-eval loop below verifies
7811                    // the full `@@` predicate per row.
7812                    try_gin_seek(w, schema_cols, self.active_catalog(), table, alias, &ctx)
7813                })
7814                .or_else(|| {
7815                    // v7.15.0 — trigram-GIN-accelerated
7816                    // `WHERE col LIKE / ILIKE '<pat>'` when the
7817                    // column has a `gin_trgm_ops` GIN index.
7818                    // Over-approximate candidate set; the WHERE
7819                    // re-eval verifies the LIKE per row.
7820                    try_trgm_seek(w, schema_cols, table, alias)
7821                })
7822        });
7823
7824        // Aggregate path: filter rows first, then hand off to the
7825        // aggregate executor which does its own projection + ORDER BY.
7826        if aggregate::uses_aggregate(stmt) {
7827            let mut filtered: Vec<&Row> = Vec::new();
7828            // v6.2.6 — Memoize: per-query LRU cache for correlated
7829            // scalar subqueries. Fresh per row-loop entry so each
7830            // SELECT execution gets an isolated cache.
7831            let mut memo = memoize::MemoizeCache::new();
7832            if let Some(rows) = &indexed_rows {
7833                for cow in rows {
7834                    let row = cow.as_ref();
7835                    if let Some(where_expr) = &stmt.where_ {
7836                        let cond = self.eval_expr_with_correlated(
7837                            where_expr,
7838                            row,
7839                            &ctx,
7840                            cancel,
7841                            Some(&mut memo),
7842                        )?;
7843                        if !matches!(cond, Value::Bool(true)) {
7844                            continue;
7845                        }
7846                    }
7847                    filtered.push(row);
7848                }
7849            } else {
7850                for i in 0..table.row_count() {
7851                    let row = &table.rows()[i];
7852                    if let Some(where_expr) = &stmt.where_ {
7853                        let cond = self.eval_expr_with_correlated(
7854                            where_expr,
7855                            row,
7856                            &ctx,
7857                            cancel,
7858                            Some(&mut memo),
7859                        )?;
7860                        if !matches!(cond, Value::Bool(true)) {
7861                            continue;
7862                        }
7863                    }
7864                    filtered.push(row);
7865                }
7866            }
7867            let mut agg = aggregate::run(stmt, &filtered, schema_cols, Some(alias))?;
7868            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7869            return Ok(QueryResult::Rows {
7870                columns: agg.columns,
7871                rows: agg.rows,
7872            });
7873        }
7874
7875        let projection = build_projection(&stmt.items, schema_cols, alias)?;
7876        // v7.19 P5 — single-table SELECT path for SRF
7877        // `SELECT unnest(arr) FROM t` shape. Detect a top-level
7878        // unnest in the projection list. When present, the
7879        // per-row processor emits one output row per array
7880        // element (broadcasting non-SRF projections from the
7881        // same input row). Empty / NULL arrays emit zero rows
7882        // for that input — PG semantics.
7883        let srf_position = projection.iter().position(|p| is_top_level_unnest(&p.expr));
7884
7885        // Materialise the filter pass into `(order_key, projected_row)`
7886        // tuples. The order key is `None` when there's no ORDER BY clause.
7887        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
7888        // v6.2.6 — Memoize per-row WHERE eval shares one cache.
7889        let mut memo = memoize::MemoizeCache::new();
7890        // Inline the per-row work in a closure so the indexed and full-
7891        // scan branches share the body.
7892        let mut process_row = |row: &Row, loop_idx: usize| -> Result<(), EngineError> {
7893            if loop_idx.is_multiple_of(256) {
7894                cancel.check()?;
7895            }
7896            if let Some(where_expr) = &stmt.where_ {
7897                let cond =
7898                    self.eval_expr_with_correlated(where_expr, row, &ctx, cancel, Some(&mut memo))?;
7899                if !matches!(cond, Value::Bool(true)) {
7900                    return Ok(());
7901                }
7902            }
7903            let order_keys = if stmt.order_by.is_empty() {
7904                Vec::new()
7905            } else {
7906                build_order_keys(&stmt.order_by, row, &ctx)?
7907            };
7908            if let Some(srf_idx) = srf_position {
7909                let srf_arg = top_level_unnest_arg(&projection[srf_idx].expr)
7910                    .expect("checked by is_top_level_unnest above");
7911                let arr_val = eval::eval_expr(srf_arg, row, &ctx)?;
7912                let elements = array_value_to_elements(&arr_val)?;
7913                for elem in elements {
7914                    let mut values = Vec::with_capacity(projection.len());
7915                    for (i, p) in projection.iter().enumerate() {
7916                        if i == srf_idx {
7917                            values.push(elem.clone());
7918                        } else {
7919                            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
7920                        }
7921                    }
7922                    tagged.push((order_keys.clone(), Row::new(values)));
7923                }
7924            } else {
7925                let mut values = Vec::with_capacity(projection.len());
7926                for p in &projection {
7927                    values.push(eval::eval_expr(&p.expr, row, &ctx)?);
7928                }
7929                tagged.push((order_keys, Row::new(values)));
7930            }
7931            Ok(())
7932        };
7933        if let Some(rows) = &indexed_rows {
7934            for (loop_idx, cow) in rows.iter().enumerate() {
7935                process_row(cow.as_ref(), loop_idx)?;
7936            }
7937        } else {
7938            for i in 0..table.row_count() {
7939                process_row(&table.rows()[i], i)?;
7940            }
7941        }
7942
7943        if !stmt.order_by.is_empty() {
7944            // Partial-sort fast path: when LIMIT is small relative to
7945            // the row count, select_nth_unstable + sort just the
7946            // prefix is O(n + k log k) instead of O(n log n). DISTINCT
7947            // requires the full sort because de-dup happens after.
7948            // WITH TIES likewise needs the full sort so the tie
7949            // extension can scan past `limit` to find rows that
7950            // share the last-kept row's key.
7951            let keep = if stmt.distinct || stmt.limit_with_ties {
7952                None
7953            } else {
7954                stmt.limit_literal()
7955                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
7956            };
7957            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
7958            partial_sort_tagged(&mut tagged, keep, &descs);
7959        }
7960
7961        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST … WITH TIES` extends
7962        // past the truncated tail through every row that shares the
7963        // last-kept row's ORDER BY key. The tie check uses the
7964        // already-computed `(order_keys, row)` pairs so it matches
7965        // the sort comparator exactly. DISTINCT + WITH TIES falls
7966        // through to the no-ties path (PG also disallows their
7967        // combination; SPG silently drops the tie extension here so
7968        // the customer doesn't see a hard error mid-query — the
7969        // user-visible result is still correct, just narrower).
7970        let output_rows: Vec<Row> = if stmt.limit_with_ties && !stmt.distinct {
7971            apply_offset_and_limit_tagged(
7972                &mut tagged,
7973                stmt.offset_literal(),
7974                stmt.limit_literal(),
7975                true,
7976            );
7977            tagged.into_iter().map(|(_, r)| r).collect()
7978        } else {
7979            let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
7980            if stmt.distinct {
7981                output_rows = dedup_rows(output_rows);
7982            }
7983            apply_offset_and_limit(
7984                &mut output_rows,
7985                stmt.offset_literal(),
7986                stmt.limit_literal(),
7987            );
7988            output_rows
7989        };
7990
7991        let columns: Vec<ColumnSchema> = projection
7992            .into_iter()
7993            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
7994            .collect();
7995
7996        Ok(QueryResult::Rows {
7997            columns,
7998            rows: output_rows,
7999        })
8000    }
8001
8002    /// Multi-table SELECT executor (one or more JOIN peers).
8003    ///
8004    /// v1.10 builds the joined row set up-front via nested-loop joins,
8005    /// then runs WHERE + projection + ORDER BY against the combined
8006    /// rows. No index seek. Aggregates and DISTINCT still work because
8007    /// the executor delegates projection through the same shared paths.
8008    #[allow(clippy::too_many_lines)]
8009    /// v7.13.2 — mailrs round-6 S5. Resolve a TableRef into an
8010    /// owned (rows, schema) pair. Catalog tables clone their hot
8011    /// rows + schema; UNNEST table refs evaluate their array
8012    /// expression once and synthesise a single-column row set
8013    /// using the same dispatch as `exec_select_unnest`. Used by
8014    /// the joined-select path so UNNEST can appear in any FROM
8015    /// position, not just as the primary.
8016    fn materialise_table_ref(
8017        &self,
8018        tref: &TableRef,
8019    ) -> Result<(Vec<Row>, Vec<ColumnSchema>), EngineError> {
8020        if let Some(expr) = tref.unnest_expr.as_deref() {
8021            let empty_schema: Vec<ColumnSchema> = Vec::new();
8022            let ctx = EvalContext::new(&empty_schema, None);
8023            let dummy_row = Row::new(Vec::new());
8024            let (elem_dtype, rows) =
8025                match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
8026                    Value::Null => (DataType::Text, Vec::new()),
8027                    Value::TextArray(items) => (
8028                        DataType::Text,
8029                        items
8030                            .into_iter()
8031                            .map(|item| {
8032                                Row::new(alloc::vec![match item {
8033                                    Some(s) => Value::Text(s),
8034                                    None => Value::Null,
8035                                }])
8036                            })
8037                            .collect(),
8038                    ),
8039                    Value::IntArray(items) => (
8040                        DataType::Int,
8041                        items
8042                            .into_iter()
8043                            .map(|item| {
8044                                Row::new(alloc::vec![match item {
8045                                    Some(n) => Value::Int(n),
8046                                    None => Value::Null,
8047                                }])
8048                            })
8049                            .collect(),
8050                    ),
8051                    Value::BigIntArray(items) => (
8052                        DataType::BigInt,
8053                        items
8054                            .into_iter()
8055                            .map(|item| {
8056                                Row::new(alloc::vec![match item {
8057                                    Some(n) => Value::BigInt(n),
8058                                    None => Value::Null,
8059                                }])
8060                            })
8061                            .collect(),
8062                    ),
8063                    other => {
8064                        return Err(EngineError::Unsupported(alloc::format!(
8065                            "unnest() expects an array argument, got {:?}",
8066                            other.data_type()
8067                        )));
8068                    }
8069                };
8070            let alias = tref.alias.clone().unwrap_or_else(|| "unnest".to_string());
8071            let col_name = tref.unnest_column_aliases.first().cloned().unwrap_or(alias);
8072            return Ok((
8073                rows,
8074                alloc::vec![ColumnSchema::new(col_name, elem_dtype, true)],
8075            ));
8076        }
8077        let table =
8078            self.active_catalog()
8079                .get(&tref.name)
8080                .ok_or_else(|| StorageError::TableNotFound {
8081                    name: tref.name.clone(),
8082                })?;
8083        let rows: Vec<Row> = table.rows().iter().cloned().collect();
8084        let cols = table.schema().columns.clone();
8085        Ok((rows, cols))
8086    }
8087
8088    /// v7.17.0 Phase 3.P0-43 — materialise a `FROM` with one or more
8089    /// JOINs into `(combined_schema, filtered_rows)`. The combined
8090    /// schema uses composite `alias.col` column names so the
8091    /// qualifier-aware column resolver finds every join peer by
8092    /// exact match; the filtered rows are the join cross-product
8093    /// after the optional WHERE clause is applied.
8094    ///
8095    /// Shared by `exec_joined_select` and the JOIN branch of
8096    /// `exec_select_with_window`; both paths used to inline the
8097    /// same nested-loop logic and the window path rejected JOIN
8098    /// outright.
8099    fn build_joined_filtered_rows(
8100        &self,
8101        from: &FromClause,
8102        where_: Option<&Expr>,
8103    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
8104        let (primary_rows, primary_cols) = self.materialise_table_ref(&from.primary)?;
8105        let primary_alias = from
8106            .primary
8107            .alias
8108            .as_deref()
8109            .unwrap_or(from.primary.name.as_str())
8110            .to_string();
8111        // v7.17.0 Phase 3.P0-41 — LATERAL peers can't be
8112        // pre-materialised because their rows depend on outer
8113        // columns. For each peer, build either an eager
8114        // (rows, schema) pair or a "lateral" sentinel carrying
8115        // just the schema and the inner SELECT to re-run per
8116        // outer row.
8117        #[allow(clippy::type_complexity)]
8118        let mut joined: Vec<JoinedPeer<'_>> = Vec::new();
8119        for j in &from.joins {
8120            let a = j
8121                .table
8122                .alias
8123                .as_deref()
8124                .unwrap_or(j.table.name.as_str())
8125                .to_string();
8126            if let Some(inner_box) = &j.table.lateral_subquery {
8127                // Probe schema by running the inner SELECT against a
8128                // NULL-padded outer context. The probe gives us the
8129                // projection's column shape; rows materialise per
8130                // left-row below.
8131                let schema = self.lateral_probe_schema(inner_box)?;
8132                joined.push(JoinedPeer {
8133                    eager_rows: None,
8134                    cols: schema,
8135                    alias: a,
8136                    kind: j.kind,
8137                    on: j.on.as_ref(),
8138                    lateral: Some(inner_box.as_ref()),
8139                });
8140            } else {
8141                let (rows, cols) = self.materialise_table_ref(&j.table)?;
8142                joined.push(JoinedPeer {
8143                    eager_rows: Some(rows),
8144                    cols,
8145                    alias: a,
8146                    kind: j.kind,
8147                    on: j.on.as_ref(),
8148                    lateral: None,
8149                });
8150            }
8151        }
8152        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
8153        for col in &primary_cols {
8154            combined_schema.push(ColumnSchema::new(
8155                alloc::format!("{primary_alias}.{}", col.name),
8156                col.ty,
8157                col.nullable,
8158            ));
8159        }
8160        for peer in &joined {
8161            for col in &peer.cols {
8162                combined_schema.push(ColumnSchema::new(
8163                    alloc::format!("{}.{}", peer.alias, col.name),
8164                    col.ty,
8165                    col.nullable,
8166                ));
8167            }
8168        }
8169        let ctx = EvalContext::new(&combined_schema, None);
8170        let mut working: Vec<Row> = primary_rows;
8171        // Track the per-row width consumed by the outer left side so
8172        // each lateral evaluation sees the correct schema slice.
8173        let mut consumed_cols = primary_cols.len();
8174        for peer in &joined {
8175            let right_arity = peer.cols.len();
8176            let mut next: Vec<Row> = Vec::new();
8177            for left in &working {
8178                let mut left_matched = false;
8179                let per_left_rrows: alloc::borrow::Cow<'_, [Row]> = match peer.lateral {
8180                    Some(inner) => {
8181                        // Substitute outer columns and run the inner
8182                        // SELECT against the current left row's slice
8183                        // of the combined schema.
8184                        let outer_schema = &combined_schema[..consumed_cols];
8185                        let rows = self.materialise_lateral_for_outer(inner, outer_schema, left)?;
8186                        alloc::borrow::Cow::Owned(rows)
8187                    }
8188                    None => {
8189                        let r = peer.eager_rows.as_ref().expect("non-lateral peer eager");
8190                        alloc::borrow::Cow::Borrowed(r.as_slice())
8191                    }
8192                };
8193                for right in per_left_rrows.as_ref() {
8194                    let mut combined_vals = left.values.clone();
8195                    combined_vals.extend(right.values.iter().cloned());
8196                    let combined = Row::new(combined_vals);
8197                    let keep = if let Some(on_expr) = peer.on {
8198                        let cond = eval::eval_expr(on_expr, &combined, &ctx)?;
8199                        matches!(cond, Value::Bool(true))
8200                    } else {
8201                        true
8202                    };
8203                    if keep {
8204                        next.push(combined);
8205                        left_matched = true;
8206                    }
8207                }
8208                if !left_matched && matches!(peer.kind, JoinKind::Left) {
8209                    let mut combined_vals = left.values.clone();
8210                    for _ in 0..right_arity {
8211                        combined_vals.push(Value::Null);
8212                    }
8213                    next.push(Row::new(combined_vals));
8214                }
8215            }
8216            working = next;
8217            consumed_cols += right_arity;
8218            debug_assert!(consumed_cols <= combined_schema.len());
8219        }
8220        let mut filtered: Vec<Row> = Vec::new();
8221        for row in working {
8222            if let Some(where_expr) = where_ {
8223                let cond = eval::eval_expr(where_expr, &row, &ctx)?;
8224                if !matches!(cond, Value::Bool(true)) {
8225                    continue;
8226                }
8227            }
8228            filtered.push(row);
8229        }
8230        Ok((combined_schema, filtered))
8231    }
8232
8233    /// v7.17.0 Phase 3.P0-41 — probe a LATERAL subquery's projection
8234    /// schema by running it once with a NULL-padded outer context.
8235    /// The probe never materialises real outer rows; it just executes
8236    /// the inner SELECT with `outer_alias.col` references substituted
8237    /// to NULL so the projection's type inference is exercised.
8238    fn lateral_probe_schema(
8239        &self,
8240        inner: &SelectStatement,
8241    ) -> Result<Vec<ColumnSchema>, EngineError> {
8242        // Substitute every qualified column reference whose qualifier
8243        // does NOT match an in-subquery FROM alias with NULL. The
8244        // safest probe is to walk the inner SELECT and replace any
8245        // `<qual>.<col>` whose qual isn't bound inside the subquery
8246        // with a Null literal. For the v7.17 probe we just run the
8247        // unmodified subquery and surface the columns; if it fails
8248        // (e.g. references an outer column the probe can't resolve),
8249        // we synthesise a best-effort schema from the SELECT items
8250        // by inferring a single Text-typed column per projection.
8251        match self.execute_readonly_select_for_lateral_probe(inner) {
8252            Ok(QueryResult::Rows { columns, .. }) => Ok(columns),
8253            // Best-effort fallback: each SELECT item becomes a TEXT
8254            // column. Real schemas only differ when the inner SELECT
8255            // references outer columns at projection-time; those
8256            // queries surface via the substitution path during
8257            // per-row execution and still return the right values.
8258            _ => {
8259                let mut out: Vec<ColumnSchema> = Vec::new();
8260                for (i, item) in inner.items.iter().enumerate() {
8261                    let name = match item {
8262                        SelectItem::Expr { alias: Some(a), .. } => a.clone(),
8263                        SelectItem::Expr { expr, .. } => synth_lateral_col_name(expr, i),
8264                        SelectItem::Wildcard => alloc::format!("col{i}"),
8265                    };
8266                    out.push(ColumnSchema::new(name, DataType::Text, true));
8267                }
8268                Ok(out)
8269            }
8270        }
8271    }
8272
8273    /// v7.17.0 Phase 3.P0-41 — try the inner LATERAL subquery against
8274    /// the engine in read-only mode for schema-probe purposes. Failure
8275    /// is expected when the subquery references an outer column the
8276    /// probe can't resolve; the caller falls back to a best-effort
8277    /// schema based on the SELECT items.
8278    fn execute_readonly_select_for_lateral_probe(
8279        &self,
8280        inner: &SelectStatement,
8281    ) -> Result<QueryResult, EngineError> {
8282        self.exec_bare_select_cancel(inner, CancelToken::none())
8283    }
8284
8285    /// v7.17.0 Phase 3.P0-41 — materialise a LATERAL subquery's rows
8286    /// for one outer-row context. Walks the inner SELECT, replaces
8287    /// every `<outer_alias>.<col>` reference whose alias appears in
8288    /// the outer schema with the literal value from the outer row,
8289    /// then runs the rewritten SELECT against the engine.
8290    fn materialise_lateral_for_outer(
8291        &self,
8292        inner: &SelectStatement,
8293        outer_schema: &[ColumnSchema],
8294        outer_row: &Row,
8295    ) -> Result<Vec<Row>, EngineError> {
8296        let mut substituted = inner.clone();
8297        substitute_outer_columns_multi(&mut substituted, outer_row, outer_schema);
8298        let result = self.exec_bare_select_cancel(&substituted, CancelToken::none())?;
8299        match result {
8300            QueryResult::Rows { rows, .. } => Ok(rows),
8301            _ => Err(EngineError::Unsupported(
8302                "LATERAL subquery must be a SELECT (cannot be a write statement)".into(),
8303            )),
8304        }
8305    }
8306
8307    fn exec_joined_select(
8308        &self,
8309        stmt: &SelectStatement,
8310        from: &FromClause,
8311    ) -> Result<QueryResult, EngineError> {
8312        // v7.17.0 Phase 3.P0-43 + P0-41 — delegate the join +
8313        // WHERE materialisation to the shared helper so the LATERAL
8314        // / UNNEST / regular-catalog paths route through one place.
8315        // (`build_joined_filtered_rows` carries LATERAL support as
8316        // of Phase 3.P0-41.) Downstream we still handle aggregate /
8317        // projection / ORDER BY / DISTINCT / LIMIT inline because
8318        // those depend on the SelectStatement's items list.
8319        let (combined_schema, filtered) =
8320            self.build_joined_filtered_rows(from, stmt.where_.as_ref())?;
8321        let ctx = EvalContext::new(&combined_schema, None);
8322        // Aggregate path: handle GROUP BY / aggregate calls over the
8323        // joined+filtered rows.
8324        if aggregate::uses_aggregate(stmt) {
8325            let refs: Vec<&Row> = filtered.iter().collect();
8326            let mut agg = aggregate::run(stmt, &refs, &combined_schema, None)?;
8327            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
8328            return Ok(QueryResult::Rows {
8329                columns: agg.columns,
8330                rows: agg.rows,
8331            });
8332        }
8333
8334        let projection = build_projection(&stmt.items, &combined_schema, "")?;
8335        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
8336        for row in &filtered {
8337            let mut values = Vec::with_capacity(projection.len());
8338            for p in &projection {
8339                values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8340            }
8341            let order_keys = if stmt.order_by.is_empty() {
8342                Vec::new()
8343            } else {
8344                build_order_keys(&stmt.order_by, row, &ctx)?
8345            };
8346            tagged.push((order_keys, Row::new(values)));
8347        }
8348        if !stmt.order_by.is_empty() {
8349            let keep = if stmt.distinct {
8350                None
8351            } else {
8352                stmt.limit_literal()
8353                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
8354            };
8355            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
8356            partial_sort_tagged(&mut tagged, keep, &descs);
8357        }
8358        let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
8359        if stmt.distinct {
8360            output_rows = dedup_rows(output_rows);
8361        }
8362        apply_offset_and_limit(
8363            &mut output_rows,
8364            stmt.offset_literal(),
8365            stmt.limit_literal(),
8366        );
8367        let columns: Vec<ColumnSchema> = projection
8368            .into_iter()
8369            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8370            .collect();
8371        Ok(QueryResult::Rows {
8372            columns,
8373            rows: output_rows,
8374        })
8375    }
8376}
8377
8378/// One row-producing projection: an expression to evaluate, the resulting
8379/// column's user-visible name, its inferred type, and nullability.
8380#[derive(Debug, Clone)]
8381struct ProjectedItem {
8382    expr: Expr,
8383    output_name: String,
8384    ty: DataType,
8385    nullable: bool,
8386}
8387
8388/// Dedupe a row set, preserving first-seen order. `Row`'s `PartialEq` is
8389/// structural (`Vec<Value>` ⇒ pairwise `Value` equality), which gives SQL
8390/// `NULL = NULL → TRUE` and `NaN = NaN → FALSE`. The first agrees with
8391/// the spec's "two NULLs are not distinct"; the second is a tolerated
8392/// quirk for v1 (no NaN literals are reachable from the SQL surface).
8393fn dedup_rows(rows: Vec<Row>) -> Vec<Row> {
8394    let mut out: Vec<Row> = Vec::with_capacity(rows.len());
8395    for r in rows {
8396        if !out.iter().any(|seen| seen == &r) {
8397            out.push(r);
8398        }
8399    }
8400    out
8401}
8402
8403/// Coerce a `Value` to an `f64` sort key for ORDER BY. Numbers map directly;
8404/// NULL sorts last (treated as `+∞`); booleans are 0.0 / 1.0; text uses lex
8405/// order via the byte values; vectors are not sortable.
8406fn value_to_order_key(v: &Value) -> Result<f64, EngineError> {
8407    match v {
8408        Value::Null => Ok(f64::INFINITY),
8409        Value::SmallInt(n) => Ok(f64::from(*n)),
8410        Value::Int(n) => Ok(f64::from(*n)),
8411        Value::Date(d) => Ok(f64::from(*d)),
8412        #[allow(clippy::cast_precision_loss)]
8413        Value::Timestamp(t) => Ok(*t as f64),
8414        // v7.17.0 Phase 3.P0-32 — PG TIME ordered by underlying
8415        // i64 microseconds (matches wall-clock ordering).
8416        #[allow(clippy::cast_precision_loss)]
8417        Value::Time(us) => Ok(*us as f64),
8418        // v7.17.0 Phase 3.P0-33 — MySQL YEAR ordered by underlying
8419        // u16 (matches calendar ordering; zero-year sentinel
8420        // sorts before 1901).
8421        Value::Year(y) => Ok(f64::from(*y)),
8422        // v7.17.0 Phase 3.P0-34 — PG TIMETZ ordered by the
8423        // UTC-equivalent microseconds (local wall - offset). Two
8424        // values for the same physical instant in different zones
8425        // sort equal — matches PG TIMETZ index behaviour.
8426        #[allow(clippy::cast_precision_loss)]
8427        Value::TimeTz { us, offset_secs } => Ok((us - i64::from(*offset_secs) * 1_000_000) as f64),
8428        // v7.17.0 Phase 3.P0-35 — PG MONEY ordered by i64 cents.
8429        #[allow(clippy::cast_precision_loss)]
8430        Value::Money(c) => Ok(*c as f64),
8431        // v7.17.0 Phase 3.P0-38 — range ordering is not supported
8432        // in v7.17.0 (needs lex-then-inclusivity tiebreak).
8433        Value::Range { .. } => Err(EngineError::Unsupported(
8434            "ORDER BY of a range value is not supported in v7.17.0".into(),
8435        )),
8436        // v7.17.0 Phase 3.P0-39 — hstore is not orderable.
8437        Value::Hstore(_) => Err(EngineError::Unsupported(
8438            "ORDER BY of a hstore value is not supported".into(),
8439        )),
8440        // v7.17.0 Phase 3.P0-40 — 2D arrays not orderable.
8441        Value::IntArray2D(_) | Value::BigIntArray2D(_) | Value::TextArray2D(_) => Err(
8442            EngineError::Unsupported("ORDER BY of a 2D array is not supported in v7.17.0".into()),
8443        ),
8444        #[allow(clippy::cast_precision_loss)]
8445        Value::Numeric { scaled, scale } => {
8446            // Scaled integer / 10^scale, computed via f64 for sort
8447            // ordering only. Precision losses here only matter for
8448            // ORDER BY tie-breaks well past 15 significant digits.
8449            // `f64::powi` lives in std; we hand-roll the loop so the
8450            // no_std engine crate doesn't need it.
8451            let mut divisor = 1.0_f64;
8452            for _ in 0..*scale {
8453                divisor *= 10.0;
8454            }
8455            Ok((*scaled as f64) / divisor)
8456        }
8457        #[allow(clippy::cast_precision_loss)]
8458        Value::BigInt(n) => Ok(*n as f64),
8459        Value::Float(x) => Ok(*x),
8460        Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
8461        Value::Text(s) => {
8462            // Lex order by codepoints — good enough for ORDER BY name.
8463            // Map first 8 bytes packed into u64 as a coarse key; ties fall to
8464            // partial_cmp Equal. v1.x can swap in a real string comparator.
8465            let mut key: u64 = 0;
8466            for &b in s.as_bytes().iter().take(8) {
8467                key = (key << 8) | u64::from(b);
8468            }
8469            #[allow(clippy::cast_precision_loss)]
8470            Ok(key as f64)
8471        }
8472        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
8473            Err(EngineError::Unsupported(
8474                "ORDER BY of a raw vector column is not meaningful — use `<->`".into(),
8475            ))
8476        }
8477        Value::Interval { .. } => Err(EngineError::Unsupported(
8478            "ORDER BY of an INTERVAL is not supported in v2.11 \
8479             (months vs micros has no single canonical ordering)"
8480                .into(),
8481        )),
8482        Value::Json(_) => Err(EngineError::Unsupported(
8483            "ORDER BY of a JSON value is not supported — cast the document to text first".into(),
8484        )),
8485        // v7.5.0 — Value is #[non_exhaustive]; future variants need
8486        // an explicit ORDER BY mapping. Surface as Unsupported until
8487        // engine support is added.
8488        _ => Err(EngineError::Unsupported(
8489            "ORDER BY of this value type is not supported".into(),
8490        )),
8491    }
8492}
8493
8494/// Try to plan a WHERE clause as an equality lookup against an existing
8495/// index. Returns the candidate row indices on success; `None` means the
8496/// caller should fall back to a full scan.
8497///
8498/// v0.8 recognises a single top-level `col = literal` (in either operand
8499/// order). AND chains and range scans land in later milestones.
8500/// Look for `ORDER BY col <dist-op> literal LIMIT k` against an
8501/// NSW-indexed vector column. Recognised distance ops: `<->` (L2),
8502/// `<#>` (inner product), `<=>` (cosine). When a WHERE clause is
8503/// present, the planner does an "over-fetch and filter" pass — it
8504/// asks the graph for `k * over_fetch` candidates, evaluates WHERE
8505/// against each, and trims back to `k`. Returns the row indices in
8506/// ascending-distance order when the plan applies.
8507fn try_nsw_knn(
8508    stmt: &SelectStatement,
8509    table: &Table,
8510    schema_cols: &[ColumnSchema],
8511    table_alias: &str,
8512) -> Option<Vec<usize>> {
8513    if stmt.distinct {
8514        return None;
8515    }
8516    let limit = usize::try_from(stmt.limit_literal()?).ok()?;
8517    if limit == 0 {
8518        return None;
8519    }
8520    // v6.4.0 — NSW kNN dispatch needs a single ORDER BY key on the
8521    // distance metric. Multi-key ORDER BY falls through to the
8522    // generic sort path.
8523    if stmt.order_by.len() != 1 {
8524        return None;
8525    }
8526    let order = &stmt.order_by[0];
8527    // NSW kNN returns rows ascending by distance — DESC inverts the
8528    // natural order, so the planner can't handle it without a sort
8529    // pass. Fall back to the generic ORDER BY path.
8530    if order.desc {
8531        return None;
8532    }
8533    let Expr::Binary { lhs, op, rhs } = &order.expr else {
8534        return None;
8535    };
8536    let metric = match op {
8537        BinOp::L2Distance => spg_storage::NswMetric::L2,
8538        BinOp::InnerProduct => spg_storage::NswMetric::InnerProduct,
8539        BinOp::CosineDistance => spg_storage::NswMetric::Cosine,
8540        _ => return None,
8541    };
8542    // Accept both `col <op> literal` and `literal <op> col`.
8543    let ((Expr::Column(col), literal) | (literal, Expr::Column(col))) =
8544        (lhs.as_ref(), rhs.as_ref())
8545    else {
8546        return None;
8547    };
8548    if let Some(q) = &col.qualifier
8549        && q != table_alias
8550    {
8551        return None;
8552    }
8553    let col_pos = schema_cols.iter().position(|s| s.name == col.name)?;
8554    let query = literal_to_vector(literal)?;
8555    let idx = spg_storage::nsw_index_on(table, col_pos)?;
8556    if let Some(where_expr) = &stmt.where_ {
8557        // Over-fetch and filter. The factor (10×) is a heuristic that
8558        // covers typical selectivity for the corpus tests; v2.x will
8559        // make it configurable.
8560        let over_fetch = limit.saturating_mul(10).max(NSW_OVER_FETCH_FLOOR);
8561        let candidates = spg_storage::nsw_query(table, &idx.name, &query, over_fetch, metric);
8562        let ctx = EvalContext::new(schema_cols, Some(table_alias));
8563        let mut kept: Vec<usize> = Vec::with_capacity(limit);
8564        for i in candidates {
8565            let row = &table.rows()[i];
8566            let cond = eval::eval_expr(where_expr, row, &ctx).ok()?;
8567            if matches!(cond, Value::Bool(true)) {
8568                kept.push(i);
8569                if kept.len() >= limit {
8570                    break;
8571                }
8572            }
8573        }
8574        Some(kept)
8575    } else {
8576        Some(spg_storage::nsw_query(
8577            table, &idx.name, &query, limit, metric,
8578        ))
8579    }
8580}
8581
8582/// Lower bound on the over-fetch pool when WHERE is present — even
8583/// for tiny `LIMIT 1` queries we keep enough candidates to absorb a
8584/// few WHERE rejections.
8585const NSW_OVER_FETCH_FLOOR: usize = 32;
8586
8587/// Pull a `Vec<f32>` out of a literal-or-cast expression. Returns
8588/// `None` for anything we can't fold at plan time.
8589fn literal_to_vector(e: &Expr) -> Option<Vec<f32>> {
8590    match e {
8591        Expr::Literal(Literal::Vector(v)) => Some(v.clone()),
8592        Expr::Cast { expr, .. } => literal_to_vector(expr),
8593        _ => None,
8594    }
8595}
8596
8597/// Materialise rows in a planner-supplied order (used by the NSW path)
8598/// without re-running ORDER BY. The projection + LIMIT slot mirror the
8599/// equivalent block in `exec_bare_select`.
8600fn materialise_in_order(
8601    stmt: &SelectStatement,
8602    table: &Table,
8603    schema_cols: &[ColumnSchema],
8604    table_alias: &str,
8605    ordered_rows: &[usize],
8606) -> Result<QueryResult, EngineError> {
8607    let ctx = EvalContext::new(schema_cols, Some(table_alias));
8608    let projection = build_projection(&stmt.items, schema_cols, table_alias)?;
8609    let mut output_rows: Vec<Row> = Vec::with_capacity(ordered_rows.len());
8610    for &i in ordered_rows {
8611        let row = &table.rows()[i];
8612        let mut values = Vec::with_capacity(projection.len());
8613        for p in &projection {
8614            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8615        }
8616        output_rows.push(Row::new(values));
8617    }
8618    apply_offset_and_limit(
8619        &mut output_rows,
8620        stmt.offset_literal(),
8621        stmt.limit_literal(),
8622    );
8623    let columns: Vec<ColumnSchema> = projection
8624        .into_iter()
8625        .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8626        .collect();
8627    Ok(QueryResult::Rows {
8628        columns,
8629        rows: output_rows,
8630    })
8631}
8632
8633/// v7.20 P4 — hot-row POSITION seek for the mutation paths
8634/// (UPDATE / DELETE index their planned writes by position in
8635/// `table.rows()`, so the Cow-row shape `try_index_seek`
8636/// returns doesn't fit). Same top-level-AND recursion and
8637/// col=literal resolution; the caller re-applies the full WHERE
8638/// to every returned row so the index only narrows candidates.
8639///
8640/// Returns `None` (→ caller full-scans) when no equality leaf
8641/// hits an index OR any matching locator lives in the cold tier
8642/// — the mutation paths operate on hot rows, and the PK
8643/// promote-then-walk upstream already handles the
8644/// cold-single-row case.
8645fn try_index_seek_positions(
8646    where_expr: &Expr,
8647    schema_cols: &[ColumnSchema],
8648    table: &Table,
8649    table_alias: &str,
8650) -> Option<Vec<usize>> {
8651    if let Expr::Binary {
8652        lhs,
8653        op: BinOp::And,
8654        rhs,
8655    } = where_expr
8656    {
8657        if let Some(p) = try_index_seek_positions(lhs, schema_cols, table, table_alias) {
8658            return Some(p);
8659        }
8660        return try_index_seek_positions(rhs, schema_cols, table, table_alias);
8661    }
8662    let Expr::Binary {
8663        lhs,
8664        op: BinOp::Eq,
8665        rhs,
8666    } = where_expr
8667    else {
8668        return None;
8669    };
8670    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
8671        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
8672    let idx = table.index_on(col_pos)?;
8673    let key = IndexKey::from_value(&value)?;
8674    let locators = idx.lookup_eq(&key);
8675    let mut out = Vec::with_capacity(locators.len());
8676    for loc in locators {
8677        match *loc {
8678            spg_storage::RowLocator::Hot(i) => out.push(i),
8679            spg_storage::RowLocator::Cold { .. } => return None,
8680        }
8681    }
8682    Some(out)
8683}
8684
8685fn try_index_seek<'a>(
8686    where_expr: &Expr,
8687    schema_cols: &[ColumnSchema],
8688    catalog: &'a Catalog,
8689    table: &'a Table,
8690    table_alias: &str,
8691) -> Option<Vec<Cow<'a, Row>>> {
8692    // v7.11.3 — recurse through top-level `AND` so a PG-style
8693    // composite predicate like `WHERE id = 1 AND created_at > $1`
8694    // still hits the index on `id`. The caller re-applies the
8695    // full WHERE expression to each returned row, so dropping the
8696    // residual conjuncts here is correct — the index just narrows
8697    // the candidate set.
8698    if let Expr::Binary {
8699        lhs,
8700        op: BinOp::And,
8701        rhs,
8702    } = where_expr
8703    {
8704        // Try LHS first (typical convention: leading equality on
8705        // the indexed column comes first in user-written SQL).
8706        if let Some(rows) = try_index_seek(lhs, schema_cols, catalog, table, table_alias) {
8707            return Some(rows);
8708        }
8709        return try_index_seek(rhs, schema_cols, catalog, table, table_alias);
8710    }
8711    let Expr::Binary {
8712        lhs,
8713        op: BinOp::Eq,
8714        rhs,
8715    } = where_expr
8716    else {
8717        return None;
8718    };
8719    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
8720        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
8721    let idx = table.index_on(col_pos)?;
8722    let key = IndexKey::from_value(&value)?;
8723    let locators = idx.lookup_eq(&key);
8724    let table_name = table.schema().name.as_str();
8725    // v5.1: each locator dispatches to either the hot tier (zero-
8726    // copy borrow of `table.rows()[i]`) or a cold-tier segment
8727    // (one page read + dense row decode, ~µs scale). Cold rows are
8728    // returned as `Cow::Owned` so the caller's `&Row` iteration
8729    // doesn't see a tier distinction; pre-freezer (no cold
8730    // segments loaded) every locator is `Hot` and every entry is
8731    // `Cow::Borrowed` — identical cost to the pre-v5.1 path.
8732    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(locators.len());
8733    for loc in locators {
8734        match *loc {
8735            spg_storage::RowLocator::Hot(i) => {
8736                if let Some(row) = table.rows().get(i) {
8737                    out.push(Cow::Borrowed(row));
8738                }
8739            }
8740            spg_storage::RowLocator::Cold { segment_id, .. } => {
8741                if let Some(row) = catalog.resolve_cold_locator(table_name, segment_id, &key) {
8742                    out.push(Cow::Owned(row));
8743                }
8744            }
8745        }
8746    }
8747    Some(out)
8748}
8749
8750/// v7.12.3 — GIN-accelerated candidate seek for `WHERE col @@ <ts_query>`.
8751///
8752/// Recurses through top-level `AND` like [`try_index_seek`] so a
8753/// composite predicate `WHERE search_vector @@ q AND id > $1` still
8754/// hits the GIN index on `search_vector` — the caller re-applies the
8755/// full WHERE expression to each returned candidate, so dropping the
8756/// `id > $1` residual here stays semantically correct.
8757///
8758/// Returns `None` when:
8759///   - no leaf is a `col @@ <rhs>` shape on a GIN-indexed column;
8760///   - the RHS can't be const-evaluated to a `Value::TsQuery`
8761///     (typically because it references row columns);
8762///   - the resolved `TsQuery` uses query shapes the MVP doesn't
8763///     accelerate (`Not`, `Phrase` — those fall through to full scan).
8764///
8765/// On `Some(rows)` the caller iterates only `rows` and re-evaluates
8766/// the full `@@` predicate per row, so an over-approximate candidate
8767/// set is safe.
8768fn try_gin_seek<'a>(
8769    where_expr: &Expr,
8770    schema_cols: &[ColumnSchema],
8771    catalog: &'a Catalog,
8772    table: &'a Table,
8773    table_alias: &str,
8774    ctx: &eval::EvalContext<'_>,
8775) -> Option<Vec<Cow<'a, Row>>> {
8776    if let Expr::Binary {
8777        lhs,
8778        op: BinOp::And,
8779        rhs,
8780    } = where_expr
8781    {
8782        if let Some(rows) = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx) {
8783            return Some(rows);
8784        }
8785        return try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx);
8786    }
8787    // v7.17.0 Phase 3.P0-44 — MySQL `MATCH(col1, col2) AGAINST (...)`
8788    // desugars into `(to_tsvector(col1) @@ q) OR (to_tsvector(col2) @@ q)`
8789    // in the parser. To accelerate the multi-column case, walk OR the same
8790    // way we walk AND: only emit a candidate set if BOTH sides can seek
8791    // (otherwise the OR result is unbounded and we must fall through to
8792    // the full scan). Candidates are union'd; the caller's WHERE re-eval
8793    // verifies the full predicate per row, so duplicates / supersets stay
8794    // semantically safe.
8795    if let Expr::Binary {
8796        lhs,
8797        op: BinOp::Or,
8798        rhs,
8799    } = where_expr
8800    {
8801        let left = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx)?;
8802        let right = try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx)?;
8803        let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(left.len() + right.len());
8804        out.extend(left);
8805        out.extend(right);
8806        return Some(out);
8807    }
8808    let Expr::Binary {
8809        lhs,
8810        op: BinOp::TsMatch,
8811        rhs,
8812    } = where_expr
8813    else {
8814        return None;
8815    };
8816    // Either side can be the column; pgvector idiom (`vec @@ q`)
8817    // hits the first arm, FROM-clause-derived (`plainto_tsquery($1)
8818    // q ... WHERE search_vector @@ q`) the same. CROSS JOIN derived
8819    // tables resolve `q` to a Column too.
8820    let (col_pos, query) = resolve_gin_col_query(lhs, rhs, schema_cols, table_alias, ctx)
8821        .or_else(|| resolve_gin_col_query(rhs, lhs, schema_cols, table_alias, ctx))?;
8822    // v7.17.0 Phase 3.P0-44 — MySQL `FULLTEXT KEY` builds a
8823    // `IndexKind::GinFulltext` posting list (Phase 2.2). It shares
8824    // the same `gin_lookup_word` shape as the tsvector-typed GIN,
8825    // so the MATCH-AGAINST `@@` predicate (desugared by the parser
8826    // into `to_tsvector(col) @@ plainto_tsquery('term')`) routes
8827    // through the same candidate-set seek.
8828    let idx = table
8829        .indices()
8830        .iter()
8831        .find(|i| i.column_position == col_pos && (i.is_gin() || i.is_gin_fulltext()))?;
8832    let candidates = gin_query_candidates(idx, &query)?;
8833    let _ = catalog; // cold-tier row resolution unused in MVP; see below.
8834    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(candidates.len());
8835    for loc in candidates {
8836        match loc {
8837            spg_storage::RowLocator::Hot(i) => {
8838                if let Some(row) = table.rows().get(i) {
8839                    out.push(Cow::Borrowed(row));
8840                }
8841            }
8842            // GIN cold-tier rows in the MVP: skipped, matching the
8843            // full-scan `@@` path which itself only iterates
8844            // `table.rows()` (hot tier). When v7.13+ adds cold-tier
8845            // scan-time materialisation for `@@`, the parallel
8846            // resolution lands here; until then both paths see the
8847            // same hot-only candidate set so correctness is preserved.
8848            spg_storage::RowLocator::Cold { .. } => {}
8849        }
8850    }
8851    Some(out)
8852}
8853
8854/// v7.15.0 — trigram-GIN-accelerated candidate seek for
8855/// `WHERE col LIKE '<pat>'` and `WHERE col ILIKE '<pat>'` when
8856/// the column has a `gin_trgm_ops` GIN index.
8857///
8858/// Walks top-level `AND` so multi-predicate WHEREs (`col LIKE
8859/// 'foo%' AND id > 1`) still hit the trigram index; the caller
8860/// re-evaluates the full WHERE per candidate row, so dropping
8861/// non-LIKE conjuncts here stays semantically correct.
8862///
8863/// Returns `None` when:
8864///   - no leaf is `col LIKE/ILIKE <literal>` on a trigram-GIN-
8865///     indexed column;
8866///   - the pattern's literal runs are too short to constrain
8867///     (pattern decomposes into `< 3`-char runs, e.g. `%ab%`);
8868///   - the pattern doesn't const-evaluate to a TEXT.
8869fn try_trgm_seek<'a>(
8870    where_expr: &Expr,
8871    schema_cols: &[ColumnSchema],
8872    table: &'a Table,
8873    table_alias: &str,
8874) -> Option<Vec<Cow<'a, Row>>> {
8875    if let Expr::Binary {
8876        lhs,
8877        op: BinOp::And,
8878        rhs,
8879    } = where_expr
8880    {
8881        if let Some(rows) = try_trgm_seek(lhs, schema_cols, table, table_alias) {
8882            return Some(rows);
8883        }
8884        return try_trgm_seek(rhs, schema_cols, table, table_alias);
8885    }
8886    // LIKE node is what carries the column reference + pattern.
8887    // ILIKE is the same AST node — PG's LIKE/ILIKE both lower
8888    // through `Expr::Like { expr, pattern, negated }`. The trigram
8889    // index posting-list keys are already lower-cased and
8890    // case-folded, so we only need the pattern's literal runs.
8891    let Expr::Like { expr, pattern, .. } = where_expr else {
8892        return None;
8893    };
8894    // Column side.
8895    let Expr::Column(c) = expr.as_ref() else {
8896        return None;
8897    };
8898    if let Some(q) = &c.qualifier
8899        && q != table_alias
8900    {
8901        return None;
8902    }
8903    let col_pos = schema_cols
8904        .iter()
8905        .position(|s| s.name.eq_ignore_ascii_case(&c.name))?;
8906    // Index must exist on that column AND be a trigram-GIN.
8907    let idx = table
8908        .indices()
8909        .iter()
8910        .find(|i| i.column_position == col_pos && i.is_gin_trgm())?;
8911    // Pattern side must be a literal TEXT — anything else (column
8912    // ref, function call, parameter that hasn't been bound yet)
8913    // falls through to full scan.
8914    let Expr::Literal(spg_sql::ast::Literal::String(pat)) = pattern.as_ref() else {
8915        return None;
8916    };
8917    let trigrams = spg_storage::trgm::trigrams_from_like_pattern(pat)?;
8918    // Intersect every trigram's posting list. Empty intersection
8919    // → empty candidate set (caller short-circuits its row loop).
8920    let mut iter = trigrams.iter();
8921    let first = iter.next()?;
8922    let mut acc: Vec<spg_storage::RowLocator> = {
8923        let mut v = idx.gin_trgm_lookup(first).to_vec();
8924        v.sort_by_key(locator_sort_key);
8925        v.dedup_by_key(|l| locator_sort_key(l));
8926        v
8927    };
8928    for tri in iter {
8929        let mut next: Vec<spg_storage::RowLocator> = idx.gin_trgm_lookup(tri).to_vec();
8930        next.sort_by_key(locator_sort_key);
8931        next.dedup_by_key(|l| locator_sort_key(l));
8932        // Sorted-merge intersection.
8933        let mut merged: Vec<spg_storage::RowLocator> =
8934            Vec::with_capacity(acc.len().min(next.len()));
8935        let (mut i, mut j) = (0usize, 0usize);
8936        while i < acc.len() && j < next.len() {
8937            let lk = locator_sort_key(&acc[i]);
8938            let rk = locator_sort_key(&next[j]);
8939            match lk.cmp(&rk) {
8940                core::cmp::Ordering::Less => i += 1,
8941                core::cmp::Ordering::Greater => j += 1,
8942                core::cmp::Ordering::Equal => {
8943                    merged.push(acc[i]);
8944                    i += 1;
8945                    j += 1;
8946                }
8947            }
8948        }
8949        acc = merged;
8950        if acc.is_empty() {
8951            break;
8952        }
8953    }
8954    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(acc.len());
8955    for loc in acc {
8956        if let spg_storage::RowLocator::Hot(i) = loc
8957            && let Some(row) = table.rows().get(i)
8958        {
8959            out.push(Cow::Borrowed(row));
8960        }
8961        // Cold-tier rows: skipped in MVP (same as try_gin_seek).
8962    }
8963    Some(out)
8964}
8965
8966/// v7.12.3 — extract `(column_position, TsQueryAst)` when one side of
8967/// the binary is a column reference to a GIN-indexed tsvector column
8968/// and the other side const-evaluates to a `Value::TsQuery`. Returns
8969/// `None` if the column reference is for the wrong table alias, or if
8970/// the RHS expression depends on row data.
8971fn resolve_gin_col_query(
8972    col_side: &Expr,
8973    query_side: &Expr,
8974    schema_cols: &[ColumnSchema],
8975    table_alias: &str,
8976    ctx: &eval::EvalContext<'_>,
8977) -> Option<(usize, spg_storage::TsQueryAst)> {
8978    // v7.17.0 Phase 3.P0-44 — the MATCH AGAINST desugar wraps the
8979    // column in `to_tsvector('simple', col)`, so we peel that wrapper
8980    // before the column lookup. Direct `col @@ tsquery` paths (the
8981    // tsvector-typed v7.12 surface) skip the wrapper entirely.
8982    let column = match col_side {
8983        Expr::Column(c) => c,
8984        Expr::FunctionCall { name, args }
8985            if name.eq_ignore_ascii_case("to_tsvector") && !args.is_empty() =>
8986        {
8987            // PG `to_tsvector` accepts either `to_tsvector(col)` or
8988            // `to_tsvector(config, col)`. In both shapes the column
8989            // we care about is the final argument.
8990            if let Expr::Column(c) = args.last().unwrap() {
8991                c
8992            } else {
8993                return None;
8994            }
8995        }
8996        _ => return None,
8997    };
8998    let c = column;
8999    if let Some(q) = &c.qualifier
9000        && q != table_alias
9001    {
9002        return None;
9003    }
9004    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
9005    // Const-evaluate the query side with an empty row — fails fast
9006    // (with a `ColumnNotFound` / similar) if the expression actually
9007    // depends on row data, which is exactly the bail signal we want.
9008    let empty_row = Row::new(Vec::new());
9009    let v = eval::eval_expr(query_side, &empty_row, ctx).ok()?;
9010    let Value::TsQuery(q) = v else { return None };
9011    Some((pos, q))
9012}
9013
9014/// v7.12.3 — walk a `TsQueryAst` against an [`IndexKind::Gin`] index
9015/// to produce a candidate row-locator set. Returns `None` for query
9016/// shapes the MVP doesn't accelerate (`Not` / `Phrase` — both bail to
9017/// full scan since their semantics need either complementation across
9018/// the whole row set or positional verification beyond what the
9019/// posting list carries).
9020///
9021/// Candidate sets are over-approximate — the caller re-applies the
9022/// full `@@` predicate per row, so reporting "row was in some
9023/// posting list" without verifying positions / weights stays correct.
9024fn gin_query_candidates(
9025    idx: &spg_storage::Index,
9026    query: &spg_storage::TsQueryAst,
9027) -> Option<Vec<spg_storage::RowLocator>> {
9028    use spg_storage::TsQueryAst;
9029    match query {
9030        TsQueryAst::Term { word, .. } => {
9031            let mut v: Vec<spg_storage::RowLocator> = idx.gin_lookup_word(word).to_vec();
9032            v.sort_by_key(locator_sort_key);
9033            v.dedup_by_key(|l| locator_sort_key(l));
9034            Some(v)
9035        }
9036        TsQueryAst::And(l, r) => {
9037            let mut left = gin_query_candidates(idx, l)?;
9038            let mut right = gin_query_candidates(idx, r)?;
9039            left.sort_by_key(locator_sort_key);
9040            right.sort_by_key(locator_sort_key);
9041            // Sorted-merge intersection.
9042            let mut out: Vec<spg_storage::RowLocator> = Vec::new();
9043            let (mut i, mut j) = (0usize, 0usize);
9044            while i < left.len() && j < right.len() {
9045                let lk = locator_sort_key(&left[i]);
9046                let rk = locator_sort_key(&right[j]);
9047                match lk.cmp(&rk) {
9048                    core::cmp::Ordering::Less => i += 1,
9049                    core::cmp::Ordering::Greater => j += 1,
9050                    core::cmp::Ordering::Equal => {
9051                        out.push(left[i]);
9052                        i += 1;
9053                        j += 1;
9054                    }
9055                }
9056            }
9057            Some(out)
9058        }
9059        TsQueryAst::Or(l, r) => {
9060            let mut out = gin_query_candidates(idx, l)?;
9061            out.extend(gin_query_candidates(idx, r)?);
9062            out.sort_by_key(locator_sort_key);
9063            out.dedup_by_key(|l| locator_sort_key(l));
9064            Some(out)
9065        }
9066        // Not / Phrase bail to full scan in the MVP. Not needs
9067        // complementation against the whole row set (not represented
9068        // in the posting-list view); Phrase needs positional
9069        // verification beyond what `word → rows` carries.
9070        TsQueryAst::Not(_) | TsQueryAst::Phrase { .. } => None,
9071    }
9072}
9073
9074/// v7.12.3 — total ordering on `RowLocator` for sort/dedup purposes
9075/// inside the GIN intersection / union loops. Hot rows order by their
9076/// row index; Cold rows order after all Hot rows, then by
9077/// `(segment_id, the cold sub-key)`.
9078fn locator_sort_key(l: &spg_storage::RowLocator) -> (u8, u64, u64) {
9079    match *l {
9080        spg_storage::RowLocator::Hot(i) => (0, i as u64, 0),
9081        spg_storage::RowLocator::Cold {
9082            segment_id,
9083            page_offset,
9084        } => (1, u64::from(segment_id), u64::from(page_offset)),
9085    }
9086}
9087
9088/// v5.2.3: extract `(column_position, IndexKey)` when `where_expr`
9089/// is a simple `col = literal` predicate suitable for a `BTree` index
9090/// seek. Used by `exec_update_cancel` / `exec_delete_cancel` to
9091/// decide whether a write touches a cold-tier row (which requires
9092/// promote-on-write / shadow-on-delete) before falling through to
9093/// the hot-tier row walk.
9094///
9095/// Returns `None` for any predicate shape the planner can't push
9096/// down to an index seek — complex WHERE clauses always take the
9097/// hot-only path (cold rows are immutable to non-indexed writes
9098/// until a future scan-fanout sub-version).
9099fn try_pk_predicate(
9100    where_expr: &Expr,
9101    schema_cols: &[ColumnSchema],
9102    table_alias: &str,
9103) -> Option<(usize, IndexKey)> {
9104    let Expr::Binary {
9105        lhs,
9106        op: BinOp::Eq,
9107        rhs,
9108    } = where_expr
9109    else {
9110        return None;
9111    };
9112    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
9113        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
9114    let key = IndexKey::from_value(&value)?;
9115    Some((col_pos, key))
9116}
9117
9118fn resolve_col_literal_pair(
9119    col_side: &Expr,
9120    lit_side: &Expr,
9121    schema_cols: &[ColumnSchema],
9122    table_alias: &str,
9123) -> Option<(usize, Value)> {
9124    let Expr::Column(c) = col_side else {
9125        return None;
9126    };
9127    if let Some(q) = &c.qualifier
9128        && q != table_alias
9129    {
9130        return None;
9131    }
9132    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
9133    let Expr::Literal(l) = lit_side else {
9134        return None;
9135    };
9136    let v = match l {
9137        Literal::Integer(n) => {
9138            if let Ok(small) = i32::try_from(*n) {
9139                Value::Int(small)
9140            } else {
9141                Value::BigInt(*n)
9142            }
9143        }
9144        Literal::Float(x) => Value::Float(*x),
9145        Literal::String(s) => Value::Text(s.clone()),
9146        Literal::Bool(b) => Value::Bool(*b),
9147        Literal::Null => Value::Null,
9148        // Vector and Interval literals can't be used as B-tree index keys.
9149        // Tell the planner to fall back to full-scan.
9150        Literal::Vector(_) | Literal::Interval { .. } => return None,
9151    };
9152    Some((pos, v))
9153}
9154
9155/// Find the schema entry that a SELECT-list `Expr::Column` refers to.
9156/// Mirrors `resolve_column` in `eval.rs`, but returns a proper
9157/// `EngineError` so the projection-build path keeps `UnknownQualifier`
9158/// vs `ColumnNotFound` distinct.
9159fn resolve_projection_column<'a>(
9160    c: &ColumnName,
9161    schema_cols: &'a [ColumnSchema],
9162    table_alias: &str,
9163) -> Result<&'a ColumnSchema, EngineError> {
9164    if let Some(q) = &c.qualifier {
9165        let composite = alloc::format!("{q}.{name}", name = c.name);
9166        if let Some(s) = schema_cols.iter().find(|s| s.name == composite) {
9167            return Ok(s);
9168        }
9169        // Single-table case: the qualifier may equal the active alias —
9170        // then look for the bare column name.
9171        if q == table_alias
9172            && let Some(s) = schema_cols.iter().find(|s| s.name == c.name)
9173        {
9174            return Ok(s);
9175        }
9176        // For multi-table schemas the qualifier is unknown only if no
9177        // column bears the "<q>." prefix. For single-table, the alias
9178        // mismatch alone is enough.
9179        let prefix = alloc::format!("{q}.");
9180        let qualifier_known =
9181            q == table_alias || schema_cols.iter().any(|s| s.name.starts_with(&prefix));
9182        if !qualifier_known {
9183            return Err(EngineError::Eval(EvalError::UnknownQualifier {
9184                qualifier: q.clone(),
9185            }));
9186        }
9187        return Err(EngineError::Eval(EvalError::ColumnNotFound {
9188            name: c.name.clone(),
9189        }));
9190    }
9191    if let Some(s) = schema_cols.iter().find(|s| s.name == c.name) {
9192        return Ok(s);
9193    }
9194    let suffix = alloc::format!(".{name}", name = c.name);
9195    let mut matches = schema_cols.iter().filter(|s| s.name.ends_with(&suffix));
9196    let first = matches.next();
9197    let extra = matches.next();
9198    match (first, extra) {
9199        (Some(s), None) => Ok(s),
9200        (Some(_), Some(_)) => Err(EngineError::Eval(EvalError::TypeMismatch {
9201            detail: alloc::format!("ambiguous column reference: {}", c.name),
9202        })),
9203        _ => Err(EngineError::Eval(EvalError::ColumnNotFound {
9204            name: c.name.clone(),
9205        })),
9206    }
9207}
9208
9209fn build_projection(
9210    items: &[SelectItem],
9211    schema_cols: &[ColumnSchema],
9212    table_alias: &str,
9213) -> Result<Vec<ProjectedItem>, EngineError> {
9214    let mut out = Vec::new();
9215    for item in items {
9216        match item {
9217            SelectItem::Wildcard => {
9218                for col in schema_cols {
9219                    out.push(ProjectedItem {
9220                        expr: Expr::Column(ColumnName {
9221                            qualifier: None,
9222                            name: col.name.clone(),
9223                        }),
9224                        output_name: col.name.clone(),
9225                        ty: col.ty,
9226                        nullable: col.nullable,
9227                    });
9228                }
9229            }
9230            SelectItem::Expr { expr, alias } => {
9231                // Plain column ref keeps full schema info (real type +
9232                // nullability). For compound expressions try the
9233                // describe-side function-return-type table first
9234                // (e.g. `SELECT now()` → Timestamptz, `SELECT
9235                // concat(…)` → Text). Falls back to nullable Text
9236                // for shapes the describe path can't resolve.
9237                if let Expr::Column(c) = expr {
9238                    let sch = resolve_projection_column(c, schema_cols, table_alias)?;
9239                    let output_name = alias.clone().unwrap_or_else(|| c.name.clone());
9240                    out.push(ProjectedItem {
9241                        expr: expr.clone(),
9242                        output_name,
9243                        ty: sch.ty,
9244                        nullable: sch.nullable,
9245                    });
9246                } else if let Some(shape) = describe::describe_expr(expr, schema_cols) {
9247                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
9248                    out.push(ProjectedItem {
9249                        expr: expr.clone(),
9250                        output_name,
9251                        ty: shape.ty,
9252                        nullable: shape.nullable,
9253                    });
9254                } else {
9255                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
9256                    out.push(ProjectedItem {
9257                        expr: expr.clone(),
9258                        output_name,
9259                        ty: DataType::Text,
9260                        nullable: true,
9261                    });
9262                }
9263            }
9264        }
9265    }
9266    Ok(out)
9267}
9268
9269/// Promote an integer to a NUMERIC value at the requested scale.
9270/// Rejects values that, after scaling, would overflow the column's
9271/// precision budget.
9272fn numeric_from_integer(
9273    n: i128,
9274    precision: u8,
9275    scale: u8,
9276    col_name: &str,
9277) -> Result<Value, EngineError> {
9278    let factor = pow10_i128(scale);
9279    let scaled = n.checked_mul(factor).ok_or_else(|| {
9280        EngineError::Unsupported(alloc::format!(
9281            "integer overflow scaling value for column `{col_name}` to scale {scale}"
9282        ))
9283    })?;
9284    check_precision(scaled, precision, col_name)?;
9285    Ok(Value::Numeric { scaled, scale })
9286}
9287
9288/// Float → NUMERIC. Uses round-half-away-from-zero on `x * 10^scale`,
9289/// then verifies the result fits the column's precision.
9290#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
9291fn numeric_from_float(
9292    x: f64,
9293    precision: u8,
9294    scale: u8,
9295    col_name: &str,
9296) -> Result<Value, EngineError> {
9297    if !x.is_finite() {
9298        return Err(EngineError::Unsupported(alloc::format!(
9299            "cannot store non-finite float in NUMERIC column `{col_name}`"
9300        )));
9301    }
9302    let mut factor = 1.0_f64;
9303    for _ in 0..scale {
9304        factor *= 10.0;
9305    }
9306    // Round half-away-from-zero by biasing then casting (`as i128`
9307    // truncates toward zero, so the bias + truncation gives the
9308    // desired rounding). `f64::floor` / `ceil` live in std; we don't
9309    // need them — the cast handles the truncation step.
9310    let shifted = x * factor;
9311    let biased = if shifted >= 0.0 {
9312        shifted + 0.5
9313    } else {
9314        shifted - 0.5
9315    };
9316    // Range-check before casting back to i128 — the cast itself is
9317    // saturating in Rust, which would silently truncate huge inputs.
9318    if !(-1e38..=1e38).contains(&biased) {
9319        return Err(EngineError::Unsupported(alloc::format!(
9320            "value {x} overflows NUMERIC range for column `{col_name}`"
9321        )));
9322    }
9323    let scaled = biased as i128;
9324    check_precision(scaled, precision, col_name)?;
9325    Ok(Value::Numeric { scaled, scale })
9326}
9327
9328/// v7.17.0 Phase 3.P0-67 — parse PG-canonical decimal text into
9329/// `(mantissa: i128, source_scale: u8)`. Accepts optional sign,
9330/// optional integer part, optional fractional part. Rejects
9331/// scientific notation, embedded spaces, locale-specific
9332/// thousand separators. Returns None on bad input — coerce_value
9333/// turns that into a TypeMismatch error.
9334fn parse_numeric_text(s: &str) -> Option<(i128, u8)> {
9335    let s = s.trim();
9336    if s.is_empty() {
9337        return None;
9338    }
9339    let (negative, rest) = match s.as_bytes()[0] {
9340        b'-' => (true, &s[1..]),
9341        b'+' => (false, &s[1..]),
9342        _ => (false, s),
9343    };
9344    if rest.is_empty() {
9345        return None;
9346    }
9347    // Reject scientific notation — bigdecimal collapses it before
9348    // hitting the wire, and we want a clear error if a stray `e`
9349    // sneaks in.
9350    if rest.bytes().any(|b| b == b'e' || b == b'E') {
9351        return None;
9352    }
9353    let (int_part, frac_part) = match rest.find('.') {
9354        Some(idx) => (&rest[..idx], &rest[idx + 1..]),
9355        None => (rest, ""),
9356    };
9357    if int_part.is_empty() && frac_part.is_empty() {
9358        return None;
9359    }
9360    if int_part.bytes().any(|b| !b.is_ascii_digit()) {
9361        return None;
9362    }
9363    if frac_part.bytes().any(|b| !b.is_ascii_digit()) {
9364        return None;
9365    }
9366    let scale_u32 = u32::try_from(frac_part.len()).ok()?;
9367    if scale_u32 > u32::from(u8::MAX) {
9368        return None;
9369    }
9370    let scale = scale_u32 as u8;
9371    let mut digits = alloc::string::String::with_capacity(int_part.len() + frac_part.len() + 1);
9372    if negative {
9373        digits.push('-');
9374    }
9375    digits.push_str(int_part);
9376    digits.push_str(frac_part);
9377    // Strip a leading "+0..0" so parse doesn't choke on "00" etc.
9378    let digits = if digits == "-" {
9379        return None;
9380    } else if digits.is_empty() {
9381        "0"
9382    } else {
9383        digits.as_str()
9384    };
9385    let mantissa: i128 = digits.parse().ok()?;
9386    Some((mantissa, scale))
9387}
9388
9389/// Move a Numeric value from `src_scale` to `dst_scale`. Going up
9390/// multiplies by 10; going down rounds half-away-from-zero.
9391fn numeric_rescale(
9392    scaled: i128,
9393    src_scale: u8,
9394    precision: u8,
9395    dst_scale: u8,
9396    col_name: &str,
9397) -> Result<Value, EngineError> {
9398    let new_scaled = if dst_scale >= src_scale {
9399        let bump = pow10_i128(dst_scale - src_scale);
9400        scaled.checked_mul(bump).ok_or_else(|| {
9401            EngineError::Unsupported(alloc::format!(
9402                "overflow rescaling NUMERIC for column `{col_name}`"
9403            ))
9404        })?
9405    } else {
9406        let drop = pow10_i128(src_scale - dst_scale);
9407        let half = drop / 2;
9408        if scaled >= 0 {
9409            (scaled + half) / drop
9410        } else {
9411            (scaled - half) / drop
9412        }
9413    };
9414    check_precision(new_scaled, precision, col_name)?;
9415    Ok(Value::Numeric {
9416        scaled: new_scaled,
9417        scale: dst_scale,
9418    })
9419}
9420
9421/// Drop the fractional part of a scaled integer, returning the integer
9422/// portion (toward zero). Used for NUMERIC → INT casts.
9423const fn numeric_truncate_to_integer(scaled: i128, scale: u8) -> i128 {
9424    if scale == 0 {
9425        return scaled;
9426    }
9427    let factor = pow10_i128_const(scale);
9428    scaled / factor
9429}
9430
9431/// Verify a scaled NUMERIC value fits the column's declared precision.
9432/// `precision == 0` is the "unconstrained" form (bare `NUMERIC`); we
9433/// skip the check there.
9434fn check_precision(scaled: i128, precision: u8, col_name: &str) -> Result<(), EngineError> {
9435    if precision == 0 {
9436        return Ok(());
9437    }
9438    let limit = pow10_i128(precision);
9439    if scaled.unsigned_abs() >= limit.unsigned_abs() {
9440        return Err(EngineError::Unsupported(alloc::format!(
9441            "NUMERIC value exceeds precision {precision} for column `{col_name}`"
9442        )));
9443    }
9444    Ok(())
9445}
9446
9447const fn pow10_i128_const(p: u8) -> i128 {
9448    let mut acc: i128 = 1;
9449    let mut i = 0;
9450    while i < p {
9451        acc *= 10;
9452        i += 1;
9453    }
9454    acc
9455}
9456
9457fn pow10_i128(p: u8) -> i128 {
9458    pow10_i128_const(p)
9459}
9460
9461/// Walk a parsed `Statement`, swapping any `NOW()` /
9462/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()` function calls for a
9463/// literal cast that wraps the engine's per-statement clock reading.
9464/// When `now_micros` is `None`, calls stay as-is and surface as
9465/// `unknown function` at eval time — keeps the error path explicit.
9466/// v4.10: pre-walk the WHERE / projection / etc. of a SELECT and
9467/// replace every subquery node with a materialised literal. SPG
9468/// only supports uncorrelated subqueries — the inner SELECT does
9469/// not see outer-row columns, so the result is the same for every
9470/// outer row and can be evaluated once.
9471///
9472/// Returns the rewritten statement; the caller passes this to the
9473/// regular row-loop executor which no longer sees Subquery nodes
9474/// in its tree.
9475impl Engine {
9476    /// v4.12 window executor. Implements `ROW_NUMBER` / `RANK` /
9477    /// `DENSE_RANK` and the partition-aware aggregates `SUM` /
9478    /// `AVG` / `COUNT` / `MIN` / `MAX`. The plan is:
9479    /// 1. Apply the WHERE filter.
9480    /// 2. For each unique `WindowFunction` node in the projection,
9481    ///    partition + sort, compute the per-row value.
9482    /// 3. Append the window values as synthetic columns (`__win_N`)
9483    ///    to the row schema.
9484    /// 4. Rewrite the projection to read those columns.
9485    /// 5. Hand off to the regular project / ORDER BY / LIMIT pipe.
9486    #[allow(
9487        clippy::too_many_lines,
9488        clippy::type_complexity,
9489        clippy::needless_range_loop
9490    )] // window-eval is one cohesive pipe; splitting fragments
9491    fn exec_select_with_window(
9492        &self,
9493        stmt: &SelectStatement,
9494        cancel: CancelToken<'_>,
9495    ) -> Result<QueryResult, EngineError> {
9496        let from = stmt.from.as_ref().ok_or_else(|| {
9497            EngineError::Unsupported("window functions require a FROM clause".into())
9498        })?;
9499        // v7.17.0 Phase 3.P0-43 — JOIN + window functions. Phase
9500        // 3.6 rejected this combination outright ("queued for
9501        // v5.x"); P0-43 materialises the join + WHERE through the
9502        // existing nested-loop helper and runs the window pipeline
9503        // on the joined row set with the combined `alias.col`
9504        // schema. The window expressions resolve through the
9505        // qualifier-aware column resolver same as the aggregate /
9506        // projection paths on JOIN.
9507        let (schema_cols_owned, alias_opt): (Vec<ColumnSchema>, Option<&str>);
9508        let filtered: Vec<Row>;
9509        if from.joins.is_empty() {
9510            let primary = &from.primary;
9511            let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
9512                StorageError::TableNotFound {
9513                    name: primary.name.clone(),
9514                }
9515            })?;
9516            let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
9517            schema_cols_owned = table.schema().columns.clone();
9518            alias_opt = Some(alias);
9519            // Materialise WHERE-filtered rows owned so the JOIN
9520            // and single-table paths share a single downstream
9521            // shape. The clone is cheap relative to the window
9522            // computation that follows.
9523            let ctx = self.ev_ctx(&schema_cols_owned, alias_opt);
9524            let mut owned: Vec<Row> = Vec::new();
9525            for (i, row) in table.rows().iter().enumerate() {
9526                if i.is_multiple_of(256) {
9527                    cancel.check()?;
9528                }
9529                if let Some(w) = &stmt.where_ {
9530                    let cond = eval::eval_expr(w, row, &ctx)?;
9531                    if !matches!(cond, Value::Bool(true)) {
9532                        continue;
9533                    }
9534                }
9535                owned.push(row.clone());
9536            }
9537            filtered = owned;
9538        } else {
9539            let (combined_schema, rows) =
9540                self.build_joined_filtered_rows(from, stmt.where_.as_ref())?;
9541            schema_cols_owned = combined_schema;
9542            alias_opt = None;
9543            filtered = rows;
9544        }
9545        let schema_cols = &schema_cols_owned;
9546        let ctx = self.ev_ctx(schema_cols, alias_opt);
9547        let alias = alias_opt.unwrap_or("");
9548        let n_rows = filtered.len();
9549        // Borrow refs into the owned row vec once so the downstream
9550        // `compute_window_partition` call (which takes `&[&Row]`) and
9551        // the per-row eval loops share a single backing buffer.
9552        let filtered_refs: Vec<&Row> = filtered.iter().collect();
9553
9554        // 2) Collect unique window function nodes from projection.
9555        let mut window_nodes: Vec<Expr> = Vec::new();
9556        for item in &stmt.items {
9557            if let SelectItem::Expr { expr, .. } = item {
9558                collect_window_nodes(expr, &mut window_nodes);
9559            }
9560        }
9561
9562        // 3) For each window, compute per-row value.
9563        // Index: same order as window_nodes; for row i, win_vals[w][i].
9564        let mut win_vals: Vec<Vec<Value>> = Vec::with_capacity(window_nodes.len());
9565        for wnode in &window_nodes {
9566            let Expr::WindowFunction {
9567                name,
9568                args,
9569                partition_by,
9570                order_by,
9571                frame,
9572                null_treatment,
9573            } = wnode
9574            else {
9575                unreachable!("collect_window_nodes pushes only WindowFunction");
9576            };
9577            // Compute (partition_key, order_key, original_index) for each row.
9578            let mut indexed: Vec<(Vec<Value>, Vec<(Value, bool)>, usize)> =
9579                Vec::with_capacity(n_rows);
9580            for (i, row) in filtered.iter().enumerate() {
9581                let pkey: Vec<Value> = partition_by
9582                    .iter()
9583                    .map(|p| eval::eval_expr(p, row, &ctx))
9584                    .collect::<Result<_, _>>()?;
9585                let okey: Vec<(Value, bool)> = order_by
9586                    .iter()
9587                    .map(|(e, desc)| eval::eval_expr(e, row, &ctx).map(|v| (v, *desc)))
9588                    .collect::<Result<_, _>>()?;
9589                indexed.push((pkey, okey, i));
9590            }
9591            // Sort by (partition_key, order_key). Partition key uses
9592            // a stable encoded form; order key respects ASC/DESC.
9593            indexed.sort_by(|a, b| {
9594                let p_cmp = partition_key_cmp(&a.0, &b.0);
9595                if p_cmp != core::cmp::Ordering::Equal {
9596                    return p_cmp;
9597                }
9598                order_key_cmp(&a.1, &b.1)
9599            });
9600            // Per-partition compute.
9601            let mut out_vals: Vec<Value> = alloc::vec![Value::Null; n_rows];
9602            let mut p_start = 0;
9603            while p_start < indexed.len() {
9604                let mut p_end = p_start + 1;
9605                while p_end < indexed.len()
9606                    && partition_key_cmp(&indexed[p_start].0, &indexed[p_end].0)
9607                        == core::cmp::Ordering::Equal
9608                {
9609                    p_end += 1;
9610                }
9611                // Compute the function within this partition slice.
9612                compute_window_partition(
9613                    name,
9614                    args,
9615                    !order_by.is_empty(),
9616                    frame.as_ref(),
9617                    *null_treatment,
9618                    &indexed[p_start..p_end],
9619                    &filtered_refs,
9620                    &ctx,
9621                    &mut out_vals,
9622                )?;
9623                p_start = p_end;
9624            }
9625            win_vals.push(out_vals);
9626        }
9627
9628        // 4) Build extended schema: original columns + synthetic.
9629        let mut ext_cols = schema_cols.clone();
9630        for i in 0..window_nodes.len() {
9631            ext_cols.push(ColumnSchema::new(
9632                alloc::format!("__win_{i}"),
9633                DataType::Text, // type doesn't matter for projection eval
9634                true,
9635            ));
9636        }
9637        // 5) Build extended rows: each row gets its window values appended.
9638        let mut ext_rows: Vec<Row> = Vec::with_capacity(n_rows);
9639        for i in 0..n_rows {
9640            let mut values = filtered[i].values.clone();
9641            for w in 0..window_nodes.len() {
9642                values.push(win_vals[w][i].clone());
9643            }
9644            ext_rows.push(Row::new(values));
9645        }
9646        // 6) Rewrite the projection: WindowFunction nodes → Column(__win_N).
9647        let mut rewritten_items: Vec<SelectItem> = Vec::with_capacity(stmt.items.len());
9648        for item in &stmt.items {
9649            let new_item = match item {
9650                SelectItem::Wildcard => SelectItem::Wildcard,
9651                SelectItem::Expr { expr, alias } => {
9652                    let mut e = expr.clone();
9653                    rewrite_window_to_columns(&mut e, &window_nodes);
9654                    SelectItem::Expr {
9655                        expr: e,
9656                        alias: alias.clone(),
9657                    }
9658                }
9659            };
9660            rewritten_items.push(new_item);
9661        }
9662
9663        // 7) Project into final rows. JOIN case uses None so the
9664        // qualifier check in `resolve_column` falls through to the
9665        // composite `alias.col` schema lookup; single-table case
9666        // keeps the bare alias so `bare_col` resolution still
9667        // works for the projection's per-row column references.
9668        let ext_ctx = EvalContext::new(&ext_cols, alias_opt);
9669        let projection = build_projection(&rewritten_items, &ext_cols, alias)?;
9670        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(n_rows);
9671        for (i, row) in ext_rows.iter().enumerate() {
9672            if i.is_multiple_of(256) {
9673                cancel.check()?;
9674            }
9675            let mut values = Vec::with_capacity(projection.len());
9676            for p in &projection {
9677                values.push(eval::eval_expr(&p.expr, row, &ext_ctx)?);
9678            }
9679            let order_keys = if stmt.order_by.is_empty() {
9680                Vec::new()
9681            } else {
9682                let mut keys = Vec::with_capacity(stmt.order_by.len());
9683                for o in &stmt.order_by {
9684                    let mut e = o.expr.clone();
9685                    rewrite_window_to_columns(&mut e, &window_nodes);
9686                    let key = eval::eval_expr(&e, row, &ext_ctx)?;
9687                    keys.push(value_to_order_key(&key)?);
9688                }
9689                keys
9690            };
9691            tagged.push((order_keys, Row::new(values)));
9692        }
9693        // ORDER BY + LIMIT/OFFSET on the projected rows.
9694        if !stmt.order_by.is_empty() {
9695            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
9696            sort_by_keys(&mut tagged, &descs);
9697        }
9698        let mut out_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
9699        apply_offset_and_limit(&mut out_rows, stmt.offset_literal(), stmt.limit_literal());
9700        let final_cols: Vec<ColumnSchema> = projection
9701            .into_iter()
9702            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
9703            .collect();
9704        Ok(QueryResult::Rows {
9705            columns: final_cols,
9706            rows: out_rows,
9707        })
9708    }
9709
9710    /// v4.11: materialise each CTE into a temp table inside a
9711    /// cloned catalog, then run the body SELECT against a fresh
9712    /// engine instance that owns the enriched catalog. The clone
9713    /// is moderately expensive — only paid by CTE-bearing queries.
9714    /// Subqueries inside CTE bodies / the main body resolve as
9715    /// usual; `clock_fn` is propagated so `NOW()` lines up.
9716    /// v7.16.2 — mailrs round-10 A.3. Materialise the
9717    /// `information_schema.*` / `pg_catalog.*` virtual views
9718    /// the SELECT references, then re-execute the SELECT
9719    /// against an enriched catalog where those views are real
9720    /// tables. Same pattern as `exec_with_ctes`. The temp
9721    /// engine carries `meta_views_materialised = true` so its
9722    /// own meta-dispatch short-circuits — without that we'd
9723    /// infinite-recurse since the temp catalog's view name
9724    /// still starts with `__spg_info_` and re-triggers the
9725    /// check.
9726    fn exec_select_with_meta_views(
9727        &self,
9728        stmt: &SelectStatement,
9729        cancel: CancelToken<'_>,
9730    ) -> Result<QueryResult, EngineError> {
9731        let mut needed: alloc::collections::BTreeSet<String> = alloc::collections::BTreeSet::new();
9732        collect_meta_view_names(stmt, &mut needed);
9733        let mut catalog = self.active_catalog().clone();
9734        for view in &needed {
9735            if catalog.get(view).is_some() {
9736                continue;
9737            }
9738            match view.as_str() {
9739                "__spg_info_columns" => {
9740                    let (schema, rows) = synth_information_schema_columns(self.active_catalog());
9741                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9742                }
9743                "__spg_info_tables" => {
9744                    let (schema, rows) = synth_information_schema_tables(self.active_catalog());
9745                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9746                }
9747                "__spg_pg_class" => {
9748                    let (schema, rows) = synth_pg_class(self.active_catalog());
9749                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9750                }
9751                "__spg_pg_attribute" => {
9752                    let (schema, rows) = synth_pg_attribute(self.active_catalog());
9753                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9754                }
9755                // v7.17.0 Phase 3.P0-50 — pg_catalog.pg_type for
9756                // sqlx / SQLAlchemy / Diesel / pgAdmin lookups.
9757                "__spg_pg_type" => {
9758                    let (schema, rows) = synth_pg_type(self.active_catalog());
9759                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9760                }
9761                // v7.17.0 Phase 3.P0-51 — pg_catalog.pg_proc for
9762                // function-name introspection (ORM / pgAdmin).
9763                "__spg_pg_proc" => {
9764                    let (schema, rows) = synth_pg_proc(self.active_catalog());
9765                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9766                }
9767                // v7.17.0 Phase 3.P0-52 — pg_catalog.pg_namespace
9768                // (schema list for admin tools' tree views).
9769                "__spg_pg_namespace" => {
9770                    let (schema, rows) = synth_pg_namespace(self.active_catalog());
9771                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9772                }
9773                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_indexes view
9774                // for pgAdmin / DataGrip "indexes per table" listings.
9775                "__spg_pg_indexes" => {
9776                    let (schema, rows) = synth_pg_indexes(self.active_catalog());
9777                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9778                }
9779                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_index (raw)
9780                // for index introspection by ORM compilers.
9781                "__spg_pg_index" => {
9782                    let (schema, rows) = synth_pg_index_raw(self.active_catalog());
9783                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9784                }
9785                // v7.17.0 Phase 3.P0-54 — pg_catalog.pg_constraint
9786                // for FK / UNIQUE / PK / CHECK introspection.
9787                "__spg_pg_constraint" => {
9788                    let (schema, rows) = synth_pg_constraint(self.active_catalog());
9789                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9790                }
9791                // v7.17.0 Phase 3.P0-55 — pg_catalog.pg_database /
9792                // pg_roles / pg_user. SPG is single-database so
9793                // pg_database surfaces just `postgres`; pg_roles
9794                // / pg_user walk the engine's UserStore.
9795                "__spg_pg_database" => {
9796                    let (schema, rows) = synth_pg_database(self.active_catalog());
9797                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9798                }
9799                "__spg_pg_roles" | "__spg_pg_user" => {
9800                    let (schema, rows) = synth_pg_roles(self);
9801                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9802                }
9803                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_views. PG's
9804                // pg_views surfaces every CREATE VIEW result; SPG
9805                // ships one row per declared view from the catalog.
9806                "__spg_pg_views" => {
9807                    let (schema, rows) = synth_pg_views(self.active_catalog());
9808                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9809                }
9810                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_matviews.
9811                // SPG has no materialised view surface yet so the
9812                // table shares pg_views's schema but stays empty.
9813                "__spg_pg_matviews" => {
9814                    let (schema, _) = synth_pg_views(self.active_catalog());
9815                    materialise_meta_view(&mut catalog, view, schema, Vec::new())?;
9816                }
9817                // v7.17.0 Phase 3.P0-57 — pg_catalog.pg_settings.
9818                "__spg_pg_settings" => {
9819                    let (schema, rows) = synth_pg_settings(self);
9820                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9821                }
9822                // v7.17.0 Phase 3.P0-63 — information_schema.KEY_COLUMN_USAGE.
9823                "__spg_info_key_column_usage" => {
9824                    let (schema, rows) = synth_info_key_column_usage(self.active_catalog());
9825                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9826                }
9827                // v7.17.0 Phase 3.P0-64 — information_schema.REFERENTIAL_CONSTRAINTS.
9828                "__spg_info_referential_constraints" => {
9829                    let (schema, rows) = synth_info_referential_constraints(self.active_catalog());
9830                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9831                }
9832                // v7.17.0 Phase 3.P0-64 — information_schema.STATISTICS.
9833                "__spg_info_statistics" => {
9834                    let (schema, rows) = synth_info_statistics(self.active_catalog());
9835                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9836                }
9837                // v7.17.0 Phase 3.P0-64 — information_schema.ROUTINES.
9838                "__spg_info_routines" => {
9839                    let (schema, rows) = synth_info_routines();
9840                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9841                }
9842                // v7.17.0 Phase 3.P0-65 — mysql.user / mysql.db.
9843                "__spg_mysql_user" => {
9844                    let (schema, rows) = synth_mysql_user(self);
9845                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9846                }
9847                "__spg_mysql_db" => {
9848                    let (schema, rows) = synth_mysql_db();
9849                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9850                }
9851                _ => {
9852                    return Err(EngineError::Unsupported(alloc::format!(
9853                        "meta view {view:?} is not yet materialisable; \
9854                         v7.16.2 covers information_schema.columns / .tables \
9855                         and pg_catalog.pg_class / pg_attribute; \
9856                         v7.17.0 P0-50..P0-57 add pg_type / pg_proc / pg_namespace / \
9857                         pg_indexes / pg_index / pg_constraint / pg_database / pg_roles / \
9858                         pg_user / pg_views / pg_matviews / pg_settings"
9859                    )));
9860                }
9861            }
9862        }
9863        let mut temp = Engine::restore(catalog);
9864        if let Some(c) = self.clock {
9865            temp = temp.with_clock(c);
9866        }
9867        if let Some(f) = self.salt_fn {
9868            temp = temp.with_salt_fn(f);
9869        }
9870        temp.meta_views_materialised = true;
9871        temp.exec_select_cancel(stmt, cancel)
9872    }
9873
9874    fn exec_with_ctes(
9875        &self,
9876        stmt: &SelectStatement,
9877        cancel: CancelToken<'_>,
9878    ) -> Result<QueryResult, EngineError> {
9879        cancel.check()?;
9880        let mut catalog = self.active_catalog().clone();
9881        for cte in &stmt.ctes {
9882            if catalog.get(&cte.name).is_some() {
9883                return Err(EngineError::Unsupported(alloc::format!(
9884                    "CTE name {:?} shadows an existing table; rename the CTE",
9885                    cte.name
9886                )));
9887            }
9888            let (columns, rows) = if cte.recursive {
9889                self.materialise_recursive_cte(cte, &catalog, cancel)?
9890            } else {
9891                let body_result = self.exec_select_cancel(&cte.body, cancel)?;
9892                let QueryResult::Rows { columns, rows } = body_result else {
9893                    return Err(EngineError::Unsupported(alloc::format!(
9894                        "CTE {:?} body did not return rows",
9895                        cte.name
9896                    )));
9897                };
9898                (columns, rows)
9899            };
9900            // v4.22: the projection builder labels any non-column
9901            // expression as Text — including literal SELECT 1.
9902            // Promote each column's type to whatever the rows
9903            // actually carry so the CTE storage table accepts them.
9904            let inferred = infer_column_types(&columns, &rows);
9905            let mut columns = inferred;
9906            // v4.22: apply optional `WITH name(a, b, c)` overrides.
9907            if !cte.column_overrides.is_empty() {
9908                if cte.column_overrides.len() != columns.len() {
9909                    return Err(EngineError::Unsupported(alloc::format!(
9910                        "CTE {:?} column list has {} names but body returns {} columns",
9911                        cte.name,
9912                        cte.column_overrides.len(),
9913                        columns.len()
9914                    )));
9915                }
9916                for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
9917                    col.name.clone_from(name);
9918                }
9919            }
9920            let schema = TableSchema::new(cte.name.clone(), columns);
9921            catalog.create_table(schema).map_err(EngineError::Storage)?;
9922            let table = catalog
9923                .get_mut(&cte.name)
9924                .expect("just-created CTE table must exist");
9925            for row in rows {
9926                table.insert(row).map_err(EngineError::Storage)?;
9927            }
9928        }
9929        // Strip CTEs from the body before running on the temp engine
9930        // so we don't recurse forever.
9931        let mut body = stmt.clone();
9932        body.ctes = Vec::new();
9933        let mut temp = Engine::restore(catalog);
9934        if let Some(c) = self.clock {
9935            temp = temp.with_clock(c);
9936        }
9937        if let Some(f) = self.salt_fn {
9938            temp = temp.with_salt_fn(f);
9939        }
9940        temp.exec_select_cancel(&body, cancel)
9941    }
9942
9943    /// v4.22: materialise a WITH RECURSIVE CTE. The body must be a
9944    /// UNION (or UNION ALL) of an anchor that does not reference
9945    /// the CTE name, and one or more recursive terms that do. The
9946    /// anchor runs first; each subsequent iteration runs the
9947    /// recursive term against a temp catalog where the CTE name is
9948    /// bound to the *previous* iteration's output. Iteration stops
9949    /// when the recursive term yields no rows; UNION (DISTINCT)
9950    /// deduplicates against the accumulated result, UNION ALL does
9951    /// not. A hard cap on total rows prevents runaway queries.
9952    #[allow(clippy::too_many_lines)]
9953    fn materialise_recursive_cte(
9954        &self,
9955        cte: &spg_sql::ast::Cte,
9956        base_catalog: &Catalog,
9957        cancel: CancelToken<'_>,
9958    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
9959        const MAX_TOTAL_ROWS: usize = 1_000_000;
9960        const MAX_ITERATIONS: usize = 100_000;
9961        cancel.check()?;
9962        if cte.body.unions.is_empty() {
9963            return Err(EngineError::Unsupported(alloc::format!(
9964                "WITH RECURSIVE {:?} body must be a UNION of an anchor and a recursive term",
9965                cte.name
9966            )));
9967        }
9968        // Anchor: the body's leading SELECT, with unions stripped.
9969        let mut anchor = cte.body.clone();
9970        let union_terms = core::mem::take(&mut anchor.unions);
9971        anchor.ctes = Vec::new();
9972        // Anchor must not reference the CTE name.
9973        if select_refers_to(&anchor, &cte.name) {
9974            return Err(EngineError::Unsupported(alloc::format!(
9975                "WITH RECURSIVE {:?}: the anchor must not reference the CTE itself",
9976                cte.name
9977            )));
9978        }
9979        let anchor_result = self.exec_select_cancel(&anchor, cancel)?;
9980        let QueryResult::Rows {
9981            columns: anchor_cols,
9982            rows: anchor_rows,
9983        } = anchor_result
9984        else {
9985            return Err(EngineError::Unsupported(alloc::format!(
9986                "WITH RECURSIVE {:?}: anchor did not return rows",
9987                cte.name
9988            )));
9989        };
9990        // The projection builder labels non-column expressions Text;
9991        // refine column types from the anchor's actual values so the
9992        // intermediate iter-catalog tables accept them.
9993        let mut columns = infer_column_types(&anchor_cols, &anchor_rows);
9994        if !cte.column_overrides.is_empty() {
9995            if cte.column_overrides.len() != columns.len() {
9996                return Err(EngineError::Unsupported(alloc::format!(
9997                    "CTE {:?} column list has {} names but anchor returns {} columns",
9998                    cte.name,
9999                    cte.column_overrides.len(),
10000                    columns.len()
10001                )));
10002            }
10003            for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
10004                col.name.clone_from(name);
10005            }
10006        }
10007        let mut all_rows: Vec<Row> = anchor_rows.clone();
10008        let mut working_set: Vec<Row> = anchor_rows;
10009        let mut seen: alloc::collections::BTreeSet<Vec<u8>> = alloc::collections::BTreeSet::new();
10010        // Track at least one "all UNION ALL" flag — if every union
10011        // kind is ALL we skip the dedup step (faster + matches PG).
10012        let all_union_all = union_terms.iter().all(|(k, _)| matches!(k, UnionKind::All));
10013        if !all_union_all {
10014            for r in &all_rows {
10015                seen.insert(encode_row_key(r));
10016            }
10017        }
10018        for iter in 0..MAX_ITERATIONS {
10019            cancel.check()?;
10020            if working_set.is_empty() {
10021                break;
10022            }
10023            // Build a fresh catalog: base + CTE bound to working_set.
10024            let mut iter_catalog = base_catalog.clone();
10025            let schema = TableSchema::new(cte.name.clone(), columns.clone());
10026            iter_catalog
10027                .create_table(schema)
10028                .map_err(EngineError::Storage)?;
10029            {
10030                let table = iter_catalog.get_mut(&cte.name).expect("just-created");
10031                for row in &working_set {
10032                    table.insert(row.clone()).map_err(EngineError::Storage)?;
10033                }
10034            }
10035            let mut iter_engine = Engine::restore(iter_catalog);
10036            if let Some(c) = self.clock {
10037                iter_engine = iter_engine.with_clock(c);
10038            }
10039            if let Some(f) = self.salt_fn {
10040                iter_engine = iter_engine.with_salt_fn(f);
10041            }
10042            // Run each recursive term in sequence and collect new rows.
10043            let mut next_set: Vec<Row> = Vec::new();
10044            for (_, term) in &union_terms {
10045                let mut term = term.clone();
10046                term.ctes = Vec::new();
10047                let r = iter_engine.exec_select_cancel(&term, cancel)?;
10048                let QueryResult::Rows {
10049                    columns: rc,
10050                    rows: rs,
10051                } = r
10052                else {
10053                    return Err(EngineError::Unsupported(alloc::format!(
10054                        "WITH RECURSIVE {:?}: recursive term did not return rows",
10055                        cte.name
10056                    )));
10057                };
10058                if rc.len() != columns.len() {
10059                    return Err(EngineError::Unsupported(alloc::format!(
10060                        "WITH RECURSIVE {:?}: column count of recursive term ({}) does not match anchor ({})",
10061                        cte.name,
10062                        rc.len(),
10063                        columns.len()
10064                    )));
10065                }
10066                for row in rs {
10067                    if !all_union_all {
10068                        let key = encode_row_key(&row);
10069                        if !seen.insert(key) {
10070                            continue;
10071                        }
10072                    }
10073                    next_set.push(row);
10074                }
10075            }
10076            if next_set.is_empty() {
10077                break;
10078            }
10079            all_rows.extend(next_set.iter().cloned());
10080            working_set = next_set;
10081            if all_rows.len() > MAX_TOTAL_ROWS {
10082                return Err(EngineError::Unsupported(alloc::format!(
10083                    "WITH RECURSIVE {:?}: produced more than {MAX_TOTAL_ROWS} rows — likely runaway recursion",
10084                    cte.name
10085                )));
10086            }
10087            if iter + 1 == MAX_ITERATIONS {
10088                return Err(EngineError::Unsupported(alloc::format!(
10089                    "WITH RECURSIVE {:?}: exceeded {MAX_ITERATIONS} iterations",
10090                    cte.name
10091                )));
10092            }
10093        }
10094        Ok((columns, all_rows))
10095    }
10096
10097    fn resolve_select_subqueries(
10098        &self,
10099        stmt: &mut SelectStatement,
10100        cancel: CancelToken<'_>,
10101    ) -> Result<(), EngineError> {
10102        for item in &mut stmt.items {
10103            if let SelectItem::Expr { expr, .. } = item {
10104                self.resolve_expr_subqueries(expr, cancel)?;
10105            }
10106        }
10107        if let Some(w) = &mut stmt.where_ {
10108            self.resolve_expr_subqueries(w, cancel)?;
10109        }
10110        if let Some(gs) = &mut stmt.group_by {
10111            for g in gs {
10112                self.resolve_expr_subqueries(g, cancel)?;
10113            }
10114        }
10115        if let Some(h) = &mut stmt.having {
10116            self.resolve_expr_subqueries(h, cancel)?;
10117        }
10118        for o in &mut stmt.order_by {
10119            self.resolve_expr_subqueries(&mut o.expr, cancel)?;
10120        }
10121        for (_, peer) in &mut stmt.unions {
10122            self.resolve_select_subqueries(peer, cancel)?;
10123        }
10124        Ok(())
10125    }
10126
10127    #[allow(clippy::only_used_in_recursion)] // engine handle reads aren't really pure
10128    fn resolve_expr_subqueries(
10129        &self,
10130        e: &mut Expr,
10131        cancel: CancelToken<'_>,
10132    ) -> Result<(), EngineError> {
10133        // Replace-on-this-node cases first.
10134        if let Some(replacement) = self.subquery_replacement(e, cancel)? {
10135            *e = replacement;
10136            return Ok(());
10137        }
10138        match e {
10139            Expr::Binary { lhs, rhs, .. } => {
10140                self.resolve_expr_subqueries(lhs, cancel)?;
10141                self.resolve_expr_subqueries(rhs, cancel)?;
10142            }
10143            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10144                self.resolve_expr_subqueries(expr, cancel)?;
10145            }
10146            Expr::FunctionCall { args, .. } => {
10147                for a in args {
10148                    self.resolve_expr_subqueries(a, cancel)?;
10149                }
10150            }
10151            Expr::Like { expr, pattern, .. } => {
10152                self.resolve_expr_subqueries(expr, cancel)?;
10153                self.resolve_expr_subqueries(pattern, cancel)?;
10154            }
10155            Expr::Extract { source, .. } => self.resolve_expr_subqueries(source, cancel)?,
10156            // v4.12 window functions — recurse into args + ORDER BY
10157            // + PARTITION BY in case they carry inner subqueries.
10158            Expr::WindowFunction {
10159                args,
10160                partition_by,
10161                order_by,
10162                ..
10163            } => {
10164                for a in args {
10165                    self.resolve_expr_subqueries(a, cancel)?;
10166                }
10167                for p in partition_by {
10168                    self.resolve_expr_subqueries(p, cancel)?;
10169                }
10170                for (e, _) in order_by {
10171                    self.resolve_expr_subqueries(e, cancel)?;
10172                }
10173            }
10174            // Subquery nodes are handled in subquery_replacement
10175            // (which returned None — defensive no-op); Literal /
10176            // Column are leaves.
10177            Expr::ScalarSubquery(_)
10178            | Expr::Exists { .. }
10179            | Expr::InSubquery { .. }
10180            | Expr::Literal(_)
10181            | Expr::Placeholder(_)
10182            | Expr::Column(_) => {}
10183            // v7.10.10 — recurse children.
10184            Expr::Array(items) => {
10185                for elem in items {
10186                    self.resolve_expr_subqueries(elem, cancel)?;
10187                }
10188            }
10189            Expr::ArraySubscript { target, index } => {
10190                self.resolve_expr_subqueries(target, cancel)?;
10191                self.resolve_expr_subqueries(index, cancel)?;
10192            }
10193            Expr::AnyAll { expr, array, .. } => {
10194                self.resolve_expr_subqueries(expr, cancel)?;
10195                self.resolve_expr_subqueries(array, cancel)?;
10196            }
10197            Expr::Case {
10198                operand,
10199                branches,
10200                else_branch,
10201            } => {
10202                if let Some(o) = operand {
10203                    self.resolve_expr_subqueries(o, cancel)?;
10204                }
10205                for (w, t) in branches {
10206                    self.resolve_expr_subqueries(w, cancel)?;
10207                    self.resolve_expr_subqueries(t, cancel)?;
10208                }
10209                if let Some(e) = else_branch {
10210                    self.resolve_expr_subqueries(e, cancel)?;
10211                }
10212            }
10213        }
10214        Ok(())
10215    }
10216
10217    /// v4.23: per-row eval that handles correlated subqueries.
10218    /// Equivalent to `eval::eval_expr` when the expression has no
10219    /// subqueries; otherwise clones the expression, substitutes
10220    /// outer-row columns into each surviving subquery node, runs
10221    /// the inner SELECT, and replaces the node with the literal
10222    /// result. Only the WHERE-filter call sites use this path so
10223    /// the uncorrelated fast path is preserved everywhere else.
10224    fn eval_expr_with_correlated(
10225        &self,
10226        expr: &Expr,
10227        row: &Row,
10228        ctx: &EvalContext<'_>,
10229        cancel: CancelToken<'_>,
10230        memo: Option<&mut memoize::MemoizeCache>,
10231    ) -> Result<Value, EngineError> {
10232        if !expr_has_subquery(expr) {
10233            return eval::eval_expr(expr, row, ctx).map_err(EngineError::Eval);
10234        }
10235        let mut e = expr.clone();
10236        self.resolve_correlated_in_expr(&mut e, row, ctx, cancel, memo)?;
10237        eval::eval_expr(&e, row, ctx).map_err(EngineError::Eval)
10238    }
10239
10240    fn resolve_correlated_in_expr(
10241        &self,
10242        e: &mut Expr,
10243        row: &Row,
10244        ctx: &EvalContext<'_>,
10245        cancel: CancelToken<'_>,
10246        mut memo: Option<&mut memoize::MemoizeCache>,
10247    ) -> Result<(), EngineError> {
10248        match e {
10249            Expr::ScalarSubquery(inner) => {
10250                // v6.2.6 — Memoize: build the cache key from the
10251                // pre-substitution subquery repr + the outer row's
10252                // values. Two outer rows with identical correlated
10253                // values hit the same entry.
10254                let cache_key = memo.as_ref().map(|_| memoize::CacheKey {
10255                    subquery_repr: alloc::format!("{}", **inner),
10256                    outer_values: row.values.clone(),
10257                });
10258                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key.as_ref())
10259                    && let Some(cached) = cache.get(k)
10260                {
10261                    *e = value_to_literal_expr(cached)?;
10262                    return Ok(());
10263                }
10264                let mut s = (**inner).clone();
10265                substitute_outer_columns(&mut s, row, ctx);
10266                let r = self.exec_select_cancel(&s, cancel)?;
10267                let QueryResult::Rows { rows, .. } = r else {
10268                    return Err(EngineError::Unsupported(
10269                        "scalar subquery: inner did not return rows".into(),
10270                    ));
10271                };
10272                let value = match rows.as_slice() {
10273                    [] => Value::Null,
10274                    [r0] => r0.values.first().cloned().unwrap_or(Value::Null),
10275                    _ => {
10276                        return Err(EngineError::Unsupported(alloc::format!(
10277                            "scalar subquery returned {} rows; expected 0 or 1",
10278                            rows.len()
10279                        )));
10280                    }
10281                };
10282                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key) {
10283                    cache.insert(k, value.clone());
10284                }
10285                *e = value_to_literal_expr(value)?;
10286            }
10287            Expr::Exists { subquery, negated } => {
10288                let mut s = (**subquery).clone();
10289                substitute_outer_columns(&mut s, row, ctx);
10290                let r = self.exec_select_cancel(&s, cancel)?;
10291                let exists = matches!(r, QueryResult::Rows { rows, .. } if !rows.is_empty());
10292                let bit = if *negated { !exists } else { exists };
10293                *e = Expr::Literal(Literal::Bool(bit));
10294            }
10295            Expr::InSubquery {
10296                expr: lhs,
10297                subquery,
10298                negated,
10299            } => {
10300                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
10301                let lhs_val = eval::eval_expr(lhs, row, ctx).map_err(EngineError::Eval)?;
10302                let mut s = (**subquery).clone();
10303                substitute_outer_columns(&mut s, row, ctx);
10304                let r = self.exec_select_cancel(&s, cancel)?;
10305                let QueryResult::Rows { columns, rows, .. } = r else {
10306                    return Err(EngineError::Unsupported(
10307                        "IN-subquery: inner did not return rows".into(),
10308                    ));
10309                };
10310                if columns.len() != 1 {
10311                    return Err(EngineError::Unsupported(alloc::format!(
10312                        "IN-subquery must project exactly one column; got {}",
10313                        columns.len()
10314                    )));
10315                }
10316                let mut found = false;
10317                let mut any_null = false;
10318                for r0 in rows {
10319                    let v = r0.values.into_iter().next().unwrap_or(Value::Null);
10320                    if v.is_null() {
10321                        any_null = true;
10322                        continue;
10323                    }
10324                    if value_cmp(&v, &lhs_val) == core::cmp::Ordering::Equal {
10325                        found = true;
10326                        break;
10327                    }
10328                }
10329                let bit = if found {
10330                    !*negated
10331                } else if any_null {
10332                    return Err(EngineError::Unsupported(
10333                        "IN-subquery with NULL in result and no match: NULL semantics not yet implemented".into(),
10334                    ));
10335                } else {
10336                    *negated
10337                };
10338                *e = Expr::Literal(Literal::Bool(bit));
10339            }
10340            Expr::Binary { lhs, rhs, .. } => {
10341                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
10342                self.resolve_correlated_in_expr(rhs, row, ctx, cancel, memo.as_deref_mut())?;
10343            }
10344            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10345                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10346            }
10347            Expr::Like { expr, pattern, .. } => {
10348                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10349                self.resolve_correlated_in_expr(pattern, row, ctx, cancel, memo.as_deref_mut())?;
10350            }
10351            Expr::FunctionCall { args, .. } => {
10352                for a in args {
10353                    self.resolve_correlated_in_expr(a, row, ctx, cancel, memo.as_deref_mut())?;
10354                }
10355            }
10356            Expr::Extract { source, .. } => {
10357                self.resolve_correlated_in_expr(source, row, ctx, cancel, memo.as_deref_mut())?;
10358            }
10359            Expr::WindowFunction { .. }
10360            | Expr::Literal(_)
10361            | Expr::Placeholder(_)
10362            | Expr::Column(_) => {}
10363            // v7.10.10 — recurse children.
10364            Expr::Array(items) => {
10365                for elem in items {
10366                    self.resolve_correlated_in_expr(elem, row, ctx, cancel, memo.as_deref_mut())?;
10367                }
10368            }
10369            Expr::ArraySubscript { target, index } => {
10370                self.resolve_correlated_in_expr(target, row, ctx, cancel, memo.as_deref_mut())?;
10371                self.resolve_correlated_in_expr(index, row, ctx, cancel, memo.as_deref_mut())?;
10372            }
10373            Expr::AnyAll { expr, array, .. } => {
10374                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10375                self.resolve_correlated_in_expr(array, row, ctx, cancel, memo.as_deref_mut())?;
10376            }
10377            Expr::Case {
10378                operand,
10379                branches,
10380                else_branch,
10381            } => {
10382                if let Some(o) = operand {
10383                    self.resolve_correlated_in_expr(o, row, ctx, cancel, memo.as_deref_mut())?;
10384                }
10385                for (w, t) in branches {
10386                    self.resolve_correlated_in_expr(w, row, ctx, cancel, memo.as_deref_mut())?;
10387                    self.resolve_correlated_in_expr(t, row, ctx, cancel, memo.as_deref_mut())?;
10388                }
10389                if let Some(e) = else_branch {
10390                    self.resolve_correlated_in_expr(e, row, ctx, cancel, memo.as_deref_mut())?;
10391                }
10392            }
10393        }
10394        Ok(())
10395    }
10396
10397    fn subquery_replacement(
10398        &self,
10399        e: &Expr,
10400        cancel: CancelToken<'_>,
10401    ) -> Result<Option<Expr>, EngineError> {
10402        match e {
10403            Expr::ScalarSubquery(inner) => {
10404                let mut s = (**inner).clone();
10405                // Recurse into the inner SELECT first so nested
10406                // subqueries materialise bottom-up.
10407                self.resolve_select_subqueries(&mut s, cancel)?;
10408                let r = match self.exec_bare_select_cancel(&s, cancel) {
10409                    Ok(r) => r,
10410                    Err(e) if is_correlation_error(&e) => return Ok(None),
10411                    Err(e) => return Err(e),
10412                };
10413                let QueryResult::Rows { rows, .. } = r else {
10414                    return Err(EngineError::Unsupported(
10415                        "scalar subquery: inner statement did not return rows".into(),
10416                    ));
10417                };
10418                let value = match rows.as_slice() {
10419                    [] => Value::Null,
10420                    [row] => row.values.first().cloned().unwrap_or(Value::Null),
10421                    _ => {
10422                        return Err(EngineError::Unsupported(alloc::format!(
10423                            "scalar subquery returned {} rows; expected 0 or 1",
10424                            rows.len()
10425                        )));
10426                    }
10427                };
10428                Ok(Some(value_to_literal_expr(value)?))
10429            }
10430            Expr::Exists { subquery, negated } => {
10431                let mut s = (**subquery).clone();
10432                self.resolve_select_subqueries(&mut s, cancel)?;
10433                let r = match self.exec_bare_select_cancel(&s, cancel) {
10434                    Ok(r) => r,
10435                    Err(e) if is_correlation_error(&e) => return Ok(None),
10436                    Err(e) => return Err(e),
10437                };
10438                let exists = match r {
10439                    QueryResult::Rows { rows, .. } => !rows.is_empty(),
10440                    QueryResult::CommandOk { .. } => false,
10441                };
10442                let bit = if *negated { !exists } else { exists };
10443                Ok(Some(Expr::Literal(Literal::Bool(bit))))
10444            }
10445            Expr::InSubquery {
10446                expr,
10447                subquery,
10448                negated,
10449            } => {
10450                let mut s = (**subquery).clone();
10451                self.resolve_select_subqueries(&mut s, cancel)?;
10452                let r = match self.exec_bare_select_cancel(&s, cancel) {
10453                    Ok(r) => r,
10454                    Err(e) if is_correlation_error(&e) => return Ok(None),
10455                    Err(e) => return Err(e),
10456                };
10457                let QueryResult::Rows { columns, rows, .. } = r else {
10458                    return Err(EngineError::Unsupported(
10459                        "IN-subquery: inner statement did not return rows".into(),
10460                    ));
10461                };
10462                if columns.len() != 1 {
10463                    return Err(EngineError::Unsupported(alloc::format!(
10464                        "IN-subquery must project exactly one column; got {}",
10465                        columns.len()
10466                    )));
10467                }
10468                // Build the same OR-Eq chain the parse-time literal-list
10469                // path constructs, with each value lifted into a Literal.
10470                let mut acc: Option<Expr> = None;
10471                for row in rows {
10472                    let v = row.values.into_iter().next().unwrap_or(Value::Null);
10473                    let lit = value_to_literal_expr(v)?;
10474                    let cmp = Expr::Binary {
10475                        lhs: expr.clone(),
10476                        op: BinOp::Eq,
10477                        rhs: Box::new(lit),
10478                    };
10479                    acc = Some(match acc {
10480                        None => cmp,
10481                        Some(prev) => Expr::Binary {
10482                            lhs: Box::new(prev),
10483                            op: BinOp::Or,
10484                            rhs: Box::new(cmp),
10485                        },
10486                    });
10487                }
10488                let combined = acc.unwrap_or(Expr::Literal(Literal::Bool(false)));
10489                let final_expr = if *negated {
10490                    Expr::Unary {
10491                        op: UnOp::Not,
10492                        expr: Box::new(combined),
10493                    }
10494                } else {
10495                    combined
10496                };
10497                Ok(Some(final_expr))
10498            }
10499            _ => Ok(None),
10500        }
10501    }
10502}
10503
10504// ---- v4.12 window-function helpers ----
10505// The (partition-key, order-key, original-index) tuple shape used
10506// across these helpers is intrinsic to the planner. Factoring it
10507// into a typedef adds indirection without making the code clearer,
10508// so several lints are allowed inline on the affected functions
10509// rather than module-wide.
10510
10511/// v4.22: cheap structural scan for `FROM <name>` (qualified or
10512/// not) inside a SELECT — used to verify the anchor of a WITH
10513/// RECURSIVE CTE doesn't recurse into itself. Conservative: walks
10514/// FROM joins, subqueries, and unions.
10515fn select_refers_to(stmt: &SelectStatement, target: &str) -> bool {
10516    if let Some(from) = &stmt.from
10517        && from_refers_to(from, target)
10518    {
10519        return true;
10520    }
10521    for (_, peer) in &stmt.unions {
10522        if select_refers_to(peer, target) {
10523            return true;
10524        }
10525    }
10526    for item in &stmt.items {
10527        if let SelectItem::Expr { expr, .. } = item
10528            && expr_refers_to(expr, target)
10529        {
10530            return true;
10531        }
10532    }
10533    if let Some(w) = &stmt.where_
10534        && expr_refers_to(w, target)
10535    {
10536        return true;
10537    }
10538    false
10539}
10540
10541fn from_refers_to(from: &FromClause, target: &str) -> bool {
10542    if from.primary.name.eq_ignore_ascii_case(target) {
10543        return true;
10544    }
10545    from.joins
10546        .iter()
10547        .any(|j| j.table.name.eq_ignore_ascii_case(target))
10548}
10549
10550fn expr_refers_to(e: &Expr, target: &str) -> bool {
10551    match e {
10552        Expr::ScalarSubquery(s) => select_refers_to(s, target),
10553        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
10554            select_refers_to(subquery, target)
10555        }
10556        Expr::Binary { lhs, rhs, .. } => expr_refers_to(lhs, target) || expr_refers_to(rhs, target),
10557        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10558            expr_refers_to(expr, target)
10559        }
10560        Expr::Like { expr, pattern, .. } => {
10561            expr_refers_to(expr, target) || expr_refers_to(pattern, target)
10562        }
10563        Expr::FunctionCall { args, .. } => args.iter().any(|a| expr_refers_to(a, target)),
10564        Expr::Extract { source, .. } => expr_refers_to(source, target),
10565        Expr::WindowFunction {
10566            args,
10567            partition_by,
10568            order_by,
10569            ..
10570        } => {
10571            args.iter().any(|a| expr_refers_to(a, target))
10572                || partition_by.iter().any(|p| expr_refers_to(p, target))
10573                || order_by.iter().any(|(o, _)| expr_refers_to(o, target))
10574        }
10575        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
10576        Expr::Array(items) => items.iter().any(|e| expr_refers_to(e, target)),
10577        Expr::ArraySubscript { target: t, index } => {
10578            expr_refers_to(t, target) || expr_refers_to(index, target)
10579        }
10580        Expr::AnyAll { expr, array, .. } => {
10581            expr_refers_to(expr, target) || expr_refers_to(array, target)
10582        }
10583        Expr::Case {
10584            operand,
10585            branches,
10586            else_branch,
10587        } => {
10588            operand
10589                .as_deref()
10590                .is_some_and(|o| expr_refers_to(o, target))
10591                || branches
10592                    .iter()
10593                    .any(|(w, t)| expr_refers_to(w, target) || expr_refers_to(t, target))
10594                || else_branch
10595                    .as_deref()
10596                    .is_some_and(|e| expr_refers_to(e, target))
10597        }
10598    }
10599}
10600
10601/// v4.22: pick more specific column types from observed rows when
10602/// the projection builder defaulted to Text (the v1.x behavior for
10603/// non-column expressions). Lets `WITH t(n) AS (SELECT 1 ...)`
10604/// land an Int column in the CTE storage table rather than failing
10605/// the insert with "expected TEXT, got INT".
10606/// v7.16.2 — map an SPG [`DataType`] to the PG-canonical
10607/// `information_schema.columns.data_type` text. Covers the
10608/// values mailrs's migrations probe (`'ARRAY'`, `'integer'`,
10609/// `'text'`, …). Unknown variants fall back to the SPG name
10610/// downcased — better than panicking on a future DataType.
10611fn pg_data_type_text(ty: DataType) -> alloc::string::String {
10612    let s = match ty {
10613        DataType::Int => "integer",
10614        DataType::BigInt => "bigint",
10615        DataType::SmallInt => "smallint",
10616        DataType::Float => "double precision",
10617        DataType::Bool => "boolean",
10618        DataType::Text => "text",
10619        DataType::Varchar(_) => "character varying",
10620        DataType::Date => "date",
10621        DataType::Timestamp => "timestamp without time zone",
10622        DataType::Timestamptz => "timestamp with time zone",
10623        DataType::Json => "jsonb",
10624        DataType::Bytes => "bytea",
10625        DataType::TextArray | DataType::IntArray | DataType::BigIntArray => "ARRAY",
10626        DataType::TsVector => "tsvector",
10627        DataType::TsQuery => "tsquery",
10628        DataType::Vector { .. } => "USER-DEFINED",
10629        // Non-exhaustive — fall back to "USER-DEFINED" the way
10630        // PG labels any pg_type it doesn't recognise.
10631        _ => "USER-DEFINED",
10632    };
10633    alloc::string::String::from(s)
10634}
10635
10636/// v7.16.2 — synthesise `information_schema.columns`. mailrs
10637/// queries are of shape `SELECT 1 FROM information_schema.columns
10638/// WHERE table_name = … AND column_name = … AND data_type = …` —
10639/// the v7.16.2 view returns the columns mailrs probes; broader
10640/// PG-spec parity (ordinal_position, is_nullable, character_
10641/// maximum_length, udt_name, …) lands as needed.
10642fn synth_information_schema_columns(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10643    let schema = alloc::vec![
10644        ColumnSchema::new("table_catalog", DataType::Text, false),
10645        ColumnSchema::new("table_schema", DataType::Text, false),
10646        ColumnSchema::new("table_name", DataType::Text, false),
10647        ColumnSchema::new("column_name", DataType::Text, false),
10648        ColumnSchema::new("ordinal_position", DataType::Int, false),
10649        ColumnSchema::new("is_nullable", DataType::Text, false),
10650        ColumnSchema::new("data_type", DataType::Text, false),
10651    ];
10652    let mut rows: Vec<Row> = Vec::new();
10653    for tname in cat.table_names() {
10654        let Some(t) = cat.get(&tname) else { continue };
10655        for (i, col) in t.schema().columns.iter().enumerate() {
10656            #[allow(clippy::cast_possible_wrap)]
10657            let ordinal = (i + 1) as i32;
10658            rows.push(Row::new(alloc::vec![
10659                Value::Text("spg".into()),
10660                Value::Text("public".into()),
10661                Value::Text(tname.clone()),
10662                Value::Text(col.name.clone()),
10663                Value::Int(ordinal),
10664                Value::Text(if col.nullable {
10665                    "YES".into()
10666                } else {
10667                    "NO".into()
10668                }),
10669                Value::Text(pg_data_type_text(col.ty)),
10670            ]));
10671        }
10672    }
10673    (schema, rows)
10674}
10675
10676/// v7.16.2 — synthesise `information_schema.tables`.
10677fn synth_information_schema_tables(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10678    let schema = alloc::vec![
10679        ColumnSchema::new("table_catalog", DataType::Text, false),
10680        ColumnSchema::new("table_schema", DataType::Text, false),
10681        ColumnSchema::new("table_name", DataType::Text, false),
10682        ColumnSchema::new("table_type", DataType::Text, false),
10683    ];
10684    let mut rows: Vec<Row> = Vec::new();
10685    for tname in cat.table_names() {
10686        rows.push(Row::new(alloc::vec![
10687            Value::Text("spg".into()),
10688            Value::Text("public".into()),
10689            Value::Text(tname.clone()),
10690            Value::Text("BASE TABLE".into()),
10691        ]));
10692    }
10693    (schema, rows)
10694}
10695
10696/// v7.16.2 — synthesise `pg_catalog.pg_class`. Minimum shape
10697/// for psql `\d` / ORM probes: `relname` + `relkind`. Each
10698/// user table emits one row.
10699fn synth_pg_class(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10700    let schema = alloc::vec![
10701        ColumnSchema::new("relname", DataType::Text, false),
10702        ColumnSchema::new("relkind", DataType::Text, false),
10703        ColumnSchema::new("relnamespace", DataType::BigInt, false),
10704    ];
10705    let mut rows: Vec<Row> = Vec::new();
10706    for tname in cat.table_names() {
10707        rows.push(Row::new(alloc::vec![
10708            Value::Text(tname.clone()),
10709            Value::Text("r".into()),
10710            Value::BigInt(2200), // PG's `public` namespace OID
10711        ]));
10712    }
10713    (schema, rows)
10714}
10715
10716/// v7.16.2 — synthesise `pg_catalog.pg_attribute`. Minimum
10717/// shape: `attrelid` (text — SPG has no OID), `attname`,
10718/// `attnum`, `atttypid` (text), `attnotnull`.
10719fn synth_pg_attribute(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10720    let schema = alloc::vec![
10721        ColumnSchema::new("attrelid", DataType::Text, false),
10722        ColumnSchema::new("attname", DataType::Text, false),
10723        ColumnSchema::new("attnum", DataType::Int, false),
10724        ColumnSchema::new("atttypid", DataType::Text, false),
10725        ColumnSchema::new("attnotnull", DataType::Bool, false),
10726    ];
10727    let mut rows: Vec<Row> = Vec::new();
10728    for tname in cat.table_names() {
10729        let Some(t) = cat.get(&tname) else { continue };
10730        for (i, col) in t.schema().columns.iter().enumerate() {
10731            #[allow(clippy::cast_possible_wrap)]
10732            let ordinal = (i + 1) as i32;
10733            rows.push(Row::new(alloc::vec![
10734                Value::Text(tname.clone()),
10735                Value::Text(col.name.clone()),
10736                Value::Int(ordinal),
10737                Value::Text(pg_data_type_text(col.ty)),
10738                Value::Bool(!col.nullable),
10739            ]));
10740        }
10741    }
10742    (schema, rows)
10743}
10744
10745/// v7.17.0 Phase 3.P0-50 — synthesise `pg_catalog.pg_type`. The
10746/// returned rows cover every built-in scalar / array type sqlx,
10747/// SQLAlchemy, Diesel and pgAdmin look up at compile / connect
10748/// time. PG-canonical schema columns we expose:
10749///   * oid           — type OID (the lookup key sqlx uses)
10750///   * typname       — canonical type name (`int4`, `text`, …)
10751///   * typlen        — width in bytes (-1 for var-length)
10752///   * typtype       — `b`ase / `c`omposite / `e`num / etc.
10753///   * typcategory   — PG type category single-char
10754///   * typelem       — element OID for arrays (0 otherwise)
10755///   * typarray      — array-type OID (0 if no array type)
10756///   * typnamespace  — schema OID (always `public` = 2200)
10757///
10758/// Other pg_type columns (typowner, typinput/typoutput, etc.)
10759/// land in follow-up work — sqlx encoders don't query them at
10760/// connect time.
10761fn synth_pg_type(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10762    let schema = alloc::vec![
10763        ColumnSchema::new("oid", DataType::BigInt, false),
10764        ColumnSchema::new("typname", DataType::Text, false),
10765        ColumnSchema::new("typlen", DataType::SmallInt, false),
10766        ColumnSchema::new("typtype", DataType::Text, false),
10767        ColumnSchema::new("typcategory", DataType::Text, false),
10768        ColumnSchema::new("typelem", DataType::BigInt, false),
10769        ColumnSchema::new("typarray", DataType::BigInt, false),
10770        ColumnSchema::new("typnamespace", DataType::BigInt, false),
10771    ];
10772    // (oid, name, len, type, cat, elem, array_oid). PG OID
10773    // numbers come straight from `pg_type.dat`.
10774    let scalars: &[(i64, &str, i16, &str, &str, i64, i64)] = &[
10775        // bool
10776        (16, "bool", 1, "b", "B", 0, 1000),
10777        (17, "bytea", -1, "b", "U", 0, 1001),
10778        (18, "char", 1, "b", "S", 0, 1002),
10779        (19, "name", 64, "b", "S", 0, 1003),
10780        (20, "int8", 8, "b", "N", 0, 1016),
10781        (21, "int2", 2, "b", "N", 0, 1005),
10782        (23, "int4", 4, "b", "N", 0, 1007),
10783        (24, "regproc", 4, "b", "N", 0, 1008),
10784        (25, "text", -1, "b", "S", 0, 1009),
10785        (26, "oid", 4, "b", "N", 0, 1028),
10786        (114, "json", -1, "b", "U", 0, 199),
10787        (142, "xml", -1, "b", "U", 0, 143),
10788        (700, "float4", 4, "b", "N", 0, 1021),
10789        (701, "float8", 8, "b", "N", 0, 1022),
10790        (650, "cidr", -1, "b", "I", 0, 651),
10791        (869, "inet", -1, "b", "I", 0, 1041),
10792        (829, "macaddr", 6, "b", "U", 0, 1040),
10793        (1042, "bpchar", -1, "b", "S", 0, 1014),
10794        (1043, "varchar", -1, "b", "S", 0, 1015),
10795        (1082, "date", 4, "b", "D", 0, 1182),
10796        (1083, "time", 8, "b", "D", 0, 1183),
10797        (1114, "timestamp", 8, "b", "D", 0, 1115),
10798        (1184, "timestamptz", 8, "b", "D", 0, 1185),
10799        (1186, "interval", 16, "b", "T", 0, 1187),
10800        (1266, "timetz", 12, "b", "D", 0, 1270),
10801        (1700, "numeric", -1, "b", "N", 0, 1231),
10802        (790, "money", 8, "b", "N", 0, 791),
10803        (2950, "uuid", 16, "b", "U", 0, 2951),
10804        (3802, "jsonb", -1, "b", "U", 0, 3807),
10805        (3614, "tsvector", -1, "b", "U", 0, 3643),
10806        (3615, "tsquery", -1, "b", "U", 0, 3645),
10807        // hstore + range types — typcategory 'U' (user) / 'R' (range).
10808        (3908, "tstzrange", -1, "r", "R", 0, 3909),
10809        (3910, "tsrange", -1, "r", "R", 0, 3911),
10810        (3904, "int4range", -1, "r", "R", 0, 3905),
10811        (3926, "int8range", -1, "r", "R", 0, 3927),
10812        (3906, "numrange", -1, "r", "R", 0, 3907),
10813        (3912, "daterange", -1, "r", "R", 0, 3913),
10814    ];
10815    // Array companion types share the typelem / typcategory='A'.
10816    // We emit just the array OIDs the scalars reference.
10817    let arrays: &[(i64, &str, i64)] = &[
10818        (1000, "_bool", 16),
10819        (1001, "_bytea", 17),
10820        (1002, "_char", 18),
10821        (1003, "_name", 19),
10822        (1016, "_int8", 20),
10823        (1005, "_int2", 21),
10824        (1007, "_int4", 23),
10825        (1008, "_regproc", 24),
10826        (1009, "_text", 25),
10827        (1028, "_oid", 26),
10828        (199, "_json", 114),
10829        (143, "_xml", 142),
10830        (1021, "_float4", 700),
10831        (1022, "_float8", 701),
10832        (651, "_cidr", 650),
10833        (1041, "_inet", 869),
10834        (1040, "_macaddr", 829),
10835        (1014, "_bpchar", 1042),
10836        (1015, "_varchar", 1043),
10837        (1182, "_date", 1082),
10838        (1183, "_time", 1083),
10839        (1115, "_timestamp", 1114),
10840        (1185, "_timestamptz", 1184),
10841        (1187, "_interval", 1186),
10842        (1270, "_timetz", 1266),
10843        (1231, "_numeric", 1700),
10844        (791, "_money", 790),
10845        (2951, "_uuid", 2950),
10846        (3807, "_jsonb", 3802),
10847        (3643, "_tsvector", 3614),
10848        (3645, "_tsquery", 3615),
10849    ];
10850    let mut rows: Vec<Row> = Vec::with_capacity(scalars.len() + arrays.len());
10851    for &(oid, name, len, ty, cat, elem, arr) in scalars {
10852        rows.push(Row::new(alloc::vec![
10853            Value::BigInt(oid),
10854            Value::Text(name.into()),
10855            Value::SmallInt(len),
10856            Value::Text(ty.into()),
10857            Value::Text(cat.into()),
10858            Value::BigInt(elem),
10859            Value::BigInt(arr),
10860            Value::BigInt(2200),
10861        ]));
10862    }
10863    for &(oid, name, elem) in arrays {
10864        rows.push(Row::new(alloc::vec![
10865            Value::BigInt(oid),
10866            Value::Text(name.into()),
10867            Value::SmallInt(-1),
10868            Value::Text("b".into()),
10869            Value::Text("A".into()),
10870            Value::BigInt(elem),
10871            Value::BigInt(0),
10872            Value::BigInt(2200),
10873        ]));
10874    }
10875    (schema, rows)
10876}
10877
10878/// v7.17.0 Phase 3.P0-51 — synthesise `pg_catalog.pg_proc`. ORM /
10879/// pgAdmin probes look up functions by name; SPG synthesises rows
10880/// for the built-in scalar functions / aggregates / window funcs
10881/// the engine actually dispatches. SPG has no user-defined
10882/// functions yet so the table is a stable static list.
10883///
10884/// Schema columns exposed:
10885///   * oid (BigInt) — function OID from PG's pg_proc.dat
10886///   * proname (Text) — function name (lowercase)
10887///   * pronamespace (BigInt) — 11 (`pg_catalog`)
10888///   * prokind (Text) — 'f' function, 'a' aggregate, 'w' window
10889///   * pronargs (SmallInt) — declared arg count (-1 for variadic)
10890///   * prorettype (BigInt) — return type OID (matches synth_pg_type)
10891fn synth_pg_proc(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10892    let schema = alloc::vec![
10893        ColumnSchema::new("oid", DataType::BigInt, false),
10894        ColumnSchema::new("proname", DataType::Text, false),
10895        ColumnSchema::new("pronamespace", DataType::BigInt, false),
10896        ColumnSchema::new("prokind", DataType::Text, false),
10897        ColumnSchema::new("pronargs", DataType::Int, false),
10898        ColumnSchema::new("prorettype", DataType::BigInt, false),
10899    ];
10900    // (oid, name, kind, nargs, rettype). OIDs taken from PG's
10901    // pg_proc.dat for the common subset.
10902    let funcs: &[(i64, &str, &str, i32, i64)] = &[
10903        // Scalar functions.
10904        (1318, "length", "f", 1, 23),
10905        (871, "upper", "f", 1, 25),
10906        (870, "lower", "f", 1, 25),
10907        (936, "substring", "f", 3, 25),
10908        (937, "substring", "f", 2, 25),
10909        (3055, "btrim", "f", 1, 25),
10910        (885, "btrim", "f", 2, 25),
10911        (3056, "ltrim", "f", 1, 25),
10912        (875, "ltrim", "f", 2, 25),
10913        (3057, "rtrim", "f", 1, 25),
10914        (876, "rtrim", "f", 2, 25),
10915        (1397, "abs", "f", 1, 23),
10916        (1396, "abs", "f", 1, 20),
10917        (1606, "round", "f", 1, 1700),
10918        (1707, "round", "f", 2, 1700),
10919        (2308, "ceil", "f", 1, 701),
10920        (2309, "ceiling", "f", 1, 701),
10921        (2310, "floor", "f", 1, 701),
10922        (1376, "sqrt", "f", 1, 701),
10923        (1369, "ln", "f", 1, 701),
10924        (1373, "exp", "f", 1, 701),
10925        (1368, "power", "f", 2, 701),
10926        (2228, "random", "f", 0, 701),
10927        // Date / time.
10928        (1299, "now", "f", 0, 1184),
10929        (1274, "current_timestamp", "f", 0, 1184),
10930        (1140, "current_date", "f", 0, 1082),
10931        (2050, "current_time", "f", 0, 1083),
10932        (1158, "date_trunc", "f", 2, 1184),
10933        (1171, "date_part", "f", 2, 701),
10934        (1172, "age", "f", 1, 1186),
10935        (936, "to_char", "f", 2, 25),
10936        // Session / introspection.
10937        (861, "current_database", "f", 0, 19),
10938        (745, "current_user", "f", 0, 19),
10939        (745, "session_user", "f", 0, 19),
10940        (1402, "current_schema", "f", 0, 19),
10941        // String concat / format.
10942        (3058, "concat", "f", -1, 25),
10943        (3059, "concat_ws", "f", -1, 25),
10944        (3539, "format", "f", -1, 25),
10945        // Type introspection.
10946        (2877, "pg_typeof", "f", 1, 2206),
10947        // JSON.
10948        (3198, "json_build_object", "f", -1, 114),
10949        (3199, "jsonb_build_object", "f", -1, 3802),
10950        (3271, "json_build_array", "f", -1, 114),
10951        (3272, "jsonb_build_array", "f", -1, 3802),
10952        // UUID.
10953        (3253, "gen_random_uuid", "f", 0, 2950),
10954        (3252, "uuid_generate_v4", "f", 0, 2950),
10955        // Aggregates.
10956        (2147, "count", "a", 0, 20),
10957        (2803, "count", "a", -1, 20),
10958        (2116, "max", "a", 1, 23),
10959        (2132, "min", "a", 1, 23),
10960        (2108, "sum", "a", 1, 20),
10961        (2100, "avg", "a", 1, 1700),
10962        (2517, "string_agg", "a", 2, 25),
10963        (2747, "array_agg", "a", 1, 1009),
10964        (2517, "bool_and", "a", 1, 16),
10965        (2518, "bool_or", "a", 1, 16),
10966        (2519, "every", "a", 1, 16),
10967        // Window functions.
10968        (3100, "row_number", "w", 0, 20),
10969        (3101, "rank", "w", 0, 20),
10970        (3102, "dense_rank", "w", 0, 20),
10971        (3103, "percent_rank", "w", 0, 701),
10972        (3104, "cume_dist", "w", 0, 701),
10973        (3105, "lag", "w", -1, 2283),
10974        (3106, "lead", "w", -1, 2283),
10975        (3107, "first_value", "w", 1, 2283),
10976        (3108, "last_value", "w", 1, 2283),
10977        (3109, "nth_value", "w", 2, 2283),
10978    ];
10979    let mut rows: Vec<Row> = Vec::with_capacity(funcs.len());
10980    for &(oid, name, kind, nargs, rettype) in funcs {
10981        rows.push(Row::new(alloc::vec![
10982            Value::BigInt(oid),
10983            Value::Text(name.into()),
10984            Value::BigInt(11),
10985            Value::Text(kind.into()),
10986            Value::Int(nargs),
10987            Value::BigInt(rettype),
10988        ]));
10989    }
10990    (schema, rows)
10991}
10992
10993/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.user`. MySQL admin
10994/// queries (`SELECT user, host FROM mysql.user`) probe this at
10995/// connect time to list accounts. SPG ships one row per
10996/// UserStore entry plus a synthetic `root` superuser row for
10997/// MySQL bootstrap compat.
10998fn synth_mysql_user(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
10999    let schema = alloc::vec![
11000        ColumnSchema::new("user", DataType::Text, false),
11001        ColumnSchema::new("host", DataType::Text, false),
11002        ColumnSchema::new("select_priv", DataType::Text, false),
11003    ];
11004    let mut rows: Vec<Row> = Vec::new();
11005    rows.push(Row::new(alloc::vec![
11006        Value::Text("root".into()),
11007        Value::Text("localhost".into()),
11008        Value::Text("Y".into()),
11009    ]));
11010    for (name, _) in engine.users.iter() {
11011        if name != "root" {
11012            rows.push(Row::new(alloc::vec![
11013                Value::Text(name.to_string()),
11014                Value::Text("%".into()),
11015                Value::Text("Y".into()),
11016            ]));
11017        }
11018    }
11019    (schema, rows)
11020}
11021
11022/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.db`. The
11023/// per-database privileges table. SPG is single-database so the
11024/// table surfaces one row per declared user with full privileges
11025/// on the canonical `postgres` database.
11026fn synth_mysql_db() -> (Vec<ColumnSchema>, Vec<Row>) {
11027    let schema = alloc::vec![
11028        ColumnSchema::new("host", DataType::Text, false),
11029        ColumnSchema::new("db", DataType::Text, false),
11030        ColumnSchema::new("user", DataType::Text, false),
11031        ColumnSchema::new("select_priv", DataType::Text, false),
11032    ];
11033    let rows = alloc::vec![Row::new(alloc::vec![
11034        Value::Text("localhost".into()),
11035        Value::Text("postgres".into()),
11036        Value::Text("root".into()),
11037        Value::Text("Y".into()),
11038    ])];
11039    (schema, rows)
11040}
11041
11042/// v7.17.0 Phase 3.P0-63 — synthesise
11043/// `information_schema.KEY_COLUMN_USAGE`. ORM migration tools
11044/// (Alembic, Sequelize, TypeORM) walk this view to discover FK
11045/// relationships in MySQL-flavoured introspection queries.
11046///
11047/// Schema columns exposed:
11048///   * CONSTRAINT_NAME (Text)
11049///   * TABLE_NAME (Text)
11050///   * COLUMN_NAME (Text)
11051///   * ORDINAL_POSITION (Int)
11052///   * REFERENCED_TABLE_NAME (Text) — empty for non-FK rows
11053///   * REFERENCED_COLUMN_NAME (Text) — empty for non-FK rows
11054fn synth_info_key_column_usage(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11055    let schema = alloc::vec![
11056        ColumnSchema::new("constraint_name", DataType::Text, false),
11057        ColumnSchema::new("table_name", DataType::Text, false),
11058        ColumnSchema::new("column_name", DataType::Text, false),
11059        ColumnSchema::new("ordinal_position", DataType::Int, false),
11060        ColumnSchema::new("referenced_table_name", DataType::Text, false),
11061        ColumnSchema::new("referenced_column_name", DataType::Text, false),
11062    ];
11063    let mut rows: Vec<Row> = Vec::new();
11064    for tname in cat.table_names() {
11065        let Some(t) = cat.get(&tname) else { continue };
11066        let cols = &t.schema().columns;
11067        let col_name_at = |pos: usize| -> String {
11068            cols.get(pos)
11069                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
11070        };
11071        // FKs.
11072        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11073            let conname = fk
11074                .name
11075                .clone()
11076                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11077            for (i, (&local, &parent)) in fk
11078                .local_columns
11079                .iter()
11080                .zip(fk.parent_columns.iter())
11081                .enumerate()
11082            {
11083                let parent_name = cat
11084                    .get(&fk.parent_table)
11085                    .and_then(|pt| pt.schema().columns.get(parent).map(|c| c.name.clone()))
11086                    .unwrap_or_else(|| alloc::format!("col{parent}"));
11087                #[allow(clippy::cast_possible_wrap)]
11088                let ordinal = (i + 1) as i32;
11089                rows.push(Row::new(alloc::vec![
11090                    Value::Text(conname.clone()),
11091                    Value::Text(tname.clone()),
11092                    Value::Text(col_name_at(local)),
11093                    Value::Int(ordinal),
11094                    Value::Text(fk.parent_table.clone()),
11095                    Value::Text(parent_name),
11096                ]));
11097            }
11098        }
11099        // PK / composite UC entries.
11100        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
11101            let conname = if uc.is_primary_key {
11102                alloc::format!("{}_pkey", tname)
11103            } else {
11104                alloc::format!("{}_uniq{ci}", tname)
11105            };
11106            for (i, &local) in uc.columns.iter().enumerate() {
11107                #[allow(clippy::cast_possible_wrap)]
11108                let ordinal = (i + 1) as i32;
11109                rows.push(Row::new(alloc::vec![
11110                    Value::Text(conname.clone()),
11111                    Value::Text(tname.clone()),
11112                    Value::Text(col_name_at(local)),
11113                    Value::Int(ordinal),
11114                    Value::Text(String::new()),
11115                    Value::Text(String::new()),
11116                ]));
11117            }
11118        }
11119    }
11120    (schema, rows)
11121}
11122
11123/// v7.17.0 Phase 3.P0-64 — synthesise
11124/// `information_schema.REFERENTIAL_CONSTRAINTS`. One row per FK.
11125fn synth_info_referential_constraints(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11126    let schema = alloc::vec![
11127        ColumnSchema::new("constraint_name", DataType::Text, false),
11128        ColumnSchema::new("table_name", DataType::Text, false),
11129        ColumnSchema::new("referenced_table_name", DataType::Text, false),
11130        ColumnSchema::new("update_rule", DataType::Text, false),
11131        ColumnSchema::new("delete_rule", DataType::Text, false),
11132    ];
11133    fn rule_name(a: spg_storage::FkAction) -> &'static str {
11134        match a {
11135            spg_storage::FkAction::Cascade => "CASCADE",
11136            spg_storage::FkAction::SetNull => "SET NULL",
11137            spg_storage::FkAction::SetDefault => "SET DEFAULT",
11138            spg_storage::FkAction::Restrict => "RESTRICT",
11139            spg_storage::FkAction::NoAction => "NO ACTION",
11140        }
11141    }
11142    let mut rows: Vec<Row> = Vec::new();
11143    for tname in cat.table_names() {
11144        let Some(t) = cat.get(&tname) else { continue };
11145        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11146            let conname = fk
11147                .name
11148                .clone()
11149                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11150            rows.push(Row::new(alloc::vec![
11151                Value::Text(conname),
11152                Value::Text(tname.clone()),
11153                Value::Text(fk.parent_table.clone()),
11154                Value::Text(rule_name(fk.on_update).into()),
11155                Value::Text(rule_name(fk.on_delete).into()),
11156            ]));
11157        }
11158    }
11159    (schema, rows)
11160}
11161
11162/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.STATISTICS`.
11163/// One row per (index × column) — admin tools walk this to
11164/// surface index-cardinality estimates.
11165fn synth_info_statistics(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11166    let schema = alloc::vec![
11167        ColumnSchema::new("table_name", DataType::Text, false),
11168        ColumnSchema::new("index_name", DataType::Text, false),
11169        ColumnSchema::new("column_name", DataType::Text, false),
11170        ColumnSchema::new("seq_in_index", DataType::Int, false),
11171        ColumnSchema::new("non_unique", DataType::Int, false),
11172        ColumnSchema::new("index_type", DataType::Text, false),
11173    ];
11174    let mut rows: Vec<Row> = Vec::new();
11175    for tname in cat.table_names() {
11176        let Some(t) = cat.get(&tname) else { continue };
11177        for idx in t.indices() {
11178            let col = t
11179                .schema()
11180                .columns
11181                .get(idx.column_position)
11182                .map_or("?".into(), |c| c.name.clone());
11183            rows.push(Row::new(alloc::vec![
11184                Value::Text(tname.clone()),
11185                Value::Text(idx.name.clone()),
11186                Value::Text(col),
11187                Value::Int(1),
11188                Value::Int(i32::from(!idx.is_unique)),
11189                Value::Text("BTREE".into()),
11190            ]));
11191        }
11192    }
11193    (schema, rows)
11194}
11195
11196/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.ROUTINES`.
11197/// SPG has no user-defined functions in v7.17 so the surface is
11198/// always empty; admin tools just need the table to exist.
11199fn synth_info_routines() -> (Vec<ColumnSchema>, Vec<Row>) {
11200    let schema = alloc::vec![
11201        ColumnSchema::new("routine_name", DataType::Text, false),
11202        ColumnSchema::new("routine_type", DataType::Text, false),
11203        ColumnSchema::new("data_type", DataType::Text, false),
11204    ];
11205    (schema, Vec::new())
11206}
11207
11208/// v7.17.0 Phase 3.P0-54 — synthesise `pg_catalog.pg_constraint`.
11209/// ORM compilers (Diesel, sea-orm) and admin tools probe this for
11210/// FK / UNIQUE / PK / CHECK definitions to surface relationship
11211/// graphs and validation rules. SPG ships one row per
11212/// uniqueness constraint + foreign key declared in the catalog.
11213///
11214/// Schema columns exposed:
11215///   * conname (Text) — constraint name (synthetic when anonymous)
11216///   * contype (Text) — `p` PK, `u` UNIQUE, `f` FK, `c` CHECK
11217///   * conrelid (Text) — owner table name
11218///   * confrelid (Text) — referenced parent table (FK only;
11219///     empty string otherwise)
11220///   * conkey (Text) — comma-separated column names
11221///   * confkey (Text) — comma-separated parent column names (FK only)
11222fn synth_pg_constraint(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11223    let schema = alloc::vec![
11224        ColumnSchema::new("conname", DataType::Text, false),
11225        ColumnSchema::new("contype", DataType::Text, false),
11226        ColumnSchema::new("conrelid", DataType::Text, false),
11227        ColumnSchema::new("confrelid", DataType::Text, false),
11228        ColumnSchema::new("conkey", DataType::Text, false),
11229        ColumnSchema::new("confkey", DataType::Text, false),
11230    ];
11231    let mut rows: Vec<Row> = Vec::new();
11232    for tname in cat.table_names() {
11233        let Some(t) = cat.get(&tname) else { continue };
11234        let cols = &t.schema().columns;
11235        let col_name_at = |pos: usize| -> String {
11236            cols.get(pos)
11237                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
11238        };
11239        // Uniqueness constraints (composite UNIQUE / PRIMARY KEY).
11240        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
11241            let kind = if uc.is_primary_key { "p" } else { "u" };
11242            let conname = if uc.is_primary_key {
11243                alloc::format!("{}_pkey", tname)
11244            } else {
11245                alloc::format!("{}_uniq{ci}", tname)
11246            };
11247            let conkey: Vec<String> = uc.columns.iter().map(|&p| col_name_at(p)).collect();
11248            rows.push(Row::new(alloc::vec![
11249                Value::Text(conname),
11250                Value::Text(kind.into()),
11251                Value::Text(tname.clone()),
11252                Value::Text(String::new()),
11253                Value::Text(conkey.join(",")),
11254                Value::Text(String::new()),
11255            ]));
11256        }
11257        // Single-column PK / UNIQUE indexes that have no
11258        // matching entry in `uniqueness_constraints` (the engine
11259        // creates only the BTree index for the bare-column case;
11260        // composite forms ride the UC path above).
11261        for idx in t.indices() {
11262            if !idx.is_unique {
11263                continue;
11264            }
11265            let is_primary = idx.name.ends_with("_pkey");
11266            let conname = idx.name.clone();
11267            let kind = if is_primary { "p" } else { "u" };
11268            let col_name = col_name_at(idx.column_position);
11269            // Skip if already emitted via the UC loop above (same
11270            // tuple shape — single-column).
11271            let already = t
11272                .schema()
11273                .uniqueness_constraints
11274                .iter()
11275                .any(|uc| uc.columns.len() == 1 && uc.columns[0] == idx.column_position);
11276            if already {
11277                continue;
11278            }
11279            rows.push(Row::new(alloc::vec![
11280                Value::Text(conname),
11281                Value::Text(kind.into()),
11282                Value::Text(tname.clone()),
11283                Value::Text(String::new()),
11284                Value::Text(col_name),
11285                Value::Text(String::new()),
11286            ]));
11287        }
11288        // Foreign keys.
11289        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11290            let conname = fk
11291                .name
11292                .clone()
11293                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11294            let conkey: Vec<String> = fk.local_columns.iter().map(|&p| col_name_at(p)).collect();
11295            // Parent column names: look up the parent table's
11296            // schema if it exists; otherwise emit positions.
11297            let confkey: Vec<String> = if let Some(parent) = cat.get(&fk.parent_table) {
11298                fk.parent_columns
11299                    .iter()
11300                    .map(|&p| {
11301                        parent
11302                            .schema()
11303                            .columns
11304                            .get(p)
11305                            .map_or_else(|| alloc::format!("col{p}"), |c| c.name.clone())
11306                    })
11307                    .collect()
11308            } else {
11309                fk.parent_columns
11310                    .iter()
11311                    .map(|p| alloc::format!("col{p}"))
11312                    .collect()
11313            };
11314            rows.push(Row::new(alloc::vec![
11315                Value::Text(conname),
11316                Value::Text("f".into()),
11317                Value::Text(tname.clone()),
11318                Value::Text(fk.parent_table.clone()),
11319                Value::Text(conkey.join(",")),
11320                Value::Text(confkey.join(",")),
11321            ]));
11322        }
11323    }
11324    (schema, rows)
11325}
11326
11327/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_database`.
11328/// SPG is single-database so we surface a single row keyed on the
11329/// canonical `postgres` database name (matching what every PG
11330/// admin tool's startup screen expects to find).
11331fn synth_pg_database(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11332    let schema = alloc::vec![
11333        ColumnSchema::new("oid", DataType::BigInt, false),
11334        ColumnSchema::new("datname", DataType::Text, false),
11335        ColumnSchema::new("datdba", DataType::BigInt, false),
11336        ColumnSchema::new("encoding", DataType::Int, false),
11337        ColumnSchema::new("datcollate", DataType::Text, false),
11338    ];
11339    let rows = alloc::vec![Row::new(alloc::vec![
11340        Value::BigInt(16384),
11341        Value::Text("postgres".into()),
11342        Value::BigInt(10),
11343        Value::Int(6), // UTF8
11344        Value::Text("en_US.UTF-8".into()),
11345    ])];
11346    (schema, rows)
11347}
11348
11349/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_roles`. PG's
11350/// pg_roles is a view over pg_authid showing all roles. SPG ships
11351/// one row per declared user from the engine's UserStore so admin
11352/// tool startup screens can populate.
11353fn synth_pg_roles(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11354    let schema = alloc::vec![
11355        ColumnSchema::new("oid", DataType::BigInt, false),
11356        ColumnSchema::new("rolname", DataType::Text, false),
11357        ColumnSchema::new("rolsuper", DataType::Bool, false),
11358        ColumnSchema::new("rolinherit", DataType::Bool, false),
11359        ColumnSchema::new("rolcanlogin", DataType::Bool, false),
11360    ];
11361    let mut rows: Vec<Row> = Vec::new();
11362    let oid: i64 = 10;
11363    for (i, (name, _)) in engine.users.iter().enumerate() {
11364        rows.push(Row::new(alloc::vec![
11365            Value::BigInt(oid + (i as i64) + 1),
11366            Value::Text(name.to_string()),
11367            Value::Bool(false),
11368            Value::Bool(true),
11369            Value::Bool(true),
11370        ]));
11371    }
11372    // Always include `postgres` as the bootstrap superuser if not
11373    // already present — admin tools probe for it.
11374    if !rows
11375        .iter()
11376        .any(|r| matches!(&r.values[1], Value::Text(s) if s == "postgres"))
11377    {
11378        rows.insert(
11379            0,
11380            Row::new(alloc::vec![
11381                Value::BigInt(10),
11382                Value::Text("postgres".into()),
11383                Value::Bool(true),
11384                Value::Bool(true),
11385                Value::Bool(true),
11386            ]),
11387        );
11388    }
11389    (schema, rows)
11390}
11391
11392/// v7.17.0 Phase 3.P0-56 — synthesise `pg_catalog.pg_views`. PG's
11393/// pg_views is a view listing every catalog view; SPG ships one
11394/// row per declared view + its definition text.
11395fn synth_pg_views(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11396    let schema = alloc::vec![
11397        ColumnSchema::new("schemaname", DataType::Text, false),
11398        ColumnSchema::new("viewname", DataType::Text, false),
11399        ColumnSchema::new("definition", DataType::Text, false),
11400    ];
11401    let mut rows: Vec<Row> = Vec::new();
11402    for (name, def) in cat.views() {
11403        rows.push(Row::new(alloc::vec![
11404            Value::Text("public".into()),
11405            Value::Text(name.clone()),
11406            Value::Text(def.body.clone()),
11407        ]));
11408    }
11409    (schema, rows)
11410}
11411
11412/// v7.17.0 Phase 3.P0-57 — synthesise `pg_catalog.pg_settings`. ORM
11413/// connection-checkers (sqlx pre-flight, Diesel migrator) and admin
11414/// tools read `pg_settings` to discover server-side configuration.
11415/// SPG surfaces every session_param + a small set of canonical PG
11416/// defaults so the pre-flight queries match.
11417fn synth_pg_settings(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11418    let schema = alloc::vec![
11419        ColumnSchema::new("name", DataType::Text, false),
11420        ColumnSchema::new("setting", DataType::Text, false),
11421        ColumnSchema::new("category", DataType::Text, false),
11422    ];
11423    let mut rows: Vec<Row> = Vec::new();
11424    // Canonical defaults every admin tool expects to find.
11425    let defaults: &[(&str, &str, &str)] = &[
11426        ("server_version", "16.0 (spg)", "Preset Options"),
11427        ("server_encoding", "UTF8", "Client Connection Defaults"),
11428        ("client_encoding", "UTF8", "Client Connection Defaults"),
11429        ("DateStyle", "ISO, MDY", "Client Connection Defaults"),
11430        ("TimeZone", "UTC", "Client Connection Defaults"),
11431        ("standard_conforming_strings", "on", "Compatibility"),
11432        ("integer_datetimes", "on", "Compatibility"),
11433        ("max_connections", "100", "Connections and Authentication"),
11434    ];
11435    for &(name, val, cat) in defaults {
11436        rows.push(Row::new(alloc::vec![
11437            Value::Text(name.into()),
11438            Value::Text(val.into()),
11439            Value::Text(cat.into()),
11440        ]));
11441    }
11442    // Session-set params override the static defaults.
11443    for (k, v) in &engine.session_params {
11444        if !defaults
11445            .iter()
11446            .any(|(n, _, _)| (*n).eq_ignore_ascii_case(k))
11447        {
11448            rows.push(Row::new(alloc::vec![
11449                Value::Text(k.clone()),
11450                Value::Text(v.clone()),
11451                Value::Text("Session".into()),
11452            ]));
11453        }
11454    }
11455    (schema, rows)
11456}
11457
11458/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_indexes`.
11459/// PG's pg_indexes is a real view on pg_index + pg_class + pg_attribute.
11460/// SPG ships it as a synthesised flat table so admin tools (pgAdmin,
11461/// DataGrip) can list indexes by tablename without joining four catalogs.
11462///
11463/// Schema columns exposed:
11464///   * schemaname (Text) — always `public`
11465///   * tablename (Text)
11466///   * indexname (Text)
11467///   * indexdef (Text) — best-effort CREATE INDEX DDL
11468fn synth_pg_indexes(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11469    let schema = alloc::vec![
11470        ColumnSchema::new("schemaname", DataType::Text, false),
11471        ColumnSchema::new("tablename", DataType::Text, false),
11472        ColumnSchema::new("indexname", DataType::Text, false),
11473        ColumnSchema::new("indexdef", DataType::Text, false),
11474    ];
11475    let mut rows: Vec<Row> = Vec::new();
11476    for tname in cat.table_names() {
11477        let Some(t) = cat.get(&tname) else { continue };
11478        for idx in t.indices() {
11479            let col_name = t
11480                .schema()
11481                .columns
11482                .get(idx.column_position)
11483                .map_or("?".into(), |c| c.name.clone());
11484            let unique_kw = if idx.is_unique { "UNIQUE " } else { "" };
11485            let indexdef = alloc::format!(
11486                "CREATE {unique_kw}INDEX {} ON public.{} ({})",
11487                idx.name,
11488                tname,
11489                col_name
11490            );
11491            rows.push(Row::new(alloc::vec![
11492                Value::Text("public".into()),
11493                Value::Text(tname.clone()),
11494                Value::Text(idx.name.clone()),
11495                Value::Text(indexdef),
11496            ]));
11497        }
11498    }
11499    (schema, rows)
11500}
11501
11502/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_index`. The
11503/// "raw" pg_index catalog used by PG-internal tooling for index
11504/// flags and ordinal information. SPG ships the columns ORM probes
11505/// actually filter on.
11506///
11507/// Schema columns exposed:
11508///   * indexrelid (BigInt) — index OID (synthetic = position+1)
11509///   * indrelid (BigInt) — table OID (synthetic = position+1)
11510///   * indnatts (Int) — number of indexed columns
11511///   * indisunique (Bool)
11512///   * indisprimary (Bool)
11513fn synth_pg_index_raw(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11514    let schema = alloc::vec![
11515        ColumnSchema::new("indexrelid", DataType::BigInt, false),
11516        ColumnSchema::new("indrelid", DataType::BigInt, false),
11517        ColumnSchema::new("indnatts", DataType::Int, false),
11518        ColumnSchema::new("indisunique", DataType::Bool, false),
11519        ColumnSchema::new("indisprimary", DataType::Bool, false),
11520    ];
11521    let mut rows: Vec<Row> = Vec::new();
11522    let mut idx_oid: i64 = 100_000;
11523    for (table_idx, tname) in cat.table_names().iter().enumerate() {
11524        let Some(t) = cat.get(tname) else { continue };
11525        for idx in t.indices() {
11526            idx_oid += 1;
11527            #[allow(clippy::cast_possible_wrap)]
11528            let nattrs = (1 + idx.extra_column_positions.len()) as i32;
11529            // is_primary: SPG / PG flag the primary via the
11530            // index name convention `<table>_pkey`.
11531            let is_primary = idx.name.ends_with("_pkey");
11532            rows.push(Row::new(alloc::vec![
11533                Value::BigInt(idx_oid),
11534                Value::BigInt((table_idx + 1) as i64),
11535                Value::Int(nattrs),
11536                Value::Bool(idx.is_unique),
11537                Value::Bool(is_primary),
11538            ]));
11539        }
11540    }
11541    (schema, rows)
11542}
11543
11544/// v7.17.0 Phase 3.P0-52 — synthesise `pg_catalog.pg_namespace`.
11545/// SPG is single-schema so we expose the canonical PG schemas:
11546/// `public` (user-facing), `pg_catalog` (built-in), and
11547/// `information_schema` (PG meta).
11548fn synth_pg_namespace(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11549    let schema = alloc::vec![
11550        ColumnSchema::new("oid", DataType::BigInt, false),
11551        ColumnSchema::new("nspname", DataType::Text, false),
11552        ColumnSchema::new("nspowner", DataType::BigInt, false),
11553    ];
11554    let rows = alloc::vec![
11555        Row::new(alloc::vec![
11556            Value::BigInt(11),
11557            Value::Text("pg_catalog".into()),
11558            Value::BigInt(10),
11559        ]),
11560        Row::new(alloc::vec![
11561            Value::BigInt(2200),
11562            Value::Text("public".into()),
11563            Value::BigInt(10),
11564        ]),
11565        Row::new(alloc::vec![
11566            Value::BigInt(13000),
11567            Value::Text("information_schema".into()),
11568            Value::BigInt(10),
11569        ]),
11570    ];
11571    (schema, rows)
11572}
11573
11574/// v7.16.2 — drop the synthesised meta view into the enriched
11575/// catalog so the regular FROM-resolution path can see it.
11576fn materialise_meta_view(
11577    catalog: &mut Catalog,
11578    name: &str,
11579    columns: Vec<ColumnSchema>,
11580    rows: Vec<Row>,
11581) -> Result<(), EngineError> {
11582    let schema = TableSchema::new(name.to_string(), columns);
11583    catalog.create_table(schema).map_err(EngineError::Storage)?;
11584    let table = catalog
11585        .get_mut(name)
11586        .expect("just-created meta view must exist");
11587    for row in rows {
11588        table.insert(row).map_err(EngineError::Storage)?;
11589    }
11590    Ok(())
11591}
11592
11593/// v7.16.2 — true when the SELECT statement references any
11594/// `__spg_info_*` or `__spg_pg_*` synthetic table name (the
11595/// parser produces these for `information_schema.X` /
11596/// `pg_catalog.X`). Used by `exec_select_cancel` to short-
11597/// circuit into the meta-view materialisation path.
11598/// v7.17.0 Phase 1.2 — append the names of any catalog-known
11599/// views referenced by `tref` to `into`. Helper for
11600/// `Engine::expand_views_in_select`. A view that's been already
11601/// materialised as a table (e.g. via the synthetic CTE pass for
11602/// SELECT FROM v) is skipped — the table form wins so the
11603/// recursive exec_select_cancel call inside exec_with_ctes
11604/// doesn't re-expand and trigger the CTE-shadow guard.
11605fn collect_view_refs(
11606    tref: &spg_sql::ast::TableRef,
11607    cat: &spg_storage::Catalog,
11608    into: &mut Vec<String>,
11609) {
11610    if cat.views().contains_key(&tref.name)
11611        && cat.get(&tref.name).is_none()
11612        && !into.iter().any(|n| n == &tref.name)
11613    {
11614        into.push(tref.name.clone());
11615    }
11616}
11617
11618fn select_references_meta_view(stmt: &SelectStatement) -> bool {
11619    fn is_meta(name: &str) -> bool {
11620        name.starts_with("__spg_info_")
11621            || name.starts_with("__spg_pg_")
11622            || name.starts_with("__spg_mysql_")
11623    }
11624    if let Some(from) = &stmt.from {
11625        if is_meta(&from.primary.name) {
11626            return true;
11627        }
11628        for j in &from.joins {
11629            if is_meta(&j.table.name) {
11630                return true;
11631            }
11632        }
11633    }
11634    for cte in &stmt.ctes {
11635        if select_references_meta_view(&cte.body) {
11636            return true;
11637        }
11638    }
11639    false
11640}
11641
11642/// v7.16.2 — collect every meta-view name a SELECT touches.
11643/// Returns a deduplicated, sorted list. Caller materialises
11644/// each one into the enriched catalog before re-running the
11645/// SELECT. Walks JOINs, CTEs, and the primary FROM.
11646fn collect_meta_view_names(
11647    stmt: &SelectStatement,
11648    into: &mut alloc::collections::BTreeSet<String>,
11649) {
11650    fn is_meta(name: &str) -> bool {
11651        name.starts_with("__spg_info_")
11652            || name.starts_with("__spg_pg_")
11653            || name.starts_with("__spg_mysql_")
11654    }
11655    if let Some(from) = &stmt.from {
11656        if is_meta(&from.primary.name) {
11657            into.insert(from.primary.name.clone());
11658        }
11659        for j in &from.joins {
11660            if is_meta(&j.table.name) {
11661                into.insert(j.table.name.clone());
11662            }
11663        }
11664    }
11665    for cte in &stmt.ctes {
11666        collect_meta_view_names(&cte.body, into);
11667    }
11668}
11669
11670fn infer_column_types(columns: &[ColumnSchema], rows: &[Row]) -> Vec<ColumnSchema> {
11671    let mut out = columns.to_vec();
11672    for (col_idx, col) in out.iter_mut().enumerate() {
11673        if col.ty != DataType::Text {
11674            continue;
11675        }
11676        let mut inferred: Option<DataType> = None;
11677        let mut all_null = true;
11678        for row in rows {
11679            let Some(v) = row.values.get(col_idx) else {
11680                continue;
11681            };
11682            let ty = match v {
11683                Value::Null => continue,
11684                Value::SmallInt(_) => DataType::SmallInt,
11685                Value::Int(_) => DataType::Int,
11686                Value::BigInt(_) => DataType::BigInt,
11687                Value::Float(_) => DataType::Float,
11688                Value::Bool(_) => DataType::Bool,
11689                Value::Vector(_) => DataType::Vector {
11690                    dim: 0,
11691                    encoding: VecEncoding::F32,
11692                },
11693                _ => DataType::Text,
11694            };
11695            all_null = false;
11696            inferred = Some(match inferred {
11697                None => ty,
11698                Some(prev) if prev == ty => prev,
11699                Some(_) => DataType::Text,
11700            });
11701        }
11702        if let Some(t) = inferred {
11703            col.ty = t;
11704            col.nullable = true;
11705        } else if all_null {
11706            col.nullable = true;
11707        }
11708    }
11709    out
11710}
11711
11712/// v4.26: render a human-readable plan tree for `EXPLAIN <select>`.
11713/// Lines are pushed into `out`; `depth` controls indentation. We
11714/// describe the rewritten SELECT — what the executor *would* do —
11715/// using the engine handle to spot indexed lookups and table shapes.
11716#[allow(clippy::too_many_lines, clippy::format_push_string)]
11717/// v6.2.4 — Walk every line of the rendered plan tree and append
11718/// per-operator stats. Lines that name a known operator get
11719/// `(rows=N)` (`actual_rows` of the top-level operator equals the
11720/// final result row count; scans report their catalog row count
11721/// as the rows-considered metric). Other lines — Filter / Join /
11722/// GroupBy / OrderBy etc. — are marked `(—)` so the surface is
11723/// complete-by-construction; v6.2.5 fills these in via inline
11724/// executor counters.
11725/// v6.8.3 — surface "CREATE INDEX …" suggestions for every
11726/// `(table, column)` pair the query touches via WHERE / JOIN
11727/// that doesn't already have an index on the owning table.
11728/// Walks the SELECT's FROM clauses + WHERE expression tree;
11729/// returns one line per missing index. Deterministic order:
11730/// FROM-clause iteration order, then column-reference walk
11731/// order inside each WHERE. Each suggestion is a copy-pastable
11732/// DDL string.
11733fn build_index_suggestions(stmt: &SelectStatement, engine: &Engine) -> Vec<String> {
11734    use alloc::collections::BTreeSet;
11735    let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
11736    let mut out: Vec<String> = Vec::new();
11737    let cat = engine.active_catalog();
11738    // Build a (table, qualifier-or-alias) list from the FROM clause
11739    // so unqualified column refs in WHERE resolve to the correct
11740    // table.
11741    let Some(from) = &stmt.from else {
11742        return out;
11743    };
11744    let mut tables: Vec<String> = Vec::new();
11745    tables.push(from.primary.name.clone());
11746    for j in &from.joins {
11747        tables.push(j.table.name.clone());
11748    }
11749    // Collect column refs from the WHERE expression. JOIN ON
11750    // predicates also feed in.
11751    let mut col_refs: Vec<spg_sql::ast::ColumnName> = Vec::new();
11752    if let Some(w) = &stmt.where_ {
11753        collect_column_refs(w, &mut col_refs);
11754    }
11755    for j in &from.joins {
11756        if let Some(on) = &j.on {
11757            collect_column_refs(on, &mut col_refs);
11758        }
11759    }
11760    for cn in &col_refs {
11761        // Resolve owner table: explicit qualifier first, else
11762        // first table in FROM that has a column of this name.
11763        let owner: Option<String> = if let Some(q) = &cn.qualifier {
11764            tables.iter().find(|t| t == &q).cloned()
11765        } else {
11766            tables.iter().find_map(|t| {
11767                cat.get(t).and_then(|tbl| {
11768                    if tbl.schema().column_position(&cn.name).is_some() {
11769                        Some(t.clone())
11770                    } else {
11771                        None
11772                    }
11773                })
11774            })
11775        };
11776        let Some(owner) = owner else {
11777            continue;
11778        };
11779        let Some(tbl) = cat.get(&owner) else {
11780            continue;
11781        };
11782        let Some(col_pos) = tbl.schema().column_position(&cn.name) else {
11783            continue;
11784        };
11785        // Skip if any BTree index already covers this column as
11786        // its key.
11787        let already_indexed = tbl.indices().iter().any(|i| {
11788            matches!(i.kind, spg_storage::IndexKind::BTree(_))
11789                && i.column_position == col_pos
11790                && i.expression.is_none()
11791                && i.partial_predicate.is_none()
11792        });
11793        if already_indexed {
11794            continue;
11795        }
11796        if seen.insert((owner.clone(), cn.name.clone())) {
11797            out.push(alloc::format!(
11798                "SUGGEST: CREATE INDEX ix_{}_{} ON {} ({})",
11799                owner,
11800                cn.name,
11801                owner,
11802                cn.name
11803            ));
11804        }
11805    }
11806    out
11807}
11808
11809/// Walks an `Expr` and pushes every `ColumnName` it references.
11810/// Order is depth-first, left-to-right.
11811fn collect_column_refs(expr: &Expr, out: &mut Vec<spg_sql::ast::ColumnName>) {
11812    match expr {
11813        Expr::Column(cn) => out.push(cn.clone()),
11814        Expr::FunctionCall { args, .. } => {
11815            for a in args {
11816                collect_column_refs(a, out);
11817            }
11818        }
11819        Expr::Binary { lhs, rhs, .. } => {
11820            collect_column_refs(lhs, out);
11821            collect_column_refs(rhs, out);
11822        }
11823        Expr::Unary { expr: e, .. } => collect_column_refs(e, out),
11824        _ => {}
11825    }
11826}
11827
11828fn annotate_explain_lines(lines: &mut [String], total_rows: usize, engine: &Engine) {
11829    let catalog = engine.active_catalog();
11830    let cold_ids = catalog.cold_segment_ids_global();
11831    let any_cold = !cold_ids.is_empty();
11832    let cold_ids_repr = if any_cold {
11833        let mut s = alloc::string::String::from("[");
11834        for (i, id) in cold_ids.iter().enumerate() {
11835            if i > 0 {
11836                s.push(',');
11837            }
11838            s.push_str(&alloc::format!("{id}"));
11839        }
11840        s.push(']');
11841        s
11842    } else {
11843        alloc::string::String::new()
11844    };
11845    for (idx, line) in lines.iter_mut().enumerate() {
11846        let trimmed = line.trim_start();
11847        let is_top_level = idx == 0;
11848        if is_top_level {
11849            line.push_str(&alloc::format!(" (rows={total_rows})"));
11850            continue;
11851        }
11852        if let Some(rest) = trimmed.strip_prefix("From: ") {
11853            let (name, scan_kind) = match rest.split_once(" [") {
11854                Some((n, k)) => (n.trim(), k.trim_end_matches(']')),
11855                None => (rest.trim(), ""),
11856            };
11857            let bare = name.split_whitespace().next().unwrap_or(name);
11858            let hot = catalog.get(bare).map(|t| t.rows().len());
11859            // v6.2.7 — `cold_segments=[id0,id1,…]` enumerates every
11860            // cold-tier segment the scan COULD have walked. v6.2.x
11861            // can tighten to per-table by walking the table's
11862            // BTree-index cold locators.
11863            let annot = match (hot, scan_kind) {
11864                (Some(h), "full scan") => {
11865                    let mut s = alloc::format!(" (hot_rows={h}");
11866                    if any_cold {
11867                        s.push_str(&alloc::format!(
11868                            ", cold_tier=present, cold_segments={cold_ids_repr}"
11869                        ));
11870                    }
11871                    s.push(')');
11872                    s
11873                }
11874                (Some(h), "index seek") => {
11875                    let mut s = alloc::format!(" (hot_rows≤{h}");
11876                    if any_cold {
11877                        s.push_str(&alloc::format!(
11878                            ", cold_tier=present, cold_segments={cold_ids_repr}"
11879                        ));
11880                    }
11881                    s.push(')');
11882                    s
11883                }
11884                _ => " (rows=—)".to_string(),
11885            };
11886            line.push_str(&annot);
11887            continue;
11888        }
11889        // Filter / GroupBy / Having / OrderBy / Limit / Join etc.
11890        line.push_str(" (rows=—)");
11891    }
11892}
11893
11894fn explain_select(stmt: &SelectStatement, engine: &Engine, depth: usize, out: &mut Vec<String>) {
11895    let pad = "  ".repeat(depth);
11896    // 1) Top-level operator label.
11897    let top = if !stmt.ctes.is_empty() {
11898        if stmt.ctes.iter().any(|c| c.recursive) {
11899            "CTEScan (WITH RECURSIVE)"
11900        } else {
11901            "CTEScan (WITH)"
11902        }
11903    } else if !stmt.unions.is_empty() {
11904        "UnionScan"
11905    } else if select_has_window(stmt) {
11906        "WindowAgg"
11907    } else if aggregate::uses_aggregate(stmt) {
11908        "Aggregate"
11909    } else if stmt.distinct {
11910        "Distinct"
11911    } else if stmt.from.is_some() {
11912        "TableScan"
11913    } else {
11914        "Result"
11915    };
11916    out.push(alloc::format!("{pad}{top}"));
11917    let child = "  ".repeat(depth + 1);
11918    // 2) CTE bodies.
11919    for cte in &stmt.ctes {
11920        let head = if cte.recursive {
11921            alloc::format!("{child}CTE (recursive): {}", cte.name)
11922        } else {
11923            alloc::format!("{child}CTE: {}", cte.name)
11924        };
11925        out.push(head);
11926        explain_select(&cte.body, engine, depth + 2, out);
11927    }
11928    // 3) FROM details — primary table + joins, index hits.
11929    if let Some(from) = &stmt.from {
11930        let mut tag = alloc::format!("{child}From: {}", from.primary.name);
11931        if let Some(alias) = &from.primary.alias {
11932            tag.push_str(&alloc::format!(" AS {alias}"));
11933        }
11934        // Try to detect an index-seek opportunity on WHERE against
11935        // the primary table — same heuristic the executor uses.
11936        if let Some(w) = &stmt.where_
11937            && let Some(table) = engine.active_catalog().get(&from.primary.name)
11938        {
11939            let alias = from.primary.alias.as_deref().unwrap_or(&from.primary.name);
11940            let cols = &table.schema().columns;
11941            if try_index_seek(w, cols, engine.active_catalog(), table, alias).is_some() {
11942                tag.push_str(" [index seek]");
11943            } else {
11944                tag.push_str(" [full scan]");
11945            }
11946        } else {
11947            tag.push_str(" [full scan]");
11948        }
11949        out.push(tag);
11950        for j in &from.joins {
11951            let kind = match j.kind {
11952                spg_sql::ast::JoinKind::Inner => "INNER JOIN",
11953                spg_sql::ast::JoinKind::Left => "LEFT JOIN",
11954                spg_sql::ast::JoinKind::Cross => "CROSS JOIN",
11955            };
11956            let mut s = alloc::format!("{child}{kind}: {}", j.table.name);
11957            if let Some(alias) = &j.table.alias {
11958                s.push_str(&alloc::format!(" AS {alias}"));
11959            }
11960            if j.on.is_some() {
11961                s.push_str(" (ON …)");
11962            }
11963            out.push(s);
11964        }
11965    }
11966    // 4) WHERE / GROUP BY / HAVING / ORDER BY / LIMIT / OFFSET.
11967    if let Some(w) = &stmt.where_ {
11968        let mut s = alloc::format!("{child}Filter: {w}");
11969        if expr_has_subquery(w) {
11970            s.push_str(" [subquery]");
11971        }
11972        out.push(s);
11973    }
11974    if let Some(gs) = &stmt.group_by {
11975        let mut parts = Vec::new();
11976        for g in gs {
11977            parts.push(alloc::format!("{g}"));
11978        }
11979        out.push(alloc::format!("{child}GroupBy: {}", parts.join(", ")));
11980    }
11981    if let Some(h) = &stmt.having {
11982        out.push(alloc::format!("{child}Having: {h}"));
11983    }
11984    for o in &stmt.order_by {
11985        let dir = if o.desc { "DESC" } else { "ASC" };
11986        out.push(alloc::format!("{child}OrderBy: {} {dir}", o.expr));
11987    }
11988    if let Some(lim) = stmt.limit {
11989        out.push(alloc::format!("{child}Limit: {lim}"));
11990    }
11991    if let Some(off) = stmt.offset {
11992        out.push(alloc::format!("{child}Offset: {off}"));
11993    }
11994    // 5) Projection — collapse Wildcard or render N items.
11995    if stmt
11996        .items
11997        .iter()
11998        .any(|it| matches!(it, SelectItem::Wildcard))
11999    {
12000        out.push(alloc::format!("{child}Project: *"));
12001    } else {
12002        out.push(alloc::format!(
12003            "{child}Project: {} item(s)",
12004            stmt.items.len()
12005        ));
12006    }
12007    // 6) Recurse into UNION peers.
12008    for (kind, peer) in &stmt.unions {
12009        let label = match kind {
12010            UnionKind::All => "UNION ALL",
12011            UnionKind::Distinct => "UNION",
12012        };
12013        out.push(alloc::format!("{child}{label}"));
12014        explain_select(peer, engine, depth + 2, out);
12015    }
12016}
12017
12018/// v4.23: recognise the engine errors that indicate the inner
12019/// SELECT couldn't be evaluated in isolation because it references
12020/// an outer column — used by `subquery_replacement` to skip
12021/// materialisation and let row-eval handle it instead.
12022fn is_correlation_error(e: &EngineError) -> bool {
12023    matches!(
12024        e,
12025        EngineError::Eval(
12026            eval::EvalError::ColumnNotFound { .. } | eval::EvalError::UnknownQualifier { .. }
12027        )
12028    )
12029}
12030
12031/// v4.23: walk every Expr in `stmt` and replace each Column ref
12032/// that targets the outer scope (qualifier matches the outer
12033/// table alias) with a Literal carrying the outer row's value.
12034/// Conservative: only qualified refs are substituted, so the user
12035/// must write `outer_alias.col` to reference an outer column. This
12036/// matches PG's lexical scoping for correlated subqueries and
12037/// avoids accidentally rebinding inner columns of the same name.
12038/// v7.17.0 Phase 3.P0-41 — LATERAL peer descriptor. Either eagerly
12039/// materialised (every regular table / unnest / generate_series) or
12040/// lateral (subquery re-evaluated per outer row).
12041struct JoinedPeer<'a> {
12042    eager_rows: Option<Vec<Row>>,
12043    cols: Vec<ColumnSchema>,
12044    alias: String,
12045    kind: JoinKind,
12046    on: Option<&'a Expr>,
12047    lateral: Option<&'a SelectStatement>,
12048}
12049
12050/// v7.17.0 Phase 3.P0-41 — synthesise a column name for a LATERAL
12051/// projection item that has no explicit alias. PG names anonymous
12052/// projection items by the function call's name or by `column<i>`.
12053/// SPG mirrors the latter (lower-overhead than walking arbitrary
12054/// Expr shapes) so the probe-schema fallback path produces stable
12055/// names for the lateral peer's columns.
12056fn synth_lateral_col_name(expr: &Expr, idx: usize) -> String {
12057    match expr {
12058        // Bare column reference — use the column's own name.
12059        Expr::Column(c) => c.name.clone(),
12060        // Function call — use the function name (PG canonical:
12061        // `count` / `max` / `lower` …).
12062        Expr::FunctionCall { name, .. } => name.clone(),
12063        // Cast — drill into the inner expression.
12064        Expr::Cast { expr: inner, .. } => synth_lateral_col_name(inner, idx),
12065        // Everything else falls back to PG's `column<N>` placeholder.
12066        _ => alloc::format!("column{}", idx + 1),
12067    }
12068}
12069
12070/// v7.17.0 Phase 3.P0-41 — substitute every `<alias>.<col>` Expr
12071/// reference whose `<alias>.<col>` exists in the outer composite
12072/// schema with the matching value from the outer row. Walks the
12073/// entire SELECT body (items, WHERE, GROUP BY, HAVING, ORDER BY,
12074/// UNION peers) so any depth of outer reference inside the
12075/// LATERAL subquery resolves before execution.
12076fn substitute_outer_columns_multi(
12077    stmt: &mut SelectStatement,
12078    outer_row: &Row,
12079    outer_schema: &[ColumnSchema],
12080) {
12081    substitute_outer_in_select(stmt, outer_row, outer_schema);
12082}
12083
12084fn substitute_outer_in_select(
12085    stmt: &mut SelectStatement,
12086    outer_row: &Row,
12087    outer_schema: &[ColumnSchema],
12088) {
12089    for item in &mut stmt.items {
12090        if let SelectItem::Expr { expr, .. } = item {
12091            substitute_outer_in_expr(expr, outer_row, outer_schema);
12092        }
12093    }
12094    if let Some(w) = &mut stmt.where_ {
12095        substitute_outer_in_expr(w, outer_row, outer_schema);
12096    }
12097    if let Some(gs) = &mut stmt.group_by {
12098        for g in gs {
12099            substitute_outer_in_expr(g, outer_row, outer_schema);
12100        }
12101    }
12102    if let Some(h) = &mut stmt.having {
12103        substitute_outer_in_expr(h, outer_row, outer_schema);
12104    }
12105    for o in &mut stmt.order_by {
12106        substitute_outer_in_expr(&mut o.expr, outer_row, outer_schema);
12107    }
12108    for (_, peer) in &mut stmt.unions {
12109        substitute_outer_in_select(peer, outer_row, outer_schema);
12110    }
12111}
12112
12113fn substitute_outer_in_expr(e: &mut Expr, outer_row: &Row, outer_schema: &[ColumnSchema]) {
12114    if let Expr::Column(c) = e
12115        && let Some(qual) = &c.qualifier
12116    {
12117        let composite = alloc::format!("{qual}.{}", c.name);
12118        if let Some(idx) = outer_schema
12119            .iter()
12120            .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
12121        {
12122            let v = outer_row.values.get(idx).cloned().unwrap_or(Value::Null);
12123            if let Ok(lit) = value_to_literal_expr(v) {
12124                *e = lit;
12125                return;
12126            }
12127        }
12128    }
12129    match e {
12130        Expr::Binary { lhs, rhs, .. } => {
12131            substitute_outer_in_expr(lhs, outer_row, outer_schema);
12132            substitute_outer_in_expr(rhs, outer_row, outer_schema);
12133        }
12134        Expr::Unary { expr: inner, .. } => {
12135            substitute_outer_in_expr(inner, outer_row, outer_schema);
12136        }
12137        Expr::FunctionCall { args, .. } => {
12138            for a in args {
12139                substitute_outer_in_expr(a, outer_row, outer_schema);
12140            }
12141        }
12142        Expr::Cast { expr: inner, .. } => {
12143            substitute_outer_in_expr(inner, outer_row, outer_schema);
12144        }
12145        Expr::Case {
12146            operand,
12147            branches,
12148            else_branch,
12149        } => {
12150            if let Some(op) = operand {
12151                substitute_outer_in_expr(op, outer_row, outer_schema);
12152            }
12153            for (cond, val) in branches {
12154                substitute_outer_in_expr(cond, outer_row, outer_schema);
12155                substitute_outer_in_expr(val, outer_row, outer_schema);
12156            }
12157            if let Some(e) = else_branch {
12158                substitute_outer_in_expr(e, outer_row, outer_schema);
12159            }
12160        }
12161        _ => {}
12162    }
12163}
12164
12165fn substitute_outer_columns(stmt: &mut SelectStatement, row: &Row, ctx: &EvalContext<'_>) {
12166    let Some(outer_alias) = ctx.table_alias else {
12167        return;
12168    };
12169    substitute_in_select(stmt, row, ctx, outer_alias);
12170}
12171
12172fn substitute_in_select(
12173    stmt: &mut SelectStatement,
12174    row: &Row,
12175    ctx: &EvalContext<'_>,
12176    outer_alias: &str,
12177) {
12178    for item in &mut stmt.items {
12179        if let SelectItem::Expr { expr, .. } = item {
12180            substitute_in_expr(expr, row, ctx, outer_alias);
12181        }
12182    }
12183    if let Some(w) = &mut stmt.where_ {
12184        substitute_in_expr(w, row, ctx, outer_alias);
12185    }
12186    if let Some(gs) = &mut stmt.group_by {
12187        for g in gs {
12188            substitute_in_expr(g, row, ctx, outer_alias);
12189        }
12190    }
12191    if let Some(h) = &mut stmt.having {
12192        substitute_in_expr(h, row, ctx, outer_alias);
12193    }
12194    for o in &mut stmt.order_by {
12195        substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
12196    }
12197    for (_, peer) in &mut stmt.unions {
12198        substitute_in_select(peer, row, ctx, outer_alias);
12199    }
12200}
12201
12202fn substitute_in_expr(e: &mut Expr, row: &Row, ctx: &EvalContext<'_>, outer_alias: &str) {
12203    if let Expr::Column(c) = e
12204        && let Some(qual) = &c.qualifier
12205        && qual.eq_ignore_ascii_case(outer_alias)
12206    {
12207        // Look up the column's index in the outer schema.
12208        if let Some(idx) = ctx
12209            .columns
12210            .iter()
12211            .position(|sc| sc.name.eq_ignore_ascii_case(&c.name))
12212        {
12213            let v = row.values.get(idx).cloned().unwrap_or(Value::Null);
12214            if let Ok(lit) = value_to_literal_expr(v) {
12215                *e = lit;
12216                return;
12217            }
12218        }
12219    }
12220    match e {
12221        Expr::Binary { lhs, rhs, .. } => {
12222            substitute_in_expr(lhs, row, ctx, outer_alias);
12223            substitute_in_expr(rhs, row, ctx, outer_alias);
12224        }
12225        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12226            substitute_in_expr(expr, row, ctx, outer_alias);
12227        }
12228        Expr::Like { expr, pattern, .. } => {
12229            substitute_in_expr(expr, row, ctx, outer_alias);
12230            substitute_in_expr(pattern, row, ctx, outer_alias);
12231        }
12232        Expr::FunctionCall { args, .. } => {
12233            for a in args {
12234                substitute_in_expr(a, row, ctx, outer_alias);
12235            }
12236        }
12237        Expr::Extract { source, .. } => substitute_in_expr(source, row, ctx, outer_alias),
12238        Expr::WindowFunction {
12239            args,
12240            partition_by,
12241            order_by,
12242            ..
12243        } => {
12244            for a in args {
12245                substitute_in_expr(a, row, ctx, outer_alias);
12246            }
12247            for p in partition_by {
12248                substitute_in_expr(p, row, ctx, outer_alias);
12249            }
12250            for (o, _) in order_by {
12251                substitute_in_expr(o, row, ctx, outer_alias);
12252            }
12253        }
12254        Expr::ScalarSubquery(s) => substitute_in_select(s, row, ctx, outer_alias),
12255        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
12256            substitute_in_select(subquery, row, ctx, outer_alias);
12257        }
12258        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
12259        Expr::Array(items) => {
12260            for elem in items {
12261                substitute_in_expr(elem, row, ctx, outer_alias);
12262            }
12263        }
12264        Expr::ArraySubscript { target, index } => {
12265            substitute_in_expr(target, row, ctx, outer_alias);
12266            substitute_in_expr(index, row, ctx, outer_alias);
12267        }
12268        Expr::AnyAll { expr, array, .. } => {
12269            substitute_in_expr(expr, row, ctx, outer_alias);
12270            substitute_in_expr(array, row, ctx, outer_alias);
12271        }
12272        Expr::Case {
12273            operand,
12274            branches,
12275            else_branch,
12276        } => {
12277            if let Some(o) = operand {
12278                substitute_in_expr(o, row, ctx, outer_alias);
12279            }
12280            for (w, t) in branches {
12281                substitute_in_expr(w, row, ctx, outer_alias);
12282                substitute_in_expr(t, row, ctx, outer_alias);
12283            }
12284            if let Some(e) = else_branch {
12285                substitute_in_expr(e, row, ctx, outer_alias);
12286            }
12287        }
12288    }
12289}
12290
12291/// v4.22: encode a Row to a comparable byte key for UNION-DISTINCT
12292/// dedup inside the recursive iteration. Crude but deterministic
12293/// — Debug prints embed type discriminants so NULL ≠ "" ≠ 0.
12294fn encode_row_key(row: &Row) -> Vec<u8> {
12295    let mut out = Vec::new();
12296    for v in &row.values {
12297        let s = alloc::format!("{v:?}|");
12298        out.extend_from_slice(s.as_bytes());
12299    }
12300    out
12301}
12302
12303fn select_has_window(stmt: &SelectStatement) -> bool {
12304    for item in &stmt.items {
12305        if let SelectItem::Expr { expr, .. } = item
12306            && expr_has_window(expr)
12307        {
12308            return true;
12309        }
12310    }
12311    false
12312}
12313
12314fn expr_has_window(e: &Expr) -> bool {
12315    match e {
12316        Expr::WindowFunction { .. } => true,
12317        Expr::Binary { lhs, rhs, .. } => expr_has_window(lhs) || expr_has_window(rhs),
12318        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12319            expr_has_window(expr)
12320        }
12321        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_window),
12322        Expr::Like { expr, pattern, .. } => expr_has_window(expr) || expr_has_window(pattern),
12323        Expr::Extract { source, .. } => expr_has_window(source),
12324        Expr::ScalarSubquery(_)
12325        | Expr::Exists { .. }
12326        | Expr::InSubquery { .. }
12327        | Expr::Literal(_)
12328        | Expr::Placeholder(_)
12329        | Expr::Column(_) => false,
12330        Expr::Array(items) => items.iter().any(expr_has_window),
12331        Expr::ArraySubscript { target, index } => expr_has_window(target) || expr_has_window(index),
12332        Expr::AnyAll { expr, array, .. } => expr_has_window(expr) || expr_has_window(array),
12333        Expr::Case {
12334            operand,
12335            branches,
12336            else_branch,
12337        } => {
12338            operand.as_deref().is_some_and(expr_has_window)
12339                || branches
12340                    .iter()
12341                    .any(|(w, t)| expr_has_window(w) || expr_has_window(t))
12342                || else_branch.as_deref().is_some_and(expr_has_window)
12343        }
12344    }
12345}
12346
12347fn collect_window_nodes(e: &Expr, out: &mut Vec<Expr>) {
12348    if let Expr::WindowFunction { .. } = e {
12349        // Deduplicate by structural equality on the expression
12350        // (cheap because window args + partition + order are
12351        // small). Without dedup we'd recompute identical windows
12352        // once per occurrence in the projection.
12353        if !out.iter().any(|x| x == e) {
12354            out.push(e.clone());
12355        }
12356        return;
12357    }
12358    match e {
12359        // Already handled by the early-return at the top.
12360        Expr::WindowFunction { .. } => unreachable!(),
12361        Expr::Binary { lhs, rhs, .. } => {
12362            collect_window_nodes(lhs, out);
12363            collect_window_nodes(rhs, out);
12364        }
12365        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12366            collect_window_nodes(expr, out);
12367        }
12368        Expr::FunctionCall { args, .. } => {
12369            for a in args {
12370                collect_window_nodes(a, out);
12371            }
12372        }
12373        Expr::Like { expr, pattern, .. } => {
12374            collect_window_nodes(expr, out);
12375            collect_window_nodes(pattern, out);
12376        }
12377        Expr::Extract { source, .. } => collect_window_nodes(source, out),
12378        _ => {}
12379    }
12380}
12381
12382fn rewrite_window_to_columns(e: &mut Expr, window_nodes: &[Expr]) {
12383    if let Expr::WindowFunction { .. } = e
12384        && let Some(idx) = window_nodes.iter().position(|w| w == e)
12385    {
12386        *e = Expr::Column(spg_sql::ast::ColumnName {
12387            qualifier: None,
12388            name: alloc::format!("__win_{idx}"),
12389        });
12390        return;
12391    }
12392    match e {
12393        Expr::Binary { lhs, rhs, .. } => {
12394            rewrite_window_to_columns(lhs, window_nodes);
12395            rewrite_window_to_columns(rhs, window_nodes);
12396        }
12397        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12398            rewrite_window_to_columns(expr, window_nodes);
12399        }
12400        Expr::FunctionCall { args, .. } => {
12401            for a in args {
12402                rewrite_window_to_columns(a, window_nodes);
12403            }
12404        }
12405        Expr::Like { expr, pattern, .. } => {
12406            rewrite_window_to_columns(expr, window_nodes);
12407            rewrite_window_to_columns(pattern, window_nodes);
12408        }
12409        Expr::Extract { source, .. } => rewrite_window_to_columns(source, window_nodes),
12410        _ => {}
12411    }
12412}
12413
12414/// Total order over partition-key tuples. NULL sorts as the
12415/// lowest value (matches the `<` partial order's NULL-last
12416/// behaviour with `INFINITY` flipped).
12417fn partition_key_cmp(a: &[Value], b: &[Value]) -> core::cmp::Ordering {
12418    for (x, y) in a.iter().zip(b.iter()) {
12419        let c = value_cmp(x, y);
12420        if c != core::cmp::Ordering::Equal {
12421            return c;
12422        }
12423    }
12424    a.len().cmp(&b.len())
12425}
12426
12427fn order_key_cmp(a: &[(Value, bool)], b: &[(Value, bool)]) -> core::cmp::Ordering {
12428    for ((va, desc), (vb, _)) in a.iter().zip(b.iter()) {
12429        let c = value_cmp(va, vb);
12430        let c = if *desc { c.reverse() } else { c };
12431        if c != core::cmp::Ordering::Equal {
12432            return c;
12433        }
12434    }
12435    a.len().cmp(&b.len())
12436}
12437
12438/// v7.17.0 Phase 3.10 — true when the Value is one of the
12439/// integer-shaped variants `generate_series` accepts as a start
12440/// / stop / step component. Float / NUMERIC are rejected — PG's
12441/// `generate_series(numeric, numeric)` overload is out of v7.17
12442/// scope.
12443const fn value_is_integer(v: &Value) -> bool {
12444    matches!(v, Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_))
12445}
12446
12447/// v7.17.0 Phase 3.10 — widen any integer-shaped Value to i64 for
12448/// the generate_series iteration loop. Non-integer inputs panic;
12449/// caller guards via `value_is_integer`.
12450const fn value_to_i64(v: &Value) -> i64 {
12451    match v {
12452        Value::SmallInt(n) => *n as i64,
12453        Value::Int(n) => *n as i64,
12454        Value::BigInt(n) => *n,
12455        _ => panic!("value_to_i64 called on non-integer Value"),
12456    }
12457}
12458
12459/// v7.17.0 Phase 3.10 — integer-mode generate_series materialiser.
12460/// Step direction follows the sign: positive step iterates upward
12461/// (stops when current > stop); negative iterates downward; zero
12462/// errors. Caller-facing row stream is `BigInt`-typed so a single
12463/// projection schema covers SmallInt / Int / BigInt callers.
12464fn generate_series_integers(
12465    start: i64,
12466    stop: i64,
12467    step: i64,
12468    cancel: &CancelToken<'_>,
12469) -> Result<alloc::vec::Vec<Row>, EngineError> {
12470    if step == 0 {
12471        return Err(EngineError::Unsupported(
12472            "generate_series(): step argument cannot be zero".into(),
12473        ));
12474    }
12475    let mut out = alloc::vec::Vec::new();
12476    let mut cur = start;
12477    // Hard cap to keep a runaway call from eating all memory. PG
12478    // has no such cap but does honour query timeout; SPG's cancel
12479    // token will fire too — this is a defense-in-depth backstop.
12480    const MAX_ROWS: usize = 10_000_000;
12481    loop {
12482        cancel.check()?;
12483        if step > 0 && cur > stop {
12484            break;
12485        }
12486        if step < 0 && cur < stop {
12487            break;
12488        }
12489        out.push(Row::new(alloc::vec![Value::BigInt(cur)]));
12490        if out.len() > MAX_ROWS {
12491            return Err(EngineError::Unsupported(alloc::format!(
12492                "generate_series(): exceeded {MAX_ROWS} rows; \
12493                 narrow start/stop or use a larger step"
12494            )));
12495        }
12496        cur = match cur.checked_add(step) {
12497            Some(n) => n,
12498            None => break,
12499        };
12500    }
12501    Ok(out)
12502}
12503
12504/// v7.17.0 Phase 3.10 — timestamp-mode generate_series. step is a
12505/// `Value::Interval { months, micros }` per the caller's guard;
12506/// each iteration adds the interval via `apply_binary_interval`
12507/// so month-shifting handles short-month rollover (PG semantics).
12508fn generate_series_timestamps(
12509    start: i64,
12510    stop: i64,
12511    step: Value,
12512    cancel: &CancelToken<'_>,
12513) -> Result<alloc::vec::Vec<Row>, EngineError> {
12514    let (months, micros) = match &step {
12515        Value::Interval { months, micros } => (*months, *micros),
12516        _ => unreachable!("caller guards step.is_interval"),
12517    };
12518    if months == 0 && micros == 0 {
12519        return Err(EngineError::Unsupported(
12520            "generate_series(): INTERVAL step cannot be zero".into(),
12521        ));
12522    }
12523    let ascending = months > 0 || micros > 0;
12524    let mut out = alloc::vec::Vec::new();
12525    let mut cur = Value::Timestamp(start);
12526    const MAX_ROWS: usize = 10_000_000;
12527    loop {
12528        cancel.check()?;
12529        let cur_t = match cur {
12530            Value::Timestamp(t) => t,
12531            _ => unreachable!("loop invariant: cur is Timestamp"),
12532        };
12533        if ascending && cur_t > stop {
12534            break;
12535        }
12536        if !ascending && cur_t < stop {
12537            break;
12538        }
12539        out.push(Row::new(alloc::vec![Value::Timestamp(cur_t)]));
12540        if out.len() > MAX_ROWS {
12541            return Err(EngineError::Unsupported(alloc::format!(
12542                "generate_series(): exceeded {MAX_ROWS} rows; \
12543                 narrow start/stop or use a larger step"
12544            )));
12545        }
12546        let next = eval::apply_binary_interval(
12547            spg_sql::ast::BinOp::Add,
12548            &cur,
12549            &Value::Interval { months, micros },
12550        )
12551        .map_err(EngineError::Eval)?;
12552        cur = match next {
12553            Some(v) => v,
12554            None => break,
12555        };
12556    }
12557    Ok(out)
12558}
12559
12560#[allow(clippy::match_same_arms)] // explicit arms per type document the supported pairs
12561fn value_cmp(a: &Value, b: &Value) -> core::cmp::Ordering {
12562    use core::cmp::Ordering;
12563    match (a, b) {
12564        (Value::Null, Value::Null) => Ordering::Equal,
12565        (Value::Null, _) => Ordering::Less,
12566        (_, Value::Null) => Ordering::Greater,
12567        (Value::Int(x), Value::Int(y)) => x.cmp(y),
12568        (Value::BigInt(x), Value::BigInt(y)) => x.cmp(y),
12569        (Value::SmallInt(x), Value::SmallInt(y)) => x.cmp(y),
12570        (Value::Text(x), Value::Text(y)) => x.cmp(y),
12571        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
12572        (Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
12573        (Value::Date(x), Value::Date(y)) => x.cmp(y),
12574        (Value::Timestamp(x), Value::Timestamp(y)) => x.cmp(y),
12575        // Cross-type compare: fall back to the debug rendering —
12576        // same-partition is the goal, exact order is irrelevant.
12577        _ => alloc::format!("{a:?}").cmp(&alloc::format!("{b:?}")),
12578    }
12579}
12580
12581/// Compute the window function's per-row output for one partition.
12582/// `slice` has (partition key, order key, original-row-index)
12583/// tuples already sorted by order key. `filtered_rows` is the
12584/// full row list indexed by original-row-index. `out_vals` is
12585/// the destination, also indexed by original-row-index.
12586#[allow(
12587    clippy::too_many_arguments,
12588    clippy::cast_possible_truncation,
12589    clippy::cast_possible_wrap,
12590    clippy::cast_precision_loss,
12591    clippy::cast_sign_loss,
12592    clippy::doc_markdown,
12593    clippy::too_many_lines,
12594    clippy::type_complexity,
12595    clippy::match_same_arms
12596)]
12597fn compute_window_partition(
12598    name: &str,
12599    args: &[Expr],
12600    ordered: bool,
12601    frame: Option<&WindowFrame>,
12602    null_treatment: spg_sql::ast::NullTreatment,
12603    slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)],
12604    filtered_rows: &[&Row],
12605    ctx: &EvalContext<'_>,
12606    out_vals: &mut [Value],
12607) -> Result<(), EngineError> {
12608    let ignore_nulls = matches!(null_treatment, spg_sql::ast::NullTreatment::Ignore);
12609    let lower = name.to_ascii_lowercase();
12610    match lower.as_str() {
12611        "row_number" => {
12612            for (rank, (_, _, idx)) in slice.iter().enumerate() {
12613                out_vals[*idx] = Value::BigInt((rank + 1) as i64);
12614            }
12615            Ok(())
12616        }
12617        "rank" => {
12618            let mut prev_key: Option<&[(Value, bool)]> = None;
12619            let mut current_rank: i64 = 1;
12620            for (i, (_, okey, idx)) in slice.iter().enumerate() {
12621                if let Some(p) = prev_key
12622                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
12623                {
12624                    current_rank = (i + 1) as i64;
12625                }
12626                if prev_key.is_none() {
12627                    current_rank = 1;
12628                }
12629                out_vals[*idx] = Value::BigInt(current_rank);
12630                prev_key = Some(okey.as_slice());
12631            }
12632            Ok(())
12633        }
12634        "dense_rank" => {
12635            let mut prev_key: Option<&[(Value, bool)]> = None;
12636            let mut current_rank: i64 = 0;
12637            for (_, okey, idx) in slice {
12638                if prev_key.is_none_or(|p| order_key_cmp(p, okey) != core::cmp::Ordering::Equal) {
12639                    current_rank += 1;
12640                }
12641                out_vals[*idx] = Value::BigInt(current_rank);
12642                prev_key = Some(okey.as_slice());
12643            }
12644            Ok(())
12645        }
12646        "sum" | "avg" | "min" | "max" | "count" | "count_star" => {
12647            // Pre-evaluate the function arg per row in the slice
12648            // (count_star has no arg).
12649            let arg_values: Vec<Value> = if lower == "count_star" || args.is_empty() {
12650                slice.iter().map(|_| Value::Null).collect()
12651            } else {
12652                slice
12653                    .iter()
12654                    .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12655                    .collect::<Result<_, _>>()
12656                    .map_err(EngineError::Eval)?
12657            };
12658            // v4.20: pick the effective frame. Explicit frame
12659            // overrides the implicit default (running for ordered,
12660            // whole-partition for unordered).
12661            let eff = effective_frame(frame, ordered)?;
12662            #[allow(clippy::needless_range_loop)]
12663            for i in 0..slice.len() {
12664                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
12665                let mut sum: f64 = 0.0;
12666                let mut count: i64 = 0;
12667                let mut min_v: Option<f64> = None;
12668                let mut max_v: Option<f64> = None;
12669                let mut row_count: i64 = 0;
12670                if lo <= hi {
12671                    for j in lo..=hi {
12672                        let v = &arg_values[j];
12673                        match lower.as_str() {
12674                            "count_star" => row_count += 1,
12675                            "count" => {
12676                                if !v.is_null() {
12677                                    count += 1;
12678                                }
12679                            }
12680                            _ => {
12681                                if let Some(x) = value_to_f64(v) {
12682                                    sum += x;
12683                                    count += 1;
12684                                    min_v = Some(min_v.map_or(x, |m| m.min(x)));
12685                                    max_v = Some(max_v.map_or(x, |m| m.max(x)));
12686                                }
12687                            }
12688                        }
12689                    }
12690                }
12691                let value = match lower.as_str() {
12692                    "count_star" => Value::BigInt(row_count),
12693                    "count" => Value::BigInt(count),
12694                    "sum" => Value::Float(sum),
12695                    "avg" => {
12696                        if count == 0 {
12697                            Value::Null
12698                        } else {
12699                            Value::Float(sum / count as f64)
12700                        }
12701                    }
12702                    "min" => min_v.map_or(Value::Null, Value::Float),
12703                    "max" => max_v.map_or(Value::Null, Value::Float),
12704                    _ => unreachable!(),
12705                };
12706                let (_, _, idx) = &slice[i];
12707                out_vals[*idx] = value;
12708            }
12709            Ok(())
12710        }
12711        "lag" | "lead" => {
12712            // lag(expr [, offset [, default]])
12713            // lead(expr [, offset [, default]])
12714            if args.is_empty() {
12715                return Err(EngineError::Unsupported(alloc::format!(
12716                    "{lower}() requires at least one argument"
12717                )));
12718            }
12719            let offset: i64 = if args.len() >= 2 {
12720                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
12721                    .map_err(EngineError::Eval)?;
12722                match v {
12723                    Value::SmallInt(n) => i64::from(n),
12724                    Value::Int(n) => i64::from(n),
12725                    Value::BigInt(n) => n,
12726                    _ => {
12727                        return Err(EngineError::Unsupported(alloc::format!(
12728                            "{lower}() offset must be integer"
12729                        )));
12730                    }
12731                }
12732            } else {
12733                1
12734            };
12735            let default: Value = if args.len() >= 3 {
12736                eval::eval_expr(&args[2], filtered_rows[slice[0].2], ctx)
12737                    .map_err(EngineError::Eval)?
12738            } else {
12739                Value::Null
12740            };
12741            let values: Vec<Value> = slice
12742                .iter()
12743                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12744                .collect::<Result<_, _>>()
12745                .map_err(EngineError::Eval)?;
12746            let n = slice.len();
12747            for (i, (_, _, idx)) in slice.iter().enumerate() {
12748                let signed_offset = if lower == "lag" { -offset } else { offset };
12749                let v = if ignore_nulls {
12750                    // v6.4.2 — IGNORE NULLS: walk in the offset direction
12751                    // skipping NULL values; the `offset`-th non-NULL
12752                    // encountered is the result.
12753                    let step: i64 = if signed_offset >= 0 { 1 } else { -1 };
12754                    let needed: i64 = signed_offset.abs();
12755                    if needed == 0 {
12756                        values[i].clone()
12757                    } else {
12758                        let mut j: i64 = i as i64;
12759                        let mut hits: i64 = 0;
12760                        let mut found: Option<Value> = None;
12761                        loop {
12762                            j += step;
12763                            if j < 0 || j >= n as i64 {
12764                                break;
12765                            }
12766                            #[allow(clippy::cast_sign_loss)]
12767                            let v = &values[j as usize];
12768                            if !v.is_null() {
12769                                hits += 1;
12770                                if hits == needed {
12771                                    found = Some(v.clone());
12772                                    break;
12773                                }
12774                            }
12775                        }
12776                        found.unwrap_or_else(|| default.clone())
12777                    }
12778                } else {
12779                    let target_signed = i64::try_from(i).unwrap_or(i64::MAX) + signed_offset;
12780                    if target_signed < 0 || target_signed >= i64::try_from(n).unwrap_or(i64::MAX) {
12781                        default.clone()
12782                    } else {
12783                        #[allow(clippy::cast_sign_loss)]
12784                        {
12785                            values[target_signed as usize].clone()
12786                        }
12787                    }
12788                };
12789                out_vals[*idx] = v;
12790            }
12791            Ok(())
12792        }
12793        "first_value" | "last_value" | "nth_value" => {
12794            if args.is_empty() {
12795                return Err(EngineError::Unsupported(alloc::format!(
12796                    "{lower}() requires at least one argument"
12797                )));
12798            }
12799            let values: Vec<Value> = slice
12800                .iter()
12801                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12802                .collect::<Result<_, _>>()
12803                .map_err(EngineError::Eval)?;
12804            let nth: usize = if lower == "nth_value" {
12805                if args.len() < 2 {
12806                    return Err(EngineError::Unsupported(
12807                        "nth_value() requires (expr, n)".into(),
12808                    ));
12809                }
12810                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
12811                    .map_err(EngineError::Eval)?;
12812                let raw = match v {
12813                    Value::SmallInt(n) => i64::from(n),
12814                    Value::Int(n) => i64::from(n),
12815                    Value::BigInt(n) => n,
12816                    _ => {
12817                        return Err(EngineError::Unsupported(
12818                            "nth_value() n must be integer".into(),
12819                        ));
12820                    }
12821                };
12822                if raw < 1 {
12823                    return Err(EngineError::Unsupported(
12824                        "nth_value() n must be >= 1".into(),
12825                    ));
12826                }
12827                #[allow(clippy::cast_sign_loss)]
12828                {
12829                    raw as usize
12830                }
12831            } else {
12832                0
12833            };
12834            let eff = effective_frame(frame, ordered)?;
12835            for i in 0..slice.len() {
12836                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
12837                let (_, _, idx) = &slice[i];
12838                let v = if lo > hi {
12839                    Value::Null
12840                } else if ignore_nulls && matches!(lower.as_str(), "first_value" | "last_value") {
12841                    // v6.4.2 — IGNORE NULLS: skip NULL cells when
12842                    // selecting the boundary value within the frame.
12843                    if lower == "first_value" {
12844                        (lo..=hi)
12845                            .find_map(|j| {
12846                                let v = &values[j];
12847                                (!v.is_null()).then(|| v.clone())
12848                            })
12849                            .unwrap_or(Value::Null)
12850                    } else {
12851                        (lo..=hi)
12852                            .rev()
12853                            .find_map(|j| {
12854                                let v = &values[j];
12855                                (!v.is_null()).then(|| v.clone())
12856                            })
12857                            .unwrap_or(Value::Null)
12858                    }
12859                } else {
12860                    match lower.as_str() {
12861                        "first_value" => values[lo].clone(),
12862                        "last_value" => values[hi].clone(),
12863                        "nth_value" => {
12864                            let pos = lo + nth - 1;
12865                            if pos > hi {
12866                                Value::Null
12867                            } else {
12868                                values[pos].clone()
12869                            }
12870                        }
12871                        _ => unreachable!(),
12872                    }
12873                };
12874                out_vals[*idx] = v;
12875            }
12876            Ok(())
12877        }
12878        "ntile" => {
12879            if args.is_empty() {
12880                return Err(EngineError::Unsupported(
12881                    "ntile(n) requires an integer argument".into(),
12882                ));
12883            }
12884            let v = eval::eval_expr(&args[0], filtered_rows[slice[0].2], ctx)
12885                .map_err(EngineError::Eval)?;
12886            let bucket_count: i64 = match v {
12887                Value::SmallInt(n) => i64::from(n),
12888                Value::Int(n) => i64::from(n),
12889                Value::BigInt(n) => n,
12890                _ => {
12891                    return Err(EngineError::Unsupported(
12892                        "ntile() argument must be integer".into(),
12893                    ));
12894                }
12895            };
12896            if bucket_count < 1 {
12897                return Err(EngineError::Unsupported(
12898                    "ntile() argument must be >= 1".into(),
12899                ));
12900            }
12901            #[allow(clippy::cast_sign_loss)]
12902            let buckets = bucket_count as usize;
12903            let n = slice.len();
12904            // Each bucket gets `base` rows; the first `extras` buckets
12905            // get one extra. PG semantics.
12906            let base = n / buckets;
12907            let extras = n % buckets;
12908            let mut bucket: usize = 1;
12909            let mut remaining_in_bucket = if extras > 0 { base + 1 } else { base };
12910            let mut buckets_with_extra_remaining = extras;
12911            for (_, _, idx) in slice {
12912                if remaining_in_bucket == 0 {
12913                    bucket += 1;
12914                    buckets_with_extra_remaining = buckets_with_extra_remaining.saturating_sub(1);
12915                    remaining_in_bucket = if buckets_with_extra_remaining > 0 {
12916                        base + 1
12917                    } else {
12918                        base
12919                    };
12920                    // Edge: if base==0 and extras==0, all rows fit;
12921                    // shouldn't reach here, but guard anyway.
12922                    if remaining_in_bucket == 0 {
12923                        remaining_in_bucket = 1;
12924                    }
12925                }
12926                out_vals[*idx] = Value::BigInt(i64::try_from(bucket).unwrap_or(i64::MAX));
12927                remaining_in_bucket -= 1;
12928            }
12929            Ok(())
12930        }
12931        "percent_rank" => {
12932            // (rank - 1) / (n - 1) where rank is the standard RANK().
12933            // Single-row partitions get 0.
12934            let n = slice.len();
12935            let mut prev_key: Option<&[(Value, bool)]> = None;
12936            let mut current_rank: i64 = 1;
12937            for (i, (_, okey, idx)) in slice.iter().enumerate() {
12938                if let Some(p) = prev_key
12939                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
12940                {
12941                    current_rank = i64::try_from(i + 1).unwrap_or(i64::MAX);
12942                }
12943                if prev_key.is_none() {
12944                    current_rank = 1;
12945                }
12946                #[allow(clippy::cast_precision_loss)]
12947                let pr = if n <= 1 {
12948                    0.0
12949                } else {
12950                    (current_rank - 1) as f64 / (n - 1) as f64
12951                };
12952                out_vals[*idx] = Value::Float(pr);
12953                prev_key = Some(okey.as_slice());
12954            }
12955            Ok(())
12956        }
12957        "cume_dist" => {
12958            // # rows up to and including this row's peer group / n.
12959            let n = slice.len();
12960            // First pass: find peer-group-end rank for each row.
12961            for i in 0..slice.len() {
12962                let peer_end = peer_group_end(slice, i);
12963                #[allow(clippy::cast_precision_loss)]
12964                let cd = (peer_end + 1) as f64 / n as f64;
12965                let (_, _, idx) = &slice[i];
12966                out_vals[*idx] = Value::Float(cd);
12967            }
12968            Ok(())
12969        }
12970        other => Err(EngineError::Unsupported(alloc::format!(
12971            "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)"
12972        ))),
12973    }
12974}
12975
12976/// v4.20: resolve the user-provided frame down to a normalised
12977/// `(kind, start, end)`. `None` means default — derive from
12978/// `ordered`: ordered ⇒ RANGE UNBOUNDED PRECEDING AND CURRENT ROW,
12979/// unordered ⇒ ROWS UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
12980/// Single-bound shorthand (e.g. `ROWS 5 PRECEDING`) normalises
12981/// end → CURRENT ROW per the PG spec.
12982fn effective_frame(
12983    frame: Option<&WindowFrame>,
12984    ordered: bool,
12985) -> Result<(FrameKind, FrameBound, FrameBound), EngineError> {
12986    match frame {
12987        None => {
12988            if ordered {
12989                Ok((
12990                    FrameKind::Range,
12991                    FrameBound::UnboundedPreceding,
12992                    FrameBound::CurrentRow,
12993                ))
12994            } else {
12995                Ok((
12996                    FrameKind::Rows,
12997                    FrameBound::UnboundedPreceding,
12998                    FrameBound::UnboundedFollowing,
12999                ))
13000            }
13001        }
13002        Some(fr) => {
13003            let end = fr.end.clone().unwrap_or(FrameBound::CurrentRow);
13004            // Reject start > end (a few impossible combinations).
13005            if matches!(fr.start, FrameBound::UnboundedFollowing)
13006                || matches!(end, FrameBound::UnboundedPreceding)
13007            {
13008                return Err(EngineError::Unsupported(alloc::format!(
13009                    "invalid frame: start={:?} end={:?}",
13010                    fr.start,
13011                    end
13012                )));
13013            }
13014            // RANGE OFFSET PRECEDING / FOLLOWING needs value-typed
13015            // arithmetic on the ORDER BY key (e.g. `RANGE BETWEEN
13016            // INTERVAL '1 day' PRECEDING AND CURRENT ROW`). Not
13017            // implemented in v4.20.
13018            if fr.kind == FrameKind::Range
13019                && (matches!(
13020                    fr.start,
13021                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
13022                ) || matches!(
13023                    end,
13024                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
13025                ))
13026            {
13027                return Err(EngineError::Unsupported(
13028                    "RANGE with explicit offset bounds is not supported (v4.20: only UNBOUNDED / CURRENT ROW for RANGE)".into(),
13029                ));
13030            }
13031            Ok((fr.kind, fr.start.clone(), end))
13032        }
13033    }
13034}
13035
13036/// Compute `(lo, hi)` row-index bounds inside the partition slice
13037/// for the row at position `i`. Inclusive, clamped to
13038/// `[0, slice.len()-1]`. Empty result if `lo > hi`.
13039#[allow(clippy::type_complexity)]
13040fn frame_bounds_for_row(
13041    eff: &(FrameKind, FrameBound, FrameBound),
13042    i: usize,
13043    slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)],
13044) -> (usize, usize) {
13045    let (kind, start, end) = eff;
13046    let n = slice.len();
13047    let last = n.saturating_sub(1);
13048    let (mut lo, mut hi) = match kind {
13049        FrameKind::Rows => {
13050            let lo = match start {
13051                FrameBound::UnboundedPreceding => 0,
13052                FrameBound::OffsetPreceding(k) => {
13053                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13054                    i.saturating_sub(k)
13055                }
13056                FrameBound::CurrentRow => i,
13057                FrameBound::OffsetFollowing(k) => {
13058                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13059                    i.saturating_add(k).min(last)
13060                }
13061                FrameBound::UnboundedFollowing => last,
13062            };
13063            let hi = match end {
13064                FrameBound::UnboundedPreceding => 0,
13065                FrameBound::OffsetPreceding(k) => {
13066                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13067                    i.saturating_sub(k)
13068                }
13069                FrameBound::CurrentRow => i,
13070                FrameBound::OffsetFollowing(k) => {
13071                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
13072                    i.saturating_add(k).min(last)
13073                }
13074                FrameBound::UnboundedFollowing => last,
13075            };
13076            (lo, hi)
13077        }
13078        FrameKind::Range => {
13079            // RANGE bounds are peer-aware. With only UNBOUNDED and
13080            // CURRENT ROW supported (rejected at effective_frame for
13081            // explicit offsets), the start/end map to the
13082            // partition's full extent at the same-order-key peer
13083            // group boundary.
13084            let lo = match start {
13085                FrameBound::UnboundedPreceding => 0,
13086                FrameBound::CurrentRow => peer_group_start(slice, i),
13087                FrameBound::UnboundedFollowing => last,
13088                _ => unreachable!("offset bounds rejected for RANGE"),
13089            };
13090            let hi = match end {
13091                FrameBound::UnboundedPreceding => 0,
13092                FrameBound::CurrentRow => peer_group_end(slice, i),
13093                FrameBound::UnboundedFollowing => last,
13094                _ => unreachable!("offset bounds rejected for RANGE"),
13095            };
13096            (lo, hi)
13097        }
13098    };
13099    if hi >= n {
13100        hi = last;
13101    }
13102    if lo >= n {
13103        lo = last;
13104    }
13105    (lo, hi)
13106}
13107
13108/// Find the inclusive index of the first row with the same ORDER
13109/// BY key as `slice[i]`. Slice is already sorted by partition then
13110/// order, so peers are contiguous.
13111#[allow(clippy::type_complexity)]
13112fn peer_group_start(slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)], i: usize) -> usize {
13113    let key = &slice[i].1;
13114    let mut j = i;
13115    while j > 0 && order_key_cmp(&slice[j - 1].1, key) == core::cmp::Ordering::Equal {
13116        j -= 1;
13117    }
13118    j
13119}
13120
13121/// Find the inclusive index of the last row with the same ORDER
13122/// BY key as `slice[i]`.
13123#[allow(clippy::type_complexity)]
13124fn peer_group_end(slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)], i: usize) -> usize {
13125    let key = &slice[i].1;
13126    let mut j = i;
13127    while j + 1 < slice.len() && order_key_cmp(&slice[j + 1].1, key) == core::cmp::Ordering::Equal {
13128        j += 1;
13129    }
13130    j
13131}
13132
13133fn value_to_f64(v: &Value) -> Option<f64> {
13134    match v {
13135        Value::SmallInt(n) => Some(f64::from(*n)),
13136        Value::Int(n) => Some(f64::from(*n)),
13137        #[allow(clippy::cast_precision_loss)]
13138        Value::BigInt(n) => Some(*n as f64),
13139        Value::Float(x) => Some(*x),
13140        _ => None,
13141    }
13142}
13143
13144/// Quick scan for any subquery-bearing node in a SELECT's WHERE /
13145/// projection / `order_by` — saves cloning the AST when there are
13146/// none (the common case).
13147fn expr_tree_has_subquery(stmt: &SelectStatement) -> bool {
13148    let mut any = false;
13149    for item in &stmt.items {
13150        if let SelectItem::Expr { expr, .. } = item {
13151            any = any || expr_has_subquery(expr);
13152        }
13153    }
13154    if let Some(w) = &stmt.where_ {
13155        any = any || expr_has_subquery(w);
13156    }
13157    if let Some(h) = &stmt.having {
13158        any = any || expr_has_subquery(h);
13159    }
13160    for o in &stmt.order_by {
13161        any = any || expr_has_subquery(&o.expr);
13162    }
13163    for (_, peer) in &stmt.unions {
13164        any = any || expr_tree_has_subquery(peer);
13165    }
13166    any
13167}
13168
13169fn expr_has_subquery(e: &Expr) -> bool {
13170    match e {
13171        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => true,
13172        Expr::Binary { lhs, rhs, .. } => expr_has_subquery(lhs) || expr_has_subquery(rhs),
13173        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13174            expr_has_subquery(expr)
13175        }
13176        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_subquery),
13177        Expr::Like { expr, pattern, .. } => expr_has_subquery(expr) || expr_has_subquery(pattern),
13178        Expr::Extract { source, .. } => expr_has_subquery(source),
13179        Expr::WindowFunction {
13180            args,
13181            partition_by,
13182            order_by,
13183            ..
13184        } => {
13185            args.iter().any(expr_has_subquery)
13186                || partition_by.iter().any(expr_has_subquery)
13187                || order_by.iter().any(|(e, _)| expr_has_subquery(e))
13188        }
13189        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
13190        Expr::Array(items) => items.iter().any(expr_has_subquery),
13191        Expr::ArraySubscript { target, index } => {
13192            expr_has_subquery(target) || expr_has_subquery(index)
13193        }
13194        Expr::AnyAll { expr, array, .. } => expr_has_subquery(expr) || expr_has_subquery(array),
13195        Expr::Case {
13196            operand,
13197            branches,
13198            else_branch,
13199        } => {
13200            operand.as_deref().is_some_and(expr_has_subquery)
13201                || branches
13202                    .iter()
13203                    .any(|(w, t)| expr_has_subquery(w) || expr_has_subquery(t))
13204                || else_branch.as_deref().is_some_and(expr_has_subquery)
13205        }
13206    }
13207}
13208
13209/// v4.10 helper: materialise a runtime `Value` back into an AST
13210/// `Expr::Literal` for the subquery-rewrite path. Supports the
13211/// types `Literal` can represent (Integer / Float / Text / Bool /
13212/// Null). Date / Timestamp / Numeric / Vector / Interval / JSON
13213/// would lose precision through Literal and aren't supported in
13214/// uncorrelated-subquery results; they error with a clear hint.
13215fn value_to_literal_expr(v: Value) -> Result<Expr, EngineError> {
13216    let lit = match v {
13217        Value::Null => Literal::Null,
13218        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13219        Value::Int(n) => Literal::Integer(i64::from(n)),
13220        Value::BigInt(n) => Literal::Integer(n),
13221        Value::Float(x) => Literal::Float(x),
13222        Value::Text(s) | Value::Json(s) => Literal::String(s),
13223        Value::Bool(b) => Literal::Bool(b),
13224        other => {
13225            return Err(EngineError::Unsupported(alloc::format!(
13226                "subquery result type {:?} not yet materialisable; cast to text or integer in the inner SELECT",
13227                other.data_type()
13228            )));
13229        }
13230    };
13231    Ok(Expr::Literal(lit))
13232}
13233
13234/// v7.13.0 — wider helper used by `INSERT … SELECT` (mailrs
13235/// round-5 G4). Covers the most common `Value` variants. Types
13236/// that need lossy textual round-trip (BYTEA, arrays, ts*)
13237/// surface as an Unsupported error so the caller can add a cast
13238/// in the inner SELECT.
13239fn value_to_literal_expr_permissive(v: Value) -> Result<Expr, EngineError> {
13240    let lit = match v {
13241        Value::Null => Literal::Null,
13242        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13243        Value::Int(n) => Literal::Integer(i64::from(n)),
13244        Value::BigInt(n) => Literal::Integer(n),
13245        Value::Float(x) => Literal::Float(x),
13246        Value::Text(s) | Value::Json(s) => Literal::String(s),
13247        Value::Bool(b) => Literal::Bool(b),
13248        Value::Vector(xs) => Literal::Vector(xs),
13249        // Date / Timestamp / Timestamptz / Numeric round-trip
13250        // through a TEXT literal that `coerce_value` re-parses
13251        // against the target column type.
13252        Value::Date(days) => {
13253            let micros = (i64::from(days)) * 86_400_000_000;
13254            Literal::String(format_timestamp_micros_as_date(micros))
13255        }
13256        Value::Timestamp(us) => Literal::String(format_timestamp_micros(us)),
13257        Value::Numeric { scaled, scale } => Literal::String(format_numeric(scaled, scale)),
13258        other => {
13259            return Err(EngineError::Unsupported(alloc::format!(
13260                "INSERT … SELECT cannot materialise value of type {:?}; \
13261                 add an explicit CAST in the inner SELECT",
13262                other.data_type()
13263            )));
13264        }
13265    };
13266    Ok(Expr::Literal(lit))
13267}
13268
13269fn format_timestamp_micros(us: i64) -> String {
13270    // Same Y/M/D split used by the wire layer; epoch-relative.
13271    let days = us.div_euclid(86_400_000_000);
13272    let intra_day = us.rem_euclid(86_400_000_000);
13273    let date = format_timestamp_micros_as_date(days * 86_400_000_000);
13274    let secs = intra_day / 1_000_000;
13275    let us_rem = intra_day % 1_000_000;
13276    let h = (secs / 3600) % 24;
13277    let m = (secs / 60) % 60;
13278    let s = secs % 60;
13279    if us_rem == 0 {
13280        alloc::format!("{date} {h:02}:{m:02}:{s:02}")
13281    } else {
13282        alloc::format!("{date} {h:02}:{m:02}:{s:02}.{us_rem:06}")
13283    }
13284}
13285
13286fn format_timestamp_micros_as_date(us: i64) -> String {
13287    // Days since 1970-01-01 → calendar Y-M-D via the proleptic
13288    // Gregorian conversion used by spg-engine's date helpers.
13289    let days = us.div_euclid(86_400_000_000);
13290    // 1970-01-01 = JDN 2440588.
13291    let jdn = days + 2_440_588;
13292    let (y, mo, d) = jdn_to_ymd(jdn);
13293    alloc::format!("{y:04}-{mo:02}-{d:02}")
13294}
13295
13296fn jdn_to_ymd(jdn: i64) -> (i64, u32, u32) {
13297    // Fliegel & Van Flandern (1968) — works for all positive JDNs.
13298    let l = jdn + 68569;
13299    let n = (4 * l) / 146_097;
13300    let l = l - (146_097 * n + 3) / 4;
13301    let i = (4000 * (l + 1)) / 1_461_001;
13302    let l = l - (1461 * i) / 4 + 31;
13303    let j = (80 * l) / 2447;
13304    let day = (l - (2447 * j) / 80) as u32;
13305    let l = j / 11;
13306    let month = (j + 2 - 12 * l) as u32;
13307    let year = 100 * (n - 49) + i + l;
13308    (year, month, day)
13309}
13310
13311fn format_numeric(scaled: i128, scale: u8) -> String {
13312    if scale == 0 {
13313        return alloc::format!("{scaled}");
13314    }
13315    let abs = scaled.unsigned_abs();
13316    let divisor = 10u128.pow(u32::from(scale));
13317    let whole = abs / divisor;
13318    let frac = abs % divisor;
13319    let sign = if scaled < 0 { "-" } else { "" };
13320    alloc::format!("{sign}{whole}.{frac:0width$}", width = usize::from(scale))
13321}
13322
13323/// v6.1.1 — walk the prepared `Statement` AST and replace every
13324/// `Expr::Placeholder(n)` with `Expr::Literal(value_to_literal(
13325/// params[n-1]))`. The dispatch downstream sees a `Statement`
13326/// indistinguishable from a simple-query parse, so the exec path
13327/// stays unchanged.
13328///
13329/// Errors fall into one shape: a `$N` references past the bound
13330/// `params.len()`. Out-of-range happens when the Bind didn't
13331/// supply enough values; pgwire surfaces this as a protocol error
13332/// to the client.
13333/// v7.15.0 — rewrite every (potentially-qualified) column
13334/// identifier matching `old` to `new` in a stored SQL source
13335/// string. Used by `ALTER TABLE … RENAME COLUMN` to patch
13336/// CHECK predicate sources, partial-index predicate sources,
13337/// and runtime DEFAULT expression sources before they get
13338/// re-parsed on the next INSERT/UPDATE.
13339///
13340/// Round-trips through the parser, so the rewritten output is
13341/// the canonical Display form (matches what the engine stores
13342/// for fresh predicates). If the source doesn't parse, surfaces
13343/// the parse error — the invariant that stored predicates are
13344/// in canonical Display form means a parse failure here is a
13345/// real bug, not a user mistake to swallow.
13346fn rewrite_column_in_source(
13347    src: &str,
13348    old: &str,
13349    new: &str,
13350) -> Result<alloc::string::String, EngineError> {
13351    let mut expr = spg_sql::parser::parse_expression(src).map_err(|e| {
13352        EngineError::Unsupported(alloc::format!(
13353            "ALTER TABLE RENAME COLUMN: stored predicate source {src:?} \
13354             failed to parse for rewrite ({e})"
13355        ))
13356    })?;
13357    rewrite_column_in_expr(&mut expr, old, new);
13358    Ok(alloc::format!("{expr}"))
13359}
13360
13361/// v7.15.0 — Expr walker that swaps `Expr::Column { name: old, .. }`
13362/// for `Expr::Column { name: new, .. }`. Qualifier is preserved
13363/// (e.g. `t.old` → `t.new`); a foreign-table qualifier still
13364/// gets rewritten because the AST has no way to tell us this
13365/// predicate is on table T versus table T2 — predicate sources
13366/// in SPG are always scoped to the owning table, so any
13367/// qualifier present is either redundant or wrong.
13368fn rewrite_column_in_expr(e: &mut Expr, old: &str, new: &str) {
13369    match e {
13370        Expr::Column(c) => {
13371            if c.name.eq_ignore_ascii_case(old) {
13372                c.name = new.to_string();
13373            }
13374        }
13375        Expr::Binary { lhs, rhs, .. } => {
13376            rewrite_column_in_expr(lhs, old, new);
13377            rewrite_column_in_expr(rhs, old, new);
13378        }
13379        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13380            rewrite_column_in_expr(expr, old, new);
13381        }
13382        Expr::FunctionCall { args, .. } => {
13383            for a in args {
13384                rewrite_column_in_expr(a, old, new);
13385            }
13386        }
13387        Expr::Like { expr, pattern, .. } => {
13388            rewrite_column_in_expr(expr, old, new);
13389            rewrite_column_in_expr(pattern, old, new);
13390        }
13391        Expr::Extract { source, .. } => rewrite_column_in_expr(source, old, new),
13392        Expr::WindowFunction {
13393            args,
13394            partition_by,
13395            order_by,
13396            ..
13397        } => {
13398            for a in args {
13399                rewrite_column_in_expr(a, old, new);
13400            }
13401            for p in partition_by {
13402                rewrite_column_in_expr(p, old, new);
13403            }
13404            for (o, _) in order_by {
13405                rewrite_column_in_expr(o, old, new);
13406            }
13407        }
13408        Expr::Array(items) => {
13409            for elem in items {
13410                rewrite_column_in_expr(elem, old, new);
13411            }
13412        }
13413        Expr::ArraySubscript { target, index } => {
13414            rewrite_column_in_expr(target, old, new);
13415            rewrite_column_in_expr(index, old, new);
13416        }
13417        Expr::AnyAll { expr, array, .. } => {
13418            rewrite_column_in_expr(expr, old, new);
13419            rewrite_column_in_expr(array, old, new);
13420        }
13421        Expr::Case {
13422            operand,
13423            branches,
13424            else_branch,
13425        } => {
13426            if let Some(o) = operand {
13427                rewrite_column_in_expr(o, old, new);
13428            }
13429            for (w, t) in branches {
13430                rewrite_column_in_expr(w, old, new);
13431                rewrite_column_in_expr(t, old, new);
13432            }
13433            if let Some(e) = else_branch {
13434                rewrite_column_in_expr(e, old, new);
13435            }
13436        }
13437        // Stored predicate sources never contain subqueries —
13438        // CHECK / partial-index / runtime_default are all scalar.
13439        // If a future feature changes that, recurse here.
13440        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => {}
13441        Expr::Literal(_) | Expr::Placeholder(_) => {}
13442    }
13443}
13444
13445/// v7.16.0 — walks a parsed statement and replaces every
13446/// `Expr::Placeholder(N)` with the corresponding `params[N-1]`
13447/// re-encoded as an `Expr::Literal`. Used internally by
13448/// `Engine::execute_prepared` AND surfaced for the spg-embedded
13449/// WAL path (which needs the bind-final AST so replay sees a
13450/// simple-query-shaped statement, not a `$1`-shaped one). Errors
13451/// when a placeholder references an index past the params slice.
13452pub fn substitute_placeholders(stmt: &mut Statement, params: &[Value]) -> Result<(), EngineError> {
13453    match stmt {
13454        Statement::Select(s) => substitute_select(s, params)?,
13455        Statement::Insert(ins) => {
13456            for row in &mut ins.rows {
13457                for e in row {
13458                    substitute_expr(e, params)?;
13459                }
13460            }
13461        }
13462        Statement::Update(u) => {
13463            for (_, e) in &mut u.assignments {
13464                substitute_expr(e, params)?;
13465            }
13466            if let Some(w) = &mut u.where_ {
13467                substitute_expr(w, params)?;
13468            }
13469        }
13470        Statement::Delete(d) => {
13471            if let Some(w) = &mut d.where_ {
13472                substitute_expr(w, params)?;
13473            }
13474        }
13475        Statement::Explain(e) => substitute_select(&mut e.inner, params)?,
13476        // Other statements (CREATE / BEGIN / SHOW / …) have no
13477        // expression slots; no walk needed.
13478        _ => {}
13479    }
13480    Ok(())
13481}
13482
13483fn substitute_select(s: &mut SelectStatement, params: &[Value]) -> Result<(), EngineError> {
13484    for item in &mut s.items {
13485        if let SelectItem::Expr { expr, .. } = item {
13486            substitute_expr(expr, params)?;
13487        }
13488    }
13489    if let Some(w) = &mut s.where_ {
13490        substitute_expr(w, params)?;
13491    }
13492    if let Some(gs) = &mut s.group_by {
13493        for g in gs {
13494            substitute_expr(g, params)?;
13495        }
13496    }
13497    if let Some(h) = &mut s.having {
13498        substitute_expr(h, params)?;
13499    }
13500    for o in &mut s.order_by {
13501        substitute_expr(&mut o.expr, params)?;
13502    }
13503    for (_, peer) in &mut s.unions {
13504        substitute_select(peer, params)?;
13505    }
13506    // v7.9.24 — LIMIT $N / OFFSET $N placeholder resolution.
13507    // mailrs H2. After this pass each LIMIT/OFFSET that was a
13508    // Placeholder is rewritten to Literal so the existing
13509    // `LimitExpr::as_literal` path consumes a concrete u32.
13510    if let Some(le) = s.limit {
13511        s.limit = Some(resolve_limit_placeholder(le, params)?);
13512    }
13513    if let Some(le) = s.offset {
13514        s.offset = Some(resolve_limit_placeholder(le, params)?);
13515    }
13516    Ok(())
13517}
13518
13519fn resolve_limit_placeholder(
13520    le: spg_sql::ast::LimitExpr,
13521    params: &[Value],
13522) -> Result<spg_sql::ast::LimitExpr, EngineError> {
13523    use spg_sql::ast::LimitExpr;
13524    match le {
13525        LimitExpr::Literal(_) => Ok(le),
13526        LimitExpr::Placeholder(n) => {
13527            let idx = usize::from(n).saturating_sub(1);
13528            let v = params.get(idx).ok_or_else(|| {
13529                EngineError::Eval(EvalError::PlaceholderOutOfRange {
13530                    n,
13531                    bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
13532                })
13533            })?;
13534            let int = match v {
13535                Value::SmallInt(x) => Some(i64::from(*x)),
13536                Value::Int(x) => Some(i64::from(*x)),
13537                Value::BigInt(x) => Some(*x),
13538                _ => None,
13539            }
13540            .ok_or_else(|| {
13541                EngineError::Unsupported(alloc::format!(
13542                    "LIMIT/OFFSET ${n} bound to non-integer {v:?}"
13543                ))
13544            })?;
13545            if int < 0 {
13546                return Err(EngineError::Unsupported(alloc::format!(
13547                    "LIMIT/OFFSET ${n} bound to negative value {int}"
13548                )));
13549            }
13550            let bounded = u32::try_from(int).map_err(|_| {
13551                EngineError::Unsupported(alloc::format!(
13552                    "LIMIT/OFFSET ${n} value {int} exceeds u32 range"
13553                ))
13554            })?;
13555            Ok(LimitExpr::Literal(bounded))
13556        }
13557    }
13558}
13559
13560fn substitute_expr(e: &mut Expr, params: &[Value]) -> Result<(), EngineError> {
13561    if let Expr::Placeholder(n) = e {
13562        let idx = usize::from(*n).saturating_sub(1);
13563        let v = params.get(idx).ok_or_else(|| {
13564            EngineError::Eval(EvalError::PlaceholderOutOfRange {
13565                n: *n,
13566                bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
13567            })
13568        })?;
13569        *e = Expr::Literal(value_to_literal(v.clone()));
13570        return Ok(());
13571    }
13572    match e {
13573        Expr::Binary { lhs, rhs, .. } => {
13574            substitute_expr(lhs, params)?;
13575            substitute_expr(rhs, params)?;
13576        }
13577        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13578            substitute_expr(expr, params)?;
13579        }
13580        Expr::FunctionCall { args, .. } => {
13581            for a in args {
13582                substitute_expr(a, params)?;
13583            }
13584        }
13585        Expr::Like { expr, pattern, .. } => {
13586            substitute_expr(expr, params)?;
13587            substitute_expr(pattern, params)?;
13588        }
13589        Expr::Extract { source, .. } => substitute_expr(source, params)?,
13590        Expr::ScalarSubquery(s) => substitute_select(s, params)?,
13591        Expr::Exists { subquery, .. } => substitute_select(subquery, params)?,
13592        Expr::InSubquery { expr, subquery, .. } => {
13593            substitute_expr(expr, params)?;
13594            substitute_select(subquery, params)?;
13595        }
13596        Expr::WindowFunction {
13597            args,
13598            partition_by,
13599            order_by,
13600            ..
13601        } => {
13602            for a in args {
13603                substitute_expr(a, params)?;
13604            }
13605            for p in partition_by {
13606                substitute_expr(p, params)?;
13607            }
13608            for (e, _) in order_by {
13609                substitute_expr(e, params)?;
13610            }
13611        }
13612        Expr::Literal(_) | Expr::Column(_) => {}
13613        // Already handled above.
13614        Expr::Placeholder(_) => unreachable!("Placeholder handled at top of fn"),
13615        Expr::Array(items) => {
13616            for elem in items {
13617                substitute_expr(elem, params)?;
13618            }
13619        }
13620        Expr::ArraySubscript { target, index } => {
13621            substitute_expr(target, params)?;
13622            substitute_expr(index, params)?;
13623        }
13624        Expr::AnyAll { expr, array, .. } => {
13625            substitute_expr(expr, params)?;
13626            substitute_expr(array, params)?;
13627        }
13628        Expr::Case {
13629            operand,
13630            branches,
13631            else_branch,
13632        } => {
13633            if let Some(o) = operand {
13634                substitute_expr(o, params)?;
13635            }
13636            for (w, t) in branches {
13637                substitute_expr(w, params)?;
13638                substitute_expr(t, params)?;
13639            }
13640            if let Some(e) = else_branch {
13641                substitute_expr(e, params)?;
13642            }
13643        }
13644    }
13645    Ok(())
13646}
13647
13648/// v6.1.1 — convert a runtime `Value` into the closest matching
13649/// `Literal` for the substitute walker. Lossless for the simple
13650/// scalars (Int / Float / Text / Bool); Numeric / Date / Timestamp
13651/// / Json / Interval render as their canonical text form so the
13652/// downstream coerce_value can re-parse against the target column
13653/// type. SQ8 / HalfVector cells are NOT expected as bind params;
13654/// pgwire's Bind decodes vector params to the f32 representation
13655/// before they reach this helper.
13656/// v6.2.0 — total ordering on `Value`s used by ANALYZE to sort a
13657/// column's non-NULL sample before histogram building. Cross-type
13658/// pairs (Int vs Float, Date vs Timestamp, …) compare via the
13659/// same widening the eval-side `compare` operator uses; everything
13660/// else (the genuinely-incompatible pairs) falls back to ordering
13661/// by canonical string form so the sort is still total + stable.
13662/// Vector / SQ8 / Half / Json / Numeric / Interval values reach
13663/// here only via the string-fallback path because vector columns
13664/// are filtered out upstream.
13665fn sort_values_for_histogram(a: &Value, b: &Value) -> core::cmp::Ordering {
13666    use core::cmp::Ordering;
13667    match (a, b) {
13668        (Value::SmallInt(a), Value::SmallInt(b)) => a.cmp(b),
13669        (Value::Int(a), Value::Int(b)) => a.cmp(b),
13670        (Value::BigInt(a), Value::BigInt(b)) => a.cmp(b),
13671        (Value::SmallInt(a), Value::Int(b)) => i32::from(*a).cmp(b),
13672        (Value::Int(a), Value::SmallInt(b)) => a.cmp(&i32::from(*b)),
13673        (Value::Int(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
13674        (Value::BigInt(a), Value::Int(b)) => a.cmp(&i64::from(*b)),
13675        (Value::SmallInt(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
13676        (Value::BigInt(a), Value::SmallInt(b)) => a.cmp(&i64::from(*b)),
13677        (Value::Float(a), Value::Float(b)) => a.partial_cmp(b).unwrap_or(Ordering::Equal),
13678        (Value::Text(a), Value::Text(b)) | (Value::Json(a), Value::Json(b)) => a.cmp(b),
13679        (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
13680        (Value::Date(a), Value::Date(b)) => a.cmp(b),
13681        (Value::Timestamp(a), Value::Timestamp(b)) => a.cmp(b),
13682        // Mixed numeric/float — widen to f64 and compare.
13683        (Value::SmallInt(n), Value::Float(x)) => {
13684            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
13685        }
13686        (Value::Float(x), Value::SmallInt(n)) => {
13687            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
13688        }
13689        (Value::Int(n), Value::Float(x)) => {
13690            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
13691        }
13692        (Value::Float(x), Value::Int(n)) => {
13693            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
13694        }
13695        (Value::BigInt(n), Value::Float(x)) => {
13696            #[allow(clippy::cast_precision_loss)]
13697            let nf = *n as f64;
13698            nf.partial_cmp(x).unwrap_or(Ordering::Equal)
13699        }
13700        (Value::Float(x), Value::BigInt(n)) => {
13701            #[allow(clippy::cast_precision_loss)]
13702            let nf = *n as f64;
13703            x.partial_cmp(&nf).unwrap_or(Ordering::Equal)
13704        }
13705        // Cross-type fallback: lexicographic on canonical form.
13706        // Total + stable so the sort is well-defined.
13707        _ => canonical_value_repr(a).cmp(&canonical_value_repr(b)),
13708    }
13709}
13710
13711/// v6.2.0 — render the histogram bounds list as a `[v0, v1, ...]`
13712/// string for the `spg_statistic.histogram_bounds` column. Values
13713/// containing `,` or `[` / `]` are JSON-style escaped so the
13714/// rendering round-trips through a future parser; v6.2.0 only
13715/// uses the rendered form for human consumption, so the escaping
13716/// is conservative.
13717fn render_histogram_bounds(bounds: &[alloc::string::String]) -> alloc::string::String {
13718    let mut out = alloc::string::String::with_capacity(bounds.len() * 8 + 2);
13719    out.push('[');
13720    for (i, b) in bounds.iter().enumerate() {
13721        if i > 0 {
13722            out.push_str(", ");
13723        }
13724        let needs_quote = b.contains([',', '[', ']', '"']) || b.is_empty();
13725        if needs_quote {
13726            out.push('"');
13727            for ch in b.chars() {
13728                if ch == '"' || ch == '\\' {
13729                    out.push('\\');
13730                }
13731                out.push(ch);
13732            }
13733            out.push('"');
13734        } else {
13735            out.push_str(b);
13736        }
13737    }
13738    out.push(']');
13739    out
13740}
13741
13742/// v6.2.0 — canonical textual form of a `Value` for histogram
13743/// bound storage. Strings used by ANALYZE for sort + bound output.
13744/// INT / BIGINT → decimal; FLOAT → shortest-round-trip via
13745/// `{:?}`; TEXT pass-through; BOOL → `t` / `f`; DATE / TIMESTAMP →
13746/// the same form `format_date` / `format_timestamp` produce for
13747/// SQL Display. Vector / SQ8 / Half / Json / Numeric / Interval
13748/// reach this only via a non-Vector column (vector columns are
13749/// skipped upstream); they fall back to a Debug-derived form so
13750/// stats still serialise without crashing.
13751pub(crate) fn canonical_value_repr(v: &Value) -> alloc::string::String {
13752    match v {
13753        Value::Null => "NULL".to_string(),
13754        Value::SmallInt(n) => alloc::format!("{n}"),
13755        Value::Int(n) => alloc::format!("{n}"),
13756        Value::BigInt(n) => alloc::format!("{n}"),
13757        Value::Float(x) => alloc::format!("{x:?}"),
13758        Value::Text(s) | Value::Json(s) => s.clone(),
13759        Value::Bool(b) => if *b { "t" } else { "f" }.to_string(),
13760        Value::Date(d) => eval::format_date(*d),
13761        Value::Timestamp(t) => eval::format_timestamp(*t),
13762        // v7.17.0 Phase 3.P0-32 — PG TIME canonical text form.
13763        Value::Time(us) => eval::format_time(*us),
13764        // v7.17.0 Phase 3.P0-33 — MySQL YEAR 4-digit zero-padded.
13765        Value::Year(y) => alloc::format!("{y:04}"),
13766        // v7.17.0 Phase 3.P0-34 — PG TIMETZ canonical text form.
13767        Value::TimeTz { us, offset_secs } => eval::format_timetz(*us, *offset_secs),
13768        // v7.17.0 Phase 3.P0-35 — PG MONEY canonical en_US text form.
13769        Value::Money(c) => eval::format_money(*c),
13770        // v7.17.0 Phase 3.P0-38 — PG range canonical text form.
13771        v @ Value::Range { .. } => format_range_str(v),
13772        // v7.17.0 Phase 3.P0-39 — PG hstore canonical text form.
13773        Value::Hstore(pairs) => format_hstore_str(pairs),
13774        // v7.17.0 Phase 3.P0-40 — 2D array canonical text form.
13775        Value::IntArray2D(rows) => format_int_2d_text(rows),
13776        Value::BigIntArray2D(rows) => format_bigint_2d_text(rows),
13777        Value::TextArray2D(rows) => format_text_2d_text(rows),
13778        Value::Interval { months, micros } => eval::format_interval(*months, *micros),
13779        Value::Numeric { scaled, scale } => eval::format_numeric(*scaled, *scale),
13780        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
13781            // Unreachable in practice (vector columns are filtered
13782            // out before this). Defensive fallback so a future
13783            // vector-stats path doesn't crash.
13784            alloc::format!("{v:?}")
13785        }
13786        // v7.5.0 — Value is #[non_exhaustive] for downstream
13787        // forward-compat. Future variants fall through to Debug
13788        // form here (same shape as the vector fallback above).
13789        _ => alloc::format!("{v:?}"),
13790    }
13791}
13792
13793/// v6.2.0 — true for engine-managed catalog tables that the bare
13794/// `ANALYZE` (no target) should skip. v6.2.0 has no internal
13795/// tables yet (publications / subscriptions / users / statistics
13796/// all live as engine fields, not catalog tables), so this is a
13797/// reserved future-proofing hook — every existing user table is
13798/// analysed.
13799const fn is_internal_table_name(_name: &str) -> bool {
13800    false
13801}
13802
13803fn value_to_literal(v: Value) -> Literal {
13804    match v {
13805        Value::Null => Literal::Null,
13806        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13807        Value::Int(n) => Literal::Integer(i64::from(n)),
13808        Value::BigInt(n) => Literal::Integer(n),
13809        Value::Float(x) => Literal::Float(x),
13810        Value::Text(s) | Value::Json(s) => Literal::String(s),
13811        Value::Bool(b) => Literal::Bool(b),
13812        Value::Vector(v) => Literal::Vector(v),
13813        Value::Numeric { scaled, scale } => Literal::String(eval::format_numeric(scaled, scale)),
13814        Value::Date(d) => Literal::String(eval::format_date(d)),
13815        Value::Timestamp(t) => Literal::String(eval::format_timestamp(t)),
13816        // v7.17.0 Phase 3.P0-69 — UUID round-trips via canonical
13817        // hyphenated text. Without this arm the fallback below
13818        // renders `Debug` form ("Uuid([85, …])") which the
13819        // engine's Text → Uuid coerce can't parse, breaking
13820        // prepared-bind round-trip from the spg-sqlx adapter.
13821        Value::Uuid(b) => Literal::String(spg_storage::format_uuid(&b)),
13822        // v7.16.0 — BYTEA round-trip for the spg-sqlx Bind path.
13823        // PG-canonical text rep is `\x` + lowercase hex; the
13824        // engine's coerce_value already accepts that on the
13825        // text → bytea direction.
13826        Value::Bytes(b) => Literal::String(eval::format_bytea_hex(&b)),
13827        // v7.16.0 — array round-trip for the spg-sqlx Bind
13828        // path. Render as PG external form `{a,b,c}`; the
13829        // engine's text → array coerce (just below in
13830        // coerce_value) accepts it on the matching column type.
13831        Value::TextArray(items) => Literal::String(eval::format_text_array(&items)),
13832        Value::IntArray(items) => Literal::String(eval::format_int_array(&items)),
13833        Value::BigIntArray(items) => Literal::String(eval::format_bigint_array(&items)),
13834        Value::Interval { months, micros } => Literal::Interval {
13835            months,
13836            micros,
13837            text: eval::format_interval(months, micros),
13838        },
13839        // SQ8 / halfvec cells dequantise to f32 before reaching the
13840        // substitute walker; pgwire's Bind path handles that.
13841        Value::Sq8Vector(q) => Literal::Vector(spg_storage::quantize::dequantize(&q)),
13842        Value::HalfVector(h) => Literal::Vector(h.to_f32_vec()),
13843        // v7.5.0 — Value is #[non_exhaustive]; future variants
13844        // render as Debug-form String literal until explicit
13845        // mapping is added.
13846        v => Literal::String(alloc::format!("{v:?}")),
13847    }
13848}
13849
13850fn rewrite_clock_calls(stmt: &mut Statement, now_micros: Option<i64>) {
13851    let Some(now) = now_micros else {
13852        return;
13853    };
13854    match stmt {
13855        Statement::Select(s) => rewrite_select_clock(s, now),
13856        Statement::Insert(ins) => {
13857            for row in &mut ins.rows {
13858                for e in row {
13859                    rewrite_expr_clock(e, now);
13860                }
13861            }
13862        }
13863        _ => {}
13864    }
13865}
13866
13867fn rewrite_select_clock(s: &mut SelectStatement, now: i64) {
13868    for item in &mut s.items {
13869        if let SelectItem::Expr { expr, .. } = item {
13870            rewrite_expr_clock(expr, now);
13871        }
13872    }
13873    if let Some(w) = &mut s.where_ {
13874        rewrite_expr_clock(w, now);
13875    }
13876    if let Some(gs) = &mut s.group_by {
13877        for g in gs {
13878            rewrite_expr_clock(g, now);
13879        }
13880    }
13881    if let Some(h) = &mut s.having {
13882        rewrite_expr_clock(h, now);
13883    }
13884    for o in &mut s.order_by {
13885        rewrite_expr_clock(&mut o.expr, now);
13886    }
13887    for (_, peer) in &mut s.unions {
13888        rewrite_select_clock(peer, now);
13889    }
13890}
13891
13892/// v3.0.3 hot path: every recursion lands in exactly one `match` arm.
13893/// Literal / Column-with-qualifier (the dominant cases on a typical
13894/// AST) take a single pattern dispatch and exit. The clock-rewrite
13895/// targets (zero-arg `NOW` / `CURRENT_TIMESTAMP` / `CURRENT_DATE`
13896/// functions, and bare `CURRENT_TIMESTAMP` / `CURRENT_DATE` column
13897/// refs) sit on their own arms with match guards so the fall-through
13898/// to the recursive arms is unambiguous.
13899fn rewrite_expr_clock(e: &mut Expr, now: i64) {
13900    // Fast-path test on the no-recursion shapes first. We can't fold
13901    // them into the big match below because they need to *replace* `e`
13902    // outright; the recursive arms below match on its sub-fields.
13903    if let Some(replacement) = clock_replacement_for(e, now) {
13904        *e = replacement;
13905        return;
13906    }
13907    match e {
13908        Expr::Binary { lhs, rhs, .. } => {
13909            rewrite_expr_clock(lhs, now);
13910            rewrite_expr_clock(rhs, now);
13911        }
13912        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13913            rewrite_expr_clock(expr, now);
13914        }
13915        Expr::FunctionCall { args, .. } => {
13916            for a in args {
13917                rewrite_expr_clock(a, now);
13918            }
13919        }
13920        Expr::Like { expr, pattern, .. } => {
13921            rewrite_expr_clock(expr, now);
13922            rewrite_expr_clock(pattern, now);
13923        }
13924        Expr::Extract { source, .. } => rewrite_expr_clock(source, now),
13925        // v4.10 subquery nodes — recurse into the inner SELECT's
13926        // expression slots so e.g. SELECT NOW() in a scalar
13927        // subquery picks up the same instant as the outer query.
13928        Expr::ScalarSubquery(s) => rewrite_select_clock(s, now),
13929        Expr::Exists { subquery, .. } => rewrite_select_clock(subquery, now),
13930        Expr::InSubquery { expr, subquery, .. } => {
13931            rewrite_expr_clock(expr, now);
13932            rewrite_select_clock(subquery, now);
13933        }
13934        // v4.12 window functions — args + PARTITION BY + ORDER BY
13935        // may all reference clock literals.
13936        Expr::WindowFunction {
13937            args,
13938            partition_by,
13939            order_by,
13940            ..
13941        } => {
13942            for a in args {
13943                rewrite_expr_clock(a, now);
13944            }
13945            for p in partition_by {
13946                rewrite_expr_clock(p, now);
13947            }
13948            for (e, _) in order_by {
13949                rewrite_expr_clock(e, now);
13950            }
13951        }
13952        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
13953        Expr::Array(items) => {
13954            for elem in items {
13955                rewrite_expr_clock(elem, now);
13956            }
13957        }
13958        Expr::ArraySubscript { target, index } => {
13959            rewrite_expr_clock(target, now);
13960            rewrite_expr_clock(index, now);
13961        }
13962        Expr::AnyAll { expr, array, .. } => {
13963            rewrite_expr_clock(expr, now);
13964            rewrite_expr_clock(array, now);
13965        }
13966        Expr::Case {
13967            operand,
13968            branches,
13969            else_branch,
13970        } => {
13971            if let Some(o) = operand {
13972                rewrite_expr_clock(o, now);
13973            }
13974            for (w, t) in branches {
13975                rewrite_expr_clock(w, now);
13976                rewrite_expr_clock(t, now);
13977            }
13978            if let Some(e) = else_branch {
13979                rewrite_expr_clock(e, now);
13980            }
13981        }
13982    }
13983}
13984
13985/// Returns `Some(Expr)` when `e` is one of the clock-call shapes that
13986/// must be rewritten; otherwise `None` so the caller falls through to
13987/// the recursive walk. Identifies both function-call forms (`NOW()` /
13988/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()`) and bare-identifier forms
13989/// (`CURRENT_TIMESTAMP` / `CURRENT_DATE` as unqualified column refs,
13990/// which is how PG accepts them without parens).
13991fn clock_replacement_for(e: &Expr, now: i64) -> Option<Expr> {
13992    let (kind, name) = match e {
13993        Expr::FunctionCall { name, args } if args.is_empty() => (ClockSite::Fn, name.as_str()),
13994        Expr::Column(c) if c.qualifier.is_none() => (ClockSite::BareIdent, c.name.as_str()),
13995        _ => return None,
13996    };
13997    // ASCII case-insensitive name match. Each entry decides what
13998    // synthetic literal the call expands to.
13999    //
14000    // v7.17.0 Phase 3.P0-29 — `unix_timestamp` (no args) joins this
14001    // table as MySQL's epoch-seconds equivalent of `now()`. Folded
14002    // to a BigInt literal here so apply_function never needs a
14003    // clock dependency.
14004    enum ClockShape {
14005        Timestamp,
14006        Date,
14007        UnixSeconds,
14008    }
14009    let shape = match name.len() {
14010        3 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("now") => {
14011            Some(ClockShape::Timestamp)
14012        }
14013        12 if name.eq_ignore_ascii_case("current_date") => Some(ClockShape::Date),
14014        14 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("unix_timestamp") => {
14015            Some(ClockShape::UnixSeconds)
14016        }
14017        17 if name.eq_ignore_ascii_case("current_timestamp") => Some(ClockShape::Timestamp),
14018        _ => None,
14019    };
14020    let shape = shape?;
14021    let payload = match shape {
14022        ClockShape::Timestamp => now,
14023        ClockShape::Date => now.div_euclid(86_400_000_000),
14024        ClockShape::UnixSeconds => now.div_euclid(1_000_000),
14025    };
14026    let target = match shape {
14027        ClockShape::Timestamp => spg_sql::ast::CastTarget::Timestamp,
14028        ClockShape::Date => spg_sql::ast::CastTarget::Date,
14029        ClockShape::UnixSeconds => spg_sql::ast::CastTarget::BigInt,
14030    };
14031    Some(Expr::Cast {
14032        expr: alloc::boxed::Box::new(Expr::Literal(spg_sql::ast::Literal::Integer(payload))),
14033        target,
14034    })
14035}
14036
14037#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14038enum ClockSite {
14039    Fn,
14040    BareIdent,
14041}
14042
14043/// `ORDER BY <integer>` references the N-th SELECT item (1-based).
14044/// Swap the integer literal for the matching item's expression so the
14045/// executor doesn't need a special-case branch. Recurses into UNION
14046/// peers because each peer keeps its own SELECT list.
14047/// v6.4.1 — expand `GROUP BY ALL` to every non-aggregate SELECT-list
14048/// item. Mirrors DuckDB / PG 19 semantics. Wildcards (`SELECT * …`)
14049/// are NOT expanded by GROUP BY ALL (PG 19 leaves the wildcard intact
14050/// and groups by whatever explicit non-aggregates remain — none in
14051/// the wildcard-only case, which still works for non-aggregate
14052/// queries).
14053fn expand_group_by_all(s: &mut SelectStatement) {
14054    if !s.group_by_all {
14055        for (_, peer) in &mut s.unions {
14056            expand_group_by_all(peer);
14057        }
14058        return;
14059    }
14060    let mut groups: Vec<Expr> = Vec::new();
14061    for item in &s.items {
14062        if let SelectItem::Expr { expr, .. } = item
14063            && !aggregate::contains_aggregate(expr)
14064        {
14065            groups.push(expr.clone());
14066        }
14067    }
14068    s.group_by = Some(groups);
14069    s.group_by_all = false;
14070    for (_, peer) in &mut s.unions {
14071        expand_group_by_all(peer);
14072    }
14073}
14074
14075fn resolve_order_by_position(s: &mut SelectStatement) {
14076    // v6.4.0 — iterate every ORDER BY key. Position references
14077    // (`ORDER BY 2`) bind to the 1-based projection index;
14078    // identifier references that match a SELECT-list alias bind to
14079    // the projected expression (Step 4 of L3a).
14080    for order in &mut s.order_by {
14081        match &order.expr {
14082            Expr::Literal(Literal::Integer(n)) if *n >= 1 => {
14083                if let Ok(idx_one_based) = usize::try_from(*n) {
14084                    let idx = idx_one_based - 1;
14085                    if idx < s.items.len()
14086                        && let SelectItem::Expr { expr, .. } = &s.items[idx]
14087                    {
14088                        order.expr = expr.clone();
14089                    }
14090                }
14091            }
14092            Expr::Column(c) if c.qualifier.is_none() => {
14093                // Alias-in-ORDER-BY lookup.
14094                for item in &s.items {
14095                    if let SelectItem::Expr {
14096                        expr,
14097                        alias: Some(a),
14098                    } = item
14099                        && a == &c.name
14100                    {
14101                        order.expr = expr.clone();
14102                        break;
14103                    }
14104                }
14105            }
14106            _ => {}
14107        }
14108    }
14109    for (_, peer) in &mut s.unions {
14110        resolve_order_by_position(peer);
14111    }
14112}
14113
14114/// Sort `tagged` by `f64` key, reversing the comparator under DESC.
14115/// Used by the UNION ORDER BY path; per-block paths inline the same
14116/// comparator because they already hold `&OrderBy` directly.
14117/// v3.1.1: partial-sort helper. When `keep` (= offset + limit) is
14118/// strictly less than `tagged.len()`, run `select_nth_unstable_by` to
14119/// partition the prefix in O(n), then sort just that prefix in O(k
14120/// log k). Total O(n + k log k), vs O(n log n) for a full sort. The
14121/// caller decides what `keep` is; passing `None` (no LIMIT) keeps the
14122/// full-sort behaviour.
14123///
14124/// `tagged` holds `(Option<f64>, Row)` (the SELECT path) — `None` keys
14125/// sort last in ascending order, mirroring NULL-sorts-last in SQL.
14126fn partial_sort_tagged(tagged: &mut Vec<(Vec<f64>, Row)>, keep: Option<usize>, descs: &[bool]) {
14127    let cmp = |a: &(Vec<f64>, Row), b: &(Vec<f64>, Row)| cmp_multi_key(&a.0, &b.0, descs);
14128    match keep {
14129        Some(k) if k < tagged.len() && k > 0 => {
14130            let pivot = k - 1;
14131            tagged.select_nth_unstable_by(pivot, cmp);
14132            tagged[..k].sort_by(cmp);
14133            tagged.truncate(k);
14134        }
14135        _ => {
14136            tagged.sort_by(cmp);
14137        }
14138    }
14139}
14140
14141fn sort_by_keys(tagged: &mut [(Vec<f64>, Row)], descs: &[bool]) {
14142    tagged.sort_by(|a, b| cmp_multi_key(&a.0, &b.0, descs));
14143}
14144
14145/// v6.4.0 — multi-key ORDER BY comparator. Each key's per-key DESC
14146/// flag is honored independently. NULL is encoded as `f64::INFINITY`
14147/// so it sorts last in ASC and first in DESC (matches PG default).
14148fn cmp_multi_key(a: &[f64], b: &[f64], descs: &[bool]) -> core::cmp::Ordering {
14149    use core::cmp::Ordering;
14150    for (i, (ka, kb)) in a.iter().zip(b.iter()).enumerate() {
14151        let ord = ka.partial_cmp(kb).unwrap_or(Ordering::Equal);
14152        let ord = if descs.get(i).copied().unwrap_or(false) {
14153            ord.reverse()
14154        } else {
14155            ord
14156        };
14157        if ord != Ordering::Equal {
14158            return ord;
14159        }
14160    }
14161    Ordering::Equal
14162}
14163
14164/// v6.4.0 — eval every ORDER BY expression for a row and pack the
14165/// resulting keys into a `Vec<f64>`. NULL → `f64::INFINITY`.
14166fn build_order_keys(
14167    order_by: &[OrderBy],
14168    row: &Row,
14169    ctx: &EvalContext,
14170) -> Result<Vec<f64>, EngineError> {
14171    let mut keys = Vec::with_capacity(order_by.len());
14172    for o in order_by {
14173        let v = eval::eval_expr(&o.expr, row, ctx)?;
14174        keys.push(value_to_order_key(&v)?);
14175    }
14176    Ok(keys)
14177}
14178
14179/// Drop the first `offset` rows then truncate to `limit`. PG / `MySQL`
14180/// agree: OFFSET applies *after* ORDER BY but *before* LIMIT (so
14181/// `LIMIT 10 OFFSET 5` keeps rows 6..=15).
14182fn apply_offset_and_limit(rows: &mut Vec<Row>, offset: Option<u32>, limit: Option<u32>) {
14183    if let Some(off) = offset {
14184        let off = off as usize;
14185        if off >= rows.len() {
14186            rows.clear();
14187        } else {
14188            rows.drain(..off);
14189        }
14190    }
14191    if let Some(n) = limit {
14192        rows.truncate(n as usize);
14193    }
14194}
14195
14196/// v7.17.0 Phase 3.P0-49 — offset + limit applied to a tagged
14197/// `(order_keys, row)` sequence, with optional SQL:2008 `WITH
14198/// TIES` extension. When `with_ties` is set, the truncated tail
14199/// is extended through every subsequent row whose order keys
14200/// equal the last-kept row's keys (so a "top 3 by score" with
14201/// WITH TIES emits row 4 too when row 4 ties row 3 on `score`).
14202///
14203/// The order-key vector is the per-row sort key the caller already
14204/// computed via `build_order_keys`; equal-key detection therefore
14205/// matches the sort comparator exactly.
14206fn apply_offset_and_limit_tagged(
14207    tagged: &mut Vec<(Vec<f64>, Row)>,
14208    offset: Option<u32>,
14209    limit: Option<u32>,
14210    with_ties: bool,
14211) {
14212    if let Some(off) = offset {
14213        let off = off as usize;
14214        if off >= tagged.len() {
14215            tagged.clear();
14216        } else {
14217            tagged.drain(..off);
14218        }
14219    }
14220    if let Some(n) = limit {
14221        let n = n as usize;
14222        if with_ties && n > 0 && n < tagged.len() {
14223            let cutoff_key = tagged[n - 1].0.clone();
14224            let mut end = n;
14225            while end < tagged.len() && tagged[end].0 == cutoff_key {
14226                end += 1;
14227            }
14228            tagged.truncate(end);
14229        } else {
14230            tagged.truncate(n);
14231        }
14232    }
14233}
14234
14235/// v7.17.0 Phase 3.P0-49 — PG-canonical: `FETCH FIRST <n> ROWS
14236/// WITH TIES` requires an `ORDER BY`. Without one, there's no
14237/// way to identify "ties" deterministically, so PG errors at
14238/// plan time. SPG mirrors that surface so the same DDL / app
14239/// behaviour holds on cutover.
14240fn check_with_ties_requires_order_by(stmt: &SelectStatement) -> Result<(), EngineError> {
14241    if stmt.limit_with_ties && stmt.order_by.is_empty() {
14242        return Err(EngineError::Unsupported(alloc::string::String::from(
14243            "FETCH FIRST … ROWS WITH TIES requires an ORDER BY clause",
14244        )));
14245    }
14246    Ok(())
14247}
14248
14249/// v7.6.1 — resolve a parser-level `ForeignKeyConstraint` (column
14250/// names + parent table name) into the storage-layer shape (column
14251/// indices + same parent table). Validates everything the engine
14252/// needs to know about the FK at CREATE TABLE time:
14253///
14254///   - parent table exists (catalog lookup, unless self-referencing)
14255///   - parent columns exist on the parent table
14256///   - parent column list matches the local arity (defaults to the
14257///     parent's primary index column when omitted)
14258///   - parent columns are covered by a `BTree` UNIQUE-class index
14259///     (SPG's stand-in for `PRIMARY KEY`/`UNIQUE`) — required so
14260///     the v7.6.2 INSERT path can do an O(log n) parent lookup
14261///   - local columns exist on the table being created
14262fn resolve_foreign_key(
14263    local_table_name: &str,
14264    local_cols: &[ColumnSchema],
14265    fk: spg_sql::ast::ForeignKeyConstraint,
14266    catalog: &Catalog,
14267) -> Result<spg_storage::ForeignKeyConstraint, EngineError> {
14268    // Resolve local columns.
14269    let mut local_columns = Vec::with_capacity(fk.columns.len());
14270    for name in &fk.columns {
14271        let pos = local_cols
14272            .iter()
14273            .position(|c| c.name == *name)
14274            .ok_or_else(|| {
14275                EngineError::Unsupported(alloc::format!(
14276                    "FOREIGN KEY references unknown local column {name:?}"
14277                ))
14278            })?;
14279        local_columns.push(pos);
14280    }
14281    // Self-referencing FK: parent table is the one we're creating.
14282    // The parent column resolution uses the local column list since
14283    // the catalog doesn't have this table yet.
14284    let is_self_ref = fk.parent_table == local_table_name;
14285    let (parent_cols_for_lookup, parent_table_str): (&[ColumnSchema], &str) = if is_self_ref {
14286        (local_cols, local_table_name)
14287    } else {
14288        let parent_table = catalog.get(&fk.parent_table).ok_or_else(|| {
14289            EngineError::Storage(StorageError::TableNotFound {
14290                name: fk.parent_table.clone(),
14291            })
14292        })?;
14293        (
14294            parent_table.schema().columns.as_slice(),
14295            fk.parent_table.as_str(),
14296        )
14297    };
14298    // Resolve parent column names → positions. If the FK omitted the
14299    // parent column list, fall back to the parent's primary index
14300    // column (single-column only — composite default is rejected
14301    // because there's no unambiguous "PK" in SPG's index list).
14302    let parent_columns: Vec<usize> = if fk.parent_columns.is_empty() {
14303        if fk.columns.len() != 1 {
14304            return Err(EngineError::Unsupported(
14305                "composite FOREIGN KEY without explicit parent column list is not supported \
14306                 — list the parent columns explicitly"
14307                    .into(),
14308            ));
14309        }
14310        // Find a single BTree index on the parent and use its column.
14311        let pos = pick_pk_index_column(catalog, parent_table_str, is_self_ref, local_cols)
14312            .ok_or_else(|| {
14313                EngineError::Unsupported(alloc::format!(
14314                    "parent table {parent_table_str:?} has no PRIMARY-key / UNIQUE BTree index \
14315                     to default the FOREIGN KEY against"
14316                ))
14317            })?;
14318        alloc::vec![pos]
14319    } else {
14320        let mut out = Vec::with_capacity(fk.parent_columns.len());
14321        for name in &fk.parent_columns {
14322            let pos = parent_cols_for_lookup
14323                .iter()
14324                .position(|c| c.name == *name)
14325                .ok_or_else(|| {
14326                    EngineError::Unsupported(alloc::format!(
14327                        "FOREIGN KEY references unknown parent column \
14328                         {name:?} on table {parent_table_str:?}"
14329                    ))
14330                })?;
14331            out.push(pos);
14332        }
14333        out
14334    };
14335    if parent_columns.len() != local_columns.len() {
14336        return Err(EngineError::Unsupported(alloc::format!(
14337            "FOREIGN KEY arity mismatch: {} local columns vs {} parent columns",
14338            local_columns.len(),
14339            parent_columns.len()
14340        )));
14341    }
14342    // For non-self-referencing FKs, verify the parent column set is
14343    // covered by a BTree index. SPG doesn't have a `PRIMARY KEY`
14344    // declaration; the convention is "the parent column for FK
14345    // purposes must have a BTree index" — which the user creates via
14346    // `CREATE INDEX ... USING btree (col)` (the default). We accept
14347    // any single-column BTree index that covers a parent column;
14348    // composite parent column lists require an index whose `column_position`
14349    // matches the first parent column (multi-column BTree indices
14350    // are not in the v7.x roadmap).
14351    if !is_self_ref {
14352        let parent_table = catalog.get(&fk.parent_table).expect("checked above");
14353        let primary_parent_col = parent_columns[0];
14354        let has_btree = parent_table
14355            .schema()
14356            .columns
14357            .get(primary_parent_col)
14358            .is_some()
14359            && parent_table.indices().iter().any(|idx| {
14360                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14361                    && idx.column_position == primary_parent_col
14362                    && idx.partial_predicate.is_none()
14363            });
14364        if !has_btree {
14365            return Err(EngineError::Unsupported(alloc::format!(
14366                "FOREIGN KEY parent column on {:?} is not covered by an unconditional BTree \
14367                 index — create one with `CREATE INDEX ... ON {} ({})` first",
14368                parent_table_str,
14369                parent_table_str,
14370                parent_table.schema().columns[primary_parent_col].name,
14371            )));
14372        }
14373    }
14374    let on_delete = fk_action_sql_to_storage(fk.on_delete);
14375    let on_update = fk_action_sql_to_storage(fk.on_update);
14376    Ok(spg_storage::ForeignKeyConstraint {
14377        name: fk.name,
14378        local_columns,
14379        parent_table: fk.parent_table,
14380        parent_columns,
14381        on_delete,
14382        on_update,
14383    })
14384}
14385
14386/// v7.6.1 — pick a sentinel "primary key" column from the parent
14387/// table when the FK didn't name parent columns. Picks the first
14388/// single-column unconditional BTree index — that's the closest
14389/// thing SPG has to a PRIMARY KEY today. Self-referencing FKs use
14390/// `local_cols` as the column source.
14391fn pick_pk_index_column(
14392    catalog: &Catalog,
14393    parent_name: &str,
14394    is_self_ref: bool,
14395    local_cols: &[ColumnSchema],
14396) -> Option<usize> {
14397    if is_self_ref {
14398        // Self-ref FK omitted parent columns: pick column 0 by
14399        // convention (no catalog entry yet). Engine will widen this
14400        // when v7.6.7 lands; v7.6.1 only handles the explicit form.
14401        let _ = local_cols;
14402        return Some(0);
14403    }
14404    let parent = catalog.get(parent_name)?;
14405    parent.indices().iter().find_map(|idx| {
14406        if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14407            && idx.partial_predicate.is_none()
14408            && idx.included_columns.is_empty()
14409            && idx.expression.is_none()
14410        {
14411            Some(idx.column_position)
14412        } else {
14413            None
14414        }
14415    })
14416}
14417
14418/// v7.9.8 / v7.9.10 — resolve the column positions that
14419/// identify a conflict for ON CONFLICT. Returns a Vec of
14420/// column positions (1 element for single-column form, N for
14421/// composite). When the user wrote bare `ON CONFLICT DO …`,
14422/// falls back to the table's first unconditional BTree index
14423/// (always single-column today).
14424fn resolve_on_conflict_columns(
14425    catalog: &Catalog,
14426    table_name: &str,
14427    target: &[String],
14428) -> Result<Vec<usize>, EngineError> {
14429    let table = catalog.get(table_name).ok_or_else(|| {
14430        EngineError::Storage(StorageError::TableNotFound {
14431            name: table_name.into(),
14432        })
14433    })?;
14434    if target.is_empty() {
14435        // v7.13.2 — mailrs round-6 S5 follow-up. Composite UNIQUE
14436        // constraints carry a multi-column tuple; the prior code
14437        // path picked only the leading column of the first BTree
14438        // index, which caused `ON CONFLICT DO NOTHING` to dedup
14439        // by leading column alone (3 rows with same group_id but
14440        // different permission collapsed to 1). PG semantics use
14441        // the full tuple. Prefer a UniquenessConstraint's full
14442        // column list when one exists; fall back to the leading
14443        // BTree column for legacy single-column UNIQUE.
14444        if let Some(uc) = table.schema().uniqueness_constraints.first() {
14445            return Ok(uc.columns.clone());
14446        }
14447        let pos = table
14448            .indices()
14449            .iter()
14450            .find_map(|idx| {
14451                if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14452                    && idx.partial_predicate.is_none()
14453                    && idx.included_columns.is_empty()
14454                    && idx.expression.is_none()
14455                {
14456                    Some(idx.column_position)
14457                } else {
14458                    None
14459                }
14460            })
14461            .ok_or_else(|| {
14462                EngineError::Unsupported(alloc::format!(
14463                    "ON CONFLICT without target requires a UNIQUE BTree index on {table_name:?}"
14464                ))
14465            })?;
14466        return Ok(alloc::vec![pos]);
14467    }
14468    let mut out = Vec::with_capacity(target.len());
14469    for name in target {
14470        let pos = table
14471            .schema()
14472            .columns
14473            .iter()
14474            .position(|c| c.name == *name)
14475            .ok_or_else(|| {
14476                EngineError::Unsupported(alloc::format!(
14477                    "ON CONFLICT target column {name:?} not found on {table_name:?}"
14478                ))
14479            })?;
14480        out.push(pos);
14481    }
14482    Ok(out)
14483}
14484
14485/// v7.9.8 — check whether the BTree index on `column_pos` of
14486/// `table_name` already has a row with this key.
14487fn on_conflict_key_exists(
14488    catalog: &Catalog,
14489    table_name: &str,
14490    column_pos: usize,
14491    key: &Value,
14492) -> bool {
14493    let Some(table) = catalog.get(table_name) else {
14494        return false;
14495    };
14496    let Some(idx_key) = spg_storage::IndexKey::from_value(key) else {
14497        return false;
14498    };
14499    table.indices().iter().any(|idx| {
14500        matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14501            && idx.column_position == column_pos
14502            && idx.partial_predicate.is_none()
14503            && !idx.lookup_eq(&idx_key).is_empty()
14504    })
14505}
14506
14507/// v7.9.9 / v7.9.10 — look up an existing row's position by
14508/// matching all `column_positions` against the incoming `key`
14509/// tuple. Single-column shape (one column) reduces to the
14510/// canonical PK lookup; composite shapes scan linearly until
14511/// every position matches.
14512fn lookup_row_position_by_keys(
14513    catalog: &Catalog,
14514    table_name: &str,
14515    column_positions: &[usize],
14516    key: &[&Value],
14517) -> Option<usize> {
14518    let table = catalog.get(table_name)?;
14519    table.rows().iter().position(|r| {
14520        column_positions
14521            .iter()
14522            .enumerate()
14523            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
14524    })
14525}
14526
14527/// v7.9.10 — does the table already contain a row whose
14528/// `column_positions` tuple equals `key`? Single-column shape
14529/// uses the existing BTree fast path; composite shapes fall
14530/// back to a row scan.
14531fn on_conflict_keys_exist(
14532    catalog: &Catalog,
14533    table_name: &str,
14534    column_positions: &[usize],
14535    key: &[&Value],
14536) -> bool {
14537    if column_positions.len() == 1 {
14538        return on_conflict_key_exists(catalog, table_name, column_positions[0], key[0]);
14539    }
14540    let Some(table) = catalog.get(table_name) else {
14541        return false;
14542    };
14543    table.rows().iter().any(|r| {
14544        column_positions
14545            .iter()
14546            .enumerate()
14547            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
14548    })
14549}
14550
14551/// v7.9.9 — apply ON CONFLICT DO UPDATE SET assignments to an
14552/// existing row.
14553///
14554/// `incoming` is the rejected INSERT row (used to resolve
14555/// `EXCLUDED.col` references in the assignment exprs);
14556/// `target_pos` is the position of the existing row in the table.
14557/// Each assignment substitutes `EXCLUDED.col` with the matching
14558/// incoming value, evaluates the resulting expression against
14559/// the existing row, and writes the new value into the
14560/// corresponding column of the returned `Vec<Value>`. If
14561/// `where_` evaluates falsy, returns Ok(None) — PG behaviour:
14562/// the conflicting row is silently kept unchanged.
14563fn apply_on_conflict_assignments(
14564    catalog: &Catalog,
14565    table_name: &str,
14566    target_pos: usize,
14567    incoming: &[Value],
14568    assignments: &[(String, Expr)],
14569    where_: Option<&Expr>,
14570) -> Result<Option<Vec<Value>>, EngineError> {
14571    let table = catalog.get(table_name).ok_or_else(|| {
14572        EngineError::Storage(StorageError::TableNotFound {
14573            name: table_name.into(),
14574        })
14575    })?;
14576    let schema_cols = table.schema().columns.clone();
14577    let existing = table
14578        .rows()
14579        .get(target_pos)
14580        .ok_or_else(|| {
14581            EngineError::Unsupported(alloc::format!(
14582                "ON CONFLICT DO UPDATE: row position {target_pos} out of bounds on {table_name:?}"
14583            ))
14584        })?
14585        .clone();
14586    let ctx = eval::EvalContext::new(&schema_cols, Some(table_name));
14587    // Optional WHERE filter on the conflict row.
14588    if let Some(w) = where_ {
14589        let pred = w.clone();
14590        let pred = substitute_excluded_refs(pred, &schema_cols, incoming);
14591        let v = eval::eval_expr(&pred, &existing, &ctx)?;
14592        if !matches!(v, Value::Bool(true)) {
14593            return Ok(None);
14594        }
14595    }
14596    let mut new_values = existing.values.clone();
14597    for (col_name, expr) in assignments {
14598        let target_idx = schema_cols
14599            .iter()
14600            .position(|c| c.name == *col_name)
14601            .ok_or_else(|| {
14602                EngineError::Eval(EvalError::ColumnNotFound {
14603                    name: col_name.clone(),
14604                })
14605            })?;
14606        let sub = substitute_excluded_refs(expr.clone(), &schema_cols, incoming);
14607        let v = eval::eval_expr(&sub, &existing, &ctx)?;
14608        let coerced = coerce_value(v, schema_cols[target_idx].ty, col_name, target_idx)?;
14609        check_unsigned_range(&coerced, &schema_cols[target_idx], target_idx)?;
14610        new_values[target_idx] = coerced;
14611    }
14612    Ok(Some(new_values))
14613}
14614
14615/// v7.9.9 — walk an `Expr` tree replacing any `Column { qualifier:
14616/// "EXCLUDED", name }` reference with a `Literal` of the matching
14617/// value from the incoming-row vec. Resolution against the
14618/// child-table column list (by name).
14619fn substitute_excluded_refs(expr: Expr, schema_cols: &[ColumnSchema], incoming: &[Value]) -> Expr {
14620    use spg_sql::ast::ColumnName;
14621    match expr {
14622        Expr::Column(ColumnName { qualifier, name })
14623            if qualifier
14624                .as_deref()
14625                .is_some_and(|q| q.eq_ignore_ascii_case("excluded")) =>
14626        {
14627            let pos = schema_cols.iter().position(|c| c.name == name);
14628            match pos {
14629                Some(p) => {
14630                    let v = incoming.get(p).cloned().unwrap_or(Value::Null);
14631                    value_to_literal_expr(v)
14632                        .unwrap_or_else(|_| Expr::Literal(spg_sql::ast::Literal::Null))
14633                }
14634                None => Expr::Column(ColumnName { qualifier, name }),
14635            }
14636        }
14637        Expr::Binary { op, lhs, rhs } => Expr::Binary {
14638            op,
14639            lhs: Box::new(substitute_excluded_refs(*lhs, schema_cols, incoming)),
14640            rhs: Box::new(substitute_excluded_refs(*rhs, schema_cols, incoming)),
14641        },
14642        Expr::Unary { op, expr } => Expr::Unary {
14643            op,
14644            expr: Box::new(substitute_excluded_refs(*expr, schema_cols, incoming)),
14645        },
14646        Expr::FunctionCall { name, args } => Expr::FunctionCall {
14647            name,
14648            args: args
14649                .into_iter()
14650                .map(|a| substitute_excluded_refs(a, schema_cols, incoming))
14651                .collect(),
14652        },
14653        other => other,
14654    }
14655}
14656
14657/// v7.6.2 / v7.6.7 — INSERT-side FK enforcement. For every row
14658/// about to be inserted into `child_table`, every FK declared on
14659/// that table is checked: the row's FK columns must either be
14660/// NULL (SQL spec skip) or match an existing parent row via the
14661/// parent's BTree PK / UNIQUE index.
14662///
14663/// Returns `EngineError::Unsupported` with a `FOREIGN KEY violation`
14664/// payload on first failure.
14665///
14666/// **Self-referencing FKs (v7.6.7 widening):** when `fk.parent_table
14667/// == child_table`, the parent rows visible to this check are
14668///  (a) rows already committed to the table, plus
14669///  (b) earlier rows from the *same* `rows` batch.
14670/// This makes `INSERT INTO tree VALUES (1, NULL), (2, 1), (3, 2)`
14671/// work in a single statement — common pattern for bulk-loading
14672/// hierarchies.
14673/// v7.9.19 — enforce table-level UNIQUE / PRIMARY KEY tuple
14674/// constraints at INSERT time. For each constraint declared on
14675/// the target table, check that no existing row + no earlier row
14676/// in the same batch has the same full-column tuple. NULL in
14677/// any column lifts the row out of the check (SQL spec: NULL
14678/// ≠ NULL for uniqueness). mailrs G1 + G6.
14679fn enforce_uniqueness_inserts(
14680    catalog: &Catalog,
14681    child_table: &str,
14682    constraints: &[spg_storage::UniquenessConstraint],
14683    rows: &[Vec<Value>],
14684) -> Result<(), EngineError> {
14685    if constraints.is_empty() {
14686        return Ok(());
14687    }
14688    let table = catalog.get(child_table).ok_or_else(|| {
14689        EngineError::Storage(StorageError::TableNotFound {
14690            name: child_table.into(),
14691        })
14692    })?;
14693    let schema = table.schema();
14694    for uc in constraints {
14695        for (batch_idx, row_values) in rows.iter().enumerate() {
14696            // v7.17.0 Phase 3.P0-45 — fold each key cell by its
14697            // column's declared Collation before comparing. Phase
14698            // 2.5b wired Collation into GROUP BY / ORDER BY / `=`
14699            // but the UNIQUE-constraint enforcement still compared
14700            // Text byte-wise; a `*_ci` column would let
14701            // `('Foo')` and `('FOO')` coexist when MySQL would
14702            // reject the second. Owned Values so the fold and
14703            // the borrow live in the same scope.
14704            let key: Vec<Value> = uc
14705                .columns
14706                .iter()
14707                .map(|&i| collated_key_cell(&row_values[i], i, schema))
14708                .collect();
14709            let has_null = key.iter().any(|v| matches!(v, Value::Null));
14710            // v7.13.0 — `NULLS NOT DISTINCT` (mailrs round-5 G10,
14711            // PG 15+): two rows whose constrained columns are all
14712            // NULL collide. SQL-standard `NULLS DISTINCT` lets any
14713            // NULL skip the check.
14714            if has_null && !uc.nulls_not_distinct {
14715                continue;
14716            }
14717            // Table-side collision: scan existing rows.
14718            let collides_in_table = table.rows().iter().any(|prow| {
14719                uc.columns.iter().enumerate().all(|(i, &p)| {
14720                    prow.values
14721                        .get(p)
14722                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
14723                })
14724            });
14725            // Batch-side collision: earlier rows in the same INSERT.
14726            let collides_in_batch = rows[..batch_idx].iter().any(|earlier| {
14727                uc.columns.iter().enumerate().all(|(i, &p)| {
14728                    earlier
14729                        .get(p)
14730                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
14731                })
14732            });
14733            if collides_in_table || collides_in_batch {
14734                let kind = if uc.is_primary_key {
14735                    "PRIMARY KEY"
14736                } else {
14737                    "UNIQUE"
14738                };
14739                let col_names: Vec<String> = uc
14740                    .columns
14741                    .iter()
14742                    .map(|&i| table.schema().columns[i].name.clone())
14743                    .collect();
14744                return Err(EngineError::Unsupported(alloc::format!(
14745                    "{kind} violation on {child_table:?} columns {col_names:?}: \
14746                     row #{batch_idx} duplicates an existing key"
14747                )));
14748            }
14749        }
14750    }
14751    Ok(())
14752}
14753
14754/// v7.17.0 Phase 3.P0-45 — return a key cell folded by its column's
14755/// declared `Collation`. For `CaseInsensitive`, fold Text payloads to
14756/// ASCII lowercase (matches Phase 2.5's `*_ci` semantics: ASCII case-
14757/// fold only, non-ASCII bytes stay byte-wise). For `Binary` or non-Text
14758/// values, the cell passes through unchanged. The caller compares the
14759/// folded values with `==`.
14760fn collated_key_cell(
14761    v: &spg_storage::Value,
14762    column_position: usize,
14763    schema: &spg_storage::TableSchema,
14764) -> spg_storage::Value {
14765    match (v, schema.columns.get(column_position).map(|c| c.collation)) {
14766        (spg_storage::Value::Text(s), Some(spg_storage::Collation::CaseInsensitive)) => {
14767            spg_storage::Value::Text(s.to_ascii_lowercase())
14768        }
14769        _ => v.clone(),
14770    }
14771}
14772
14773/// v7.9.29 — `true` iff `v` counts as a truthy SQL value for a
14774/// WHERE-style predicate. NULL → false (three-valued logic
14775/// collapses to "skip this row" for index inclusion). Numeric
14776/// non-zero, BIGINT non-zero, TINYINT non-zero, BOOLEAN true → true.
14777/// Everything else (strings, vectors, JSON, …) is not a valid
14778/// predicate result and surfaces as `false` so a malformed
14779/// predicate degrades to "row not in index" rather than panicking.
14780fn predicate_truthy(v: &spg_storage::Value) -> bool {
14781    use spg_storage::Value as V;
14782    match v {
14783        V::Bool(b) => *b,
14784        V::Int(n) => *n != 0,
14785        V::BigInt(n) => *n != 0,
14786        V::SmallInt(n) => *n != 0,
14787        _ => false,
14788    }
14789}
14790
14791/// v7.9.29 — at CREATE UNIQUE INDEX time, scan the table's
14792/// committed rows for pre-existing duplicates. If any pair of rows
14793/// matches the predicate AND has the same index key, refuse to
14794/// create the index so the user fixes the data before retrying.
14795fn check_existing_unique_violation(
14796    idx: &spg_storage::Index,
14797    schema: &spg_storage::TableSchema,
14798    rows: &[spg_storage::Row],
14799) -> Result<(), EngineError> {
14800    let predicate_expr = match idx.partial_predicate.as_deref() {
14801        Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
14802            EngineError::Unsupported(alloc::format!(
14803                "stored partial predicate {s:?} failed to re-parse: {e:?}"
14804            ))
14805        })?),
14806        None => None,
14807    };
14808    let ctx = eval::EvalContext::new(&schema.columns, None);
14809    let key_positions = unique_key_positions(idx);
14810    let mut seen: alloc::vec::Vec<alloc::vec::Vec<spg_storage::Value>> = alloc::vec::Vec::new();
14811    for row in rows {
14812        if let Some(expr) = &predicate_expr {
14813            let v = eval::eval_expr(expr, row, &ctx).map_err(|e| {
14814                EngineError::Unsupported(alloc::format!(
14815                    "evaluating UNIQUE INDEX predicate against existing row: {e:?}"
14816                ))
14817            })?;
14818            if !predicate_truthy(&v) {
14819                continue;
14820            }
14821        }
14822        let key: alloc::vec::Vec<spg_storage::Value> = key_positions
14823            .iter()
14824            .map(|&p| {
14825                let v = row
14826                    .values
14827                    .get(p)
14828                    .cloned()
14829                    .unwrap_or(spg_storage::Value::Null);
14830                collated_key_cell(&v, p, schema)
14831            })
14832            .collect();
14833        if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
14834            continue;
14835        }
14836        if seen.iter().any(|other| *other == key) {
14837            return Err(EngineError::Unsupported(alloc::format!(
14838                "CREATE UNIQUE INDEX {:?}: existing rows already violate the constraint",
14839                idx.name
14840            )));
14841        }
14842        seen.push(key);
14843    }
14844    Ok(())
14845}
14846
14847/// v7.9.29 — full key tuple for a UNIQUE INDEX (leading +
14848/// extra positions). For single-column indexes this is just
14849/// `[column_position]`.
14850fn unique_key_positions(idx: &spg_storage::Index) -> alloc::vec::Vec<usize> {
14851    let mut out = alloc::vec::Vec::with_capacity(1 + idx.extra_column_positions.len());
14852    out.push(idx.column_position);
14853    out.extend_from_slice(&idx.extra_column_positions);
14854    out
14855}
14856
14857/// v7.9.29 — at INSERT time, walk every `is_unique` index on the
14858/// target table. For each, eval the index's optional predicate
14859/// against (a) the candidate row and (b) every committed row plus
14860/// earlier batch rows; only rows where the predicate is truthy
14861/// participate. A duplicate key among predicate-matching rows is a
14862/// uniqueness violation. NULL keys lift the row out of the check
14863/// (matching PG's "UNIQUE allows multiple NULLs" semantics).
14864fn enforce_unique_index_inserts(
14865    catalog: &Catalog,
14866    table_name: &str,
14867    rows: &[alloc::vec::Vec<spg_storage::Value>],
14868) -> Result<(), EngineError> {
14869    let table = catalog.get(table_name).ok_or_else(|| {
14870        EngineError::Storage(StorageError::TableNotFound {
14871            name: table_name.into(),
14872        })
14873    })?;
14874    let schema = table.schema();
14875    let ctx = eval::EvalContext::new(&schema.columns, None);
14876    for idx in table.indices() {
14877        if !idx.is_unique {
14878            continue;
14879        }
14880        // Re-parse the predicate once per index per batch.
14881        let predicate_expr = match idx.partial_predicate.as_deref() {
14882            Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
14883                EngineError::Unsupported(alloc::format!(
14884                    "UNIQUE INDEX {:?} predicate {s:?} failed to re-parse: {e:?}",
14885                    idx.name
14886                ))
14887            })?),
14888            None => None,
14889        };
14890        let key_positions = unique_key_positions(idx);
14891        let key_of = |values: &[spg_storage::Value]| -> alloc::vec::Vec<spg_storage::Value> {
14892            // v7.17.0 Phase 3.P0-45 — fold per-column collation
14893            // before building the comparison key so a `*_ci`
14894            // column treats `'Foo'` and `'FOO'` as equal.
14895            key_positions
14896                .iter()
14897                .map(|&p| {
14898                    let v = values.get(p).cloned().unwrap_or(spg_storage::Value::Null);
14899                    collated_key_cell(&v, p, schema)
14900                })
14901                .collect()
14902        };
14903        // Helper: does `values` participate in this index? (predicate
14904        // truthy when present.) Wraps `values` into a transient Row
14905        // because eval_expr requires &Row.
14906        let participates = |values: &[spg_storage::Value]| -> Result<bool, EngineError> {
14907            let Some(expr) = &predicate_expr else {
14908                return Ok(true);
14909            };
14910            let tmp_row = spg_storage::Row {
14911                values: values.to_vec(),
14912            };
14913            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
14914                EngineError::Unsupported(alloc::format!(
14915                    "UNIQUE INDEX {:?} predicate eval: {e:?}",
14916                    idx.name
14917                ))
14918            })?;
14919            Ok(predicate_truthy(&v))
14920        };
14921        for (batch_idx, row_values) in rows.iter().enumerate() {
14922            if !participates(row_values)? {
14923                continue;
14924            }
14925            let key = key_of(row_values);
14926            if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
14927                continue;
14928            }
14929            // Committed-table collision.
14930            for prow in table.rows() {
14931                if !participates(&prow.values)? {
14932                    continue;
14933                }
14934                if key_of(&prow.values) == key {
14935                    return Err(EngineError::Unsupported(alloc::format!(
14936                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
14937                         row #{batch_idx} duplicates an existing key",
14938                        idx.name
14939                    )));
14940                }
14941            }
14942            // Within-batch collision: earlier rows in the same INSERT.
14943            for earlier in &rows[..batch_idx] {
14944                if !participates(earlier)? {
14945                    continue;
14946                }
14947                if key_of(earlier) == key {
14948                    return Err(EngineError::Unsupported(alloc::format!(
14949                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
14950                         row #{batch_idx} duplicates an earlier row in the same batch",
14951                        idx.name
14952                    )));
14953                }
14954            }
14955        }
14956    }
14957    Ok(())
14958}
14959
14960/// v7.13.0 — `UPDATE OF cols` filter helper (mailrs round-5 G7).
14961/// Returns `true` when at least one of `filter_cols` has a
14962/// different value in `new_row` vs `old_row`. Column lookup is
14963/// case-insensitive against `schema_cols`; unknown filter columns
14964/// are treated as "not changed" (the trigger therefore won't
14965/// fire on them — surfacing a parse-time error would be too
14966/// strict for catalog reloads where the schema may have drifted).
14967fn any_column_changed(
14968    filter_cols: &[String],
14969    schema_cols: &[ColumnSchema],
14970    old_row: &Row,
14971    new_row: &Row,
14972) -> bool {
14973    for col_name in filter_cols {
14974        let Some(pos) = schema_cols
14975            .iter()
14976            .position(|c| c.name.eq_ignore_ascii_case(col_name))
14977        else {
14978            continue;
14979        };
14980        let old_v = old_row.values.get(pos);
14981        let new_v = new_row.values.get(pos);
14982        if old_v != new_v {
14983            return true;
14984        }
14985    }
14986    false
14987}
14988
14989/// v7.13.0 — evaluate every CHECK predicate on the schema against
14990/// each candidate row. Mirrors PG semantics: a `false` result
14991/// rejects the mutation; a NULL result *passes* (CHECK rejects
14992/// only on definite-false, not on unknown). mailrs round-5 G3.
14993fn enforce_check_constraints(
14994    catalog: &Catalog,
14995    table_name: &str,
14996    rows: &[alloc::vec::Vec<spg_storage::Value>],
14997) -> Result<(), EngineError> {
14998    let table = catalog.get(table_name).ok_or_else(|| {
14999        EngineError::Storage(StorageError::TableNotFound {
15000            name: table_name.into(),
15001        })
15002    })?;
15003    let schema = table.schema();
15004    // v7.17.0 Phase 1.5 — domain-level CHECKs are enforced in
15005    // parallel with table-level CHECKs. Collect both lists up
15006    // front; if neither exists we early-out.
15007    let mut domain_checks_per_col: alloc::vec::Vec<(usize, alloc::vec::Vec<Expr>)> =
15008        alloc::vec::Vec::new();
15009    for (idx, col) in schema.columns.iter().enumerate() {
15010        let Some(dname) = &col.user_domain_type else {
15011            continue;
15012        };
15013        let Some(dom) = catalog.domain_types().get(dname) else {
15014            continue;
15015        };
15016        let mut parsed_for_col: alloc::vec::Vec<Expr> =
15017            alloc::vec::Vec::with_capacity(dom.checks.len());
15018        for src in &dom.checks {
15019            let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
15020                EngineError::Unsupported(alloc::format!(
15021                    "DOMAIN {dname:?} CHECK ({src:?}) on column {:?}: re-parse failed: {e:?}",
15022                    col.name
15023                ))
15024            })?;
15025            parsed_for_col.push(expr);
15026        }
15027        if !parsed_for_col.is_empty() {
15028            domain_checks_per_col.push((idx, parsed_for_col));
15029        }
15030    }
15031    if schema.checks.is_empty() && domain_checks_per_col.is_empty() {
15032        return Ok(());
15033    }
15034    let ctx = eval::EvalContext::new(&schema.columns, None);
15035    let mut parsed: alloc::vec::Vec<(usize, Expr)> = alloc::vec::Vec::new();
15036    for (i, src) in schema.checks.iter().enumerate() {
15037        let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
15038            EngineError::Unsupported(alloc::format!(
15039                "CHECK constraint #{i} on {table_name:?} ({src:?}) failed to re-parse: {e:?}"
15040            ))
15041        })?;
15042        parsed.push((i, expr));
15043    }
15044    for (batch_idx, row_values) in rows.iter().enumerate() {
15045        let tmp_row = spg_storage::Row {
15046            values: row_values.clone(),
15047        };
15048        for (i, expr) in &parsed {
15049            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
15050                EngineError::Unsupported(alloc::format!(
15051                    "CHECK constraint #{i} on {table_name:?} eval at row #{batch_idx}: {e:?}"
15052                ))
15053            })?;
15054            // PG: NULL passes (CHECK rejects on definite-false only).
15055            if matches!(v, spg_storage::Value::Bool(false)) {
15056                return Err(EngineError::Unsupported(alloc::format!(
15057                    "CHECK constraint violation on {table_name:?} (row #{batch_idx}): {:?}",
15058                    schema.checks[*i]
15059                )));
15060            }
15061        }
15062        // v7.17.0 Phase 1.5 — domain-level CHECKs. Each CHECK
15063        // expression references VALUE as a column-name; we
15064        // substitute the per-row cell into the eval context by
15065        // synthesising a single-column row of just that value
15066        // under a temporary `value` column schema.
15067        for (col_idx, checks) in &domain_checks_per_col {
15068            let cell = row_values
15069                .get(*col_idx)
15070                .cloned()
15071                .unwrap_or(spg_storage::Value::Null);
15072            let synth_cols = alloc::vec![spg_storage::ColumnSchema::new(
15073                "value",
15074                schema.columns[*col_idx].ty,
15075                schema.columns[*col_idx].nullable,
15076            )];
15077            let synth_ctx = eval::EvalContext::new(&synth_cols, None);
15078            let synth_row = spg_storage::Row {
15079                values: alloc::vec![cell],
15080            };
15081            for (ci, expr) in checks.iter().enumerate() {
15082                let v = eval::eval_expr(expr, &synth_row, &synth_ctx).map_err(|e| {
15083                    EngineError::Unsupported(alloc::format!(
15084                        "DOMAIN CHECK #{ci} on column {:?} eval at row #{batch_idx}: {e:?}",
15085                        schema.columns[*col_idx].name
15086                    ))
15087                })?;
15088                if matches!(v, spg_storage::Value::Bool(false)) {
15089                    return Err(EngineError::Unsupported(alloc::format!(
15090                        "DOMAIN CHECK violation on column {:?} (row #{batch_idx})",
15091                        schema.columns[*col_idx].name
15092                    )));
15093                }
15094            }
15095        }
15096    }
15097    Ok(())
15098}
15099
15100fn enforce_fk_inserts(
15101    catalog: &Catalog,
15102    child_table: &str,
15103    fks: &[spg_storage::ForeignKeyConstraint],
15104    rows: &[Vec<Value>],
15105) -> Result<(), EngineError> {
15106    for fk in fks {
15107        let parent_is_self = fk.parent_table == child_table;
15108        let parent = if parent_is_self {
15109            // Self-ref: read the current state of the same table.
15110            // The mut borrow on child has been dropped by the caller.
15111            catalog.get(child_table).ok_or_else(|| {
15112                EngineError::Storage(StorageError::TableNotFound {
15113                    name: child_table.into(),
15114                })
15115            })?
15116        } else {
15117            catalog.get(&fk.parent_table).ok_or_else(|| {
15118                EngineError::Storage(StorageError::TableNotFound {
15119                    name: fk.parent_table.clone(),
15120                })
15121            })?
15122        };
15123        for (batch_idx, row_values) in rows.iter().enumerate() {
15124            // Single-column FK fast path: try the parent's BTree
15125            // index for an O(log n) lookup. Composite FKs fall back
15126            // to a parent-row scan.
15127            if fk.local_columns.len() == 1 {
15128                let v = &row_values[fk.local_columns[0]];
15129                if matches!(v, Value::Null) {
15130                    continue;
15131                }
15132                let parent_col = fk.parent_columns[0];
15133                let key = spg_storage::IndexKey::from_value(v).ok_or_else(|| {
15134                    EngineError::Unsupported(alloc::format!(
15135                        "FOREIGN KEY column value of type {:?} is not index-eligible",
15136                        v.data_type()
15137                    ))
15138                })?;
15139                let present_committed = parent.indices().iter().any(|idx| {
15140                    matches!(idx.kind, spg_storage::IndexKind::BTree(_))
15141                        && idx.column_position == parent_col
15142                        && idx.partial_predicate.is_none()
15143                        && !idx.lookup_eq(&key).is_empty()
15144                });
15145                // v7.6.7 self-ref widening: also accept a match
15146                // against earlier rows in this same batch when the
15147                // FK points at the table being inserted into.
15148                let present_in_batch = parent_is_self
15149                    && rows[..batch_idx]
15150                        .iter()
15151                        .any(|earlier| earlier.get(parent_col) == Some(v));
15152                if !(present_committed || present_in_batch) {
15153                    return Err(EngineError::Unsupported(alloc::format!(
15154                        "FOREIGN KEY violation: no parent row in {:?} where {} = {:?}",
15155                        fk.parent_table,
15156                        parent
15157                            .schema()
15158                            .columns
15159                            .get(parent_col)
15160                            .map_or("?", |c| c.name.as_str()),
15161                        v,
15162                    )));
15163                }
15164            } else {
15165                // Composite FK: scan parent rows. v7.6.7 also
15166                // accepts a match against earlier rows in the same
15167                // batch (self-ref bulk-loading of hierarchies).
15168                if fk
15169                    .local_columns
15170                    .iter()
15171                    .all(|&i| matches!(row_values.get(i), Some(Value::Null)))
15172                {
15173                    continue;
15174                }
15175                let local: Vec<&Value> = fk.local_columns.iter().map(|&i| &row_values[i]).collect();
15176                let parent_match_committed = parent.rows().iter().any(|prow| {
15177                    fk.parent_columns
15178                        .iter()
15179                        .enumerate()
15180                        .all(|(i, &pi)| prow.values.get(pi) == Some(local[i]))
15181                });
15182                let parent_match_in_batch = parent_is_self
15183                    && rows[..batch_idx].iter().any(|earlier| {
15184                        fk.parent_columns
15185                            .iter()
15186                            .enumerate()
15187                            .all(|(i, &pi)| earlier.get(pi) == Some(local[i]))
15188                    });
15189                if !(parent_match_committed || parent_match_in_batch) {
15190                    return Err(EngineError::Unsupported(alloc::format!(
15191                        "FOREIGN KEY violation: no parent row in {:?} matching composite key",
15192                        fk.parent_table,
15193                    )));
15194                }
15195            }
15196        }
15197    }
15198    Ok(())
15199}
15200
15201/// v7.6.4 / v7.6.5 — one step of the FK action plan computed for a
15202/// DELETE on a parent. The plan is a list of these steps, stacked
15203/// across the FK graph by `plan_fk_parent_deletions`.
15204#[derive(Debug, Clone)]
15205struct FkChildStep {
15206    child_table: String,
15207    action: FkChildAction,
15208}
15209
15210#[derive(Debug, Clone)]
15211enum FkChildAction {
15212    /// CASCADE — remove these rows. Sorted, deduplicated positions.
15213    Delete { positions: Vec<usize> },
15214    /// SET NULL — for each (row, column) in the flat list, write
15215    /// NULL into that child cell. Multiple FKs on the same row may
15216    /// produce overlapping entries (deduped at plan time).
15217    SetNull {
15218        positions: Vec<usize>,
15219        columns: Vec<usize>,
15220    },
15221    /// SET DEFAULT — same shape as SetNull but writes the column's
15222    /// declared DEFAULT value (resolved at plan time). Columns
15223    /// without a DEFAULT raise an error during planning.
15224    SetDefault {
15225        positions: Vec<usize>,
15226        columns: Vec<usize>,
15227        defaults: Vec<Value>,
15228    },
15229}
15230
15231/// v7.6.3 → v7.6.5 — plan FK fallout for a DELETE on a parent table.
15232///
15233/// Walks every table in the catalog looking for FKs whose
15234/// `parent_table` is `parent_table_name`. For each such FK + each
15235/// to-be-deleted parent row:
15236///
15237///   - RESTRICT / NoAction → error, no plan returned
15238///   - CASCADE → child rows get scheduled for deletion; recursive
15239///   - SetNull → child FK column(s) scheduled to be NULL-ed.
15240///     Verified NULL-able at plan time.
15241///   - SetDefault → child FK column(s) scheduled to be reset to
15242///     their declared DEFAULT. Columns without a DEFAULT raise.
15243///
15244/// SET NULL / SET DEFAULT do NOT cascade further — the child row
15245/// stays; only one of its columns mutates.
15246fn plan_fk_parent_deletions(
15247    catalog: &Catalog,
15248    parent_table_name: &str,
15249    to_delete_positions: &[usize],
15250    to_delete_rows: &[Vec<Value>],
15251) -> Result<Vec<FkChildStep>, EngineError> {
15252    use alloc::collections::{BTreeMap, BTreeSet};
15253    if to_delete_rows.is_empty() {
15254        return Ok(Vec::new());
15255    }
15256    let mut delete_plan: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
15257    // setnull / setdefault keyed by child_table → (row_idx, col_idx) → optional default
15258    let mut setnull_plan: BTreeMap<String, BTreeSet<(usize, usize)>> = BTreeMap::new();
15259    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15260    let mut visited: BTreeSet<(String, usize)> = BTreeSet::new();
15261    for &p in to_delete_positions {
15262        visited.insert((parent_table_name.to_string(), p));
15263    }
15264    let mut work: Vec<(String, Vec<Value>)> = to_delete_rows
15265        .iter()
15266        .map(|r| (parent_table_name.to_string(), r.clone()))
15267        .collect();
15268    while let Some((cur_parent, parent_row)) = work.pop() {
15269        for child_name in catalog.table_names() {
15270            let child = catalog
15271                .get(&child_name)
15272                .expect("table_names → catalog.get round-trip is total");
15273            for fk in &child.schema().foreign_keys {
15274                if fk.parent_table != cur_parent {
15275                    continue;
15276                }
15277                let parent_key: Vec<&Value> = fk
15278                    .parent_columns
15279                    .iter()
15280                    .map(|&pi| &parent_row[pi])
15281                    .collect();
15282                if parent_key.iter().any(|v| matches!(v, Value::Null)) {
15283                    continue;
15284                }
15285                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
15286                    if child_name == cur_parent
15287                        && visited.contains(&(child_name.clone(), child_row_idx))
15288                    {
15289                        continue;
15290                    }
15291                    let matches_key = fk
15292                        .local_columns
15293                        .iter()
15294                        .enumerate()
15295                        .all(|(i, &li)| child_row.values.get(li) == Some(parent_key[i]));
15296                    if !matches_key {
15297                        continue;
15298                    }
15299                    match fk.on_delete {
15300                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
15301                            return Err(EngineError::Unsupported(alloc::format!(
15302                                "FOREIGN KEY violation: DELETE on {cur_parent:?} is \
15303                                 restricted by FK from {child_name:?}.{:?}",
15304                                fk.local_columns,
15305                            )));
15306                        }
15307                        spg_storage::FkAction::Cascade => {
15308                            if visited.insert((child_name.clone(), child_row_idx)) {
15309                                delete_plan
15310                                    .entry(child_name.clone())
15311                                    .or_default()
15312                                    .insert(child_row_idx);
15313                                work.push((child_name.clone(), child_row.values.clone()));
15314                            }
15315                        }
15316                        spg_storage::FkAction::SetNull => {
15317                            // Verify every local FK column is NULL-able.
15318                            for &li in &fk.local_columns {
15319                                let col = child.schema().columns.get(li).ok_or_else(|| {
15320                                    EngineError::Unsupported(alloc::format!(
15321                                        "FK local column {li} missing in {child_name:?}"
15322                                    ))
15323                                })?;
15324                                if !col.nullable {
15325                                    return Err(EngineError::Unsupported(alloc::format!(
15326                                        "FOREIGN KEY ON DELETE SET NULL: column \
15327                                         {child_name:?}.{:?} is NOT NULL — cannot SET NULL",
15328                                        col.name,
15329                                    )));
15330                                }
15331                            }
15332                            let entry = setnull_plan.entry(child_name.clone()).or_default();
15333                            for &li in &fk.local_columns {
15334                                entry.insert((child_row_idx, li));
15335                            }
15336                        }
15337                        spg_storage::FkAction::SetDefault => {
15338                            // Resolve the DEFAULT for every local FK col.
15339                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
15340                            for &li in &fk.local_columns {
15341                                let col = child.schema().columns.get(li).ok_or_else(|| {
15342                                    EngineError::Unsupported(alloc::format!(
15343                                        "FK local column {li} missing in {child_name:?}"
15344                                    ))
15345                                })?;
15346                                let default = col.default.clone().ok_or_else(|| {
15347                                    EngineError::Unsupported(alloc::format!(
15348                                        "FOREIGN KEY ON DELETE SET DEFAULT: column \
15349                                         {child_name:?}.{:?} has no DEFAULT declared",
15350                                        col.name,
15351                                    ))
15352                                })?;
15353                                entry.insert((child_row_idx, li), default);
15354                            }
15355                        }
15356                    }
15357                }
15358            }
15359        }
15360    }
15361    // Flatten the three plans into the ordered `FkChildStep` list.
15362    // Deletes are applied last per child (after any null/default
15363    // re-writes on the same child) so a child row that's both
15364    // re-written and then cascade-deleted only ends up deleted —
15365    // but in v7.6.5 SetNull/Cascade never overlap on the same row
15366    // (a single FK chooses exactly one action), so the order is
15367    // mostly a precaution.
15368    let mut steps: Vec<FkChildStep> = Vec::new();
15369    for (child_table, entries) in setnull_plan {
15370        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
15371        steps.push(FkChildStep {
15372            child_table,
15373            action: FkChildAction::SetNull { positions, columns },
15374        });
15375    }
15376    for (child_table, entries) in setdefault_plan {
15377        let mut positions = Vec::with_capacity(entries.len());
15378        let mut columns = Vec::with_capacity(entries.len());
15379        let mut defaults = Vec::with_capacity(entries.len());
15380        for ((p, c), v) in entries {
15381            positions.push(p);
15382            columns.push(c);
15383            defaults.push(v);
15384        }
15385        steps.push(FkChildStep {
15386            child_table,
15387            action: FkChildAction::SetDefault {
15388                positions,
15389                columns,
15390                defaults,
15391            },
15392        });
15393    }
15394    for (child_table, positions) in delete_plan {
15395        steps.push(FkChildStep {
15396            child_table,
15397            action: FkChildAction::Delete {
15398                positions: positions.into_iter().collect(),
15399            },
15400        });
15401    }
15402    Ok(steps)
15403}
15404
15405/// v7.6.6 — plan FK fallout for an UPDATE that mutates parent-side
15406/// PK/UNIQUE columns. Walks every other table whose FK references
15407/// `parent_table_name`; for each FK whose parent_columns overlap a
15408/// mutated column, decides the action by `fk.on_update`.
15409///
15410///   - RESTRICT / NoAction → error if any child references the OLD
15411///     value
15412///   - CASCADE → child FK columns get rewritten to the NEW parent
15413///     value (a SetNull-style update step with the new value)
15414///   - SetNull → child FK columns set to NULL
15415///   - SetDefault → child FK columns set to declared default
15416///
15417/// `plan_with_old` is `(row_position, old_values, new_values)` so
15418/// the planner can detect "did this row's parent key actually
15419/// change?" — only rows where at least one referenced parent
15420/// column moved trigger inbound work.
15421fn plan_fk_parent_updates(
15422    catalog: &Catalog,
15423    parent_table_name: &str,
15424    plan_with_old: &[(usize, Vec<Value>, Vec<Value>)],
15425) -> Result<Vec<FkChildStep>, EngineError> {
15426    use alloc::collections::BTreeMap;
15427    if plan_with_old.is_empty() {
15428        return Ok(Vec::new());
15429    }
15430    // For each child table we may touch, build per-child step
15431    // lists. UPDATE never deletes children — `delete_plan` stays
15432    // empty here but is kept structurally aligned with
15433    // `plan_fk_parent_deletions` for future use.
15434    let delete_plan: BTreeMap<String, alloc::collections::BTreeSet<usize>> = BTreeMap::new();
15435    let mut setnull_plan: BTreeMap<String, alloc::collections::BTreeSet<(usize, usize)>> =
15436        BTreeMap::new();
15437    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15438    // Cascade-update plan: child_table → row_idx → col_idx → new_value
15439    let mut cascade_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15440
15441    for child_name in catalog.table_names() {
15442        let child = catalog
15443            .get(&child_name)
15444            .expect("table_names → catalog.get total");
15445        for fk in &child.schema().foreign_keys {
15446            if fk.parent_table != parent_table_name {
15447                continue;
15448            }
15449            for (_pos, old_row, new_row) in plan_with_old {
15450                // Did any parent FK column change?
15451                let key_changed = fk
15452                    .parent_columns
15453                    .iter()
15454                    .any(|&pi| old_row.get(pi) != new_row.get(pi));
15455                if !key_changed {
15456                    continue;
15457                }
15458                // The OLD parent key — used to find referring children.
15459                let old_key: Vec<&Value> =
15460                    fk.parent_columns.iter().map(|&pi| &old_row[pi]).collect();
15461                if old_key.iter().any(|v| matches!(v, Value::Null)) {
15462                    // NULL parent has no children — skip.
15463                    continue;
15464                }
15465                let new_key: Vec<&Value> =
15466                    fk.parent_columns.iter().map(|&pi| &new_row[pi]).collect();
15467                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
15468                    // Self-ref same-row updates: a row updating its
15469                    // own PK doesn't restrict itself.
15470                    if child_name == parent_table_name
15471                        && plan_with_old.iter().any(|(p, _, _)| *p == child_row_idx)
15472                    {
15473                        continue;
15474                    }
15475                    let matches_key = fk
15476                        .local_columns
15477                        .iter()
15478                        .enumerate()
15479                        .all(|(i, &li)| child_row.values.get(li) == Some(old_key[i]));
15480                    if !matches_key {
15481                        continue;
15482                    }
15483                    match fk.on_update {
15484                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
15485                            return Err(EngineError::Unsupported(alloc::format!(
15486                                "FOREIGN KEY violation: UPDATE on {parent_table_name:?} PK is \
15487                                 restricted by FK from {child_name:?}.{:?}",
15488                                fk.local_columns,
15489                            )));
15490                        }
15491                        spg_storage::FkAction::Cascade => {
15492                            // Rewrite child FK columns to new key.
15493                            let entry = cascade_plan.entry(child_name.clone()).or_default();
15494                            for (i, &li) in fk.local_columns.iter().enumerate() {
15495                                entry.insert((child_row_idx, li), new_key[i].clone());
15496                            }
15497                        }
15498                        spg_storage::FkAction::SetNull => {
15499                            for &li in &fk.local_columns {
15500                                let col = child.schema().columns.get(li).ok_or_else(|| {
15501                                    EngineError::Unsupported(alloc::format!(
15502                                        "FK local column {li} missing in {child_name:?}"
15503                                    ))
15504                                })?;
15505                                if !col.nullable {
15506                                    return Err(EngineError::Unsupported(alloc::format!(
15507                                        "FOREIGN KEY ON UPDATE SET NULL: column \
15508                                         {child_name:?}.{:?} is NOT NULL",
15509                                        col.name,
15510                                    )));
15511                                }
15512                            }
15513                            let entry = setnull_plan.entry(child_name.clone()).or_default();
15514                            for &li in &fk.local_columns {
15515                                entry.insert((child_row_idx, li));
15516                            }
15517                        }
15518                        spg_storage::FkAction::SetDefault => {
15519                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
15520                            for &li in &fk.local_columns {
15521                                let col = child.schema().columns.get(li).ok_or_else(|| {
15522                                    EngineError::Unsupported(alloc::format!(
15523                                        "FK local column {li} missing in {child_name:?}"
15524                                    ))
15525                                })?;
15526                                let default = col.default.clone().ok_or_else(|| {
15527                                    EngineError::Unsupported(alloc::format!(
15528                                        "FOREIGN KEY ON UPDATE SET DEFAULT: column \
15529                                         {child_name:?}.{:?} has no DEFAULT",
15530                                        col.name,
15531                                    ))
15532                                })?;
15533                                entry.insert((child_row_idx, li), default);
15534                            }
15535                        }
15536                    }
15537                }
15538            }
15539        }
15540    }
15541    // Flatten into FkChildStep list. UPDATE doesn't produce
15542    // DeleteSteps (CASCADE on UPDATE just rewrites FK values).
15543    let mut steps: Vec<FkChildStep> = Vec::new();
15544    for (child_table, entries) in cascade_plan {
15545        let mut positions = Vec::with_capacity(entries.len());
15546        let mut columns = Vec::with_capacity(entries.len());
15547        let mut defaults = Vec::with_capacity(entries.len());
15548        for ((p, c), v) in entries {
15549            positions.push(p);
15550            columns.push(c);
15551            defaults.push(v);
15552        }
15553        // We reuse `FkChildAction::SetDefault` for cascade-update:
15554        // both shapes are "write a known value into specific cells"
15555        // — `apply_per_cell_writes` doesn't care whether the value
15556        // came from a DEFAULT declaration or a new parent key.
15557        steps.push(FkChildStep {
15558            child_table,
15559            action: FkChildAction::SetDefault {
15560                positions,
15561                columns,
15562                defaults,
15563            },
15564        });
15565    }
15566    for (child_table, entries) in setnull_plan {
15567        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
15568        steps.push(FkChildStep {
15569            child_table,
15570            action: FkChildAction::SetNull { positions, columns },
15571        });
15572    }
15573    for (child_table, entries) in setdefault_plan {
15574        let mut positions = Vec::with_capacity(entries.len());
15575        let mut columns = Vec::with_capacity(entries.len());
15576        let mut defaults = Vec::with_capacity(entries.len());
15577        for ((p, c), v) in entries {
15578            positions.push(p);
15579            columns.push(c);
15580            defaults.push(v);
15581        }
15582        steps.push(FkChildStep {
15583            child_table,
15584            action: FkChildAction::SetDefault {
15585                positions,
15586                columns,
15587                defaults,
15588            },
15589        });
15590    }
15591    let _ = delete_plan; // UPDATE never deletes children.
15592    Ok(steps)
15593}
15594
15595/// v7.6.5 — apply one FK child step to the catalog. Encapsulates
15596/// the three action variants so the DELETE executor stays a
15597/// simple loop over the planned steps.
15598fn apply_fk_child_step(catalog: &mut Catalog, step: &FkChildStep) -> Result<(), EngineError> {
15599    let child = catalog.get_mut(&step.child_table).ok_or_else(|| {
15600        EngineError::Storage(StorageError::TableNotFound {
15601            name: step.child_table.clone(),
15602        })
15603    })?;
15604    match &step.action {
15605        FkChildAction::Delete { positions } => {
15606            let _ = child.delete_rows(positions);
15607        }
15608        FkChildAction::SetNull { positions, columns } => {
15609            apply_per_cell_writes(child, positions, columns, |_| Value::Null)?;
15610        }
15611        FkChildAction::SetDefault {
15612            positions,
15613            columns,
15614            defaults,
15615        } => {
15616            apply_per_cell_writes(child, positions, columns, |i| defaults[i].clone())?;
15617        }
15618    }
15619    Ok(())
15620}
15621
15622/// v7.6.5 — write new values into selected child cells via
15623/// `Table::update_row` (the catalog's existing UPDATE entry).
15624/// Groups writes by row position so multi-column updates on the
15625/// same row only call `update_row` once. `value_for(i)` produces
15626/// the new value for the i-th (position, column) entry.
15627fn apply_per_cell_writes(
15628    child: &mut spg_storage::Table,
15629    positions: &[usize],
15630    columns: &[usize],
15631    mut value_for: impl FnMut(usize) -> Value,
15632) -> Result<(), EngineError> {
15633    use alloc::collections::BTreeMap;
15634    let mut by_row: BTreeMap<usize, Vec<(usize, Value)>> = BTreeMap::new();
15635    for i in 0..positions.len() {
15636        by_row
15637            .entry(positions[i])
15638            .or_default()
15639            .push((columns[i], value_for(i)));
15640    }
15641    for (pos, mutations) in by_row {
15642        let mut new_values = child.rows()[pos].values.clone();
15643        for (col, v) in mutations {
15644            if let Some(slot) = new_values.get_mut(col) {
15645                *slot = v;
15646            }
15647        }
15648        child
15649            .update_row(pos, new_values)
15650            .map_err(EngineError::Storage)?;
15651    }
15652    Ok(())
15653}
15654
15655fn fk_action_sql_to_storage(a: spg_sql::ast::FkAction) -> spg_storage::FkAction {
15656    match a {
15657        spg_sql::ast::FkAction::Restrict => spg_storage::FkAction::Restrict,
15658        spg_sql::ast::FkAction::Cascade => spg_storage::FkAction::Cascade,
15659        spg_sql::ast::FkAction::SetNull => spg_storage::FkAction::SetNull,
15660        spg_sql::ast::FkAction::SetDefault => spg_storage::FkAction::SetDefault,
15661        spg_sql::ast::FkAction::NoAction => spg_storage::FkAction::NoAction,
15662    }
15663}
15664
15665/// v7.9.21 — resolve a column's DEFAULT for INSERT-time
15666/// default-fill. Free fn (rather than `&self`) so callers
15667/// with an active `&mut Table` borrow can still use it.
15668/// Literal defaults take the cached path (`col.default`);
15669/// runtime defaults hit `clock_fn` at each call. mailrs G4.
15670fn resolve_column_default_free(
15671    col: &ColumnSchema,
15672    clock_fn: Option<ClockFn>,
15673) -> Result<Value, EngineError> {
15674    if let Some(rt) = &col.runtime_default {
15675        return eval_runtime_default_free(rt, col.ty, clock_fn);
15676    }
15677    Ok(col.default.clone().unwrap_or(Value::Null))
15678}
15679
15680fn eval_runtime_default_free(
15681    rt: &str,
15682    ty: DataType,
15683    clock_fn: Option<ClockFn>,
15684) -> Result<Value, EngineError> {
15685    let s = rt.trim().to_ascii_lowercase();
15686    // v7.17.0 Phase 2.1 — also strip `(N)` precision suffix
15687    // so MySQL `CURRENT_TIMESTAMP(6)` resolves the same as
15688    // bare `CURRENT_TIMESTAMP`. SPG stores TIMESTAMP at fixed
15689    // microsecond resolution; the precision modifier is
15690    // parser-only.
15691    let with_no_parens = s.trim_end_matches("()");
15692    let canonical: &str = if let Some(open_idx) = with_no_parens.find('(') {
15693        if with_no_parens.ends_with(')') {
15694            &with_no_parens[..open_idx]
15695        } else {
15696            with_no_parens
15697        }
15698    } else {
15699        with_no_parens
15700    };
15701    let now_us = match clock_fn {
15702        Some(f) => f(),
15703        None => 0,
15704    };
15705    let v = match canonical {
15706        "now" | "current_timestamp" | "localtimestamp" => Value::Timestamp(now_us),
15707        "current_date" => Value::Date((now_us / 86_400_000_000) as i32),
15708        "current_time" | "localtime" => Value::Timestamp(now_us),
15709        // v7.17.0 — UUID generators in DEFAULT clauses. Required
15710        // for the canonical Django / Rails / Hibernate `id UUID
15711        // PRIMARY KEY DEFAULT gen_random_uuid()` pattern. Each
15712        // INSERT evaluates the function fresh; the per-row UUID
15713        // is the storage value, not a cached literal.
15714        "gen_random_uuid" | "uuid_generate_v4" => Value::Uuid(eval::gen_random_uuid_bytes()),
15715        other => {
15716            return Err(EngineError::Unsupported(alloc::format!(
15717                "runtime DEFAULT expression {other:?} not supported \
15718                 (v7.17.0 whitelist: now() / current_timestamp / \
15719                 current_date / current_time / localtimestamp / \
15720                 localtime / gen_random_uuid() / \
15721                 uuid_generate_v4())"
15722            )));
15723        }
15724    };
15725    coerce_value(v, ty, "DEFAULT", 0)
15726}
15727
15728/// v7.9.21 — true when a DEFAULT expression needs INSERT-time
15729/// evaluation rather than being cacheable as a literal Value.
15730/// FunctionCall is the immediate case (`now()`,
15731/// `current_timestamp`). Literal expressions and simple sign-
15732/// flipped numerics still take the static-cache path.
15733fn is_runtime_default_expr(expr: &Expr) -> bool {
15734    match expr {
15735        Expr::FunctionCall { .. } => true,
15736        Expr::Unary { expr, .. } => is_runtime_default_expr(expr),
15737        _ => false,
15738    }
15739}
15740
15741/// v7.17.0 Phase 1.4 — INSERT/UPDATE-time enum label check. When
15742/// `col_idx` has a registered label list, the cell value must be
15743/// NULL or one of the labels (case-sensitive per PG).
15744/// v7.17.0 Phase 3.P0-37 — validate + canonicalise a MySQL inline
15745/// SET cell. For non-SET columns this is a no-op pass-through.
15746///
15747/// Semantics:
15748///   * NULL preserved.
15749///   * Empty string → `''` (zero flags).
15750///   * Otherwise split on ',', trim each token, validate every
15751///     token against the column's variant list (error on miss),
15752///     de-dup, then re-emit in DEFINITION order joined by ','.
15753fn canonicalize_set_value(
15754    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
15755    col_idx: usize,
15756    col_name: &str,
15757    value: Value,
15758) -> Result<Value, EngineError> {
15759    let Some(variants) = lookup.get(&col_idx) else {
15760        return Ok(value);
15761    };
15762    match value {
15763        Value::Null => Ok(Value::Null),
15764        Value::Text(s) => {
15765            if s.is_empty() {
15766                return Ok(Value::Text(alloc::string::String::new()));
15767            }
15768            // Collect a presence-set of variant indices to keep
15769            // definition order + handle de-dup in one pass.
15770            let mut present = alloc::vec![false; variants.len()];
15771            for raw in s.split(',') {
15772                let tok = raw.trim();
15773                if tok.is_empty() {
15774                    continue;
15775                }
15776                let idx = variants.iter().position(|v| v == tok).ok_or_else(|| {
15777                    EngineError::Unsupported(alloc::format!(
15778                        "column {col_name:?}: invalid SET token {tok:?}; \
15779                         allowed: {variants:?}"
15780                    ))
15781                })?;
15782                present[idx] = true;
15783            }
15784            // Re-emit in definition order.
15785            let mut out = alloc::string::String::new();
15786            let mut first = true;
15787            for (i, keep) in present.iter().enumerate() {
15788                if !keep {
15789                    continue;
15790                }
15791                if !first {
15792                    out.push(',');
15793                }
15794                first = false;
15795                out.push_str(&variants[i]);
15796            }
15797            Ok(Value::Text(out))
15798        }
15799        other => Err(EngineError::Unsupported(alloc::format!(
15800            "column {col_name:?}: SET-typed column expects TEXT, got {:?}",
15801            other.data_type()
15802        ))),
15803    }
15804}
15805
15806fn enforce_enum_label(
15807    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
15808    col_idx: usize,
15809    col_name: &str,
15810    value: &Value,
15811) -> Result<(), EngineError> {
15812    if let Some(labels) = lookup.get(&col_idx) {
15813        match value {
15814            Value::Null => Ok(()),
15815            Value::Text(s) => {
15816                if labels.iter().any(|l| l == s) {
15817                    Ok(())
15818                } else {
15819                    Err(EngineError::Unsupported(alloc::format!(
15820                        "column {col_name:?}: invalid enum label {s:?}; allowed: {labels:?}"
15821                    )))
15822                }
15823            }
15824            other => Err(EngineError::Unsupported(alloc::format!(
15825                "column {col_name:?}: enum-typed column expects TEXT, got {:?}",
15826                other.data_type()
15827            ))),
15828        }
15829    } else {
15830        Ok(())
15831    }
15832}
15833
15834fn column_def_to_schema(c: ColumnDef) -> Result<ColumnSchema, EngineError> {
15835    let ty = column_type_to_data_type(c.ty);
15836    let mut schema = ColumnSchema::new(c.name.clone(), ty, c.nullable);
15837    // user_type_ref is the raw ident the parser couldn't resolve
15838    // to a built-in; classification into enum vs domain happens
15839    // at exec_create_table where we have catalog access. We
15840    // park it temporarily as user_enum_type and the engine
15841    // promotes domain bindings to user_domain_type before the
15842    // table is stored.
15843    if let Some(name) = c.user_type_ref {
15844        schema.user_enum_type = Some(name);
15845    }
15846    // v7.17.0 Phase 2.1 — render the ON UPDATE expression to
15847    // canonical text (the engine re-parses at UPDATE time).
15848    if let Some(expr) = c.on_update_runtime {
15849        schema.on_update_runtime = Some(alloc::format!("{expr}"));
15850    }
15851    // v7.17.0 Phase 2.5 — bridge the AST `Collation` enum to the
15852    // storage one. Same variants, different crates (spg-storage
15853    // owns no dep on spg-sql).
15854    schema.collation = match c.collation {
15855        spg_sql::ast::Collation::Binary => spg_storage::Collation::Binary,
15856        spg_sql::ast::Collation::CaseInsensitive => spg_storage::Collation::CaseInsensitive,
15857    };
15858    // v7.17.0 Phase 4.4 — MySQL `UNSIGNED` flag propagates to
15859    // storage so engine INSERT / UPDATE can range-check.
15860    schema.is_unsigned = c.is_unsigned;
15861    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant list.
15862    // INSERT validation lives in coerce_value (Text → Text path
15863    // with the column's variant list as the accept-set).
15864    schema.inline_enum_variants = c.inline_enum_variants;
15865    // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
15866    // INSERT canonicalisation (de-dup + sort by definition order)
15867    // lives in the exec_insert path next to the ENUM check.
15868    schema.inline_set_variants = c.inline_set_variants;
15869    if let Some(default_expr) = c.default {
15870        // v7.9.21 — distinguish literal defaults (evaluated once
15871        // at CREATE TABLE) from expression defaults (deferred to
15872        // INSERT). Function calls (`now()`, `current_timestamp`
15873        // — see v7.9.20 keyword promotion) take the runtime path.
15874        // Literals continue to cache. mailrs G4.
15875        if is_runtime_default_expr(&default_expr) {
15876            let display = alloc::format!("{default_expr}");
15877            schema = schema.with_runtime_default(display);
15878        } else {
15879            let raw = literal_expr_to_value(default_expr)?;
15880            let coerced = coerce_value(raw, ty, &c.name, 0)?;
15881            schema = schema.with_default(coerced);
15882        }
15883    }
15884    if c.auto_increment {
15885        // AUTO_INCREMENT only makes sense on integer-shaped columns.
15886        if !matches!(ty, DataType::SmallInt | DataType::Int | DataType::BigInt) {
15887            return Err(EngineError::Unsupported(alloc::format!(
15888                "AUTO_INCREMENT requires an integer column type, got {ty:?}"
15889            )));
15890        }
15891        schema = schema.with_auto_increment();
15892    }
15893    Ok(schema)
15894}
15895
15896/// v7.10.4 — decode a BYTEA literal. Accepts:
15897///   * `\xDEADBEEF` (case-insensitive hex; whitespace stripped)
15898///   * `Hello\000world` (backslash escape form; `\\` for literal backslash)
15899///   * Anything else → raw UTF-8 bytes of the input (PG accepts this too).
15900fn decode_bytea_literal(s: &str) -> Result<alloc::vec::Vec<u8>, &'static str> {
15901    let s = s.trim();
15902    if let Some(hex) = s.strip_prefix("\\x").or_else(|| s.strip_prefix("\\X")) {
15903        // Hex form. Each pair of hex digits → one byte.
15904        let cleaned: alloc::string::String = hex.chars().filter(|c| !c.is_whitespace()).collect();
15905        if cleaned.len() % 2 != 0 {
15906            return Err("odd-length hex literal");
15907        }
15908        let mut out = alloc::vec::Vec::with_capacity(cleaned.len() / 2);
15909        let cleaned_bytes = cleaned.as_bytes();
15910        for i in (0..cleaned_bytes.len()).step_by(2) {
15911            let hi = hex_nibble(cleaned_bytes[i])?;
15912            let lo = hex_nibble(cleaned_bytes[i + 1])?;
15913            out.push((hi << 4) | lo);
15914        }
15915        return Ok(out);
15916    }
15917    // Escape form or raw. Walk char-by-char; `\\` and `\NNN` octal
15918    // sequences decode; anything else is a literal byte.
15919    let bytes = s.as_bytes();
15920    let mut out = alloc::vec::Vec::with_capacity(bytes.len());
15921    let mut i = 0;
15922    while i < bytes.len() {
15923        let b = bytes[i];
15924        if b == b'\\' && i + 1 < bytes.len() {
15925            let n = bytes[i + 1];
15926            if n == b'\\' {
15927                out.push(b'\\');
15928                i += 2;
15929                continue;
15930            }
15931            if n.is_ascii_digit()
15932                && i + 3 < bytes.len()
15933                && bytes[i + 2].is_ascii_digit()
15934                && bytes[i + 3].is_ascii_digit()
15935            {
15936                let oct = |x: u8| (x - b'0') as u32;
15937                let v = oct(n) * 64 + oct(bytes[i + 2]) * 8 + oct(bytes[i + 3]);
15938                if v <= 0xFF {
15939                    out.push(v as u8);
15940                    i += 4;
15941                    continue;
15942                }
15943            }
15944        }
15945        out.push(b);
15946        i += 1;
15947    }
15948    Ok(out)
15949}
15950
15951fn hex_nibble(b: u8) -> Result<u8, &'static str> {
15952    match b {
15953        b'0'..=b'9' => Ok(b - b'0'),
15954        b'a'..=b'f' => Ok(b - b'a' + 10),
15955        b'A'..=b'F' => Ok(b - b'A' + 10),
15956        _ => Err("invalid hex digit"),
15957    }
15958}
15959
15960/// v7.10.11 — decode a PG TEXT[] external array form
15961/// (`{a,b,NULL}` with optional double-quoted elements). The
15962/// engine takes a leading/trailing `{`/`}` and splits at commas.
15963/// Quoted elements (`"hello, world"`) preserve embedded commas;
15964/// `\\` and `\"` decode to literal backslash / quote. Plain
15965/// unquoted `NULL` (case-insensitive) maps to `None`.
15966/// v7.11.13 — pick the array type for `ARRAY[lit, …]` from the
15967/// element values. Single-element-type rules:
15968///   - all NULL / all Text → TextArray
15969///   - all Int (or Int+NULL) → IntArray
15970///   - any BigInt without Text → BigIntArray (widening)
15971///   - any Text → TextArray (fallback; non-string elements
15972///     render as text)
15973fn array_literal_widen(items: alloc::vec::Vec<Value>) -> Value {
15974    let mut has_text = false;
15975    let mut has_bigint = false;
15976    let mut has_int = false;
15977    for v in &items {
15978        match v {
15979            Value::Null => {}
15980            Value::Text(_) | Value::Json(_) => has_text = true,
15981            Value::BigInt(_) => has_bigint = true,
15982            Value::Int(_) | Value::SmallInt(_) => has_int = true,
15983            _ => has_text = true,
15984        }
15985    }
15986    if has_text || (!has_bigint && !has_int) {
15987        let out: alloc::vec::Vec<Option<alloc::string::String>> = items
15988            .into_iter()
15989            .map(|v| match v {
15990                Value::Null => None,
15991                Value::Text(s) | Value::Json(s) => Some(s),
15992                other => Some(alloc::format!("{other:?}")),
15993            })
15994            .collect();
15995        return Value::TextArray(out);
15996    }
15997    if has_bigint {
15998        let out: alloc::vec::Vec<Option<i64>> = items
15999            .into_iter()
16000            .map(|v| match v {
16001                Value::Null => None,
16002                Value::Int(n) => Some(i64::from(n)),
16003                Value::SmallInt(n) => Some(i64::from(n)),
16004                Value::BigInt(n) => Some(n),
16005                _ => unreachable!("widen: unexpected non-integer in BigInt path"),
16006            })
16007            .collect();
16008        return Value::BigIntArray(out);
16009    }
16010    let out: alloc::vec::Vec<Option<i32>> = items
16011        .into_iter()
16012        .map(|v| match v {
16013            Value::Null => None,
16014            Value::Int(n) => Some(n),
16015            Value::SmallInt(n) => Some(i32::from(n)),
16016            _ => unreachable!("widen: unexpected non-i32-compatible in Int path"),
16017        })
16018        .collect();
16019    Value::IntArray(out)
16020}
16021
16022fn decode_text_array_literal(
16023    s: &str,
16024) -> Result<alloc::vec::Vec<Option<alloc::string::String>>, &'static str> {
16025    let trimmed = s.trim();
16026    let inner = trimmed
16027        .strip_prefix('{')
16028        .and_then(|x| x.strip_suffix('}'))
16029        .ok_or("TEXT[] literal must be enclosed in '{...}'")?;
16030    let mut out: alloc::vec::Vec<Option<alloc::string::String>> = alloc::vec::Vec::new();
16031    if inner.trim().is_empty() {
16032        return Ok(out);
16033    }
16034    let bytes = inner.as_bytes();
16035    let mut i = 0;
16036    while i <= bytes.len() {
16037        // Skip leading whitespace.
16038        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
16039            i += 1;
16040        }
16041        // Quoted element.
16042        if i < bytes.len() && bytes[i] == b'"' {
16043            i += 1; // open quote
16044            let mut buf = alloc::string::String::new();
16045            while i < bytes.len() && bytes[i] != b'"' {
16046                if bytes[i] == b'\\' && i + 1 < bytes.len() {
16047                    buf.push(bytes[i + 1] as char);
16048                    i += 2;
16049                } else {
16050                    buf.push(bytes[i] as char);
16051                    i += 1;
16052                }
16053            }
16054            if i >= bytes.len() {
16055                return Err("unterminated quoted element");
16056            }
16057            i += 1; // close quote
16058            out.push(Some(buf));
16059        } else {
16060            // Unquoted element — read until next comma or end.
16061            let start = i;
16062            while i < bytes.len() && bytes[i] != b',' {
16063                i += 1;
16064            }
16065            let raw = inner[start..i].trim();
16066            if raw.eq_ignore_ascii_case("NULL") {
16067                out.push(None);
16068            } else {
16069                out.push(Some(alloc::string::ToString::to_string(raw)));
16070            }
16071        }
16072        // Skip whitespace, expect comma or end.
16073        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
16074            i += 1;
16075        }
16076        if i >= bytes.len() {
16077            break;
16078        }
16079        if bytes[i] != b',' {
16080            return Err("expected ',' between TEXT[] elements");
16081        }
16082        i += 1;
16083    }
16084    Ok(out)
16085}
16086
16087/// v7.10.11 — encode a TEXT[] back into the PG external array
16088/// form. NULL elements become the literal `NULL`; elements
16089/// containing commas, quotes, backslashes, or braces are
16090/// double-quoted with `\\` / `\"` escapes.
16091fn encode_text_array(items: &[Option<alloc::string::String>]) -> alloc::string::String {
16092    let mut out = alloc::string::String::with_capacity(2 + items.len() * 8);
16093    out.push('{');
16094    for (i, item) in items.iter().enumerate() {
16095        if i > 0 {
16096            out.push(',');
16097        }
16098        match item {
16099            None => out.push_str("NULL"),
16100            Some(s) => {
16101                let needs_quote = s.is_empty()
16102                    || s.eq_ignore_ascii_case("NULL")
16103                    || s.chars()
16104                        .any(|c| matches!(c, ',' | '{' | '}' | '"' | '\\' | ' ' | '\t'));
16105                if needs_quote {
16106                    out.push('"');
16107                    for c in s.chars() {
16108                        if c == '"' || c == '\\' {
16109                            out.push('\\');
16110                        }
16111                        out.push(c);
16112                    }
16113                    out.push('"');
16114                } else {
16115                    out.push_str(s);
16116                }
16117            }
16118        }
16119    }
16120    out.push('}');
16121    out
16122}
16123
16124/// v7.10.4 — encode BYTEA bytes in PG hex output format
16125/// (`\x` prefix, lowercase hex pairs). Used by Text-side
16126/// round-trip + the wire layer's text-mode encoder.
16127fn encode_bytea_hex(b: &[u8]) -> alloc::string::String {
16128    let mut out = alloc::string::String::with_capacity(2 + 2 * b.len());
16129    out.push_str("\\x");
16130    for byte in b {
16131        let hi = byte >> 4;
16132        let lo = byte & 0x0F;
16133        out.push(hex_digit(hi));
16134        out.push(hex_digit(lo));
16135    }
16136    out
16137}
16138
16139const fn hex_digit(n: u8) -> char {
16140    match n {
16141        0..=9 => (b'0' + n) as char,
16142        10..=15 => (b'a' + n - 10) as char,
16143        _ => '?',
16144    }
16145}
16146
16147/// v7.17.0 Phase 3.P0-39 — parse a PG `hstore` text literal into
16148/// a flat key→value map. Empty string → empty map. Duplicate
16149/// keys take last-write-wins (matches PG `hstore_in`).
16150///
16151/// Accepted shapes (minimal subset):
16152///   * `'a=>1, b=>2'`            — bareword keys/values
16153///   * `'"a"=>"1", "b"=>"2"'`    — quoted keys/values
16154///   * `'a=>NULL'`               — case-insensitive NULL token
16155///     surfaces as `None` (no quotes around NULL)
16156///
16157/// Returns None on parse failure → caller surfaces as hard error.
16158fn parse_hstore_str(
16159    s: &str,
16160) -> Option<Vec<(alloc::string::String, Option<alloc::string::String>)>> {
16161    let bytes = s.as_bytes();
16162    let mut i = 0;
16163    let mut out: Vec<(alloc::string::String, Option<alloc::string::String>)> = Vec::new();
16164    let skip_ws = |bytes: &[u8], i: &mut usize| {
16165        while *i < bytes.len() && matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r') {
16166            *i += 1;
16167        }
16168    };
16169    let parse_token = |bytes: &[u8], i: &mut usize| -> Option<alloc::string::String> {
16170        if *i >= bytes.len() {
16171            return None;
16172        }
16173        if bytes[*i] == b'"' {
16174            *i += 1;
16175            let mut out = alloc::string::String::new();
16176            while *i < bytes.len() {
16177                match bytes[*i] {
16178                    b'"' => {
16179                        *i += 1;
16180                        return Some(out);
16181                    }
16182                    b'\\' if *i + 1 < bytes.len() => {
16183                        out.push(bytes[*i + 1] as char);
16184                        *i += 2;
16185                    }
16186                    c => {
16187                        out.push(c as char);
16188                        *i += 1;
16189                    }
16190                }
16191            }
16192            None
16193        } else {
16194            let start = *i;
16195            while *i < bytes.len()
16196                && !matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r' | b',' | b'=')
16197            {
16198                *i += 1;
16199            }
16200            if *i == start {
16201                return None;
16202            }
16203            Some(alloc::str::from_utf8(&bytes[start..*i]).ok()?.to_string())
16204        }
16205    };
16206    skip_ws(bytes, &mut i);
16207    while i < bytes.len() {
16208        let key = parse_token(bytes, &mut i)?;
16209        skip_ws(bytes, &mut i);
16210        if i + 1 >= bytes.len() || bytes[i] != b'=' || bytes[i + 1] != b'>' {
16211            return None;
16212        }
16213        i += 2;
16214        skip_ws(bytes, &mut i);
16215        // Check for unquoted NULL token (case-insensitive).
16216        let val_token = if i + 4 <= bytes.len()
16217            && bytes[i..i + 4].eq_ignore_ascii_case(b"NULL")
16218            && (i + 4 == bytes.len() || matches!(bytes[i + 4], b' ' | b'\t' | b',' | b'\n' | b'\r'))
16219        {
16220            i += 4;
16221            None
16222        } else {
16223            Some(parse_token(bytes, &mut i)?)
16224        };
16225        // Replace any existing entry with the same key (last-wins).
16226        if let Some(pos) = out.iter().position(|(k, _)| k == &key) {
16227            out[pos] = (key, val_token);
16228        } else {
16229            out.push((key, val_token));
16230        }
16231        skip_ws(bytes, &mut i);
16232        if i >= bytes.len() {
16233            break;
16234        }
16235        if bytes[i] == b',' {
16236            i += 1;
16237            skip_ws(bytes, &mut i);
16238            continue;
16239        }
16240        return None;
16241    }
16242    Some(out)
16243}
16244
16245/// v7.17.0 Phase 3.P0-39 — render a hstore as canonical PG text
16246/// form `"k"=>"v"` (keys and non-NULL values always quoted;
16247/// NULL token is bare).
16248fn format_hstore_str(
16249    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
16250) -> alloc::string::String {
16251    let mut out = alloc::string::String::new();
16252    for (i, (k, v)) in pairs.iter().enumerate() {
16253        if i > 0 {
16254            out.push_str(", ");
16255        }
16256        out.push('"');
16257        out.push_str(k);
16258        out.push_str("\"=>");
16259        match v {
16260            None => out.push_str("NULL"),
16261            Some(val) => {
16262                out.push('"');
16263                out.push_str(val);
16264                out.push('"');
16265            }
16266        }
16267    }
16268    out
16269}
16270
16271/// v7.17.0 Phase 3.P0-39 — pub re-export so pgwire + sqllogictest
16272/// share the single hstore renderer.
16273pub fn format_hstore_text(
16274    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
16275) -> alloc::string::String {
16276    format_hstore_str(pairs)
16277}
16278
16279// ─── v7.17.0 Phase 3.P0-40 — 2D array parse + display ─────────
16280
16281/// Split a PG external 2D-array literal `'{{a,b},{c,d}}'` into
16282/// per-row token lists. Returns Err on shape mismatch.
16283fn split_2d_literal(s: &str) -> Result<Vec<Vec<alloc::string::String>>, &'static str> {
16284    let s = s.trim();
16285    let outer = s
16286        .strip_prefix('{')
16287        .and_then(|x| x.strip_suffix('}'))
16288        .ok_or("missing outer '{...}' braces")?;
16289    let trimmed = outer.trim();
16290    if trimmed.is_empty() {
16291        return Ok(Vec::new());
16292    }
16293    let mut rows: Vec<Vec<alloc::string::String>> = Vec::new();
16294    let mut i = 0;
16295    let bytes = trimmed.as_bytes();
16296    while i < bytes.len() {
16297        while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r' | b',') {
16298            i += 1;
16299        }
16300        if i >= bytes.len() {
16301            break;
16302        }
16303        if bytes[i] != b'{' {
16304            return Err("expected '{' opening a row");
16305        }
16306        i += 1;
16307        let row_start = i;
16308        let mut depth = 1;
16309        while i < bytes.len() && depth > 0 {
16310            match bytes[i] {
16311                b'{' => depth += 1,
16312                b'}' => depth -= 1,
16313                _ => {}
16314            }
16315            if depth > 0 {
16316                i += 1;
16317            }
16318        }
16319        if depth != 0 {
16320            return Err("unbalanced '{...}' in row");
16321        }
16322        let row_text = &trimmed[row_start..i];
16323        i += 1;
16324        let cells: Vec<alloc::string::String> = if row_text.trim().is_empty() {
16325            Vec::new()
16326        } else {
16327            row_text.split(',').map(|t| t.trim().to_string()).collect()
16328        };
16329        rows.push(cells);
16330    }
16331    if let Some(first) = rows.first() {
16332        let cols = first.len();
16333        for r in &rows {
16334            if r.len() != cols {
16335                return Err("ragged 2D array (rows have different column counts)");
16336            }
16337        }
16338    }
16339    Ok(rows)
16340}
16341
16342fn parse_int_2d_literal(s: &str) -> Result<Vec<Vec<Option<i32>>>, &'static str> {
16343    let raw = split_2d_literal(s)?;
16344    raw.into_iter()
16345        .map(|row| {
16346            row.into_iter()
16347                .map(|cell| {
16348                    if cell.eq_ignore_ascii_case("NULL") {
16349                        Ok(None)
16350                    } else {
16351                        cell.parse::<i32>()
16352                            .map(Some)
16353                            .map_err(|_| "invalid int element")
16354                    }
16355                })
16356                .collect()
16357        })
16358        .collect()
16359}
16360
16361fn parse_bigint_2d_literal(s: &str) -> Result<Vec<Vec<Option<i64>>>, &'static str> {
16362    let raw = split_2d_literal(s)?;
16363    raw.into_iter()
16364        .map(|row| {
16365            row.into_iter()
16366                .map(|cell| {
16367                    if cell.eq_ignore_ascii_case("NULL") {
16368                        Ok(None)
16369                    } else {
16370                        cell.parse::<i64>()
16371                            .map(Some)
16372                            .map_err(|_| "invalid bigint element")
16373                    }
16374                })
16375                .collect()
16376        })
16377        .collect()
16378}
16379
16380fn parse_text_2d_literal(s: &str) -> Result<Vec<Vec<Option<alloc::string::String>>>, &'static str> {
16381    let raw = split_2d_literal(s)?;
16382    Ok(raw
16383        .into_iter()
16384        .map(|row| {
16385            row.into_iter()
16386                .map(|cell| {
16387                    if cell.eq_ignore_ascii_case("NULL") {
16388                        None
16389                    } else {
16390                        Some(cell.trim_matches('"').to_string())
16391                    }
16392                })
16393                .collect()
16394        })
16395        .collect())
16396}
16397
16398fn format_int_2d_text(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
16399    let mut out = alloc::string::String::from("{");
16400    for (i, row) in rows.iter().enumerate() {
16401        if i > 0 {
16402            out.push(',');
16403        }
16404        out.push('{');
16405        for (j, cell) in row.iter().enumerate() {
16406            if j > 0 {
16407                out.push(',');
16408            }
16409            match cell {
16410                None => out.push_str("NULL"),
16411                Some(n) => out.push_str(&alloc::format!("{n}")),
16412            }
16413        }
16414        out.push('}');
16415    }
16416    out.push('}');
16417    out
16418}
16419
16420fn format_bigint_2d_text(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
16421    let mut out = alloc::string::String::from("{");
16422    for (i, row) in rows.iter().enumerate() {
16423        if i > 0 {
16424            out.push(',');
16425        }
16426        out.push('{');
16427        for (j, cell) in row.iter().enumerate() {
16428            if j > 0 {
16429                out.push(',');
16430            }
16431            match cell {
16432                None => out.push_str("NULL"),
16433                Some(n) => out.push_str(&alloc::format!("{n}")),
16434            }
16435        }
16436        out.push('}');
16437    }
16438    out.push('}');
16439    out
16440}
16441
16442fn format_text_2d_text(rows: &[Vec<Option<alloc::string::String>>]) -> alloc::string::String {
16443    let mut out = alloc::string::String::from("{");
16444    for (i, row) in rows.iter().enumerate() {
16445        if i > 0 {
16446            out.push(',');
16447        }
16448        out.push('{');
16449        for (j, cell) in row.iter().enumerate() {
16450            if j > 0 {
16451                out.push(',');
16452            }
16453            match cell {
16454                None => out.push_str("NULL"),
16455                Some(s) => out.push_str(s),
16456            }
16457        }
16458        out.push('}');
16459    }
16460    out.push('}');
16461    out
16462}
16463
16464/// v7.17.0 Phase 3.P0-40 — pub re-exports so pgwire + sqllogictest
16465/// share the single 2D-array renderer.
16466pub fn format_int_2d_text_pub(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
16467    format_int_2d_text(rows)
16468}
16469pub fn format_bigint_2d_text_pub(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
16470    format_bigint_2d_text(rows)
16471}
16472pub fn format_text_2d_text_pub(
16473    rows: &[Vec<Option<alloc::string::String>>],
16474) -> alloc::string::String {
16475    format_text_2d_text(rows)
16476}
16477
16478/// v7.17.0 Phase 3.P0-38 — parse a PG range literal of the form
16479/// `'[lo,up)'` / `'(lo,up]'` / `'[lo,up]'` / `'(lo,up)'` /
16480/// `'empty'`. Lower / upper may be empty (unbounded). Returns
16481/// `None` on any parse failure; caller surfaces as hard error.
16482fn parse_range_str(s: &str, kind: spg_storage::RangeKind) -> Option<Value> {
16483    let s = s.trim();
16484    if s.eq_ignore_ascii_case("empty") {
16485        return Some(Value::Range {
16486            kind,
16487            lower: None,
16488            upper: None,
16489            lower_inc: false,
16490            upper_inc: false,
16491            empty: true,
16492        });
16493    }
16494    let bytes = s.as_bytes();
16495    if bytes.len() < 3 {
16496        return None;
16497    }
16498    let lower_inc = match bytes[0] {
16499        b'[' => true,
16500        b'(' => false,
16501        _ => return None,
16502    };
16503    let upper_inc = match bytes[bytes.len() - 1] {
16504        b']' => true,
16505        b')' => false,
16506        _ => return None,
16507    };
16508    let inner = &s[1..s.len() - 1];
16509    let (lo_text, up_text) = inner.split_once(',')?;
16510    let lower = if lo_text.is_empty() {
16511        None
16512    } else {
16513        Some(alloc::boxed::Box::new(parse_range_element(lo_text, kind)?))
16514    };
16515    let upper = if up_text.is_empty() {
16516        None
16517    } else {
16518        Some(alloc::boxed::Box::new(parse_range_element(up_text, kind)?))
16519    };
16520    Some(Value::Range {
16521        kind,
16522        lower,
16523        upper,
16524        lower_inc,
16525        upper_inc,
16526        empty: false,
16527    })
16528}
16529
16530/// v7.17.0 Phase 3.P0-38 — parse a single range bound text into
16531/// the matching element Value for the RangeKind.
16532fn parse_range_element(text: &str, kind: spg_storage::RangeKind) -> Option<Value> {
16533    let text = text.trim().trim_matches('"');
16534    use spg_storage::RangeKind as K;
16535    match kind {
16536        K::Int4 => text.parse::<i32>().ok().map(Value::Int),
16537        K::Int8 => text.parse::<i64>().ok().map(Value::BigInt),
16538        K::Num => {
16539            // Reuse the Numeric parse via the engine's text-coercion
16540            // path; bail to None on failure.
16541            let dot = text.find('.');
16542            let scale: u8 = dot.map_or(0, |p| (text.len() - p - 1) as u8);
16543            let digits: alloc::string::String = text
16544                .chars()
16545                .filter(|c| *c == '-' || c.is_ascii_digit())
16546                .collect();
16547            let scaled: i128 = digits.parse().ok()?;
16548            Some(Value::Numeric { scaled, scale })
16549        }
16550        K::Ts | K::TsTz => {
16551            // Reuse the existing timestamp parse path. v7.17.0
16552            // expects `'YYYY-MM-DD HH:MM:SS[.ffffff]'` in range
16553            // bounds (TZ offset on TsTz is OOS for the initial
16554            // P0-38; ship plain Timestamp shape).
16555            crate::eval::parse_timestamp_literal(text).map(Value::Timestamp)
16556        }
16557        K::Date => crate::eval::parse_date_literal(text).map(Value::Date),
16558    }
16559}
16560
16561/// v7.17.0 Phase 3.P0-38 — render a Range value as its canonical
16562/// PG text form. Re-exported via [`format_range_text`] for use
16563/// from spg-server's pgwire layer.
16564pub fn format_range_text(v: &Value) -> alloc::string::String {
16565    format_range_str(v)
16566}
16567
16568fn format_range_str(v: &Value) -> alloc::string::String {
16569    let Value::Range {
16570        lower,
16571        upper,
16572        lower_inc,
16573        upper_inc,
16574        empty,
16575        ..
16576    } = v
16577    else {
16578        return alloc::string::String::new();
16579    };
16580    if *empty {
16581        return "empty".into();
16582    }
16583    let mut out = alloc::string::String::new();
16584    out.push(if *lower_inc { '[' } else { '(' });
16585    if let Some(l) = lower {
16586        out.push_str(&format_range_element(l));
16587    }
16588    out.push(',');
16589    if let Some(u) = upper {
16590        out.push_str(&format_range_element(u));
16591    }
16592    out.push(if *upper_inc { ']' } else { ')' });
16593    out
16594}
16595
16596fn format_range_element(v: &Value) -> alloc::string::String {
16597    match v {
16598        Value::Int(n) => alloc::format!("{n}"),
16599        Value::BigInt(n) => alloc::format!("{n}"),
16600        Value::Date(d) => crate::eval::format_date(*d),
16601        Value::Timestamp(t) => crate::eval::format_timestamp(*t),
16602        Value::Numeric { scaled, scale } => crate::eval::format_numeric(*scaled, *scale),
16603        other => alloc::format!("{other:?}"),
16604    }
16605}
16606
16607/// v7.17.0 Phase 3.P0-35 — parse a PG `money` literal into i64
16608/// cents. Accepts:
16609///   * Optional leading `-` (negative)
16610///   * Optional `$` prefix
16611///   * Integer portion with optional `,` thousands separators
16612///   * Optional `.` followed by 1-2 digits (cents); 1 digit
16613///     auto-pads to 2 (`.5` → 50 cents).
16614///
16615/// Returns None on any parse failure — caller surfaces as hard
16616/// SQL error.
16617fn parse_money_str(s: &str) -> Option<i64> {
16618    let s = s.trim();
16619    let (neg, rest) = match s.strip_prefix('-') {
16620        Some(r) => (true, r.trim_start()),
16621        None => (false, s),
16622    };
16623    let rest = rest.strip_prefix('$').unwrap_or(rest).trim_start();
16624    let (int_part, frac_part) = match rest.split_once('.') {
16625        Some((i, f)) => (i, Some(f)),
16626        None => (rest, None),
16627    };
16628    if int_part.is_empty() {
16629        return None;
16630    }
16631    // Validate + strip commas from the integer portion.
16632    let mut int_digits = alloc::string::String::with_capacity(int_part.len());
16633    for b in int_part.bytes() {
16634        match b {
16635            b',' => {}
16636            b'0'..=b'9' => int_digits.push(b as char),
16637            _ => return None,
16638        }
16639    }
16640    if int_digits.is_empty() {
16641        return None;
16642    }
16643    let dollars: i64 = int_digits.parse().ok()?;
16644    let cents: i64 = match frac_part {
16645        None => 0,
16646        Some(f) => {
16647            if f.is_empty() || f.len() > 2 || !f.bytes().all(|b| b.is_ascii_digit()) {
16648                return None;
16649            }
16650            let padded = if f.len() == 1 {
16651                alloc::format!("{f}0")
16652            } else {
16653                f.to_string()
16654            };
16655            padded.parse().ok()?
16656        }
16657    };
16658    let total = dollars.checked_mul(100)?.checked_add(cents)?;
16659    Some(if neg { -total } else { total })
16660}
16661
16662/// v7.17.0 Phase 3.P0-34 — parse a PG `timetz` literal
16663/// `HH:MM:SS[.fraction]±HH[:MM]` into (us, offset_secs).
16664///
16665/// The offset suffix is MANDATORY: SPG doesn't have a session TZ
16666/// wired into eval, so a bare `HH:MM:SS` literal would be
16667/// ambiguous. Returns None for any parse failure or out-of-range
16668/// component — caller surfaces as a hard SQL error.
16669///
16670/// Offset range: ±14 hours (±50400 seconds), matching PG's
16671/// internal limit.
16672fn parse_timetz_str(s: &str) -> Option<(i64, i32)> {
16673    let s = s.trim();
16674    // Find the offset sign — scan from right since the time part
16675    // never contains '+' / '-' (after the optional fractional dot
16676    // it's all digits and ':').
16677    let bytes = s.as_bytes();
16678    let sign_pos = bytes
16679        .iter()
16680        .enumerate()
16681        .rev()
16682        .find(|&(_, &b)| b == b'+' || b == b'-')
16683        .map(|(i, _)| i)?;
16684    if sign_pos == 0 {
16685        return None; // bare sign — no time component
16686    }
16687    let time_part = &s[..sign_pos];
16688    let offset_part = &s[sign_pos..];
16689    let us = parse_time_str(time_part)?;
16690    let sign: i32 = if offset_part.starts_with('+') { 1 } else { -1 };
16691    let offset_body = &offset_part[1..];
16692    let (hh_str, mm_str) = match offset_body.split_once(':') {
16693        Some((h, m)) => (h, m),
16694        None => (offset_body, "0"),
16695    };
16696    let hh: i32 = hh_str.parse().ok()?;
16697    let mm: i32 = mm_str.parse().ok()?;
16698    if !(0..=14).contains(&hh) || !(0..=59).contains(&mm) {
16699        return None;
16700    }
16701    let total = sign * (hh * 3600 + mm * 60);
16702    if total.abs() > 50_400 {
16703        return None;
16704    }
16705    Some((us, total))
16706}
16707
16708/// v7.17.0 Phase 3.P0-33 — funnel an integer literal through MySQL
16709/// YEAR range validation: 0 sentinel or 1901..=2155. Out-of-range
16710/// surfaces as a hard SQL error (no silent truncation, mirrors PG
16711/// `time_in` / `uuid_in` discipline).
16712fn coerce_int_to_year(n: i64, col_name: &str) -> Result<Value, EngineError> {
16713    if n == 0 || (1901..=2155).contains(&n) {
16714        // u16::try_from cannot fail in this range; the cast also
16715        // covers the 0 sentinel.
16716        return Ok(Value::Year(n as u16));
16717    }
16718    Err(EngineError::Eval(EvalError::TypeMismatch {
16719        detail: alloc::format!(
16720            "year value out of range: {n} (column `{col_name}`; \
16721             MySQL accepts 0 or 1901..=2155)"
16722        ),
16723    }))
16724}
16725
16726/// v7.17.0 Phase 3.P0-32 — parse a PG `time` literal
16727/// `HH:MM:SS[.fraction]` into microseconds since 00:00:00.
16728///
16729/// Accepts:
16730///   * `HH:MM:SS`            — exact-second precision
16731///   * `HH:MM:SS.f` .. `.ffffff` — 1-6 fractional digits, right-padded
16732///     with zeros to microseconds
16733///
16734/// Range: hour 0..=23, minute 0..=59, second 0..=59. Anything else
16735/// returns None — caller surfaces as a hard SQL error (no silent
16736/// truncation, matches PG's `time_in` behaviour).
16737fn parse_time_str(s: &str) -> Option<i64> {
16738    let s = s.trim();
16739    let (hms, frac) = match s.split_once('.') {
16740        Some((h, f)) => (h, Some(f)),
16741        None => (s, None),
16742    };
16743    let mut parts = hms.split(':');
16744    let hh: u32 = parts.next()?.parse().ok()?;
16745    let mm: u32 = parts.next()?.parse().ok()?;
16746    let ss: u32 = parts.next()?.parse().ok()?;
16747    if parts.next().is_some() {
16748        return None;
16749    }
16750    if hh > 23 || mm > 59 || ss > 59 {
16751        return None;
16752    }
16753    let frac_us: i64 = match frac {
16754        None => 0,
16755        Some(f) => {
16756            if f.is_empty() || f.len() > 6 || !f.bytes().all(|b| b.is_ascii_digit()) {
16757                return None;
16758            }
16759            // Right-pad with zeros so '.5' = 500000 µsec.
16760            let mut padded = alloc::string::String::with_capacity(6);
16761            padded.push_str(f);
16762            while padded.len() < 6 {
16763                padded.push('0');
16764            }
16765            padded.parse().ok()?
16766        }
16767    };
16768    Some(
16769        i64::from(hh) * 3_600_000_000
16770            + i64::from(mm) * 60_000_000
16771            + i64::from(ss) * 1_000_000
16772            + frac_us,
16773    )
16774}
16775
16776const fn column_type_to_data_type(t: ColumnTypeName) -> DataType {
16777    match t {
16778        ColumnTypeName::SmallInt => DataType::SmallInt,
16779        ColumnTypeName::Int => DataType::Int,
16780        ColumnTypeName::BigInt => DataType::BigInt,
16781        ColumnTypeName::Float => DataType::Float,
16782        ColumnTypeName::Text => DataType::Text,
16783        ColumnTypeName::Varchar(n) => DataType::Varchar(n),
16784        ColumnTypeName::Char(n) => DataType::Char(n),
16785        ColumnTypeName::Bool => DataType::Bool,
16786        ColumnTypeName::Vector { dim, encoding } => DataType::Vector {
16787            dim,
16788            encoding: match encoding {
16789                SqlVecEncoding::F32 => VecEncoding::F32,
16790                SqlVecEncoding::Sq8 => VecEncoding::Sq8,
16791                SqlVecEncoding::F16 => VecEncoding::F16,
16792            },
16793        },
16794        ColumnTypeName::Numeric(precision, scale) => DataType::Numeric { precision, scale },
16795        ColumnTypeName::Date => DataType::Date,
16796        ColumnTypeName::Timestamp => DataType::Timestamp,
16797        ColumnTypeName::Timestamptz => DataType::Timestamptz,
16798        ColumnTypeName::Json => DataType::Json,
16799        ColumnTypeName::Jsonb => DataType::Jsonb,
16800        ColumnTypeName::Bytes => DataType::Bytes,
16801        ColumnTypeName::TextArray => DataType::TextArray,
16802        ColumnTypeName::IntArray => DataType::IntArray,
16803        ColumnTypeName::BigIntArray => DataType::BigIntArray,
16804        ColumnTypeName::TsVector => DataType::TsVector,
16805        ColumnTypeName::TsQuery => DataType::TsQuery,
16806        ColumnTypeName::Uuid => DataType::Uuid,
16807        ColumnTypeName::Time => DataType::Time,
16808        ColumnTypeName::Year => DataType::Year,
16809        ColumnTypeName::TimeTz => DataType::TimeTz,
16810        ColumnTypeName::Money => DataType::Money,
16811        ColumnTypeName::Range(k) => DataType::Range(match k {
16812            spg_sql::ast::RangeKindAst::Int4 => spg_storage::RangeKind::Int4,
16813            spg_sql::ast::RangeKindAst::Int8 => spg_storage::RangeKind::Int8,
16814            spg_sql::ast::RangeKindAst::Num => spg_storage::RangeKind::Num,
16815            spg_sql::ast::RangeKindAst::Ts => spg_storage::RangeKind::Ts,
16816            spg_sql::ast::RangeKindAst::TsTz => spg_storage::RangeKind::TsTz,
16817            spg_sql::ast::RangeKindAst::Date => spg_storage::RangeKind::Date,
16818        }),
16819        ColumnTypeName::Hstore => DataType::Hstore,
16820        ColumnTypeName::IntArray2D => DataType::IntArray2D,
16821        ColumnTypeName::BigIntArray2D => DataType::BigIntArray2D,
16822        ColumnTypeName::TextArray2D => DataType::TextArray2D,
16823    }
16824}
16825
16826/// Convert an INSERT VALUES expression to a storage Value. Supports literal
16827/// expressions, unary-minus over numeric literals, and pgvector-style
16828/// `'[..]'::vector` cast (v1.2). Anything more complex returns `Unsupported`.
16829fn literal_expr_to_value(expr: Expr) -> Result<Value, EngineError> {
16830    match expr {
16831        Expr::Literal(l) => Ok(literal_to_value(l)),
16832        Expr::Cast { expr, target } => {
16833            let inner_value = literal_expr_to_value(*expr)?;
16834            crate::eval::cast_value(inner_value, target).map_err(EngineError::Eval)
16835        }
16836        Expr::Unary {
16837            op: UnOp::Neg,
16838            expr,
16839        } => match *expr {
16840            Expr::Literal(Literal::Integer(n)) => {
16841                // Fold to i32 if it fits, else BigInt. Parser emits Integer(i64)
16842                // — overflow on negate of i64::MIN is the one edge case.
16843                let neg = n.checked_neg().ok_or_else(|| {
16844                    EngineError::Unsupported("integer literal overflow on negation".into())
16845                })?;
16846                Ok(int_value_for(neg))
16847            }
16848            Expr::Literal(Literal::Float(x)) => Ok(Value::Float(-x)),
16849            other => Err(EngineError::Unsupported(alloc::format!(
16850                "unary minus over non-literal expression: {other:?}"
16851            ))),
16852        },
16853        // v7.10.10 — `ARRAY[lit, lit, …]` constructor accepted at
16854        // INSERT-time. Each element must reduce to a Value through
16855        // `literal_expr_to_value`; NULL elements become `None`.
16856        // v7.11.13 — deduce shape from element values: all Int →
16857        // IntArray; any BigInt → BigIntArray (widening); any Text
16858        // → TextArray. Cast targets (`ARRAY[]::INT[]`) flow through
16859        // the outer Cast arm before reaching here and re-coerce.
16860        Expr::Array(items) => {
16861            let mut materialised: alloc::vec::Vec<Value> =
16862                alloc::vec::Vec::with_capacity(items.len());
16863            for elem in items {
16864                materialised.push(literal_expr_to_value(elem)?);
16865            }
16866            Ok(array_literal_widen(materialised))
16867        }
16868        // Any other Expr shape — fall back to a general evaluation
16869        // against an empty row + empty schema. This unblocks the
16870        // app-common patterns where INSERT VALUES carries a
16871        // non-correlated function call:
16872        //   INSERT INTO t VALUES (concat('U-', 42))
16873        //   INSERT INTO t VALUES (now())
16874        //   INSERT INTO t VALUES (format('%s-%s', 'a', 'b'))
16875        // Any expression that references a column or `$N`
16876        // placeholder fails cleanly inside `eval_expr` with a
16877        // descriptive error; literals + casts + ARRAY[…] continue
16878        // to take the fast paths above so the hot INSERT path is
16879        // unchanged on the common case.
16880        other => {
16881            let empty_schema: alloc::vec::Vec<spg_storage::ColumnSchema> = alloc::vec::Vec::new();
16882            let ctx = EvalContext::new(&empty_schema, None);
16883            let empty_row = spg_storage::Row::new(alloc::vec::Vec::new());
16884            crate::eval::eval_expr(&other, &empty_row, &ctx).map_err(EngineError::Eval)
16885        }
16886    }
16887}
16888
16889fn literal_to_value(l: Literal) -> Value {
16890    match l {
16891        Literal::Integer(n) => int_value_for(n),
16892        Literal::Float(x) => Value::Float(x),
16893        Literal::String(s) => Value::Text(s),
16894        Literal::Bool(b) => Value::Bool(b),
16895        Literal::Null => Value::Null,
16896        Literal::Vector(v) => Value::Vector(v),
16897        Literal::Interval { months, micros, .. } => Value::Interval { months, micros },
16898    }
16899}
16900
16901/// Pick `Int` (`i32`) when the literal fits, else `BigInt`. `INT` vs `BIGINT`
16902/// columns will still enforce the right tag downstream — this is just the
16903/// default we synthesise from an unannotated integer literal.
16904fn int_value_for(n: i64) -> Value {
16905    if let Ok(small) = i32::try_from(n) {
16906        Value::Int(small)
16907    } else {
16908        Value::BigInt(n)
16909    }
16910}
16911
16912/// Widen / narrow `v` to fit `expected`. Numerics permit safe widening
16913/// (`Int → BigInt`, `Int/BigInt → Float`) and best-effort narrowing
16914/// (`BigInt → Int` succeeds only when the value fits in `i32`). Everything
16915/// else returns `TypeMismatch` carrying the column name for caller diagnostics.
16916/// `NULL` is always permitted; the nullability check happens later in storage.
16917#[allow(clippy::too_many_lines)]
16918/// v7.17.0 Phase 4.4 — reject negative integer values on UNSIGNED
16919/// columns. Called after `coerce_value` at each INSERT / UPDATE
16920/// site that has ColumnSchema context. NULL passes through (a
16921/// nullable UNSIGNED column can legitimately hold NULL).
16922fn check_unsigned_range(
16923    v: &Value,
16924    schema: &ColumnSchema,
16925    position: usize,
16926) -> Result<(), EngineError> {
16927    if !schema.is_unsigned {
16928        return Ok(());
16929    }
16930    let n = match v {
16931        Value::SmallInt(x) => i64::from(*x),
16932        Value::Int(x) => i64::from(*x),
16933        Value::BigInt(x) => *x,
16934        _ => return Ok(()), // non-integer cells (NULL, default) skip
16935    };
16936    if n < 0 {
16937        return Err(EngineError::Unsupported(alloc::format!(
16938            "column {:?} is UNSIGNED but got negative value {n} at position {position}",
16939            schema.name
16940        )));
16941    }
16942    Ok(())
16943}
16944
16945fn coerce_value(
16946    v: Value,
16947    expected: DataType,
16948    col_name: &str,
16949    position: usize,
16950) -> Result<Value, EngineError> {
16951    if v.is_null() {
16952        return Ok(Value::Null);
16953    }
16954    let actual = v.data_type().expect("non-null");
16955    if actual == expected {
16956        return Ok(v);
16957    }
16958    let coerced = match (v, expected) {
16959        (Value::Int(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
16960        (Value::Int(n), DataType::Float) => Some(Value::Float(f64::from(n))),
16961        (Value::Int(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
16962        (Value::Int(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
16963            i128::from(n),
16964            precision,
16965            scale,
16966            col_name,
16967        )?),
16968        (Value::SmallInt(n), DataType::Int) => Some(Value::Int(i32::from(n))),
16969        (Value::SmallInt(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
16970        (Value::SmallInt(n), DataType::Float) => Some(Value::Float(f64::from(n))),
16971        (Value::SmallInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
16972            i128::from(n),
16973            precision,
16974            scale,
16975            col_name,
16976        )?),
16977        (Value::BigInt(n), DataType::Int) => i32::try_from(n).ok().map(Value::Int),
16978        (Value::BigInt(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
16979        #[allow(clippy::cast_precision_loss)]
16980        (Value::BigInt(n), DataType::Float) => Some(Value::Float(n as f64)),
16981        (Value::BigInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
16982            i128::from(n),
16983            precision,
16984            scale,
16985            col_name,
16986        )?),
16987        (Value::Float(x), DataType::Numeric { precision, scale }) => {
16988            Some(numeric_from_float(x, precision, scale, col_name)?)
16989        }
16990        // v7.17.0 Phase 3.P0-67 — Text → NUMERIC. Parse a
16991        // canonical decimal text (`"-1234.56"` / `"42"` /
16992        // `"0.0001"`) into `(mantissa, source_scale)` and rescale
16993        // to the column's declared scale. Required for prepared
16994        // binds: `value_to_literal` flattens a Value::Numeric
16995        // into a TEXT literal because Literal carries no native
16996        // Numeric variant, so the placeholder substitution path
16997        // reaches coerce_value as Text → Numeric. Without this
16998        // arm the round-trip surfaces a TypeMismatch even though
16999        // the cell already left the engine as a valid Numeric.
17000        (Value::Text(s), DataType::Numeric { precision, scale }) => {
17001            let Some((mantissa, src_scale)) = parse_numeric_text(&s) else {
17002                return Err(EngineError::Eval(EvalError::TypeMismatch {
17003                    detail: alloc::format!("cannot parse {s:?} as NUMERIC for column `{col_name}`"),
17004                }));
17005            };
17006            Some(numeric_rescale(
17007                mantissa, src_scale, precision, scale, col_name,
17008            )?)
17009        }
17010        // Text → DATE / TIMESTAMP: parse canonical text forms.
17011        (Value::Text(s), DataType::Date) => {
17012            let d = eval::parse_date_literal(&s).ok_or_else(|| {
17013                EngineError::Eval(EvalError::TypeMismatch {
17014                    detail: alloc::format!("cannot parse {s:?} as DATE for column `{col_name}`"),
17015                })
17016            })?;
17017            Some(Value::Date(d))
17018        }
17019        // v7.14.0 — MySQL DEFAULT clauses quote integer / float
17020        // / boolean literals (`DEFAULT '0'`, `DEFAULT '1'`,
17021        // `DEFAULT '3.14'`, `DEFAULT 'true'`). Coerce the text
17022        // form to the column's numeric / bool type at DEFAULT-
17023        // installation time so the storage check sees a typed
17024        // value. Parse failures fall through to TypeMismatch.
17025        (Value::Text(s), DataType::SmallInt) => s.parse::<i16>().ok().map(Value::SmallInt),
17026        (Value::Text(s), DataType::Int) => s.parse::<i32>().ok().map(Value::Int),
17027        (Value::Text(s), DataType::BigInt) => s.parse::<i64>().ok().map(Value::BigInt),
17028        (Value::Text(s), DataType::Float) => s.parse::<f64>().ok().map(Value::Float),
17029        (Value::Text(s), DataType::Bool) => match s.to_ascii_lowercase().as_str() {
17030            "0" | "false" | "f" | "no" | "off" => Some(Value::Bool(false)),
17031            "1" | "true" | "t" | "yes" | "on" => Some(Value::Bool(true)),
17032            _ => None,
17033        },
17034        // v7.17.0 Phase 3.P0-46 — MySQL TINYINT(1) (which Phase 4.3
17035        // classifies as DataType::Bool) is the storage shape every
17036        // mysqldump-restored boolean column lands in. mysqldump emits
17037        // the values as integer `0` / `1` literals, so int → bool
17038        // coerce on INSERT is required for a 0-change cutover. MySQL's
17039        // rule is "any non-zero is truthy"; we follow that for all
17040        // signed int widths so the same coerce path serves an
17041        // explicit `BOOLEAN` column too.
17042        (Value::Int(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17043        (Value::SmallInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17044        (Value::BigInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
17045        // v4.9: Text ↔ JSON coercion. No structural validation —
17046        // any text literal is accepted; the responsibility for
17047        // valid JSON lies with the producer.
17048        (Value::Text(s), DataType::Json | DataType::Jsonb) => Some(Value::Json(s)),
17049        (Value::Json(s), DataType::Text) => Some(Value::Text(s)),
17050        // v7.13.3 — mailrs round-7 S10. SPG's storage represents
17051        // both JSON and JSONB on-disk as `Value::Json(String)` —
17052        // they share the underlying text payload. The cast
17053        // `'<text>'::jsonb` produces a Value::Json that needs to
17054        // satisfy a DataType::Jsonb column. Identity coerce in
17055        // both directions so JSON ↔ JSONB assignments work at all
17056        // INSERT / ALTER COLUMN TYPE / DEFAULT contexts.
17057        (Value::Json(s), DataType::Jsonb | DataType::Json) => Some(Value::Json(s)),
17058        // v7.10.4 — Text → BYTEA. Decode PG-style literal forms:
17059        //   - Hex:    `\x48656c6c6f`  (case-insensitive hex pairs)
17060        //   - Escape: `Hello\\000world`  (backslash + octal triples)
17061        //   - Plain:  any string → raw UTF-8 bytes (PG also accepts)
17062        // Errors surface as TypeMismatch so the operator gets a
17063        // clear "this literal isn't a bytea literal" hint.
17064        (Value::Text(s), DataType::Bytes) => {
17065            let bytes = decode_bytea_literal(&s).map_err(|e| {
17066                EngineError::Eval(EvalError::TypeMismatch {
17067                    detail: alloc::format!(
17068                        "cannot parse {s:?} as BYTEA for column `{col_name}`: {e}"
17069                    ),
17070                })
17071            })?;
17072            Some(Value::Bytes(bytes))
17073        }
17074        // v7.10.4 — BYTEA → Text round-trip uses the PG hex
17075        // output (lowercase, `\x` prefix). Important when a
17076        // SELECT pulls a bytea cell through a Text column path.
17077        (Value::Bytes(b), DataType::Text) => Some(Value::Text(encode_bytea_hex(&b))),
17078        // v7.17.0 — Text → UUID. PG accepts canonical hyphenated,
17079        // unhyphenated, uppercase, and `{...}`-braced forms; we
17080        // funnel all four through `spg_storage::parse_uuid_str`.
17081        // A malformed literal surfaces as a SQL TypeMismatch
17082        // rather than silently inserting garbage — `0-change
17083        // cutover` requires that an app inserting bad UUID text
17084        // sees the same hard error PG would raise.
17085        (Value::Text(s), DataType::Uuid) => match spg_storage::parse_uuid_str(&s) {
17086            Some(b) => Some(Value::Uuid(b)),
17087            None => {
17088                return Err(EngineError::Eval(EvalError::TypeMismatch {
17089                    detail: alloc::format!(
17090                        "invalid input syntax for type uuid: {s:?} (column `{col_name}`)"
17091                    ),
17092                }));
17093            }
17094        },
17095        // v7.17.0 — UUID → Text canonical 8-4-4-4-12 lowercase.
17096        // Surfaces when a SELECT plucks a uuid cell through a
17097        // Text column path (e.g. INSERT INTO log SELECT id::text
17098        // FROM other_table).
17099        (Value::Uuid(b), DataType::Text) => Some(Value::Text(spg_storage::format_uuid(&b))),
17100        // v7.17.0 Phase 3.P0-32 — Text → TIME. Accepts
17101        // `HH:MM:SS` and `HH:MM:SS.ffffff` (1-6 fractional digits).
17102        // Out-of-range hour/min/sec is a hard SQL error (no
17103        // silent truncation — same 0-change-cutover discipline
17104        // we apply to UUID).
17105        (Value::Text(s), DataType::Time) => match parse_time_str(&s) {
17106            Some(us) => Some(Value::Time(us)),
17107            None => {
17108                return Err(EngineError::Eval(EvalError::TypeMismatch {
17109                    detail: alloc::format!(
17110                        "invalid input syntax for type time: {s:?} (column `{col_name}`)"
17111                    ),
17112                }));
17113            }
17114        },
17115        // v7.17.0 Phase 3.P0-32 — TIME → Text canonical `HH:MM:SS[.ffffff]`.
17116        (Value::Time(us), DataType::Text) => Some(Value::Text(eval::format_time(us))),
17117        // v7.17.0 Phase 3.P0-33 — int / bigint → YEAR. Range
17118        // check enforces the MySQL canonical 1901..=2155 + 0
17119        // sentinel; out-of-range is a hard SQL error (no silent
17120        // truncation, mirrors P0-32 / P0-25 discipline).
17121        (Value::SmallInt(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
17122        (Value::Int(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
17123        (Value::BigInt(n), DataType::Year) => Some(coerce_int_to_year(n, col_name)?),
17124        // Text → YEAR. Accepts the 4-digit decimal form only;
17125        // two-digit YEAR (`'99'` → 1999) was deprecated in MySQL
17126        // 5.7 and is out of scope for v7.17.0.
17127        (Value::Text(s), DataType::Year) => match s.trim().parse::<i64>() {
17128            Ok(n) => Some(coerce_int_to_year(n, col_name)?),
17129            Err(_) => {
17130                return Err(EngineError::Eval(EvalError::TypeMismatch {
17131                    detail: alloc::format!(
17132                        "invalid input syntax for type year: {s:?} (column `{col_name}`)"
17133                    ),
17134                }));
17135            }
17136        },
17137        // YEAR → Text 4-digit zero-padded.
17138        (Value::Year(y), DataType::Text) => Some(Value::Text(alloc::format!("{y:04}"))),
17139        // v7.17.0 Phase 3.P0-34 — Text → TIMETZ. Mandatory
17140        // signed offset suffix; missing offset is a hard error
17141        // (SPG has no session TZ wired into eval, unlike PG).
17142        (Value::Text(s), DataType::TimeTz) => match parse_timetz_str(&s) {
17143            Some((us, offset_secs)) => Some(Value::TimeTz { us, offset_secs }),
17144            None => {
17145                return Err(EngineError::Eval(EvalError::TypeMismatch {
17146                    detail: alloc::format!(
17147                        "invalid input syntax for type time with time zone: \
17148                         {s:?} (column `{col_name}`)"
17149                    ),
17150                }));
17151            }
17152        },
17153        // TIMETZ → Text canonical `HH:MM:SS[.ffffff]±HH[:MM]`.
17154        (Value::TimeTz { us, offset_secs }, DataType::Text) => {
17155            Some(Value::Text(eval::format_timetz(us, offset_secs)))
17156        }
17157        // v7.17.0 Phase 3.P0-35 — Text → MONEY. Accepts `$N.NN`,
17158        // `$N,NNN.NN`, optional leading `-`. Bare numeric literals
17159        // arrive via the Int/BigInt/Float/Numeric arms below.
17160        (Value::Text(s), DataType::Money) => match parse_money_str(&s) {
17161            Some(c) => Some(Value::Money(c)),
17162            None => {
17163                return Err(EngineError::Eval(EvalError::TypeMismatch {
17164                    detail: alloc::format!(
17165                        "invalid input syntax for type money: {s:?} (column `{col_name}`)"
17166                    ),
17167                }));
17168            }
17169        },
17170        // Int / BigInt / SmallInt / Float / Numeric → MONEY.
17171        // Bare numeric literal is interpreted as a major-unit
17172        // amount (matches PG: `100`::money → $100.00 = 10000 cents).
17173        (Value::SmallInt(n), DataType::Money) => {
17174            Some(Value::Money(i64::from(n).saturating_mul(100)))
17175        }
17176        (Value::Int(n), DataType::Money) => Some(Value::Money(i64::from(n).saturating_mul(100))),
17177        (Value::BigInt(n), DataType::Money) => Some(Value::Money(n.saturating_mul(100))),
17178        (Value::Float(x), DataType::Money) => {
17179            // Round half-away-from-zero to cents (no_std — no
17180            // `f64::round`, so hand-roll via biased truncation).
17181            let scaled = x * 100.0;
17182            let cents = if scaled >= 0.0 {
17183                (scaled + 0.5) as i64
17184            } else {
17185                (scaled - 0.5) as i64
17186            };
17187            Some(Value::Money(cents))
17188        }
17189        (Value::Numeric { scaled, scale }, DataType::Money) => {
17190            // Convert exact decimal to cents (scale 2). If scale > 2,
17191            // round half-away-from-zero. If scale < 2, multiply up.
17192            let cents = if scale == 2 {
17193                scaled
17194            } else if scale < 2 {
17195                let mult = 10_i128.pow(u32::from(2 - scale));
17196                scaled.saturating_mul(mult)
17197            } else {
17198                let div = 10_i128.pow(u32::from(scale - 2));
17199                let half = div / 2;
17200                let bias = if scaled >= 0 { half } else { -half };
17201                (scaled + bias) / div
17202            };
17203            Some(Value::Money(i64::try_from(cents).unwrap_or(i64::MAX)))
17204        }
17205        // MONEY → Text canonical `$N,NNN.CC`.
17206        (Value::Money(c), DataType::Text) => Some(Value::Text(eval::format_money(c))),
17207        // v7.17.0 Phase 3.P0-38 — Text → Range. Accepts canonical
17208        // PG forms: `'empty'`, `'[a,b)'`, `'(a,b]'`, `'[a,b]'`,
17209        // `'(a,b)'`, with empty lower or upper for unbounded.
17210        (Value::Text(s), DataType::Range(kind)) => match parse_range_str(&s, kind) {
17211            Some(v) => Some(v),
17212            None => {
17213                return Err(EngineError::Eval(EvalError::TypeMismatch {
17214                    detail: alloc::format!(
17215                        "invalid input syntax for range type: {s:?} (column `{col_name}`)"
17216                    ),
17217                }));
17218            }
17219        },
17220        // Range → Text canonical form (`[a,b)`, `'empty'`, etc).
17221        (v @ Value::Range { .. }, DataType::Text) => Some(Value::Text(format_range_str(&v))),
17222        // v7.17.0 Phase 3.P0-39 — Text → Hstore.
17223        (Value::Text(s), DataType::Hstore) => match parse_hstore_str(&s) {
17224            Some(pairs) => Some(Value::Hstore(pairs)),
17225            None => {
17226                return Err(EngineError::Eval(EvalError::TypeMismatch {
17227                    detail: alloc::format!(
17228                        "invalid input syntax for type hstore: {s:?} (column `{col_name}`)"
17229                    ),
17230                }));
17231            }
17232        },
17233        // Hstore → Text canonical `"k"=>"v"` form.
17234        (Value::Hstore(pairs), DataType::Text) => Some(Value::Text(format_hstore_str(&pairs))),
17235        // v7.17.0 Phase 3.P0-40 — Text → 2D arrays via PG
17236        // external `'{{a,b},{c,d}}'` literal.
17237        (Value::Text(s), DataType::IntArray2D) => match parse_int_2d_literal(&s) {
17238            Ok(m) => Some(Value::IntArray2D(m)),
17239            Err(e) => {
17240                return Err(EngineError::Eval(EvalError::TypeMismatch {
17241                    detail: alloc::format!(
17242                        "invalid input syntax for INT[][]: {s:?} (column `{col_name}`): {e}"
17243                    ),
17244                }));
17245            }
17246        },
17247        (Value::Text(s), DataType::BigIntArray2D) => match parse_bigint_2d_literal(&s) {
17248            Ok(m) => Some(Value::BigIntArray2D(m)),
17249            Err(e) => {
17250                return Err(EngineError::Eval(EvalError::TypeMismatch {
17251                    detail: alloc::format!(
17252                        "invalid input syntax for BIGINT[][]: {s:?} (column `{col_name}`): {e}"
17253                    ),
17254                }));
17255            }
17256        },
17257        (Value::Text(s), DataType::TextArray2D) => match parse_text_2d_literal(&s) {
17258            Ok(m) => Some(Value::TextArray2D(m)),
17259            Err(e) => {
17260                return Err(EngineError::Eval(EvalError::TypeMismatch {
17261                    detail: alloc::format!(
17262                        "invalid input syntax for TEXT[][]: {s:?} (column `{col_name}`): {e}"
17263                    ),
17264                }));
17265            }
17266        },
17267        // 2D arrays → Text canonical nested form.
17268        (Value::IntArray2D(rows), DataType::Text) => Some(Value::Text(format_int_2d_text(&rows))),
17269        (Value::BigIntArray2D(rows), DataType::Text) => {
17270            Some(Value::Text(format_bigint_2d_text(&rows)))
17271        }
17272        (Value::TextArray2D(rows), DataType::Text) => Some(Value::Text(format_text_2d_text(&rows))),
17273        // v7.10.11 — Text → TEXT[]. Decode PG's external array
17274        // form `'{a,b,NULL}'`. NULL element token (case-insensitive)
17275        // is the literal `NULL`; everything else is a quoted or
17276        // unquoted text element. mailrs `'{label1,label2}'::TEXT[]`.
17277        (Value::Text(s), DataType::TextArray) => {
17278            let arr = decode_text_array_literal(&s).map_err(|e| {
17279                EngineError::Eval(EvalError::TypeMismatch {
17280                    detail: alloc::format!(
17281                        "cannot parse {s:?} as TEXT[] for column `{col_name}`: {e}"
17282                    ),
17283                })
17284            })?;
17285            Some(Value::TextArray(arr))
17286        }
17287        // v7.16.0 — Text → IntArray / BigIntArray for the
17288        // spg-sqlx Bind path. Decode the PG external form
17289        // `{1,2,3}` as a TEXT array first, then parse each
17290        // element as int. Same shape as the TextArray decode
17291        // above with an element-wise narrow.
17292        (Value::Text(s), DataType::IntArray) => {
17293            let arr = decode_text_array_literal(&s).map_err(|e| {
17294                EngineError::Eval(EvalError::TypeMismatch {
17295                    detail: alloc::format!(
17296                        "cannot parse {s:?} as INT[] for column `{col_name}`: {e}"
17297                    ),
17298                })
17299            })?;
17300            let mut out: Vec<Option<i32>> = Vec::with_capacity(arr.len());
17301            for elem in arr {
17302                match elem {
17303                    None => out.push(None),
17304                    Some(t) => {
17305                        let n: i32 = t.parse().map_err(|_| {
17306                            EngineError::Eval(EvalError::TypeMismatch {
17307                                detail: alloc::format!(
17308                                    "cannot parse {t:?} as INT element for `{col_name}`"
17309                                ),
17310                            })
17311                        })?;
17312                        out.push(Some(n));
17313                    }
17314                }
17315            }
17316            Some(Value::IntArray(out))
17317        }
17318        (Value::Text(s), DataType::BigIntArray) => {
17319            let arr = decode_text_array_literal(&s).map_err(|e| {
17320                EngineError::Eval(EvalError::TypeMismatch {
17321                    detail: alloc::format!(
17322                        "cannot parse {s:?} as BIGINT[] for column `{col_name}`: {e}"
17323                    ),
17324                })
17325            })?;
17326            let mut out: Vec<Option<i64>> = Vec::with_capacity(arr.len());
17327            for elem in arr {
17328                match elem {
17329                    None => out.push(None),
17330                    Some(t) => {
17331                        let n: i64 = t.parse().map_err(|_| {
17332                            EngineError::Eval(EvalError::TypeMismatch {
17333                                detail: alloc::format!(
17334                                    "cannot parse {t:?} as BIGINT element for `{col_name}`"
17335                                ),
17336                            })
17337                        })?;
17338                        out.push(Some(n));
17339                    }
17340                }
17341            }
17342            Some(Value::BigIntArray(out))
17343        }
17344        // v7.10.11 — TEXT[] → Text round-trip uses PG's
17345        // external array form (`{a,b,NULL}`). Lets a SELECT
17346        // pull an array column through any Text-side codepath.
17347        (Value::TextArray(items), DataType::Text) => Some(Value::Text(encode_text_array(&items))),
17348        // v7.17.0 Phase 3.P0-68 — Text → VECTOR auto-coerce.
17349        // Matches the existing Text → TsVector arm and the
17350        // `::vector` cast: PG-canonical pgvector external form
17351        // (`'[1, 2, -3]'`) becomes a typed Vector value at the
17352        // column boundary. Dim mismatch surfaces as TypeMismatch.
17353        // For SQ8 / HALF encodings we chain through the standard
17354        // quantise helpers so the storage shape matches the
17355        // declared encoding without a second coerce pass.
17356        (Value::Text(s), DataType::Vector { dim, encoding }) => {
17357            let parsed = eval::parse_vector_text(&s).ok_or_else(|| {
17358                EngineError::Eval(EvalError::TypeMismatch {
17359                    detail: alloc::format!("cannot parse {s:?} as VECTOR for column `{col_name}`"),
17360                })
17361            })?;
17362            if parsed.len() != dim as usize {
17363                return Err(EngineError::Eval(EvalError::TypeMismatch {
17364                    detail: alloc::format!(
17365                        "VECTOR({dim}) column `{col_name}` rejects literal of length {}",
17366                        parsed.len()
17367                    ),
17368                }));
17369            }
17370            Some(match encoding {
17371                VecEncoding::F32 => Value::Vector(parsed),
17372                VecEncoding::Sq8 => Value::Sq8Vector(spg_storage::quantize::quantize(&parsed)),
17373                VecEncoding::F16 => {
17374                    Value::HalfVector(spg_storage::halfvec::HalfVector::from_f32_slice(&parsed))
17375                }
17376            })
17377        }
17378        // v7.16.1 — Text → TSVECTOR auto-coerce for the
17379        // INSERT-side wire path (mailrs round-9 A.2.a). PG
17380        // implicitly promotes the TEXT literal at INSERT into a
17381        // TSVECTOR column; SPG previously rejected with a hard
17382        // type mismatch, blocking 23,276 pg_dump rows into
17383        // `messages.search_vector`. We route through the same
17384        // `decode_tsvector_external` the `::tsvector` cast
17385        // already uses, so PG-canonical forms (`'word'`,
17386        // `'word:1A,2B'`, multi-lexeme, empty `''`) all parse.
17387        (Value::Text(s), DataType::TsVector) => {
17388            let lexs = eval::decode_tsvector_external(&s).map_err(|e| {
17389                EngineError::Eval(EvalError::TypeMismatch {
17390                    detail: alloc::format!(
17391                        "cannot parse {s:?} as TSVECTOR for column `{col_name}`: {e}"
17392                    ),
17393                })
17394            })?;
17395            Some(Value::TsVector(lexs))
17396        }
17397        (Value::Text(s), DataType::Timestamp | DataType::Timestamptz) => {
17398            let t = eval::parse_timestamp_literal(&s).ok_or_else(|| {
17399                EngineError::Eval(EvalError::TypeMismatch {
17400                    detail: alloc::format!(
17401                        "cannot parse {s:?} as TIMESTAMP for column `{col_name}`"
17402                    ),
17403                })
17404            })?;
17405            Some(Value::Timestamp(t))
17406        }
17407        // DATE ↔ TIMESTAMP convertibility (DATE → midnight,
17408        // TIMESTAMP → day truncation).
17409        (Value::Date(d), DataType::Timestamp | DataType::Timestamptz) => {
17410            Some(Value::Timestamp(i64::from(d) * 86_400_000_000))
17411        }
17412        // v7.9.21 — Value::Timestamp lands in either Timestamp
17413        // or Timestamptz columns; the on-disk layout is the
17414        // same i64 microseconds UTC.
17415        (Value::Timestamp(t), DataType::Timestamptz) => Some(Value::Timestamp(t)),
17416        (Value::Timestamp(t), DataType::Date) => {
17417            let days = t.div_euclid(86_400_000_000);
17418            i32::try_from(days).ok().map(Value::Date)
17419        }
17420        (
17421            Value::Numeric {
17422                scaled,
17423                scale: src_scale,
17424            },
17425            DataType::Numeric { precision, scale },
17426        ) => Some(numeric_rescale(
17427            scaled, src_scale, precision, scale, col_name,
17428        )?),
17429        #[allow(clippy::cast_precision_loss)]
17430        (Value::Numeric { scaled, scale }, DataType::Float) => {
17431            let mut div = 1.0_f64;
17432            for _ in 0..scale {
17433                div *= 10.0;
17434            }
17435            Some(Value::Float((scaled as f64) / div))
17436        }
17437        (Value::Numeric { scaled, scale }, DataType::Int) => {
17438            let truncated = numeric_truncate_to_integer(scaled, scale);
17439            i32::try_from(truncated).ok().map(Value::Int)
17440        }
17441        (Value::Numeric { scaled, scale }, DataType::BigInt) => {
17442            let truncated = numeric_truncate_to_integer(scaled, scale);
17443            i64::try_from(truncated).ok().map(Value::BigInt)
17444        }
17445        (Value::Numeric { scaled, scale }, DataType::SmallInt) => {
17446            let truncated = numeric_truncate_to_integer(scaled, scale);
17447            i16::try_from(truncated).ok().map(Value::SmallInt)
17448        }
17449        // VARCHAR(n) enforces an upper bound on character count.
17450        (Value::Text(s), DataType::Varchar(max)) => {
17451            if u32::try_from(s.chars().count()).unwrap_or(u32::MAX) <= max {
17452                Some(Value::Text(s))
17453            } else {
17454                return Err(EngineError::Unsupported(alloc::format!(
17455                    "value for VARCHAR({max}) column `{col_name}` exceeds length: \
17456                     {} chars",
17457                    s.chars().count()
17458                )));
17459            }
17460        }
17461        // v6.0.1: f32 → SQ8 INSERT-time quantisation. Triggered
17462        // when the column declares `VECTOR(N) USING SQ8` and
17463        // the INSERT VALUES expression yields a raw f32 vector
17464        // (the normal pgvector-shape literal). Dim mismatch
17465        // falls through the `_ => None` arm and surfaces as
17466        // `TypeMismatch` with the expected SQ8 column type —
17467        // matching the F32 path's existing error.
17468        (
17469            Value::Vector(v),
17470            DataType::Vector {
17471                dim,
17472                encoding: VecEncoding::Sq8,
17473            },
17474        ) if v.len() == dim as usize => Some(Value::Sq8Vector(spg_storage::quantize::quantize(&v))),
17475        // v6.0.3: f32 → f16 INSERT-time conversion for HALF
17476        // columns. Bit-exact at the storage layer (modulo
17477        // half-precision rounding); no rerank pass needed at
17478        // search time.
17479        (
17480            Value::Vector(v),
17481            DataType::Vector {
17482                dim,
17483                encoding: VecEncoding::F16,
17484            },
17485        ) if v.len() == dim as usize => Some(Value::HalfVector(
17486            spg_storage::halfvec::HalfVector::from_f32_slice(&v),
17487        )),
17488        // CHAR(n) right-pads with U+0020 to exactly n chars; if the input
17489        // is already longer we reject (PG truncates trailing-space-only;
17490        // staying strict for v1).
17491        (Value::Text(s), DataType::Char(size)) => {
17492            let len = u32::try_from(s.chars().count()).unwrap_or(u32::MAX);
17493            if len > size {
17494                return Err(EngineError::Unsupported(alloc::format!(
17495                    "value for CHAR({size}) column `{col_name}` exceeds length: \
17496                     {len} chars"
17497                )));
17498            }
17499            let need = (size - len) as usize;
17500            let mut padded = s;
17501            padded.reserve(need);
17502            for _ in 0..need {
17503                padded.push(' ');
17504            }
17505            Some(Value::Text(padded))
17506        }
17507        _ => None,
17508    };
17509    coerced.ok_or(EngineError::Storage(StorageError::TypeMismatch {
17510        column: col_name.into(),
17511        expected,
17512        actual,
17513        position,
17514    }))
17515}
17516
17517/// v7.12.4 — render a function arg list into the
17518/// canonical form the storage layer caches as
17519/// [`spg_storage::FunctionDef::args_repr`]. The catalogue uses
17520/// this string for both display + as a coarse signature key
17521/// for the (deferred) overload resolution v7.12.5+ adds.
17522fn render_function_args(args: &[spg_sql::ast::FunctionArg]) -> alloc::string::String {
17523    use core::fmt::Write;
17524    let mut out = alloc::string::String::from("(");
17525    for (i, a) in args.iter().enumerate() {
17526        if i > 0 {
17527            out.push_str(", ");
17528        }
17529        match a.mode {
17530            spg_sql::ast::FunctionArgMode::In => {}
17531            spg_sql::ast::FunctionArgMode::Out => out.push_str("OUT "),
17532            spg_sql::ast::FunctionArgMode::InOut => out.push_str("INOUT "),
17533        }
17534        if let Some(n) = &a.name {
17535            out.push_str(n);
17536            out.push(' ');
17537        }
17538        match &a.ty {
17539            spg_sql::ast::FunctionArgType::Typed(t) => {
17540                let _ = write!(out, "{t}");
17541            }
17542            spg_sql::ast::FunctionArgType::Raw(s) => out.push_str(s),
17543        }
17544    }
17545    out.push(')');
17546    out
17547}
17548
17549/// v7.19 P5 — true iff `expr` is `unnest(arg)` at the top level
17550/// (case-insensitive). Used by `exec_select_cancel`'s
17551/// projection loop to detect Set-Returning-Function rows that
17552/// need per-row expansion. Only the top-level call counts —
17553/// `coalesce(unnest(arr), 'x')` is NOT a SRF row from the
17554/// projection's perspective; it would surface as an "unknown
17555/// function" mismatch downstream, which is what we want
17556/// (multi-SRF / nested SRF is documented carve-out for v7.19).
17557fn is_top_level_unnest(expr: &spg_sql::ast::Expr) -> bool {
17558    match expr {
17559        spg_sql::ast::Expr::FunctionCall { name, args } => {
17560            name.eq_ignore_ascii_case("unnest") && args.len() == 1
17561        }
17562        _ => false,
17563    }
17564}
17565
17566/// v7.19 P5 — extract the array argument out of a top-level
17567/// `unnest(arg)` call. `None` if `expr` isn't a `unnest` call
17568/// of arity 1 (mirrors `is_top_level_unnest`).
17569fn top_level_unnest_arg(expr: &spg_sql::ast::Expr) -> Option<&spg_sql::ast::Expr> {
17570    match expr {
17571        spg_sql::ast::Expr::FunctionCall { name, args }
17572            if name.eq_ignore_ascii_case("unnest") && args.len() == 1 =>
17573        {
17574            Some(&args[0])
17575        }
17576        _ => None,
17577    }
17578}
17579
17580/// v7.19 P5 — turn an array-typed `Value` into the element list
17581/// `unnest()` projection emits. NULL → empty list (PG: `unnest(NULL)
17582/// = (no rows)`). Non-array values fall through to a type-mismatch
17583/// error.
17584fn array_value_to_elements(v: &Value) -> Result<Vec<Value>, EngineError> {
17585    match v {
17586        Value::Null => Ok(Vec::new()),
17587        Value::TextArray(items) => Ok(items
17588            .iter()
17589            .map(|opt| {
17590                opt.as_ref()
17591                    .map(|s| Value::Text(s.clone()))
17592                    .unwrap_or(Value::Null)
17593            })
17594            .collect()),
17595        Value::IntArray(items) => Ok(items
17596            .iter()
17597            .map(|opt| opt.map(Value::Int).unwrap_or(Value::Null))
17598            .collect()),
17599        Value::BigIntArray(items) => Ok(items
17600            .iter()
17601            .map(|opt| opt.map(Value::BigInt).unwrap_or(Value::Null))
17602            .collect()),
17603        other => Err(EngineError::Eval(EvalError::TypeMismatch {
17604            detail: alloc::format!(
17605                "unnest() expects an array argument, got {:?}",
17606                other.data_type()
17607            ),
17608        })),
17609    }
17610}
17611
17612#[cfg(test)]
17613mod tests {
17614    use super::*;
17615    use alloc::vec;
17616
17617    fn unwrap_command_ok(r: &QueryResult) -> usize {
17618        match r {
17619            QueryResult::CommandOk { affected, .. } => *affected,
17620            QueryResult::Rows { .. } => panic!("expected CommandOk, got Rows"),
17621        }
17622    }
17623
17624    #[test]
17625    fn update_seek_positions_engages_on_indexed_eq() {
17626        let mut e = Engine::new();
17627        e.execute("CREATE TABLE b (id INT NOT NULL, v INT NOT NULL)")
17628            .unwrap();
17629        e.execute("CREATE INDEX b_id ON b (id)").unwrap();
17630        for i in 0..100 {
17631            e.execute(&alloc::format!("INSERT INTO b VALUES ({i}, {i})"))
17632                .unwrap();
17633        }
17634        let stmt = spg_sql::parser::parse_statement("UPDATE b SET v = v + 1 WHERE id = 42")
17635            .expect("parse");
17636        let Statement::Update(u) = stmt else {
17637            panic!("expected Update, got {stmt:?}");
17638        };
17639        let w = u.where_.as_ref().expect("where");
17640        let table = e.catalog().get("b").unwrap();
17641        let schema_cols = table.schema().columns.clone();
17642        // step-by-step: each sub-resolution must succeed.
17643        let Expr::Binary { lhs, op, rhs } = w else {
17644            panic!("WHERE not Binary: {w:?}");
17645        };
17646        assert_eq!(*op, BinOp::Eq, "op not Eq");
17647        let pair = resolve_col_literal_pair(lhs, rhs, &schema_cols, "b");
17648        assert!(
17649            pair.is_some(),
17650            "resolve_col_literal_pair None: lhs={lhs:?} rhs={rhs:?}"
17651        );
17652        let (col_pos, value) = pair.unwrap();
17653        assert!(
17654            table.index_on(col_pos).is_some(),
17655            "no index on col {col_pos}"
17656        );
17657        assert!(
17658            IndexKey::from_value(&value).is_some(),
17659            "IndexKey::from_value None for {value:?}"
17660        );
17661        let positions = try_index_seek_positions(w, &schema_cols, table, "b");
17662        assert_eq!(positions, Some(vec![42]), "seek did not engage");
17663    }
17664
17665    #[test]
17666    fn create_table_registers_schema() {
17667        let mut e = Engine::new();
17668        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT)")
17669            .unwrap();
17670        assert_eq!(e.catalog().table_count(), 1);
17671        let t = e.catalog().get("foo").unwrap();
17672        assert_eq!(t.schema().columns.len(), 2);
17673        assert_eq!(t.schema().columns[0].ty, DataType::Int);
17674        assert!(!t.schema().columns[0].nullable);
17675        assert_eq!(t.schema().columns[1].ty, DataType::Text);
17676    }
17677
17678    #[test]
17679    fn create_table_vector_default_is_f32_encoded() {
17680        let mut e = Engine::new();
17681        e.execute("CREATE TABLE t (v VECTOR(8))").unwrap();
17682        let t = e.catalog().get("t").unwrap();
17683        assert_eq!(
17684            t.schema().columns[0].ty,
17685            DataType::Vector {
17686                dim: 8,
17687                encoding: VecEncoding::F32,
17688            },
17689        );
17690    }
17691
17692    #[test]
17693    fn create_table_vector_using_sq8_succeeds() {
17694        // v6.0.1 step 3: the step-1 fence in `column_def_to_schema`
17695        // is lifted. CREATE TABLE persists an SQ8 column type in
17696        // the catalog; INSERT (next test) quantises raw f32 input.
17697        let mut e = Engine::new();
17698        e.execute("CREATE TABLE t (v VECTOR(8) USING SQ8)").unwrap();
17699        let t = e.catalog().get("t").unwrap();
17700        assert_eq!(
17701            t.schema().columns[0].ty,
17702            DataType::Vector {
17703                dim: 8,
17704                encoding: VecEncoding::Sq8,
17705            },
17706        );
17707    }
17708
17709    #[test]
17710    fn insert_into_sq8_column_quantises_f32_payload() {
17711        // v6.0.1 step 3: INSERT-time `coerce_value` rewrites a raw
17712        // `Value::Vector(Vec<f32>)` literal into the column's
17713        // quantised representation. The row that lands in the
17714        // catalog must therefore hold a `Value::Sq8Vector`, not the
17715        // original f32 buffer — that's the bit that delivers the
17716        // 4× compression target.
17717        let mut e = Engine::new();
17718        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
17719        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
17720            .unwrap();
17721        let t = e.catalog().get("t").unwrap();
17722        assert_eq!(t.rows().len(), 1);
17723        match &t.rows()[0].values[0] {
17724            Value::Sq8Vector(q) => {
17725                assert_eq!(q.bytes.len(), 4);
17726                // min/max are derived from the payload: min=0.0, max=1.0.
17727                assert!((q.min - 0.0).abs() < 1e-6);
17728                assert!((q.max - 1.0).abs() < 1e-6);
17729            }
17730            other => panic!("expected Sq8Vector cell, got {other:?}"),
17731        }
17732    }
17733
17734    #[test]
17735    fn create_table_vector_using_half_succeeds_and_insert_converts_to_f16() {
17736        // v6.0.3: CREATE TABLE accepts USING HALF; INSERT path
17737        // converts the incoming `Value::Vector(Vec<f32>)` cell
17738        // into `Value::HalfVector(HalfVector)` via the new
17739        // `coerce_value` arm. The dequantised round-trip is
17740        // bit-exact for f16-representable values, so 0.0 / 0.25
17741        // / 0.5 / 1.0 hit their grid points exactly.
17742        let mut e = Engine::new();
17743        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
17744            .unwrap();
17745        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
17746            .unwrap();
17747        let t = e.catalog().get("t").unwrap();
17748        assert_eq!(t.rows().len(), 1);
17749        match &t.rows()[0].values[0] {
17750            Value::HalfVector(h) => {
17751                assert_eq!(h.dim(), 4);
17752                let back = h.to_f32_vec();
17753                let expected = alloc::vec![0.0_f32, 0.25, 0.5, 1.0];
17754                for (g, e) in back.iter().zip(expected.iter()) {
17755                    assert!(
17756                        (g - e).abs() < 1e-6,
17757                        "{g} vs {e} should be exact on f16 grid"
17758                    );
17759                }
17760            }
17761            other => panic!("expected HalfVector cell, got {other:?}"),
17762        }
17763    }
17764
17765    #[test]
17766    fn alter_index_rebuild_in_place_succeeds() {
17767        // v6.0.4: bare REBUILD (no encoding switch) walks every
17768        // row again to rebuild the NSW graph. Verifies the engine
17769        // dispatch + storage helper plumbing without changing any
17770        // cell encoding.
17771        let mut e = Engine::new();
17772        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
17773            .unwrap();
17774        for i in 0..8_i32 {
17775            #[allow(clippy::cast_precision_loss)]
17776            let base = (i as f32) * 0.1;
17777            e.execute(&alloc::format!(
17778                "INSERT INTO t VALUES ({i}, [{base}, {b1}, {b2}])",
17779                b1 = base + 0.01,
17780                b2 = base + 0.02,
17781            ))
17782            .unwrap();
17783        }
17784        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
17785        e.execute("ALTER INDEX t_idx REBUILD").unwrap();
17786        // Schema encoding stays F32 (no encoding clause).
17787        assert_eq!(
17788            e.catalog().get("t").unwrap().schema().columns[1].ty,
17789            DataType::Vector {
17790                dim: 3,
17791                encoding: VecEncoding::F32,
17792            },
17793        );
17794    }
17795
17796    #[test]
17797    fn alter_index_rebuild_with_encoding_switches_cell_type() {
17798        // v6.0.4: REBUILD WITH (encoding = SQ8) recodes every
17799        // stored cell from F32 → SQ8 + rebuilds the graph atop the
17800        // new encoding. Post-rebuild, cells must be Sq8Vector and
17801        // the schema must report encoding = Sq8.
17802        let mut e = Engine::new();
17803        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(4) NOT NULL)")
17804            .unwrap();
17805        e.execute("INSERT INTO t VALUES (1, [0.0, 0.25, 0.5, 1.0])")
17806            .unwrap();
17807        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
17808        e.execute("ALTER INDEX t_idx REBUILD WITH (encoding = SQ8)")
17809            .unwrap();
17810        let t = e.catalog().get("t").unwrap();
17811        assert_eq!(
17812            t.schema().columns[1].ty,
17813            DataType::Vector {
17814                dim: 4,
17815                encoding: VecEncoding::Sq8,
17816            },
17817        );
17818        assert!(matches!(t.rows()[0].values[1], Value::Sq8Vector(_)));
17819    }
17820
17821    #[test]
17822    fn alter_index_rebuild_unknown_index_errors() {
17823        let mut e = Engine::new();
17824        let err = e.execute("ALTER INDEX nope REBUILD").unwrap_err();
17825        assert!(
17826            matches!(
17827                &err,
17828                EngineError::Storage(StorageError::IndexNotFound { name }) if name == "nope"
17829            ),
17830            "got: {err}"
17831        );
17832    }
17833
17834    #[test]
17835    fn alter_index_rebuild_on_btree_index_errors() {
17836        // REBUILD on a B-tree index has no semantic meaning in
17837        // v6.0.4 — rejected at the storage layer with `Unsupported`.
17838        let mut e = Engine::new();
17839        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
17840        e.execute("INSERT INTO t VALUES (1)").unwrap();
17841        e.execute("CREATE INDEX t_idx ON t (id)").unwrap();
17842        let err = e.execute("ALTER INDEX t_idx REBUILD").unwrap_err();
17843        assert!(
17844            matches!(&err, EngineError::Storage(StorageError::Unsupported(_))),
17845            "got: {err}"
17846        );
17847    }
17848
17849    #[test]
17850    fn prepared_insert_substitutes_placeholders() {
17851        // v6.1.1: prepare() parses once; execute_prepared() walks the
17852        // AST and replaces $1/$2 with the param Values BEFORE the
17853        // dispatch sees them. Same logical result as a simple-query
17854        // INSERT, but parse happens once per *statement*, not per
17855        // execution.
17856        let mut e = Engine::new();
17857        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
17858            .unwrap();
17859        let stmt = e.prepare("INSERT INTO t VALUES ($1, $2)").unwrap();
17860        for (id, name) in [(1, "alice"), (2, "bob"), (3, "carol")] {
17861            e.execute_prepared(stmt.clone(), &[Value::Int(id), Value::Text(name.into())])
17862                .unwrap();
17863        }
17864        // Read back via simple-query SELECT.
17865        let rows_result = e.execute("SELECT id, name FROM t").unwrap();
17866        let QueryResult::Rows { rows, .. } = rows_result else {
17867            panic!("expected Rows")
17868        };
17869        assert_eq!(rows.len(), 3);
17870    }
17871
17872    #[test]
17873    fn prepared_select_with_placeholder_filters_rows() {
17874        let mut e = Engine::new();
17875        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
17876            .unwrap();
17877        for i in 0..10_i32 {
17878            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
17879                .unwrap();
17880        }
17881        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
17882        let QueryResult::Rows { rows, .. } = e.execute_prepared(stmt, &[Value::Int(35)]).unwrap()
17883        else {
17884            panic!("expected Rows")
17885        };
17886        // v = 35 means i*7 = 35 → i = 5.
17887        assert_eq!(rows.len(), 1);
17888        assert_eq!(rows[0].values[0], Value::Int(5));
17889    }
17890
17891    #[test]
17892    fn prepared_too_few_params_errors() {
17893        let mut e = Engine::new();
17894        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
17895        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
17896        let err = e.execute_prepared(stmt, &[]).unwrap_err();
17897        assert!(
17898            matches!(
17899                &err,
17900                EngineError::Eval(EvalError::PlaceholderOutOfRange { n: 1, bound: 0 })
17901            ),
17902            "got: {err}"
17903        );
17904    }
17905
17906    #[test]
17907    fn bytea_cast_round_trips_text_input() {
17908        // v7.18 — `'hello'::bytea` produces the raw bytes. Closes
17909        // the mailrs D-pre #3 reverse-acceptance gap.
17910        let e = Engine::new();
17911        let r = e.execute_readonly("SELECT 'hello'::bytea").unwrap();
17912        let QueryResult::Rows { rows, .. } = r else {
17913            panic!("expected Rows")
17914        };
17915        assert_eq!(rows.len(), 1);
17916        assert_eq!(rows[0].values[0], Value::Bytes(b"hello".to_vec()));
17917    }
17918
17919    #[test]
17920    fn bytea_cast_pg_escape_hex_form() {
17921        // E'\\xdeadbeef'::bytea — E-string decodes to `\xdeadbeef`
17922        // (literal 10 chars), then ::bytea reads it as PG hex
17923        // form bytea literal → 4 bytes.
17924        let e = Engine::new();
17925        let r = e.execute_readonly(r"SELECT E'\\xdeadbeef'::bytea").unwrap();
17926        let QueryResult::Rows { rows, .. } = r else {
17927            panic!("expected Rows")
17928        };
17929        assert_eq!(
17930            rows[0].values[0],
17931            Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef])
17932        );
17933    }
17934
17935    #[test]
17936    fn bytea_cast_chains_through_octet_length() {
17937        // octet_length('hello'::bytea) → 5. Confirms the cast
17938        // composes inside larger expressions, not just at top
17939        // level.
17940        let e = Engine::new();
17941        let r = e
17942            .execute_readonly("SELECT octet_length('hello'::bytea)")
17943            .unwrap();
17944        let QueryResult::Rows { rows, .. } = r else {
17945            panic!("expected Rows")
17946        };
17947        match &rows[0].values[0] {
17948            Value::Int(n) => assert_eq!(*n, 5),
17949            Value::BigInt(n) => assert_eq!(*n, 5),
17950            other => panic!("expected integer length, got {other:?}"),
17951        }
17952    }
17953
17954    #[test]
17955    fn readonly_prepared_on_snapshot_select_with_placeholder() {
17956        // v7.18 — sqlx Pool fan-out relies on running prepared
17957        // SELECTs against a frozen snapshot without re-entering
17958        // the writer engine. Mirrors the simple-query SELECT path
17959        // in `execute_readonly_on_snapshot` but takes a Statement
17960        // + bound params (the shape sqlx's Execute path produces).
17961        let mut e = Engine::new();
17962        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
17963            .unwrap();
17964        for i in 0..10_i32 {
17965            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
17966                .unwrap();
17967        }
17968        let snapshot = e.clone_snapshot();
17969        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
17970        let QueryResult::Rows { rows, .. } =
17971            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(35)])
17972                .unwrap()
17973        else {
17974            panic!("expected Rows")
17975        };
17976        assert_eq!(rows.len(), 1);
17977        assert_eq!(rows[0].values[0], Value::Int(5));
17978    }
17979
17980    #[test]
17981    fn readonly_prepared_on_snapshot_rejects_writes() {
17982        // DDL / DML prepared statements on the readonly path must
17983        // surface `WriteRequired` so the spg-sqlx connection layer
17984        // routes them to the writer mutex instead of the snapshot.
17985        let mut e = Engine::new();
17986        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
17987        let snapshot = e.clone_snapshot();
17988        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
17989        let err = Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(1)])
17990            .unwrap_err();
17991        assert!(matches!(&err, EngineError::WriteRequired), "got: {err}");
17992    }
17993
17994    #[test]
17995    fn readonly_prepared_on_snapshot_frozen_view() {
17996        // The snapshot reflects engine state at clone_snapshot()
17997        // time. Writes after the snapshot are NOT visible — caller
17998        // takes a fresh snapshot (or `AsyncReadHandle::refresh()`)
17999        // to see them. This is the contract the per-statement
18000        // refresh in spg-sqlx relies on.
18001        let mut e = Engine::new();
18002        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18003        e.execute("INSERT INTO t VALUES (1)").unwrap();
18004        let snapshot = e.clone_snapshot();
18005        e.execute("INSERT INTO t VALUES (2)").unwrap();
18006        let stmt = e.prepare("SELECT id FROM t WHERE id = $1").unwrap();
18007        let QueryResult::Rows { rows, .. } =
18008            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(2)])
18009                .unwrap()
18010        else {
18011            panic!("expected Rows")
18012        };
18013        assert!(rows.is_empty(), "id=2 was inserted after snapshot");
18014    }
18015
18016    #[test]
18017    fn describe_prepared_on_snapshot_resolves_columns() {
18018        // v7.18 — sqlx's Executor::describe path on the readonly
18019        // fan-out needs to resolve column names + types against
18020        // the snapshot's catalog (not the live engine's catalog,
18021        // which may have moved on).
18022        let mut e = Engine::new();
18023        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
18024            .unwrap();
18025        let snapshot = e.clone_snapshot();
18026        let stmt = e.prepare("SELECT id, name FROM t WHERE id = $1").unwrap();
18027        let (_params, cols) = Engine::describe_prepared_on_snapshot(&snapshot, &stmt);
18028        assert_eq!(cols.len(), 2);
18029        assert_eq!(cols[0].name, "id");
18030        assert_eq!(cols[0].ty, DataType::Int);
18031        assert_eq!(cols[1].name, "name");
18032        assert_eq!(cols[1].ty, DataType::Text);
18033    }
18034
18035    #[test]
18036    fn insert_into_half_column_dim_mismatch_errors() {
18037        let mut e = Engine::new();
18038        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
18039            .unwrap();
18040        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
18041        assert!(matches!(
18042            &err,
18043            EngineError::Storage(StorageError::TypeMismatch { .. })
18044        ));
18045    }
18046
18047    #[test]
18048    fn insert_into_sq8_column_dim_mismatch_errors() {
18049        // Dim mismatch falls through the `coerce_value` Vector→Sq8
18050        // arm's guard and surfaces as `TypeMismatch` — the same
18051        // error the F32 path produces today, so client error
18052        // handling stays uniform across encodings.
18053        let mut e = Engine::new();
18054        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
18055        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
18056        assert!(
18057            matches!(
18058                &err,
18059                EngineError::Storage(StorageError::TypeMismatch { .. })
18060            ),
18061            "got: {err}",
18062        );
18063    }
18064
18065    #[test]
18066    fn create_table_duplicate_errors() {
18067        let mut e = Engine::new();
18068        e.execute("CREATE TABLE foo (a INT)").unwrap();
18069        let err = e.execute("CREATE TABLE foo (a INT)").unwrap_err();
18070        assert!(matches!(
18071            err,
18072            EngineError::Storage(StorageError::DuplicateTable { ref name }) if name == "foo"
18073        ));
18074    }
18075
18076    #[test]
18077    fn insert_into_unknown_table_errors() {
18078        let mut e = Engine::new();
18079        let err = e.execute("INSERT INTO ghost VALUES (1)").unwrap_err();
18080        assert!(matches!(
18081            err,
18082            EngineError::Storage(StorageError::TableNotFound { ref name }) if name == "ghost"
18083        ));
18084    }
18085
18086    #[test]
18087    fn insert_happy_path_reports_one_affected() {
18088        let mut e = Engine::new();
18089        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18090        let r = e.execute("INSERT INTO foo VALUES (42)").unwrap();
18091        assert_eq!(unwrap_command_ok(&r), 1);
18092        assert_eq!(e.catalog().get("foo").unwrap().row_count(), 1);
18093    }
18094
18095    #[test]
18096    fn insert_arity_mismatch_propagates() {
18097        let mut e = Engine::new();
18098        e.execute("CREATE TABLE foo (a INT, b TEXT)").unwrap();
18099        let err = e.execute("INSERT INTO foo VALUES (1)").unwrap_err();
18100        assert!(matches!(
18101            err,
18102            EngineError::Storage(StorageError::ArityMismatch { .. })
18103        ));
18104    }
18105
18106    #[test]
18107    fn insert_negative_integer_via_unary_minus() {
18108        let mut e = Engine::new();
18109        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18110        e.execute("INSERT INTO foo VALUES (-7)").unwrap();
18111        let rows = e.catalog().get("foo").unwrap().rows();
18112        assert_eq!(rows[0].values[0], Value::Int(-7));
18113    }
18114
18115    #[test]
18116    fn insert_expression_evaluated_against_empty_context() {
18117        // PG-canonical: INSERT VALUES accepts an arbitrary scalar
18118        // expression. The engine evaluates against an empty row
18119        // context — column references would error, but pure
18120        // arithmetic / function calls are fine.
18121        let mut e = Engine::new();
18122        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
18123        e.execute("INSERT INTO foo VALUES (1 + 2)").unwrap();
18124        let rows = e.catalog().get("foo").unwrap().rows();
18125        assert_eq!(rows[0].values[0], Value::Int(3));
18126    }
18127
18128    #[test]
18129    fn select_star_returns_all_rows_in_insertion_order() {
18130        let mut e = Engine::new();
18131        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT NOT NULL)")
18132            .unwrap();
18133        e.execute("INSERT INTO foo VALUES (1, 'one')").unwrap();
18134        e.execute("INSERT INTO foo VALUES (2, 'two')").unwrap();
18135        e.execute("INSERT INTO foo VALUES (3, 'three')").unwrap();
18136
18137        let r = e.execute("SELECT * FROM foo").unwrap();
18138        let QueryResult::Rows { columns, rows } = r else {
18139            panic!("expected Rows")
18140        };
18141        assert_eq!(columns.len(), 2);
18142        assert_eq!(columns[0].name, "a");
18143        assert_eq!(rows.len(), 3);
18144        assert_eq!(
18145            rows[1].values,
18146            vec![Value::Int(2), Value::Text("two".into())]
18147        );
18148    }
18149
18150    #[test]
18151    fn select_star_on_empty_table_returns_zero_rows() {
18152        let mut e = Engine::new();
18153        e.execute("CREATE TABLE foo (a INT)").unwrap();
18154        let r = e.execute("SELECT * FROM foo").unwrap();
18155        match r {
18156            QueryResult::Rows { rows, .. } => assert!(rows.is_empty()),
18157            QueryResult::CommandOk { .. } => panic!("expected Rows"),
18158        }
18159    }
18160
18161    // --- v0.4: WHERE + projection ------------------------------------------
18162
18163    fn make_three_row_users(e: &mut Engine) {
18164        e.execute("CREATE TABLE users (id INT NOT NULL, name TEXT NOT NULL, score INT)")
18165            .unwrap();
18166        e.execute("INSERT INTO users VALUES (1, 'alice', 90)")
18167            .unwrap();
18168        e.execute("INSERT INTO users VALUES (2, 'bob', NULL)")
18169            .unwrap();
18170        e.execute("INSERT INTO users VALUES (3, 'cara', 70)")
18171            .unwrap();
18172    }
18173
18174    fn unwrap_rows(r: QueryResult) -> (Vec<ColumnSchema>, Vec<Row>) {
18175        match r {
18176            QueryResult::Rows { columns, rows } => (columns, rows),
18177            QueryResult::CommandOk { .. } => panic!("expected Rows"),
18178        }
18179    }
18180
18181    #[test]
18182    fn where_filter_passes_only_true_rows() {
18183        let mut e = Engine::new();
18184        make_three_row_users(&mut e);
18185        let r = e.execute("SELECT * FROM users WHERE id > 1").unwrap();
18186        let (_, rows) = unwrap_rows(r);
18187        assert_eq!(rows.len(), 2);
18188        assert_eq!(rows[0].values[0], Value::Int(2));
18189        assert_eq!(rows[1].values[0], Value::Int(3));
18190    }
18191
18192    #[test]
18193    fn where_with_null_result_filters_out_row() {
18194        let mut e = Engine::new();
18195        make_three_row_users(&mut e);
18196        // score is NULL for bob → score > 80 is NULL → row excluded
18197        let r = e.execute("SELECT * FROM users WHERE score > 80").unwrap();
18198        let (_, rows) = unwrap_rows(r);
18199        assert_eq!(rows.len(), 1);
18200        assert_eq!(rows[0].values[1], Value::Text("alice".into()));
18201    }
18202
18203    #[test]
18204    fn projection_named_columns() {
18205        let mut e = Engine::new();
18206        make_three_row_users(&mut e);
18207        let r = e.execute("SELECT name, score FROM users").unwrap();
18208        let (cols, rows) = unwrap_rows(r);
18209        assert_eq!(cols.len(), 2);
18210        assert_eq!(cols[0].name, "name");
18211        assert_eq!(cols[1].name, "score");
18212        assert_eq!(rows.len(), 3);
18213        assert_eq!(
18214            rows[0].values,
18215            vec![Value::Text("alice".into()), Value::Int(90)]
18216        );
18217    }
18218
18219    #[test]
18220    fn projection_with_column_alias() {
18221        let mut e = Engine::new();
18222        make_three_row_users(&mut e);
18223        let r = e
18224            .execute("SELECT name AS who FROM users WHERE id = 1")
18225            .unwrap();
18226        let (cols, rows) = unwrap_rows(r);
18227        assert_eq!(cols[0].name, "who");
18228        assert_eq!(rows.len(), 1);
18229        assert_eq!(rows[0].values[0], Value::Text("alice".into()));
18230    }
18231
18232    #[test]
18233    fn qualified_column_with_table_alias_resolves() {
18234        let mut e = Engine::new();
18235        make_three_row_users(&mut e);
18236        let r = e
18237            .execute("SELECT u.id, u.name FROM users AS u WHERE u.id < 3")
18238            .unwrap();
18239        let (cols, rows) = unwrap_rows(r);
18240        assert_eq!(cols.len(), 2);
18241        assert_eq!(rows.len(), 2);
18242    }
18243
18244    #[test]
18245    fn qualified_column_with_wrong_alias_errors() {
18246        let mut e = Engine::new();
18247        make_three_row_users(&mut e);
18248        let err = e.execute("SELECT x.id FROM users AS u").unwrap_err();
18249        assert!(matches!(
18250            err,
18251            EngineError::Eval(EvalError::UnknownQualifier { ref qualifier }) if qualifier == "x"
18252        ));
18253    }
18254
18255    #[test]
18256    fn select_unknown_column_errors_in_projection() {
18257        let mut e = Engine::new();
18258        make_three_row_users(&mut e);
18259        let err = e.execute("SELECT ghost FROM users").unwrap_err();
18260        assert!(matches!(
18261            err,
18262            EngineError::Eval(EvalError::ColumnNotFound { ref name }) if name == "ghost"
18263        ));
18264    }
18265
18266    #[test]
18267    fn where_unknown_column_errors() {
18268        let mut e = Engine::new();
18269        make_three_row_users(&mut e);
18270        let err = e
18271            .execute("SELECT * FROM users WHERE ghost = 1")
18272            .unwrap_err();
18273        assert!(matches!(
18274            err,
18275            EngineError::Eval(EvalError::ColumnNotFound { .. })
18276        ));
18277    }
18278
18279    #[test]
18280    fn expression_projection_evaluates_and_renders() {
18281        // Compound expressions in the SELECT list are evaluated per row;
18282        // the output column is typed TEXT, name defaults to the expression.
18283        let mut e = Engine::new();
18284        e.execute("CREATE TABLE t (a INT NOT NULL)").unwrap();
18285        e.execute("INSERT INTO t VALUES (3)").unwrap();
18286        let (_, rows) = unwrap_rows(e.execute("SELECT 1 + 2 FROM t").unwrap());
18287        assert_eq!(rows.len(), 1);
18288        // The expression evaluates to integer 3; rendered as the cell value
18289        // (storage::Value::Int(3) since arithmetic kept ints).
18290        assert_eq!(rows[0].values[0], Value::Int(3));
18291    }
18292
18293    #[test]
18294    fn select_unknown_table_errors() {
18295        let mut e = Engine::new();
18296        let err = e.execute("SELECT * FROM ghost").unwrap_err();
18297        assert!(matches!(
18298            err,
18299            EngineError::Storage(StorageError::TableNotFound { .. })
18300        ));
18301    }
18302
18303    #[test]
18304    fn invalid_sql_returns_parse_error() {
18305        // v4.4: UPDATE is now real SQL, so use a true syntactic
18306        // garbage payload for the parse-error path.
18307        let mut e = Engine::new();
18308        let err = e.execute("THIS_IS_NOT_A_KEYWORD foo bar baz").unwrap_err();
18309        assert!(matches!(err, EngineError::Parse(_)));
18310    }
18311
18312    // --- v0.8 CREATE INDEX + index seek ------------------------------------
18313
18314    #[test]
18315    fn create_index_registers_on_table() {
18316        let mut e = Engine::new();
18317        make_three_row_users(&mut e);
18318        e.execute("CREATE INDEX by_name ON users (name)").unwrap();
18319        let t = e.catalog().get("users").unwrap();
18320        assert_eq!(t.indices().len(), 1);
18321        assert_eq!(t.indices()[0].name, "by_name");
18322    }
18323
18324    #[test]
18325    fn create_index_on_unknown_table_errors() {
18326        let mut e = Engine::new();
18327        let err = e.execute("CREATE INDEX i ON ghost (a)").unwrap_err();
18328        assert!(matches!(
18329            err,
18330            EngineError::Storage(StorageError::TableNotFound { .. })
18331        ));
18332    }
18333
18334    #[test]
18335    fn create_index_on_unknown_column_errors() {
18336        let mut e = Engine::new();
18337        make_three_row_users(&mut e);
18338        let err = e.execute("CREATE INDEX i ON users (ghost)").unwrap_err();
18339        assert!(matches!(
18340            err,
18341            EngineError::Storage(StorageError::ColumnNotFound { .. })
18342        ));
18343    }
18344
18345    #[test]
18346    fn select_eq_uses_index_returns_same_rows_as_scan() {
18347        // Build two engines: one with an index, one without. Same query →
18348        // same row set (index is a planner optimisation, not a semantic
18349        // change).
18350        let mut without = Engine::new();
18351        make_three_row_users(&mut without);
18352        let mut with = Engine::new();
18353        make_three_row_users(&mut with);
18354        with.execute("CREATE INDEX by_id ON users (id)").unwrap();
18355
18356        let q = "SELECT * FROM users WHERE id = 2";
18357        let (_, no_idx_rows) = unwrap_rows(without.execute(q).unwrap());
18358        let (_, idx_rows) = unwrap_rows(with.execute(q).unwrap());
18359        assert_eq!(no_idx_rows, idx_rows);
18360        assert_eq!(idx_rows.len(), 1);
18361    }
18362
18363    #[test]
18364    fn select_eq_with_no_matching_index_value_returns_empty() {
18365        let mut e = Engine::new();
18366        make_three_row_users(&mut e);
18367        e.execute("CREATE INDEX by_id ON users (id)").unwrap();
18368        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM users WHERE id = 999").unwrap());
18369        assert_eq!(rows.len(), 0);
18370    }
18371
18372    // --- v0.9 transactions -------------------------------------------------
18373
18374    #[test]
18375    fn begin_sets_in_transaction_flag() {
18376        let mut e = Engine::new();
18377        assert!(!e.in_transaction());
18378        e.execute("BEGIN").unwrap();
18379        assert!(e.in_transaction());
18380    }
18381
18382    #[test]
18383    fn double_begin_errors() {
18384        let mut e = Engine::new();
18385        e.execute("BEGIN").unwrap();
18386        let err = e.execute("BEGIN").unwrap_err();
18387        assert_eq!(err, EngineError::TransactionAlreadyOpen);
18388    }
18389
18390    #[test]
18391    fn commit_without_begin_errors() {
18392        let mut e = Engine::new();
18393        let err = e.execute("COMMIT").unwrap_err();
18394        assert_eq!(err, EngineError::NoActiveTransaction);
18395    }
18396
18397    #[test]
18398    fn rollback_without_begin_errors() {
18399        let mut e = Engine::new();
18400        let err = e.execute("ROLLBACK").unwrap_err();
18401        assert_eq!(err, EngineError::NoActiveTransaction);
18402    }
18403
18404    #[test]
18405    fn commit_applies_shadow_to_committed_catalog() {
18406        let mut e = Engine::new();
18407        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18408        e.execute("BEGIN").unwrap();
18409        e.execute("INSERT INTO t VALUES (1)").unwrap();
18410        e.execute("INSERT INTO t VALUES (2)").unwrap();
18411        e.execute("COMMIT").unwrap();
18412        assert!(!e.in_transaction());
18413        assert_eq!(e.catalog().get("t").unwrap().row_count(), 2);
18414    }
18415
18416    #[test]
18417    fn rollback_discards_shadow() {
18418        let mut e = Engine::new();
18419        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18420        e.execute("BEGIN").unwrap();
18421        e.execute("INSERT INTO t VALUES (1)").unwrap();
18422        e.execute("INSERT INTO t VALUES (2)").unwrap();
18423        e.execute("ROLLBACK").unwrap();
18424        assert!(!e.in_transaction());
18425        assert_eq!(e.catalog().get("t").unwrap().row_count(), 0);
18426    }
18427
18428    #[test]
18429    fn select_during_tx_sees_uncommitted_writes_own_session() {
18430        // The shadow catalog is read by SELECTs while a TX is open — the
18431        // session can see its own pending writes.
18432        let mut e = Engine::new();
18433        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18434        e.execute("BEGIN").unwrap();
18435        e.execute("INSERT INTO t VALUES (42)").unwrap();
18436        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM t").unwrap());
18437        assert_eq!(rows.len(), 1);
18438        assert_eq!(rows[0].values[0], Value::Int(42));
18439    }
18440
18441    #[test]
18442    fn snapshot_with_no_users_is_bare_catalog_format() {
18443        let mut e = Engine::new();
18444        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18445        let bytes = e.snapshot();
18446        assert_eq!(
18447            &bytes[..8],
18448            b"SPGDB001",
18449            "must be the bare v3.x catalog magic"
18450        );
18451        let e2 = Engine::restore_envelope(&bytes).unwrap();
18452        assert!(e2.users().is_empty());
18453        assert_eq!(e2.catalog().table_count(), 1);
18454    }
18455
18456    #[test]
18457    fn snapshot_with_users_round_trips_both_via_envelope() {
18458        let mut e = Engine::new();
18459        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18460        e.create_user("alice", "pw1", Role::Admin, [9; 16]).unwrap();
18461        e.create_user("bob", "pw2", Role::ReadOnly, [5; 16])
18462            .unwrap();
18463        let bytes = e.snapshot();
18464        assert_eq!(&bytes[..8], b"SPGENV01", "must be the v4.1 envelope magic");
18465        let e2 = Engine::restore_envelope(&bytes).unwrap();
18466        assert_eq!(e2.users().len(), 2);
18467        assert_eq!(e2.verify_user("alice", "pw1"), Some(Role::Admin));
18468        assert_eq!(e2.verify_user("bob", "pw2"), Some(Role::ReadOnly));
18469        assert_eq!(e2.verify_user("alice", "wrong"), None);
18470        assert_eq!(e2.catalog().table_count(), 1);
18471    }
18472
18473    #[test]
18474    fn ddl_inside_tx_also_rolled_back() {
18475        let mut e = Engine::new();
18476        e.execute("BEGIN").unwrap();
18477        e.execute("CREATE TABLE t (v INT)").unwrap();
18478        // Visible inside the TX.
18479        e.execute("SELECT * FROM t").unwrap();
18480        e.execute("ROLLBACK").unwrap();
18481        // Gone after rollback.
18482        let err = e.execute("SELECT * FROM t").unwrap_err();
18483        assert!(matches!(
18484            err,
18485            EngineError::Storage(StorageError::TableNotFound { .. })
18486        ));
18487    }
18488
18489    // ── v6.1.2: CREATE / DROP PUBLICATION (engine-side) ──────
18490
18491    #[test]
18492    fn create_publication_lands_in_catalog() {
18493        let mut e = Engine::new();
18494        assert!(e.publications().is_empty());
18495        e.execute("CREATE PUBLICATION pub_a").unwrap();
18496        assert_eq!(e.publications().len(), 1);
18497        assert!(e.publications().contains("pub_a"));
18498    }
18499
18500    #[test]
18501    fn create_publication_duplicate_errors() {
18502        let mut e = Engine::new();
18503        e.execute("CREATE PUBLICATION pub_a").unwrap();
18504        let err = e.execute("CREATE PUBLICATION pub_a").unwrap_err();
18505        assert!(
18506            alloc::format!("{err:?}").contains("DuplicateName"),
18507            "got {err:?}"
18508        );
18509    }
18510
18511    #[test]
18512    fn drop_publication_silent_when_absent() {
18513        let mut e = Engine::new();
18514        // PG-compatible: DROP a publication that doesn't exist
18515        // succeeds (no-op) but reports zero affected.
18516        let r = e.execute("DROP PUBLICATION nope").unwrap();
18517        match r {
18518            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
18519            other => panic!("expected CommandOk, got {other:?}"),
18520        }
18521    }
18522
18523    #[test]
18524    fn drop_publication_present_reports_one_affected() {
18525        let mut e = Engine::new();
18526        e.execute("CREATE PUBLICATION pub_a").unwrap();
18527        let r = e.execute("DROP PUBLICATION pub_a").unwrap();
18528        match r {
18529            QueryResult::CommandOk {
18530                affected,
18531                modified_catalog,
18532            } => {
18533                assert_eq!(affected, 1);
18534                assert!(modified_catalog);
18535            }
18536            other => panic!("expected CommandOk, got {other:?}"),
18537        }
18538        assert!(e.publications().is_empty());
18539    }
18540
18541    #[test]
18542    fn publications_persist_across_snapshot_restore() {
18543        // The persist-across-restart ship-gate at the engine layer —
18544        // snapshot → restore_envelope round trip must preserve the
18545        // publication catalog. The spg-server e2e covers the
18546        // process-restart variant.
18547        let mut e = Engine::new();
18548        e.execute("CREATE PUBLICATION pub_a").unwrap();
18549        e.execute("CREATE PUBLICATION pub_b FOR ALL TABLES")
18550            .unwrap();
18551        let snap = e.snapshot();
18552        let e2 = Engine::restore_envelope(&snap).unwrap();
18553        assert_eq!(e2.publications().len(), 2);
18554        assert!(e2.publications().contains("pub_a"));
18555        assert!(e2.publications().contains("pub_b"));
18556    }
18557
18558    #[test]
18559    fn create_publication_allowed_inside_transaction() {
18560        // v6.1.4 dropped the v6.1.2 in-TX guard — PG allows
18561        // CREATE PUBLICATION inside a TX and the auto-commit
18562        // wrap path needs the same allowance.
18563        let mut e = Engine::new();
18564        e.execute("BEGIN").unwrap();
18565        e.execute("CREATE PUBLICATION pub_a").unwrap();
18566        e.execute("COMMIT").unwrap();
18567        assert!(e.publications().contains("pub_a"));
18568    }
18569
18570    // ── v6.1.3: SHOW PUBLICATIONS + FOR-list variants ───────
18571
18572    #[test]
18573    fn create_publication_for_table_list_lands_with_scope() {
18574        let mut e = Engine::new();
18575        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
18576        e.execute("CREATE TABLE t2 (id INT NOT NULL)").unwrap();
18577        e.execute("CREATE PUBLICATION pub_a FOR TABLE t1, t2")
18578            .unwrap();
18579        let scope = e.publications().get("pub_a").cloned();
18580        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = scope else {
18581            panic!("expected ForTables scope, got {scope:?}")
18582        };
18583        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
18584    }
18585
18586    #[test]
18587    fn create_publication_all_tables_except_lands_with_scope() {
18588        let mut e = Engine::new();
18589        e.execute("CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t3")
18590            .unwrap();
18591        let scope = e.publications().get("pub_a").cloned();
18592        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = scope else {
18593            panic!("expected AllTablesExcept scope, got {scope:?}")
18594        };
18595        assert_eq!(ts, alloc::vec!["t3".to_string()]);
18596    }
18597
18598    #[test]
18599    fn show_publications_empty_returns_zero_rows() {
18600        let e = Engine::new();
18601        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
18602        let QueryResult::Rows { rows, columns } = r else {
18603            panic!()
18604        };
18605        assert!(rows.is_empty());
18606        assert_eq!(columns.len(), 3);
18607        assert_eq!(columns[0].name, "name");
18608        assert_eq!(columns[1].name, "scope");
18609        assert_eq!(columns[2].name, "table_count");
18610    }
18611
18612    #[test]
18613    fn show_publications_returns_one_row_per_publication_ordered_by_name() {
18614        let mut e = Engine::new();
18615        e.execute("CREATE PUBLICATION z_pub").unwrap();
18616        e.execute("CREATE PUBLICATION a_pub FOR TABLE t1, t2")
18617            .unwrap();
18618        e.execute("CREATE PUBLICATION m_pub FOR ALL TABLES EXCEPT bad")
18619            .unwrap();
18620        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
18621        let QueryResult::Rows { rows, .. } = r else {
18622            panic!()
18623        };
18624        assert_eq!(rows.len(), 3);
18625        // Alphabetical order: a_pub, m_pub, z_pub.
18626        let names: Vec<&str> = rows
18627            .iter()
18628            .map(|r| {
18629                if let Value::Text(s) = &r.values[0] {
18630                    s.as_str()
18631                } else {
18632                    panic!()
18633                }
18634            })
18635            .collect();
18636        assert_eq!(names, alloc::vec!["a_pub", "m_pub", "z_pub"]);
18637        // Row 0 — a_pub scope summary + table_count = 2.
18638        match &rows[0].values[1] {
18639            Value::Text(s) => assert_eq!(s, "FOR TABLE t1, t2"),
18640            other => panic!("expected Text, got {other:?}"),
18641        }
18642        assert_eq!(rows[0].values[2], Value::Int(2));
18643        // Row 1 — m_pub.
18644        match &rows[1].values[1] {
18645            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES EXCEPT bad"),
18646            other => panic!("expected Text, got {other:?}"),
18647        }
18648        assert_eq!(rows[1].values[2], Value::Int(1));
18649        // Row 2 — z_pub (AllTables → NULL count).
18650        match &rows[2].values[1] {
18651            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES"),
18652            other => panic!("expected Text, got {other:?}"),
18653        }
18654        assert_eq!(rows[2].values[2], Value::Null);
18655    }
18656
18657    #[test]
18658    fn for_list_scopes_persist_across_snapshot() {
18659        // The v6.1.2 envelope-v3 round-trip exercised AllTables;
18660        // v6.1.3 needs the scope-1 / scope-2 tags to survive too.
18661        let mut e = Engine::new();
18662        e.execute("CREATE PUBLICATION p1 FOR TABLE t1, t2").unwrap();
18663        e.execute("CREATE PUBLICATION p2 FOR ALL TABLES EXCEPT bad, worse")
18664            .unwrap();
18665        let snap = e.snapshot();
18666        let e2 = Engine::restore_envelope(&snap).unwrap();
18667        assert_eq!(e2.publications().len(), 2);
18668        let p1 = e2.publications().get("p1").cloned();
18669        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = p1 else {
18670            panic!("p1 scope lost: {p1:?}")
18671        };
18672        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
18673        let p2 = e2.publications().get("p2").cloned();
18674        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = p2 else {
18675            panic!("p2 scope lost: {p2:?}")
18676        };
18677        assert_eq!(ts, alloc::vec!["bad".to_string(), "worse".to_string()]);
18678    }
18679
18680    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW + envelope v4 ─
18681
18682    #[test]
18683    fn create_subscription_lands_in_catalog_with_defaults() {
18684        let mut e = Engine::new();
18685        e.execute(
18686            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
18687        )
18688        .unwrap();
18689        let s = e.subscriptions().get("sub_a").cloned().expect("present");
18690        assert_eq!(s.conn_str, "host=127.0.0.1 port=20002");
18691        assert_eq!(s.publications, alloc::vec!["pub_a".to_string()]);
18692        assert!(s.enabled);
18693        assert_eq!(s.last_received_pos, 0);
18694    }
18695
18696    #[test]
18697    fn create_subscription_duplicate_name_errors() {
18698        let mut e = Engine::new();
18699        e.execute("CREATE SUBSCRIPTION s CONNECTION 'host=x' PUBLICATION p")
18700            .unwrap();
18701        let err = e
18702            .execute("CREATE SUBSCRIPTION s CONNECTION 'host=y' PUBLICATION p")
18703            .unwrap_err();
18704        assert!(
18705            alloc::format!("{err:?}").contains("DuplicateName"),
18706            "got {err:?}"
18707        );
18708    }
18709
18710    #[test]
18711    fn drop_subscription_silent_when_absent() {
18712        let mut e = Engine::new();
18713        let r = e.execute("DROP SUBSCRIPTION never").unwrap();
18714        match r {
18715            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
18716            other => panic!("expected CommandOk, got {other:?}"),
18717        }
18718    }
18719
18720    #[test]
18721    fn subscription_advance_updates_last_pos_monotone() {
18722        let mut e = Engine::new();
18723        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
18724            .unwrap();
18725        assert!(e.subscription_advance("s", 100));
18726        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
18727        assert!(e.subscription_advance("s", 50)); // stale → ignored
18728        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
18729        assert!(e.subscription_advance("s", 200));
18730        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 200);
18731        assert!(!e.subscription_advance("missing", 1));
18732    }
18733
18734    #[test]
18735    fn show_subscriptions_returns_rows_ordered_by_name() {
18736        let mut e = Engine::new();
18737        e.execute("CREATE SUBSCRIPTION z_sub CONNECTION 'h=x' PUBLICATION p1, p2")
18738            .unwrap();
18739        e.execute("CREATE SUBSCRIPTION a_sub CONNECTION 'h=y' PUBLICATION p3")
18740            .unwrap();
18741        let r = e.execute_readonly("SHOW SUBSCRIPTIONS").unwrap();
18742        let QueryResult::Rows { rows, columns } = r else {
18743            panic!()
18744        };
18745        assert_eq!(rows.len(), 2);
18746        assert_eq!(columns.len(), 5);
18747        assert_eq!(columns[0].name, "name");
18748        assert_eq!(columns[4].name, "last_received_pos");
18749        // Alphabetical: a_sub, z_sub.
18750        let names: Vec<&str> = rows
18751            .iter()
18752            .map(|r| {
18753                if let Value::Text(s) = &r.values[0] {
18754                    s.as_str()
18755                } else {
18756                    panic!()
18757                }
18758            })
18759            .collect();
18760        assert_eq!(names, alloc::vec!["a_sub", "z_sub"]);
18761        // Row 0: a_sub
18762        assert_eq!(rows[0].values[1], Value::Text("h=y".to_string()));
18763        assert_eq!(rows[0].values[2], Value::Text("p3".to_string()));
18764        assert_eq!(rows[0].values[3], Value::Bool(true));
18765        assert_eq!(rows[0].values[4], Value::BigInt(0));
18766        // Row 1: z_sub — publications join with ", "
18767        assert_eq!(rows[1].values[2], Value::Text("p1, p2".to_string()));
18768    }
18769
18770    #[test]
18771    fn subscriptions_persist_across_snapshot_envelope_v4() {
18772        let mut e = Engine::new();
18773        e.execute("CREATE SUBSCRIPTION s1 CONNECTION 'h=A' PUBLICATION p1, p2")
18774            .unwrap();
18775        e.execute("CREATE SUBSCRIPTION s2 CONNECTION 'h=B' PUBLICATION p3")
18776            .unwrap();
18777        e.subscription_advance("s2", 42);
18778        let snap = e.snapshot();
18779        let e2 = Engine::restore_envelope(&snap).unwrap();
18780        assert_eq!(e2.subscriptions().len(), 2);
18781        let s1 = e2.subscriptions().get("s1").unwrap();
18782        assert_eq!(s1.conn_str, "h=A");
18783        assert_eq!(
18784            s1.publications,
18785            alloc::vec!["p1".to_string(), "p2".to_string()]
18786        );
18787        assert_eq!(s1.last_received_pos, 0);
18788        let s2 = e2.subscriptions().get("s2").unwrap();
18789        assert_eq!(s2.last_received_pos, 42);
18790    }
18791
18792    #[test]
18793    fn v3_envelope_loads_with_empty_subscriptions() {
18794        // v3 snapshot (publications-only). Forge it by hand so we
18795        // verify v6.1.4 readers don't panic — they must surface
18796        // empty subscriptions and a populated publication table.
18797        let mut e = Engine::new();
18798        e.execute("CREATE PUBLICATION pub_legacy").unwrap();
18799        let catalog = e.catalog.serialize();
18800        let users = crate::users::serialize_users(&e.users);
18801        let pubs = e.publications.serialize();
18802        let mut buf = Vec::new();
18803        buf.extend_from_slice(b"SPGENV01");
18804        buf.push(3u8); // v3
18805        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
18806        buf.extend_from_slice(&catalog);
18807        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
18808        buf.extend_from_slice(&users);
18809        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
18810        buf.extend_from_slice(&pubs);
18811        let crc = spg_crypto::crc32::crc32(&buf);
18812        buf.extend_from_slice(&crc.to_le_bytes());
18813
18814        let e2 = Engine::restore_envelope(&buf).expect("v3 envelope restores under v4 reader");
18815        assert!(e2.subscriptions().is_empty());
18816        assert!(e2.publications().contains("pub_legacy"));
18817    }
18818
18819    #[test]
18820    fn create_subscription_allowed_inside_transaction() {
18821        let mut e = Engine::new();
18822        e.execute("BEGIN").unwrap();
18823        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
18824            .unwrap();
18825        e.execute("COMMIT").unwrap();
18826        assert!(e.subscriptions().contains("s"));
18827    }
18828
18829    // ── v6.2.0: ANALYZE + spg_statistic + envelope v5 ──────────
18830    #[test]
18831    fn analyze_populates_histogram_bounds() {
18832        let mut e = Engine::new();
18833        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT)")
18834            .unwrap();
18835        for i in 0..50 {
18836            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'name{i}')"))
18837                .unwrap();
18838        }
18839        e.execute("ANALYZE t").unwrap();
18840        let stats = e.statistics();
18841        let id_stats = stats.get("t", "id").unwrap();
18842        assert!(id_stats.histogram_bounds.len() >= 2);
18843        assert_eq!(id_stats.histogram_bounds.first().unwrap(), "0");
18844        assert_eq!(id_stats.histogram_bounds.last().unwrap(), "49");
18845        assert!((id_stats.null_frac - 0.0).abs() < 1e-6);
18846        assert_eq!(id_stats.n_distinct, 50);
18847    }
18848
18849    #[test]
18850    fn reanalyze_overwrites_prior_stats() {
18851        let mut e = Engine::new();
18852        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18853        for i in 0..10 {
18854            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18855                .unwrap();
18856        }
18857        e.execute("ANALYZE t").unwrap();
18858        let n1 = e.statistics().get("t", "id").unwrap().n_distinct;
18859        assert_eq!(n1, 10);
18860        for i in 10..30 {
18861            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18862                .unwrap();
18863        }
18864        e.execute("ANALYZE t").unwrap();
18865        let n2 = e.statistics().get("t", "id").unwrap().n_distinct;
18866        assert_eq!(n2, 30);
18867    }
18868
18869    #[test]
18870    fn analyze_unknown_table_errors() {
18871        let mut e = Engine::new();
18872        let err = e.execute("ANALYZE nonexistent").unwrap_err();
18873        assert!(matches!(
18874            err,
18875            EngineError::Storage(StorageError::TableNotFound { .. })
18876        ));
18877    }
18878
18879    #[test]
18880    fn bare_analyze_covers_all_user_tables() {
18881        let mut e = Engine::new();
18882        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
18883        e.execute("CREATE TABLE t2 (name TEXT NOT NULL)").unwrap();
18884        e.execute("INSERT INTO t1 VALUES (1)").unwrap();
18885        e.execute("INSERT INTO t2 VALUES ('alice')").unwrap();
18886        let r = e.execute("ANALYZE").unwrap();
18887        match r {
18888            QueryResult::CommandOk {
18889                affected,
18890                modified_catalog,
18891            } => {
18892                assert_eq!(affected, 2);
18893                assert!(modified_catalog);
18894            }
18895            other => panic!("expected CommandOk, got {other:?}"),
18896        }
18897        assert!(e.statistics().get("t1", "id").is_some());
18898        assert!(e.statistics().get("t2", "name").is_some());
18899    }
18900
18901    #[test]
18902    fn select_from_spg_statistic_returns_rows_per_column() {
18903        let mut e = Engine::new();
18904        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
18905            .unwrap();
18906        e.execute("INSERT INTO t VALUES (1, 'a')").unwrap();
18907        e.execute("INSERT INTO t VALUES (2, 'b')").unwrap();
18908        e.execute("ANALYZE t").unwrap();
18909        let r = e.execute_readonly("SELECT * FROM spg_statistic").unwrap();
18910        let QueryResult::Rows { rows, columns } = r else {
18911            panic!()
18912        };
18913        // v6.7.0 — spg_statistic gained a `cold_row_count` column.
18914        assert_eq!(columns.len(), 6);
18915        assert_eq!(columns[0].name, "table_name");
18916        assert_eq!(columns[4].name, "histogram_bounds");
18917        assert_eq!(columns[5].name, "cold_row_count");
18918        assert_eq!(rows.len(), 2, "one row per column of t");
18919        // Sorted by (table_name, column_name).
18920        match (&rows[0].values[0], &rows[0].values[1]) {
18921            (Value::Text(t), Value::Text(c)) => {
18922                assert_eq!(t, "t");
18923                // BTreeMap orders (table, column); columns "id" < "label".
18924                assert_eq!(c, "id");
18925            }
18926            _ => panic!(),
18927        }
18928    }
18929
18930    #[test]
18931    fn analyze_skips_vector_columns() {
18932        // Vector columns have their own stats shape (HNSW graph);
18933        // ANALYZE leaves them out of spg_statistic.
18934        let mut e = Engine::new();
18935        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
18936            .unwrap();
18937        e.execute("INSERT INTO t VALUES (1, [1, 2, 3])").unwrap();
18938        e.execute("ANALYZE t").unwrap();
18939        assert!(e.statistics().get("t", "id").is_some());
18940        assert!(e.statistics().get("t", "v").is_none());
18941    }
18942
18943    #[test]
18944    fn statistics_persist_across_envelope_v5_round_trip() {
18945        let mut e = Engine::new();
18946        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18947        for i in 0..20 {
18948            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18949                .unwrap();
18950        }
18951        e.execute("ANALYZE").unwrap();
18952        let snap = e.snapshot();
18953        let e2 = Engine::restore_envelope(&snap).unwrap();
18954        let s = e2.statistics().get("t", "id").unwrap();
18955        assert_eq!(s.n_distinct, 20);
18956    }
18957
18958    // ── v6.2.1 auto-analyze threshold ───────────────────────────
18959
18960    #[test]
18961    fn auto_analyze_threshold_fires_after_10pct_of_min_rows_on_small_table() {
18962        // For a table with 0 rows then 10 inserts → modified=10,
18963        // row_count=10. Threshold = 0.1 × max(10, 100) = 10. So
18964        // after the 10th INSERT the threshold is met.
18965        let mut e = Engine::new();
18966        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18967        for i in 0..9 {
18968            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18969                .unwrap();
18970        }
18971        assert!(e.tables_needing_analyze().is_empty(), "9 < threshold");
18972        e.execute("INSERT INTO t VALUES (9)").unwrap();
18973        let needs = e.tables_needing_analyze();
18974        assert_eq!(needs, alloc::vec!["t".to_string()]);
18975    }
18976
18977    #[test]
18978    fn auto_analyze_threshold_uses_10pct_of_row_count_for_large_tables() {
18979        // After ANALYZE on 1000 rows, threshold = 0.1 × row_count.
18980        // Each new INSERT bumps both modified and row_count, so to
18981        // trigger from N=1000 we need modifications ≥ 0.1 × (1000+M),
18982        // i.e. M ≥ 112. The test inserts 50 (no fire), then 150
18983        // more (200 total mods, row_count=1200, threshold=120 → fire).
18984        let mut e = Engine::new();
18985        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18986        for i in 0..1000 {
18987            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18988                .unwrap();
18989        }
18990        e.execute("ANALYZE t").unwrap();
18991        assert!(e.tables_needing_analyze().is_empty(), "fresh ANALYZE");
18992        for i in 1000..1050 {
18993            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18994                .unwrap();
18995        }
18996        assert!(
18997            e.tables_needing_analyze().is_empty(),
18998            "50 inserts < threshold of ~105"
18999        );
19000        for i in 1050..1200 {
19001            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19002                .unwrap();
19003        }
19004        assert_eq!(
19005            e.tables_needing_analyze(),
19006            alloc::vec!["t".to_string()],
19007            "200 inserts > 0.1 × 1200 threshold"
19008        );
19009    }
19010
19011    #[test]
19012    fn auto_analyze_threshold_resets_after_analyze() {
19013        let mut e = Engine::new();
19014        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
19015        for i in 0..200 {
19016            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
19017                .unwrap();
19018        }
19019        assert!(!e.tables_needing_analyze().is_empty());
19020        e.execute("ANALYZE").unwrap();
19021        assert!(
19022            e.tables_needing_analyze().is_empty(),
19023            "ANALYZE must reset the counter"
19024        );
19025    }
19026
19027    #[test]
19028    fn auto_analyze_threshold_tracks_updates_and_deletes() {
19029        let mut e = Engine::new();
19030        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
19031            .unwrap();
19032        for i in 0..50 {
19033            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'x')"))
19034                .unwrap();
19035        }
19036        e.execute("ANALYZE t").unwrap();
19037        // UPDATE 20 rows + DELETE 5 → modified=25. Threshold = 0.1
19038        // × max(50, 100) = 10. So 25 >= 10 → trigger.
19039        e.execute("UPDATE t SET label = 'y' WHERE id < 20").unwrap();
19040        e.execute("DELETE FROM t WHERE id >= 45").unwrap();
19041        assert_eq!(e.tables_needing_analyze(), alloc::vec!["t".to_string()]);
19042    }
19043
19044    #[test]
19045    fn v4_envelope_loads_with_empty_statistics() {
19046        // Forge a v4 envelope by hand: catalog + users + pubs +
19047        // subs trailer, no statistics. A v6.2.0 reader must accept
19048        // it and surface an empty Statistics.
19049        let mut e = Engine::new();
19050        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
19051            .unwrap();
19052        let catalog = e.catalog.serialize();
19053        let users = crate::users::serialize_users(&e.users);
19054        let pubs = e.publications.serialize();
19055        let subs = e.subscriptions.serialize();
19056        let mut buf = Vec::new();
19057        buf.extend_from_slice(b"SPGENV01");
19058        buf.push(4u8);
19059        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
19060        buf.extend_from_slice(&catalog);
19061        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
19062        buf.extend_from_slice(&users);
19063        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
19064        buf.extend_from_slice(&pubs);
19065        buf.extend_from_slice(&u32::try_from(subs.len()).unwrap().to_le_bytes());
19066        buf.extend_from_slice(&subs);
19067        let crc = spg_crypto::crc32::crc32(&buf);
19068        buf.extend_from_slice(&crc.to_le_bytes());
19069        let e2 = Engine::restore_envelope(&buf).expect("v4 envelope restores");
19070        assert!(e2.statistics().is_empty());
19071    }
19072
19073    #[test]
19074    fn v1_v2_envelope_loads_with_empty_publications() {
19075        // A snapshot taken before v6.1.2 (no publication trailer,
19076        // envelope v2) must still deserialise — and the resulting
19077        // engine must report zero publications. Use the engine's own
19078        // round-trip with no publications: that emits v3 but with an
19079        // empty pubs block. Then forge a v2 envelope by hand to lock
19080        // the back-compat path.
19081        let mut e = Engine::new();
19082        // Force users to be non-empty so the snapshot takes the
19083        // envelope path rather than the bare-catalog fallback.
19084        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
19085            .unwrap();
19086
19087        // Forge an envelope v2: same shape as v3 but no pubs trailer.
19088        let catalog = e.catalog.serialize();
19089        let users = crate::users::serialize_users(&e.users);
19090        let mut buf = Vec::new();
19091        buf.extend_from_slice(b"SPGENV01");
19092        buf.push(2u8); // v2
19093        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
19094        buf.extend_from_slice(&catalog);
19095        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
19096        buf.extend_from_slice(&users);
19097        let crc = spg_crypto::crc32::crc32(&buf);
19098        buf.extend_from_slice(&crc.to_le_bytes());
19099
19100        let e2 = Engine::restore_envelope(&buf).expect("v2 envelope restores");
19101        assert!(e2.publications().is_empty());
19102    }
19103}