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    /// Construct an engine restored from a previously-snapshotted catalog
911    /// (see `snapshot()`).
912    pub fn restore(catalog: Catalog) -> Self {
913        Self {
914            catalog,
915            tx_catalogs: BTreeMap::new(),
916            current_tx: None,
917            next_tx_id: 1,
918            clock: None,
919            salt_fn: None,
920            max_query_rows: None,
921            users: UserStore::new(),
922            publications: publications::Publications::new(),
923            subscriptions: subscriptions::Subscriptions::new(),
924            statistics: statistics::Statistics::new(),
925            plan_cache: plan_cache::PlanCache::new(),
926            query_stats: query_stats::QueryStats::new(),
927            activity_provider: None,
928            audit_chain_provider: None,
929            audit_verifier: None,
930            slow_query_threshold_us: None,
931            slow_query_logger: None,
932            session_params: BTreeMap::new(),
933            trigger_recursion_depth: 0,
934            foreign_key_checks: true,
935            meta_views_materialised: false,
936            pending_foreign_keys: Vec::new(),
937        }
938    }
939
940    /// Restore an engine + user table from a v4.1 envelope produced
941    /// by `snapshot_with_users()`. Falls back to plain catalog-only
942    /// restore if the envelope magic isn't present (so v3.x snapshot
943    /// files still load). v6.1.2 adds the optional publications
944    /// trailer (envelope v3); a v1/v2 envelope deserialises to an
945    /// empty publication table.
946    pub fn restore_envelope(buf: &[u8]) -> Result<Self, EngineError> {
947        match split_envelope(buf) {
948            EnvelopeParse::Pair {
949                catalog: catalog_bytes,
950                users: user_bytes,
951                publications: pub_bytes,
952                subscriptions: sub_bytes,
953                statistics: stats_bytes,
954            } => {
955                let catalog = Catalog::deserialize(catalog_bytes).map_err(EngineError::Storage)?;
956                let users = users::deserialize_users(user_bytes)
957                    .map_err(|e| EngineError::Unsupported(alloc::format!("users restore: {e}")))?;
958                let publications = match pub_bytes {
959                    Some(b) => publications::Publications::deserialize(b).map_err(|e| {
960                        EngineError::Unsupported(alloc::format!("publications restore: {e:?}"))
961                    })?,
962                    None => publications::Publications::new(),
963                };
964                let subscriptions = match sub_bytes {
965                    Some(b) => subscriptions::Subscriptions::deserialize(b).map_err(|e| {
966                        EngineError::Unsupported(alloc::format!("subscriptions restore: {e:?}"))
967                    })?,
968                    None => subscriptions::Subscriptions::new(),
969                };
970                let statistics = match stats_bytes {
971                    Some(b) => statistics::Statistics::deserialize(b).map_err(|e| {
972                        EngineError::Unsupported(alloc::format!("statistics restore: {e:?}"))
973                    })?,
974                    None => statistics::Statistics::new(),
975                };
976                Ok(Self {
977                    catalog,
978                    tx_catalogs: BTreeMap::new(),
979                    current_tx: None,
980                    next_tx_id: 1,
981                    clock: None,
982                    salt_fn: None,
983                    max_query_rows: None,
984                    users,
985                    publications,
986                    subscriptions,
987                    statistics,
988                    plan_cache: plan_cache::PlanCache::new(),
989                    query_stats: query_stats::QueryStats::new(),
990                    activity_provider: None,
991                    audit_chain_provider: None,
992                    audit_verifier: None,
993                    slow_query_threshold_us: None,
994                    slow_query_logger: None,
995                    session_params: BTreeMap::new(),
996                    trigger_recursion_depth: 0,
997                    foreign_key_checks: true,
998                    meta_views_materialised: false,
999                    pending_foreign_keys: Vec::new(),
1000                })
1001            }
1002            EnvelopeParse::CrcMismatch { expected, computed } => {
1003                Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
1004                    "snapshot envelope CRC32 mismatch (expected={expected:#010x}, computed={computed:#010x})"
1005                ))))
1006            }
1007            EnvelopeParse::Bare => {
1008                let catalog = Catalog::deserialize(buf).map_err(EngineError::Storage)?;
1009                Ok(Self::restore(catalog))
1010            }
1011        }
1012    }
1013
1014    pub const fn users(&self) -> &UserStore {
1015        &self.users
1016    }
1017
1018    /// `salt` is supplied by the caller (the host has a random
1019    /// source; the engine is `no_std`). Caller should pass a fresh
1020    /// 16-byte random value per user.
1021    pub fn create_user(
1022        &mut self,
1023        name: &str,
1024        password: &str,
1025        role: Role,
1026        salt: [u8; 16],
1027    ) -> Result<(), UserError> {
1028        self.users.create(name, password, role, salt)?;
1029        // v4.8: also derive SCRAM-SHA-256 secrets so PG-wire SASL
1030        // auth can verify without re-running PBKDF2 per attempt.
1031        // Uses a fresh salt from the host RNG (falls back to a
1032        // deterministic per-username salt when no RNG is wired, same
1033        // as the legacy hash path).
1034        let scram_salt = self.salt_fn.map_or_else(
1035            || {
1036                let mut s = [0u8; users::SCRAM_SALT_LEN];
1037                let digest = spg_crypto::hash(name.as_bytes());
1038                // Use bytes 16..32 of BLAKE3 so we don't reuse the
1039                // exact same fallback salt as the BLAKE3 hash path.
1040                s.copy_from_slice(&digest[16..32]);
1041                s
1042            },
1043            |f| f(),
1044        );
1045        self.users
1046            .enable_scram(name, password, scram_salt, users::SCRAM_DEFAULT_ITERS)?;
1047        Ok(())
1048    }
1049
1050    pub fn drop_user(&mut self, name: &str) -> Result<(), UserError> {
1051        self.users.drop(name)
1052    }
1053
1054    pub fn verify_user(&self, name: &str, password: &str) -> Option<Role> {
1055        self.users.verify(name, password)
1056    }
1057
1058    /// Builder: attach a wall clock so `NOW()` / `CURRENT_TIMESTAMP` /
1059    /// `CURRENT_DATE` evaluate to a real value instead of erroring out.
1060    #[must_use]
1061    pub const fn with_clock(mut self, clock: ClockFn) -> Self {
1062        self.clock = Some(clock);
1063        self
1064    }
1065
1066    /// Builder: attach an OS-backed RNG for per-user password salts.
1067    /// The host (`spg-server`) typically wires this to `/dev/urandom`.
1068    #[must_use]
1069    pub const fn with_salt_fn(mut self, f: SaltFn) -> Self {
1070        self.salt_fn = Some(f);
1071        self
1072    }
1073
1074    /// Builder: cap the number of rows a single SELECT may return.
1075    /// Exceeding the cap raises `EngineError::RowLimitExceeded` —
1076    /// the bound is checked inside the executor so a runaway
1077    /// catalog scan can't allocate millions of rows before the
1078    /// server gets a chance to reject the result.
1079    #[must_use]
1080    pub const fn with_max_query_rows(mut self, n: usize) -> Self {
1081        self.max_query_rows = Some(n);
1082        self
1083    }
1084
1085    /// The *committed* catalog. Note: during a transaction this returns the
1086    /// pre-TX state — `SELECT` inside a TX goes through `execute()` and reads
1087    /// the shadow. Tests that inspect outside-TX state should use this.
1088    pub const fn catalog(&self) -> &Catalog {
1089        &self.catalog
1090    }
1091
1092    /// Serialize the *committed* catalog to bytes. v0.6 was full-snapshot; v0.9
1093    /// adds the rule that an open TX's shadow is never snapshotted — only the
1094    /// post-COMMIT state is persisted. v4.1 wraps the catalog in an envelope
1095    /// when there are users to persist; an empty user table snapshots as the
1096    /// bare catalog format (backwards-compat with v3.x readers). v6.1.2
1097    /// adds publications to the envelope condition: either non-empty
1098    /// users OR non-empty publications now triggers the envelope path.
1099    pub fn snapshot(&self) -> Vec<u8> {
1100        if self.users.is_empty()
1101            && self.publications.is_empty()
1102            && self.subscriptions.is_empty()
1103            && self.statistics.is_empty()
1104        {
1105            self.catalog.serialize()
1106        } else {
1107            build_envelope(
1108                &self.catalog.serialize(),
1109                &users::serialize_users(&self.users),
1110                &self.publications.serialize(),
1111                &self.subscriptions.serialize(),
1112                &self.statistics.serialize(),
1113            )
1114        }
1115    }
1116
1117    /// True when at least one TX slot is in flight. v4.41.1 runtime
1118    /// invariant: at most one slot active at a time (dispatch holds
1119    /// `engine.write()` across the entire wrap). v4.42 will let this
1120    /// return true with multiple slots concurrently.
1121    pub fn in_transaction(&self) -> bool {
1122        !self.tx_catalogs.is_empty()
1123    }
1124
1125    /// v4.41.1 allocate a fresh TX handle. Used by spg-server dispatch
1126    /// to scope each implicit-wrap BEGIN..stmt..COMMIT to its own slot
1127    /// in `tx_catalogs`. v4.42 — the commit-barrier leader allocates
1128    /// one of these per task in its group, runs `BEGIN`+sql+`COMMIT`
1129    /// sequentially under a single `engine.write()` so each task's
1130    /// mutations accumulate into shared state, then either keeps the
1131    /// accumulated state (fsync OK) or restores the pre-image via
1132    /// `replace_catalog` (fsync err).
1133    pub fn alloc_tx_id(&mut self) -> TxId {
1134        let id = TxId(self.next_tx_id);
1135        self.next_tx_id = self.next_tx_id.saturating_add(1);
1136        id
1137    }
1138
1139    /// v4.42 — atomically replace the live catalog. Used by the
1140    /// commit-barrier leader to roll back a group whose batched
1141    /// fsync failed: the leader snapshots `engine.catalog().clone()`
1142    /// (O(1) Arc bump after the v4.39/v4.40 persistent migration)
1143    /// at group start, sequentially applies each task's BEGIN+sql+
1144    /// COMMIT under the same write lock to accumulate mutations
1145    /// into shared state, batches the WAL bytes, fsyncs once, and
1146    /// on failure calls this with the pre-image to undo every
1147    /// task in the group at once.
1148    ///
1149    /// **Does NOT touch `tx_catalogs` / `current_tx`.** Any
1150    /// explicit-TX slot from a concurrent client (created via the
1151    /// legacy `IMPLICIT_TX`-less dispatch path or via the future
1152    /// MVCC-readers v5+ work) has its own snapshot baked into the
1153    /// slot — restoring `self.catalog` to the pre-image leaves
1154    /// those slots untouched, exactly as they were when the leader
1155    /// took the lock. The leader's own implicit-TX slots are all
1156    /// already discarded (`exec_commit` removed them as each
1157    /// task's COMMIT ran) by the time this is reached.
1158    pub fn replace_catalog(&mut self, catalog: Catalog) {
1159        self.catalog = catalog;
1160    }
1161
1162    /// v6.7.0 — public shim around `Catalog::freeze_oldest_to_cold`
1163    /// so tests + the spg-server freezer can drive a freeze without
1164    /// reaching into the private `active_catalog_mut`. v6.7.4
1165    /// parallel freezer will build on this surface.
1166    ///
1167    /// Marks the table's cached `cold_row_count` stale because the
1168    /// freeze added cold locators that ANALYZE hasn't yet refreshed.
1169    pub fn freeze_oldest_to_cold(
1170        &mut self,
1171        table_name: &str,
1172        index_name: &str,
1173        max_rows: usize,
1174    ) -> Result<spg_storage::FreezeReport, EngineError> {
1175        let report = self
1176            .active_catalog_mut()
1177            .freeze_oldest_to_cold(table_name, index_name, max_rows)
1178            .map_err(EngineError::Storage)?;
1179        if let Some(t) = self.active_catalog_mut().get_mut(table_name) {
1180            t.mark_cold_row_count_stale();
1181        }
1182        Ok(report)
1183    }
1184
1185    /// v6.7.5 — public shim used by the spg-server follower's
1186    /// segment-forwarding receiver. Registers a cold-tier segment
1187    /// at a specific id (the master's id, as transmitted on the
1188    /// wire) so the follower's BTree-Cold locators stay byte-
1189    /// identical with the master's. Wraps
1190    /// `Catalog::load_segment_bytes_at` under the standard
1191    /// clone-mutate-replace pattern.
1192    ///
1193    /// Returns `Ok(())` on success **and** on the "slot already
1194    /// occupied" case — a follower mid-reconnect may receive a
1195    /// segment chunk for a segment_id it already has on disk
1196    /// (forwarded last session); the caller should treat that
1197    /// path as a no-op rather than a fatal error.
1198    pub fn receive_cold_segment(
1199        &mut self,
1200        segment_id: u32,
1201        bytes: Vec<u8>,
1202    ) -> Result<(), EngineError> {
1203        let mut new_cat = self.catalog.clone();
1204        match new_cat.load_segment_bytes_at(segment_id, bytes) {
1205            Ok(()) => {
1206                self.replace_catalog(new_cat);
1207                Ok(())
1208            }
1209            Err(StorageError::Corrupt(msg)) if msg.contains("already occupied") => Ok(()),
1210            Err(e) => Err(EngineError::Storage(e)),
1211        }
1212    }
1213
1214    /// v6.7.3 — public shim around `Catalog::compact_cold_segments`
1215    /// driving every BTree index on every user table. Returns one
1216    /// `(table, index, report)` triple for each merge that
1217    /// actually happened (no-op (table, index) pairs are filtered
1218    /// out so callers can size persist-side work to the live
1219    /// merges). Caller is responsible for persisting each
1220    /// `report.merged_segment_bytes` and updating the on-disk
1221    /// segment registry; engine layer is no_std and never
1222    /// touches disk.
1223    ///
1224    /// Marks every touched table's cached `cold_row_count` stale
1225    /// — compaction GC'd some shadowed rows, so the count must be
1226    /// re-derived on the next ANALYZE.
1227    pub fn compact_cold_segments_with_target(
1228        &mut self,
1229        target_segment_bytes: u64,
1230    ) -> Result<Vec<(String, String, CompactReport)>, EngineError> {
1231        let table_names = self.active_catalog().table_names();
1232        let mut reports: Vec<(String, String, CompactReport)> = Vec::new();
1233        for tname in table_names {
1234            if is_internal_table_name(&tname) {
1235                continue;
1236            }
1237            let idx_names: Vec<String> = {
1238                let Some(t) = self.active_catalog().get(&tname) else {
1239                    continue;
1240                };
1241                t.indices()
1242                    .iter()
1243                    .filter(|i| matches!(i.kind, IndexKind::BTree(_)))
1244                    .map(|i| i.name.clone())
1245                    .collect()
1246            };
1247            for iname in idx_names {
1248                let report = self
1249                    .active_catalog_mut()
1250                    .compact_cold_segments(&tname, &iname, target_segment_bytes)
1251                    .map_err(EngineError::Storage)?;
1252                if report.merged_segment_id.is_some() {
1253                    if let Some(t) = self.active_catalog_mut().get_mut(&tname) {
1254                        t.mark_cold_row_count_stale();
1255                    }
1256                    reports.push((tname.clone(), iname, report));
1257                }
1258            }
1259        }
1260        Ok(reports)
1261    }
1262
1263    fn active_catalog(&self) -> &Catalog {
1264        match self.current_tx {
1265            Some(t) => self
1266                .tx_catalogs
1267                .get(&t)
1268                .map_or(&self.catalog, |s| &s.catalog),
1269            None => &self.catalog,
1270        }
1271    }
1272
1273    /// v7.12.4 — snapshot every row-level trigger on `table` that
1274    /// fires for `event` (`"INSERT"` / `"UPDATE"` / `"DELETE"`) at
1275    /// the given `timing` (`"BEFORE"` / `"AFTER"`), and clone its
1276    /// referenced function definition. Returned as a vec of owned
1277    /// `FunctionDef` so the row-write loop can fire them without
1278    /// holding a borrow on the catalog (which would conflict with
1279    /// the table.insert / update_row / delete mutable borrows).
1280    /// v7.16.2 — top-level DO block executor. Walks the
1281    /// PlPgSqlBlock via [`triggers::execute_do_block_top_level`],
1282    /// then runs each collected EmbeddedSql statement through
1283    /// the engine's regular execute path (NOT deferred — DO is
1284    /// outside any row-write borrow). Errors from any step
1285    /// abort the block and propagate verbatim.
1286    /// v7.16.2 — resolve every subquery inside a PlPgSqlBlock's
1287    /// expression slots so the downstream trigger-flavoured
1288    /// evaluator (which expects pre-resolved Expr::Literal /
1289    /// Binary chains) doesn't trip on raw Exists/ScalarSubquery
1290    /// nodes. Walks IF conditions, Assign values, RAISE args.
1291    /// EmbeddedSql statements re-enter the engine for execution
1292    /// later so their subqueries get the normal SELECT-side
1293    /// resolution.
1294    fn resolve_plpgsql_block_subqueries(
1295        &self,
1296        block: &mut spg_sql::ast::PlPgSqlBlock,
1297        cancel: CancelToken<'_>,
1298    ) -> Result<(), EngineError> {
1299        for d in &mut block.declarations {
1300            if let Some(e) = &mut d.default {
1301                self.resolve_expr_subqueries(e, cancel)?;
1302            }
1303        }
1304        self.resolve_plpgsql_stmts_subqueries(&mut block.statements, cancel)
1305    }
1306
1307    fn resolve_plpgsql_stmts_subqueries(
1308        &self,
1309        stmts: &mut [spg_sql::ast::PlPgSqlStmt],
1310        cancel: CancelToken<'_>,
1311    ) -> Result<(), EngineError> {
1312        use spg_sql::ast::PlPgSqlStmt;
1313        for stmt in stmts {
1314            match stmt {
1315                PlPgSqlStmt::Assign { value, .. } => {
1316                    self.resolve_expr_subqueries(value, cancel)?;
1317                }
1318                PlPgSqlStmt::Return(spg_sql::ast::ReturnTarget::Expr(e)) => {
1319                    self.resolve_expr_subqueries(e, cancel)?;
1320                }
1321                PlPgSqlStmt::Return(_) => {}
1322                PlPgSqlStmt::If {
1323                    branches,
1324                    else_branch,
1325                } => {
1326                    for (cond, body) in branches.iter_mut() {
1327                        self.resolve_expr_subqueries(cond, cancel)?;
1328                        self.resolve_plpgsql_stmts_subqueries(body, cancel)?;
1329                    }
1330                    self.resolve_plpgsql_stmts_subqueries(else_branch, cancel)?;
1331                }
1332                PlPgSqlStmt::Raise { args, .. } => {
1333                    for a in args {
1334                        self.resolve_expr_subqueries(a, cancel)?;
1335                    }
1336                }
1337                PlPgSqlStmt::EmbeddedSql(_) => {
1338                    // Embedded SQL goes back through execute_stmt
1339                    // _with_cancel which runs the SELECT-side
1340                    // resolver itself; nothing to do here.
1341                }
1342                PlPgSqlStmt::SelectInto { body, .. } => {
1343                    // SELECT INTO runs through Engine::execute
1344                    // when reached, so subquery resolution
1345                    // happens via the normal SELECT-side path.
1346                    // Still walk for nested subqueries inside
1347                    // the SELECT body so eval doesn't trip.
1348                    self.resolve_select_subqueries(body, cancel)?;
1349                }
1350            }
1351        }
1352        Ok(())
1353    }
1354
1355    fn exec_do_block(
1356        &mut self,
1357        body: spg_sql::ast::PlPgSqlBlock,
1358    ) -> Result<QueryResult, EngineError> {
1359        // v7.16.2 — pre-resolve every subquery the body's
1360        // expressions reach. `eval::eval_expr` errors on
1361        // unresolved Exists/ScalarSubquery/InSubquery; the
1362        // top-level SELECT path runs `resolve_select_subqueries`
1363        // for the caller — for plpgsql we have to do the
1364        // equivalent before the body walker runs. Catches the
1365        // mailrs idiom `IF EXISTS (SELECT 1 FROM
1366        // information_schema.columns WHERE …) THEN …`.
1367        let mut body = body;
1368        self.resolve_plpgsql_block_subqueries(&mut body, CancelToken::none())?;
1369        let dts = self
1370            .session_param("default_text_search_config")
1371            .map(String::from);
1372        // v7.16.2 — SELECT … INTO resolver. The walker calls
1373        // this synchronously when it hits a SelectInto stmt
1374        // so the IF / locals scope sees the result before the
1375        // next statement. Body walks for trigger paths (no
1376        // resolver) error loudly on SelectInto.
1377        // SAFETY: the closure shares this engine borrow with
1378        // the walker, but the walker only borrows for the
1379        // duration of `execute_do_block_top_level` and doesn't
1380        // reach back into the engine through any other path —
1381        // so the recursive `&mut` is sound. We use a `RefCell`
1382        // for interior mutability since the closure is
1383        // Fn-shaped.
1384        let engine_cell = core::cell::RefCell::new(&mut *self);
1385        let resolver_fn =
1386            |stmt: &spg_sql::ast::Statement| -> Result<Value, triggers::TriggerError> {
1387                let mut eng = engine_cell.borrow_mut();
1388                let r = eng
1389                    .execute_stmt_with_cancel(stmt.clone(), CancelToken::none())
1390                    .map_err(|e| triggers::TriggerError::EvalFailed {
1391                        function: "DO".into(),
1392                        cause: eval::EvalError::TypeMismatch {
1393                            detail: alloc::format!("SELECT … INTO failed: {e}"),
1394                        },
1395                    })?;
1396                match r {
1397                    QueryResult::Rows { rows, .. } => match rows.into_iter().next() {
1398                        Some(row) => Ok(row.values.into_iter().next().unwrap_or(Value::Null)),
1399                        None => Ok(Value::Null),
1400                    },
1401                    _ => Err(triggers::TriggerError::EvalFailed {
1402                        function: "DO".into(),
1403                        cause: eval::EvalError::TypeMismatch {
1404                            detail: "SELECT … INTO body must be a SELECT".into(),
1405                        },
1406                    }),
1407                }
1408            };
1409        let collected =
1410            triggers::execute_do_block_top_level(&body, dts.as_deref(), Some(&resolver_fn))
1411                .map_err(|e| {
1412                    EngineError::Storage(StorageError::Corrupt(alloc::format!("DO: {e}")))
1413                })?;
1414        // engine_cell goes out of scope here, releasing the &mut self borrow
1415        // Run each embedded statement against the engine. The
1416        // statements were already substitute-walked for NEW/OLD/
1417        // locals (those evaluate to engine literals before they
1418        // land here) so dispatch is plain execute_stmt_with_cancel.
1419        for stmt in collected {
1420            // v7.16.2 — preserve current_tx wrap so an outer
1421            // BEGIN/COMMIT around a DO block keeps the
1422            // EmbeddedSql writes inside that same tx slot.
1423            self.execute_stmt_with_cancel(stmt, CancelToken::none())?;
1424        }
1425        Ok(QueryResult::CommandOk {
1426            affected: 0,
1427            modified_catalog: !self.in_transaction(),
1428        })
1429    }
1430
1431    fn snapshot_row_triggers(
1432        &self,
1433        table: &str,
1434        event: &str,
1435        timing: &str,
1436    ) -> Vec<spg_storage::FunctionDef> {
1437        let cat = self.active_catalog();
1438        cat.triggers()
1439            .iter()
1440            .filter(|t| {
1441                // v7.16.1 — skip disabled triggers (mailrs
1442                // round-9 A.2.b — pg_dump --disable-triggers).
1443                t.enabled
1444                    && t.table == table
1445                    && t.timing.eq_ignore_ascii_case(timing)
1446                    && t.for_each.eq_ignore_ascii_case("row")
1447                    && t.events.iter().any(|e| e.eq_ignore_ascii_case(event))
1448            })
1449            .filter_map(|t| cat.functions().get(&t.function).cloned())
1450            .collect()
1451    }
1452
1453    /// v7.13.0 — UPDATE-side snapshot that pairs each trigger's
1454    /// function with its `UPDATE OF cols` filter (mailrs round-5
1455    /// G7). Empty filter Vec means "fire unconditionally", matching
1456    /// the v7.12 behaviour.
1457    fn snapshot_update_row_triggers(
1458        &self,
1459        table: &str,
1460        timing: &str,
1461    ) -> Vec<(spg_storage::FunctionDef, Vec<String>)> {
1462        let cat = self.active_catalog();
1463        cat.triggers()
1464            .iter()
1465            .filter(|t| {
1466                // v7.16.1 — skip disabled triggers.
1467                t.enabled
1468                    && t.table == table
1469                    && t.timing.eq_ignore_ascii_case(timing)
1470                    && t.for_each.eq_ignore_ascii_case("row")
1471                    && t.events.iter().any(|e| e.eq_ignore_ascii_case("UPDATE"))
1472            })
1473            .filter_map(|t| {
1474                cat.functions()
1475                    .get(&t.function)
1476                    .cloned()
1477                    .map(|fd| (fd, t.update_columns.clone()))
1478            })
1479            .collect()
1480    }
1481
1482    /// v7.12.7 — drain the trigger-emitted embedded SQL queue.
1483    /// Called by the INSERT / UPDATE / DELETE executors after
1484    /// their main row-write loop returns. Each statement runs
1485    /// inside the same cancel scope as the firing DML and bumps
1486    /// the recursion counter; nested embedded SQL beyond
1487    /// [`MAX_TRIGGER_RECURSION`] errors with a clear message so
1488    /// a trigger-graph cycle surfaces as a query failure instead
1489    /// of stack-blowing the engine.
1490    fn execute_deferred_trigger_stmts(
1491        &mut self,
1492        deferred: Vec<triggers::DeferredEmbeddedStmt>,
1493        cancel: CancelToken<'_>,
1494    ) -> Result<(), EngineError> {
1495        for d in deferred {
1496            if self.trigger_recursion_depth >= MAX_TRIGGER_RECURSION {
1497                return Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
1498                    "trigger embedded SQL recursion depth {} exceeded (trigger function \
1499                     {:?} would push past the {} cap — check for trigger cycles)",
1500                    self.trigger_recursion_depth,
1501                    d.function,
1502                    MAX_TRIGGER_RECURSION,
1503                ))));
1504            }
1505            self.trigger_recursion_depth += 1;
1506            let res = self.execute_stmt_with_cancel(d.stmt, cancel);
1507            self.trigger_recursion_depth -= 1;
1508            res?;
1509        }
1510        Ok(())
1511    }
1512
1513    fn active_catalog_mut(&mut self) -> &mut Catalog {
1514        let tx = self.current_tx;
1515        match tx {
1516            Some(t) => match self.tx_catalogs.get_mut(&t) {
1517                Some(s) => &mut s.catalog,
1518                None => &mut self.catalog,
1519            },
1520            None => &mut self.catalog,
1521        }
1522    }
1523
1524    /// Read-only execute path. Succeeds for `SELECT` / `SHOW TABLES`
1525    /// / `SHOW COLUMNS`; returns `EngineError::WriteRequired` for
1526    /// every other statement, so the caller can fall through to the
1527    /// `&mut self` `execute` path under a write lock. Engine state is
1528    /// not mutated even on the success path (`rewrite_clock_calls`
1529    /// and `resolve_order_by_position` both mutate the locally-owned
1530    /// AST, not `self`).
1531    ///
1532    /// **v4.0 concurrency**: this is the entry point the server takes
1533    /// under an `RwLock::read()` so multiple `SELECT` clients run in
1534    /// parallel without serialising on a single mutex.
1535    pub fn execute_readonly(&self, sql: &str) -> Result<QueryResult, EngineError> {
1536        self.execute_readonly_with_cancel(sql, CancelToken::none())
1537    }
1538
1539    /// v4.5 — read path with cooperative cancellation. Token's
1540    /// `is_cancelled` is checked at the start (so a watchdog that
1541    /// already fired returns Cancelled immediately) and at row-loop
1542    /// checkpoints inside `exec_select`. SHOW paths are O(small) and
1543    /// don't bother checking.
1544    pub fn execute_readonly_with_cancel(
1545        &self,
1546        sql: &str,
1547        cancel: CancelToken<'_>,
1548    ) -> Result<QueryResult, EngineError> {
1549        cancel.check()?;
1550        let mut stmt = parser::parse_statement(sql)?;
1551        let now_micros = self.clock.map(|f| f());
1552        rewrite_clock_calls(&mut stmt, now_micros);
1553        if let Statement::Select(s) = &mut stmt {
1554            resolve_order_by_position(s);
1555            // v6.2.3 — cost-based JOIN reorder (read path).
1556            reorder::reorder_joins(s, &self.catalog, &self.statistics);
1557        }
1558        let result = match stmt {
1559            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
1560            Statement::ShowTables => Ok(self.exec_show_tables()),
1561            Statement::ShowDatabases => Ok(self.exec_show_databases()),
1562            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
1563            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
1564            Statement::ShowStatus => Ok(self.exec_show_status()),
1565            Statement::ShowVariables => Ok(self.exec_show_variables()),
1566            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
1567            Statement::ShowColumns(table) => self.exec_show_columns(&table),
1568            Statement::ShowUsers => Ok(self.exec_show_users()),
1569            Statement::ShowPublications => Ok(self.exec_show_publications()),
1570            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
1571            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
1572                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
1573            )),
1574            Statement::Explain(e) => self.exec_explain(&e, cancel),
1575            _ => Err(EngineError::WriteRequired),
1576        };
1577        self.enforce_row_limit(result)
1578    }
1579
1580    /// v4.2: cap result-set size. Applied after the executor
1581    /// materialises rows but before they leave the engine — wrapping
1582    /// every Rows-returning exec_* function would scatter the check.
1583    fn enforce_row_limit(
1584        &self,
1585        result: Result<QueryResult, EngineError>,
1586    ) -> Result<QueryResult, EngineError> {
1587        if let (Ok(QueryResult::Rows { rows, .. }), Some(cap)) = (&result, self.max_query_rows)
1588            && rows.len() > cap
1589        {
1590            return Err(EngineError::RowLimitExceeded(cap));
1591        }
1592        result
1593    }
1594
1595    pub fn execute(&mut self, sql: &str) -> Result<QueryResult, EngineError> {
1596        self.execute_in_with_cancel(sql, IMPLICIT_TX, CancelToken::none())
1597    }
1598
1599    /// v4.5 — write path with cooperative cancellation. Same dispatch
1600    /// as `execute_in_with_cancel(sql, IMPLICIT_TX, cancel)`. Kept as
1601    /// a separate entry point for backward-compat with the v4.5
1602    /// public API.
1603    pub fn execute_with_cancel(
1604        &mut self,
1605        sql: &str,
1606        cancel: CancelToken<'_>,
1607    ) -> Result<QueryResult, EngineError> {
1608        self.execute_in_with_cancel(sql, IMPLICIT_TX, cancel)
1609    }
1610
1611    /// v4.41.1 multi-slot write entry. Routes `sql` through the TX
1612    /// slot identified by `tx_id` so spg-server dispatch can scope
1613    /// each implicit-wrap BEGIN..stmt..COMMIT to its own slot in
1614    /// `tx_catalogs`. `IMPLICIT_TX` is the legacy single-slot path
1615    /// every other caller (engine self-tests, replay, spg-embedded)
1616    /// implicitly takes via `execute()` / `execute_with_cancel()`.
1617    pub fn execute_in(&mut self, sql: &str, tx_id: TxId) -> Result<QueryResult, EngineError> {
1618        self.execute_in_with_cancel(sql, tx_id, CancelToken::none())
1619    }
1620
1621    /// v4.41.1 write path with cooperative cancellation + explicit TX
1622    /// scope. Sets `self.current_tx` for the duration of the call so
1623    /// every `exec_*` helper transparently sees its TX's shadow
1624    /// catalog and savepoint stack; restores on exit so the field is
1625    /// only valid mid-call (no leakage across calls).
1626    pub fn execute_in_with_cancel(
1627        &mut self,
1628        sql: &str,
1629        tx_id: TxId,
1630        cancel: CancelToken<'_>,
1631    ) -> Result<QueryResult, EngineError> {
1632        let saved = self.current_tx;
1633        self.current_tx = Some(tx_id);
1634        let result = self.execute_inner_with_cancel(sql, cancel);
1635        self.current_tx = saved;
1636        result
1637    }
1638
1639    /// v6.1.1 — parse and pre-process a SQL string ONCE so the
1640    /// resulting [`Statement`] can be cached and re-executed via
1641    /// [`Engine::execute_prepared`]. Returns the same `Statement`
1642    /// the simple-query path would synthesise internally (clock
1643    /// rewrites + ORDER BY position-ref resolution applied at
1644    /// prepare time, since both are session-independent). The
1645    /// `$N` placeholders in the SQL stay as `Expr::Placeholder(n)`
1646    /// nodes; they're resolved to concrete values per-call by
1647    /// `execute_prepared`'s substitution walk.
1648    ///
1649    /// Pgwire's `Parse` (P) message lands here.
1650    pub fn prepare(&self, sql: &str) -> Result<Statement, ParseError> {
1651        let mut stmt = parser::parse_statement(sql)?;
1652        let now_micros = self.clock.map(|f| f());
1653        rewrite_clock_calls(&mut stmt, now_micros);
1654        if let Statement::Select(s) = &mut stmt {
1655            // v6.4.1 — expand `GROUP BY ALL` to every non-aggregate
1656            // SELECT-list item BEFORE position / alias resolution so
1657            // downstream passes see the explicit list.
1658            expand_group_by_all(s);
1659            resolve_order_by_position(s);
1660            // v6.2.3 — cost-based JOIN reorder. No-op for
1661            // single-table FROMs or any non-INNER join shape.
1662            reorder::reorder_joins(s, &self.catalog, &self.statistics);
1663        }
1664        Ok(stmt)
1665    }
1666
1667    /// v6.3.0 — cached prepare. Returns a cloned `Statement` from
1668    /// the plan cache on hit, runs the full `prepare()` path on miss
1669    /// and inserts the resulting plan before returning. Skipping the
1670    /// parse + JOIN-reorder pipeline on hit is the dominant win for
1671    /// JDBC / sqlx / pgx clients that reuse the same SQL string.
1672    ///
1673    /// Returns a cloned `Statement` (not a borrow) because the
1674    /// pgwire layer owns its `PreparedStmt` map per-session and the
1675    /// engine-level cache must stay available for other sessions.
1676    /// Clone cost on a 5-table JOIN AST is well under the parse cost
1677    /// it replaces.
1678    pub fn prepare_cached(&mut self, sql: &str) -> Result<Statement, ParseError> {
1679        // v6.3.1 — version-aware lookup. If the cached plan was
1680        // prepared before the most recent ANALYZE, evict and replan.
1681        let current_version = self.statistics.version();
1682        if let Some(plan) = self.plan_cache.get(sql) {
1683            if plan.statistics_version == current_version {
1684                return Ok(plan.stmt.clone());
1685            }
1686            // Stale entry — fall through to evict + re-prepare.
1687        }
1688        self.plan_cache.evict(sql);
1689        let stmt = self.prepare(sql)?;
1690        let source_tables = plan_cache::collect_source_tables(&stmt);
1691        let plan = plan_cache::PreparedPlan {
1692            stmt: stmt.clone(),
1693            statistics_version: current_version,
1694            source_tables,
1695            describe_columns: alloc::vec::Vec::new(),
1696        };
1697        self.plan_cache.insert(String::from(sql), plan);
1698        Ok(stmt)
1699    }
1700
1701    /// v6.3.0 — read-only accessor for tests and v6.3.1 invalidation.
1702    pub fn plan_cache(&self) -> &plan_cache::PlanCache {
1703        &self.plan_cache
1704    }
1705
1706    /// v6.3.0 — mutable accessor for v6.3.1 invalidation hooks.
1707    pub fn plan_cache_mut(&mut self) -> &mut plan_cache::PlanCache {
1708        &mut self.plan_cache
1709    }
1710
1711    /// v6.3.3 — Describe a prepared `Statement` without executing.
1712    /// Returns `(parameter_oids, output_columns)`. Empty
1713    /// `output_columns` means the statement has no row-producing
1714    /// shape we could resolve here (JOIN, subquery, non-SELECT, …)
1715    /// — pgwire layer maps that to a `NoData` reply.
1716    pub fn describe_prepared(&self, stmt: &Statement) -> (Vec<u32>, Vec<ColumnSchema>) {
1717        describe::describe_prepared(stmt, self.active_catalog())
1718    }
1719
1720    /// v6.1.1 — execute a [`Statement`] previously returned by
1721    /// [`Engine::prepare`], substituting `Expr::Placeholder(n)`
1722    /// nodes for the corresponding [`Value`] in `params` (1-based
1723    /// per PG: `$1` → `params[0]`). Bind-time string parameters
1724    /// are decoded into typed `Value`s by the pgwire layer before
1725    /// this call so the resulting AST hits the same execution
1726    /// path as a simple query — no SQL re-parse.
1727    ///
1728    /// Pgwire's `Execute` (E) message after a `Bind` (B) lands here.
1729    pub fn execute_prepared(
1730        &mut self,
1731        stmt: Statement,
1732        params: &[Value],
1733    ) -> Result<QueryResult, EngineError> {
1734        self.execute_prepared_with_cancel(stmt, params, CancelToken::none())
1735    }
1736
1737    /// v7.17.0 Phase 2.3 — prepared-statement entry that honors a
1738    /// caller-supplied `CancelToken`. Mirrors `execute_prepared`'s
1739    /// `current_tx` save/restore so the extended-query path stays
1740    /// transactionally consistent with the simple-query path.
1741    pub fn execute_prepared_with_cancel(
1742        &mut self,
1743        mut stmt: Statement,
1744        params: &[Value],
1745        cancel: CancelToken<'_>,
1746    ) -> Result<QueryResult, EngineError> {
1747        substitute_placeholders(&mut stmt, params)?;
1748        // v7.16.0 — set `current_tx` for the duration of the
1749        // dispatch so the `exec_*` helpers see the right TX
1750        // slot (matches what `execute_in_with_cancel` does for
1751        // simple-query). Pre-v7.16 the simple-query path
1752        // worked because every public entry point routed
1753        // through `execute_in_with_cancel`; the prepared path
1754        // skipped the wrap and so its INSERTs/UPDATEs landed
1755        // in the no-tx default slot, silently invisible to a
1756        // BEGIN/COMMIT-bracketed flow. Caught by spg-sqlx's
1757        // first transaction-visibility test.
1758        let saved = self.current_tx;
1759        self.current_tx = Some(IMPLICIT_TX);
1760        let result = self.execute_stmt_with_cancel(stmt, cancel);
1761        self.current_tx = saved;
1762        result
1763    }
1764
1765    fn execute_inner_with_cancel(
1766        &mut self,
1767        sql: &str,
1768        cancel: CancelToken<'_>,
1769    ) -> Result<QueryResult, EngineError> {
1770        cancel.check()?;
1771        let stmt = self.prepare(sql)?;
1772        // v6.5.1 — wrap the executor with a wall-clock window so we
1773        // can record into spg_stat_query. Skip when the engine has
1774        // no clock attached (no_std embedded callers).
1775        let start_us = self.clock.map(|f| f());
1776        let result = self.execute_stmt_with_cancel(stmt, cancel);
1777        if let (Some(t0), Ok(_)) = (start_us, &result) {
1778            let now = self.clock.map_or(t0, |f| f());
1779            let elapsed = now.saturating_sub(t0).max(0) as u64;
1780            self.query_stats.record(sql, elapsed, now as u64);
1781            // v6.5.6 — slow-query log: fire callback when elapsed
1782            // exceeds the configured floor.
1783            if let (Some(threshold), Some(logger)) =
1784                (self.slow_query_threshold_us, self.slow_query_logger)
1785                && elapsed >= threshold
1786            {
1787                logger(sql, elapsed);
1788            }
1789        }
1790        result
1791    }
1792
1793    fn execute_stmt_with_cancel(
1794        &mut self,
1795        stmt: Statement,
1796        cancel: CancelToken<'_>,
1797    ) -> Result<QueryResult, EngineError> {
1798        cancel.check()?;
1799        // v7.17.0 Phase 1.1 — pre-resolve nextval / currval /
1800        // setval calls in the statement tree. Walks SELECT
1801        // projection, INSERT VALUES, UPDATE SET, DELETE WHERE,
1802        // and DEFAULT exprs; replaces sequence FunctionCall
1803        // nodes with concrete Literal values minted against the
1804        // catalog. This is the only place that mutates sequence
1805        // state from a SELECT-shaped path (exec_select_cancel is
1806        // `&self` and can't reach the catalog mutably).
1807        //
1808        // Fast-path: when no sequences exist anywhere in the
1809        // catalog (the typical hot-path INSERT load), skip the
1810        // walker entirely. Single map-emptiness check on the
1811        // catalog beats walking every expression on every call.
1812        let mut stmt = stmt;
1813        // v7.17 dump-compat — the fast-path check
1814        // `sequences().is_empty()` skips pre-resolve when no
1815        // sequence exists in the *currently active* catalog
1816        // snapshot. The committed catalog or the implicit-TX
1817        // catalog may legitimately disagree on this between
1818        // CREATE SEQUENCE and a later setval(): always run the
1819        // resolver — the walk is O(expr-count) and dwarfed by
1820        // the parse cost we just paid.
1821        self.pre_resolve_sequence_calls_in_statement(&mut stmt)?;
1822        let result = match stmt {
1823            Statement::CreateTable(s) => self.exec_create_table(s),
1824            // v7.9.15 — CREATE EXTENSION is a no-op on SPG. Returns
1825            // CommandOk with affected=0; modified_catalog=false so
1826            // the WAL doesn't grow a useless entry. mailrs F3.
1827            Statement::CreateExtension(_) => Ok(QueryResult::CommandOk {
1828                affected: 0,
1829                modified_catalog: false,
1830            }),
1831            // v7.16.2 — DO $$ ... $$ block. mailrs round-10 A.2
1832            // — the pre-v7.9.27 no-op SILENTLY swallowed every
1833            // mailrs migrate-038/-040/-042 idempotent rename
1834            // (the IF EXISTS … THEN ALTER … END block never
1835            // ran). v7.16.2 dispatches to exec_do_block which
1836            // runs the PlPgSqlBlock at top level via the same
1837            // execute_stmts machinery the trigger executor
1838            // uses (NEW=None, OLD=None — DO blocks have no
1839            // row context).
1840            Statement::DoBlock(body) => self.exec_do_block(body),
1841            // v7.14.0 — empty-statement no-op for pg_dump /
1842            // mysqldump preamble lines that collapse to nothing
1843            // after comment-stripping.
1844            Statement::Empty => Ok(QueryResult::CommandOk {
1845                affected: 0,
1846                modified_catalog: false,
1847            }),
1848            Statement::DropTable { names, if_exists } => self.exec_drop_table(names, if_exists),
1849            Statement::DropIndex { name, if_exists } => self.exec_drop_index(name, if_exists),
1850            Statement::CreateIndex(s) => self.exec_create_index(s),
1851            Statement::Insert(s) => self.exec_insert(s),
1852            Statement::Update(s) => self.exec_update_cancel(&s, cancel),
1853            Statement::Delete(s) => self.exec_delete_cancel(&s, cancel),
1854            Statement::Merge(s) => self.exec_merge_cancel(&s, cancel),
1855            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
1856            Statement::Begin => self.exec_begin(),
1857            Statement::Commit => self.exec_commit(),
1858            Statement::Rollback => self.exec_rollback(),
1859            Statement::Savepoint(name) => self.exec_savepoint(name),
1860            Statement::RollbackToSavepoint(name) => self.exec_rollback_to_savepoint(&name),
1861            Statement::ReleaseSavepoint(name) => self.exec_release_savepoint(&name),
1862            Statement::ShowTables => Ok(self.exec_show_tables()),
1863            Statement::ShowDatabases => Ok(self.exec_show_databases()),
1864            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
1865            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
1866            Statement::ShowStatus => Ok(self.exec_show_status()),
1867            Statement::ShowVariables => Ok(self.exec_show_variables()),
1868            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
1869            Statement::ShowColumns(table) => self.exec_show_columns(&table),
1870            Statement::ShowUsers => Ok(self.exec_show_users()),
1871            Statement::ShowPublications => Ok(self.exec_show_publications()),
1872            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
1873            Statement::CreateUser(s) => self.exec_create_user(&s),
1874            Statement::DropUser(name) => self.exec_drop_user(&name),
1875            Statement::Explain(e) => self.exec_explain(&e, cancel),
1876            Statement::AlterIndex(s) => self.exec_alter_index(s),
1877            Statement::AlterTable(s) => self.exec_alter_table(s),
1878            Statement::CreatePublication(s) => self.exec_create_publication(s),
1879            Statement::DropPublication(name) => self.exec_drop_publication(&name),
1880            Statement::CreateSubscription(s) => self.exec_create_subscription(s),
1881            Statement::DropSubscription(name) => self.exec_drop_subscription(&name),
1882            // v6.1.7 — WAIT FOR WAL POSITION needs `lag_state`,
1883            // which lives in spg-server's ServerState. The engine
1884            // surfaces a clear error; the server-layer dispatch
1885            // intercepts the SQL before it reaches the engine on
1886            // a server build, so this arm only fires for
1887            // engine-only callers (spg-embedded, lib tests).
1888            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
1889                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
1890            )),
1891            // v6.2.0 — ANALYZE recomputes per-column histograms.
1892            Statement::Analyze(target) => self.exec_analyze(target.as_deref()),
1893            // v6.7.3 — COMPACT COLD SEGMENTS.
1894            Statement::CompactColdSegments => self.exec_compact_cold_segments(),
1895            // v7.12.1 — SET / RESET session parameter. Engine
1896            // tracks the value in `session_params`; FTS dispatcher
1897            // reads `default_text_search_config`. Everything else
1898            // is a recorded no-op (PG dump compat).
1899            Statement::SetParameter { name, value } => {
1900                self.set_session_param(name, value);
1901                Ok(QueryResult::CommandOk {
1902                    affected: 0,
1903                    modified_catalog: false,
1904                })
1905            }
1906            // v7.14.0 — MySQL multi-assignment SET. Each pair runs
1907            // through `set_session_param` so engine-known params
1908            // (FOREIGN_KEY_CHECKS, session_replication_role, …) take
1909            // effect; unknown pairs (including `@VAR` LHS from the
1910            // mysqldump preamble) are recorded then ignored.
1911            Statement::SetParameterList(pairs) => {
1912                for (name, value) in pairs {
1913                    self.set_session_param(name, value);
1914                }
1915                Ok(QueryResult::CommandOk {
1916                    affected: 0,
1917                    modified_catalog: false,
1918                })
1919            }
1920            // v7.12.4 — CREATE FUNCTION / CREATE TRIGGER / DROP …
1921            // for the PL/pgSQL trigger surface. exec_* methods are
1922            // defined alongside the existing CREATE handlers below.
1923            Statement::CreateFunction(s) => self.exec_create_function(s),
1924            Statement::CreateTrigger(s) => self.exec_create_trigger(s),
1925            Statement::DropTrigger {
1926                name,
1927                table,
1928                if_exists,
1929            } => self.exec_drop_trigger(&name, &table, if_exists),
1930            Statement::DropFunction { name, if_exists } => {
1931                self.exec_drop_function(&name, if_exists)
1932            }
1933            Statement::CreateSequence(s) => self.exec_create_sequence(s),
1934            Statement::AlterSequence(s) => self.exec_alter_sequence(s),
1935            Statement::DropSequence { names, if_exists } => {
1936                self.exec_drop_sequence(&names, if_exists)
1937            }
1938            Statement::CreateView(s) => self.exec_create_view(s),
1939            Statement::DropView { names, if_exists } => self.exec_drop_view(&names, if_exists),
1940            Statement::CreateMaterializedView(s) => self.exec_create_materialized_view(s),
1941            Statement::RefreshMaterializedView { name, with_data } => {
1942                self.exec_refresh_materialized_view(&name, with_data)
1943            }
1944            Statement::DropMaterializedView { names, if_exists } => {
1945                self.exec_drop_materialized_view(&names, if_exists)
1946            }
1947            Statement::CreateType(s) => self.exec_create_type(s),
1948            Statement::DropType { names, if_exists } => self.exec_drop_type(&names, if_exists),
1949            Statement::CreateDomain(s) => self.exec_create_domain(s),
1950            Statement::DropDomain { names, if_exists } => self.exec_drop_domain(&names, if_exists),
1951            Statement::CreateSchema {
1952                name,
1953                if_not_exists,
1954            } => self.exec_create_schema(name, if_not_exists),
1955            Statement::DropSchema { names, if_exists } => self.exec_drop_schema(&names, if_exists),
1956            Statement::ResetParameter(target) => {
1957                match target {
1958                    None => self.session_params.clear(),
1959                    Some(name) => {
1960                        self.session_params.remove(&name.to_ascii_lowercase());
1961                    }
1962                }
1963                Ok(QueryResult::CommandOk {
1964                    affected: 0,
1965                    modified_catalog: false,
1966                })
1967            }
1968        };
1969        self.enforce_row_limit(result)
1970    }
1971
1972    /// v6.1.2 — `CREATE PUBLICATION` runtime path. Duplicate names
1973    /// surface as `EngineError::Unsupported` so the existing PG-wire
1974    /// error mapping stays uniform; the message carries the name so
1975    /// operators can grep replication-log noise. Inside-transaction
1976    /// invocation is rejected (matches `CREATE USER` / `DROP USER`
1977    /// stance) — replication-catalog mutation is a connection-level
1978    /// administrative op, not a transactional one.
1979    fn exec_create_publication(
1980        &mut self,
1981        s: CreatePublicationStatement,
1982    ) -> Result<QueryResult, EngineError> {
1983        // v6.1.4 — the v6.1.2 "no DDL inside a transaction" guard
1984        // was over-cautious: it also blocked the auto-commit wrap
1985        // path (which begins an internal TX around every WAL-
1986        // logged statement). PG itself allows CREATE PUBLICATION
1987        // inside a transaction (it rolls back with the TX).
1988        self.publications
1989            .create(s.name, s.scope)
1990            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE PUBLICATION: {e:?}")))?;
1991        Ok(QueryResult::CommandOk {
1992            affected: 1,
1993            modified_catalog: true,
1994        })
1995    }
1996
1997    /// v6.1.2 — `DROP PUBLICATION` runtime path. PG-compatible silent
1998    /// no-op when the publication doesn't exist (returns `affected=0`
1999    /// in that case so the wire-level command tag distinguishes
2000    /// "dropped" from "no-op", though both succeed).
2001    fn exec_drop_publication(&mut self, name: &str) -> Result<QueryResult, EngineError> {
2002        let removed = self.publications.drop(name);
2003        Ok(QueryResult::CommandOk {
2004            affected: usize::from(removed),
2005            modified_catalog: removed,
2006        })
2007    }
2008
2009    /// v6.1.2 — read access to the publication catalog. Used by
2010    /// the v6.1.5 publisher-side WAL filter, by `SHOW PUBLICATIONS`
2011    /// (v6.1.3+), and by e2e tests that need to assert state without
2012    /// going through the wire.
2013    pub const fn publications(&self) -> &publications::Publications {
2014        &self.publications
2015    }
2016
2017    /// v6.1.4 — `CREATE SUBSCRIPTION` runtime path. Defaults
2018    /// `enabled = true` and `last_received_pos = 0` for a freshly-
2019    /// created subscription. The actual worker thread is spawned
2020    /// by spg-server once the engine returns success.
2021    fn exec_create_subscription(
2022        &mut self,
2023        s: CreateSubscriptionStatement,
2024    ) -> Result<QueryResult, EngineError> {
2025        // See exec_create_publication — the in_transaction gate
2026        // was over-cautious; the auto-commit wrap path holds an
2027        // internal TX that this check was incorrectly blocking.
2028        let sub = subscriptions::Subscription {
2029            conn_str: s.conn_str,
2030            publications: s.publications,
2031            enabled: true,
2032            last_received_pos: 0,
2033        };
2034        self.subscriptions
2035            .create(s.name, sub)
2036            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE SUBSCRIPTION: {e:?}")))?;
2037        Ok(QueryResult::CommandOk {
2038            affected: 1,
2039            modified_catalog: true,
2040        })
2041    }
2042
2043    /// v6.1.4 — `DROP SUBSCRIPTION`. Silent no-op when the name
2044    /// doesn't exist (PG-compatible). The associated worker is
2045    /// torn down by spg-server when it observes the catalog
2046    /// change at the next snapshot or via the engine's
2047    /// subscriptions accessor (the worker polls the catalog on
2048    /// reconnect; v6.1.5's filter-side will tighten this to an
2049    /// explicit signal).
2050    fn exec_drop_subscription(&mut self, name: &str) -> Result<QueryResult, EngineError> {
2051        let removed = self.subscriptions.drop(name);
2052        Ok(QueryResult::CommandOk {
2053            affected: usize::from(removed),
2054            modified_catalog: removed,
2055        })
2056    }
2057
2058    /// v6.1.4 — read access to the subscription catalog. Used by
2059    /// the subscription worker (read its own row to find its
2060    /// publications + last applied position), by SHOW SUBSCRIPTIONS,
2061    /// and by e2e tests asserting state directly.
2062    pub const fn subscriptions(&self) -> &subscriptions::Subscriptions {
2063        &self.subscriptions
2064    }
2065
2066    /// v6.1.4 — write access to `last_received_pos`. Worker
2067    /// calls this after each apply batch (under the engine's
2068    /// write-lock). Returns `false` when the subscription was
2069    /// dropped between when the worker received the record and
2070    /// when this call landed.
2071    pub fn subscription_advance(&mut self, name: &str, pos: u64) -> bool {
2072        self.subscriptions.update_last_received_pos(name, pos)
2073    }
2074
2075    /// v6.1.4 — `SHOW SUBSCRIPTIONS` row materialisation. Returns
2076    /// `(name, conn_str, publications, enabled, last_received_pos)`
2077    /// ordered by subscription name. The `publications` column is
2078    /// the comma-joined list ("p1, p2") for ergonomic SHOW output;
2079    /// callers wanting structured access read `Engine::subscriptions`.
2080    fn exec_show_subscriptions(&self) -> QueryResult {
2081        let columns = alloc::vec![
2082            ColumnSchema::new("name", DataType::Text, false),
2083            ColumnSchema::new("conn_str", DataType::Text, false),
2084            ColumnSchema::new("publications", DataType::Text, false),
2085            ColumnSchema::new("enabled", DataType::Bool, false),
2086            ColumnSchema::new("last_received_pos", DataType::BigInt, false),
2087        ];
2088        let rows: Vec<Row> = self
2089            .subscriptions
2090            .iter()
2091            .map(|(name, sub)| {
2092                Row::new(alloc::vec![
2093                    Value::Text(name.clone()),
2094                    Value::Text(sub.conn_str.clone()),
2095                    Value::Text(sub.publications.join(", ")),
2096                    Value::Bool(sub.enabled),
2097                    Value::BigInt(i64::try_from(sub.last_received_pos).unwrap_or(i64::MAX)),
2098                ])
2099            })
2100            .collect();
2101        QueryResult::Rows { columns, rows }
2102    }
2103
2104    /// v6.2.0 — materialise `spg_statistic` rows. One row per
2105    /// `(table, column)` pair tracked in `Statistics`, with
2106    /// `histogram_bounds` rendered as a `[v0, v1, ...]` string —
2107    /// the same canonical form vector literals use for round-trip.
2108    fn exec_spg_statistic(&self) -> QueryResult {
2109        let columns = alloc::vec![
2110            ColumnSchema::new("table_name", DataType::Text, false),
2111            ColumnSchema::new("column_name", DataType::Text, false),
2112            ColumnSchema::new("null_frac", DataType::Float, false),
2113            ColumnSchema::new("n_distinct", DataType::BigInt, false),
2114            ColumnSchema::new("histogram_bounds", DataType::Text, false),
2115            // v6.7.0 — appended column (v6.2.0 stability contract
2116            // allows APPEND to spg_statistic, not reorder/rename).
2117            // Reports the cached per-table cold-row count; same
2118            // value across every column row of the same table.
2119            ColumnSchema::new("cold_row_count", DataType::BigInt, false),
2120        ];
2121        let rows: Vec<Row> = self
2122            .statistics
2123            .iter()
2124            .map(|((t, c), s)| {
2125                let cold = self
2126                    .catalog
2127                    .get(t)
2128                    .map_or(0, |table| table.cold_row_count());
2129                Row::new(alloc::vec![
2130                    Value::Text(t.clone()),
2131                    Value::Text(c.clone()),
2132                    Value::Float(f64::from(s.null_frac)),
2133                    Value::BigInt(i64::try_from(s.n_distinct).unwrap_or(i64::MAX)),
2134                    Value::Text(render_histogram_bounds(&s.histogram_bounds)),
2135                    Value::BigInt(i64::try_from(cold).unwrap_or(i64::MAX)),
2136                ])
2137            })
2138            .collect();
2139        QueryResult::Rows { columns, rows }
2140    }
2141
2142    /// v6.5.0 — materialise `spg_stat_replication` rows. One row
2143    /// per subscription with `(name, conn_str, publications,
2144    /// last_received_pos, enabled)`. Surface mirrors
2145    /// `SHOW SUBSCRIPTIONS` but follows the virtual-table dispatch
2146    /// shape so it composes with SELECT clauses (WHERE, projection
2147    /// onto specific columns, etc).
2148    fn exec_spg_stat_replication(&self) -> QueryResult {
2149        let columns = alloc::vec![
2150            ColumnSchema::new("name", DataType::Text, false),
2151            ColumnSchema::new("conn_str", DataType::Text, false),
2152            ColumnSchema::new("publications", DataType::Text, false),
2153            ColumnSchema::new("last_received_pos", DataType::BigInt, false),
2154            ColumnSchema::new("enabled", DataType::Bool, false),
2155        ];
2156        let rows: Vec<Row> = self
2157            .subscriptions
2158            .iter()
2159            .map(|(name, sub)| {
2160                Row::new(alloc::vec![
2161                    Value::Text(name.clone()),
2162                    Value::Text(sub.conn_str.clone()),
2163                    Value::Text(sub.publications.join(",")),
2164                    Value::BigInt(i64::try_from(sub.last_received_pos).unwrap_or(i64::MAX)),
2165                    Value::Bool(sub.enabled),
2166                ])
2167            })
2168            .collect();
2169        QueryResult::Rows { columns, rows }
2170    }
2171
2172    /// v6.5.0 — materialise `spg_stat_segment` rows. One row per
2173    /// cold-tier segment with `(segment_id, num_rows, num_pages,
2174    /// total_bytes)`.
2175    ///
2176    /// v6.7.0 — appended `table_name` column resolves the v6.5.0
2177    /// carve-out. Walks every user table's BTree indices to find
2178    /// which table's Cold locators point at each segment. Empty
2179    /// string for orphan segments (loaded via SPG_PRELOAD_COLD_SEGMENT
2180    /// before any index registered a locator). The walk is
2181    /// O(tables × indices × keys); cached per call, not across
2182    /// calls — re-walked on every `SELECT * FROM spg_stat_segment`.
2183    fn exec_spg_stat_segment(&self) -> QueryResult {
2184        let columns = alloc::vec![
2185            ColumnSchema::new("segment_id", DataType::BigInt, false),
2186            ColumnSchema::new("table_name", DataType::Text, false),
2187            ColumnSchema::new("num_rows", DataType::BigInt, false),
2188            ColumnSchema::new("num_pages", DataType::BigInt, false),
2189            ColumnSchema::new("total_bytes", DataType::BigInt, false),
2190        ];
2191        // v6.7.0 — build a segment_id → table_name map by walking
2192        // every user table's BTree indices once. O(tables × indices
2193        // × keys) for the v6.5.0 carve-out resolution; acceptable
2194        // because spg_stat_segment is operator-facing (not on a
2195        // hot-loop path).
2196        let mut segment_owners: alloc::collections::BTreeMap<u32, String> = BTreeMap::new();
2197        for tname in self.catalog.table_names() {
2198            if is_internal_table_name(&tname) {
2199                continue;
2200            }
2201            let Some(t) = self.catalog.get(&tname) else {
2202                continue;
2203            };
2204            for idx in t.indices() {
2205                if let spg_storage::IndexKind::BTree(map) = &idx.kind {
2206                    for (_, locs) in map.iter() {
2207                        for loc in locs {
2208                            if let spg_storage::RowLocator::Cold { segment_id, .. } = loc {
2209                                segment_owners
2210                                    .entry(*segment_id)
2211                                    .or_insert_with(|| tname.clone());
2212                            }
2213                        }
2214                    }
2215                }
2216            }
2217        }
2218        let rows: Vec<Row> = self
2219            .catalog
2220            .cold_segment_ids_global()
2221            .iter()
2222            .filter_map(|&id| {
2223                let seg = self.catalog.cold_segment(id)?;
2224                let meta = seg.meta();
2225                let owner = segment_owners.get(&id).cloned().unwrap_or_default();
2226                Some(Row::new(alloc::vec![
2227                    Value::BigInt(i64::from(id)),
2228                    Value::Text(owner),
2229                    Value::BigInt(i64::try_from(meta.num_rows).unwrap_or(i64::MAX)),
2230                    Value::BigInt(i64::from(meta.num_pages)),
2231                    Value::BigInt(i64::try_from(meta.total_bytes).unwrap_or(i64::MAX)),
2232                ]))
2233            })
2234            .collect();
2235        QueryResult::Rows { columns, rows }
2236    }
2237
2238    /// v6.5.1 — materialise `spg_stat_query` rows. One row per
2239    /// distinct SQL text recorded since the engine booted, capped
2240    /// at `QUERY_STATS_MAX` (1024). Columns:
2241    ///   sql, exec_count, total_us, mean_us, max_us, last_seen_us
2242    /// mean_us = total_us / exec_count (saturating).
2243    fn exec_spg_stat_query(&self) -> QueryResult {
2244        let columns = alloc::vec![
2245            ColumnSchema::new("sql", DataType::Text, false),
2246            ColumnSchema::new("exec_count", DataType::BigInt, false),
2247            ColumnSchema::new("total_us", DataType::BigInt, false),
2248            ColumnSchema::new("mean_us", DataType::BigInt, false),
2249            ColumnSchema::new("max_us", DataType::BigInt, false),
2250            ColumnSchema::new("last_seen_us", DataType::BigInt, false),
2251        ];
2252        let rows: Vec<Row> = self
2253            .query_stats
2254            .snapshot()
2255            .into_iter()
2256            .map(|(sql, s)| {
2257                let mean = if s.exec_count == 0 {
2258                    0
2259                } else {
2260                    s.total_us / s.exec_count
2261                };
2262                Row::new(alloc::vec![
2263                    Value::Text(sql),
2264                    Value::BigInt(i64::try_from(s.exec_count).unwrap_or(i64::MAX)),
2265                    Value::BigInt(i64::try_from(s.total_us).unwrap_or(i64::MAX)),
2266                    Value::BigInt(i64::try_from(mean).unwrap_or(i64::MAX)),
2267                    Value::BigInt(i64::try_from(s.max_us).unwrap_or(i64::MAX)),
2268                    Value::BigInt(i64::try_from(s.last_seen_us).unwrap_or(i64::MAX)),
2269                ])
2270            })
2271            .collect();
2272        QueryResult::Rows { columns, rows }
2273    }
2274
2275    /// v6.5.2 — register a connection-state provider. spg-server
2276    /// calls this at startup with a function that snapshots its
2277    /// per-pgwire-connection registry. Engine reads through the
2278    /// callback on `SELECT * FROM spg_stat_activity`.
2279    #[must_use]
2280    pub const fn with_activity_provider(mut self, f: ActivityProvider) -> Self {
2281        self.activity_provider = Some(f);
2282        self
2283    }
2284
2285    /// v6.5.3 — register audit chain provider + verifier.
2286    #[must_use]
2287    pub const fn with_audit_providers(
2288        mut self,
2289        chain: AuditChainProvider,
2290        verify: AuditVerifier,
2291    ) -> Self {
2292        self.audit_chain_provider = Some(chain);
2293        self.audit_verifier = Some(verify);
2294        self
2295    }
2296
2297    /// v6.5.6 — register a slow-query log callback. `threshold_us`
2298    /// is the floor (in microseconds); only executes above the floor
2299    /// fire the callback. spg-server wires this from
2300    /// `SPG_SLOW_QUERY_THRESHOLD_MS` (default 100 ms).
2301    #[must_use]
2302    pub const fn with_slow_query_log(mut self, threshold_us: u64, logger: SlowQueryLogger) -> Self {
2303        self.slow_query_threshold_us = Some(threshold_us);
2304        self.slow_query_logger = Some(logger);
2305        self
2306    }
2307
2308    /// v6.5.6 — operator knob for plan cache cap. spg-server reads
2309    /// `SPG_PLAN_CACHE_MAX` env at startup; uses this to override
2310    /// the compile-time default of 256.
2311    pub fn set_plan_cache_max(&mut self, n: usize) {
2312        self.plan_cache.set_max_entries(n);
2313    }
2314
2315    /// v6.5.2 — materialise `spg_stat_activity` rows. Pulls a fresh
2316    /// snapshot from the registered `ActivityProvider`. Returns an
2317    /// empty result set when no provider is registered (the no_std
2318    /// embedded path with no pgwire layer).
2319    fn exec_spg_stat_activity(&self) -> QueryResult {
2320        let columns = alloc::vec![
2321            ColumnSchema::new("pid", DataType::Int, false),
2322            ColumnSchema::new("user", DataType::Text, false),
2323            ColumnSchema::new("started_at_us", DataType::BigInt, false),
2324            ColumnSchema::new("current_sql", DataType::Text, false),
2325            ColumnSchema::new("wait_event", DataType::Text, false),
2326            ColumnSchema::new("elapsed_us", DataType::BigInt, false),
2327            ColumnSchema::new("in_transaction", DataType::Bool, false),
2328            ColumnSchema::new("application_name", DataType::Text, false),
2329        ];
2330        let rows: Vec<Row> = self
2331            .activity_provider
2332            .map(|f| f())
2333            .unwrap_or_default()
2334            .into_iter()
2335            .map(|r| {
2336                Row::new(alloc::vec![
2337                    Value::Int(i32::try_from(r.pid).unwrap_or(i32::MAX)),
2338                    Value::Text(r.user),
2339                    Value::BigInt(r.started_at_us),
2340                    Value::Text(r.current_sql),
2341                    Value::Text(r.wait_event),
2342                    Value::BigInt(r.elapsed_us),
2343                    Value::Bool(r.in_transaction),
2344                    Value::Text(r.application_name),
2345                ])
2346            })
2347            .collect();
2348        QueryResult::Rows { columns, rows }
2349    }
2350
2351    /// v6.5.4 — materialise `spg_table_ddl` rows. One row per user
2352    /// table with `(table_name, ddl)`. Reconstructed from catalog
2353    /// state on demand.
2354    fn exec_spg_table_ddl(&self) -> QueryResult {
2355        let columns = alloc::vec![
2356            ColumnSchema::new("table_name", DataType::Text, false),
2357            ColumnSchema::new("ddl", DataType::Text, false),
2358        ];
2359        let rows: Vec<Row> = self
2360            .catalog
2361            .table_names()
2362            .into_iter()
2363            .filter(|n| !is_internal_table_name(n))
2364            .filter_map(|name| {
2365                let table = self.catalog.get(&name)?;
2366                let ddl = render_create_table(&name, &table.schema().columns);
2367                Some(Row::new(alloc::vec![Value::Text(name), Value::Text(ddl),]))
2368            })
2369            .collect();
2370        QueryResult::Rows { columns, rows }
2371    }
2372
2373    /// v6.5.4 — materialise `spg_role_ddl` rows. One row per user
2374    /// with `(role_name, ddl)`. Password is redacted (matches the
2375    /// `Statement::CreateUser` Display which prints `'<redacted>'`).
2376    fn exec_spg_role_ddl(&self) -> QueryResult {
2377        let columns = alloc::vec![
2378            ColumnSchema::new("role_name", DataType::Text, false),
2379            ColumnSchema::new("ddl", DataType::Text, false),
2380        ];
2381        let rows: Vec<Row> = self
2382            .users
2383            .iter()
2384            .map(|(name, rec)| {
2385                let ddl = alloc::format!(
2386                    "CREATE USER {name} WITH PASSWORD '<redacted>' ROLE '{}'",
2387                    rec.role.as_str(),
2388                );
2389                Row::new(alloc::vec![
2390                    Value::Text(String::from(name)),
2391                    Value::Text(ddl)
2392                ])
2393            })
2394            .collect();
2395        QueryResult::Rows { columns, rows }
2396    }
2397
2398    /// v6.5.4 — materialise `spg_database_ddl`: single row whose
2399    /// `ddl` column concatenates every user table's CREATE +
2400    /// every role's CREATE in deterministic catalog order. Suitable
2401    /// for piping back through `Engine::execute` to recreate a
2402    /// schema-equivalent database.
2403    fn exec_spg_database_ddl(&self) -> QueryResult {
2404        let columns = alloc::vec![ColumnSchema::new("ddl", DataType::Text, false)];
2405        let mut out = String::new();
2406        for (name, rec) in self.users.iter() {
2407            out.push_str(&alloc::format!(
2408                "CREATE USER {name} WITH PASSWORD '<redacted>' ROLE '{}';\n",
2409                rec.role.as_str(),
2410            ));
2411        }
2412        for name in self.catalog.table_names() {
2413            if is_internal_table_name(&name) {
2414                continue;
2415            }
2416            if let Some(table) = self.catalog.get(&name) {
2417                out.push_str(&render_create_table(&name, &table.schema().columns));
2418                out.push_str(";\n");
2419            }
2420        }
2421        QueryResult::Rows {
2422            columns,
2423            rows: alloc::vec![Row::new(alloc::vec![Value::Text(out)])],
2424        }
2425    }
2426
2427    /// v6.5.3 — materialise `spg_audit_chain` rows. Pulls a fresh
2428    /// snapshot from the registered provider; empty when no
2429    /// provider is set.
2430    fn exec_spg_audit_chain(&self) -> QueryResult {
2431        let columns = alloc::vec![
2432            ColumnSchema::new("seq", DataType::BigInt, false),
2433            ColumnSchema::new("ts_ms", DataType::BigInt, false),
2434            ColumnSchema::new("prev_hash", DataType::Text, false),
2435            ColumnSchema::new("entry_hash", DataType::Text, false),
2436            ColumnSchema::new("sql", DataType::Text, false),
2437        ];
2438        let rows: Vec<Row> = self
2439            .audit_chain_provider
2440            .map(|f| f())
2441            .unwrap_or_default()
2442            .into_iter()
2443            .map(|r| {
2444                Row::new(alloc::vec![
2445                    Value::BigInt(r.seq),
2446                    Value::BigInt(r.ts_ms),
2447                    Value::Text(r.prev_hash_hex),
2448                    Value::Text(r.entry_hash_hex),
2449                    Value::Text(r.sql),
2450                ])
2451            })
2452            .collect();
2453        QueryResult::Rows { columns, rows }
2454    }
2455
2456    /// v6.5.3 — materialise `spg_audit_verify` single-row result.
2457    /// `(verified_count, broken_at_seq)` — broken_at_seq is `-1`
2458    /// on a clean chain. Returns one row with both values 0 when
2459    /// no verifier is registered (no-data fallback for embedded
2460    /// callers).
2461    fn exec_spg_audit_verify(&self) -> QueryResult {
2462        let columns = alloc::vec![
2463            ColumnSchema::new("verified_count", DataType::BigInt, false),
2464            ColumnSchema::new("broken_at_seq", DataType::BigInt, false),
2465        ];
2466        let (verified, broken) = self.audit_verifier.map(|f| f()).unwrap_or((0, -1));
2467        let row = Row::new(alloc::vec![Value::BigInt(verified), Value::BigInt(broken),]);
2468        QueryResult::Rows {
2469            columns,
2470            rows: alloc::vec![row],
2471        }
2472    }
2473
2474    /// v6.5.1 — read-only accessor for tests + v6.5.6 ops resets.
2475    pub fn query_stats(&self) -> &query_stats::QueryStats {
2476        &self.query_stats
2477    }
2478
2479    /// v6.5.1 — mutable accessor (clear, etc).
2480    pub fn query_stats_mut(&mut self) -> &mut query_stats::QueryStats {
2481        &mut self.query_stats
2482    }
2483
2484    /// v6.2.0 — read access to the per-column statistics table.
2485    /// Used by the planner (v6.2.2 selectivity functions read this),
2486    /// by `SELECT * FROM spg_statistic`, and by e2e tests.
2487    pub const fn statistics(&self) -> &statistics::Statistics {
2488        &self.statistics
2489    }
2490
2491    /// v6.2.1 — return tables whose modified-row count crossed the
2492    /// auto-analyze threshold since the last ANALYZE on that table.
2493    /// The threshold is `0.1 × max(row_count, MIN_ROWS_FOR_AUTO_
2494    /// ANALYZE)` — combines PG-style fractional + absolute lower
2495    /// bound so a fresh / tiny table doesn't get hammered on every
2496    /// INSERT.
2497    ///
2498    /// Designed to be cheap: walks every user table's
2499    /// `Catalog::table_names()` + reads `statistics::modified_
2500    /// since_last_analyze()` (BTreeMap lookup). The background
2501    /// worker calls this under `engine.read()` then drops the lock
2502    /// before re-acquiring `engine.write()` for the actual ANALYZE.
2503    pub fn tables_needing_analyze(&self) -> Vec<String> {
2504        const MIN_ROWS: u64 = 100;
2505        let mut out = Vec::new();
2506        for name in self.catalog.table_names() {
2507            if is_internal_table_name(&name) {
2508                continue;
2509            }
2510            let Some(table) = self.catalog.get(&name) else {
2511                continue;
2512            };
2513            let row_count = table.rows().len() as u64;
2514            let modified = self.statistics.modified_since_last_analyze(&name);
2515            // Threshold: ceil(0.1 × max(row_count, MIN_ROWS)),
2516            // computed in integer arithmetic so spg-engine stays
2517            // no_std without pulling in libm. `(n + 9) / 10` is
2518            // `ceil(n / 10)` for non-negative `n`.
2519            let base = row_count.max(MIN_ROWS);
2520            let threshold = base.saturating_add(9) / 10;
2521            if modified >= threshold {
2522                out.push(name);
2523            }
2524        }
2525        out
2526    }
2527
2528    /// v6.2.0 — `ANALYZE [<table>]` runtime. Bare `ANALYZE` walks
2529    /// every user table; `ANALYZE <name>` re-stats one. For each
2530    /// target table, single-pass scan + per-column histogram +
2531    /// `null_frac` + `n_distinct`. Replaces the table's prior
2532    /// stats; resets the modified-row counter.
2533    ///
2534    /// v6.2.0 doesn't sample — it scans the full table. v6.2.x
2535    /// can add reservoir sampling at the > 100 K-row mark; not a
2536    /// scope blocker for the current commit since rows ≤ 100 K
2537    /// analyse in milliseconds.
2538    fn exec_analyze(&mut self, target: Option<&str>) -> Result<QueryResult, EngineError> {
2539        let names: Vec<String> = if let Some(name) = target {
2540            // Verify the table exists; surface a clear error if not.
2541            if self.catalog.get(name).is_none() {
2542                return Err(EngineError::Storage(StorageError::TableNotFound {
2543                    name: name.to_string(),
2544                }));
2545            }
2546            alloc::vec![name.to_string()]
2547        } else {
2548            self.catalog
2549                .table_names()
2550                .into_iter()
2551                .filter(|n| !is_internal_table_name(n))
2552                .collect()
2553        };
2554        let mut analysed = 0usize;
2555        for table_name in &names {
2556            self.analyze_one_table(table_name)?;
2557            analysed += 1;
2558        }
2559        // v6.3.1 — plan cache invalidation. Bump stats version so
2560        // future lookups see the new generation, and selectively
2561        // evict every plan whose `source_tables` overlap with the
2562        // ANALYZE target set. Bare ANALYZE (all tables) clears the
2563        // whole cache.
2564        if analysed > 0 {
2565            self.statistics.bump_version();
2566            if target.is_some() {
2567                for t in &names {
2568                    self.plan_cache.evict_referencing(t);
2569                }
2570            } else {
2571                self.plan_cache.clear();
2572            }
2573        }
2574        Ok(QueryResult::CommandOk {
2575            affected: analysed,
2576            modified_catalog: true,
2577        })
2578    }
2579
2580    /// v6.7.3 — `COMPACT COLD SEGMENTS` runtime path. Drives the
2581    /// engine-layer compaction shim with the default
2582    /// 4 MiB segment-size threshold. spg-server intercepts the
2583    /// SQL before it reaches the engine on a server build —
2584    /// it reads `SPG_COMPACTION_TARGET_SEGMENT_BYTES`, calls
2585    /// `Engine::compact_cold_segments_with_target` directly with
2586    /// the env value, and persists every merged segment to
2587    /// v7.12.1 — record a `SET <name> = <value>` parameter. Names
2588    /// are case-folded to lowercase to match PG; values keep their
2589    /// caller-supplied form so observability paths see what was
2590    /// requested. Only `default_text_search_config` is consulted by
2591    /// the engine today.
2592    fn set_session_param(&mut self, name: String, value: spg_sql::ast::SetValue) {
2593        let normalised = match value {
2594            spg_sql::ast::SetValue::String(s) => s,
2595            spg_sql::ast::SetValue::Ident(s) => s,
2596            spg_sql::ast::SetValue::Number(s) => s,
2597            spg_sql::ast::SetValue::Default => String::new(),
2598        };
2599        let key = name.to_ascii_lowercase();
2600        // v7.14.0 — mysqldump preamble emits
2601        // `SET FOREIGN_KEY_CHECKS=0` so it can CREATE TABLE in any
2602        // order despite cross-table FK references; the closing
2603        // section emits `SET FOREIGN_KEY_CHECKS=1` (or
2604        // `=@OLD_FOREIGN_KEY_CHECKS` which resolves to "ON" in our
2605        // session-variable-aware path). Match both shapes.
2606        // Also accept PG's `session_replication_role = 'replica'`
2607        // which suppresses trigger + FK enforcement during a
2608        // logical replication apply (pg_dump preserves this for
2609        // schema-only mode but it shows up in some restores).
2610        let value_off = matches!(
2611            normalised.to_ascii_lowercase().as_str(),
2612            "0" | "off" | "false"
2613        );
2614        let value_on = matches!(
2615            normalised.to_ascii_lowercase().as_str(),
2616            "1" | "on" | "true"
2617        );
2618        if key == "foreign_key_checks"
2619            || key == "session_replication_role" && normalised.eq_ignore_ascii_case("replica")
2620        {
2621            if value_off || key == "session_replication_role" {
2622                self.foreign_key_checks = false;
2623            } else if value_on
2624                || (key == "session_replication_role" && normalised.eq_ignore_ascii_case("origin"))
2625            {
2626                self.foreign_key_checks = true;
2627                // Drain pending FK queue against the now-complete
2628                // catalog. Errors here surface as the SET reply —
2629                // caller knows enabling checks revealed orphans.
2630                let _ = self.drain_pending_foreign_keys();
2631            }
2632        }
2633        self.session_params.insert(key, normalised);
2634    }
2635
2636    /// v7.14.0 — resolve every queued FK whose installation was
2637    /// deferred (`SET FOREIGN_KEY_CHECKS=0` window). Called by
2638    /// `set_session_param` when checks flip back on and by the
2639    /// drop-import release gate. Each FK is resolved against the
2640    /// current catalog; remaining missing-parent errors propagate
2641    /// up so the caller knows the import was incomplete.
2642    fn drain_pending_foreign_keys(&mut self) -> Result<(), EngineError> {
2643        let pending = core::mem::take(&mut self.pending_foreign_keys);
2644        for (child, fk) in pending {
2645            // Resolve against the current catalog. Skip silently
2646            // when the child table itself was dropped between
2647            // queue + drain.
2648            let cols_snapshot = match self.active_catalog().get(&child) {
2649                Some(t) => t.schema().columns.clone(),
2650                None => continue,
2651            };
2652            let storage_fk =
2653                resolve_foreign_key(&child, &cols_snapshot, fk, self.active_catalog())?;
2654            let table = self
2655                .active_catalog_mut()
2656                .get_mut(&child)
2657                .expect("checked above");
2658            table.schema_mut().foreign_keys.push(storage_fk);
2659        }
2660        Ok(())
2661    }
2662
2663    /// v7.12.1 — read a session parameter set via `SET`. Used by
2664    /// the FTS function dispatcher to resolve the default config
2665    /// for `to_tsvector(text)` / `plainto_tsquery(text)` etc.
2666    #[must_use]
2667    pub fn session_param(&self, name: &str) -> Option<&str> {
2668        self.session_params
2669            .get(&name.to_ascii_lowercase())
2670            .map(String::as_str)
2671    }
2672
2673    /// v7.12.1 — build an `EvalContext` chained with the session's
2674    /// `default_text_search_config`. Engine-internal callers use
2675    /// this instead of `EvalContext::new` so the FTS function
2676    /// dispatcher sees the SET configuration.
2677    fn ev_ctx<'a>(
2678        &'a self,
2679        columns: &'a [ColumnSchema],
2680        alias: Option<&'a str>,
2681    ) -> EvalContext<'a> {
2682        EvalContext::new(columns, alias)
2683            .with_default_text_search_config(self.session_param("default_text_search_config"))
2684    }
2685
2686    /// `<db>.spg/segments/`. This arm only fires for engine-only
2687    /// callers (spg-embedded, lib tests); in that mode merged
2688    /// segments live in memory and are dropped at process exit.
2689    fn exec_compact_cold_segments(&mut self) -> Result<QueryResult, EngineError> {
2690        let target = COMPACTION_TARGET_DEFAULT_BYTES;
2691        let reports = self.compact_cold_segments_with_target(target)?;
2692        let columns = alloc::vec![
2693            ColumnSchema::new("table_name", DataType::Text, false),
2694            ColumnSchema::new("index_name", DataType::Text, false),
2695            ColumnSchema::new("sources_merged", DataType::BigInt, false),
2696            ColumnSchema::new("merged_segment_id", DataType::BigInt, false),
2697            ColumnSchema::new("merged_rows", DataType::BigInt, false),
2698            ColumnSchema::new("deleted_rows_pruned", DataType::BigInt, false),
2699            ColumnSchema::new("bytes_reclaimed_estimate", DataType::BigInt, false),
2700        ];
2701        let rows: Vec<Row> = reports
2702            .into_iter()
2703            .map(|(tname, iname, report)| {
2704                Row::new(alloc::vec![
2705                    Value::Text(tname),
2706                    Value::Text(iname),
2707                    Value::BigInt(i64::try_from(report.sources.len()).unwrap_or(i64::MAX)),
2708                    Value::BigInt(i64::from(report.merged_segment_id.unwrap_or(0))),
2709                    Value::BigInt(i64::try_from(report.merged_rows).unwrap_or(i64::MAX)),
2710                    Value::BigInt(i64::try_from(report.deleted_rows_pruned).unwrap_or(i64::MAX),),
2711                    Value::BigInt(
2712                        i64::try_from(report.bytes_reclaimed_estimate).unwrap_or(i64::MAX),
2713                    ),
2714                ])
2715            })
2716            .collect();
2717        Ok(QueryResult::Rows { columns, rows })
2718    }
2719
2720    /// Walk a single table's rows once and (re-)populate per-column
2721    /// stats. Drops the existing stats for `table` first so columns
2722    /// that have been DROP-ed between ANALYZEs don't leave stale
2723    /// rows.
2724    fn analyze_one_table(&mut self, table_name: &str) -> Result<(), EngineError> {
2725        let table = self.catalog.get(table_name).ok_or_else(|| {
2726            EngineError::Storage(StorageError::TableNotFound {
2727                name: table_name.to_string(),
2728            })
2729        })?;
2730        let schema = table.schema().clone();
2731        let row_count = table.rows().len();
2732        // For each column, collect (sorted) non-NULL textual values
2733        // + count NULLs; then ask `statistics::build_histogram` to
2734        // produce the 101 bounds and `estimate_n_distinct` the
2735        // distinct count.
2736        self.statistics.clear_table(table_name);
2737        for (col_pos, col_schema) in schema.columns.iter().enumerate() {
2738            // v6.2.0 skip: vector columns have their own stats
2739            // shape (HNSW graph topology). v6.2 deliberation #1.
2740            if matches!(col_schema.ty, DataType::Vector { .. }) {
2741                continue;
2742            }
2743            let mut non_null_values: Vec<Value> = Vec::with_capacity(row_count);
2744            let mut nulls: u64 = 0;
2745            for row in table.rows() {
2746                match row.values.get(col_pos) {
2747                    Some(Value::Null) | None => nulls += 1,
2748                    Some(v) => non_null_values.push(v.clone()),
2749                }
2750            }
2751            // Sort by type-aware ordering (Int as int, Text as
2752            // lex, etc.) so histogram bounds reflect the column's
2753            // natural order — not lexicographic on the string
2754            // representation, which would put "9" after "49".
2755            non_null_values.sort_by(|a, b| sort_values_for_histogram(a, b));
2756            let non_null: Vec<String> = non_null_values.iter().map(canonical_value_repr).collect();
2757            let null_frac = if row_count == 0 {
2758                0.0
2759            } else {
2760                #[allow(clippy::cast_precision_loss)]
2761                let f = nulls as f32 / row_count as f32;
2762                f
2763            };
2764            let n_distinct = statistics::estimate_n_distinct(&non_null);
2765            let histogram_bounds = statistics::build_histogram(&non_null);
2766            self.statistics.set(
2767                table_name.to_string(),
2768                col_schema.name.clone(),
2769                statistics::ColumnStats {
2770                    null_frac,
2771                    n_distinct,
2772                    histogram_bounds,
2773                },
2774            );
2775        }
2776        self.statistics.reset_modified(table_name);
2777        // v6.7.0 — refresh the per-table cold_rows cache. Walk the
2778        // BTree indices and count Cold locators (MAX across
2779        // indices); store the result on the table. Surfaced via
2780        // `spg_statistic.cold_row_count` (new column) and
2781        // `spg_stat_segment.table_name` (new column).
2782        let cold_count = {
2783            let table = self
2784                .active_catalog()
2785                .get(table_name)
2786                .expect("table still present");
2787            table.count_cold_locators()
2788        };
2789        let table_mut = self
2790            .active_catalog_mut()
2791            .get_mut(table_name)
2792            .expect("table still present");
2793        table_mut.set_cold_row_count(cold_count);
2794        Ok(())
2795    }
2796
2797    /// v6.1.3 — `SHOW PUBLICATIONS` row materialisation. Returns
2798    /// `(name, scope, table_count)` ordered by publication name.
2799    ///   - `scope` is the human-readable string:
2800    ///       `"FOR ALL TABLES"` /
2801    ///       `"FOR TABLE t1, t2"` /
2802    ///       `"FOR ALL TABLES EXCEPT t1, t2"`.
2803    ///   - `table_count` is NULL for `AllTables`, the list length
2804    ///     otherwise. NULLability lets clients distinguish "publish
2805    ///     everything" from "publish exactly 0 tables" (the v6.1.3
2806    ///     parser forbids the empty list, but the column shape is
2807    ///     ready for the v6.1.5 publisher-side semantics).
2808    fn exec_show_publications(&self) -> QueryResult {
2809        let columns = alloc::vec![
2810            ColumnSchema::new("name", DataType::Text, false),
2811            ColumnSchema::new("scope", DataType::Text, false),
2812            ColumnSchema::new("table_count", DataType::Int, true),
2813        ];
2814        let rows: Vec<Row> = self
2815            .publications
2816            .iter()
2817            .map(|(name, scope)| {
2818                let (scope_str, count_val) = match scope {
2819                    spg_sql::ast::PublicationScope::AllTables => {
2820                        ("FOR ALL TABLES".to_string(), Value::Null)
2821                    }
2822                    spg_sql::ast::PublicationScope::ForTables(ts) => (
2823                        alloc::format!("FOR TABLE {}", ts.join(", ")),
2824                        Value::Int(i32::try_from(ts.len()).unwrap_or(i32::MAX)),
2825                    ),
2826                    spg_sql::ast::PublicationScope::AllTablesExcept(ts) => (
2827                        alloc::format!("FOR ALL TABLES EXCEPT {}", ts.join(", ")),
2828                        Value::Int(i32::try_from(ts.len()).unwrap_or(i32::MAX)),
2829                    ),
2830                };
2831                Row::new(alloc::vec![
2832                    Value::Text(name.clone()),
2833                    Value::Text(scope_str),
2834                    count_val,
2835                ])
2836            })
2837            .collect();
2838        QueryResult::Rows { columns, rows }
2839    }
2840
2841    /// v4.1 `SHOW USERS` — `(name, role)` per row, ordered by name.
2842    fn exec_show_users(&self) -> QueryResult {
2843        let columns = alloc::vec![
2844            ColumnSchema::new("name", DataType::Text, false),
2845            ColumnSchema::new("role", DataType::Text, false),
2846        ];
2847        let rows: Vec<Row> = self
2848            .users
2849            .iter()
2850            .map(|(name, rec)| {
2851                Row::new(alloc::vec![
2852                    Value::Text(name.to_string()),
2853                    Value::Text(rec.role.as_str().to_string()),
2854                ])
2855            })
2856            .collect();
2857        QueryResult::Rows { columns, rows }
2858    }
2859
2860    fn exec_create_user(&mut self, s: &CreateUserStatement) -> Result<QueryResult, EngineError> {
2861        if self.in_transaction() {
2862            return Err(EngineError::Unsupported(
2863                "CREATE USER is not allowed inside a transaction".into(),
2864            ));
2865        }
2866        let role = users::Role::parse(&s.role).ok_or_else(|| {
2867            EngineError::Unsupported(alloc::format!("invalid role: {:?}", s.role))
2868        })?;
2869        // Prefer the host-injected RNG. Falls back to a deterministic
2870        // salt derived from the username only when no RNG is wired —
2871        // acceptable for tests; the server always installs one.
2872        let salt = self.salt_fn.map_or_else(
2873            || {
2874                let mut s_bytes = [0u8; 16];
2875                let digest = spg_crypto::hash(s.name.as_bytes());
2876                s_bytes.copy_from_slice(&digest[..16]);
2877                s_bytes
2878            },
2879            |f| f(),
2880        );
2881        self.users
2882            .create(&s.name, &s.password, role, salt)
2883            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE USER: {e}")))?;
2884        Ok(QueryResult::CommandOk {
2885            affected: 1,
2886            modified_catalog: true,
2887        })
2888    }
2889
2890    fn exec_drop_user(&mut self, name: &str) -> Result<QueryResult, EngineError> {
2891        if self.in_transaction() {
2892            return Err(EngineError::Unsupported(
2893                "DROP USER is not allowed inside a transaction".into(),
2894            ));
2895        }
2896        self.users
2897            .drop(name)
2898            .map_err(|e| EngineError::Unsupported(alloc::format!("DROP USER: {e}")))?;
2899        Ok(QueryResult::CommandOk {
2900            affected: 1,
2901            modified_catalog: true,
2902        })
2903    }
2904
2905    /// v7.12.4 — `CREATE [OR REPLACE] FUNCTION`. Stores the
2906    /// function metadata in the catalog. PL/pgSQL bodies are
2907    /// already parsed by the SQL parser; we re-canonicalise the
2908    /// body to source text for storage (the executor re-parses
2909    /// it at trigger fire time — see the trigger fire path).
2910    fn exec_create_function(
2911        &mut self,
2912        s: spg_sql::ast::CreateFunctionStatement,
2913    ) -> Result<QueryResult, EngineError> {
2914        let args_repr = render_function_args(&s.args);
2915        let returns = match &s.returns {
2916            spg_sql::ast::FunctionReturn::Trigger => alloc::string::String::from("TRIGGER"),
2917            spg_sql::ast::FunctionReturn::Void => alloc::string::String::from("VOID"),
2918            spg_sql::ast::FunctionReturn::Type(t) => alloc::format!("{t}"),
2919            spg_sql::ast::FunctionReturn::Other(s) => s.clone(),
2920        };
2921        let body_text = match &s.body {
2922            spg_sql::ast::FunctionBody::PlPgSql(b) => alloc::format!("{b}"),
2923            spg_sql::ast::FunctionBody::Raw(s) => s.clone(),
2924        };
2925        let def = spg_storage::FunctionDef {
2926            name: s.name.clone(),
2927            args_repr,
2928            returns,
2929            language: s.language.clone(),
2930            body: body_text,
2931        };
2932        self.active_catalog_mut()
2933            .create_function(def, s.or_replace)
2934            .map_err(EngineError::Storage)?;
2935        Ok(QueryResult::CommandOk {
2936            affected: 0,
2937            modified_catalog: true,
2938        })
2939    }
2940
2941    /// v7.12.4 — `CREATE [OR REPLACE] TRIGGER`. The referenced
2942    /// function must already exist in the catalog (forward
2943    /// references defer to a later release). Persists the
2944    /// trigger metadata for the row-write hooks below to consult.
2945    fn exec_create_trigger(
2946        &mut self,
2947        s: spg_sql::ast::CreateTriggerStatement,
2948    ) -> Result<QueryResult, EngineError> {
2949        let timing = match s.timing {
2950            spg_sql::ast::TriggerTiming::Before => "BEFORE",
2951            spg_sql::ast::TriggerTiming::After => "AFTER",
2952            spg_sql::ast::TriggerTiming::InsteadOf => "INSTEAD OF",
2953        };
2954        let events: Vec<alloc::string::String> = s
2955            .events
2956            .iter()
2957            .map(|e| match e {
2958                spg_sql::ast::TriggerEvent::Insert => alloc::string::String::from("INSERT"),
2959                spg_sql::ast::TriggerEvent::Update => alloc::string::String::from("UPDATE"),
2960                spg_sql::ast::TriggerEvent::Delete => alloc::string::String::from("DELETE"),
2961                spg_sql::ast::TriggerEvent::Truncate => alloc::string::String::from("TRUNCATE"),
2962            })
2963            .collect();
2964        let for_each = match s.for_each {
2965            spg_sql::ast::TriggerForEach::Row => "ROW",
2966            spg_sql::ast::TriggerForEach::Statement => "STATEMENT",
2967        };
2968        let def = spg_storage::TriggerDef {
2969            name: s.name.clone(),
2970            table: s.table.clone(),
2971            timing: alloc::string::String::from(timing),
2972            events,
2973            for_each: alloc::string::String::from(for_each),
2974            function: s.function.clone(),
2975            update_columns: s.update_columns.clone(),
2976            // v7.16.1 — every trigger is born enabled. Toggled
2977            // by ALTER TABLE … { ENABLE | DISABLE } TRIGGER.
2978            enabled: true,
2979        };
2980        self.active_catalog_mut()
2981            .create_trigger(def, s.or_replace)
2982            .map_err(EngineError::Storage)?;
2983        Ok(QueryResult::CommandOk {
2984            affected: 0,
2985            modified_catalog: true,
2986        })
2987    }
2988
2989    fn exec_drop_trigger(
2990        &mut self,
2991        name: &str,
2992        table: &str,
2993        if_exists: bool,
2994    ) -> Result<QueryResult, EngineError> {
2995        let removed = self.active_catalog_mut().drop_trigger(name, table);
2996        if !removed && !if_exists {
2997            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
2998                alloc::format!("trigger {name:?} on {table:?} does not exist"),
2999            )));
3000        }
3001        Ok(QueryResult::CommandOk {
3002            affected: usize::from(removed),
3003            modified_catalog: removed,
3004        })
3005    }
3006
3007    fn exec_drop_function(
3008        &mut self,
3009        name: &str,
3010        if_exists: bool,
3011    ) -> Result<QueryResult, EngineError> {
3012        let removed = self.active_catalog_mut().drop_function(name);
3013        if !removed && !if_exists {
3014            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3015                alloc::format!("function {name:?} does not exist"),
3016            )));
3017        }
3018        Ok(QueryResult::CommandOk {
3019            affected: usize::from(removed),
3020            modified_catalog: removed,
3021        })
3022    }
3023
3024    /// v7.17.0 — `CREATE SEQUENCE` engine path. Resolves
3025    /// `min_value` / `max_value` / `start` against PG defaults
3026    /// when omitted, then installs the SequenceDef in the catalog.
3027    fn exec_create_sequence(
3028        &mut self,
3029        s: spg_sql::ast::CreateSequenceStatement,
3030    ) -> Result<QueryResult, EngineError> {
3031        use spg_sql::ast::{SeqBound, SequenceDataType as AstDt};
3032        use spg_storage::{SequenceDataType, SequenceDef};
3033        let dt = match s.data_type {
3034            None => SequenceDataType::BigInt,
3035            Some(AstDt::SmallInt) => SequenceDataType::SmallInt,
3036            Some(AstDt::Int) => SequenceDataType::Int,
3037            Some(AstDt::BigInt) => SequenceDataType::BigInt,
3038        };
3039        let increment = s.options.increment.unwrap_or(1);
3040        if increment == 0 {
3041            return Err(EngineError::Unsupported(
3042                "INCREMENT must not be zero".into(),
3043            ));
3044        }
3045        let (def_min, def_max) = dt.default_bounds(increment > 0);
3046        let min_value = match s.options.min_value {
3047            None | Some(SeqBound::NoBound) => def_min,
3048            Some(SeqBound::Value(n)) => n,
3049        };
3050        let max_value = match s.options.max_value {
3051            None | Some(SeqBound::NoBound) => def_max,
3052            Some(SeqBound::Value(n)) => n,
3053        };
3054        if min_value > max_value {
3055            return Err(EngineError::Unsupported(alloc::format!(
3056                "MINVALUE ({min_value}) must be <= MAXVALUE ({max_value})"
3057            )));
3058        }
3059        let start = s
3060            .options
3061            .start
3062            .unwrap_or(if increment > 0 { min_value } else { max_value });
3063        if start < min_value || start > max_value {
3064            return Err(EngineError::Unsupported(alloc::format!(
3065                "START WITH ({start}) is outside MINVALUE..MAXVALUE ({min_value}..{max_value})"
3066            )));
3067        }
3068        let cache = s.options.cache.unwrap_or(1);
3069        if cache < 1 {
3070            return Err(EngineError::Unsupported("CACHE must be >= 1".into()));
3071        }
3072        let cycle = s.options.cycle.unwrap_or(false);
3073        let owned_by = match s.options.owned_by {
3074            None | Some(spg_sql::ast::SequenceOwnedBy::None) => None,
3075            Some(spg_sql::ast::SequenceOwnedBy::Column { table, column }) => Some((table, column)),
3076        };
3077        let def = SequenceDef {
3078            name: s.name.clone(),
3079            data_type: dt,
3080            start,
3081            increment,
3082            min_value,
3083            max_value,
3084            cache,
3085            cycle,
3086            owned_by,
3087            last_value: start,
3088            is_called: false,
3089        };
3090        self.active_catalog_mut()
3091            .create_sequence(def, s.if_not_exists)
3092            .map_err(EngineError::Storage)?;
3093        Ok(QueryResult::CommandOk {
3094            affected: 0,
3095            modified_catalog: !self.in_transaction(),
3096        })
3097    }
3098
3099    /// v7.17.0 — `ALTER SEQUENCE` engine path. Re-uses the catalog
3100    /// `alter_sequence` merge helper.
3101    fn exec_alter_sequence(
3102        &mut self,
3103        s: spg_sql::ast::AlterSequenceStatement,
3104    ) -> Result<QueryResult, EngineError> {
3105        use spg_sql::ast::SeqBound;
3106        let cat = self.active_catalog_mut();
3107        if !cat.sequences().contains_key(&s.name) {
3108            if s.if_exists {
3109                return Ok(QueryResult::CommandOk {
3110                    affected: 0,
3111                    modified_catalog: false,
3112                });
3113            }
3114            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3115                alloc::format!("sequence {:?} does not exist", s.name),
3116            )));
3117        }
3118        let min_value = match s.options.min_value {
3119            None => None,
3120            Some(SeqBound::NoBound) => None, // NO MINVALUE → keep current
3121            Some(SeqBound::Value(n)) => Some(n),
3122        };
3123        let max_value = match s.options.max_value {
3124            None => None,
3125            Some(SeqBound::NoBound) => None,
3126            Some(SeqBound::Value(n)) => Some(n),
3127        };
3128        let owned_by = s.options.owned_by.map(|ob| match ob {
3129            spg_sql::ast::SequenceOwnedBy::None => None,
3130            spg_sql::ast::SequenceOwnedBy::Column { table, column } => Some((table, column)),
3131        });
3132        cat.alter_sequence(
3133            &s.name,
3134            s.options.increment,
3135            min_value,
3136            max_value,
3137            s.options.start,
3138            s.options.restart,
3139            s.options.cache,
3140            s.options.cycle,
3141            owned_by,
3142        )
3143        .map_err(EngineError::Storage)?;
3144        Ok(QueryResult::CommandOk {
3145            affected: 0,
3146            modified_catalog: !self.in_transaction(),
3147        })
3148    }
3149
3150    /// v7.17.0 Phase 1.1 — walk a Statement tree and pre-resolve
3151    /// any sequence FunctionCall nodes inside its Expr slots.
3152    /// Delegates per-statement-kind: SELECT projection +
3153    /// WHERE, INSERT VALUES, UPDATE SET, DELETE WHERE.
3154    fn pre_resolve_sequence_calls_in_statement(
3155        &mut self,
3156        stmt: &mut Statement,
3157    ) -> Result<(), EngineError> {
3158        match stmt {
3159            Statement::Select(s) => self.pre_resolve_sequence_calls_in_select(s),
3160            Statement::Insert(s) => {
3161                for tuple in &mut s.rows {
3162                    for cell in tuple.iter_mut() {
3163                        self.resolve_sequence_calls_in_expr(cell)?;
3164                    }
3165                }
3166                Ok(())
3167            }
3168            Statement::Update(s) => {
3169                for (_col, expr) in &mut s.assignments {
3170                    self.resolve_sequence_calls_in_expr(expr)?;
3171                }
3172                if let Some(w) = &mut s.where_ {
3173                    self.resolve_sequence_calls_in_expr(w)?;
3174                }
3175                Ok(())
3176            }
3177            Statement::Delete(s) => {
3178                if let Some(w) = &mut s.where_ {
3179                    self.resolve_sequence_calls_in_expr(w)?;
3180                }
3181                Ok(())
3182            }
3183            _ => Ok(()),
3184        }
3185    }
3186
3187    fn pre_resolve_sequence_calls_in_select(
3188        &mut self,
3189        s: &mut spg_sql::ast::SelectStatement,
3190    ) -> Result<(), EngineError> {
3191        for item in &mut s.items {
3192            match item {
3193                spg_sql::ast::SelectItem::Expr { expr, .. } => {
3194                    self.resolve_sequence_calls_in_expr(expr)?;
3195                }
3196                spg_sql::ast::SelectItem::Wildcard => {}
3197            }
3198        }
3199        if let Some(w) = &mut s.where_ {
3200            self.resolve_sequence_calls_in_expr(w)?;
3201        }
3202        Ok(())
3203    }
3204
3205    /// v7.17.0 Phase 1.1 — walk an Expr tree and pre-resolve any
3206    /// `nextval(name)` / `currval(name)` / `setval(name, value[,
3207    /// is_called])` FunctionCall nodes by calling the catalog and
3208    /// replacing the node with the resulting `Expr::Literal`.
3209    /// Used by INSERT VALUES / UPDATE SET / DEFAULT eval so the
3210    /// row-eval path sees pre-computed sequence values instead of
3211    /// needing mutable catalog access mid-eval.
3212    #[allow(clippy::too_many_lines)]
3213    fn resolve_sequence_calls_in_expr(&mut self, expr: &mut Expr) -> Result<(), EngineError> {
3214        match expr {
3215            Expr::Literal(_) | Expr::Column(_) | Expr::Placeholder(_) => Ok(()),
3216            Expr::FunctionCall { name, args } => {
3217                // Descend first so nested calls — e.g.
3218                // setval('seq', currval('other')) — resolve
3219                // innermost-first.
3220                for a in args.iter_mut() {
3221                    self.resolve_sequence_calls_in_expr(a)?;
3222                }
3223                let lc = name.to_ascii_lowercase();
3224                if lc == "nextval" || lc == "currval" || lc == "setval" {
3225                    let v = self.eval_sequence_call(&lc, args)?;
3226                    *expr = Expr::Literal(value_to_literal(v));
3227                }
3228                Ok(())
3229            }
3230            Expr::Binary { lhs, rhs, .. } => {
3231                self.resolve_sequence_calls_in_expr(lhs)?;
3232                self.resolve_sequence_calls_in_expr(rhs)
3233            }
3234            Expr::Unary { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3235            Expr::Cast { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3236            Expr::IsNull { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3237            Expr::Like { expr, pattern, .. } => {
3238                self.resolve_sequence_calls_in_expr(expr)?;
3239                self.resolve_sequence_calls_in_expr(pattern)
3240            }
3241            Expr::Extract { source, .. } => self.resolve_sequence_calls_in_expr(source),
3242            Expr::Array(items) => {
3243                for it in items.iter_mut() {
3244                    self.resolve_sequence_calls_in_expr(it)?;
3245                }
3246                Ok(())
3247            }
3248            // Window / subquery / etc — sequence calls inside these
3249            // are uncommon and require separate row-eval; leave
3250            // untouched for now and rely on the eval-time error
3251            // (no sequence_resolver attached).
3252            _ => Ok(()),
3253        }
3254    }
3255
3256    /// v7.17.0 Phase 1.1 — evaluate a single nextval/currval/
3257    /// setval call. `args` are already pre-resolved Expr nodes
3258    /// (literals) — we extract their constant values.
3259    fn eval_sequence_call(&mut self, op: &str, args: &[Expr]) -> Result<Value, EngineError> {
3260        if args.is_empty() {
3261            return Err(EngineError::Unsupported(alloc::format!(
3262                "{op}() takes at least one argument"
3263            )));
3264        }
3265        let seq_name = match &args[0] {
3266            Expr::Literal(spg_sql::ast::Literal::String(s)) => {
3267                // v7.17 dump-compat — pg_dump emits sequence
3268                // names schema-qualified (`'public.posts_id_seq'`).
3269                // SPG is single-schema; strip a leading
3270                // `public.` / `pg_catalog.` so the catalog lookup
3271                // matches the bare-name CREATE SEQUENCE used.
3272                let trimmed = s
3273                    .strip_prefix("public.")
3274                    .or_else(|| s.strip_prefix("pg_catalog."))
3275                    .unwrap_or(s);
3276                trimmed.to_string()
3277            }
3278            // v7.17 dump-compat — pg_dump also emits
3279            // `nextval('public.posts_id_seq'::regclass)`
3280            // where the cast wraps the literal. Peel the cast
3281            // and continue.
3282            Expr::Cast { expr, .. } => {
3283                if let Expr::Literal(spg_sql::ast::Literal::String(s)) = expr.as_ref() {
3284                    let trimmed = s
3285                        .strip_prefix("public.")
3286                        .or_else(|| s.strip_prefix("pg_catalog."))
3287                        .unwrap_or(s);
3288                    trimmed.to_string()
3289                } else {
3290                    return Err(EngineError::Unsupported(alloc::format!(
3291                        "{op}() first argument must be a literal sequence name"
3292                    )));
3293                }
3294            }
3295            other => {
3296                return Err(EngineError::Unsupported(alloc::format!(
3297                    "{op}() first argument must be a literal sequence name, got {other:?}"
3298                )));
3299            }
3300        };
3301        match op {
3302            "nextval" => {
3303                let v = self
3304                    .active_catalog_mut()
3305                    .sequence_next_value(&seq_name)
3306                    .map_err(EngineError::Storage)?;
3307                Ok(Value::BigInt(v))
3308            }
3309            "currval" => {
3310                let v = self
3311                    .active_catalog()
3312                    .sequence_current_value(&seq_name)
3313                    .map_err(EngineError::Storage)?;
3314                Ok(Value::BigInt(v))
3315            }
3316            "setval" => {
3317                if args.len() < 2 || args.len() > 3 {
3318                    return Err(EngineError::Unsupported(alloc::format!(
3319                        "setval() takes 2 or 3 arguments, got {}",
3320                        args.len()
3321                    )));
3322                }
3323                let value = match &args[1] {
3324                    Expr::Literal(spg_sql::ast::Literal::Integer(n)) => *n,
3325                    other => {
3326                        return Err(EngineError::Unsupported(alloc::format!(
3327                            "setval() value argument must be a literal integer, got {other:?}"
3328                        )));
3329                    }
3330                };
3331                let is_called = if args.len() == 3 {
3332                    match &args[2] {
3333                        Expr::Literal(spg_sql::ast::Literal::Bool(b)) => *b,
3334                        other => {
3335                            return Err(EngineError::Unsupported(alloc::format!(
3336                                "setval() is_called argument must be a literal BOOL, got {other:?}"
3337                            )));
3338                        }
3339                    }
3340                } else {
3341                    true
3342                };
3343                let v = self
3344                    .active_catalog_mut()
3345                    .sequence_set_value(&seq_name, value, is_called)
3346                    .map_err(EngineError::Storage)?;
3347                Ok(Value::BigInt(v))
3348            }
3349            other => Err(EngineError::Unsupported(alloc::format!(
3350                "unknown sequence op {other:?}"
3351            ))),
3352        }
3353    }
3354
3355    /// v7.17.0 Phase 1.2 — find every catalog VIEW referenced in
3356    /// the SELECT's FROM / JOIN graph, re-parse each view's body
3357    /// source, and prepend it as a synthetic CTE on the
3358    /// returned SelectStatement. Returns `None` when no view
3359    /// references are found (caller proceeds with the original
3360    /// statement); returns `Some(rewritten)` otherwise (caller
3361    /// re-runs exec_select_cancel on the rewritten form so the
3362    /// regular CTE materialiser handles it).
3363    fn expand_views_in_select(
3364        &self,
3365        stmt: &SelectStatement,
3366    ) -> Result<Option<SelectStatement>, EngineError> {
3367        let cat = self.active_catalog();
3368        let mut referenced: Vec<String> = Vec::new();
3369        if let Some(from) = &stmt.from {
3370            collect_view_refs(&from.primary, cat, &mut referenced);
3371            for j in &from.joins {
3372                collect_view_refs(&j.table, cat, &mut referenced);
3373            }
3374        }
3375        // Don't expand a view name that's already shadowed by a
3376        // CTE on the same SELECT — the CTE wins per PG.
3377        referenced.retain(|n| !stmt.ctes.iter().any(|c| c.name == *n));
3378        if referenced.is_empty() {
3379            return Ok(None);
3380        }
3381        let mut new_ctes: Vec<spg_sql::ast::Cte> = Vec::with_capacity(referenced.len());
3382        for name in &referenced {
3383            let view = cat.views().get(name).ok_or_else(|| {
3384                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3385                    "view {name:?} disappeared mid-expansion"
3386                )))
3387            })?;
3388            let parsed = spg_sql::parser::parse_statement(&view.body).map_err(|e| {
3389                EngineError::Unsupported(alloc::format!("view {name:?} body re-parse failed: {e}"))
3390            })?;
3391            let Statement::Select(body) = parsed else {
3392                return Err(EngineError::Unsupported(alloc::format!(
3393                    "view {name:?} body is not a SELECT (catalog corruption)"
3394                )));
3395            };
3396            new_ctes.push(spg_sql::ast::Cte {
3397                name: name.clone(),
3398                body,
3399                recursive: false,
3400                column_overrides: view.columns.clone(),
3401            });
3402        }
3403        let mut out = stmt.clone();
3404        // Prepend so view CTEs are visible to caller-supplied CTEs.
3405        new_ctes.extend(out.ctes);
3406        out.ctes = new_ctes;
3407        Ok(Some(out))
3408    }
3409
3410    /// v7.17.0 Phase 1.2 — `CREATE VIEW` engine path. Stores the
3411    /// Display-rendered body verbatim in the catalog; SELECT-from-
3412    /// view at exec time re-parses + prepends as a synthetic CTE.
3413    fn exec_create_view(
3414        &mut self,
3415        s: spg_sql::ast::CreateViewStatement,
3416    ) -> Result<QueryResult, EngineError> {
3417        // Render the SELECT body to canonical form so the catalog
3418        // round-trips a deterministic source (no whitespace /
3419        // comment surprises in the on-disk snapshot).
3420        let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body));
3421        let def = spg_storage::ViewDef {
3422            name: s.name.clone(),
3423            columns: s.columns,
3424            body: body_repr,
3425        };
3426        self.active_catalog_mut()
3427            .create_view(def, s.or_replace, s.if_not_exists)
3428            .map_err(EngineError::Storage)?;
3429        Ok(QueryResult::CommandOk {
3430            affected: 0,
3431            modified_catalog: !self.in_transaction(),
3432        })
3433    }
3434
3435    /// v7.17.0 Phase 1.4 — `CREATE TYPE name AS ENUM (…)` engine
3436    /// path. Registers the enum in the catalog with order-
3437    /// preserving labels. PG semantics: CREATE TYPE errors if the
3438    /// name is taken (no IF NOT EXISTS).
3439    fn exec_create_type(
3440        &mut self,
3441        s: spg_sql::ast::CreateTypeStatement,
3442    ) -> Result<QueryResult, EngineError> {
3443        // Name-collision check against tables / sequences / views /
3444        // materialized views.
3445        let cat = self.active_catalog();
3446        if cat.get(&s.name).is_some() {
3447            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3448                alloc::format!("type {:?} would shadow an existing table", s.name),
3449            )));
3450        }
3451        if cat.sequences().contains_key(&s.name) {
3452            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3453                alloc::format!("type {:?} would shadow an existing sequence", s.name),
3454            )));
3455        }
3456        if cat.views().contains_key(&s.name) {
3457            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3458                alloc::format!("type {:?} would shadow an existing view", s.name),
3459            )));
3460        }
3461        let def = match s.kind {
3462            spg_sql::ast::TypeKind::Enum { labels } => {
3463                if labels.is_empty() {
3464                    return Err(EngineError::Unsupported(
3465                        "CREATE TYPE … AS ENUM requires at least one label".into(),
3466                    ));
3467                }
3468                // Reject duplicate labels per PG.
3469                for i in 0..labels.len() {
3470                    for j in (i + 1)..labels.len() {
3471                        if labels[i] == labels[j] {
3472                            return Err(EngineError::Unsupported(alloc::format!(
3473                                "CREATE TYPE {:?}: duplicate ENUM label {:?}",
3474                                s.name,
3475                                labels[i]
3476                            )));
3477                        }
3478                    }
3479                }
3480                spg_storage::EnumDef {
3481                    name: s.name.clone(),
3482                    labels,
3483                }
3484            }
3485        };
3486        self.active_catalog_mut()
3487            .create_enum_type(def)
3488            .map_err(EngineError::Storage)?;
3489        Ok(QueryResult::CommandOk {
3490            affected: 0,
3491            modified_catalog: !self.in_transaction(),
3492        })
3493    }
3494
3495    /// v7.17.0 Phase 1.5 — `CREATE DOMAIN name AS base [DEFAULT
3496    /// expr] [NOT NULL] [CHECK (expr)]*` engine path. Stores the
3497    /// base type + Display-rendered CHECK / DEFAULT sources so
3498    /// INSERT/UPDATE on bound columns can re-eval the checks.
3499    fn exec_create_domain(
3500        &mut self,
3501        s: spg_sql::ast::CreateDomainStatement,
3502    ) -> Result<QueryResult, EngineError> {
3503        let cat = self.active_catalog();
3504        if cat.domain_types().contains_key(&s.name) {
3505            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3506                alloc::format!("domain {:?} already exists", s.name),
3507            )));
3508        }
3509        if cat.get(&s.name).is_some()
3510            || cat.sequences().contains_key(&s.name)
3511            || cat.views().contains_key(&s.name)
3512            || cat.enum_types().contains_key(&s.name)
3513        {
3514            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3515                alloc::format!("domain {:?} would shadow an existing object", s.name),
3516            )));
3517        }
3518        let base_type = column_type_to_data_type(s.base_type);
3519        let default = s.default.as_ref().map(|e| alloc::format!("{e}"));
3520        let checks = s
3521            .checks
3522            .iter()
3523            .map(|e| alloc::format!("{e}"))
3524            .collect::<Vec<_>>();
3525        let def = spg_storage::DomainDef {
3526            name: s.name.clone(),
3527            base_type,
3528            nullable: !s.not_null,
3529            default,
3530            checks,
3531        };
3532        self.active_catalog_mut()
3533            .create_domain_type(def)
3534            .map_err(EngineError::Storage)?;
3535        Ok(QueryResult::CommandOk {
3536            affected: 0,
3537            modified_catalog: !self.in_transaction(),
3538        })
3539    }
3540
3541    /// v7.17.0 Phase 1.5 — `DROP DOMAIN [IF EXISTS] names`.
3542    fn exec_drop_domain(
3543        &mut self,
3544        names: &[String],
3545        if_exists: bool,
3546    ) -> Result<QueryResult, EngineError> {
3547        let mut removed = 0usize;
3548        for name in names {
3549            let was_present = self.active_catalog_mut().drop_domain_type(name);
3550            if was_present {
3551                removed += 1;
3552            } else if !if_exists {
3553                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3554                    alloc::format!("domain {name:?} does not exist"),
3555                )));
3556            }
3557        }
3558        Ok(QueryResult::CommandOk {
3559            affected: removed,
3560            modified_catalog: removed > 0 && !self.in_transaction(),
3561        })
3562    }
3563
3564    /// v7.17.0 Phase 1.6 — `CREATE SCHEMA [IF NOT EXISTS] name`.
3565    /// Registers the schema in the catalog. Schema-qualified
3566    /// table references continue to strip the prefix at lookup
3567    /// time (prefix routing, not isolation — see project-next-
3568    /// docket for the v7.18+ real-isolation tracking).
3569    fn exec_create_schema(
3570        &mut self,
3571        name: String,
3572        if_not_exists: bool,
3573    ) -> Result<QueryResult, EngineError> {
3574        self.active_catalog_mut()
3575            .create_schema(name, if_not_exists)
3576            .map_err(EngineError::Storage)?;
3577        Ok(QueryResult::CommandOk {
3578            affected: 0,
3579            modified_catalog: !self.in_transaction(),
3580        })
3581    }
3582
3583    /// v7.17.0 Phase 1.6 — `DROP SCHEMA [IF EXISTS] names`.
3584    /// Built-in schemas always reject the drop with a clear
3585    /// error.
3586    fn exec_drop_schema(
3587        &mut self,
3588        names: &[String],
3589        if_exists: bool,
3590    ) -> Result<QueryResult, EngineError> {
3591        let mut removed = 0usize;
3592        for name in names {
3593            let was_present = self
3594                .active_catalog_mut()
3595                .drop_schema(name)
3596                .map_err(EngineError::Storage)?;
3597            if was_present {
3598                removed += 1;
3599            } else if !if_exists {
3600                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3601                    alloc::format!("schema {name:?} does not exist"),
3602                )));
3603            }
3604        }
3605        Ok(QueryResult::CommandOk {
3606            affected: removed,
3607            modified_catalog: removed > 0 && !self.in_transaction(),
3608        })
3609    }
3610
3611    /// v7.17.0 Phase 1.4 — `DROP TYPE [IF EXISTS] names`. Only
3612    /// ENUM types are catalogued today; other types silently
3613    /// no-op even outside IF EXISTS to mirror the prior
3614    /// "everything's text" lax stance.
3615    fn exec_drop_type(
3616        &mut self,
3617        names: &[String],
3618        if_exists: bool,
3619    ) -> Result<QueryResult, EngineError> {
3620        let mut removed = 0usize;
3621        for name in names {
3622            let was_present = self.active_catalog_mut().drop_enum_type(name);
3623            if was_present {
3624                removed += 1;
3625            } else if !if_exists {
3626                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3627                    alloc::format!("type {name:?} does not exist"),
3628                )));
3629            }
3630        }
3631        Ok(QueryResult::CommandOk {
3632            affected: removed,
3633            modified_catalog: removed > 0 && !self.in_transaction(),
3634        })
3635    }
3636
3637    /// v7.17.0 Phase 1.3 — `CREATE MATERIALIZED VIEW` engine path.
3638    /// Materialises the body at CREATE time (unless WITH NO DATA),
3639    /// stores the result as a regular `Table`, and registers the
3640    /// body source in the catalog so REFRESH can re-run it.
3641    fn exec_create_materialized_view(
3642        &mut self,
3643        s: spg_sql::ast::CreateMaterializedViewStatement,
3644    ) -> Result<QueryResult, EngineError> {
3645        // Name-collision check (table / view / sequence / mat-view).
3646        let cat = self.active_catalog();
3647        if cat.materialized_views().contains_key(&s.name) || cat.get(&s.name).is_some() {
3648            if s.if_not_exists {
3649                return Ok(QueryResult::CommandOk {
3650                    affected: 0,
3651                    modified_catalog: false,
3652                });
3653            }
3654            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3655                alloc::format!("materialized view {:?} already exists", s.name),
3656            )));
3657        }
3658        if cat.views().contains_key(&s.name) {
3659            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3660                alloc::format!(
3661                    "materialized view {:?} would shadow an existing view",
3662                    s.name
3663                ),
3664            )));
3665        }
3666        if cat.sequences().contains_key(&s.name) {
3667            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3668                alloc::format!(
3669                    "materialized view {:?} would shadow an existing sequence",
3670                    s.name
3671                ),
3672            )));
3673        }
3674        // Render the body to canonical form for the registry.
3675        let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body.clone()));
3676        // Execute the body to learn the columns. With WITH DATA we
3677        // also materialise the rows; with WITH NO DATA we only need
3678        // the schema, so re-use a LIMIT 0 wrap to keep the column
3679        // inference path uniform without paying for the rows.
3680        let result = self.exec_select_cancel(&s.body, CancelToken::none())?;
3681        let (mut cols, rows) = match result {
3682            QueryResult::Rows { columns, rows } => (columns, rows),
3683            other => {
3684                return Err(EngineError::Unsupported(alloc::format!(
3685                    "CREATE MATERIALIZED VIEW body did not return rows: {other:?}"
3686                )));
3687            }
3688        };
3689        // Apply the column-rename list per PG semantics.
3690        if !s.columns.is_empty() {
3691            if s.columns.len() != cols.len() {
3692                return Err(EngineError::Unsupported(alloc::format!(
3693                    "CREATE MATERIALIZED VIEW {:?}: column list has {} names but body returns {}",
3694                    s.name,
3695                    s.columns.len(),
3696                    cols.len()
3697                )));
3698            }
3699            for (c, name) in cols.iter_mut().zip(s.columns.iter()) {
3700                c.name.clone_from(name);
3701            }
3702        }
3703        // Promote any synthetic-Text projections to their actual
3704        // observed types so the backing table accepts the rows.
3705        cols = infer_column_types(&cols, &rows);
3706        let schema = spg_storage::TableSchema::new(s.name.clone(), cols);
3707        let cat = self.active_catalog_mut();
3708        cat.create_table(schema).map_err(EngineError::Storage)?;
3709        if s.with_data {
3710            let table = cat
3711                .get_mut(&s.name)
3712                .expect("just-created materialized-view backing table must exist");
3713            for row in rows {
3714                table.insert(row).map_err(EngineError::Storage)?;
3715            }
3716        }
3717        cat.register_materialized_view(s.name.clone(), body_repr);
3718        Ok(QueryResult::CommandOk {
3719            affected: 0,
3720            modified_catalog: !self.in_transaction(),
3721        })
3722    }
3723
3724    /// v7.17.0 Phase 1.3 — `REFRESH MATERIALIZED VIEW name [WITH
3725    /// [NO] DATA]`. Looks up the source, re-runs it, replaces the
3726    /// backing table's rows.
3727    fn exec_refresh_materialized_view(
3728        &mut self,
3729        name: &str,
3730        with_data: bool,
3731    ) -> Result<QueryResult, EngineError> {
3732        let source = self
3733            .active_catalog()
3734            .materialized_views()
3735            .get(name)
3736            .cloned()
3737            .ok_or_else(|| {
3738                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3739                    "materialized view {name:?} does not exist"
3740                )))
3741            })?;
3742        // Wipe the existing rows first (PG truncates the matview
3743        // and rebuilds; we approximate with an empty INSERT loop).
3744        {
3745            let cat = self.active_catalog_mut();
3746            let table = cat.get_mut(name).ok_or_else(|| {
3747                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3748                    "materialized view {name:?} backing table missing"
3749                )))
3750            })?;
3751            table.truncate();
3752        }
3753        if !with_data {
3754            return Ok(QueryResult::CommandOk {
3755                affected: 0,
3756                modified_catalog: !self.in_transaction(),
3757            });
3758        }
3759        let parsed = spg_sql::parser::parse_statement(&source).map_err(|e| {
3760            EngineError::Unsupported(alloc::format!(
3761                "materialized view {name:?} body re-parse failed: {e}"
3762            ))
3763        })?;
3764        let Statement::Select(body) = parsed else {
3765            return Err(EngineError::Unsupported(alloc::format!(
3766                "materialized view {name:?} body is not a SELECT (catalog corruption)"
3767            )));
3768        };
3769        let rows = match self.exec_select_cancel(&body, CancelToken::none())? {
3770            QueryResult::Rows { rows, .. } => rows,
3771            other => {
3772                return Err(EngineError::Unsupported(alloc::format!(
3773                    "REFRESH MATERIALIZED VIEW {name:?} body did not return rows: {other:?}"
3774                )));
3775            }
3776        };
3777        let cat = self.active_catalog_mut();
3778        let table = cat.get_mut(name).expect("backing table verified above");
3779        let affected = rows.len();
3780        for row in rows {
3781            table.insert(row).map_err(EngineError::Storage)?;
3782        }
3783        Ok(QueryResult::CommandOk {
3784            affected,
3785            modified_catalog: !self.in_transaction(),
3786        })
3787    }
3788
3789    /// v7.17.0 Phase 1.3 — `DROP MATERIALIZED VIEW [IF EXISTS]
3790    /// names`. Drops the backing table + unregisters the source.
3791    fn exec_drop_materialized_view(
3792        &mut self,
3793        names: &[String],
3794        if_exists: bool,
3795    ) -> Result<QueryResult, EngineError> {
3796        let mut removed = 0usize;
3797        for name in names {
3798            let was_present = self
3799                .active_catalog_mut()
3800                .drop_materialized_view_source(name);
3801            if was_present {
3802                // Drop the backing table too.
3803                self.active_catalog_mut().drop_table(name);
3804                removed += 1;
3805            } else if !if_exists {
3806                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3807                    alloc::format!("materialized view {name:?} does not exist"),
3808                )));
3809            }
3810        }
3811        Ok(QueryResult::CommandOk {
3812            affected: removed,
3813            modified_catalog: removed > 0 && !self.in_transaction(),
3814        })
3815    }
3816
3817    /// v7.17.0 Phase 1.2 — `DROP VIEW [IF EXISTS] name [, name…]`.
3818    fn exec_drop_view(
3819        &mut self,
3820        names: &[String],
3821        if_exists: bool,
3822    ) -> Result<QueryResult, EngineError> {
3823        let mut removed = 0usize;
3824        for name in names {
3825            let was_present = self.active_catalog_mut().drop_view(name);
3826            if !was_present && !if_exists {
3827                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3828                    alloc::format!("view {name:?} does not exist"),
3829                )));
3830            }
3831            if was_present {
3832                removed += 1;
3833            }
3834        }
3835        Ok(QueryResult::CommandOk {
3836            affected: removed,
3837            modified_catalog: removed > 0 && !self.in_transaction(),
3838        })
3839    }
3840
3841    /// v7.17.0 — `DROP SEQUENCE [IF EXISTS] name [, name…]`.
3842    fn exec_drop_sequence(
3843        &mut self,
3844        names: &[String],
3845        if_exists: bool,
3846    ) -> Result<QueryResult, EngineError> {
3847        let mut removed = 0usize;
3848        for name in names {
3849            let was_present = self.active_catalog_mut().drop_sequence(name);
3850            if !was_present && !if_exists {
3851                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3852                    alloc::format!("sequence {name:?} does not exist"),
3853                )));
3854            }
3855            if was_present {
3856                removed += 1;
3857            }
3858        }
3859        Ok(QueryResult::CommandOk {
3860            affected: removed,
3861            modified_catalog: removed > 0 && !self.in_transaction(),
3862        })
3863    }
3864
3865    /// v4.4 `UPDATE <table> SET col = expr [, ...] [WHERE cond]`.
3866    /// Filter pass uses the same WHERE eval as `exec_select`. Per
3867    /// matched row, evaluate each RHS expression against the *old*
3868    /// row, then call `Table::update_row` which rebuilds indices.
3869    /// Indexed columns are correctly reflected because rebuild
3870    /// happens after the cell rewrite.
3871    fn exec_update_cancel(
3872        &mut self,
3873        stmt: &spg_sql::ast::UpdateStatement,
3874        cancel: CancelToken<'_>,
3875    ) -> Result<QueryResult, EngineError> {
3876        // v7.12.5 — snapshot BEFORE/AFTER UPDATE row triggers + the
3877        // session FTS config before the table mut-borrow opens (the
3878        // INSERT path uses the same pattern). Empty vecs are the
3879        // common "no triggers on this table" fast path.
3880        // v7.13.0 — UPDATE triggers carry an optional `UPDATE OF
3881        // cols` filter. The filter is paired with each function so
3882        // the per-row fire loop can skip when no listed column
3883        // actually differs between OLD and NEW.
3884        let before_update_triggers = self.snapshot_update_row_triggers(&stmt.table, "BEFORE");
3885        let after_update_triggers = self.snapshot_update_row_triggers(&stmt.table, "AFTER");
3886        let trigger_session_cfg: Option<String> = self
3887            .session_params
3888            .get("default_text_search_config")
3889            .cloned();
3890        // v5.2.3: if the WHERE is a PK equality and matches a cold-
3891        // tier row, promote it back to the hot tier *before* the
3892        // hot-row walk. The promote pushes the row to the end of
3893        // `table.rows`, where the upcoming SET-evaluation loop will
3894        // pick it up and apply the assignments. Lookups for the key
3895        // never observe a gap because `promote_cold_row` inserts the
3896        // hot row before retiring the cold locator.
3897        if let Some(w) = &stmt.where_ {
3898            let schema_cols = self
3899                .active_catalog()
3900                .get(&stmt.table)
3901                .ok_or_else(|| {
3902                    EngineError::Storage(StorageError::TableNotFound {
3903                        name: stmt.table.clone(),
3904                    })
3905                })?
3906                .schema()
3907                .columns
3908                .clone();
3909            if let Some((col_pos, key)) = try_pk_predicate(w, &schema_cols, stmt.table.as_str())
3910                && let Some(idx_name) = self
3911                    .active_catalog()
3912                    .get(&stmt.table)
3913                    .and_then(|t| t.index_on(col_pos).map(|i| i.name.clone()))
3914            {
3915                // Promote may be a no-op (key is hot-only or absent);
3916                // we don't care about the return value here — the
3917                // subsequent hot walk will either match or not.
3918                let _ = self
3919                    .active_catalog_mut()
3920                    .promote_cold_row(&stmt.table, &idx_name, &key);
3921            }
3922        }
3923
3924        // v7.12.1 — cache session FTS config before the table
3925        // mut-borrow (same reason as exec_delete).
3926        let ts_cfg: Option<String> = self
3927            .session_param("default_text_search_config")
3928            .map(String::from);
3929        // v7.17.0 Phase 2.1 — snapshot the clock pointer before
3930        // we hold the catalog mutably so ON UPDATE runtime
3931        // overrides see the engine wall clock.
3932        let clock_for_on_update = self.clock;
3933        let table = self
3934            .active_catalog_mut()
3935            .get_mut(&stmt.table)
3936            .ok_or_else(|| {
3937                EngineError::Storage(StorageError::TableNotFound {
3938                    name: stmt.table.clone(),
3939                })
3940            })?;
3941        let schema_cols: Vec<ColumnSchema> = table.schema().columns.clone();
3942        // Resolve each SET target to a column position once, validate
3943        // up front so a typo'd column doesn't leave a partial mutation
3944        // behind.
3945        let mut targets: Vec<(usize, &Expr)> = Vec::with_capacity(stmt.assignments.len());
3946        for (col, expr) in &stmt.assignments {
3947            let pos = schema_cols
3948                .iter()
3949                .position(|c| c.name == *col)
3950                .ok_or_else(|| {
3951                    EngineError::Eval(EvalError::ColumnNotFound { name: col.clone() })
3952                })?;
3953            targets.push((pos, expr));
3954        }
3955        // v7.17.0 Phase 2.1 — for every column with an
3956        // `ON UPDATE CURRENT_TIMESTAMP` binding that the caller
3957        // did NOT explicitly set, schedule an automatic override.
3958        // Reuses `eval_runtime_default_free` so the same
3959        // canonical runtime-expression whitelist (now /
3960        // current_timestamp / current_date / …) governs both
3961        // DEFAULT and ON UPDATE.
3962        let mut on_update_overrides: Vec<(usize, String)> = Vec::new();
3963        for (i, col) in schema_cols.iter().enumerate() {
3964            if targets.iter().any(|(p, _)| *p == i) {
3965                continue;
3966            }
3967            if let Some(src) = &col.on_update_runtime {
3968                on_update_overrides.push((i, src.clone()));
3969            }
3970        }
3971        let ctx = EvalContext::new(&schema_cols, Some(stmt.table.as_str()))
3972            .with_default_text_search_config(ts_cfg.as_deref());
3973        // Walk every row, evaluate WHERE then SET expressions. We
3974        // gather (position, new_values) tuples first and apply them
3975        // afterwards so the WHERE/RHS evaluation reads the original
3976        // row state — matches PG semantics (UPDATE doesn't see its
3977        // own writes).
3978        let mut planned: Vec<(usize, Vec<Value>)> = Vec::new();
3979        for (i, row) in table.rows().iter().enumerate() {
3980            // v4.5: cooperative cancel checkpoint every 256 rows so
3981            // a runaway UPDATE without WHERE doesn't drag past the
3982            // server's query-timeout watchdog.
3983            if i.is_multiple_of(256) {
3984                cancel.check()?;
3985            }
3986            if let Some(w) = &stmt.where_ {
3987                let cond = eval::eval_expr(w, row, &ctx)?;
3988                if !matches!(cond, Value::Bool(true)) {
3989                    continue;
3990                }
3991            }
3992            let mut new_vals = row.values.clone();
3993            for (pos, expr) in &targets {
3994                let v = eval::eval_expr(expr, row, &ctx)?;
3995                let coerced = coerce_value(v, schema_cols[*pos].ty, &schema_cols[*pos].name, *pos)?;
3996                check_unsigned_range(&coerced, &schema_cols[*pos], *pos)?;
3997                new_vals[*pos] = coerced;
3998            }
3999            // v7.17.0 Phase 2.1 — apply ON UPDATE overrides for
4000            // any column the SET clause didn't touch.
4001            for (pos, src) in &on_update_overrides {
4002                let v = eval_runtime_default_free(src, schema_cols[*pos].ty, clock_for_on_update)?;
4003                new_vals[*pos] = v;
4004            }
4005            planned.push((i, new_vals));
4006        }
4007        // v7.6.6 — capture pre-update row values for the FK
4008        // enforcement passes below. `planned` carries new values
4009        // only; pair them with the old row.
4010        let plan_with_old: Vec<(usize, Vec<Value>, Vec<Value>)> = planned
4011            .iter()
4012            .map(|(pos, new_vals)| (*pos, table.rows()[*pos].values.clone(), new_vals.clone()))
4013            .collect();
4014        let self_fks = table.schema().foreign_keys.clone();
4015        // v7.12.5 — `affected` is computed post-BEFORE-trigger
4016        // below (triggers may RETURN NULL to skip individual
4017        // rows). The pre-trigger len shape is no longer accurate.
4018        // Release mutable borrow on `table` for the FK passes.
4019        let _ = table;
4020        // v7.6.6 — Stage 2a: outbound FK check. For every row whose
4021        // local FK columns changed, the new value must exist in the
4022        // parent.
4023        if !self_fks.is_empty() {
4024            let new_rows: Vec<Vec<Value>> = planned
4025                .iter()
4026                .map(|(_pos, new_vals)| new_vals.clone())
4027                .collect();
4028            enforce_fk_inserts(self.active_catalog(), &stmt.table, &self_fks, &new_rows)?;
4029        }
4030        // v7.13.0 — CHECK constraint enforcement on UPDATE
4031        // (mailrs round-5 G3). Predicates evaluated against the
4032        // candidate post-UPDATE row; false rejects the UPDATE.
4033        {
4034            let new_rows: Vec<Vec<Value>> = planned
4035                .iter()
4036                .map(|(_pos, new_vals)| new_vals.clone())
4037                .collect();
4038            enforce_check_constraints(self.active_catalog(), &stmt.table, &new_rows)?;
4039        }
4040        // v7.6.6 — Stage 2b: inbound FK check. For every row that
4041        // changed value in a column that *some other table* uses as
4042        // a FK parent column, react per `on_update` action.
4043        let child_plan =
4044            plan_fk_parent_updates(self.active_catalog(), &stmt.table, &plan_with_old)?;
4045        // Stage 3a — apply each child-side action.
4046        for step in &child_plan {
4047            apply_fk_child_step(self.active_catalog_mut(), step)?;
4048        }
4049        // Stage 3b — apply the original UPDATE.
4050        let table = self
4051            .active_catalog_mut()
4052            .get_mut(&stmt.table)
4053            .ok_or_else(|| {
4054                EngineError::Storage(StorageError::TableNotFound {
4055                    name: stmt.table.clone(),
4056                })
4057            })?;
4058        // v7.12.5 — fire BEFORE/AFTER UPDATE row-level triggers
4059        // around the apply loop. BEFORE sees NEW=candidate +
4060        // OLD=current; may rewrite NEW or RETURN NULL to skip.
4061        // AFTER sees NEW=post-write + OLD=pre-write (both read-
4062        // only).
4063        //
4064        // Filter `planned` through the BEFORE pass first so the
4065        // RETURNING snapshot reflects what actually got written
4066        // (triggers may rewrite cells, including a cancellation).
4067        let mut applied_after_before: Vec<(usize, Row, Row)> = Vec::with_capacity(planned.len());
4068        // v7.12.7 — embedded SQL queue.
4069        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
4070        for (pos, new_vals) in &planned {
4071            let old_row = table.rows()[*pos].clone();
4072            let mut new_row = Row::new(new_vals.clone());
4073            let mut skip = false;
4074            for (fd, filter) in &before_update_triggers {
4075                // v7.13.0 — `UPDATE OF cols` filter (mailrs round-5
4076                // G7). Skip this trigger when the filter is set and
4077                // no listed column actually differs between OLD and
4078                // NEW for this row.
4079                if !filter.is_empty()
4080                    && !any_column_changed(filter, &schema_cols, &old_row, &new_row)
4081                {
4082                    continue;
4083                }
4084                let (outcome, deferred) = triggers::fire_row_trigger(
4085                    fd,
4086                    Some(new_row.clone()),
4087                    Some(&old_row),
4088                    &stmt.table,
4089                    &schema_cols,
4090                    &[],
4091                    trigger_session_cfg.as_deref(),
4092                    false,
4093                )
4094                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
4095                deferred_embedded.extend(deferred);
4096                match outcome {
4097                    triggers::TriggerOutcome::Row(r) => new_row = r,
4098                    triggers::TriggerOutcome::Skip => {
4099                        skip = true;
4100                        break;
4101                    }
4102                }
4103            }
4104            if !skip {
4105                applied_after_before.push((*pos, new_row, old_row));
4106            }
4107        }
4108        // v7.9.4 — snapshot post-update values for RETURNING (post-
4109        // BEFORE-trigger because triggers can rewrite cells).
4110        let updated_for_returning: Vec<Vec<Value>> = if stmt.returning.is_some() {
4111            applied_after_before
4112                .iter()
4113                .map(|(_pos, new_row, _old)| new_row.values.clone())
4114                .collect()
4115        } else {
4116            Vec::new()
4117        };
4118        let affected = applied_after_before.len();
4119        // Apply, then fire AFTER triggers per row. AFTER runs read-
4120        // only against the freshly-written row; v7.12.4-shape
4121        // assignment errors with a clear message.
4122        for (pos, new_row, old_row) in applied_after_before {
4123            table.update_row(pos, new_row.values.clone())?;
4124            for (fd, filter) in &after_update_triggers {
4125                if !filter.is_empty()
4126                    && !any_column_changed(filter, &schema_cols, &old_row, &new_row)
4127                {
4128                    continue;
4129                }
4130                let (_outcome, deferred) = triggers::fire_row_trigger(
4131                    fd,
4132                    Some(new_row.clone()),
4133                    Some(&old_row),
4134                    &stmt.table,
4135                    &schema_cols,
4136                    &[],
4137                    trigger_session_cfg.as_deref(),
4138                    true,
4139                )
4140                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
4141                deferred_embedded.extend(deferred);
4142            }
4143        }
4144        let _ = table;
4145        // v7.12.7 — drain trigger-emitted embedded SQL for this UPDATE.
4146        self.execute_deferred_trigger_stmts(deferred_embedded, cancel)?;
4147        // v6.2.1 — auto-analyze modified-row tracking for UPDATE.
4148        if !self.in_transaction() && affected > 0 {
4149            self.statistics
4150                .record_modifications(&stmt.table, affected as u64);
4151        }
4152        // v7.9.4 — RETURNING projection.
4153        if let Some(items) = &stmt.returning {
4154            return self.build_returning_rows(&stmt.table, items, updated_for_returning);
4155        }
4156        Ok(QueryResult::CommandOk {
4157            affected,
4158            modified_catalog: !self.in_transaction(),
4159        })
4160    }
4161
4162    /// v4.4 `DELETE FROM <table> [WHERE cond]`. Collects matching
4163    /// positions then delegates to `Table::delete_rows` (single index
4164    /// rebuild for the batch).
4165    /// v7.17.0 Phase 3.P0-42 — SQL:2003 / PG 15+ `MERGE` execution.
4166    ///
4167    /// Semantics:
4168    ///   * Resolve `target` and `source` tables (catalog reads).
4169    ///   * Build a combined `(target_alias.col, source_alias.col)`
4170    ///     schema so the ON / WHEN AND / SET / VALUES expressions
4171    ///     resolve through the standard qualifier-aware resolver.
4172    ///   * Pass 1: walk every source row × every target hot row,
4173    ///     evaluate ON, then pick the first WHEN clause that fits
4174    ///     (`Matched` if any target row matched, `NotMatched`
4175    ///     otherwise; AND-condition must hold). Collect the action
4176    ///     plan as `(deletes, updates, inserts)` so the apply pass
4177    ///     reads the original target row state.
4178    ///   * Pass 2: apply the plan against the target's mutable row
4179    ///     vector. Deletes execute by index in descending order so
4180    ///     earlier indices remain stable; updates next; inserts
4181    ///     last (matching PG's "INSERT branch sees the post-delete
4182    ///     state" behaviour for the common upsert shape).
4183    ///
4184    /// v7.17 simplifications (documented limitations):
4185    ///   * No triggers / WAL plumbing (MVP); MERGE rows don't fire
4186    ///     INSERT / UPDATE / DELETE row triggers in v7.17.
4187    ///   * No cardinality check (PG-canonical: "MERGE command
4188    ///     cannot affect row a second time" — SPG silently applies
4189    ///     the last action for a target row covered twice).
4190    ///   * Source must be a catalog-resolvable table (no subquery
4191    ///     source); RETURNING / BY SOURCE / BY TARGET unsupported.
4192    fn exec_merge_cancel(
4193        &mut self,
4194        stmt: &spg_sql::ast::MergeStatement,
4195        cancel: CancelToken<'_>,
4196    ) -> Result<QueryResult, EngineError> {
4197        let target_alias = stmt
4198            .target_alias
4199            .clone()
4200            .unwrap_or_else(|| stmt.target.clone());
4201        let source_alias = stmt
4202            .source_alias
4203            .clone()
4204            .unwrap_or_else(|| stmt.source.clone());
4205        let (target_cols, target_rows_snapshot) = {
4206            let t = self.active_catalog().get(&stmt.target).ok_or_else(|| {
4207                EngineError::Storage(StorageError::TableNotFound {
4208                    name: stmt.target.clone(),
4209                })
4210            })?;
4211            (
4212                t.schema().columns.clone(),
4213                t.rows().iter().cloned().collect::<Vec<Row>>(),
4214            )
4215        };
4216        let (source_cols, source_rows) = {
4217            let s = self.active_catalog().get(&stmt.source).ok_or_else(|| {
4218                EngineError::Storage(StorageError::TableNotFound {
4219                    name: stmt.source.clone(),
4220                })
4221            })?;
4222            (
4223                s.schema().columns.clone(),
4224                s.rows().iter().cloned().collect::<Vec<Row>>(),
4225            )
4226        };
4227        // Composite schema: target_alias.col ... source_alias.col ...
4228        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
4229        for col in &target_cols {
4230            combined_schema.push(ColumnSchema::new(
4231                alloc::format!("{target_alias}.{}", col.name),
4232                col.ty,
4233                col.nullable,
4234            ));
4235        }
4236        for col in &source_cols {
4237            combined_schema.push(ColumnSchema::new(
4238                alloc::format!("{source_alias}.{}", col.name),
4239                col.ty,
4240                col.nullable,
4241            ));
4242        }
4243        let combined_ctx = EvalContext::new(&combined_schema, None);
4244        // Source-only context for WHEN NOT MATCHED actions (no
4245        // matched target row exists — the source-side qualified
4246        // columns must still resolve).
4247        let mut source_only_schema: Vec<ColumnSchema> = Vec::new();
4248        for col in &target_cols {
4249            source_only_schema.push(ColumnSchema::new(
4250                alloc::format!("{target_alias}.{}", col.name),
4251                col.ty,
4252                col.nullable,
4253            ));
4254        }
4255        for col in &source_cols {
4256            source_only_schema.push(ColumnSchema::new(
4257                alloc::format!("{source_alias}.{}", col.name),
4258                col.ty,
4259                col.nullable,
4260            ));
4261        }
4262        let source_only_ctx = EvalContext::new(&source_only_schema, None);
4263        let target_arity = target_cols.len();
4264        let source_arity = source_cols.len();
4265
4266        // Resolve INSERT column positions once (validate names).
4267        // For each clause that's an INSERT, map column names → target positions.
4268        let mut delete_indices: Vec<usize> = Vec::new();
4269        let mut updates: Vec<(usize, Vec<Value>)> = Vec::new();
4270        let mut inserts: Vec<Vec<Value>> = Vec::new();
4271        let mut affected: usize = 0;
4272
4273        for (src_idx, src_row) in source_rows.iter().enumerate() {
4274            if src_idx.is_multiple_of(256) {
4275                cancel.check()?;
4276            }
4277            // Find every matched target index (per the ON predicate).
4278            let mut matched_targets: Vec<usize> = Vec::new();
4279            for (t_idx, t_row) in target_rows_snapshot.iter().enumerate() {
4280                let mut combined_vals = t_row.values.clone();
4281                combined_vals.extend(src_row.values.iter().cloned());
4282                let combined_row = Row::new(combined_vals);
4283                let cond = eval::eval_expr(&stmt.on, &combined_row, &combined_ctx)?;
4284                if matches!(cond, Value::Bool(true)) {
4285                    matched_targets.push(t_idx);
4286                }
4287            }
4288            let is_matched = !matched_targets.is_empty();
4289            // Pick the first WHEN clause whose kind agrees with
4290            // `is_matched` and whose AND condition (if any) holds.
4291            // AND condition for MATCHED: evaluated against the
4292            // first matched target row × source. For NOT MATCHED:
4293            // evaluated with target side NULL-padded.
4294            let fired_clause = stmt.clauses.iter().find(|c| {
4295                let kind_ok = match c.matched {
4296                    spg_sql::ast::MergeMatched::Matched => is_matched,
4297                    spg_sql::ast::MergeMatched::NotMatched => !is_matched,
4298                };
4299                if !kind_ok {
4300                    return false;
4301                }
4302                let Some(cond_expr) = &c.condition else {
4303                    return true;
4304                };
4305                let row = if is_matched {
4306                    let t = &target_rows_snapshot[matched_targets[0]];
4307                    let mut vals = t.values.clone();
4308                    vals.extend(src_row.values.iter().cloned());
4309                    Row::new(vals)
4310                } else {
4311                    let mut vals: Vec<Value> = (0..target_arity).map(|_| Value::Null).collect();
4312                    vals.extend(src_row.values.iter().cloned());
4313                    Row::new(vals)
4314                };
4315                let ctx_ref = if is_matched {
4316                    &combined_ctx
4317                } else {
4318                    &source_only_ctx
4319                };
4320                matches!(
4321                    eval::eval_expr(cond_expr, &row, ctx_ref),
4322                    Ok(Value::Bool(true))
4323                )
4324            });
4325            let Some(clause) = fired_clause else { continue };
4326            match &clause.action {
4327                spg_sql::ast::MergeAction::DoNothing => {}
4328                spg_sql::ast::MergeAction::Delete => {
4329                    for &t_idx in &matched_targets {
4330                        if !delete_indices.contains(&t_idx) {
4331                            delete_indices.push(t_idx);
4332                            affected += 1;
4333                        }
4334                    }
4335                }
4336                spg_sql::ast::MergeAction::Update { assignments } => {
4337                    // Pre-resolve SET targets to target column positions.
4338                    let mut planned_sets: Vec<(usize, &Expr)> =
4339                        Vec::with_capacity(assignments.len());
4340                    for (col, expr) in assignments {
4341                        let pos =
4342                            target_cols
4343                                .iter()
4344                                .position(|c| c.name == *col)
4345                                .ok_or_else(|| {
4346                                    EngineError::Eval(EvalError::ColumnNotFound {
4347                                        name: col.clone(),
4348                                    })
4349                                })?;
4350                        planned_sets.push((pos, expr));
4351                    }
4352                    for &t_idx in &matched_targets {
4353                        let t_row = &target_rows_snapshot[t_idx];
4354                        let mut new_values = t_row.values.clone();
4355                        let mut combined_vals = t_row.values.clone();
4356                        combined_vals.extend(src_row.values.iter().cloned());
4357                        let combined_row = Row::new(combined_vals);
4358                        for (pos, expr) in &planned_sets {
4359                            let raw = eval::eval_expr(expr, &combined_row, &combined_ctx)?;
4360                            let coerced = coerce_value(
4361                                raw,
4362                                target_cols[*pos].ty,
4363                                &target_cols[*pos].name,
4364                                *pos,
4365                            )?;
4366                            new_values[*pos] = coerced;
4367                        }
4368                        updates.push((t_idx, new_values));
4369                        affected += 1;
4370                    }
4371                }
4372                spg_sql::ast::MergeAction::Insert { columns, values } => {
4373                    // For INSERT NOT MATCHED, target side is NULL-padded.
4374                    let mut vals: Vec<Value> = (0..target_arity).map(|_| Value::Null).collect();
4375                    vals.extend(src_row.values.iter().cloned());
4376                    let synth_row = Row::new(vals);
4377                    let mut new_row_values: Vec<Value> =
4378                        (0..target_arity).map(|_| Value::Null).collect();
4379                    for (col, expr) in columns.iter().zip(values.iter()) {
4380                        let pos =
4381                            target_cols
4382                                .iter()
4383                                .position(|c| c.name == *col)
4384                                .ok_or_else(|| {
4385                                    EngineError::Eval(EvalError::ColumnNotFound {
4386                                        name: col.clone(),
4387                                    })
4388                                })?;
4389                        let raw = eval::eval_expr(expr, &synth_row, &source_only_ctx)?;
4390                        let coerced =
4391                            coerce_value(raw, target_cols[pos].ty, &target_cols[pos].name, pos)?;
4392                        new_row_values[pos] = coerced;
4393                    }
4394                    inserts.push(new_row_values);
4395                    affected += 1;
4396                }
4397            }
4398        }
4399        let _ = source_arity; // captured for symmetry; cancellation cost negligible.
4400
4401        // Apply the plan to the target table.
4402        let table = self
4403            .active_catalog_mut()
4404            .get_mut(&stmt.target)
4405            .ok_or_else(|| {
4406                EngineError::Storage(StorageError::TableNotFound {
4407                    name: stmt.target.clone(),
4408                })
4409            })?;
4410        // Apply updates first (in-place), then deletes (one batch),
4411        // then inserts. The storage API uses `update_row(pos,
4412        // new_values)`, `delete_rows(&[positions])`, and `insert(row)`.
4413        for (idx, new_vals) in &updates {
4414            table
4415                .update_row(*idx, new_vals.clone())
4416                .map_err(EngineError::Storage)?;
4417        }
4418        if !delete_indices.is_empty() {
4419            table.delete_rows(&delete_indices);
4420        }
4421        for vals in inserts {
4422            table.insert(Row::new(vals)).map_err(EngineError::Storage)?;
4423        }
4424        Ok(QueryResult::CommandOk {
4425            affected,
4426            modified_catalog: affected > 0,
4427        })
4428    }
4429
4430    fn exec_delete_cancel(
4431        &mut self,
4432        stmt: &spg_sql::ast::DeleteStatement,
4433        cancel: CancelToken<'_>,
4434    ) -> Result<QueryResult, EngineError> {
4435        // v7.12.5 — snapshot BEFORE/AFTER DELETE row triggers + the
4436        // session FTS config before the mut borrow (same shape as
4437        // INSERT / UPDATE).
4438        let before_delete_triggers = self.snapshot_row_triggers(&stmt.table, "DELETE", "BEFORE");
4439        let after_delete_triggers = self.snapshot_row_triggers(&stmt.table, "DELETE", "AFTER");
4440        let trigger_session_cfg: Option<String> = self
4441            .session_params
4442            .get("default_text_search_config")
4443            .cloned();
4444        // v5.2.3: PK-targeted DELETE → first retire any cold-tier
4445        // locator for the key. The cold row body stays in the
4446        // segment (becoming shadowed garbage that a future
4447        // compaction pass reclaims) but the index no longer
4448        // resolves it. The shadow count contributes to the
4449        // affected total; the subsequent hot walk handles any hot
4450        // rows for the same key.
4451        let mut cold_shadow_count: usize = 0;
4452        if let Some(w) = &stmt.where_ {
4453            let schema_cols = self
4454                .active_catalog()
4455                .get(&stmt.table)
4456                .ok_or_else(|| {
4457                    EngineError::Storage(StorageError::TableNotFound {
4458                        name: stmt.table.clone(),
4459                    })
4460                })?
4461                .schema()
4462                .columns
4463                .clone();
4464            if let Some((col_pos, key)) = try_pk_predicate(w, &schema_cols, stmt.table.as_str())
4465                && let Some(idx_name) = self
4466                    .active_catalog()
4467                    .get(&stmt.table)
4468                    .and_then(|t| t.index_on(col_pos).map(|i| i.name.clone()))
4469            {
4470                cold_shadow_count = self
4471                    .active_catalog_mut()
4472                    .shadow_cold_row(&stmt.table, &idx_name, &key)
4473                    .unwrap_or(0);
4474            }
4475        }
4476
4477        // v7.12.1 — cache the session FTS config as an owned
4478        // String before the mutable table borrow below; the
4479        // ctx-builder then references it via `as_deref` so the
4480        // immutable read of `session_params` doesn't conflict
4481        // with the mut borrow chain.
4482        let ts_cfg: Option<String> = self
4483            .session_param("default_text_search_config")
4484            .map(String::from);
4485        let table = self
4486            .active_catalog_mut()
4487            .get_mut(&stmt.table)
4488            .ok_or_else(|| {
4489                EngineError::Storage(StorageError::TableNotFound {
4490                    name: stmt.table.clone(),
4491                })
4492            })?;
4493        let schema_cols: Vec<ColumnSchema> = table.schema().columns.clone();
4494        let ctx = EvalContext::new(&schema_cols, Some(stmt.table.as_str()))
4495            .with_default_text_search_config(ts_cfg.as_deref());
4496        let mut positions: Vec<usize> = Vec::new();
4497        // v7.6.3 — collect every to-delete row's full Value tuple
4498        // alongside its position, so the FK enforcement pass can
4499        // run after the mut borrow drops.
4500        let mut to_delete_rows: Vec<Vec<Value>> = Vec::new();
4501        for (i, row) in table.rows().iter().enumerate() {
4502            if i.is_multiple_of(256) {
4503                cancel.check()?;
4504            }
4505            let keep = if let Some(w) = &stmt.where_ {
4506                let cond = eval::eval_expr(w, row, &ctx)?;
4507                !matches!(cond, Value::Bool(true))
4508            } else {
4509                false
4510            };
4511            if !keep {
4512                positions.push(i);
4513                to_delete_rows.push(row.values.clone());
4514            }
4515        }
4516        // v7.6.3 / v7.6.4 — Stage 2: FK enforcement on the immutable
4517        // catalog. Release the mut borrow and run reverse-scan
4518        // against every child table whose FK targets this table.
4519        // RESTRICT / NoAction raise an error; CASCADE returns a
4520        // cascade plan that stage 3 applies after the primary delete.
4521        // SET NULL / SET DEFAULT remain Unsupported until v7.6.5.
4522        let _ = table;
4523        // v7.12.5 — BEFORE DELETE row-level triggers. Each fires
4524        // with NEW=None / OLD=pre-delete row; RETURN OLD (or NEW)
4525        // = proceed, RETURN NULL = skip the row entirely. The
4526        // filter must run BEFORE the FK cascade plan so cascaded
4527        // child rows track the trigger's skip-decision on the
4528        // parent.
4529        // v7.12.7 — embedded SQL queue.
4530        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
4531        if !before_delete_triggers.is_empty() {
4532            let mut filtered_positions: Vec<usize> = Vec::with_capacity(positions.len());
4533            let mut filtered_old_rows: Vec<Vec<Value>> = Vec::with_capacity(to_delete_rows.len());
4534            for (pos, old_vals) in positions.iter().zip(to_delete_rows.iter()) {
4535                let old_row = Row::new(old_vals.clone());
4536                let mut cancel_this = false;
4537                for fd in &before_delete_triggers {
4538                    let (outcome, deferred) = triggers::fire_row_trigger(
4539                        fd,
4540                        None,
4541                        Some(&old_row),
4542                        &stmt.table,
4543                        &schema_cols,
4544                        &[],
4545                        trigger_session_cfg.as_deref(),
4546                        false,
4547                    )
4548                    .map_err(|e| {
4549                        EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}")))
4550                    })?;
4551                    deferred_embedded.extend(deferred);
4552                    if matches!(outcome, triggers::TriggerOutcome::Skip) {
4553                        cancel_this = true;
4554                        break;
4555                    }
4556                }
4557                if !cancel_this {
4558                    filtered_positions.push(*pos);
4559                    filtered_old_rows.push(old_vals.clone());
4560                }
4561            }
4562            positions = filtered_positions;
4563            to_delete_rows = filtered_old_rows;
4564        }
4565        let cascade_plan = plan_fk_parent_deletions(
4566            self.active_catalog(),
4567            &stmt.table,
4568            &positions,
4569            &to_delete_rows,
4570        )?;
4571        // Stage 3a — apply each FK child step (SET NULL / SET
4572        // DEFAULT / CASCADE delete) before deleting the parent.
4573        // The plan is already ordered: nulls/defaults first, then
4574        // cascade deletes (so a row mutated and later deleted
4575        // surfaces as deleted — though v7.6.5 doesn't produce
4576        // that overlap today).
4577        for step in &cascade_plan {
4578            apply_fk_child_step(self.active_catalog_mut(), step)?;
4579        }
4580        // Stage 3b — actually delete the original target rows.
4581        let table = self
4582            .active_catalog_mut()
4583            .get_mut(&stmt.table)
4584            .ok_or_else(|| {
4585                EngineError::Storage(StorageError::TableNotFound {
4586                    name: stmt.table.clone(),
4587                })
4588            })?;
4589        let affected = table.delete_rows(&positions) + cold_shadow_count;
4590        let _ = table;
4591        // v7.12.5 — AFTER DELETE row-level triggers fire post-write
4592        // with NEW=None / OLD=pre-delete row (each from the
4593        // already-snapshotted to_delete_rows). Return value is
4594        // ignored (matches PG AFTER semantics).
4595        if !after_delete_triggers.is_empty() {
4596            for old_vals in &to_delete_rows {
4597                let old_row = Row::new(old_vals.clone());
4598                for fd in &after_delete_triggers {
4599                    let (_outcome, deferred) = triggers::fire_row_trigger(
4600                        fd,
4601                        None,
4602                        Some(&old_row),
4603                        &stmt.table,
4604                        &schema_cols,
4605                        &[],
4606                        trigger_session_cfg.as_deref(),
4607                        true,
4608                    )
4609                    .map_err(|e| {
4610                        EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}")))
4611                    })?;
4612                    deferred_embedded.extend(deferred);
4613                }
4614            }
4615        }
4616        // v7.12.7 — drain trigger-emitted embedded SQL for this DELETE.
4617        self.execute_deferred_trigger_stmts(deferred_embedded, cancel)?;
4618        // v6.2.1 — auto-analyze modified-row tracking for DELETE.
4619        if !self.in_transaction() && affected > 0 {
4620            self.statistics
4621                .record_modifications(&stmt.table, affected as u64);
4622        }
4623        // v7.9.4 — RETURNING projection over the soon-to-be-gone
4624        // rows. `to_delete_rows` was snapshotted in stage 1 before
4625        // mutation, so the projection sees the pre-delete state
4626        // (matches PG semantics: DELETE RETURNING returns the row
4627        // as it was just before removal).
4628        if let Some(items) = &stmt.returning {
4629            return self.build_returning_rows(&stmt.table, items, to_delete_rows);
4630        }
4631        Ok(QueryResult::CommandOk {
4632            affected,
4633            modified_catalog: !self.in_transaction(),
4634        })
4635    }
4636
4637    /// `SHOW TABLES` — one row per table in the active catalog.
4638    /// Column name is `name` so result-set consumers can downstream
4639    /// `SELECT name FROM ...` style logic if needed.
4640    /// v4.26: `EXPLAIN [ANALYZE] <select>`. Returns a single-column
4641    /// `QUERY PLAN` text table — first line names the top operator
4642    /// (Scan / Aggregate / Window / etc.), indented children list
4643    /// FROM joins, WHERE filters, ORDER BY / LIMIT, projection
4644    /// shape, and any active index hits. `ANALYZE` execs the inner
4645    /// SELECT and appends actual-row + elapsed-micros annotations.
4646    #[allow(clippy::format_push_string)]
4647    fn exec_explain(
4648        &self,
4649        e: &spg_sql::ast::ExplainStatement,
4650        cancel: CancelToken<'_>,
4651    ) -> Result<QueryResult, EngineError> {
4652        let mut lines = Vec::<String>::new();
4653        explain_select(&e.inner, self, 0, &mut lines);
4654        if e.suggest {
4655            // v6.8.3 — index advisor. Walks the SELECT's FROM
4656            // tables + WHERE column refs; for each (table, column)
4657            // pair that lacks an index, append a SUGGEST line with
4658            // a copy-pastable `CREATE INDEX` statement. This is a
4659            // pure-syntax heuristic — no cardinality estimation —
4660            // matching the v6.8.3 design intent of "tell the
4661            // operator where indexes are missing", not "give the
4662            // mathematically optimal index set".
4663            let suggestions = build_index_suggestions(&e.inner, self);
4664            for s in suggestions {
4665                lines.push(s);
4666            }
4667        } else if e.analyze {
4668            // v6.2.4 — EXPLAIN ANALYZE annotates each operator line
4669            // with `(rows=N)` where the row count is computable
4670            // without re-executing the full query:
4671            //   - Top-level operator (first non-indented line):
4672            //     rows = final result.len()
4673            //   - "From: <table> [full scan]" lines: rows =
4674            //     table.rows().len() (catalog read; no execution)
4675            //   - "From: <table> [index seek]": indeterminate —
4676            //     the index step would need re-execution; v6.2.5
4677            //     adds per-operator wall-clock + hot/cold rows
4678            //     instrumentation that makes this concrete.
4679            //   - Everything else: marked `(—)` so the surface
4680            //     stays well-defined without silently dropping
4681            //     stats. v6.2.5 fills in via inline executor
4682            //     instrumentation.
4683            // Total elapsed lands on a trailing `Total: …` line.
4684            let started = self.clock.map(|f| f());
4685            let exec = self.exec_select_cancel(&e.inner, cancel)?;
4686            let elapsed_micros = match (self.clock, started) {
4687                (Some(f), Some(s)) => Some(f().saturating_sub(s)),
4688                _ => None,
4689            };
4690            let row_count = if let QueryResult::Rows { rows, .. } = &exec {
4691                rows.len()
4692            } else {
4693                0
4694            };
4695            annotate_explain_lines(&mut lines, row_count, self);
4696            let mut total = alloc::format!("Total: rows={row_count}");
4697            if let Some(us) = elapsed_micros {
4698                total.push_str(&alloc::format!(" elapsed={us}us"));
4699            }
4700            lines.push(total);
4701        }
4702        let columns = alloc::vec![ColumnSchema::new("QUERY PLAN", DataType::Text, false)];
4703        let rows: Vec<Row> = lines
4704            .into_iter()
4705            .map(|l| Row::new(alloc::vec![Value::Text(l)]))
4706            .collect();
4707        Ok(QueryResult::Rows { columns, rows })
4708    }
4709
4710    fn exec_show_tables(&self) -> QueryResult {
4711        let columns = alloc::vec![ColumnSchema::new("name", DataType::Text, false)];
4712        let rows: Vec<Row> = self
4713            .active_catalog()
4714            .table_names()
4715            .into_iter()
4716            .map(|n| Row::new(alloc::vec![Value::Text(n)]))
4717            .collect();
4718        QueryResult::Rows { columns, rows }
4719    }
4720
4721    /// v7.17.0 Phase 3.P0-59 — `SHOW CREATE TABLE <t>`. Synthesise
4722    /// a minimal MySQL-flavoured CREATE TABLE DDL from the
4723    /// catalog's TableSchema so mysqldump round-trips load against
4724    /// SPG without splitting init scripts.
4725    fn exec_show_create_table(&self, name: &str) -> Result<QueryResult, EngineError> {
4726        let t = self.active_catalog().get(name).ok_or_else(|| {
4727            EngineError::Storage(StorageError::TableNotFound { name: name.into() })
4728        })?;
4729        let cols: Vec<String> = t
4730            .schema()
4731            .columns
4732            .iter()
4733            .map(|c| {
4734                let ty = render_data_type(c.ty);
4735                let nullable = if c.nullable { "" } else { " NOT NULL" };
4736                alloc::format!("  `{}` {}{}", c.name, ty, nullable)
4737            })
4738            .collect();
4739        let mut body = cols.join(",\n");
4740        // Append UNIQUE / PRIMARY KEY clauses.
4741        for uc in &t.schema().uniqueness_constraints {
4742            let col_names: Vec<String> = uc
4743                .columns
4744                .iter()
4745                .map(|&p| {
4746                    t.schema().columns.get(p).map_or_else(
4747                        || alloc::format!("col{p}"),
4748                        |c| alloc::format!("`{}`", c.name),
4749                    )
4750                })
4751                .collect();
4752            let kw = if uc.is_primary_key {
4753                "PRIMARY KEY"
4754            } else {
4755                "UNIQUE KEY"
4756            };
4757            body.push_str(",\n  ");
4758            body.push_str(&alloc::format!("{kw} ({})", col_names.join(", ")));
4759        }
4760        // Foreign keys.
4761        for fk in &t.schema().foreign_keys {
4762            let local: Vec<String> = fk
4763                .local_columns
4764                .iter()
4765                .map(|&p| {
4766                    t.schema().columns.get(p).map_or_else(
4767                        || alloc::format!("col{p}"),
4768                        |c| alloc::format!("`{}`", c.name),
4769                    )
4770                })
4771                .collect();
4772            let parent_cols: Vec<String> =
4773                if let Some(parent) = self.active_catalog().get(&fk.parent_table) {
4774                    fk.parent_columns
4775                        .iter()
4776                        .map(|&p| {
4777                            parent.schema().columns.get(p).map_or_else(
4778                                || alloc::format!("col{p}"),
4779                                |c| alloc::format!("`{}`", c.name),
4780                            )
4781                        })
4782                        .collect()
4783                } else {
4784                    fk.parent_columns
4785                        .iter()
4786                        .map(|p| alloc::format!("col{p}"))
4787                        .collect()
4788                };
4789            body.push_str(",\n  ");
4790            body.push_str(&alloc::format!(
4791                "FOREIGN KEY ({}) REFERENCES `{}` ({})",
4792                local.join(", "),
4793                fk.parent_table,
4794                parent_cols.join(", ")
4795            ));
4796        }
4797        let ddl = alloc::format!(
4798            "CREATE TABLE `{}` (\n{}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
4799            name,
4800            body
4801        );
4802        let columns = alloc::vec![
4803            ColumnSchema::new("Table", DataType::Text, false),
4804            ColumnSchema::new("Create Table", DataType::Text, false),
4805        ];
4806        let rows = alloc::vec![Row::new(alloc::vec![
4807            Value::Text(name.into()),
4808            Value::Text(ddl),
4809        ])];
4810        Ok(QueryResult::Rows { columns, rows })
4811    }
4812
4813    /// v7.17.0 Phase 3.P0-60 — `SHOW INDEXES FROM <t>`. MySQL
4814    /// surface returns one row per (index × column) with 14
4815    /// columns; v7.17 ships the columns admin probes actually
4816    /// filter on: Table, Non_unique, Key_name, Seq_in_index,
4817    /// Column_name, Null, Index_type.
4818    fn exec_show_indexes(&self, name: &str) -> Result<QueryResult, EngineError> {
4819        let t = self.active_catalog().get(name).ok_or_else(|| {
4820            EngineError::Storage(StorageError::TableNotFound { name: name.into() })
4821        })?;
4822        let columns = alloc::vec![
4823            ColumnSchema::new("Table", DataType::Text, false),
4824            ColumnSchema::new("Non_unique", DataType::Int, false),
4825            ColumnSchema::new("Key_name", DataType::Text, false),
4826            ColumnSchema::new("Seq_in_index", DataType::Int, false),
4827            ColumnSchema::new("Column_name", DataType::Text, false),
4828            ColumnSchema::new("Null", DataType::Text, false),
4829            ColumnSchema::new("Index_type", DataType::Text, false),
4830        ];
4831        let mut rows: Vec<Row> = Vec::new();
4832        for idx in t.indices() {
4833            let col = t
4834                .schema()
4835                .columns
4836                .get(idx.column_position)
4837                .map_or("?".into(), |c| c.name.clone());
4838            let nullable = t
4839                .schema()
4840                .columns
4841                .get(idx.column_position)
4842                .map_or(true, |c| c.nullable);
4843            rows.push(Row::new(alloc::vec![
4844                Value::Text(name.into()),
4845                Value::Int(i32::from(!idx.is_unique)),
4846                Value::Text(idx.name.clone()),
4847                Value::Int(1),
4848                Value::Text(col),
4849                Value::Text(if nullable {
4850                    "YES".into()
4851                } else {
4852                    String::new()
4853                }),
4854                Value::Text("BTREE".into()),
4855            ]));
4856        }
4857        Ok(QueryResult::Rows { columns, rows })
4858    }
4859
4860    /// v7.17.0 Phase 3.P0-61 — `SHOW STATUS`. Returns canonical
4861    /// MySQL server-status counters (2-column `(Variable_name,
4862    /// Value)`).
4863    fn exec_show_status(&self) -> QueryResult {
4864        let columns = alloc::vec![
4865            ColumnSchema::new("Variable_name", DataType::Text, false),
4866            ColumnSchema::new("Value", DataType::Text, false),
4867        ];
4868        let pairs: &[(&str, &str)] = &[
4869            ("Uptime", "0"),
4870            ("Threads_connected", "1"),
4871            ("Threads_running", "1"),
4872            ("Questions", "0"),
4873            ("Slow_queries", "0"),
4874            ("Opened_tables", "0"),
4875            ("Innodb_buffer_pool_pages_total", "0"),
4876        ];
4877        let rows: Vec<Row> = pairs
4878            .iter()
4879            .map(|(k, v)| {
4880                Row::new(alloc::vec![
4881                    Value::Text((*k).into()),
4882                    Value::Text((*v).into())
4883                ])
4884            })
4885            .collect();
4886        QueryResult::Rows { columns, rows }
4887    }
4888
4889    /// v7.17.0 Phase 3.P0-61 — `SHOW VARIABLES`. Returns server-side
4890    /// variables MySQL/MariaDB clients probe at connect time.
4891    fn exec_show_variables(&self) -> QueryResult {
4892        let columns = alloc::vec![
4893            ColumnSchema::new("Variable_name", DataType::Text, false),
4894            ColumnSchema::new("Value", DataType::Text, false),
4895        ];
4896        let mut rows: Vec<Row> = Vec::new();
4897        let canonical: &[(&str, &str)] = &[
4898            ("version", "8.0.35-spg"),
4899            ("version_comment", "SPG dual-stack engine"),
4900            ("character_set_server", "utf8mb4"),
4901            ("collation_server", "utf8mb4_0900_ai_ci"),
4902            ("max_allowed_packet", "67108864"),
4903            ("autocommit", "ON"),
4904            ("sql_mode", "STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION"),
4905            ("time_zone", "SYSTEM"),
4906            ("transaction_isolation", "REPEATABLE-READ"),
4907        ];
4908        for &(k, v) in canonical {
4909            rows.push(Row::new(alloc::vec![
4910                Value::Text(k.into()),
4911                Value::Text(v.into()),
4912            ]));
4913        }
4914        // Session-set parameters surface here too.
4915        for (k, v) in &self.session_params {
4916            if !canonical.iter().any(|(n, _)| (*n).eq_ignore_ascii_case(k)) {
4917                rows.push(Row::new(alloc::vec![
4918                    Value::Text(k.clone()),
4919                    Value::Text(v.clone()),
4920                ]));
4921            }
4922        }
4923        QueryResult::Rows { columns, rows }
4924    }
4925
4926    /// v7.17.0 Phase 3.P0-62 — `SHOW PROCESSLIST`. SPG is
4927    /// single-process so the surface returns one synthetic row
4928    /// describing the current connection (Id, User, Host, db,
4929    /// Command, Time, State, Info).
4930    fn exec_show_processlist(&self) -> QueryResult {
4931        let columns = alloc::vec![
4932            ColumnSchema::new("Id", DataType::Int, false),
4933            ColumnSchema::new("User", DataType::Text, false),
4934            ColumnSchema::new("Host", DataType::Text, false),
4935            ColumnSchema::new("db", DataType::Text, true),
4936            ColumnSchema::new("Command", DataType::Text, false),
4937            ColumnSchema::new("Time", DataType::Int, false),
4938            ColumnSchema::new("State", DataType::Text, true),
4939            ColumnSchema::new("Info", DataType::Text, true),
4940        ];
4941        let rows = alloc::vec![Row::new(alloc::vec![
4942            Value::Int(1),
4943            Value::Text("postgres".into()),
4944            Value::Text("localhost".into()),
4945            Value::Text("postgres".into()),
4946            Value::Text("Query".into()),
4947            Value::Int(0),
4948            Value::Text("executing".into()),
4949            Value::Text("SHOW PROCESSLIST".into()),
4950        ])];
4951        QueryResult::Rows { columns, rows }
4952    }
4953
4954    /// v7.17.0 Phase 3.P0-58 — `SHOW DATABASES` / `SHOW SCHEMAS`.
4955    /// SPG is single-database so the result is the canonical MySQL
4956    /// set every mysql/MariaDB client expects at connect time:
4957    /// `information_schema`, `mysql`, `performance_schema`, `sys`,
4958    /// plus a `postgres` slot so dual-stack callers find their
4959    /// PG-compatible database too.
4960    fn exec_show_databases(&self) -> QueryResult {
4961        let columns = alloc::vec![ColumnSchema::new("Database", DataType::Text, false)];
4962        let names = [
4963            "information_schema",
4964            "mysql",
4965            "performance_schema",
4966            "sys",
4967            "postgres",
4968        ];
4969        let rows: Vec<Row> = names
4970            .iter()
4971            .map(|n| Row::new(alloc::vec![Value::Text((*n).into())]))
4972            .collect();
4973        QueryResult::Rows { columns, rows }
4974    }
4975
4976    /// `SHOW COLUMNS FROM <table>` — one row per column with the
4977    /// declared name, SQL type rendering, and nullability flag.
4978    fn exec_show_columns(&self, table_name: &str) -> Result<QueryResult, EngineError> {
4979        let table =
4980            self.active_catalog()
4981                .get(table_name)
4982                .ok_or_else(|| StorageError::TableNotFound {
4983                    name: table_name.into(),
4984                })?;
4985        let columns = alloc::vec![
4986            ColumnSchema::new("name", DataType::Text, false),
4987            ColumnSchema::new("type", DataType::Text, false),
4988            ColumnSchema::new("nullable", DataType::Bool, false),
4989        ];
4990        let rows: Vec<Row> = table
4991            .schema()
4992            .columns
4993            .iter()
4994            .map(|c| {
4995                Row::new(alloc::vec![
4996                    Value::Text(c.name.clone()),
4997                    Value::Text(alloc::format!("{}", c.ty)),
4998                    Value::Bool(c.nullable),
4999                ])
5000            })
5001            .collect();
5002        Ok(QueryResult::Rows { columns, rows })
5003    }
5004
5005    fn exec_begin(&mut self) -> Result<QueryResult, EngineError> {
5006        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5007        if self.tx_catalogs.contains_key(&tx_id) {
5008            return Err(EngineError::TransactionAlreadyOpen);
5009        }
5010        self.tx_catalogs.insert(
5011            tx_id,
5012            TxState {
5013                catalog: self.catalog.clone(),
5014                savepoints: Vec::new(),
5015            },
5016        );
5017        Ok(QueryResult::CommandOk {
5018            affected: 0,
5019            modified_catalog: false,
5020        })
5021    }
5022
5023    fn exec_commit(&mut self) -> Result<QueryResult, EngineError> {
5024        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5025        let state = self
5026            .tx_catalogs
5027            .remove(&tx_id)
5028            .ok_or(EngineError::NoActiveTransaction)?;
5029        self.catalog = state.catalog;
5030        // All savepoints become permanent at COMMIT and the stack
5031        // resets for the next TX (`state.savepoints` is discarded with
5032        // `state`).
5033        Ok(QueryResult::CommandOk {
5034            affected: 0,
5035            modified_catalog: true,
5036        })
5037    }
5038
5039    fn exec_rollback(&mut self) -> Result<QueryResult, EngineError> {
5040        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5041        if self.tx_catalogs.remove(&tx_id).is_none() {
5042            return Err(EngineError::NoActiveTransaction);
5043        }
5044        // savepoints discarded with the TxState
5045        Ok(QueryResult::CommandOk {
5046            affected: 0,
5047            modified_catalog: false,
5048        })
5049    }
5050
5051    fn exec_savepoint(&mut self, name: String) -> Result<QueryResult, EngineError> {
5052        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5053        let state = self
5054            .tx_catalogs
5055            .get_mut(&tx_id)
5056            .ok_or(EngineError::NoActiveTransaction)?;
5057        // PG re-uses an existing savepoint name by dropping the older
5058        // entry and pushing a fresh one — match that behaviour so
5059        // application code can `SAVEPOINT sp; ...; SAVEPOINT sp` freely.
5060        state.savepoints.retain(|(n, _)| n != &name);
5061        let snapshot = state.catalog.clone();
5062        state.savepoints.push((name, snapshot));
5063        Ok(QueryResult::CommandOk {
5064            affected: 0,
5065            modified_catalog: false,
5066        })
5067    }
5068
5069    fn exec_rollback_to_savepoint(&mut self, name: &str) -> Result<QueryResult, EngineError> {
5070        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5071        let state = self
5072            .tx_catalogs
5073            .get_mut(&tx_id)
5074            .ok_or(EngineError::NoActiveTransaction)?;
5075        let pos = state
5076            .savepoints
5077            .iter()
5078            .rposition(|(n, _)| n == name)
5079            .ok_or_else(|| {
5080                EngineError::Unsupported(alloc::format!("savepoint not found: {name}"))
5081            })?;
5082        // The savepoint stays on the stack (PG semantics): a later
5083        // `RELEASE` or further `ROLLBACK TO` is still allowed. Everything
5084        // after it is discarded.
5085        let snapshot = state.savepoints[pos].1.clone();
5086        state.savepoints.truncate(pos + 1);
5087        state.catalog = snapshot;
5088        Ok(QueryResult::CommandOk {
5089            affected: 0,
5090            modified_catalog: false,
5091        })
5092    }
5093
5094    fn exec_release_savepoint(&mut self, name: &str) -> Result<QueryResult, EngineError> {
5095        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5096        let state = self
5097            .tx_catalogs
5098            .get_mut(&tx_id)
5099            .ok_or(EngineError::NoActiveTransaction)?;
5100        let pos = state
5101            .savepoints
5102            .iter()
5103            .rposition(|(n, _)| n == name)
5104            .ok_or_else(|| {
5105                EngineError::Unsupported(alloc::format!("savepoint not found: {name}"))
5106            })?;
5107        // RELEASE keeps the work since the savepoint, just discards the
5108        // bookmark plus everything nested under it.
5109        state.savepoints.truncate(pos);
5110        Ok(QueryResult::CommandOk {
5111            affected: 0,
5112            modified_catalog: false,
5113        })
5114    }
5115
5116    /// v6.0.4 — synchronous `ALTER INDEX <name> REBUILD [WITH
5117    /// (encoding = …)]`. Walks every table in the active catalog
5118    /// looking for an index matching `stmt.name`, then delegates the
5119    /// rebuild (including any encoding switch) to
5120    /// `Table::rebuild_nsw_index`. The "live" non-blocking
5121    /// optimisation is v6.0.4.1 / v6.1.x territory.
5122    /// v6.7.2 — `ALTER TABLE t SET hot_tier_bytes = X`. Dispatch
5123    /// arm. Currently the only setting is `hot_tier_bytes`; later
5124    /// v6.7.x can extend `AlterTableTarget` without touching this
5125    /// arm structure.
5126    fn exec_alter_table(
5127        &mut self,
5128        s: spg_sql::ast::AlterTableStatement,
5129    ) -> Result<QueryResult, EngineError> {
5130        // v7.13.2 — mailrs round-6 S1: apply each subaction in order.
5131        // On first error the statement aborts; subactions already
5132        // applied stay (no transactional rollback in v7.13 — wrap in
5133        // BEGIN/COMMIT if atomicity matters).
5134        let table_name = s.name.clone();
5135        for target in s.targets {
5136            self.exec_alter_table_subaction(&table_name, target)?;
5137        }
5138        Ok(QueryResult::CommandOk {
5139            affected: 0,
5140            modified_catalog: !self.in_transaction(),
5141        })
5142    }
5143
5144    fn exec_alter_table_subaction(
5145        &mut self,
5146        table_name_outer: &str,
5147        target: spg_sql::ast::AlterTableTarget,
5148    ) -> Result<(), EngineError> {
5149        // Inner helper retains the s.name closure shape; alias to `s`
5150        // for minimal diff against the v7.13.0 body.
5151        struct S<'a> {
5152            name: &'a str,
5153        }
5154        let s = S {
5155            name: table_name_outer,
5156        };
5157        match target {
5158            spg_sql::ast::AlterTableTarget::SetHotTierBytes(n) => {
5159                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5160                    EngineError::Storage(StorageError::TableNotFound {
5161                        name: s.name.into(),
5162                    })
5163                })?;
5164                table.schema_mut().hot_tier_bytes = Some(n);
5165            }
5166            spg_sql::ast::AlterTableTarget::AddForeignKey(fk) => {
5167                // v7.6.8 — resolve FK against the live catalog first
5168                // (validates parent table, columns, indices). Then
5169                // verify every existing row in the child table
5170                // satisfies the new constraint. Then install it.
5171                let cols_snapshot = self
5172                    .active_catalog()
5173                    .get(s.name)
5174                    .ok_or_else(|| {
5175                        EngineError::Storage(StorageError::TableNotFound {
5176                            name: s.name.into(),
5177                        })
5178                    })?
5179                    .schema()
5180                    .columns
5181                    .clone();
5182                let storage_fk =
5183                    resolve_foreign_key(s.name, &cols_snapshot, fk, self.active_catalog())?;
5184                // Verify existing rows. Treat them as a virtual
5185                // INSERT batch — reusing the v7.6.2 enforce helper.
5186                let existing_rows: Vec<Vec<Value>> = self
5187                    .active_catalog()
5188                    .get(s.name)
5189                    .expect("checked above")
5190                    .rows()
5191                    .iter()
5192                    .map(|r| r.values.clone())
5193                    .collect();
5194                enforce_fk_inserts(
5195                    self.active_catalog(),
5196                    s.name,
5197                    core::slice::from_ref(&storage_fk),
5198                    &existing_rows,
5199                )?;
5200                // Reject duplicate constraint name.
5201                let table = self
5202                    .active_catalog_mut()
5203                    .get_mut(s.name)
5204                    .expect("checked above");
5205                if let Some(name) = &storage_fk.name
5206                    && table
5207                        .schema()
5208                        .foreign_keys
5209                        .iter()
5210                        .any(|f| f.name.as_ref() == Some(name))
5211                {
5212                    return Err(EngineError::Unsupported(alloc::format!(
5213                        "ALTER TABLE ADD CONSTRAINT: a constraint named {name:?} already exists"
5214                    )));
5215                }
5216                table.schema_mut().foreign_keys.push(storage_fk);
5217            }
5218            spg_sql::ast::AlterTableTarget::DropForeignKey { name, if_exists } => {
5219                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5220                    EngineError::Storage(StorageError::TableNotFound {
5221                        name: s.name.into(),
5222                    })
5223                })?;
5224                let fks = &mut table.schema_mut().foreign_keys;
5225                let before = fks.len();
5226                fks.retain(|f| f.name.as_ref() != Some(&name));
5227                if fks.len() == before && !if_exists {
5228                    return Err(EngineError::Unsupported(alloc::format!(
5229                        "ALTER TABLE DROP CONSTRAINT: no FK named {name:?} on {:?}",
5230                        s.name
5231                    )));
5232                }
5233                // v7.13.2 mailrs round-6 S7: IF EXISTS silences the miss.
5234            }
5235            spg_sql::ast::AlterTableTarget::AddColumn {
5236                column,
5237                if_not_exists,
5238            } => {
5239                // v7.13.0 — mailrs round-5 G1. Append-only column add
5240                // with back-fill of the DEFAULT (or NULL) into every
5241                // existing row. Column positions don't shift, so we
5242                // skip index rebuild.
5243                let clock = self.clock;
5244                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5245                    EngineError::Storage(StorageError::TableNotFound {
5246                        name: s.name.into(),
5247                    })
5248                })?;
5249                if table
5250                    .schema()
5251                    .columns
5252                    .iter()
5253                    .any(|c| c.name.eq_ignore_ascii_case(&column.name))
5254                {
5255                    if if_not_exists {
5256                        return Ok(());
5257                    }
5258                    return Err(EngineError::Unsupported(alloc::format!(
5259                        "ALTER TABLE ADD COLUMN: column {:?} already exists on {:?}",
5260                        column.name,
5261                        s.name
5262                    )));
5263                }
5264                let col_name = column.name.clone();
5265                let nullable = column.nullable;
5266                let has_default = column.default.is_some() || column.auto_increment;
5267                let col_schema = column_def_to_schema(column)?;
5268                let row_count = table.row_count();
5269                // Compute the back-fill value. Literal / runtime DEFAULT
5270                // funnels through the same resolver that INSERT uses
5271                // (v7.9.21 `resolve_column_default_free`). NULL when
5272                // the column is nullable and has no DEFAULT. NOT NULL
5273                // without DEFAULT errors when the table has existing
5274                // rows — same as PG.
5275                let fill_value: Value = if has_default || col_schema.runtime_default.is_some() {
5276                    resolve_column_default_free(&col_schema, clock)?
5277                } else if nullable || row_count == 0 {
5278                    Value::Null
5279                } else {
5280                    return Err(EngineError::Unsupported(alloc::format!(
5281                        "ALTER TABLE ADD COLUMN {col_name:?}: NOT NULL column requires DEFAULT \
5282                         when the table has existing rows"
5283                    )));
5284                };
5285                table.add_column(col_schema, fill_value);
5286            }
5287            spg_sql::ast::AlterTableTarget::AlterColumnType {
5288                column,
5289                new_type,
5290                using,
5291            } => {
5292                // v7.13.0 — mailrs round-5 G8. Re-evaluate each
5293                // row's column value (either through the USING
5294                // expression if supplied, or as a direct CAST of
5295                // the existing value) and re-coerce to the new
5296                // type. Indices on the column get rebuilt.
5297                let new_data_type = column_type_to_data_type(new_type);
5298                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5299                    EngineError::Storage(StorageError::TableNotFound {
5300                        name: s.name.into(),
5301                    })
5302                })?;
5303                let col_pos = table
5304                    .schema()
5305                    .columns
5306                    .iter()
5307                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5308                    .ok_or_else(|| {
5309                        EngineError::Unsupported(alloc::format!(
5310                            "ALTER COLUMN TYPE: column {column:?} not found on {:?}",
5311                            s.name
5312                        ))
5313                    })?;
5314                let schema_cols = table.schema().columns.clone();
5315                let ctx = eval::EvalContext::new(&schema_cols, None);
5316                let mut new_values: alloc::vec::Vec<Value> =
5317                    alloc::vec::Vec::with_capacity(table.row_count());
5318                for row in table.rows().iter() {
5319                    let raw = match &using {
5320                        Some(expr) => eval::eval_expr(expr, row, &ctx).map_err(|e| {
5321                            EngineError::Unsupported(alloc::format!(
5322                                "ALTER COLUMN TYPE: USING expression failed: {e:?}"
5323                            ))
5324                        })?,
5325                        None => row.values.get(col_pos).cloned().unwrap_or(Value::Null),
5326                    };
5327                    let coerced = coerce_value(raw, new_data_type, &column, col_pos)?;
5328                    new_values.push(coerced);
5329                }
5330                table.schema_mut().columns[col_pos].ty = new_data_type;
5331                for (i, v) in new_values.into_iter().enumerate() {
5332                    let mut row_values = table
5333                        .rows()
5334                        .get(i)
5335                        .expect("bounds-checked above")
5336                        .values
5337                        .clone();
5338                    row_values[col_pos] = v;
5339                    table.update_row(i, row_values)?;
5340                }
5341            }
5342            spg_sql::ast::AlterTableTarget::AddTableConstraint(tc) => {
5343                // v7.14.0 — pg_dump emits PKs as a separate
5344                // ALTER TABLE ADD CONSTRAINT post-CREATE-TABLE.
5345                // For PRIMARY KEY / UNIQUE, install a UC entry
5346                // and the implicit BTree index on the leading
5347                // column. CHECK: append predicate to schema.
5348                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5349                    EngineError::Storage(StorageError::TableNotFound {
5350                        name: s.name.into(),
5351                    })
5352                })?;
5353                let is_pk = matches!(tc, spg_sql::ast::TableConstraint::PrimaryKey { .. });
5354                match tc {
5355                    spg_sql::ast::TableConstraint::PrimaryKey { columns, .. }
5356                    | spg_sql::ast::TableConstraint::Unique { columns, .. } => {
5357                        let positions: Vec<usize> = columns
5358                            .iter()
5359                            .map(|c| {
5360                                table
5361                                    .schema()
5362                                    .columns
5363                                    .iter()
5364                                    .position(|sc| sc.name.eq_ignore_ascii_case(c))
5365                                    .ok_or_else(|| {
5366                                        EngineError::Unsupported(alloc::format!(
5367                                            "ALTER TABLE ADD CONSTRAINT: column {c:?} not found on {:?}",
5368                                            s.name
5369                                        ))
5370                                    })
5371                            })
5372                            .collect::<Result<Vec<_>, _>>()?;
5373                        // Skip if an equivalent UC is already there
5374                        // (idempotent — pg_dump's PK + a prior inline
5375                        // PK shouldn't double-install).
5376                        let already = table
5377                            .schema()
5378                            .uniqueness_constraints
5379                            .iter()
5380                            .any(|u| u.columns == positions);
5381                        if !already {
5382                            table.schema_mut().uniqueness_constraints.push(
5383                                spg_storage::UniquenessConstraint {
5384                                    is_primary_key: is_pk,
5385                                    columns: positions.clone(),
5386                                    nulls_not_distinct: false,
5387                                },
5388                            );
5389                            // PK implies NOT NULL on referenced cols.
5390                            if is_pk {
5391                                for p in &positions {
5392                                    if let Some(c) = table.schema_mut().columns.get_mut(*p) {
5393                                        c.nullable = false;
5394                                    }
5395                                }
5396                            }
5397                            // Add a BTree index on the leading
5398                            // column for INSERT-side enforcement.
5399                            let leading = &columns[0];
5400                            let already_idx = table.indices().iter().any(|idx| {
5401                                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
5402                                    && table.schema().columns[idx.column_position].name == *leading
5403                            });
5404                            if !already_idx {
5405                                let suffix = if is_pk { "pkey" } else { "key" };
5406                                let idx_name = alloc::format!("{}_{leading}_{suffix}", s.name);
5407                                let _ = table.add_index(idx_name, leading);
5408                            }
5409                        }
5410                    }
5411                    spg_sql::ast::TableConstraint::Check { expr, .. } => {
5412                        table.schema_mut().checks.push(alloc::format!("{expr}"));
5413                    }
5414                    spg_sql::ast::TableConstraint::Index { name, columns } => {
5415                        // v7.15.0 — ALTER TABLE ADD KEY (cols).
5416                        // mysqldump occasionally emits this
5417                        // post-CREATE-TABLE shape; build a BTree
5418                        // on the leading column using the
5419                        // user-supplied or synthesised name.
5420                        let leading = &columns[0];
5421                        let already_idx = table.indices().iter().any(|idx| {
5422                            matches!(idx.kind, spg_storage::IndexKind::BTree(_))
5423                                && table.schema().columns[idx.column_position].name == *leading
5424                        });
5425                        if !already_idx {
5426                            let idx_name = name
5427                                .clone()
5428                                .unwrap_or_else(|| alloc::format!("{}_{leading}_idx", s.name));
5429                            let _ = table.add_index(idx_name, leading);
5430                        }
5431                    }
5432                    spg_sql::ast::TableConstraint::FulltextIndex { name, columns } => {
5433                        // v7.17.0 Phase 2.2 — ALTER TABLE ADD
5434                        // FULLTEXT KEY (cols). Builds one
5435                        // fulltext-GIN per named column so MATCH
5436                        // AGAINST gets a real inverted index.
5437                        // Multi-column declarations expand to
5438                        // per-column GINs (the leading column
5439                        // drives MATCH AGAINST planning).
5440                        for (k, col) in columns.iter().enumerate() {
5441                            let already_idx = table.indices().iter().any(|idx| {
5442                                matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
5443                                    && table.schema().columns[idx.column_position].name == *col
5444                            });
5445                            if already_idx {
5446                                continue;
5447                            }
5448                            let idx_name = match (&name, columns.len(), k) {
5449                                (Some(n), 1, _) => n.clone(),
5450                                (Some(n), _, k) => alloc::format!("{n}_{k}"),
5451                                (None, _, _) => {
5452                                    alloc::format!("{}_{col}_ftidx", s.name)
5453                                }
5454                            };
5455                            let _ = table.add_gin_fulltext_index(idx_name, col);
5456                        }
5457                    }
5458                }
5459            }
5460            spg_sql::ast::AlterTableTarget::DropColumn {
5461                column,
5462                if_exists,
5463                cascade,
5464            } => {
5465                // v7.13.3 — mailrs round-7 S8. Remove the column +
5466                // every row's value at that position; drop any index
5467                // on the column. RESTRICT (default) rejects when an
5468                // FK on this table or partial-index predicate
5469                // references the column; CASCADE removes those
5470                // dependents first.
5471                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5472                    EngineError::Storage(StorageError::TableNotFound {
5473                        name: s.name.into(),
5474                    })
5475                })?;
5476                let col_pos = match table
5477                    .schema()
5478                    .columns
5479                    .iter()
5480                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5481                {
5482                    Some(p) => p,
5483                    None => {
5484                        if if_exists {
5485                            return Ok(());
5486                        }
5487                        return Err(EngineError::Unsupported(alloc::format!(
5488                            "ALTER TABLE DROP COLUMN: column {column:?} not found on {:?}",
5489                            s.name
5490                        )));
5491                    }
5492                };
5493                // Dependent check: FKs whose local columns include
5494                // col_pos. CASCADE drops them; otherwise reject.
5495                let dependent_fks: Vec<usize> = table
5496                    .schema()
5497                    .foreign_keys
5498                    .iter()
5499                    .enumerate()
5500                    .filter_map(|(i, fk)| {
5501                        if fk.local_columns.contains(&col_pos) {
5502                            Some(i)
5503                        } else {
5504                            None
5505                        }
5506                    })
5507                    .collect();
5508                if !dependent_fks.is_empty() && !cascade {
5509                    return Err(EngineError::Unsupported(alloc::format!(
5510                        "ALTER TABLE DROP COLUMN {column:?}: column has FK dependents; \
5511                         use DROP COLUMN ... CASCADE to remove them"
5512                    )));
5513                }
5514                // CASCADE the FK removals first.
5515                if cascade {
5516                    // Drop in reverse so indices stay valid.
5517                    let mut sorted = dependent_fks.clone();
5518                    sorted.sort();
5519                    sorted.reverse();
5520                    let fks = &mut table.schema_mut().foreign_keys;
5521                    for i in sorted {
5522                        fks.remove(i);
5523                    }
5524                }
5525                // Drop the column. New helper on Table does the
5526                // row + schema + index shift atomically.
5527                table.drop_column(col_pos);
5528            }
5529            spg_sql::ast::AlterTableTarget::SetTriggerEnabled { which, enabled } => {
5530                // v7.16.1 — mailrs round-9 A.2.b. pg_dump
5531                // --disable-triggers wraps each table's data
5532                // block with `ALTER TABLE … DISABLE TRIGGER ALL`
5533                // / `… ENABLE TRIGGER ALL`. Toggle the enabled
5534                // flag on every matching trigger so the row-
5535                // write paths skip them; the catalog snapshot
5536                // persists the new state across restarts.
5537                let table_name = s.name.to_string();
5538                let trigs = self.active_catalog_mut().triggers_mut();
5539                let mut touched = false;
5540                for t in trigs.iter_mut() {
5541                    if !t.table.eq_ignore_ascii_case(&table_name) {
5542                        continue;
5543                    }
5544                    match &which {
5545                        spg_sql::ast::TriggerSelector::All => {
5546                            t.enabled = enabled;
5547                            touched = true;
5548                        }
5549                        spg_sql::ast::TriggerSelector::Named(name) => {
5550                            if t.name.eq_ignore_ascii_case(name) {
5551                                t.enabled = enabled;
5552                                touched = true;
5553                            }
5554                        }
5555                    }
5556                }
5557                // PG semantics: `ALL` on a table with no
5558                // triggers is a no-op (no error). A `Named`
5559                // form pointing at a non-existent trigger
5560                // raises in PG; v7.16.1 also raises so we
5561                // don't silently lose state.
5562                if !touched {
5563                    if let spg_sql::ast::TriggerSelector::Named(name) = &which {
5564                        return Err(EngineError::Unsupported(alloc::format!(
5565                            "ALTER TABLE {table_name:?} {} TRIGGER {name:?}: no such trigger on table",
5566                            if enabled { "ENABLE" } else { "DISABLE" },
5567                        )));
5568                    }
5569                }
5570            }
5571            spg_sql::ast::AlterTableTarget::RenameTable { new } => {
5572                // v7.16.2 — table-level rename (mailrs round-10
5573                // A.5 — used by migrate-042's `ALTER TABLE
5574                // contacts RENAME TO email_contacts`). Storage
5575                // helper updates the schema + by_name index +
5576                // dangling FK / trigger references in one
5577                // atomic step.
5578                let old = s.name.to_string();
5579                self.active_catalog_mut()
5580                    .rename_table(&old, &new)
5581                    .map_err(EngineError::Storage)?;
5582            }
5583            spg_sql::ast::AlterTableTarget::RenameColumn { old, new } => {
5584                // v7.15.0 — `ALTER TABLE t RENAME [COLUMN] old TO
5585                // new`. Rename the column in the schema; rewrite
5586                // every stored source string on this table that
5587                // references it as a (potentially-qualified)
5588                // column identifier: CHECK predicates, partial-
5589                // index predicates, runtime DEFAULT expressions.
5590                // Then walk catalog triggers on this table and
5591                // patch any `UPDATE OF` column list. Function and
5592                // trigger bodies are NOT auto-rewritten — that
5593                // surface is dynamic SQL territory; users update
5594                // those separately (matches PG plpgsql behavior:
5595                // a column rename invalidates name-referencing
5596                // plpgsql at call time, not rename time).
5597                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5598                    EngineError::Storage(StorageError::TableNotFound {
5599                        name: s.name.into(),
5600                    })
5601                })?;
5602                let col_pos = table
5603                    .schema()
5604                    .columns
5605                    .iter()
5606                    .position(|c| c.name.eq_ignore_ascii_case(&old))
5607                    .ok_or_else(|| {
5608                        EngineError::Unsupported(alloc::format!(
5609                            "ALTER TABLE RENAME COLUMN: column {old:?} not found on {:?}",
5610                            s.name
5611                        ))
5612                    })?;
5613                // Reject same-name (case-insensitive) collision.
5614                if table
5615                    .schema()
5616                    .columns
5617                    .iter()
5618                    .enumerate()
5619                    .any(|(i, c)| i != col_pos && c.name.eq_ignore_ascii_case(&new))
5620                {
5621                    return Err(EngineError::Unsupported(alloc::format!(
5622                        "ALTER TABLE RENAME COLUMN: column {new:?} already exists on {:?}",
5623                        s.name
5624                    )));
5625                }
5626                // Schema rename first — even idempotent same-name
5627                // rename (`ALTER TABLE t RENAME a TO a`) needs to
5628                // be a no-op, not an error.
5629                if old.eq_ignore_ascii_case(&new) {
5630                    return Ok(());
5631                }
5632                table.rename_column(col_pos, &new);
5633                // Rewrite per-column runtime_default sources on
5634                // every column of this table — a DEFAULT expression
5635                // on column X may reference column Y by name (rare,
5636                // but legal in PG when the value is supplied via a
5637                // function that takes the row).
5638                let n_cols = table.schema().columns.len();
5639                for i in 0..n_cols {
5640                    let rt = table.schema().columns[i].runtime_default.clone();
5641                    if let Some(src) = rt {
5642                        let rewritten = rewrite_column_in_source(&src, &old, &new)?;
5643                        table.schema_mut().columns[i].runtime_default = Some(rewritten);
5644                    }
5645                }
5646                // Rewrite table-level CHECK predicates.
5647                let checks = table.schema().checks.clone();
5648                let mut new_checks = Vec::with_capacity(checks.len());
5649                for chk in checks {
5650                    new_checks.push(rewrite_column_in_source(&chk, &old, &new)?);
5651                }
5652                table.schema_mut().checks = new_checks;
5653                // Rewrite per-index partial_predicate sources.
5654                let n_idx = table.indices().len();
5655                for i in 0..n_idx {
5656                    let pred = table.indices()[i].partial_predicate.clone();
5657                    if let Some(src) = pred {
5658                        let rewritten = rewrite_column_in_source(&src, &old, &new)?;
5659                        // SAFETY: indices_mut would be cleanest, but
5660                        // partial_predicate is the only mutable field
5661                        // here; reach in via the public mut accessor.
5662                        table.set_partial_predicate(i, Some(rewritten));
5663                    }
5664                }
5665                // Walk catalog triggers; patch `update_columns` on
5666                // triggers attached to this table.
5667                let table_name = s.name.to_string();
5668                for trig in self.active_catalog_mut().triggers_mut() {
5669                    if !trig.table.eq_ignore_ascii_case(&table_name) {
5670                        continue;
5671                    }
5672                    for c in &mut trig.update_columns {
5673                        if c.eq_ignore_ascii_case(&old) {
5674                            *c = new.clone();
5675                        }
5676                    }
5677                }
5678            }
5679        }
5680        Ok(())
5681    }
5682
5683    fn exec_alter_index(
5684        &mut self,
5685        stmt: spg_sql::ast::AlterIndexStatement,
5686    ) -> Result<QueryResult, EngineError> {
5687        // Translate the optional SQL-side encoding choice into the
5688        // storage-side enum; the same SqlVecEncoding -> VecEncoding
5689        // bridge `column_type_to_data_type` uses.
5690        let spg_sql::ast::AlterIndexStatement {
5691            name: idx_name,
5692            target,
5693        } = stmt;
5694        // v7.16.2 — RENAME TO branch (mailrs round-10 migrate-042).
5695        // IF EXISTS makes a missing index a no-op rather than an
5696        // error, mirroring PG semantics.
5697        if let spg_sql::ast::AlterIndexTarget::Rename { new, if_exists } = target {
5698            let renamed = self.active_catalog_mut().rename_index(&idx_name, &new);
5699            return match renamed {
5700                Ok(()) => Ok(QueryResult::CommandOk {
5701                    affected: 0,
5702                    modified_catalog: !self.in_transaction(),
5703                }),
5704                Err(StorageError::IndexNotFound { .. }) if if_exists => {
5705                    Ok(QueryResult::CommandOk {
5706                        affected: 0,
5707                        modified_catalog: false,
5708                    })
5709                }
5710                Err(e) => Err(EngineError::Storage(e)),
5711            };
5712        }
5713        let spg_sql::ast::AlterIndexTarget::Rebuild { encoding } = target else {
5714            unreachable!("Rename branch returned above");
5715        };
5716        let target = encoding.map(|e| match e {
5717            SqlVecEncoding::F32 => VecEncoding::F32,
5718            SqlVecEncoding::Sq8 => VecEncoding::Sq8,
5719            SqlVecEncoding::F16 => VecEncoding::F16,
5720        });
5721        // Linear scan: index names are globally unique within a
5722        // catalog (enforced by add_nsw_index_inner) so the first
5723        // match is the only one. Save the table name to avoid
5724        // borrowing while we then take a mut borrow.
5725        let table_name = {
5726            let cat = self.active_catalog();
5727            let mut found: Option<String> = None;
5728            for tname in cat.table_names() {
5729                if let Some(t) = cat.get(&tname)
5730                    && t.indices().iter().any(|i| i.name == idx_name)
5731                {
5732                    found = Some(tname);
5733                    break;
5734                }
5735            }
5736            found.ok_or_else(|| {
5737                EngineError::Storage(StorageError::IndexNotFound {
5738                    name: idx_name.clone(),
5739                })
5740            })?
5741        };
5742        let table = self
5743            .active_catalog_mut()
5744            .get_mut(&table_name)
5745            .expect("table found above");
5746        table.rebuild_nsw_index(&idx_name, target)?;
5747        // v6.3.1 — ALTER INDEX REBUILD potentially with new encoding
5748        // changes cost characteristics; evict any cached plans.
5749        self.plan_cache.evict_referencing(&table_name);
5750        Ok(QueryResult::CommandOk {
5751            affected: 0,
5752            modified_catalog: !self.in_transaction(),
5753        })
5754    }
5755
5756    fn exec_create_index(
5757        &mut self,
5758        stmt: CreateIndexStatement,
5759    ) -> Result<QueryResult, EngineError> {
5760        let table = self
5761            .active_catalog_mut()
5762            .get_mut(&stmt.table)
5763            .ok_or_else(|| {
5764                EngineError::Storage(StorageError::TableNotFound {
5765                    name: stmt.table.clone(),
5766                })
5767            })?;
5768        // `IF NOT EXISTS` reduces DuplicateIndex to a no-op CommandOk.
5769        if stmt.if_not_exists && table.indices().iter().any(|i| i.name == stmt.name) {
5770            return Ok(QueryResult::CommandOk {
5771                affected: 0,
5772                modified_catalog: false,
5773            });
5774        }
5775        // v7.9.14 — multi-column index parses through; engine
5776        // builds a single-column BTree on the leading column only.
5777        // The extras live on the AST so spg-server's dispatcher
5778        // can emit a PG-wire NoticeResponse / log line. Composite
5779        // BTree keys land in v7.10.
5780        let _ = &stmt.extra_columns; // intentional drop on engine side
5781        let table_name = stmt.table.clone();
5782        // v6.8.0 — resolve INCLUDE column names to positions. Done
5783        // before `add_index` so a typo error surfaces before any
5784        // catalog mutation lands.
5785        let included_positions: Vec<usize> = if stmt.included_columns.is_empty() {
5786            Vec::new()
5787        } else {
5788            let schema = table.schema();
5789            stmt.included_columns
5790                .iter()
5791                .map(|c| {
5792                    schema.column_position(c).ok_or_else(|| {
5793                        EngineError::Storage(StorageError::ColumnNotFound { column: c.clone() })
5794                    })
5795                })
5796                .collect::<Result<Vec<_>, _>>()?
5797        };
5798        match stmt.method {
5799            IndexMethod::BTree => table.add_index(stmt.name.clone(), &stmt.column)?,
5800            IndexMethod::Hnsw => {
5801                if !included_positions.is_empty() {
5802                    return Err(EngineError::Unsupported(
5803                        "INCLUDE columns are not supported on HNSW indexes".into(),
5804                    ));
5805                }
5806                table.add_nsw_index(stmt.name.clone(), &stmt.column, spg_storage::NSW_DEFAULT_M)?;
5807            }
5808            // v6.7.1 — BRIN. Pure metadata; no in-memory data.
5809            IndexMethod::Brin => {
5810                if !included_positions.is_empty() {
5811                    return Err(EngineError::Unsupported(
5812                        "INCLUDE columns are not supported on BRIN indexes".into(),
5813                    ));
5814                }
5815                table.add_brin_index(stmt.name.clone(), &stmt.column)?;
5816            }
5817            // v7.12.3 — GIN inverted index. Real posting-list-backed
5818            // GIN when the indexed column is `tsvector`; falls back
5819            // to a BTree on the leading column for any other column
5820            // type so v7.9.26b's `pg_dump` compatibility (GIN on
5821            // JSONB etc. silently loading as BTree) is preserved.
5822            // Operators see the real GIN only where it matters; old
5823            // schemas keep loading.
5824            IndexMethod::Gin => {
5825                if !included_positions.is_empty() {
5826                    return Err(EngineError::Unsupported(
5827                        "INCLUDE columns are not supported on GIN indexes".into(),
5828                    ));
5829                }
5830                let col_pos = table
5831                    .schema()
5832                    .column_position(&stmt.column)
5833                    .ok_or_else(|| {
5834                        EngineError::Storage(StorageError::ColumnNotFound {
5835                            column: stmt.column.clone(),
5836                        })
5837                    })?;
5838                let col_ty = table.schema().columns[col_pos].ty;
5839                // v7.15.0 — `gin_trgm_ops` on a TEXT/VARCHAR
5840                // column dispatches to the real trigram-shingle
5841                // GIN build (LIKE / similarity acceleration).
5842                // Other GIN opclasses fall through to the regular
5843                // tsvector-vs-BTree split below.
5844                let is_trgm = stmt
5845                    .opclass
5846                    .as_deref()
5847                    .is_some_and(|op| op.eq_ignore_ascii_case("gin_trgm_ops"));
5848                if is_trgm
5849                    && matches!(
5850                        col_ty,
5851                        spg_storage::DataType::Text | spg_storage::DataType::Varchar(_)
5852                    )
5853                {
5854                    table
5855                        .add_gin_trgm_index(stmt.name.clone(), &stmt.column)
5856                        .map_err(EngineError::Storage)?;
5857                } else if col_ty == spg_storage::DataType::TsVector {
5858                    table
5859                        .add_gin_index(stmt.name.clone(), &stmt.column)
5860                        .map_err(EngineError::Storage)?;
5861                } else {
5862                    // v7.9.26b BTree fallback — the catalog still
5863                    // gets an index entry on the leading column so
5864                    // pg_dump scripts that name GIN on JSONB / etc.
5865                    // load clean; query-time gain stays opt-in for
5866                    // tsvector callers.
5867                    table.add_index(stmt.name.clone(), &stmt.column)?;
5868                }
5869            }
5870        }
5871        if !included_positions.is_empty()
5872            && let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name)
5873        {
5874            idx.included_columns = included_positions;
5875        }
5876        // v6.8.1 — persist partial-index predicate. Stored as the
5877        // expression's Display form so the catalog snapshot stays
5878        // pure (storage has no spg-sql dependency). The runtime
5879        // maintenance path treats partial indexes identically to
5880        // full indexes for v6.8.1 (over-maintenance is safe; the
5881        // planner-side "use partial when query WHERE implies the
5882        // predicate" pass is STABILITY carve-out).
5883        if let Some(pred_expr) = &stmt.partial_predicate {
5884            let canonical = pred_expr.to_string();
5885            // v7.13.2 — mailrs round-6 S2. PG's `pg_trgm` uses
5886            // `CREATE INDEX … USING gin(col gin_trgm_ops) WHERE …`
5887            // routinely to slim trigram indexes. SPG now persists
5888            // the predicate for GIN / BRIN / HNSW the same way it
5889            // already does for BTree — same v6.8.1 "over-maintain
5890            // is safe; planner-side partial routing is STABILITY
5891            // carve-out" semantics. HNSW carries an additional
5892            // caveat: the predicate isn't applied at index build
5893            // time (would require per-row eval inside the NSW
5894            // construction loop), so the index oversamples; query
5895            // time the WHERE clause still filters correctly.
5896            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
5897                idx.partial_predicate = Some(canonical);
5898            }
5899        }
5900        // v6.8.2 — persist expression index key. Same Display-form
5901        // storage; the runtime maintenance pass evaluates each
5902        // row's expression to derive the index key, but for v6.8.2
5903        // the engine falls through to the bare-column-reference
5904        // path and the expression is preserved for format-layer
5905        // round-trip + future planner work. Carved-out in
5906        // STABILITY § "Out of v6.8".
5907        if let Some(key_expr) = &stmt.expression {
5908            if matches!(
5909                stmt.method,
5910                IndexMethod::Hnsw | IndexMethod::Brin | IndexMethod::Gin
5911            ) {
5912                return Err(EngineError::Unsupported(
5913                    "Expression keys are not supported on HNSW or BRIN indexes".into(),
5914                ));
5915            }
5916            let canonical = key_expr.to_string();
5917            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
5918                idx.expression = Some(canonical);
5919            }
5920        }
5921        // v7.9.29 — persist `is_unique` flag on the storage Index.
5922        // Combined with `partial_predicate`, INSERT enforcement
5923        // checks that no other row whose predicate evaluates true
5924        // shares the same indexed key. Parser already rejected
5925        // `UNIQUE` on HNSW / BRIN, so plain BTree here.
5926        // For multi-column UNIQUE INDEX the extras matter (the
5927        // full tuple is the uniqueness key), so resolve them to
5928        // column positions and persist on the index too.
5929        if stmt.is_unique {
5930            let mut extra_positions: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
5931            for col_name in &stmt.extra_columns {
5932                let pos = table
5933                    .schema()
5934                    .columns
5935                    .iter()
5936                    .position(|c| c.name.eq_ignore_ascii_case(col_name))
5937                    .ok_or_else(|| {
5938                        EngineError::Unsupported(alloc::format!(
5939                            "UNIQUE INDEX {:?}: extra column {col_name:?} not in table {:?}",
5940                            stmt.name,
5941                            stmt.table
5942                        ))
5943                    })?;
5944                extra_positions.push(pos);
5945            }
5946            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
5947                idx.is_unique = true;
5948                idx.extra_column_positions = extra_positions;
5949            }
5950            // At index-creation time, check the existing rows for
5951            // pre-existing duplicates that would have violated the
5952            // new constraint — otherwise CREATE UNIQUE INDEX would
5953            // silently leave duplicates in place.
5954            let snapshot_indices = table.indices().to_vec();
5955            let snapshot_rows: alloc::vec::Vec<spg_storage::Row> =
5956                table.rows().iter().cloned().collect();
5957            let snapshot_schema = table.schema().clone();
5958            let idx_ref = snapshot_indices
5959                .iter()
5960                .find(|i| i.name == stmt.name)
5961                .expect("just-added index");
5962            check_existing_unique_violation(idx_ref, &snapshot_schema, &snapshot_rows)?;
5963        }
5964        // v6.3.1 — adding an index can change the optimal plan for
5965        // any cached query that references this table.
5966        self.plan_cache.evict_referencing(&table_name);
5967        Ok(QueryResult::CommandOk {
5968            affected: 0,
5969            modified_catalog: !self.in_transaction(),
5970        })
5971    }
5972
5973    /// v7.13.3 — mailrs round-7 S9. SPG-specific reconciliation
5974    /// for `CREATE TABLE IF NOT EXISTS` when the table already
5975    /// exists. Adds missing columns + inline FKs from the new
5976    /// definition; existing columns / constraints stay untouched.
5977    /// New columns with a `NOT NULL` declaration without a
5978    /// `DEFAULT` are reported as a clear error rather than
5979    /// silently dropped — this is the "fail loud on real
5980    /// incompatibility, fail silent on schema-superset" tradeoff.
5981    fn reconcile_table_if_not_exists(
5982        &mut self,
5983        stmt: CreateTableStatement,
5984    ) -> Result<QueryResult, EngineError> {
5985        let table_name = stmt.name.clone();
5986        let clock = self.clock;
5987        let existing_col_names: alloc::collections::BTreeSet<String> = self
5988            .active_catalog()
5989            .get(&table_name)
5990            .expect("checked above")
5991            .schema()
5992            .columns
5993            .iter()
5994            .map(|c| c.name.to_ascii_lowercase())
5995            .collect();
5996        let row_count = self
5997            .active_catalog()
5998            .get(&table_name)
5999            .expect("checked above")
6000            .row_count();
6001        // Collect missing column defs in source order.
6002        let new_columns: alloc::vec::Vec<spg_sql::ast::ColumnDef> = stmt
6003            .columns
6004            .iter()
6005            .filter(|c| !existing_col_names.contains(&c.name.to_ascii_lowercase()))
6006            .cloned()
6007            .collect();
6008        for col_def in new_columns {
6009            let col_name = col_def.name.clone();
6010            let nullable = col_def.nullable;
6011            let has_default = col_def.default.is_some() || col_def.auto_increment;
6012            let col_schema = column_def_to_schema(col_def)?;
6013            let fill_value: Value = if has_default || col_schema.runtime_default.is_some() {
6014                resolve_column_default_free(&col_schema, clock)?
6015            } else if nullable || row_count == 0 {
6016                Value::Null
6017            } else {
6018                return Err(EngineError::Unsupported(alloc::format!(
6019                    "CREATE TABLE IF NOT EXISTS {table_name:?}: reconciling \
6020                     column {col_name:?} requires DEFAULT (existing rows would violate NOT NULL)"
6021                )));
6022            };
6023            let table = self
6024                .active_catalog_mut()
6025                .get_mut(&table_name)
6026                .expect("checked above");
6027            table.add_column(col_schema, fill_value);
6028        }
6029        // Resolve any newly-added inline FKs (column-level
6030        // REFERENCES forms) and install. Skip FKs whose local
6031        // columns we didn't have in the existing table.
6032        let table_cols_now = self
6033            .active_catalog()
6034            .get(&table_name)
6035            .expect("checked above")
6036            .schema()
6037            .columns
6038            .clone();
6039        for fk in stmt.foreign_keys {
6040            // Only install FKs whose every local column resolves
6041            // — older catalogs may have a column the new FK
6042            // references but not the column the new FK declares.
6043            let all_resolved = fk.columns.iter().all(|c| {
6044                table_cols_now
6045                    .iter()
6046                    .any(|sc| sc.name.eq_ignore_ascii_case(c))
6047            });
6048            if !all_resolved {
6049                continue;
6050            }
6051            let already_present = {
6052                let table = self
6053                    .active_catalog()
6054                    .get(&table_name)
6055                    .expect("checked above");
6056                table.schema().foreign_keys.iter().any(|f| {
6057                    f.parent_table.eq_ignore_ascii_case(&fk.parent_table)
6058                        && f.local_columns.len() == fk.columns.len()
6059                })
6060            };
6061            if already_present {
6062                continue;
6063            }
6064            let storage_fk =
6065                resolve_foreign_key(&table_name, &table_cols_now, fk, self.active_catalog())?;
6066            let table = self
6067                .active_catalog_mut()
6068                .get_mut(&table_name)
6069                .expect("checked above");
6070            table.schema_mut().foreign_keys.push(storage_fk);
6071        }
6072        Ok(QueryResult::CommandOk {
6073            affected: 0,
6074            modified_catalog: !self.in_transaction(),
6075        })
6076    }
6077
6078    /// v7.14.0 — DROP TABLE handler (pg_dump / mysqldump preamble).
6079    fn exec_drop_table(
6080        &mut self,
6081        names: Vec<String>,
6082        if_exists: bool,
6083    ) -> Result<QueryResult, EngineError> {
6084        for name in names {
6085            let dropped = self.active_catalog_mut().drop_table(&name);
6086            if !dropped && !if_exists {
6087                return Err(EngineError::Storage(StorageError::TableNotFound { name }));
6088            }
6089        }
6090        Ok(QueryResult::CommandOk {
6091            affected: 0,
6092            modified_catalog: !self.in_transaction(),
6093        })
6094    }
6095
6096    /// v7.14.0 — DROP INDEX handler.
6097    fn exec_drop_index(
6098        &mut self,
6099        name: String,
6100        if_exists: bool,
6101    ) -> Result<QueryResult, EngineError> {
6102        let dropped = self.active_catalog_mut().drop_named_index(&name);
6103        if !dropped && !if_exists {
6104            return Err(EngineError::Storage(StorageError::IndexNotFound { name }));
6105        }
6106        Ok(QueryResult::CommandOk {
6107            affected: 0,
6108            modified_catalog: !self.in_transaction(),
6109        })
6110    }
6111
6112    fn exec_create_table(
6113        &mut self,
6114        stmt: CreateTableStatement,
6115    ) -> Result<QueryResult, EngineError> {
6116        if stmt.if_not_exists && self.active_catalog().get(&stmt.name).is_some() {
6117            // v7.16.2 — PG-strict silent no-op (mailrs round-10
6118            // surfaced this). v7.13.3's "reconcile by adding
6119            // missing columns" was friendly for mailrs round-7
6120            // where init-schema's `contacts` and migrate-023's
6121            // CardDAV `contacts` collided; but it ALSO silently
6122            // added columns to existing tables when later
6123            // migrations had a duplicate `CREATE TABLE IF NOT
6124            // EXISTS <t> (different-shape-cols)` shape. mailrs's
6125            // migrate-030 has exactly that — re-declares
6126            // system_config with `key` even though init-schema
6127            // already created it with `config_key`. PG's silent
6128            // no-op leaves system_config at `config_key`;
6129            // v7.13.3 added a phantom `key` column that then
6130            // tripped migrate-040's idempotent rename guard.
6131            // mailrs v1.7.106 ships the proper PG-style
6132            // contacts rename via DO + IF EXISTS, so SPG can
6133            // revert to PG-strict here without re-breaking the
6134            // round-7 case.
6135            return Ok(QueryResult::CommandOk {
6136                affected: 0,
6137                modified_catalog: false,
6138            });
6139        }
6140        let table_name = stmt.name.clone();
6141        // v7.9.13 — pluck the names of any columns marked
6142        // `PRIMARY KEY` inline so the post-create-table pass can
6143        // build an implicit BTree index. mailrs F1.
6144        let inline_pk_columns: Vec<String> = stmt
6145            .columns
6146            .iter()
6147            .filter(|c| c.is_primary_key)
6148            .map(|c| c.name.clone())
6149            .collect();
6150        // v7.9.19 — table-level constraints: PRIMARY KEY (a, b, ...)
6151        // and UNIQUE (a, b, ...). Each builds a BTree index on the
6152        // leading column (the existing single-column storage tier)
6153        // and registers a UniquenessConstraint on the schema for
6154        // INSERT-time enforcement of the full tuple. mailrs G1/G6.
6155        let cols = stmt
6156            .columns
6157            .into_iter()
6158            .map(column_def_to_schema)
6159            .collect::<Result<Vec<_>, _>>()?;
6160        // v7.17.0 Phase 1.4 + 1.5 — classify every raw
6161        // user_type_ref (parked as user_enum_type by
6162        // column_def_to_schema) into either an enum binding or a
6163        // domain binding. For domains, also rewrite the column's
6164        // base DataType from the placeholder Text to the domain's
6165        // declared base. Unknown idents are still a hard error
6166        // here (same as Phase 1.4) so silent acceptance never
6167        // happens.
6168        let mut cols = cols;
6169        for col in cols.iter_mut() {
6170            let Some(name) = col.user_enum_type.take() else {
6171                continue;
6172            };
6173            let cat = self.active_catalog();
6174            if cat.enum_types().contains_key(&name) {
6175                col.user_enum_type = Some(name);
6176                continue;
6177            }
6178            if let Some(dom) = cat.domain_types().get(&name) {
6179                col.ty = dom.base_type;
6180                col.user_domain_type = Some(name);
6181                if !dom.nullable {
6182                    col.nullable = false;
6183                }
6184                continue;
6185            }
6186            return Err(EngineError::Unsupported(alloc::format!(
6187                "column {:?}: unknown column type {:?} (not a built-in, ENUM, or DOMAIN)",
6188                col.name,
6189                name
6190            )));
6191        }
6192        for tc in &stmt.table_constraints {
6193            if let spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } = tc {
6194                for col_name in columns {
6195                    if let Some(col) = cols.iter_mut().find(|c| c.name == *col_name) {
6196                        col.nullable = false;
6197                    }
6198                }
6199            }
6200        }
6201        // v7.6.1 — resolve every FK in the statement against the
6202        // already-known catalog. Validates: parent table exists,
6203        // parent column names exist, arity matches, parent columns
6204        // have a PK / UNIQUE index. Self-referencing FKs (parent
6205        // table == this table) resolve against the column list we
6206        // just built — they don't need the catalog yet.
6207        let mut fks: Vec<spg_storage::ForeignKeyConstraint> =
6208            Vec::with_capacity(stmt.foreign_keys.len());
6209        for fk in stmt.foreign_keys {
6210            // v7.14.0 — when SET FOREIGN_KEY_CHECKS=0 is in effect
6211            // (mysqldump preamble + bulk imports), defer FK
6212            // resolution if the parent table isn't in the catalog
6213            // yet. The FK is queued and resolved when checks flip
6214            // back on. Self-references stay in-band (the parent is
6215            // the same as the child we're building).
6216            let needs_parent = !fk.parent_table.eq_ignore_ascii_case(&table_name);
6217            if !self.foreign_key_checks
6218                && needs_parent
6219                && self.active_catalog().get(&fk.parent_table).is_none()
6220            {
6221                self.pending_foreign_keys.push((table_name.clone(), fk));
6222                continue;
6223            }
6224            fks.push(resolve_foreign_key(
6225                &table_name,
6226                &cols,
6227                fk,
6228                self.active_catalog(),
6229            )?);
6230        }
6231        let mut schema = TableSchema::new(table_name.clone(), cols);
6232        schema.foreign_keys = fks;
6233        // v7.9.19 — translate AST table_constraints to storage
6234        // UniquenessConstraints (column name → position) so the
6235        // INSERT enforcement helper sees positions directly.
6236        let mut uc_storage: Vec<spg_storage::UniquenessConstraint> = Vec::new();
6237        let mut check_exprs: Vec<String> = Vec::new();
6238        for tc in &stmt.table_constraints {
6239            let (is_pk, names, nnd) = match tc {
6240                spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
6241                    (true, columns.clone(), false)
6242                }
6243                spg_sql::ast::TableConstraint::Unique {
6244                    columns,
6245                    nulls_not_distinct,
6246                    ..
6247                } => (false, columns.clone(), *nulls_not_distinct),
6248                spg_sql::ast::TableConstraint::Check { expr, .. } => {
6249                    // v7.13.0 — collect CHECK predicate sources;
6250                    // they get attached to the schema below.
6251                    check_exprs.push(alloc::format!("{expr}"));
6252                    continue;
6253                }
6254                // v7.15.0 — plain `KEY (cols)` from MySQL inline
6255                // is NOT a uniqueness constraint; skip the UC
6256                // build path entirely. The BTree index lands in
6257                // the post-create loop below alongside the PK/UQ
6258                // implicit indexes.
6259                spg_sql::ast::TableConstraint::Index { .. } => continue,
6260                // v7.17.0 Phase 2.2 — MySQL FULLTEXT KEY is not
6261                // a uniqueness constraint either; its GIN gets
6262                // built in the post-create loop below.
6263                spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
6264            };
6265            let mut positions = Vec::with_capacity(names.len());
6266            for n in &names {
6267                let pos = schema
6268                    .columns
6269                    .iter()
6270                    .position(|c| c.name == *n)
6271                    .ok_or_else(|| {
6272                        EngineError::Unsupported(alloc::format!(
6273                            "table constraint references unknown column {n:?}"
6274                        ))
6275                    })?;
6276                positions.push(pos);
6277            }
6278            uc_storage.push(spg_storage::UniquenessConstraint {
6279                is_primary_key: is_pk,
6280                columns: positions,
6281                nulls_not_distinct: nnd,
6282            });
6283        }
6284        schema.uniqueness_constraints = uc_storage.clone();
6285        schema.checks = check_exprs;
6286        self.active_catalog_mut().create_table(schema)?;
6287        // v7.9.13 — implicit BTree per inline PK column +
6288        // v7.9.19 — implicit BTree on the leading column of every
6289        // table-level PRIMARY KEY / UNIQUE constraint.
6290        let table = self
6291            .active_catalog_mut()
6292            .get_mut(&table_name)
6293            .expect("just created");
6294        for (i, col_name) in inline_pk_columns.iter().enumerate() {
6295            let idx_name = if inline_pk_columns.len() == 1 {
6296                alloc::format!("{table_name}_pkey")
6297            } else {
6298                alloc::format!("{table_name}_pkey_{i}")
6299            };
6300            if let Err(e) = table.add_index(idx_name, col_name) {
6301                return Err(EngineError::Storage(e));
6302            }
6303        }
6304        for (i, tc) in stmt.table_constraints.iter().enumerate() {
6305            // v7.17.0 Phase 2.2 — FULLTEXT KEY lands a real
6306            // tsvector-GIN per declared column instead of the
6307            // BTree the PK / UQ / KEY paths build. Branch early
6308            // so the BTree loop never sees the FULLTEXT shape.
6309            if let spg_sql::ast::TableConstraint::FulltextIndex { name, columns } = tc {
6310                for (k, col) in columns.iter().enumerate() {
6311                    let already = table.indices().iter().any(|idx| {
6312                        matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
6313                            && table.schema().columns[idx.column_position].name == *col
6314                    });
6315                    if already {
6316                        continue;
6317                    }
6318                    let idx_name = match (name.as_ref(), columns.len(), k) {
6319                        (Some(n), 1, _) => n.clone(),
6320                        (Some(n), _, k) => alloc::format!("{n}_{k}"),
6321                        (None, _, _) => {
6322                            alloc::format!("{table_name}_{col}_ftidx")
6323                        }
6324                    };
6325                    if let Err(e) = table.add_gin_fulltext_index(idx_name, col) {
6326                        return Err(EngineError::Storage(e));
6327                    }
6328                }
6329                continue;
6330            }
6331            // v7.15.0 — plain KEY/INDEX rides this same loop so
6332            // the implicit BTree gets built. It carries its own
6333            // user-supplied name; PK/UQ still synthesise.
6334            let (suffix, names, explicit_name): (&str, &Vec<String>, Option<&String>) = match tc {
6335                spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
6336                    ("pkey", columns, None)
6337                }
6338                spg_sql::ast::TableConstraint::Unique { columns, .. } => ("key", columns, None),
6339                spg_sql::ast::TableConstraint::Index { name, columns } => {
6340                    ("idx", columns, name.as_ref())
6341                }
6342                spg_sql::ast::TableConstraint::Check { .. } => continue,
6343                // Handled by the early-branch above.
6344                spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
6345            };
6346            let leading = &names[0];
6347            // Skip if a same-column BTree already exists (e.g.
6348            // inline PK on the leading column).
6349            let already = table.indices().iter().any(|idx| {
6350                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
6351                    && table.schema().columns[idx.column_position].name == *leading
6352            });
6353            if already {
6354                continue;
6355            }
6356            let idx_name = if let Some(n) = explicit_name {
6357                n.clone()
6358            } else if names.len() == 1 {
6359                alloc::format!("{table_name}_{leading}_{suffix}")
6360            } else {
6361                alloc::format!("{table_name}_{leading}_{suffix}_{i}")
6362            };
6363            if let Err(e) = table.add_index(idx_name, leading) {
6364                return Err(EngineError::Storage(e));
6365            }
6366        }
6367        Ok(QueryResult::CommandOk {
6368            affected: 0,
6369            modified_catalog: !self.in_transaction(),
6370        })
6371    }
6372
6373    fn exec_insert(&mut self, mut stmt: InsertStatement) -> Result<QueryResult, EngineError> {
6374        // v7.17.0 Phase 1.1 — pre-resolve any nextval / currval /
6375        // setval calls against the catalog before the row loop. We
6376        // walk each tuple expression and replace matching
6377        // FunctionCall nodes with their concrete Literal. This
6378        // keeps `literal_expr_to_value` free of `&mut self` and
6379        // lets multi-row INSERT VALUES (… nextval('seq') …)
6380        // mint a separate sequence value per row.
6381        for tuple in &mut stmt.rows {
6382            for cell in tuple.iter_mut() {
6383                self.resolve_sequence_calls_in_expr(cell)?;
6384            }
6385        }
6386        // v7.13.0 — `INSERT INTO t [(cols)] SELECT …` (mailrs
6387        // round-5 G4). Execute the inner SELECT first, then route
6388        // back through the regular VALUES code path with the
6389        // materialised rows.
6390        if let Some(select) = stmt.select_source.clone() {
6391            let select_result = self.exec_select_cancel(&select, CancelToken::none())?;
6392            let rows = match select_result {
6393                QueryResult::Rows { rows, .. } => rows,
6394                other => {
6395                    return Err(EngineError::Unsupported(alloc::format!(
6396                        "INSERT … SELECT: inner statement produced {other:?} instead of a row set"
6397                    )));
6398                }
6399            };
6400            let mut materialised: Vec<Vec<Expr>> = Vec::with_capacity(rows.len());
6401            for row in rows {
6402                let mut tuple: Vec<Expr> = Vec::with_capacity(row.values.len());
6403                for v in row.values {
6404                    tuple.push(value_to_literal_expr_permissive(v)?);
6405                }
6406                materialised.push(tuple);
6407            }
6408            let recurse = InsertStatement {
6409                table: stmt.table,
6410                columns: stmt.columns,
6411                rows: materialised,
6412                select_source: None,
6413                on_conflict: stmt.on_conflict,
6414                returning: stmt.returning,
6415            };
6416            return self.exec_insert(recurse);
6417        }
6418        // v7.9.21 — snapshot the clock fn pointer before the mut
6419        // borrow on the catalog opens; runtime DEFAULT eval needs
6420        // it inside the row hot loop.
6421        let clock = self.clock;
6422        // v7.12.4 — snapshot row-level triggers + their referenced
6423        // functions before the mut borrow on the catalog opens.
6424        // Cloned out so the row hot loop can fire them without
6425        // re-borrowing the catalog (which would conflict with
6426        // table.insert's mutable borrow).
6427        let before_insert_triggers = self.snapshot_row_triggers(&stmt.table, "INSERT", "BEFORE");
6428        let after_insert_triggers = self.snapshot_row_triggers(&stmt.table, "INSERT", "AFTER");
6429        let trigger_session_cfg: Option<alloc::string::String> = self
6430            .session_params
6431            .get("default_text_search_config")
6432            .cloned();
6433        // v7.17.0 Phase 1.4 — snapshot the enum label lookup BEFORE
6434        // opening the mutable borrow on the table below. We need
6435        // catalog-level read access (enum_types lives at the
6436        // catalog level, not the table) and the upcoming mutable
6437        // borrow shadows it.
6438        let pre_borrow_column_meta: Vec<ColumnSchema> = {
6439            let preview_table = self.active_catalog().get(&stmt.table).ok_or_else(|| {
6440                EngineError::Storage(StorageError::TableNotFound {
6441                    name: stmt.table.clone(),
6442                })
6443            })?;
6444            preview_table.schema().columns.clone()
6445        };
6446        let enum_label_lookup: alloc::collections::BTreeMap<usize, Vec<String>> =
6447            pre_borrow_column_meta
6448                .iter()
6449                .enumerate()
6450                .filter_map(|(i, col)| {
6451                    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM
6452                    // variant lists take priority over the PG
6453                    // catalog enum_types lookup (they're
6454                    // column-local and authoritative when set).
6455                    if let Some(inline) = &col.inline_enum_variants {
6456                        return Some((i, inline.clone()));
6457                    }
6458                    col.user_enum_type.as_ref().and_then(|ename| {
6459                        self.active_catalog()
6460                            .enum_types()
6461                            .get(ename)
6462                            .map(|e| (i, e.labels.clone()))
6463                    })
6464                })
6465                .collect();
6466        // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant lists.
6467        // Distinct from enum_label_lookup: SET validates that
6468        // every comma-separated token is in the variant list, and
6469        // canonicalises the cell to definition-order de-duped text.
6470        let set_variant_lookup: alloc::collections::BTreeMap<usize, Vec<String>> =
6471            pre_borrow_column_meta
6472                .iter()
6473                .enumerate()
6474                .filter_map(|(i, col)| col.inline_set_variants.as_ref().map(|vs| (i, vs.clone())))
6475                .collect();
6476        let table = self
6477            .active_catalog_mut()
6478            .get_mut(&stmt.table)
6479            .ok_or_else(|| {
6480                EngineError::Storage(StorageError::TableNotFound {
6481                    name: stmt.table.clone(),
6482                })
6483            })?;
6484        // v3.1.5: clone the columns vector only (not the whole
6485        // TableSchema — saves one String alloc for the table name).
6486        // We need an owned snapshot because we'll call `table.insert`
6487        // (mutable borrow on `table`) inside the row loop while
6488        // reading schema fields.
6489        let column_meta: Vec<ColumnSchema> = table.schema().columns.clone();
6490        let schema_cols_len = column_meta.len();
6491        // Build a permutation `tuple_pos[c] = Some(j)` meaning schema
6492        // column `c` is filled from the `j`-th tuple slot; `None` means
6493        // "fill with NULL". Validated once and reused for every row.
6494        let tuple_pos: Option<Vec<Option<usize>>> = match &stmt.columns {
6495            None => None, // 1-1 mapping, fast path
6496            Some(cols) => {
6497                let mut map = alloc::vec![None; schema_cols_len];
6498                for (j, name) in cols.iter().enumerate() {
6499                    let idx = column_meta
6500                        .iter()
6501                        .position(|c| c.name == *name)
6502                        .ok_or_else(|| {
6503                            EngineError::Eval(EvalError::ColumnNotFound { name: name.clone() })
6504                        })?;
6505                    if map[idx].is_some() {
6506                        return Err(EngineError::Storage(StorageError::ArityMismatch {
6507                            expected: schema_cols_len,
6508                            actual: cols.len(),
6509                        }));
6510                    }
6511                    map[idx] = Some(j);
6512                }
6513                // Omitted columns must either be nullable, carry a
6514                // DEFAULT, or be AUTO_INCREMENT. Catch NOT NULL
6515                // omissions up front so the WAL stays clean.
6516                for (i, col) in column_meta.iter().enumerate() {
6517                    if map[i].is_none()
6518                        && !col.nullable
6519                        && col.default.is_none()
6520                        && col.runtime_default.is_none()
6521                        && !col.auto_increment
6522                    {
6523                        return Err(EngineError::Storage(StorageError::NullInNotNull {
6524                            column: col.name.clone(),
6525                        }));
6526                    }
6527                }
6528                Some(map)
6529            }
6530        };
6531        let expected_tuple_len = stmt.columns.as_ref().map_or(schema_cols_len, Vec::len);
6532        // v7.6.2 — snapshot this table's FK list before the
6533        // mutable-borrow window so we can run parent lookups
6534        // against the immutable catalog after parsing. Empty vec is
6535        // the no-FK fast path; clone cost is O(fks * arity) which
6536        // is < 100 ns for typical schemas.
6537        let fks = table.schema().foreign_keys.clone();
6538        let mut affected = 0usize;
6539        // Stage 1 — parse + AUTO_INC + coerce all rows under the
6540        // single mutable borrow.
6541        let mut all_values: Vec<Vec<Value>> = Vec::with_capacity(stmt.rows.len());
6542        for tuple in stmt.rows {
6543            if tuple.len() != expected_tuple_len {
6544                return Err(EngineError::Storage(StorageError::ArityMismatch {
6545                    expected: expected_tuple_len,
6546                    actual: tuple.len(),
6547                }));
6548            }
6549            // Fast path: no column-list permutation → tuple slot j
6550            // maps to schema column j. We can zip schema with tuple
6551            // and skip the `raw_tuple` staging allocation entirely.
6552            let values: Vec<Value> = if let Some(map) = &tuple_pos {
6553                // Permuted path: still need raw_tuple to index by `map[i]`.
6554                let raw_tuple: Vec<Value> = tuple
6555                    .into_iter()
6556                    .map(literal_expr_to_value)
6557                    .collect::<Result<_, _>>()?;
6558                let mut out = Vec::with_capacity(schema_cols_len);
6559                for (i, col) in column_meta.iter().enumerate() {
6560                    let mut raw = match map[i] {
6561                        Some(j) => raw_tuple[j].clone(),
6562                        None => resolve_column_default_free(col, clock)?,
6563                    };
6564                    if col.auto_increment && raw.is_null() {
6565                        let next = table.next_auto_value(i).ok_or_else(|| {
6566                            EngineError::Unsupported(alloc::format!(
6567                                "AUTO_INCREMENT applies to integer columns only (column `{}`)",
6568                                col.name
6569                            ))
6570                        })?;
6571                        raw = Value::BigInt(next);
6572                    }
6573                    let coerced = coerce_value(raw, col.ty, &col.name, i)?;
6574                    enforce_enum_label(&enum_label_lookup, i, &col.name, &coerced)?;
6575                    let coerced =
6576                        canonicalize_set_value(&set_variant_lookup, i, &col.name, coerced)?;
6577                    check_unsigned_range(&coerced, col, i)?;
6578                    out.push(coerced);
6579                }
6580                out
6581            } else {
6582                // 1-1 mapping fast path: single Vec alloc, no raw_tuple.
6583                let mut out = Vec::with_capacity(schema_cols_len);
6584                for (i, (col, expr)) in column_meta.iter().zip(tuple).enumerate() {
6585                    let mut raw = literal_expr_to_value(expr)?;
6586                    if col.auto_increment && raw.is_null() {
6587                        let next = table.next_auto_value(i).ok_or_else(|| {
6588                            EngineError::Unsupported(alloc::format!(
6589                                "AUTO_INCREMENT applies to integer columns only (column `{}`)",
6590                                col.name
6591                            ))
6592                        })?;
6593                        raw = Value::BigInt(next);
6594                    }
6595                    let coerced = coerce_value(raw, col.ty, &col.name, i)?;
6596                    enforce_enum_label(&enum_label_lookup, i, &col.name, &coerced)?;
6597                    let coerced =
6598                        canonicalize_set_value(&set_variant_lookup, i, &col.name, coerced)?;
6599                    check_unsigned_range(&coerced, col, i)?;
6600                    out.push(coerced);
6601                }
6602                out
6603            };
6604            all_values.push(values);
6605        }
6606        // Stage 2 — FK enforcement on the immutable catalog.
6607        // Non-lexical lifetimes release the mutable borrow on
6608        // `table` here since stage 1 was the last use. The
6609        // parent-table lookup runs before any row is committed.
6610        let uniqueness = table.schema().uniqueness_constraints.clone();
6611        let _ = table;
6612        if !fks.is_empty() {
6613            enforce_fk_inserts(self.active_catalog(), &stmt.table, &fks, &all_values)?;
6614        }
6615        // v7.13.0 — CHECK constraint enforcement (mailrs round-5 G3).
6616        enforce_check_constraints(self.active_catalog(), &stmt.table, &all_values)?;
6617        // v7.9.19 — composite UNIQUE / PRIMARY KEY enforcement.
6618        enforce_uniqueness_inserts(self.active_catalog(), &stmt.table, &uniqueness, &all_values)?;
6619        // v7.9.29 — CREATE UNIQUE INDEX [WHERE pred] enforcement.
6620        // Independent of table-level UniquenessConstraint (which
6621        // can't carry a predicate). Walks the table's indexes;
6622        // for each `is_unique` index, only rows whose
6623        // partial_predicate evaluates truthy are checked for
6624        // collision. mailrs K1.
6625        enforce_unique_index_inserts(self.active_catalog(), &stmt.table, &all_values)?;
6626        // v7.9.8 / v7.9.9 — ON CONFLICT handling.
6627        //   - `DO NOTHING` filters `all_values` to non-conflicting
6628        //     rows + drops within-batch duplicates.
6629        //   - `DO UPDATE SET …` ALSO filters, but for each
6630        //     conflicting row it queues an UPDATE on the existing
6631        //     row using the incoming row's values as `EXCLUDED.*`.
6632        let mut pending_updates: Vec<(usize, Vec<Value>)> = Vec::new();
6633        let mut skipped_count = 0usize;
6634        if let Some(clause) = &stmt.on_conflict {
6635            let conflict_cols = resolve_on_conflict_columns(
6636                self.active_catalog(),
6637                &stmt.table,
6638                clause.target_columns.as_slice(),
6639            )?;
6640            let mut kept: Vec<Vec<Value>> = Vec::with_capacity(all_values.len());
6641            let mut seen_keys: Vec<Vec<Value>> = Vec::new();
6642            for values in all_values {
6643                let key_tuple: Vec<&Value> = conflict_cols.iter().map(|&c| &values[c]).collect();
6644                // SQL spec: NULL in any conflict column means "no
6645                // conflict possible" (NULL ≠ NULL for uniqueness).
6646                let has_null_key = key_tuple.iter().any(|v| matches!(v, Value::Null));
6647                let collides_with_table = !has_null_key
6648                    && on_conflict_keys_exist(
6649                        self.active_catalog(),
6650                        &stmt.table,
6651                        &conflict_cols,
6652                        &key_tuple,
6653                    );
6654                let key_tuple_owned: Vec<Value> = key_tuple.iter().map(|v| (*v).clone()).collect();
6655                let collides_with_batch =
6656                    !has_null_key && seen_keys.iter().any(|k| k == &key_tuple_owned);
6657                let collides = collides_with_table || collides_with_batch;
6658                match (&clause.action, collides) {
6659                    (_, false) => {
6660                        seen_keys.push(key_tuple_owned);
6661                        kept.push(values);
6662                    }
6663                    (spg_sql::ast::OnConflictAction::Nothing, true) => {
6664                        skipped_count += 1;
6665                    }
6666                    (
6667                        spg_sql::ast::OnConflictAction::Update {
6668                            assignments,
6669                            where_,
6670                        },
6671                        true,
6672                    ) => {
6673                        if !collides_with_table {
6674                            skipped_count += 1;
6675                            continue;
6676                        }
6677                        let target_pos = lookup_row_position_by_keys(
6678                            self.active_catalog(),
6679                            &stmt.table,
6680                            &conflict_cols,
6681                            &key_tuple,
6682                        )
6683                        .ok_or_else(|| {
6684                            EngineError::Unsupported(
6685                                "ON CONFLICT DO UPDATE: conflict detected but row \
6686                                 position could not be resolved (cold-tier row?)"
6687                                    .into(),
6688                            )
6689                        })?;
6690                        let updated = apply_on_conflict_assignments(
6691                            self.active_catalog(),
6692                            &stmt.table,
6693                            target_pos,
6694                            &values,
6695                            assignments,
6696                            where_.as_ref(),
6697                        )?;
6698                        if let Some(new_row) = updated {
6699                            pending_updates.push((target_pos, new_row));
6700                        } else {
6701                            skipped_count += 1;
6702                        }
6703                    }
6704                }
6705            }
6706            all_values = kept;
6707        }
6708        // Stage 3 — insert all rows under a fresh mutable borrow.
6709        let table = self
6710            .active_catalog_mut()
6711            .get_mut(&stmt.table)
6712            .ok_or_else(|| {
6713                EngineError::Storage(StorageError::TableNotFound {
6714                    name: stmt.table.clone(),
6715                })
6716            })?;
6717        // v7.9.4 — keep RETURNING projection rows separate per
6718        // INSERT and per UPDATE branch so DO UPDATE pushes the new
6719        // post-update state, not the incoming-only values.
6720        let mut returning_rows: Vec<Vec<Value>> = Vec::new();
6721        // v7.12.7 — collect embedded SQL emitted by any trigger
6722        // fire across the row loop; engine drains the queue after
6723        // the table mut borrow drops.
6724        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
6725        'rowloop: for values in all_values {
6726            let mut row = Row::new(values);
6727            // v7.12.4 — BEFORE INSERT row-level triggers. Each
6728            // trigger may rewrite NEW cells (e.g. populate
6729            // `search_vector := to_tsvector(...)`) and may return
6730            // NULL to skip the row entirely.
6731            for fd in &before_insert_triggers {
6732                let (outcome, deferred) = triggers::fire_row_trigger(
6733                    fd,
6734                    Some(row.clone()),
6735                    None,
6736                    &stmt.table,
6737                    &column_meta,
6738                    &[],
6739                    trigger_session_cfg.as_deref(),
6740                    false,
6741                )
6742                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
6743                deferred_embedded.extend(deferred);
6744                match outcome {
6745                    triggers::TriggerOutcome::Row(r) => row = r,
6746                    triggers::TriggerOutcome::Skip => continue 'rowloop,
6747                }
6748            }
6749            if stmt.returning.is_some() {
6750                returning_rows.push(row.values.clone());
6751            }
6752            // v7.12.4 — clone for the AFTER trigger view; insert
6753            // moves the row into the table.
6754            let inserted = row.clone();
6755            table.insert(row)?;
6756            affected += 1;
6757            // v7.12.4 — AFTER INSERT row-level triggers fire post-
6758            // write. Return value is ignored (PG semantics); we
6759            // surface any error from the body up to the caller.
6760            for fd in &after_insert_triggers {
6761                let (_outcome, deferred) = triggers::fire_row_trigger(
6762                    fd,
6763                    Some(inserted.clone()),
6764                    None,
6765                    &stmt.table,
6766                    &column_meta,
6767                    &[],
6768                    trigger_session_cfg.as_deref(),
6769                    true,
6770                )
6771                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
6772                deferred_embedded.extend(deferred);
6773            }
6774        }
6775        // v7.9.9 — apply ON CONFLICT DO UPDATE rewrites collected
6776        // in the conflict-resolution pass. update_row handles
6777        // index maintenance + body re-encoding.
6778        for (pos, new_row) in pending_updates {
6779            if stmt.returning.is_some() {
6780                returning_rows.push(new_row.clone());
6781            }
6782            table.update_row(pos, new_row)?;
6783            affected += 1;
6784        }
6785        let _ = skipped_count;
6786        // v7.12.7 — drop the table mut borrow and drain any
6787        // trigger-emitted embedded SQL queued during this INSERT.
6788        // The borrow has to release first because each deferred
6789        // stmt may UPDATE / INSERT / DELETE the same (or another)
6790        // table — including, in principle, this one.
6791        let _ = table;
6792        self.execute_deferred_trigger_stmts(deferred_embedded, CancelToken::none())?;
6793        // v7.9.4/v7.9.9 — RETURNING streams the rows that ended
6794        // up in the table after this statement (insert or
6795        // post-update on conflict).
6796        if let Some(items) = &stmt.returning {
6797            return self.build_returning_rows(&stmt.table, items, returning_rows);
6798        }
6799        // v6.2.1 — auto-analyze: track per-table modified-row
6800        // counter so the background sweep can decide when to
6801        // re-ANALYZE. Cheap path on the autocommit-wrap hot loop
6802        // — one BTreeMap entry update per INSERT batch.
6803        if !self.in_transaction() && affected > 0 {
6804            self.statistics
6805                .record_modifications(&stmt.table, affected as u64);
6806        }
6807        Ok(QueryResult::CommandOk {
6808            affected,
6809            modified_catalog: !self.in_transaction(),
6810        })
6811    }
6812
6813    /// v4.5: SELECT with cooperative cancellation. The token is
6814    /// honoured between UNION peers and inside the bare-SELECT row
6815    /// loop; HNSW kNN graph walks and the aggregate executor don't
6816    /// honour it yet (deferred — those paths bound their work
6817    /// internally by `LIMIT k` and `GROUP BY` cardinality).
6818    /// v6.10.2 — cold-tier time-travel scan. Resolves the segment
6819    /// by id, decodes each row body against the table's current
6820    /// schema, applies the SELECT's projection + optional WHERE +
6821    /// optional LIMIT, returns a `Rows` result. JOINs / aggregates
6822    /// / ORDER BY are unsupported on this path (STABILITY carve-
6823    /// out); operators wanting them should restore the segment
6824    /// into a regular table first.
6825    fn exec_select_as_of_segment(
6826        &self,
6827        stmt: &SelectStatement,
6828        from: &spg_sql::ast::FromClause,
6829        segment_id: u32,
6830    ) -> Result<QueryResult, EngineError> {
6831        // v6.10.2 scope: no joins, no aggregates, no ORDER BY,
6832        // no GROUP BY / HAVING / UNION / OFFSET / DISTINCT.
6833        if !from.joins.is_empty()
6834            || stmt.group_by.is_some()
6835            || stmt.having.is_some()
6836            || !stmt.unions.is_empty()
6837            || !stmt.order_by.is_empty()
6838            || stmt.offset.is_some()
6839            || stmt.distinct
6840            || aggregate::uses_aggregate(stmt)
6841        {
6842            return Err(EngineError::Unsupported(
6843                "AS OF SEGMENT supports SELECT projection + WHERE + LIMIT only \
6844                 (joins / aggregates / ORDER BY are STABILITY § \"Out of v6.10\")"
6845                    .into(),
6846            ));
6847        }
6848        let table = self
6849            .active_catalog()
6850            .get(&from.primary.name)
6851            .ok_or_else(|| StorageError::TableNotFound {
6852                name: from.primary.name.clone(),
6853            })?;
6854        let schema = table.schema().clone();
6855        let schema_cols = &schema.columns;
6856        let alias = from
6857            .primary
6858            .alias
6859            .as_deref()
6860            .unwrap_or(from.primary.name.as_str());
6861        let ctx = EvalContext::new(schema_cols, Some(alias));
6862        let seg = self
6863            .active_catalog()
6864            .cold_segment(segment_id)
6865            .ok_or_else(|| {
6866                EngineError::Unsupported(alloc::format!(
6867                    "AS OF SEGMENT: cold segment {segment_id} not registered"
6868                ))
6869            })?;
6870        let mut out_rows: Vec<Row> = Vec::new();
6871        let mut limit_remaining: Option<usize> =
6872            stmt.limit_literal().and_then(|n| usize::try_from(n).ok());
6873        for (_key, body) in seg.scan() {
6874            let (row, _consumed) =
6875                spg_storage::decode_row_body_dense(&body, &schema).map_err(EngineError::Storage)?;
6876            if let Some(where_expr) = &stmt.where_ {
6877                let cond = self.eval_expr_simple(where_expr, &row, &ctx)?;
6878                if !matches!(cond, Value::Bool(true)) {
6879                    continue;
6880                }
6881            }
6882            // Projection.
6883            let projected = self.project_row_simple(&row, &stmt.items, schema_cols, alias)?;
6884            out_rows.push(projected);
6885            if let Some(rem) = limit_remaining.as_mut() {
6886                if *rem == 0 {
6887                    out_rows.pop();
6888                    break;
6889                }
6890                *rem -= 1;
6891            }
6892        }
6893        // Output column schema: derive from SELECT items.
6894        let columns = self.derive_output_columns(&stmt.items, schema_cols, alias);
6895        Ok(QueryResult::Rows {
6896            columns,
6897            rows: out_rows,
6898        })
6899    }
6900
6901    /// v6.10.2 — simple-path WHERE eval that doesn't go through
6902    /// the correlated-subquery / Memoize machinery. AS OF SEGMENT
6903    /// scan paths predicate against a snapshot frozen segment, no
6904    /// cross-row state.
6905    fn eval_expr_simple(
6906        &self,
6907        expr: &Expr,
6908        row: &Row,
6909        ctx: &EvalContext,
6910    ) -> Result<Value, EngineError> {
6911        let cancel = CancelToken::none();
6912        self.eval_expr_with_correlated(expr, row, ctx, cancel, None)
6913    }
6914
6915    /// v7.9.4 — INSERT / UPDATE / DELETE RETURNING projector.
6916    /// Given the table name, the user-supplied projection items,
6917    /// and the mutated rows (post-insert / post-update values, or
6918    /// pre-delete snapshot), build a `QueryResult::Rows` whose
6919    /// schema describes the projected columns. Mailrs migration
6920    /// blocker #1.
6921    fn build_returning_rows(
6922        &self,
6923        table_name: &str,
6924        items: &[SelectItem],
6925        mutated_rows: Vec<Vec<Value>>,
6926    ) -> Result<QueryResult, EngineError> {
6927        let table = self.active_catalog().get(table_name).ok_or_else(|| {
6928            EngineError::Storage(StorageError::TableNotFound {
6929                name: table_name.into(),
6930            })
6931        })?;
6932        let schema_cols = table.schema().columns.clone();
6933        let columns = self.derive_output_columns(items, &schema_cols, table_name);
6934        let mut out_rows: Vec<Row> = Vec::with_capacity(mutated_rows.len());
6935        for values in mutated_rows {
6936            let row = Row::new(values);
6937            let projected = self.project_row_simple(&row, items, &schema_cols, table_name)?;
6938            out_rows.push(projected);
6939        }
6940        Ok(QueryResult::Rows {
6941            columns,
6942            rows: out_rows,
6943        })
6944    }
6945
6946    /// v6.10.2 — projection for AS OF SEGMENT. Resolves
6947    /// `SelectItem::Wildcard` to all schema columns and
6948    /// `SelectItem::Expr` via the regular eval path.
6949    fn project_row_simple(
6950        &self,
6951        row: &Row,
6952        items: &[SelectItem],
6953        schema_cols: &[ColumnSchema],
6954        alias: &str,
6955    ) -> Result<Row, EngineError> {
6956        let ctx = EvalContext::new(schema_cols, Some(alias));
6957        let cancel = CancelToken::none();
6958        let mut out_vals = Vec::new();
6959        for item in items {
6960            match item {
6961                SelectItem::Wildcard => {
6962                    out_vals.extend(row.values.iter().cloned());
6963                }
6964                SelectItem::Expr { expr, .. } => {
6965                    let v = self.eval_expr_with_correlated(expr, row, &ctx, cancel, None)?;
6966                    out_vals.push(v);
6967                }
6968            }
6969        }
6970        Ok(Row::new(out_vals))
6971    }
6972
6973    /// v6.10.2 — derive the output `ColumnSchema` list for an
6974    /// AS OF SEGMENT projection. Wildcards take the full schema;
6975    /// expressions take the alias if present or a synthetic
6976    /// `?column?` (PG convention) otherwise.
6977    fn derive_output_columns(
6978        &self,
6979        items: &[SelectItem],
6980        schema_cols: &[ColumnSchema],
6981        _alias: &str,
6982    ) -> Vec<ColumnSchema> {
6983        let mut out = Vec::new();
6984        for item in items {
6985            match item {
6986                SelectItem::Wildcard => {
6987                    out.extend(schema_cols.iter().cloned());
6988                }
6989                SelectItem::Expr { alias, .. } => {
6990                    let name = alias.clone().unwrap_or_else(|| "?column?".to_string());
6991                    // Default to Text; the caller's row values
6992                    // carry the actual type. v6.10.2 scope.
6993                    out.push(ColumnSchema::new(name, DataType::Text, true));
6994                }
6995            }
6996        }
6997        out
6998    }
6999
7000    fn exec_select_cancel(
7001        &self,
7002        stmt: &SelectStatement,
7003        cancel: CancelToken<'_>,
7004    ) -> Result<QueryResult, EngineError> {
7005        cancel.check()?;
7006        // v7.17.0 Phase 1.2 — user-defined VIEW expansion. If the
7007        // FROM / JOIN graph references any catalogued view name,
7008        // re-parse the view body and prepend it as a synthetic
7009        // CTE. Recurses on views-in-views via the regular CTE
7010        // dispatch below. Fast-path: skip the walker entirely when
7011        // the catalog has no views (the typical OLTP load).
7012        if !self.active_catalog().views().is_empty() {
7013            if let Some(rewritten) = self.expand_views_in_select(stmt)? {
7014                return self.exec_select_cancel(&rewritten, cancel);
7015            }
7016        }
7017        // v7.16.2 — information_schema / pg_catalog virtual
7018        // views (mailrs round-10 A.3). If the SELECT touches a
7019        // synthetic meta-table name (`__spg_info_*` /
7020        // `__spg_pg_*` — produced by the parser for
7021        // `information_schema.X` / `pg_catalog.X`), clone the
7022        // catalog, materialise the requested view as a real
7023        // temporary table, and re-execute against an enriched
7024        // engine. Same pattern as `exec_with_ctes` for CTEs.
7025        if !self.meta_views_materialised && select_references_meta_view(stmt) {
7026            return self.exec_select_with_meta_views(stmt, cancel);
7027        }
7028        // v6.10.2 — cold-tier time-travel short-circuit. When the
7029        // primary TableRef carries `AS OF SEGMENT '<id>'`, run a
7030        // dedicated cold-segment scan instead of the regular
7031        // hot+index path. The scope is intentionally narrow for
7032        // v6.10.2 — bare `SELECT * FROM <t> AS OF SEGMENT 'id'`,
7033        // optionally with a single-column-equality WHERE. JOINs /
7034        // aggregates / ORDER BY / subqueries on top of a time-
7035        // travelled scan are STABILITY § "Out of v6.10".
7036        if let Some(from) = &stmt.from
7037            && let Some(seg_id) = from.primary.as_of_segment
7038        {
7039            return self.exec_select_as_of_segment(stmt, from, seg_id);
7040        }
7041        // v6.2.0 / v6.5.0 — virtual-table short-circuits. Detected
7042        // pre-CTE because they don't read from the catalog and
7043        // shouldn't participate in regular FROM resolution.
7044        if let Some(from) = &stmt.from
7045            && from.joins.is_empty()
7046            && stmt.where_.is_none()
7047            && stmt.group_by.is_none()
7048            && stmt.having.is_none()
7049            && stmt.unions.is_empty()
7050            && stmt.order_by.is_empty()
7051            && stmt.limit.is_none()
7052            && stmt.offset.is_none()
7053            && !stmt.distinct
7054            && stmt.items.iter().all(|i| matches!(i, SelectItem::Wildcard))
7055        {
7056            let lower = from.primary.name.to_ascii_lowercase();
7057            match lower.as_str() {
7058                "spg_statistic" => return Ok(self.exec_spg_statistic()),
7059                // v6.5.0 — observability v2 virtual tables.
7060                "spg_stat_replication" => return Ok(self.exec_spg_stat_replication()),
7061                "spg_stat_segment" => return Ok(self.exec_spg_stat_segment()),
7062                "spg_stat_query" => return Ok(self.exec_spg_stat_query()),
7063                "spg_stat_activity" => return Ok(self.exec_spg_stat_activity()),
7064                "spg_audit_chain" => return Ok(self.exec_spg_audit_chain()),
7065                "spg_audit_verify" => return Ok(self.exec_spg_audit_verify()),
7066                "spg_table_ddl" => return Ok(self.exec_spg_table_ddl()),
7067                "spg_role_ddl" => return Ok(self.exec_spg_role_ddl()),
7068                "spg_database_ddl" => return Ok(self.exec_spg_database_ddl()),
7069                _ => {}
7070            }
7071        }
7072        // v4.11: CTEs materialise into a temporary enriched catalog
7073        // *before* anything else — the body SELECT can then refer
7074        // to CTE names via the regular FROM-clause resolution.
7075        // Uncorrelated only: each CTE body runs once against the
7076        // current catalog, not against later CTEs' results (left-
7077        // to-right materialisation would relax this, but we keep
7078        // it simple for v4.11 MVP).
7079        if !stmt.ctes.is_empty() {
7080            return self.exec_with_ctes(stmt, cancel);
7081        }
7082        // v4.10: subqueries (uncorrelated) are resolved here, before
7083        // the executor sees the row loop. We clone the statement so
7084        // we can mutate without disturbing the caller's AST — most
7085        // queries pass through with no subquery nodes and the clone
7086        // is cheap; with subqueries the materialisation cost
7087        // dominates anyway.
7088        let mut stmt_owned;
7089        let stmt_ref: &SelectStatement = if expr_tree_has_subquery(stmt) {
7090            stmt_owned = stmt.clone();
7091            self.resolve_select_subqueries(&mut stmt_owned, cancel)?;
7092            &stmt_owned
7093        } else {
7094            stmt
7095        };
7096        if stmt_ref.unions.is_empty() {
7097            return self.exec_bare_select_cancel(stmt_ref, cancel);
7098        }
7099        // UNION path: clone-strip the head into a bare block (its own
7100        // DISTINCT and any inner ORDER BY are dropped by parser rule —
7101        // the wrapper SelectStatement carries them), execute, then chain
7102        // peers with left-associative dedup semantics.
7103        let mut head = stmt_ref.clone();
7104        head.unions = Vec::new();
7105        head.order_by = Vec::new();
7106        head.limit = None;
7107        let QueryResult::Rows { columns, mut rows } =
7108            self.exec_bare_select_cancel(&head, cancel)?
7109        else {
7110            unreachable!("bare SELECT cannot return CommandOk")
7111        };
7112        for (kind, peer) in &stmt_ref.unions {
7113            let QueryResult::Rows {
7114                columns: peer_cols,
7115                rows: peer_rows,
7116            } = self.exec_bare_select_cancel(peer, cancel)?
7117            else {
7118                unreachable!("bare SELECT cannot return CommandOk")
7119            };
7120            if peer_cols.len() != columns.len() {
7121                return Err(EngineError::Unsupported(alloc::format!(
7122                    "UNION arity mismatch: head has {} columns, peer has {}",
7123                    columns.len(),
7124                    peer_cols.len()
7125                )));
7126            }
7127            rows.extend(peer_rows);
7128            if matches!(kind, UnionKind::Distinct) {
7129                rows = dedup_rows(rows);
7130            }
7131        }
7132        // ORDER BY at the top of a UNION applies to the combined result.
7133        // Eval against the projected schema (NOT the source table).
7134        if !stmt.order_by.is_empty() {
7135            let synth_ctx = EvalContext::new(&columns, None);
7136            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
7137            let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(rows.len());
7138            for r in rows {
7139                let keys = build_order_keys(&stmt.order_by, &r, &synth_ctx)?;
7140                tagged.push((keys, r));
7141            }
7142            sort_by_keys(&mut tagged, &descs);
7143            rows = tagged.into_iter().map(|(_, r)| r).collect();
7144        }
7145        apply_offset_and_limit(&mut rows, stmt.offset_literal(), stmt.limit_literal());
7146        Ok(QueryResult::Rows { columns, rows })
7147    }
7148
7149    #[allow(clippy::too_many_lines)]
7150    #[allow(clippy::too_many_lines)] // huge match — splitting fragments the planner
7151    /// v7.11.7 — execute `SELECT … FROM unnest(expr) [AS] alias …`.
7152    /// Synthesises a single-column virtual table whose column type
7153    /// is TEXT and whose rows are the array elements. Routes
7154    /// through the regular projection / WHERE / ORDER BY / LIMIT
7155    /// machinery so set-returning UNNEST composes naturally with
7156    /// the rest of the SELECT surface.
7157    fn exec_select_unnest(
7158        &self,
7159        stmt: &SelectStatement,
7160        primary: &TableRef,
7161        cancel: CancelToken<'_>,
7162    ) -> Result<QueryResult, EngineError> {
7163        let expr = primary
7164            .unnest_expr
7165            .as_deref()
7166            .expect("caller guards unnest_expr.is_some()");
7167        // Evaluate the array expression once. Empty schema / empty
7168        // row — uncorrelated UNNEST cannot reference outer columns.
7169        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7170        let ctx = EvalContext::new(&empty_schema, None);
7171        let dummy_row = Row::new(alloc::vec::Vec::new());
7172        // v7.11.13 — unnest dispatches per array element type so
7173        // INT[] / BIGINT[] surface their PG types in projection.
7174        let (elem_dtype, rows): (DataType, alloc::vec::Vec<Row>) =
7175            match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
7176                Value::Null => (DataType::Text, alloc::vec::Vec::new()),
7177                Value::TextArray(items) => {
7178                    let rows = items
7179                        .into_iter()
7180                        .map(|item| {
7181                            Row::new(alloc::vec![match item {
7182                                Some(s) => Value::Text(s),
7183                                None => Value::Null,
7184                            }])
7185                        })
7186                        .collect();
7187                    (DataType::Text, rows)
7188                }
7189                Value::IntArray(items) => {
7190                    let rows = items
7191                        .into_iter()
7192                        .map(|item| {
7193                            Row::new(alloc::vec![match item {
7194                                Some(n) => Value::Int(n),
7195                                None => Value::Null,
7196                            }])
7197                        })
7198                        .collect();
7199                    (DataType::Int, rows)
7200                }
7201                Value::BigIntArray(items) => {
7202                    let rows = items
7203                        .into_iter()
7204                        .map(|item| {
7205                            Row::new(alloc::vec![match item {
7206                                Some(n) => Value::BigInt(n),
7207                                None => Value::Null,
7208                            }])
7209                        })
7210                        .collect();
7211                    (DataType::BigInt, rows)
7212                }
7213                other => {
7214                    return Err(EngineError::Unsupported(alloc::format!(
7215                        "unnest() expects an array argument, got {:?}",
7216                        other.data_type()
7217                    )));
7218                }
7219            };
7220        let alias = primary
7221            .alias
7222            .clone()
7223            .unwrap_or_else(|| "unnest".to_string());
7224        // v7.13.2 — mailrs round-6 S5. Honour PG-standard
7225        // `UNNEST(arr) AS p(col_name)` column-list aliasing: the
7226        // first entry overrides the projected column's name.
7227        // Without the column list, fall back to the table alias
7228        // (pre-v7.13.2 behaviour).
7229        let col_name = primary
7230            .unnest_column_aliases
7231            .first()
7232            .cloned()
7233            .unwrap_or_else(|| alias.clone());
7234        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7235        let schema_cols = alloc::vec![col_schema.clone()];
7236        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7237        // Apply WHERE.
7238        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7239            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7240            for row in rows {
7241                cancel.check()?;
7242                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7243                if matches!(v, Value::Bool(true)) {
7244                    out.push(row);
7245                }
7246            }
7247            out
7248        } else {
7249            rows
7250        };
7251        // v7.17.0 Phase 3.P0-48 — aggregate dispatch over the
7252        // unnest source. Same routing the relational scan path
7253        // already takes — without it `SELECT COUNT(*) FROM
7254        // unnest(ARRAY[…])` either errored at projection time or
7255        // returned the wrong shape.
7256        if aggregate::uses_aggregate(stmt) {
7257            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7258            let mut agg = aggregate::run(stmt, &filtered_refs, &schema_cols, Some(&alias))?;
7259            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7260            return Ok(QueryResult::Rows {
7261                columns: agg.columns,
7262                rows: agg.rows,
7263            });
7264        }
7265        // Projection.
7266        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7267        let mut projected_rows: alloc::vec::Vec<Row> =
7268            alloc::vec::Vec::with_capacity(filtered.len());
7269        for row in &filtered {
7270            let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7271            for p in &projection {
7272                vals.push(eval::eval_expr(&p.expr, row, &scan_ctx).map_err(EngineError::Eval)?);
7273            }
7274            projected_rows.push(Row::new(vals));
7275        }
7276        // ORDER BY / LIMIT — apply on the projected rows (cheap;
7277        // unnest result sets are small by design).
7278        let columns: alloc::vec::Vec<ColumnSchema> = projection
7279            .iter()
7280            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7281            .collect();
7282        // Re-evaluate ORDER BY against the source schema (pre-projection
7283        // so col refs by name still resolve through `scan_ctx`).
7284        if !stmt.order_by.is_empty() {
7285            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7286                .iter()
7287                .enumerate()
7288                .map(|(i, r)| -> Result<_, EngineError> {
7289                    let keys: Result<Vec<Value>, EngineError> = stmt
7290                        .order_by
7291                        .iter()
7292                        .map(|ob| {
7293                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7294                        })
7295                        .collect();
7296                    Ok((i, keys?))
7297                })
7298                .collect::<Result<_, _>>()?;
7299            indexed.sort_by(|a, b| {
7300                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7301                    let mut cmp = value_cmp(ka, kb);
7302                    if stmt.order_by[idx].desc {
7303                        cmp = cmp.reverse();
7304                    }
7305                    if cmp != core::cmp::Ordering::Equal {
7306                        return cmp;
7307                    }
7308                }
7309                core::cmp::Ordering::Equal
7310            });
7311            projected_rows = indexed
7312                .into_iter()
7313                .map(|(i, _)| projected_rows[i].clone())
7314                .collect();
7315        }
7316        // LIMIT / OFFSET — apply at the tail.
7317        if let Some(offset) = stmt.offset_literal() {
7318            let off = (offset as usize).min(projected_rows.len());
7319            projected_rows.drain(..off);
7320        }
7321        if let Some(limit) = stmt.limit_literal() {
7322            projected_rows.truncate(limit as usize);
7323        }
7324        Ok(QueryResult::Rows {
7325            columns,
7326            rows: projected_rows,
7327        })
7328    }
7329
7330    /// v7.17.0 Phase 3.10 — `FROM generate_series(start, stop [,
7331    /// step])` set-returning source. Mirrors `exec_select_unnest`'s
7332    /// shape: evaluate the arg list once against an empty row,
7333    /// materialise the row stream by stepping start → stop, then
7334    /// route through the standard WHERE / projection / ORDER BY /
7335    /// LIMIT pipeline. Two arg-type combos in v7.17:
7336    ///   * integer / integer [/ integer] — SmallInt, Int, BigInt
7337    ///     (widened to BigInt internally; step defaults to 1)
7338    ///   * timestamp / timestamp / interval — date-range
7339    ///     iteration (mailrs's daily-report pattern)
7340    fn exec_select_generate_series(
7341        &self,
7342        stmt: &SelectStatement,
7343        primary: &TableRef,
7344        cancel: CancelToken<'_>,
7345    ) -> Result<QueryResult, EngineError> {
7346        let args = primary
7347            .generate_series_args
7348            .as_ref()
7349            .expect("caller guards generate_series_args.is_some()");
7350        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7351        let ctx = EvalContext::new(&empty_schema, None);
7352        let dummy_row = Row::new(alloc::vec::Vec::new());
7353        let mut arg_values: alloc::vec::Vec<Value> = alloc::vec::Vec::with_capacity(args.len());
7354        for a in args {
7355            arg_values.push(eval::eval_expr(a, &dummy_row, &ctx).map_err(EngineError::Eval)?);
7356        }
7357        // Dispatch on the start value's shape. Reject mixed-shape
7358        // calls early (e.g. start = timestamp, stop = integer) so
7359        // the caller gets a clean error rather than a panic.
7360        let (elem_dtype, rows) = match arg_values.as_slice() {
7361            [Value::Timestamp(start), Value::Timestamp(stop), step] => {
7362                let interval_step = match step {
7363                    Value::Interval { .. } => step.clone(),
7364                    other => {
7365                        return Err(EngineError::Unsupported(alloc::format!(
7366                            "generate_series(timestamp, timestamp, …): \
7367                             step must be INTERVAL, got {:?}",
7368                            other.data_type()
7369                        )));
7370                    }
7371                };
7372                let rows = generate_series_timestamps(*start, *stop, interval_step, &cancel)?;
7373                (DataType::Timestamp, rows)
7374            }
7375            [start, stop, step]
7376                if value_is_integer(start) && value_is_integer(stop) && value_is_integer(step) =>
7377            {
7378                let s = value_to_i64(start);
7379                let e = value_to_i64(stop);
7380                let st = value_to_i64(step);
7381                let rows = generate_series_integers(s, e, st, &cancel)?;
7382                (DataType::BigInt, rows)
7383            }
7384            [start, stop] if value_is_integer(start) && value_is_integer(stop) => {
7385                let s = value_to_i64(start);
7386                let e = value_to_i64(stop);
7387                let rows = generate_series_integers(s, e, 1, &cancel)?;
7388                (DataType::BigInt, rows)
7389            }
7390            _ => {
7391                return Err(EngineError::Unsupported(alloc::format!(
7392                    "generate_series(): v7.17 supports integer or (timestamp, timestamp, interval) \
7393                     argument shapes; got {:?}",
7394                    arg_values
7395                        .iter()
7396                        .map(|v| v.data_type())
7397                        .collect::<alloc::vec::Vec<_>>()
7398                )));
7399            }
7400        };
7401        let alias = primary
7402            .alias
7403            .clone()
7404            .unwrap_or_else(|| "generate_series".to_string());
7405        let col_name = alias.clone();
7406        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7407        let schema_cols = alloc::vec![col_schema.clone()];
7408        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7409        // WHERE.
7410        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7411            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7412            for row in rows {
7413                cancel.check()?;
7414                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7415                if matches!(v, Value::Bool(true)) {
7416                    out.push(row);
7417                }
7418            }
7419            out
7420        } else {
7421            rows
7422        };
7423        // v7.17.0 Phase 3.P0-48 — aggregate dispatch for set-
7424        // returning sources. When the SELECT projection contains
7425        // aggregate functions (COUNT/SUM/MIN/MAX/AVG/string_agg/
7426        // …) we route the filtered row stream through the same
7427        // aggregate executor the relational scan path uses, so
7428        // `SELECT COUNT(*) FROM generate_series(1, 100)` returns
7429        // a single 100 row instead of erroring at projection
7430        // time. GROUP BY / HAVING / ORDER BY over the aggregate
7431        // output all ride through `aggregate::run`.
7432        if aggregate::uses_aggregate(stmt) {
7433            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7434            let mut agg = aggregate::run(stmt, &filtered_refs, &schema_cols, Some(&alias))?;
7435            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7436            return Ok(QueryResult::Rows {
7437                columns: agg.columns,
7438                rows: agg.rows,
7439            });
7440        }
7441        // Projection.
7442        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7443        let mut projected_rows: alloc::vec::Vec<Row> =
7444            alloc::vec::Vec::with_capacity(filtered.len());
7445        for row in &filtered {
7446            let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7447            for p in &projection {
7448                vals.push(eval::eval_expr(&p.expr, row, &scan_ctx).map_err(EngineError::Eval)?);
7449            }
7450            projected_rows.push(Row::new(vals));
7451        }
7452        let columns: alloc::vec::Vec<ColumnSchema> = projection
7453            .iter()
7454            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7455            .collect();
7456        // ORDER BY against the source schema.
7457        if !stmt.order_by.is_empty() {
7458            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7459                .iter()
7460                .enumerate()
7461                .map(|(i, r)| -> Result<_, EngineError> {
7462                    let keys: Result<Vec<Value>, EngineError> = stmt
7463                        .order_by
7464                        .iter()
7465                        .map(|ob| {
7466                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7467                        })
7468                        .collect();
7469                    Ok((i, keys?))
7470                })
7471                .collect::<Result<_, _>>()?;
7472            indexed.sort_by(|a, b| {
7473                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7474                    let mut cmp = value_cmp(ka, kb);
7475                    if stmt.order_by[idx].desc {
7476                        cmp = cmp.reverse();
7477                    }
7478                    if cmp != core::cmp::Ordering::Equal {
7479                        return cmp;
7480                    }
7481                }
7482                core::cmp::Ordering::Equal
7483            });
7484            projected_rows = indexed
7485                .into_iter()
7486                .map(|(i, _)| projected_rows[i].clone())
7487                .collect();
7488        }
7489        if let Some(offset) = stmt.offset_literal() {
7490            let off = (offset as usize).min(projected_rows.len());
7491            projected_rows.drain(..off);
7492        }
7493        if let Some(limit) = stmt.limit_literal() {
7494            projected_rows.truncate(limit as usize);
7495        }
7496        Ok(QueryResult::Rows {
7497            columns,
7498            rows: projected_rows,
7499        })
7500    }
7501
7502    fn exec_bare_select_cancel(
7503        &self,
7504        stmt: &SelectStatement,
7505        cancel: CancelToken<'_>,
7506    ) -> Result<QueryResult, EngineError> {
7507        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST N ROWS WITH TIES`
7508        // is meaningless without an ORDER BY; PG raises a hard
7509        // error and SPG mirrors the surface so the same DDL/app
7510        // path behaves identically on cutover.
7511        check_with_ties_requires_order_by(stmt)?;
7512        // v7.16.2 — same meta-view dispatch as
7513        // `exec_select_cancel`, applied here too because
7514        // `subquery_replacement` enters this function directly
7515        // for Exists / ScalarSubquery / InSubquery resolution
7516        // (bypassing the top-level entry to avoid double
7517        // subquery walking). Without this dispatch the subquery
7518        // hits `__spg_info_columns` and reports TableNotFound.
7519        if !self.meta_views_materialised && select_references_meta_view(stmt) {
7520            return self.exec_select_with_meta_views(stmt, cancel);
7521        }
7522        // v4.12: window-function path. When the projection contains
7523        // any `name(args) OVER (...)` we route to the dedicated
7524        // executor — partition + sort + per-row window value before
7525        // the regular projection.
7526        if select_has_window(stmt) {
7527            return self.exec_select_with_window(stmt, cancel);
7528        }
7529        // Constant SELECT (no FROM) — evaluate each item once against an
7530        // empty dummy row. Useful for `SELECT 1`, `SELECT coalesce(...)`,
7531        // `SELECT '7'::INT`. Column references will surface as
7532        // ColumnNotFound on eval since the schema is empty.
7533        let Some(from) = &stmt.from else {
7534            let empty_schema: Vec<ColumnSchema> = Vec::new();
7535            let ctx = self.ev_ctx(&empty_schema, None);
7536            let projection = build_projection(&stmt.items, &empty_schema, "")?;
7537            let dummy_row = Row::new(Vec::new());
7538            let mut values = Vec::with_capacity(projection.len());
7539            for p in &projection {
7540                values.push(eval::eval_expr(&p.expr, &dummy_row, &ctx)?);
7541            }
7542            let columns: Vec<ColumnSchema> = projection
7543                .into_iter()
7544                .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
7545                .collect();
7546            return Ok(QueryResult::Rows {
7547                columns,
7548                rows: alloc::vec![Row::new(values)],
7549            });
7550        };
7551        // Multi-table FROM (one or more joined peers) goes through the
7552        // nested-loop join executor. Single-table FROM stays on the
7553        // existing scan + index-seek path.
7554        if !from.joins.is_empty() {
7555            return self.exec_joined_select(stmt, from);
7556        }
7557        // v7.11.7 — `FROM unnest(<expr>) [AS] <alias>`. Synthesise a
7558        // single-column table at SELECT entry by evaluating the
7559        // expression once against the empty row (UNNEST is
7560        // uncorrelated in v7.11; correlated / LATERAL unnest is a
7561        // v7.12 carve-out). Build a virtual `Table` in a heap-only
7562        // catalog, then route to the regular scan path.
7563        if from.primary.unnest_expr.is_some() {
7564            return self.exec_select_unnest(stmt, &from.primary, cancel);
7565        }
7566        // v7.17.0 Phase 3.10 — `FROM generate_series(start, stop
7567        // [, step])` set-returning source. Dispatch mirrors UNNEST:
7568        // materialise the row stream from a single eval pass, then
7569        // run the regular projection / WHERE / ORDER BY / LIMIT
7570        // pipeline over the synthetic single-column table.
7571        if from.primary.generate_series_args.is_some() {
7572            return self.exec_select_generate_series(stmt, &from.primary, cancel);
7573        }
7574        let primary = &from.primary;
7575        let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
7576            StorageError::TableNotFound {
7577                name: primary.name.clone(),
7578            }
7579        })?;
7580        let schema_cols = &table.schema().columns;
7581        // The qualifier accepted on column refs is the alias (if any) else the
7582        // bare table name.
7583        let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
7584        let ctx = self.ev_ctx(schema_cols, Some(alias));
7585
7586        // NSW kNN planner: `ORDER BY col <-> literal LIMIT k` with no
7587        // WHERE and an NSW index on `col` skips the full scan. The
7588        // walk returns rows already in ascending-distance order, so
7589        // ORDER BY / LIMIT are honoured implicitly.
7590        if let Some(nsw_rows) = try_nsw_knn(stmt, table, schema_cols, alias) {
7591            return materialise_in_order(stmt, table, schema_cols, alias, &nsw_rows);
7592        }
7593
7594        // Index seek: if WHERE is `col = literal` (or commuted) and the
7595        // referenced column has an index, dispatch each locator through
7596        // the catalog (hot tier → borrow, cold tier → page-read +
7597        // decode) and iterate just those rows. Otherwise fall back to a
7598        // full scan over the hot tier (cold-tier rows are only reached
7599        // via index seek in v5.1 — full table scans against cold-tier
7600        // data ship in v5.2 with the freezer's per-segment scan API).
7601        let indexed_rows: Option<Vec<Cow<'_, Row>>> = stmt.where_.as_ref().and_then(|w| {
7602            // BTree / col=literal seek first — covers the v7.11.3 multi-
7603            // column AND case and the leading-column equality lookup.
7604            try_index_seek(w, schema_cols, self.active_catalog(), table, alias)
7605                .or_else(|| {
7606                    // v7.12.3 — GIN-accelerated `WHERE col @@
7607                    // tsquery` when the column has a `USING gin`
7608                    // index. Returns an over-approximate candidate
7609                    // set; the WHERE re-eval loop below verifies
7610                    // the full `@@` predicate per row.
7611                    try_gin_seek(w, schema_cols, self.active_catalog(), table, alias, &ctx)
7612                })
7613                .or_else(|| {
7614                    // v7.15.0 — trigram-GIN-accelerated
7615                    // `WHERE col LIKE / ILIKE '<pat>'` when the
7616                    // column has a `gin_trgm_ops` GIN index.
7617                    // Over-approximate candidate set; the WHERE
7618                    // re-eval verifies the LIKE per row.
7619                    try_trgm_seek(w, schema_cols, table, alias)
7620                })
7621        });
7622
7623        // Aggregate path: filter rows first, then hand off to the
7624        // aggregate executor which does its own projection + ORDER BY.
7625        if aggregate::uses_aggregate(stmt) {
7626            let mut filtered: Vec<&Row> = Vec::new();
7627            // v6.2.6 — Memoize: per-query LRU cache for correlated
7628            // scalar subqueries. Fresh per row-loop entry so each
7629            // SELECT execution gets an isolated cache.
7630            let mut memo = memoize::MemoizeCache::new();
7631            if let Some(rows) = &indexed_rows {
7632                for cow in rows {
7633                    let row = cow.as_ref();
7634                    if let Some(where_expr) = &stmt.where_ {
7635                        let cond = self.eval_expr_with_correlated(
7636                            where_expr,
7637                            row,
7638                            &ctx,
7639                            cancel,
7640                            Some(&mut memo),
7641                        )?;
7642                        if !matches!(cond, Value::Bool(true)) {
7643                            continue;
7644                        }
7645                    }
7646                    filtered.push(row);
7647                }
7648            } else {
7649                for i in 0..table.row_count() {
7650                    let row = &table.rows()[i];
7651                    if let Some(where_expr) = &stmt.where_ {
7652                        let cond = self.eval_expr_with_correlated(
7653                            where_expr,
7654                            row,
7655                            &ctx,
7656                            cancel,
7657                            Some(&mut memo),
7658                        )?;
7659                        if !matches!(cond, Value::Bool(true)) {
7660                            continue;
7661                        }
7662                    }
7663                    filtered.push(row);
7664                }
7665            }
7666            let mut agg = aggregate::run(stmt, &filtered, schema_cols, Some(alias))?;
7667            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7668            return Ok(QueryResult::Rows {
7669                columns: agg.columns,
7670                rows: agg.rows,
7671            });
7672        }
7673
7674        let projection = build_projection(&stmt.items, schema_cols, alias)?;
7675
7676        // Materialise the filter pass into `(order_key, projected_row)`
7677        // tuples. The order key is `None` when there's no ORDER BY clause.
7678        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
7679        // v6.2.6 — Memoize per-row WHERE eval shares one cache.
7680        let mut memo = memoize::MemoizeCache::new();
7681        // Inline the per-row work in a closure so the indexed and full-
7682        // scan branches share the body.
7683        let mut process_row = |row: &Row, loop_idx: usize| -> Result<(), EngineError> {
7684            if loop_idx.is_multiple_of(256) {
7685                cancel.check()?;
7686            }
7687            if let Some(where_expr) = &stmt.where_ {
7688                let cond =
7689                    self.eval_expr_with_correlated(where_expr, row, &ctx, cancel, Some(&mut memo))?;
7690                if !matches!(cond, Value::Bool(true)) {
7691                    return Ok(());
7692                }
7693            }
7694            let mut values = Vec::with_capacity(projection.len());
7695            for p in &projection {
7696                values.push(eval::eval_expr(&p.expr, row, &ctx)?);
7697            }
7698            let order_keys = if stmt.order_by.is_empty() {
7699                Vec::new()
7700            } else {
7701                build_order_keys(&stmt.order_by, row, &ctx)?
7702            };
7703            tagged.push((order_keys, Row::new(values)));
7704            Ok(())
7705        };
7706        if let Some(rows) = &indexed_rows {
7707            for (loop_idx, cow) in rows.iter().enumerate() {
7708                process_row(cow.as_ref(), loop_idx)?;
7709            }
7710        } else {
7711            for i in 0..table.row_count() {
7712                process_row(&table.rows()[i], i)?;
7713            }
7714        }
7715
7716        if !stmt.order_by.is_empty() {
7717            // Partial-sort fast path: when LIMIT is small relative to
7718            // the row count, select_nth_unstable + sort just the
7719            // prefix is O(n + k log k) instead of O(n log n). DISTINCT
7720            // requires the full sort because de-dup happens after.
7721            // WITH TIES likewise needs the full sort so the tie
7722            // extension can scan past `limit` to find rows that
7723            // share the last-kept row's key.
7724            let keep = if stmt.distinct || stmt.limit_with_ties {
7725                None
7726            } else {
7727                stmt.limit_literal()
7728                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
7729            };
7730            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
7731            partial_sort_tagged(&mut tagged, keep, &descs);
7732        }
7733
7734        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST … WITH TIES` extends
7735        // past the truncated tail through every row that shares the
7736        // last-kept row's ORDER BY key. The tie check uses the
7737        // already-computed `(order_keys, row)` pairs so it matches
7738        // the sort comparator exactly. DISTINCT + WITH TIES falls
7739        // through to the no-ties path (PG also disallows their
7740        // combination; SPG silently drops the tie extension here so
7741        // the customer doesn't see a hard error mid-query — the
7742        // user-visible result is still correct, just narrower).
7743        let output_rows: Vec<Row> = if stmt.limit_with_ties && !stmt.distinct {
7744            apply_offset_and_limit_tagged(
7745                &mut tagged,
7746                stmt.offset_literal(),
7747                stmt.limit_literal(),
7748                true,
7749            );
7750            tagged.into_iter().map(|(_, r)| r).collect()
7751        } else {
7752            let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
7753            if stmt.distinct {
7754                output_rows = dedup_rows(output_rows);
7755            }
7756            apply_offset_and_limit(
7757                &mut output_rows,
7758                stmt.offset_literal(),
7759                stmt.limit_literal(),
7760            );
7761            output_rows
7762        };
7763
7764        let columns: Vec<ColumnSchema> = projection
7765            .into_iter()
7766            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
7767            .collect();
7768
7769        Ok(QueryResult::Rows {
7770            columns,
7771            rows: output_rows,
7772        })
7773    }
7774
7775    /// Multi-table SELECT executor (one or more JOIN peers).
7776    ///
7777    /// v1.10 builds the joined row set up-front via nested-loop joins,
7778    /// then runs WHERE + projection + ORDER BY against the combined
7779    /// rows. No index seek. Aggregates and DISTINCT still work because
7780    /// the executor delegates projection through the same shared paths.
7781    #[allow(clippy::too_many_lines)]
7782    /// v7.13.2 — mailrs round-6 S5. Resolve a TableRef into an
7783    /// owned (rows, schema) pair. Catalog tables clone their hot
7784    /// rows + schema; UNNEST table refs evaluate their array
7785    /// expression once and synthesise a single-column row set
7786    /// using the same dispatch as `exec_select_unnest`. Used by
7787    /// the joined-select path so UNNEST can appear in any FROM
7788    /// position, not just as the primary.
7789    fn materialise_table_ref(
7790        &self,
7791        tref: &TableRef,
7792    ) -> Result<(Vec<Row>, Vec<ColumnSchema>), EngineError> {
7793        if let Some(expr) = tref.unnest_expr.as_deref() {
7794            let empty_schema: Vec<ColumnSchema> = Vec::new();
7795            let ctx = EvalContext::new(&empty_schema, None);
7796            let dummy_row = Row::new(Vec::new());
7797            let (elem_dtype, rows) =
7798                match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
7799                    Value::Null => (DataType::Text, Vec::new()),
7800                    Value::TextArray(items) => (
7801                        DataType::Text,
7802                        items
7803                            .into_iter()
7804                            .map(|item| {
7805                                Row::new(alloc::vec![match item {
7806                                    Some(s) => Value::Text(s),
7807                                    None => Value::Null,
7808                                }])
7809                            })
7810                            .collect(),
7811                    ),
7812                    Value::IntArray(items) => (
7813                        DataType::Int,
7814                        items
7815                            .into_iter()
7816                            .map(|item| {
7817                                Row::new(alloc::vec![match item {
7818                                    Some(n) => Value::Int(n),
7819                                    None => Value::Null,
7820                                }])
7821                            })
7822                            .collect(),
7823                    ),
7824                    Value::BigIntArray(items) => (
7825                        DataType::BigInt,
7826                        items
7827                            .into_iter()
7828                            .map(|item| {
7829                                Row::new(alloc::vec![match item {
7830                                    Some(n) => Value::BigInt(n),
7831                                    None => Value::Null,
7832                                }])
7833                            })
7834                            .collect(),
7835                    ),
7836                    other => {
7837                        return Err(EngineError::Unsupported(alloc::format!(
7838                            "unnest() expects an array argument, got {:?}",
7839                            other.data_type()
7840                        )));
7841                    }
7842                };
7843            let alias = tref.alias.clone().unwrap_or_else(|| "unnest".to_string());
7844            let col_name = tref.unnest_column_aliases.first().cloned().unwrap_or(alias);
7845            return Ok((
7846                rows,
7847                alloc::vec![ColumnSchema::new(col_name, elem_dtype, true)],
7848            ));
7849        }
7850        let table =
7851            self.active_catalog()
7852                .get(&tref.name)
7853                .ok_or_else(|| StorageError::TableNotFound {
7854                    name: tref.name.clone(),
7855                })?;
7856        let rows: Vec<Row> = table.rows().iter().cloned().collect();
7857        let cols = table.schema().columns.clone();
7858        Ok((rows, cols))
7859    }
7860
7861    /// v7.17.0 Phase 3.P0-43 — materialise a `FROM` with one or more
7862    /// JOINs into `(combined_schema, filtered_rows)`. The combined
7863    /// schema uses composite `alias.col` column names so the
7864    /// qualifier-aware column resolver finds every join peer by
7865    /// exact match; the filtered rows are the join cross-product
7866    /// after the optional WHERE clause is applied.
7867    ///
7868    /// Shared by `exec_joined_select` and the JOIN branch of
7869    /// `exec_select_with_window`; both paths used to inline the
7870    /// same nested-loop logic and the window path rejected JOIN
7871    /// outright.
7872    fn build_joined_filtered_rows(
7873        &self,
7874        from: &FromClause,
7875        where_: Option<&Expr>,
7876    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
7877        let (primary_rows, primary_cols) = self.materialise_table_ref(&from.primary)?;
7878        let primary_alias = from
7879            .primary
7880            .alias
7881            .as_deref()
7882            .unwrap_or(from.primary.name.as_str())
7883            .to_string();
7884        // v7.17.0 Phase 3.P0-41 — LATERAL peers can't be
7885        // pre-materialised because their rows depend on outer
7886        // columns. For each peer, build either an eager
7887        // (rows, schema) pair or a "lateral" sentinel carrying
7888        // just the schema and the inner SELECT to re-run per
7889        // outer row.
7890        #[allow(clippy::type_complexity)]
7891        let mut joined: Vec<JoinedPeer<'_>> = Vec::new();
7892        for j in &from.joins {
7893            let a = j
7894                .table
7895                .alias
7896                .as_deref()
7897                .unwrap_or(j.table.name.as_str())
7898                .to_string();
7899            if let Some(inner_box) = &j.table.lateral_subquery {
7900                // Probe schema by running the inner SELECT against a
7901                // NULL-padded outer context. The probe gives us the
7902                // projection's column shape; rows materialise per
7903                // left-row below.
7904                let schema = self.lateral_probe_schema(inner_box)?;
7905                joined.push(JoinedPeer {
7906                    eager_rows: None,
7907                    cols: schema,
7908                    alias: a,
7909                    kind: j.kind,
7910                    on: j.on.as_ref(),
7911                    lateral: Some(inner_box.as_ref()),
7912                });
7913            } else {
7914                let (rows, cols) = self.materialise_table_ref(&j.table)?;
7915                joined.push(JoinedPeer {
7916                    eager_rows: Some(rows),
7917                    cols,
7918                    alias: a,
7919                    kind: j.kind,
7920                    on: j.on.as_ref(),
7921                    lateral: None,
7922                });
7923            }
7924        }
7925        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
7926        for col in &primary_cols {
7927            combined_schema.push(ColumnSchema::new(
7928                alloc::format!("{primary_alias}.{}", col.name),
7929                col.ty,
7930                col.nullable,
7931            ));
7932        }
7933        for peer in &joined {
7934            for col in &peer.cols {
7935                combined_schema.push(ColumnSchema::new(
7936                    alloc::format!("{}.{}", peer.alias, col.name),
7937                    col.ty,
7938                    col.nullable,
7939                ));
7940            }
7941        }
7942        let ctx = EvalContext::new(&combined_schema, None);
7943        let mut working: Vec<Row> = primary_rows;
7944        // Track the per-row width consumed by the outer left side so
7945        // each lateral evaluation sees the correct schema slice.
7946        let mut consumed_cols = primary_cols.len();
7947        for peer in &joined {
7948            let right_arity = peer.cols.len();
7949            let mut next: Vec<Row> = Vec::new();
7950            for left in &working {
7951                let mut left_matched = false;
7952                let per_left_rrows: alloc::borrow::Cow<'_, [Row]> = match peer.lateral {
7953                    Some(inner) => {
7954                        // Substitute outer columns and run the inner
7955                        // SELECT against the current left row's slice
7956                        // of the combined schema.
7957                        let outer_schema = &combined_schema[..consumed_cols];
7958                        let rows = self.materialise_lateral_for_outer(inner, outer_schema, left)?;
7959                        alloc::borrow::Cow::Owned(rows)
7960                    }
7961                    None => {
7962                        let r = peer.eager_rows.as_ref().expect("non-lateral peer eager");
7963                        alloc::borrow::Cow::Borrowed(r.as_slice())
7964                    }
7965                };
7966                for right in per_left_rrows.as_ref() {
7967                    let mut combined_vals = left.values.clone();
7968                    combined_vals.extend(right.values.iter().cloned());
7969                    let combined = Row::new(combined_vals);
7970                    let keep = if let Some(on_expr) = peer.on {
7971                        let cond = eval::eval_expr(on_expr, &combined, &ctx)?;
7972                        matches!(cond, Value::Bool(true))
7973                    } else {
7974                        true
7975                    };
7976                    if keep {
7977                        next.push(combined);
7978                        left_matched = true;
7979                    }
7980                }
7981                if !left_matched && matches!(peer.kind, JoinKind::Left) {
7982                    let mut combined_vals = left.values.clone();
7983                    for _ in 0..right_arity {
7984                        combined_vals.push(Value::Null);
7985                    }
7986                    next.push(Row::new(combined_vals));
7987                }
7988            }
7989            working = next;
7990            consumed_cols += right_arity;
7991            debug_assert!(consumed_cols <= combined_schema.len());
7992        }
7993        let mut filtered: Vec<Row> = Vec::new();
7994        for row in working {
7995            if let Some(where_expr) = where_ {
7996                let cond = eval::eval_expr(where_expr, &row, &ctx)?;
7997                if !matches!(cond, Value::Bool(true)) {
7998                    continue;
7999                }
8000            }
8001            filtered.push(row);
8002        }
8003        Ok((combined_schema, filtered))
8004    }
8005
8006    /// v7.17.0 Phase 3.P0-41 — probe a LATERAL subquery's projection
8007    /// schema by running it once with a NULL-padded outer context.
8008    /// The probe never materialises real outer rows; it just executes
8009    /// the inner SELECT with `outer_alias.col` references substituted
8010    /// to NULL so the projection's type inference is exercised.
8011    fn lateral_probe_schema(
8012        &self,
8013        inner: &SelectStatement,
8014    ) -> Result<Vec<ColumnSchema>, EngineError> {
8015        // Substitute every qualified column reference whose qualifier
8016        // does NOT match an in-subquery FROM alias with NULL. The
8017        // safest probe is to walk the inner SELECT and replace any
8018        // `<qual>.<col>` whose qual isn't bound inside the subquery
8019        // with a Null literal. For the v7.17 probe we just run the
8020        // unmodified subquery and surface the columns; if it fails
8021        // (e.g. references an outer column the probe can't resolve),
8022        // we synthesise a best-effort schema from the SELECT items
8023        // by inferring a single Text-typed column per projection.
8024        match self.execute_readonly_select_for_lateral_probe(inner) {
8025            Ok(QueryResult::Rows { columns, .. }) => Ok(columns),
8026            // Best-effort fallback: each SELECT item becomes a TEXT
8027            // column. Real schemas only differ when the inner SELECT
8028            // references outer columns at projection-time; those
8029            // queries surface via the substitution path during
8030            // per-row execution and still return the right values.
8031            _ => {
8032                let mut out: Vec<ColumnSchema> = Vec::new();
8033                for (i, item) in inner.items.iter().enumerate() {
8034                    let name = match item {
8035                        SelectItem::Expr { alias: Some(a), .. } => a.clone(),
8036                        SelectItem::Expr { expr, .. } => synth_lateral_col_name(expr, i),
8037                        SelectItem::Wildcard => alloc::format!("col{i}"),
8038                    };
8039                    out.push(ColumnSchema::new(name, DataType::Text, true));
8040                }
8041                Ok(out)
8042            }
8043        }
8044    }
8045
8046    /// v7.17.0 Phase 3.P0-41 — try the inner LATERAL subquery against
8047    /// the engine in read-only mode for schema-probe purposes. Failure
8048    /// is expected when the subquery references an outer column the
8049    /// probe can't resolve; the caller falls back to a best-effort
8050    /// schema based on the SELECT items.
8051    fn execute_readonly_select_for_lateral_probe(
8052        &self,
8053        inner: &SelectStatement,
8054    ) -> Result<QueryResult, EngineError> {
8055        self.exec_bare_select_cancel(inner, CancelToken::none())
8056    }
8057
8058    /// v7.17.0 Phase 3.P0-41 — materialise a LATERAL subquery's rows
8059    /// for one outer-row context. Walks the inner SELECT, replaces
8060    /// every `<outer_alias>.<col>` reference whose alias appears in
8061    /// the outer schema with the literal value from the outer row,
8062    /// then runs the rewritten SELECT against the engine.
8063    fn materialise_lateral_for_outer(
8064        &self,
8065        inner: &SelectStatement,
8066        outer_schema: &[ColumnSchema],
8067        outer_row: &Row,
8068    ) -> Result<Vec<Row>, EngineError> {
8069        let mut substituted = inner.clone();
8070        substitute_outer_columns_multi(&mut substituted, outer_row, outer_schema);
8071        let result = self.exec_bare_select_cancel(&substituted, CancelToken::none())?;
8072        match result {
8073            QueryResult::Rows { rows, .. } => Ok(rows),
8074            _ => Err(EngineError::Unsupported(
8075                "LATERAL subquery must be a SELECT (cannot be a write statement)".into(),
8076            )),
8077        }
8078    }
8079
8080    fn exec_joined_select(
8081        &self,
8082        stmt: &SelectStatement,
8083        from: &FromClause,
8084    ) -> Result<QueryResult, EngineError> {
8085        // v7.17.0 Phase 3.P0-43 + P0-41 — delegate the join +
8086        // WHERE materialisation to the shared helper so the LATERAL
8087        // / UNNEST / regular-catalog paths route through one place.
8088        // (`build_joined_filtered_rows` carries LATERAL support as
8089        // of Phase 3.P0-41.) Downstream we still handle aggregate /
8090        // projection / ORDER BY / DISTINCT / LIMIT inline because
8091        // those depend on the SelectStatement's items list.
8092        let (combined_schema, filtered) =
8093            self.build_joined_filtered_rows(from, stmt.where_.as_ref())?;
8094        let ctx = EvalContext::new(&combined_schema, None);
8095        // Aggregate path: handle GROUP BY / aggregate calls over the
8096        // joined+filtered rows.
8097        if aggregate::uses_aggregate(stmt) {
8098            let refs: Vec<&Row> = filtered.iter().collect();
8099            let mut agg = aggregate::run(stmt, &refs, &combined_schema, None)?;
8100            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
8101            return Ok(QueryResult::Rows {
8102                columns: agg.columns,
8103                rows: agg.rows,
8104            });
8105        }
8106
8107        let projection = build_projection(&stmt.items, &combined_schema, "")?;
8108        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
8109        for row in &filtered {
8110            let mut values = Vec::with_capacity(projection.len());
8111            for p in &projection {
8112                values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8113            }
8114            let order_keys = if stmt.order_by.is_empty() {
8115                Vec::new()
8116            } else {
8117                build_order_keys(&stmt.order_by, row, &ctx)?
8118            };
8119            tagged.push((order_keys, Row::new(values)));
8120        }
8121        if !stmt.order_by.is_empty() {
8122            let keep = if stmt.distinct {
8123                None
8124            } else {
8125                stmt.limit_literal()
8126                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
8127            };
8128            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
8129            partial_sort_tagged(&mut tagged, keep, &descs);
8130        }
8131        let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
8132        if stmt.distinct {
8133            output_rows = dedup_rows(output_rows);
8134        }
8135        apply_offset_and_limit(
8136            &mut output_rows,
8137            stmt.offset_literal(),
8138            stmt.limit_literal(),
8139        );
8140        let columns: Vec<ColumnSchema> = projection
8141            .into_iter()
8142            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8143            .collect();
8144        Ok(QueryResult::Rows {
8145            columns,
8146            rows: output_rows,
8147        })
8148    }
8149}
8150
8151/// One row-producing projection: an expression to evaluate, the resulting
8152/// column's user-visible name, its inferred type, and nullability.
8153#[derive(Debug, Clone)]
8154struct ProjectedItem {
8155    expr: Expr,
8156    output_name: String,
8157    ty: DataType,
8158    nullable: bool,
8159}
8160
8161/// Dedupe a row set, preserving first-seen order. `Row`'s `PartialEq` is
8162/// structural (`Vec<Value>` ⇒ pairwise `Value` equality), which gives SQL
8163/// `NULL = NULL → TRUE` and `NaN = NaN → FALSE`. The first agrees with
8164/// the spec's "two NULLs are not distinct"; the second is a tolerated
8165/// quirk for v1 (no NaN literals are reachable from the SQL surface).
8166fn dedup_rows(rows: Vec<Row>) -> Vec<Row> {
8167    let mut out: Vec<Row> = Vec::with_capacity(rows.len());
8168    for r in rows {
8169        if !out.iter().any(|seen| seen == &r) {
8170            out.push(r);
8171        }
8172    }
8173    out
8174}
8175
8176/// Coerce a `Value` to an `f64` sort key for ORDER BY. Numbers map directly;
8177/// NULL sorts last (treated as `+∞`); booleans are 0.0 / 1.0; text uses lex
8178/// order via the byte values; vectors are not sortable.
8179fn value_to_order_key(v: &Value) -> Result<f64, EngineError> {
8180    match v {
8181        Value::Null => Ok(f64::INFINITY),
8182        Value::SmallInt(n) => Ok(f64::from(*n)),
8183        Value::Int(n) => Ok(f64::from(*n)),
8184        Value::Date(d) => Ok(f64::from(*d)),
8185        #[allow(clippy::cast_precision_loss)]
8186        Value::Timestamp(t) => Ok(*t as f64),
8187        // v7.17.0 Phase 3.P0-32 — PG TIME ordered by underlying
8188        // i64 microseconds (matches wall-clock ordering).
8189        #[allow(clippy::cast_precision_loss)]
8190        Value::Time(us) => Ok(*us as f64),
8191        // v7.17.0 Phase 3.P0-33 — MySQL YEAR ordered by underlying
8192        // u16 (matches calendar ordering; zero-year sentinel
8193        // sorts before 1901).
8194        Value::Year(y) => Ok(f64::from(*y)),
8195        // v7.17.0 Phase 3.P0-34 — PG TIMETZ ordered by the
8196        // UTC-equivalent microseconds (local wall - offset). Two
8197        // values for the same physical instant in different zones
8198        // sort equal — matches PG TIMETZ index behaviour.
8199        #[allow(clippy::cast_precision_loss)]
8200        Value::TimeTz { us, offset_secs } => Ok((us - i64::from(*offset_secs) * 1_000_000) as f64),
8201        // v7.17.0 Phase 3.P0-35 — PG MONEY ordered by i64 cents.
8202        #[allow(clippy::cast_precision_loss)]
8203        Value::Money(c) => Ok(*c as f64),
8204        // v7.17.0 Phase 3.P0-38 — range ordering is not supported
8205        // in v7.17.0 (needs lex-then-inclusivity tiebreak).
8206        Value::Range { .. } => Err(EngineError::Unsupported(
8207            "ORDER BY of a range value is not supported in v7.17.0".into(),
8208        )),
8209        // v7.17.0 Phase 3.P0-39 — hstore is not orderable.
8210        Value::Hstore(_) => Err(EngineError::Unsupported(
8211            "ORDER BY of a hstore value is not supported".into(),
8212        )),
8213        // v7.17.0 Phase 3.P0-40 — 2D arrays not orderable.
8214        Value::IntArray2D(_) | Value::BigIntArray2D(_) | Value::TextArray2D(_) => Err(
8215            EngineError::Unsupported("ORDER BY of a 2D array is not supported in v7.17.0".into()),
8216        ),
8217        #[allow(clippy::cast_precision_loss)]
8218        Value::Numeric { scaled, scale } => {
8219            // Scaled integer / 10^scale, computed via f64 for sort
8220            // ordering only. Precision losses here only matter for
8221            // ORDER BY tie-breaks well past 15 significant digits.
8222            // `f64::powi` lives in std; we hand-roll the loop so the
8223            // no_std engine crate doesn't need it.
8224            let mut divisor = 1.0_f64;
8225            for _ in 0..*scale {
8226                divisor *= 10.0;
8227            }
8228            Ok((*scaled as f64) / divisor)
8229        }
8230        #[allow(clippy::cast_precision_loss)]
8231        Value::BigInt(n) => Ok(*n as f64),
8232        Value::Float(x) => Ok(*x),
8233        Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
8234        Value::Text(s) => {
8235            // Lex order by codepoints — good enough for ORDER BY name.
8236            // Map first 8 bytes packed into u64 as a coarse key; ties fall to
8237            // partial_cmp Equal. v1.x can swap in a real string comparator.
8238            let mut key: u64 = 0;
8239            for &b in s.as_bytes().iter().take(8) {
8240                key = (key << 8) | u64::from(b);
8241            }
8242            #[allow(clippy::cast_precision_loss)]
8243            Ok(key as f64)
8244        }
8245        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
8246            Err(EngineError::Unsupported(
8247                "ORDER BY of a raw vector column is not meaningful — use `<->`".into(),
8248            ))
8249        }
8250        Value::Interval { .. } => Err(EngineError::Unsupported(
8251            "ORDER BY of an INTERVAL is not supported in v2.11 \
8252             (months vs micros has no single canonical ordering)"
8253                .into(),
8254        )),
8255        Value::Json(_) => Err(EngineError::Unsupported(
8256            "ORDER BY of a JSON value is not supported — cast the document to text first".into(),
8257        )),
8258        // v7.5.0 — Value is #[non_exhaustive]; future variants need
8259        // an explicit ORDER BY mapping. Surface as Unsupported until
8260        // engine support is added.
8261        _ => Err(EngineError::Unsupported(
8262            "ORDER BY of this value type is not supported".into(),
8263        )),
8264    }
8265}
8266
8267/// Try to plan a WHERE clause as an equality lookup against an existing
8268/// index. Returns the candidate row indices on success; `None` means the
8269/// caller should fall back to a full scan.
8270///
8271/// v0.8 recognises a single top-level `col = literal` (in either operand
8272/// order). AND chains and range scans land in later milestones.
8273/// Look for `ORDER BY col <dist-op> literal LIMIT k` against an
8274/// NSW-indexed vector column. Recognised distance ops: `<->` (L2),
8275/// `<#>` (inner product), `<=>` (cosine). When a WHERE clause is
8276/// present, the planner does an "over-fetch and filter" pass — it
8277/// asks the graph for `k * over_fetch` candidates, evaluates WHERE
8278/// against each, and trims back to `k`. Returns the row indices in
8279/// ascending-distance order when the plan applies.
8280fn try_nsw_knn(
8281    stmt: &SelectStatement,
8282    table: &Table,
8283    schema_cols: &[ColumnSchema],
8284    table_alias: &str,
8285) -> Option<Vec<usize>> {
8286    if stmt.distinct {
8287        return None;
8288    }
8289    let limit = usize::try_from(stmt.limit_literal()?).ok()?;
8290    if limit == 0 {
8291        return None;
8292    }
8293    // v6.4.0 — NSW kNN dispatch needs a single ORDER BY key on the
8294    // distance metric. Multi-key ORDER BY falls through to the
8295    // generic sort path.
8296    if stmt.order_by.len() != 1 {
8297        return None;
8298    }
8299    let order = &stmt.order_by[0];
8300    // NSW kNN returns rows ascending by distance — DESC inverts the
8301    // natural order, so the planner can't handle it without a sort
8302    // pass. Fall back to the generic ORDER BY path.
8303    if order.desc {
8304        return None;
8305    }
8306    let Expr::Binary { lhs, op, rhs } = &order.expr else {
8307        return None;
8308    };
8309    let metric = match op {
8310        BinOp::L2Distance => spg_storage::NswMetric::L2,
8311        BinOp::InnerProduct => spg_storage::NswMetric::InnerProduct,
8312        BinOp::CosineDistance => spg_storage::NswMetric::Cosine,
8313        _ => return None,
8314    };
8315    // Accept both `col <op> literal` and `literal <op> col`.
8316    let ((Expr::Column(col), literal) | (literal, Expr::Column(col))) =
8317        (lhs.as_ref(), rhs.as_ref())
8318    else {
8319        return None;
8320    };
8321    if let Some(q) = &col.qualifier
8322        && q != table_alias
8323    {
8324        return None;
8325    }
8326    let col_pos = schema_cols.iter().position(|s| s.name == col.name)?;
8327    let query = literal_to_vector(literal)?;
8328    let idx = spg_storage::nsw_index_on(table, col_pos)?;
8329    if let Some(where_expr) = &stmt.where_ {
8330        // Over-fetch and filter. The factor (10×) is a heuristic that
8331        // covers typical selectivity for the corpus tests; v2.x will
8332        // make it configurable.
8333        let over_fetch = limit.saturating_mul(10).max(NSW_OVER_FETCH_FLOOR);
8334        let candidates = spg_storage::nsw_query(table, &idx.name, &query, over_fetch, metric);
8335        let ctx = EvalContext::new(schema_cols, Some(table_alias));
8336        let mut kept: Vec<usize> = Vec::with_capacity(limit);
8337        for i in candidates {
8338            let row = &table.rows()[i];
8339            let cond = eval::eval_expr(where_expr, row, &ctx).ok()?;
8340            if matches!(cond, Value::Bool(true)) {
8341                kept.push(i);
8342                if kept.len() >= limit {
8343                    break;
8344                }
8345            }
8346        }
8347        Some(kept)
8348    } else {
8349        Some(spg_storage::nsw_query(
8350            table, &idx.name, &query, limit, metric,
8351        ))
8352    }
8353}
8354
8355/// Lower bound on the over-fetch pool when WHERE is present — even
8356/// for tiny `LIMIT 1` queries we keep enough candidates to absorb a
8357/// few WHERE rejections.
8358const NSW_OVER_FETCH_FLOOR: usize = 32;
8359
8360/// Pull a `Vec<f32>` out of a literal-or-cast expression. Returns
8361/// `None` for anything we can't fold at plan time.
8362fn literal_to_vector(e: &Expr) -> Option<Vec<f32>> {
8363    match e {
8364        Expr::Literal(Literal::Vector(v)) => Some(v.clone()),
8365        Expr::Cast { expr, .. } => literal_to_vector(expr),
8366        _ => None,
8367    }
8368}
8369
8370/// Materialise rows in a planner-supplied order (used by the NSW path)
8371/// without re-running ORDER BY. The projection + LIMIT slot mirror the
8372/// equivalent block in `exec_bare_select`.
8373fn materialise_in_order(
8374    stmt: &SelectStatement,
8375    table: &Table,
8376    schema_cols: &[ColumnSchema],
8377    table_alias: &str,
8378    ordered_rows: &[usize],
8379) -> Result<QueryResult, EngineError> {
8380    let ctx = EvalContext::new(schema_cols, Some(table_alias));
8381    let projection = build_projection(&stmt.items, schema_cols, table_alias)?;
8382    let mut output_rows: Vec<Row> = Vec::with_capacity(ordered_rows.len());
8383    for &i in ordered_rows {
8384        let row = &table.rows()[i];
8385        let mut values = Vec::with_capacity(projection.len());
8386        for p in &projection {
8387            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8388        }
8389        output_rows.push(Row::new(values));
8390    }
8391    apply_offset_and_limit(
8392        &mut output_rows,
8393        stmt.offset_literal(),
8394        stmt.limit_literal(),
8395    );
8396    let columns: Vec<ColumnSchema> = projection
8397        .into_iter()
8398        .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8399        .collect();
8400    Ok(QueryResult::Rows {
8401        columns,
8402        rows: output_rows,
8403    })
8404}
8405
8406fn try_index_seek<'a>(
8407    where_expr: &Expr,
8408    schema_cols: &[ColumnSchema],
8409    catalog: &'a Catalog,
8410    table: &'a Table,
8411    table_alias: &str,
8412) -> Option<Vec<Cow<'a, Row>>> {
8413    // v7.11.3 — recurse through top-level `AND` so a PG-style
8414    // composite predicate like `WHERE id = 1 AND created_at > $1`
8415    // still hits the index on `id`. The caller re-applies the
8416    // full WHERE expression to each returned row, so dropping the
8417    // residual conjuncts here is correct — the index just narrows
8418    // the candidate set.
8419    if let Expr::Binary {
8420        lhs,
8421        op: BinOp::And,
8422        rhs,
8423    } = where_expr
8424    {
8425        // Try LHS first (typical convention: leading equality on
8426        // the indexed column comes first in user-written SQL).
8427        if let Some(rows) = try_index_seek(lhs, schema_cols, catalog, table, table_alias) {
8428            return Some(rows);
8429        }
8430        return try_index_seek(rhs, schema_cols, catalog, table, table_alias);
8431    }
8432    let Expr::Binary {
8433        lhs,
8434        op: BinOp::Eq,
8435        rhs,
8436    } = where_expr
8437    else {
8438        return None;
8439    };
8440    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
8441        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
8442    let idx = table.index_on(col_pos)?;
8443    let key = IndexKey::from_value(&value)?;
8444    let locators = idx.lookup_eq(&key);
8445    let table_name = table.schema().name.as_str();
8446    // v5.1: each locator dispatches to either the hot tier (zero-
8447    // copy borrow of `table.rows()[i]`) or a cold-tier segment
8448    // (one page read + dense row decode, ~µs scale). Cold rows are
8449    // returned as `Cow::Owned` so the caller's `&Row` iteration
8450    // doesn't see a tier distinction; pre-freezer (no cold
8451    // segments loaded) every locator is `Hot` and every entry is
8452    // `Cow::Borrowed` — identical cost to the pre-v5.1 path.
8453    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(locators.len());
8454    for loc in locators {
8455        match *loc {
8456            spg_storage::RowLocator::Hot(i) => {
8457                if let Some(row) = table.rows().get(i) {
8458                    out.push(Cow::Borrowed(row));
8459                }
8460            }
8461            spg_storage::RowLocator::Cold { segment_id, .. } => {
8462                if let Some(row) = catalog.resolve_cold_locator(table_name, segment_id, &key) {
8463                    out.push(Cow::Owned(row));
8464                }
8465            }
8466        }
8467    }
8468    Some(out)
8469}
8470
8471/// v7.12.3 — GIN-accelerated candidate seek for `WHERE col @@ <ts_query>`.
8472///
8473/// Recurses through top-level `AND` like [`try_index_seek`] so a
8474/// composite predicate `WHERE search_vector @@ q AND id > $1` still
8475/// hits the GIN index on `search_vector` — the caller re-applies the
8476/// full WHERE expression to each returned candidate, so dropping the
8477/// `id > $1` residual here stays semantically correct.
8478///
8479/// Returns `None` when:
8480///   - no leaf is a `col @@ <rhs>` shape on a GIN-indexed column;
8481///   - the RHS can't be const-evaluated to a `Value::TsQuery`
8482///     (typically because it references row columns);
8483///   - the resolved `TsQuery` uses query shapes the MVP doesn't
8484///     accelerate (`Not`, `Phrase` — those fall through to full scan).
8485///
8486/// On `Some(rows)` the caller iterates only `rows` and re-evaluates
8487/// the full `@@` predicate per row, so an over-approximate candidate
8488/// set is safe.
8489fn try_gin_seek<'a>(
8490    where_expr: &Expr,
8491    schema_cols: &[ColumnSchema],
8492    catalog: &'a Catalog,
8493    table: &'a Table,
8494    table_alias: &str,
8495    ctx: &eval::EvalContext<'_>,
8496) -> Option<Vec<Cow<'a, Row>>> {
8497    if let Expr::Binary {
8498        lhs,
8499        op: BinOp::And,
8500        rhs,
8501    } = where_expr
8502    {
8503        if let Some(rows) = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx) {
8504            return Some(rows);
8505        }
8506        return try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx);
8507    }
8508    // v7.17.0 Phase 3.P0-44 — MySQL `MATCH(col1, col2) AGAINST (...)`
8509    // desugars into `(to_tsvector(col1) @@ q) OR (to_tsvector(col2) @@ q)`
8510    // in the parser. To accelerate the multi-column case, walk OR the same
8511    // way we walk AND: only emit a candidate set if BOTH sides can seek
8512    // (otherwise the OR result is unbounded and we must fall through to
8513    // the full scan). Candidates are union'd; the caller's WHERE re-eval
8514    // verifies the full predicate per row, so duplicates / supersets stay
8515    // semantically safe.
8516    if let Expr::Binary {
8517        lhs,
8518        op: BinOp::Or,
8519        rhs,
8520    } = where_expr
8521    {
8522        let left = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx)?;
8523        let right = try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx)?;
8524        let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(left.len() + right.len());
8525        out.extend(left);
8526        out.extend(right);
8527        return Some(out);
8528    }
8529    let Expr::Binary {
8530        lhs,
8531        op: BinOp::TsMatch,
8532        rhs,
8533    } = where_expr
8534    else {
8535        return None;
8536    };
8537    // Either side can be the column; pgvector idiom (`vec @@ q`)
8538    // hits the first arm, FROM-clause-derived (`plainto_tsquery($1)
8539    // q ... WHERE search_vector @@ q`) the same. CROSS JOIN derived
8540    // tables resolve `q` to a Column too.
8541    let (col_pos, query) = resolve_gin_col_query(lhs, rhs, schema_cols, table_alias, ctx)
8542        .or_else(|| resolve_gin_col_query(rhs, lhs, schema_cols, table_alias, ctx))?;
8543    // v7.17.0 Phase 3.P0-44 — MySQL `FULLTEXT KEY` builds a
8544    // `IndexKind::GinFulltext` posting list (Phase 2.2). It shares
8545    // the same `gin_lookup_word` shape as the tsvector-typed GIN,
8546    // so the MATCH-AGAINST `@@` predicate (desugared by the parser
8547    // into `to_tsvector(col) @@ plainto_tsquery('term')`) routes
8548    // through the same candidate-set seek.
8549    let idx = table
8550        .indices()
8551        .iter()
8552        .find(|i| i.column_position == col_pos && (i.is_gin() || i.is_gin_fulltext()))?;
8553    let candidates = gin_query_candidates(idx, &query)?;
8554    let _ = catalog; // cold-tier row resolution unused in MVP; see below.
8555    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(candidates.len());
8556    for loc in candidates {
8557        match loc {
8558            spg_storage::RowLocator::Hot(i) => {
8559                if let Some(row) = table.rows().get(i) {
8560                    out.push(Cow::Borrowed(row));
8561                }
8562            }
8563            // GIN cold-tier rows in the MVP: skipped, matching the
8564            // full-scan `@@` path which itself only iterates
8565            // `table.rows()` (hot tier). When v7.13+ adds cold-tier
8566            // scan-time materialisation for `@@`, the parallel
8567            // resolution lands here; until then both paths see the
8568            // same hot-only candidate set so correctness is preserved.
8569            spg_storage::RowLocator::Cold { .. } => {}
8570        }
8571    }
8572    Some(out)
8573}
8574
8575/// v7.15.0 — trigram-GIN-accelerated candidate seek for
8576/// `WHERE col LIKE '<pat>'` and `WHERE col ILIKE '<pat>'` when
8577/// the column has a `gin_trgm_ops` GIN index.
8578///
8579/// Walks top-level `AND` so multi-predicate WHEREs (`col LIKE
8580/// 'foo%' AND id > 1`) still hit the trigram index; the caller
8581/// re-evaluates the full WHERE per candidate row, so dropping
8582/// non-LIKE conjuncts here stays semantically correct.
8583///
8584/// Returns `None` when:
8585///   - no leaf is `col LIKE/ILIKE <literal>` on a trigram-GIN-
8586///     indexed column;
8587///   - the pattern's literal runs are too short to constrain
8588///     (pattern decomposes into `< 3`-char runs, e.g. `%ab%`);
8589///   - the pattern doesn't const-evaluate to a TEXT.
8590fn try_trgm_seek<'a>(
8591    where_expr: &Expr,
8592    schema_cols: &[ColumnSchema],
8593    table: &'a Table,
8594    table_alias: &str,
8595) -> Option<Vec<Cow<'a, Row>>> {
8596    if let Expr::Binary {
8597        lhs,
8598        op: BinOp::And,
8599        rhs,
8600    } = where_expr
8601    {
8602        if let Some(rows) = try_trgm_seek(lhs, schema_cols, table, table_alias) {
8603            return Some(rows);
8604        }
8605        return try_trgm_seek(rhs, schema_cols, table, table_alias);
8606    }
8607    // LIKE node is what carries the column reference + pattern.
8608    // ILIKE is the same AST node — PG's LIKE/ILIKE both lower
8609    // through `Expr::Like { expr, pattern, negated }`. The trigram
8610    // index posting-list keys are already lower-cased and
8611    // case-folded, so we only need the pattern's literal runs.
8612    let Expr::Like { expr, pattern, .. } = where_expr else {
8613        return None;
8614    };
8615    // Column side.
8616    let Expr::Column(c) = expr.as_ref() else {
8617        return None;
8618    };
8619    if let Some(q) = &c.qualifier
8620        && q != table_alias
8621    {
8622        return None;
8623    }
8624    let col_pos = schema_cols
8625        .iter()
8626        .position(|s| s.name.eq_ignore_ascii_case(&c.name))?;
8627    // Index must exist on that column AND be a trigram-GIN.
8628    let idx = table
8629        .indices()
8630        .iter()
8631        .find(|i| i.column_position == col_pos && i.is_gin_trgm())?;
8632    // Pattern side must be a literal TEXT — anything else (column
8633    // ref, function call, parameter that hasn't been bound yet)
8634    // falls through to full scan.
8635    let Expr::Literal(spg_sql::ast::Literal::String(pat)) = pattern.as_ref() else {
8636        return None;
8637    };
8638    let trigrams = spg_storage::trgm::trigrams_from_like_pattern(pat)?;
8639    // Intersect every trigram's posting list. Empty intersection
8640    // → empty candidate set (caller short-circuits its row loop).
8641    let mut iter = trigrams.iter();
8642    let first = iter.next()?;
8643    let mut acc: Vec<spg_storage::RowLocator> = {
8644        let mut v = idx.gin_trgm_lookup(first).to_vec();
8645        v.sort_by_key(locator_sort_key);
8646        v.dedup_by_key(|l| locator_sort_key(l));
8647        v
8648    };
8649    for tri in iter {
8650        let mut next: Vec<spg_storage::RowLocator> = idx.gin_trgm_lookup(tri).to_vec();
8651        next.sort_by_key(locator_sort_key);
8652        next.dedup_by_key(|l| locator_sort_key(l));
8653        // Sorted-merge intersection.
8654        let mut merged: Vec<spg_storage::RowLocator> =
8655            Vec::with_capacity(acc.len().min(next.len()));
8656        let (mut i, mut j) = (0usize, 0usize);
8657        while i < acc.len() && j < next.len() {
8658            let lk = locator_sort_key(&acc[i]);
8659            let rk = locator_sort_key(&next[j]);
8660            match lk.cmp(&rk) {
8661                core::cmp::Ordering::Less => i += 1,
8662                core::cmp::Ordering::Greater => j += 1,
8663                core::cmp::Ordering::Equal => {
8664                    merged.push(acc[i]);
8665                    i += 1;
8666                    j += 1;
8667                }
8668            }
8669        }
8670        acc = merged;
8671        if acc.is_empty() {
8672            break;
8673        }
8674    }
8675    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(acc.len());
8676    for loc in acc {
8677        if let spg_storage::RowLocator::Hot(i) = loc
8678            && let Some(row) = table.rows().get(i)
8679        {
8680            out.push(Cow::Borrowed(row));
8681        }
8682        // Cold-tier rows: skipped in MVP (same as try_gin_seek).
8683    }
8684    Some(out)
8685}
8686
8687/// v7.12.3 — extract `(column_position, TsQueryAst)` when one side of
8688/// the binary is a column reference to a GIN-indexed tsvector column
8689/// and the other side const-evaluates to a `Value::TsQuery`. Returns
8690/// `None` if the column reference is for the wrong table alias, or if
8691/// the RHS expression depends on row data.
8692fn resolve_gin_col_query(
8693    col_side: &Expr,
8694    query_side: &Expr,
8695    schema_cols: &[ColumnSchema],
8696    table_alias: &str,
8697    ctx: &eval::EvalContext<'_>,
8698) -> Option<(usize, spg_storage::TsQueryAst)> {
8699    // v7.17.0 Phase 3.P0-44 — the MATCH AGAINST desugar wraps the
8700    // column in `to_tsvector('simple', col)`, so we peel that wrapper
8701    // before the column lookup. Direct `col @@ tsquery` paths (the
8702    // tsvector-typed v7.12 surface) skip the wrapper entirely.
8703    let column = match col_side {
8704        Expr::Column(c) => c,
8705        Expr::FunctionCall { name, args }
8706            if name.eq_ignore_ascii_case("to_tsvector") && !args.is_empty() =>
8707        {
8708            // PG `to_tsvector` accepts either `to_tsvector(col)` or
8709            // `to_tsvector(config, col)`. In both shapes the column
8710            // we care about is the final argument.
8711            if let Expr::Column(c) = args.last().unwrap() {
8712                c
8713            } else {
8714                return None;
8715            }
8716        }
8717        _ => return None,
8718    };
8719    let c = column;
8720    if let Some(q) = &c.qualifier
8721        && q != table_alias
8722    {
8723        return None;
8724    }
8725    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
8726    // Const-evaluate the query side with an empty row — fails fast
8727    // (with a `ColumnNotFound` / similar) if the expression actually
8728    // depends on row data, which is exactly the bail signal we want.
8729    let empty_row = Row::new(Vec::new());
8730    let v = eval::eval_expr(query_side, &empty_row, ctx).ok()?;
8731    let Value::TsQuery(q) = v else { return None };
8732    Some((pos, q))
8733}
8734
8735/// v7.12.3 — walk a `TsQueryAst` against an [`IndexKind::Gin`] index
8736/// to produce a candidate row-locator set. Returns `None` for query
8737/// shapes the MVP doesn't accelerate (`Not` / `Phrase` — both bail to
8738/// full scan since their semantics need either complementation across
8739/// the whole row set or positional verification beyond what the
8740/// posting list carries).
8741///
8742/// Candidate sets are over-approximate — the caller re-applies the
8743/// full `@@` predicate per row, so reporting "row was in some
8744/// posting list" without verifying positions / weights stays correct.
8745fn gin_query_candidates(
8746    idx: &spg_storage::Index,
8747    query: &spg_storage::TsQueryAst,
8748) -> Option<Vec<spg_storage::RowLocator>> {
8749    use spg_storage::TsQueryAst;
8750    match query {
8751        TsQueryAst::Term { word, .. } => {
8752            let mut v: Vec<spg_storage::RowLocator> = idx.gin_lookup_word(word).to_vec();
8753            v.sort_by_key(locator_sort_key);
8754            v.dedup_by_key(|l| locator_sort_key(l));
8755            Some(v)
8756        }
8757        TsQueryAst::And(l, r) => {
8758            let mut left = gin_query_candidates(idx, l)?;
8759            let mut right = gin_query_candidates(idx, r)?;
8760            left.sort_by_key(locator_sort_key);
8761            right.sort_by_key(locator_sort_key);
8762            // Sorted-merge intersection.
8763            let mut out: Vec<spg_storage::RowLocator> = Vec::new();
8764            let (mut i, mut j) = (0usize, 0usize);
8765            while i < left.len() && j < right.len() {
8766                let lk = locator_sort_key(&left[i]);
8767                let rk = locator_sort_key(&right[j]);
8768                match lk.cmp(&rk) {
8769                    core::cmp::Ordering::Less => i += 1,
8770                    core::cmp::Ordering::Greater => j += 1,
8771                    core::cmp::Ordering::Equal => {
8772                        out.push(left[i]);
8773                        i += 1;
8774                        j += 1;
8775                    }
8776                }
8777            }
8778            Some(out)
8779        }
8780        TsQueryAst::Or(l, r) => {
8781            let mut out = gin_query_candidates(idx, l)?;
8782            out.extend(gin_query_candidates(idx, r)?);
8783            out.sort_by_key(locator_sort_key);
8784            out.dedup_by_key(|l| locator_sort_key(l));
8785            Some(out)
8786        }
8787        // Not / Phrase bail to full scan in the MVP. Not needs
8788        // complementation against the whole row set (not represented
8789        // in the posting-list view); Phrase needs positional
8790        // verification beyond what `word → rows` carries.
8791        TsQueryAst::Not(_) | TsQueryAst::Phrase { .. } => None,
8792    }
8793}
8794
8795/// v7.12.3 — total ordering on `RowLocator` for sort/dedup purposes
8796/// inside the GIN intersection / union loops. Hot rows order by their
8797/// row index; Cold rows order after all Hot rows, then by
8798/// `(segment_id, the cold sub-key)`.
8799fn locator_sort_key(l: &spg_storage::RowLocator) -> (u8, u64, u64) {
8800    match *l {
8801        spg_storage::RowLocator::Hot(i) => (0, i as u64, 0),
8802        spg_storage::RowLocator::Cold {
8803            segment_id,
8804            page_offset,
8805        } => (1, u64::from(segment_id), u64::from(page_offset)),
8806    }
8807}
8808
8809/// v5.2.3: extract `(column_position, IndexKey)` when `where_expr`
8810/// is a simple `col = literal` predicate suitable for a `BTree` index
8811/// seek. Used by `exec_update_cancel` / `exec_delete_cancel` to
8812/// decide whether a write touches a cold-tier row (which requires
8813/// promote-on-write / shadow-on-delete) before falling through to
8814/// the hot-tier row walk.
8815///
8816/// Returns `None` for any predicate shape the planner can't push
8817/// down to an index seek — complex WHERE clauses always take the
8818/// hot-only path (cold rows are immutable to non-indexed writes
8819/// until a future scan-fanout sub-version).
8820fn try_pk_predicate(
8821    where_expr: &Expr,
8822    schema_cols: &[ColumnSchema],
8823    table_alias: &str,
8824) -> Option<(usize, IndexKey)> {
8825    let Expr::Binary {
8826        lhs,
8827        op: BinOp::Eq,
8828        rhs,
8829    } = where_expr
8830    else {
8831        return None;
8832    };
8833    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
8834        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
8835    let key = IndexKey::from_value(&value)?;
8836    Some((col_pos, key))
8837}
8838
8839fn resolve_col_literal_pair(
8840    col_side: &Expr,
8841    lit_side: &Expr,
8842    schema_cols: &[ColumnSchema],
8843    table_alias: &str,
8844) -> Option<(usize, Value)> {
8845    let Expr::Column(c) = col_side else {
8846        return None;
8847    };
8848    if let Some(q) = &c.qualifier
8849        && q != table_alias
8850    {
8851        return None;
8852    }
8853    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
8854    let Expr::Literal(l) = lit_side else {
8855        return None;
8856    };
8857    let v = match l {
8858        Literal::Integer(n) => {
8859            if let Ok(small) = i32::try_from(*n) {
8860                Value::Int(small)
8861            } else {
8862                Value::BigInt(*n)
8863            }
8864        }
8865        Literal::Float(x) => Value::Float(*x),
8866        Literal::String(s) => Value::Text(s.clone()),
8867        Literal::Bool(b) => Value::Bool(*b),
8868        Literal::Null => Value::Null,
8869        // Vector and Interval literals can't be used as B-tree index keys.
8870        // Tell the planner to fall back to full-scan.
8871        Literal::Vector(_) | Literal::Interval { .. } => return None,
8872    };
8873    Some((pos, v))
8874}
8875
8876/// Find the schema entry that a SELECT-list `Expr::Column` refers to.
8877/// Mirrors `resolve_column` in `eval.rs`, but returns a proper
8878/// `EngineError` so the projection-build path keeps `UnknownQualifier`
8879/// vs `ColumnNotFound` distinct.
8880fn resolve_projection_column<'a>(
8881    c: &ColumnName,
8882    schema_cols: &'a [ColumnSchema],
8883    table_alias: &str,
8884) -> Result<&'a ColumnSchema, EngineError> {
8885    if let Some(q) = &c.qualifier {
8886        let composite = alloc::format!("{q}.{name}", name = c.name);
8887        if let Some(s) = schema_cols.iter().find(|s| s.name == composite) {
8888            return Ok(s);
8889        }
8890        // Single-table case: the qualifier may equal the active alias —
8891        // then look for the bare column name.
8892        if q == table_alias
8893            && let Some(s) = schema_cols.iter().find(|s| s.name == c.name)
8894        {
8895            return Ok(s);
8896        }
8897        // For multi-table schemas the qualifier is unknown only if no
8898        // column bears the "<q>." prefix. For single-table, the alias
8899        // mismatch alone is enough.
8900        let prefix = alloc::format!("{q}.");
8901        let qualifier_known =
8902            q == table_alias || schema_cols.iter().any(|s| s.name.starts_with(&prefix));
8903        if !qualifier_known {
8904            return Err(EngineError::Eval(EvalError::UnknownQualifier {
8905                qualifier: q.clone(),
8906            }));
8907        }
8908        return Err(EngineError::Eval(EvalError::ColumnNotFound {
8909            name: c.name.clone(),
8910        }));
8911    }
8912    if let Some(s) = schema_cols.iter().find(|s| s.name == c.name) {
8913        return Ok(s);
8914    }
8915    let suffix = alloc::format!(".{name}", name = c.name);
8916    let mut matches = schema_cols.iter().filter(|s| s.name.ends_with(&suffix));
8917    let first = matches.next();
8918    let extra = matches.next();
8919    match (first, extra) {
8920        (Some(s), None) => Ok(s),
8921        (Some(_), Some(_)) => Err(EngineError::Eval(EvalError::TypeMismatch {
8922            detail: alloc::format!("ambiguous column reference: {}", c.name),
8923        })),
8924        _ => Err(EngineError::Eval(EvalError::ColumnNotFound {
8925            name: c.name.clone(),
8926        })),
8927    }
8928}
8929
8930fn build_projection(
8931    items: &[SelectItem],
8932    schema_cols: &[ColumnSchema],
8933    table_alias: &str,
8934) -> Result<Vec<ProjectedItem>, EngineError> {
8935    let mut out = Vec::new();
8936    for item in items {
8937        match item {
8938            SelectItem::Wildcard => {
8939                for col in schema_cols {
8940                    out.push(ProjectedItem {
8941                        expr: Expr::Column(ColumnName {
8942                            qualifier: None,
8943                            name: col.name.clone(),
8944                        }),
8945                        output_name: col.name.clone(),
8946                        ty: col.ty,
8947                        nullable: col.nullable,
8948                    });
8949                }
8950            }
8951            SelectItem::Expr { expr, alias } => {
8952                // Plain column ref keeps full schema info (real type +
8953                // nullability). For compound expressions try the
8954                // describe-side function-return-type table first
8955                // (e.g. `SELECT now()` → Timestamptz, `SELECT
8956                // concat(…)` → Text). Falls back to nullable Text
8957                // for shapes the describe path can't resolve.
8958                if let Expr::Column(c) = expr {
8959                    let sch = resolve_projection_column(c, schema_cols, table_alias)?;
8960                    let output_name = alias.clone().unwrap_or_else(|| c.name.clone());
8961                    out.push(ProjectedItem {
8962                        expr: expr.clone(),
8963                        output_name,
8964                        ty: sch.ty,
8965                        nullable: sch.nullable,
8966                    });
8967                } else if let Some(shape) = describe::describe_expr(expr, schema_cols) {
8968                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
8969                    out.push(ProjectedItem {
8970                        expr: expr.clone(),
8971                        output_name,
8972                        ty: shape.ty,
8973                        nullable: shape.nullable,
8974                    });
8975                } else {
8976                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
8977                    out.push(ProjectedItem {
8978                        expr: expr.clone(),
8979                        output_name,
8980                        ty: DataType::Text,
8981                        nullable: true,
8982                    });
8983                }
8984            }
8985        }
8986    }
8987    Ok(out)
8988}
8989
8990/// Promote an integer to a NUMERIC value at the requested scale.
8991/// Rejects values that, after scaling, would overflow the column's
8992/// precision budget.
8993fn numeric_from_integer(
8994    n: i128,
8995    precision: u8,
8996    scale: u8,
8997    col_name: &str,
8998) -> Result<Value, EngineError> {
8999    let factor = pow10_i128(scale);
9000    let scaled = n.checked_mul(factor).ok_or_else(|| {
9001        EngineError::Unsupported(alloc::format!(
9002            "integer overflow scaling value for column `{col_name}` to scale {scale}"
9003        ))
9004    })?;
9005    check_precision(scaled, precision, col_name)?;
9006    Ok(Value::Numeric { scaled, scale })
9007}
9008
9009/// Float → NUMERIC. Uses round-half-away-from-zero on `x * 10^scale`,
9010/// then verifies the result fits the column's precision.
9011#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
9012fn numeric_from_float(
9013    x: f64,
9014    precision: u8,
9015    scale: u8,
9016    col_name: &str,
9017) -> Result<Value, EngineError> {
9018    if !x.is_finite() {
9019        return Err(EngineError::Unsupported(alloc::format!(
9020            "cannot store non-finite float in NUMERIC column `{col_name}`"
9021        )));
9022    }
9023    let mut factor = 1.0_f64;
9024    for _ in 0..scale {
9025        factor *= 10.0;
9026    }
9027    // Round half-away-from-zero by biasing then casting (`as i128`
9028    // truncates toward zero, so the bias + truncation gives the
9029    // desired rounding). `f64::floor` / `ceil` live in std; we don't
9030    // need them — the cast handles the truncation step.
9031    let shifted = x * factor;
9032    let biased = if shifted >= 0.0 {
9033        shifted + 0.5
9034    } else {
9035        shifted - 0.5
9036    };
9037    // Range-check before casting back to i128 — the cast itself is
9038    // saturating in Rust, which would silently truncate huge inputs.
9039    if !(-1e38..=1e38).contains(&biased) {
9040        return Err(EngineError::Unsupported(alloc::format!(
9041            "value {x} overflows NUMERIC range for column `{col_name}`"
9042        )));
9043    }
9044    let scaled = biased as i128;
9045    check_precision(scaled, precision, col_name)?;
9046    Ok(Value::Numeric { scaled, scale })
9047}
9048
9049/// v7.17.0 Phase 3.P0-67 — parse PG-canonical decimal text into
9050/// `(mantissa: i128, source_scale: u8)`. Accepts optional sign,
9051/// optional integer part, optional fractional part. Rejects
9052/// scientific notation, embedded spaces, locale-specific
9053/// thousand separators. Returns None on bad input — coerce_value
9054/// turns that into a TypeMismatch error.
9055fn parse_numeric_text(s: &str) -> Option<(i128, u8)> {
9056    let s = s.trim();
9057    if s.is_empty() {
9058        return None;
9059    }
9060    let (negative, rest) = match s.as_bytes()[0] {
9061        b'-' => (true, &s[1..]),
9062        b'+' => (false, &s[1..]),
9063        _ => (false, s),
9064    };
9065    if rest.is_empty() {
9066        return None;
9067    }
9068    // Reject scientific notation — bigdecimal collapses it before
9069    // hitting the wire, and we want a clear error if a stray `e`
9070    // sneaks in.
9071    if rest.bytes().any(|b| b == b'e' || b == b'E') {
9072        return None;
9073    }
9074    let (int_part, frac_part) = match rest.find('.') {
9075        Some(idx) => (&rest[..idx], &rest[idx + 1..]),
9076        None => (rest, ""),
9077    };
9078    if int_part.is_empty() && frac_part.is_empty() {
9079        return None;
9080    }
9081    if int_part.bytes().any(|b| !b.is_ascii_digit()) {
9082        return None;
9083    }
9084    if frac_part.bytes().any(|b| !b.is_ascii_digit()) {
9085        return None;
9086    }
9087    let scale_u32 = u32::try_from(frac_part.len()).ok()?;
9088    if scale_u32 > u32::from(u8::MAX) {
9089        return None;
9090    }
9091    let scale = scale_u32 as u8;
9092    let mut digits = alloc::string::String::with_capacity(int_part.len() + frac_part.len() + 1);
9093    if negative {
9094        digits.push('-');
9095    }
9096    digits.push_str(int_part);
9097    digits.push_str(frac_part);
9098    // Strip a leading "+0..0" so parse doesn't choke on "00" etc.
9099    let digits = if digits == "-" {
9100        return None;
9101    } else if digits.is_empty() {
9102        "0"
9103    } else {
9104        digits.as_str()
9105    };
9106    let mantissa: i128 = digits.parse().ok()?;
9107    Some((mantissa, scale))
9108}
9109
9110/// Move a Numeric value from `src_scale` to `dst_scale`. Going up
9111/// multiplies by 10; going down rounds half-away-from-zero.
9112fn numeric_rescale(
9113    scaled: i128,
9114    src_scale: u8,
9115    precision: u8,
9116    dst_scale: u8,
9117    col_name: &str,
9118) -> Result<Value, EngineError> {
9119    let new_scaled = if dst_scale >= src_scale {
9120        let bump = pow10_i128(dst_scale - src_scale);
9121        scaled.checked_mul(bump).ok_or_else(|| {
9122            EngineError::Unsupported(alloc::format!(
9123                "overflow rescaling NUMERIC for column `{col_name}`"
9124            ))
9125        })?
9126    } else {
9127        let drop = pow10_i128(src_scale - dst_scale);
9128        let half = drop / 2;
9129        if scaled >= 0 {
9130            (scaled + half) / drop
9131        } else {
9132            (scaled - half) / drop
9133        }
9134    };
9135    check_precision(new_scaled, precision, col_name)?;
9136    Ok(Value::Numeric {
9137        scaled: new_scaled,
9138        scale: dst_scale,
9139    })
9140}
9141
9142/// Drop the fractional part of a scaled integer, returning the integer
9143/// portion (toward zero). Used for NUMERIC → INT casts.
9144const fn numeric_truncate_to_integer(scaled: i128, scale: u8) -> i128 {
9145    if scale == 0 {
9146        return scaled;
9147    }
9148    let factor = pow10_i128_const(scale);
9149    scaled / factor
9150}
9151
9152/// Verify a scaled NUMERIC value fits the column's declared precision.
9153/// `precision == 0` is the "unconstrained" form (bare `NUMERIC`); we
9154/// skip the check there.
9155fn check_precision(scaled: i128, precision: u8, col_name: &str) -> Result<(), EngineError> {
9156    if precision == 0 {
9157        return Ok(());
9158    }
9159    let limit = pow10_i128(precision);
9160    if scaled.unsigned_abs() >= limit.unsigned_abs() {
9161        return Err(EngineError::Unsupported(alloc::format!(
9162            "NUMERIC value exceeds precision {precision} for column `{col_name}`"
9163        )));
9164    }
9165    Ok(())
9166}
9167
9168const fn pow10_i128_const(p: u8) -> i128 {
9169    let mut acc: i128 = 1;
9170    let mut i = 0;
9171    while i < p {
9172        acc *= 10;
9173        i += 1;
9174    }
9175    acc
9176}
9177
9178fn pow10_i128(p: u8) -> i128 {
9179    pow10_i128_const(p)
9180}
9181
9182/// Walk a parsed `Statement`, swapping any `NOW()` /
9183/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()` function calls for a
9184/// literal cast that wraps the engine's per-statement clock reading.
9185/// When `now_micros` is `None`, calls stay as-is and surface as
9186/// `unknown function` at eval time — keeps the error path explicit.
9187/// v4.10: pre-walk the WHERE / projection / etc. of a SELECT and
9188/// replace every subquery node with a materialised literal. SPG
9189/// only supports uncorrelated subqueries — the inner SELECT does
9190/// not see outer-row columns, so the result is the same for every
9191/// outer row and can be evaluated once.
9192///
9193/// Returns the rewritten statement; the caller passes this to the
9194/// regular row-loop executor which no longer sees Subquery nodes
9195/// in its tree.
9196impl Engine {
9197    /// v4.12 window executor. Implements `ROW_NUMBER` / `RANK` /
9198    /// `DENSE_RANK` and the partition-aware aggregates `SUM` /
9199    /// `AVG` / `COUNT` / `MIN` / `MAX`. The plan is:
9200    /// 1. Apply the WHERE filter.
9201    /// 2. For each unique `WindowFunction` node in the projection,
9202    ///    partition + sort, compute the per-row value.
9203    /// 3. Append the window values as synthetic columns (`__win_N`)
9204    ///    to the row schema.
9205    /// 4. Rewrite the projection to read those columns.
9206    /// 5. Hand off to the regular project / ORDER BY / LIMIT pipe.
9207    #[allow(
9208        clippy::too_many_lines,
9209        clippy::type_complexity,
9210        clippy::needless_range_loop
9211    )] // window-eval is one cohesive pipe; splitting fragments
9212    fn exec_select_with_window(
9213        &self,
9214        stmt: &SelectStatement,
9215        cancel: CancelToken<'_>,
9216    ) -> Result<QueryResult, EngineError> {
9217        let from = stmt.from.as_ref().ok_or_else(|| {
9218            EngineError::Unsupported("window functions require a FROM clause".into())
9219        })?;
9220        // v7.17.0 Phase 3.P0-43 — JOIN + window functions. Phase
9221        // 3.6 rejected this combination outright ("queued for
9222        // v5.x"); P0-43 materialises the join + WHERE through the
9223        // existing nested-loop helper and runs the window pipeline
9224        // on the joined row set with the combined `alias.col`
9225        // schema. The window expressions resolve through the
9226        // qualifier-aware column resolver same as the aggregate /
9227        // projection paths on JOIN.
9228        let (schema_cols_owned, alias_opt): (Vec<ColumnSchema>, Option<&str>);
9229        let filtered: Vec<Row>;
9230        if from.joins.is_empty() {
9231            let primary = &from.primary;
9232            let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
9233                StorageError::TableNotFound {
9234                    name: primary.name.clone(),
9235                }
9236            })?;
9237            let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
9238            schema_cols_owned = table.schema().columns.clone();
9239            alias_opt = Some(alias);
9240            // Materialise WHERE-filtered rows owned so the JOIN
9241            // and single-table paths share a single downstream
9242            // shape. The clone is cheap relative to the window
9243            // computation that follows.
9244            let ctx = self.ev_ctx(&schema_cols_owned, alias_opt);
9245            let mut owned: Vec<Row> = Vec::new();
9246            for (i, row) in table.rows().iter().enumerate() {
9247                if i.is_multiple_of(256) {
9248                    cancel.check()?;
9249                }
9250                if let Some(w) = &stmt.where_ {
9251                    let cond = eval::eval_expr(w, row, &ctx)?;
9252                    if !matches!(cond, Value::Bool(true)) {
9253                        continue;
9254                    }
9255                }
9256                owned.push(row.clone());
9257            }
9258            filtered = owned;
9259        } else {
9260            let (combined_schema, rows) =
9261                self.build_joined_filtered_rows(from, stmt.where_.as_ref())?;
9262            schema_cols_owned = combined_schema;
9263            alias_opt = None;
9264            filtered = rows;
9265        }
9266        let schema_cols = &schema_cols_owned;
9267        let ctx = self.ev_ctx(schema_cols, alias_opt);
9268        let alias = alias_opt.unwrap_or("");
9269        let n_rows = filtered.len();
9270        // Borrow refs into the owned row vec once so the downstream
9271        // `compute_window_partition` call (which takes `&[&Row]`) and
9272        // the per-row eval loops share a single backing buffer.
9273        let filtered_refs: Vec<&Row> = filtered.iter().collect();
9274
9275        // 2) Collect unique window function nodes from projection.
9276        let mut window_nodes: Vec<Expr> = Vec::new();
9277        for item in &stmt.items {
9278            if let SelectItem::Expr { expr, .. } = item {
9279                collect_window_nodes(expr, &mut window_nodes);
9280            }
9281        }
9282
9283        // 3) For each window, compute per-row value.
9284        // Index: same order as window_nodes; for row i, win_vals[w][i].
9285        let mut win_vals: Vec<Vec<Value>> = Vec::with_capacity(window_nodes.len());
9286        for wnode in &window_nodes {
9287            let Expr::WindowFunction {
9288                name,
9289                args,
9290                partition_by,
9291                order_by,
9292                frame,
9293                null_treatment,
9294            } = wnode
9295            else {
9296                unreachable!("collect_window_nodes pushes only WindowFunction");
9297            };
9298            // Compute (partition_key, order_key, original_index) for each row.
9299            let mut indexed: Vec<(Vec<Value>, Vec<(Value, bool)>, usize)> =
9300                Vec::with_capacity(n_rows);
9301            for (i, row) in filtered.iter().enumerate() {
9302                let pkey: Vec<Value> = partition_by
9303                    .iter()
9304                    .map(|p| eval::eval_expr(p, row, &ctx))
9305                    .collect::<Result<_, _>>()?;
9306                let okey: Vec<(Value, bool)> = order_by
9307                    .iter()
9308                    .map(|(e, desc)| eval::eval_expr(e, row, &ctx).map(|v| (v, *desc)))
9309                    .collect::<Result<_, _>>()?;
9310                indexed.push((pkey, okey, i));
9311            }
9312            // Sort by (partition_key, order_key). Partition key uses
9313            // a stable encoded form; order key respects ASC/DESC.
9314            indexed.sort_by(|a, b| {
9315                let p_cmp = partition_key_cmp(&a.0, &b.0);
9316                if p_cmp != core::cmp::Ordering::Equal {
9317                    return p_cmp;
9318                }
9319                order_key_cmp(&a.1, &b.1)
9320            });
9321            // Per-partition compute.
9322            let mut out_vals: Vec<Value> = alloc::vec![Value::Null; n_rows];
9323            let mut p_start = 0;
9324            while p_start < indexed.len() {
9325                let mut p_end = p_start + 1;
9326                while p_end < indexed.len()
9327                    && partition_key_cmp(&indexed[p_start].0, &indexed[p_end].0)
9328                        == core::cmp::Ordering::Equal
9329                {
9330                    p_end += 1;
9331                }
9332                // Compute the function within this partition slice.
9333                compute_window_partition(
9334                    name,
9335                    args,
9336                    !order_by.is_empty(),
9337                    frame.as_ref(),
9338                    *null_treatment,
9339                    &indexed[p_start..p_end],
9340                    &filtered_refs,
9341                    &ctx,
9342                    &mut out_vals,
9343                )?;
9344                p_start = p_end;
9345            }
9346            win_vals.push(out_vals);
9347        }
9348
9349        // 4) Build extended schema: original columns + synthetic.
9350        let mut ext_cols = schema_cols.clone();
9351        for i in 0..window_nodes.len() {
9352            ext_cols.push(ColumnSchema::new(
9353                alloc::format!("__win_{i}"),
9354                DataType::Text, // type doesn't matter for projection eval
9355                true,
9356            ));
9357        }
9358        // 5) Build extended rows: each row gets its window values appended.
9359        let mut ext_rows: Vec<Row> = Vec::with_capacity(n_rows);
9360        for i in 0..n_rows {
9361            let mut values = filtered[i].values.clone();
9362            for w in 0..window_nodes.len() {
9363                values.push(win_vals[w][i].clone());
9364            }
9365            ext_rows.push(Row::new(values));
9366        }
9367        // 6) Rewrite the projection: WindowFunction nodes → Column(__win_N).
9368        let mut rewritten_items: Vec<SelectItem> = Vec::with_capacity(stmt.items.len());
9369        for item in &stmt.items {
9370            let new_item = match item {
9371                SelectItem::Wildcard => SelectItem::Wildcard,
9372                SelectItem::Expr { expr, alias } => {
9373                    let mut e = expr.clone();
9374                    rewrite_window_to_columns(&mut e, &window_nodes);
9375                    SelectItem::Expr {
9376                        expr: e,
9377                        alias: alias.clone(),
9378                    }
9379                }
9380            };
9381            rewritten_items.push(new_item);
9382        }
9383
9384        // 7) Project into final rows. JOIN case uses None so the
9385        // qualifier check in `resolve_column` falls through to the
9386        // composite `alias.col` schema lookup; single-table case
9387        // keeps the bare alias so `bare_col` resolution still
9388        // works for the projection's per-row column references.
9389        let ext_ctx = EvalContext::new(&ext_cols, alias_opt);
9390        let projection = build_projection(&rewritten_items, &ext_cols, alias)?;
9391        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(n_rows);
9392        for (i, row) in ext_rows.iter().enumerate() {
9393            if i.is_multiple_of(256) {
9394                cancel.check()?;
9395            }
9396            let mut values = Vec::with_capacity(projection.len());
9397            for p in &projection {
9398                values.push(eval::eval_expr(&p.expr, row, &ext_ctx)?);
9399            }
9400            let order_keys = if stmt.order_by.is_empty() {
9401                Vec::new()
9402            } else {
9403                let mut keys = Vec::with_capacity(stmt.order_by.len());
9404                for o in &stmt.order_by {
9405                    let mut e = o.expr.clone();
9406                    rewrite_window_to_columns(&mut e, &window_nodes);
9407                    let key = eval::eval_expr(&e, row, &ext_ctx)?;
9408                    keys.push(value_to_order_key(&key)?);
9409                }
9410                keys
9411            };
9412            tagged.push((order_keys, Row::new(values)));
9413        }
9414        // ORDER BY + LIMIT/OFFSET on the projected rows.
9415        if !stmt.order_by.is_empty() {
9416            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
9417            sort_by_keys(&mut tagged, &descs);
9418        }
9419        let mut out_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
9420        apply_offset_and_limit(&mut out_rows, stmt.offset_literal(), stmt.limit_literal());
9421        let final_cols: Vec<ColumnSchema> = projection
9422            .into_iter()
9423            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
9424            .collect();
9425        Ok(QueryResult::Rows {
9426            columns: final_cols,
9427            rows: out_rows,
9428        })
9429    }
9430
9431    /// v4.11: materialise each CTE into a temp table inside a
9432    /// cloned catalog, then run the body SELECT against a fresh
9433    /// engine instance that owns the enriched catalog. The clone
9434    /// is moderately expensive — only paid by CTE-bearing queries.
9435    /// Subqueries inside CTE bodies / the main body resolve as
9436    /// usual; `clock_fn` is propagated so `NOW()` lines up.
9437    /// v7.16.2 — mailrs round-10 A.3. Materialise the
9438    /// `information_schema.*` / `pg_catalog.*` virtual views
9439    /// the SELECT references, then re-execute the SELECT
9440    /// against an enriched catalog where those views are real
9441    /// tables. Same pattern as `exec_with_ctes`. The temp
9442    /// engine carries `meta_views_materialised = true` so its
9443    /// own meta-dispatch short-circuits — without that we'd
9444    /// infinite-recurse since the temp catalog's view name
9445    /// still starts with `__spg_info_` and re-triggers the
9446    /// check.
9447    fn exec_select_with_meta_views(
9448        &self,
9449        stmt: &SelectStatement,
9450        cancel: CancelToken<'_>,
9451    ) -> Result<QueryResult, EngineError> {
9452        let mut needed: alloc::collections::BTreeSet<String> = alloc::collections::BTreeSet::new();
9453        collect_meta_view_names(stmt, &mut needed);
9454        let mut catalog = self.active_catalog().clone();
9455        for view in &needed {
9456            if catalog.get(view).is_some() {
9457                continue;
9458            }
9459            match view.as_str() {
9460                "__spg_info_columns" => {
9461                    let (schema, rows) = synth_information_schema_columns(self.active_catalog());
9462                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9463                }
9464                "__spg_info_tables" => {
9465                    let (schema, rows) = synth_information_schema_tables(self.active_catalog());
9466                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9467                }
9468                "__spg_pg_class" => {
9469                    let (schema, rows) = synth_pg_class(self.active_catalog());
9470                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9471                }
9472                "__spg_pg_attribute" => {
9473                    let (schema, rows) = synth_pg_attribute(self.active_catalog());
9474                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9475                }
9476                // v7.17.0 Phase 3.P0-50 — pg_catalog.pg_type for
9477                // sqlx / SQLAlchemy / Diesel / pgAdmin lookups.
9478                "__spg_pg_type" => {
9479                    let (schema, rows) = synth_pg_type(self.active_catalog());
9480                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9481                }
9482                // v7.17.0 Phase 3.P0-51 — pg_catalog.pg_proc for
9483                // function-name introspection (ORM / pgAdmin).
9484                "__spg_pg_proc" => {
9485                    let (schema, rows) = synth_pg_proc(self.active_catalog());
9486                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9487                }
9488                // v7.17.0 Phase 3.P0-52 — pg_catalog.pg_namespace
9489                // (schema list for admin tools' tree views).
9490                "__spg_pg_namespace" => {
9491                    let (schema, rows) = synth_pg_namespace(self.active_catalog());
9492                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9493                }
9494                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_indexes view
9495                // for pgAdmin / DataGrip "indexes per table" listings.
9496                "__spg_pg_indexes" => {
9497                    let (schema, rows) = synth_pg_indexes(self.active_catalog());
9498                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9499                }
9500                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_index (raw)
9501                // for index introspection by ORM compilers.
9502                "__spg_pg_index" => {
9503                    let (schema, rows) = synth_pg_index_raw(self.active_catalog());
9504                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9505                }
9506                // v7.17.0 Phase 3.P0-54 — pg_catalog.pg_constraint
9507                // for FK / UNIQUE / PK / CHECK introspection.
9508                "__spg_pg_constraint" => {
9509                    let (schema, rows) = synth_pg_constraint(self.active_catalog());
9510                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9511                }
9512                // v7.17.0 Phase 3.P0-55 — pg_catalog.pg_database /
9513                // pg_roles / pg_user. SPG is single-database so
9514                // pg_database surfaces just `postgres`; pg_roles
9515                // / pg_user walk the engine's UserStore.
9516                "__spg_pg_database" => {
9517                    let (schema, rows) = synth_pg_database(self.active_catalog());
9518                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9519                }
9520                "__spg_pg_roles" | "__spg_pg_user" => {
9521                    let (schema, rows) = synth_pg_roles(self);
9522                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9523                }
9524                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_views. PG's
9525                // pg_views surfaces every CREATE VIEW result; SPG
9526                // ships one row per declared view from the catalog.
9527                "__spg_pg_views" => {
9528                    let (schema, rows) = synth_pg_views(self.active_catalog());
9529                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9530                }
9531                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_matviews.
9532                // SPG has no materialised view surface yet so the
9533                // table shares pg_views's schema but stays empty.
9534                "__spg_pg_matviews" => {
9535                    let (schema, _) = synth_pg_views(self.active_catalog());
9536                    materialise_meta_view(&mut catalog, view, schema, Vec::new())?;
9537                }
9538                // v7.17.0 Phase 3.P0-57 — pg_catalog.pg_settings.
9539                "__spg_pg_settings" => {
9540                    let (schema, rows) = synth_pg_settings(self);
9541                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9542                }
9543                // v7.17.0 Phase 3.P0-63 — information_schema.KEY_COLUMN_USAGE.
9544                "__spg_info_key_column_usage" => {
9545                    let (schema, rows) = synth_info_key_column_usage(self.active_catalog());
9546                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9547                }
9548                // v7.17.0 Phase 3.P0-64 — information_schema.REFERENTIAL_CONSTRAINTS.
9549                "__spg_info_referential_constraints" => {
9550                    let (schema, rows) = synth_info_referential_constraints(self.active_catalog());
9551                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9552                }
9553                // v7.17.0 Phase 3.P0-64 — information_schema.STATISTICS.
9554                "__spg_info_statistics" => {
9555                    let (schema, rows) = synth_info_statistics(self.active_catalog());
9556                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9557                }
9558                // v7.17.0 Phase 3.P0-64 — information_schema.ROUTINES.
9559                "__spg_info_routines" => {
9560                    let (schema, rows) = synth_info_routines();
9561                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9562                }
9563                // v7.17.0 Phase 3.P0-65 — mysql.user / mysql.db.
9564                "__spg_mysql_user" => {
9565                    let (schema, rows) = synth_mysql_user(self);
9566                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9567                }
9568                "__spg_mysql_db" => {
9569                    let (schema, rows) = synth_mysql_db();
9570                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9571                }
9572                _ => {
9573                    return Err(EngineError::Unsupported(alloc::format!(
9574                        "meta view {view:?} is not yet materialisable; \
9575                         v7.16.2 covers information_schema.columns / .tables \
9576                         and pg_catalog.pg_class / pg_attribute; \
9577                         v7.17.0 P0-50..P0-57 add pg_type / pg_proc / pg_namespace / \
9578                         pg_indexes / pg_index / pg_constraint / pg_database / pg_roles / \
9579                         pg_user / pg_views / pg_matviews / pg_settings"
9580                    )));
9581                }
9582            }
9583        }
9584        let mut temp = Engine::restore(catalog);
9585        if let Some(c) = self.clock {
9586            temp = temp.with_clock(c);
9587        }
9588        if let Some(f) = self.salt_fn {
9589            temp = temp.with_salt_fn(f);
9590        }
9591        temp.meta_views_materialised = true;
9592        temp.exec_select_cancel(stmt, cancel)
9593    }
9594
9595    fn exec_with_ctes(
9596        &self,
9597        stmt: &SelectStatement,
9598        cancel: CancelToken<'_>,
9599    ) -> Result<QueryResult, EngineError> {
9600        cancel.check()?;
9601        let mut catalog = self.active_catalog().clone();
9602        for cte in &stmt.ctes {
9603            if catalog.get(&cte.name).is_some() {
9604                return Err(EngineError::Unsupported(alloc::format!(
9605                    "CTE name {:?} shadows an existing table; rename the CTE",
9606                    cte.name
9607                )));
9608            }
9609            let (columns, rows) = if cte.recursive {
9610                self.materialise_recursive_cte(cte, &catalog, cancel)?
9611            } else {
9612                let body_result = self.exec_select_cancel(&cte.body, cancel)?;
9613                let QueryResult::Rows { columns, rows } = body_result else {
9614                    return Err(EngineError::Unsupported(alloc::format!(
9615                        "CTE {:?} body did not return rows",
9616                        cte.name
9617                    )));
9618                };
9619                (columns, rows)
9620            };
9621            // v4.22: the projection builder labels any non-column
9622            // expression as Text — including literal SELECT 1.
9623            // Promote each column's type to whatever the rows
9624            // actually carry so the CTE storage table accepts them.
9625            let inferred = infer_column_types(&columns, &rows);
9626            let mut columns = inferred;
9627            // v4.22: apply optional `WITH name(a, b, c)` overrides.
9628            if !cte.column_overrides.is_empty() {
9629                if cte.column_overrides.len() != columns.len() {
9630                    return Err(EngineError::Unsupported(alloc::format!(
9631                        "CTE {:?} column list has {} names but body returns {} columns",
9632                        cte.name,
9633                        cte.column_overrides.len(),
9634                        columns.len()
9635                    )));
9636                }
9637                for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
9638                    col.name.clone_from(name);
9639                }
9640            }
9641            let schema = TableSchema::new(cte.name.clone(), columns);
9642            catalog.create_table(schema).map_err(EngineError::Storage)?;
9643            let table = catalog
9644                .get_mut(&cte.name)
9645                .expect("just-created CTE table must exist");
9646            for row in rows {
9647                table.insert(row).map_err(EngineError::Storage)?;
9648            }
9649        }
9650        // Strip CTEs from the body before running on the temp engine
9651        // so we don't recurse forever.
9652        let mut body = stmt.clone();
9653        body.ctes = Vec::new();
9654        let mut temp = Engine::restore(catalog);
9655        if let Some(c) = self.clock {
9656            temp = temp.with_clock(c);
9657        }
9658        if let Some(f) = self.salt_fn {
9659            temp = temp.with_salt_fn(f);
9660        }
9661        temp.exec_select_cancel(&body, cancel)
9662    }
9663
9664    /// v4.22: materialise a WITH RECURSIVE CTE. The body must be a
9665    /// UNION (or UNION ALL) of an anchor that does not reference
9666    /// the CTE name, and one or more recursive terms that do. The
9667    /// anchor runs first; each subsequent iteration runs the
9668    /// recursive term against a temp catalog where the CTE name is
9669    /// bound to the *previous* iteration's output. Iteration stops
9670    /// when the recursive term yields no rows; UNION (DISTINCT)
9671    /// deduplicates against the accumulated result, UNION ALL does
9672    /// not. A hard cap on total rows prevents runaway queries.
9673    #[allow(clippy::too_many_lines)]
9674    fn materialise_recursive_cte(
9675        &self,
9676        cte: &spg_sql::ast::Cte,
9677        base_catalog: &Catalog,
9678        cancel: CancelToken<'_>,
9679    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
9680        const MAX_TOTAL_ROWS: usize = 1_000_000;
9681        const MAX_ITERATIONS: usize = 100_000;
9682        cancel.check()?;
9683        if cte.body.unions.is_empty() {
9684            return Err(EngineError::Unsupported(alloc::format!(
9685                "WITH RECURSIVE {:?} body must be a UNION of an anchor and a recursive term",
9686                cte.name
9687            )));
9688        }
9689        // Anchor: the body's leading SELECT, with unions stripped.
9690        let mut anchor = cte.body.clone();
9691        let union_terms = core::mem::take(&mut anchor.unions);
9692        anchor.ctes = Vec::new();
9693        // Anchor must not reference the CTE name.
9694        if select_refers_to(&anchor, &cte.name) {
9695            return Err(EngineError::Unsupported(alloc::format!(
9696                "WITH RECURSIVE {:?}: the anchor must not reference the CTE itself",
9697                cte.name
9698            )));
9699        }
9700        let anchor_result = self.exec_select_cancel(&anchor, cancel)?;
9701        let QueryResult::Rows {
9702            columns: anchor_cols,
9703            rows: anchor_rows,
9704        } = anchor_result
9705        else {
9706            return Err(EngineError::Unsupported(alloc::format!(
9707                "WITH RECURSIVE {:?}: anchor did not return rows",
9708                cte.name
9709            )));
9710        };
9711        // The projection builder labels non-column expressions Text;
9712        // refine column types from the anchor's actual values so the
9713        // intermediate iter-catalog tables accept them.
9714        let mut columns = infer_column_types(&anchor_cols, &anchor_rows);
9715        if !cte.column_overrides.is_empty() {
9716            if cte.column_overrides.len() != columns.len() {
9717                return Err(EngineError::Unsupported(alloc::format!(
9718                    "CTE {:?} column list has {} names but anchor returns {} columns",
9719                    cte.name,
9720                    cte.column_overrides.len(),
9721                    columns.len()
9722                )));
9723            }
9724            for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
9725                col.name.clone_from(name);
9726            }
9727        }
9728        let mut all_rows: Vec<Row> = anchor_rows.clone();
9729        let mut working_set: Vec<Row> = anchor_rows;
9730        let mut seen: alloc::collections::BTreeSet<Vec<u8>> = alloc::collections::BTreeSet::new();
9731        // Track at least one "all UNION ALL" flag — if every union
9732        // kind is ALL we skip the dedup step (faster + matches PG).
9733        let all_union_all = union_terms.iter().all(|(k, _)| matches!(k, UnionKind::All));
9734        if !all_union_all {
9735            for r in &all_rows {
9736                seen.insert(encode_row_key(r));
9737            }
9738        }
9739        for iter in 0..MAX_ITERATIONS {
9740            cancel.check()?;
9741            if working_set.is_empty() {
9742                break;
9743            }
9744            // Build a fresh catalog: base + CTE bound to working_set.
9745            let mut iter_catalog = base_catalog.clone();
9746            let schema = TableSchema::new(cte.name.clone(), columns.clone());
9747            iter_catalog
9748                .create_table(schema)
9749                .map_err(EngineError::Storage)?;
9750            {
9751                let table = iter_catalog.get_mut(&cte.name).expect("just-created");
9752                for row in &working_set {
9753                    table.insert(row.clone()).map_err(EngineError::Storage)?;
9754                }
9755            }
9756            let mut iter_engine = Engine::restore(iter_catalog);
9757            if let Some(c) = self.clock {
9758                iter_engine = iter_engine.with_clock(c);
9759            }
9760            if let Some(f) = self.salt_fn {
9761                iter_engine = iter_engine.with_salt_fn(f);
9762            }
9763            // Run each recursive term in sequence and collect new rows.
9764            let mut next_set: Vec<Row> = Vec::new();
9765            for (_, term) in &union_terms {
9766                let mut term = term.clone();
9767                term.ctes = Vec::new();
9768                let r = iter_engine.exec_select_cancel(&term, cancel)?;
9769                let QueryResult::Rows {
9770                    columns: rc,
9771                    rows: rs,
9772                } = r
9773                else {
9774                    return Err(EngineError::Unsupported(alloc::format!(
9775                        "WITH RECURSIVE {:?}: recursive term did not return rows",
9776                        cte.name
9777                    )));
9778                };
9779                if rc.len() != columns.len() {
9780                    return Err(EngineError::Unsupported(alloc::format!(
9781                        "WITH RECURSIVE {:?}: column count of recursive term ({}) does not match anchor ({})",
9782                        cte.name,
9783                        rc.len(),
9784                        columns.len()
9785                    )));
9786                }
9787                for row in rs {
9788                    if !all_union_all {
9789                        let key = encode_row_key(&row);
9790                        if !seen.insert(key) {
9791                            continue;
9792                        }
9793                    }
9794                    next_set.push(row);
9795                }
9796            }
9797            if next_set.is_empty() {
9798                break;
9799            }
9800            all_rows.extend(next_set.iter().cloned());
9801            working_set = next_set;
9802            if all_rows.len() > MAX_TOTAL_ROWS {
9803                return Err(EngineError::Unsupported(alloc::format!(
9804                    "WITH RECURSIVE {:?}: produced more than {MAX_TOTAL_ROWS} rows — likely runaway recursion",
9805                    cte.name
9806                )));
9807            }
9808            if iter + 1 == MAX_ITERATIONS {
9809                return Err(EngineError::Unsupported(alloc::format!(
9810                    "WITH RECURSIVE {:?}: exceeded {MAX_ITERATIONS} iterations",
9811                    cte.name
9812                )));
9813            }
9814        }
9815        Ok((columns, all_rows))
9816    }
9817
9818    fn resolve_select_subqueries(
9819        &self,
9820        stmt: &mut SelectStatement,
9821        cancel: CancelToken<'_>,
9822    ) -> Result<(), EngineError> {
9823        for item in &mut stmt.items {
9824            if let SelectItem::Expr { expr, .. } = item {
9825                self.resolve_expr_subqueries(expr, cancel)?;
9826            }
9827        }
9828        if let Some(w) = &mut stmt.where_ {
9829            self.resolve_expr_subqueries(w, cancel)?;
9830        }
9831        if let Some(gs) = &mut stmt.group_by {
9832            for g in gs {
9833                self.resolve_expr_subqueries(g, cancel)?;
9834            }
9835        }
9836        if let Some(h) = &mut stmt.having {
9837            self.resolve_expr_subqueries(h, cancel)?;
9838        }
9839        for o in &mut stmt.order_by {
9840            self.resolve_expr_subqueries(&mut o.expr, cancel)?;
9841        }
9842        for (_, peer) in &mut stmt.unions {
9843            self.resolve_select_subqueries(peer, cancel)?;
9844        }
9845        Ok(())
9846    }
9847
9848    #[allow(clippy::only_used_in_recursion)] // engine handle reads aren't really pure
9849    fn resolve_expr_subqueries(
9850        &self,
9851        e: &mut Expr,
9852        cancel: CancelToken<'_>,
9853    ) -> Result<(), EngineError> {
9854        // Replace-on-this-node cases first.
9855        if let Some(replacement) = self.subquery_replacement(e, cancel)? {
9856            *e = replacement;
9857            return Ok(());
9858        }
9859        match e {
9860            Expr::Binary { lhs, rhs, .. } => {
9861                self.resolve_expr_subqueries(lhs, cancel)?;
9862                self.resolve_expr_subqueries(rhs, cancel)?;
9863            }
9864            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
9865                self.resolve_expr_subqueries(expr, cancel)?;
9866            }
9867            Expr::FunctionCall { args, .. } => {
9868                for a in args {
9869                    self.resolve_expr_subqueries(a, cancel)?;
9870                }
9871            }
9872            Expr::Like { expr, pattern, .. } => {
9873                self.resolve_expr_subqueries(expr, cancel)?;
9874                self.resolve_expr_subqueries(pattern, cancel)?;
9875            }
9876            Expr::Extract { source, .. } => self.resolve_expr_subqueries(source, cancel)?,
9877            // v4.12 window functions — recurse into args + ORDER BY
9878            // + PARTITION BY in case they carry inner subqueries.
9879            Expr::WindowFunction {
9880                args,
9881                partition_by,
9882                order_by,
9883                ..
9884            } => {
9885                for a in args {
9886                    self.resolve_expr_subqueries(a, cancel)?;
9887                }
9888                for p in partition_by {
9889                    self.resolve_expr_subqueries(p, cancel)?;
9890                }
9891                for (e, _) in order_by {
9892                    self.resolve_expr_subqueries(e, cancel)?;
9893                }
9894            }
9895            // Subquery nodes are handled in subquery_replacement
9896            // (which returned None — defensive no-op); Literal /
9897            // Column are leaves.
9898            Expr::ScalarSubquery(_)
9899            | Expr::Exists { .. }
9900            | Expr::InSubquery { .. }
9901            | Expr::Literal(_)
9902            | Expr::Placeholder(_)
9903            | Expr::Column(_) => {}
9904            // v7.10.10 — recurse children.
9905            Expr::Array(items) => {
9906                for elem in items {
9907                    self.resolve_expr_subqueries(elem, cancel)?;
9908                }
9909            }
9910            Expr::ArraySubscript { target, index } => {
9911                self.resolve_expr_subqueries(target, cancel)?;
9912                self.resolve_expr_subqueries(index, cancel)?;
9913            }
9914            Expr::AnyAll { expr, array, .. } => {
9915                self.resolve_expr_subqueries(expr, cancel)?;
9916                self.resolve_expr_subqueries(array, cancel)?;
9917            }
9918            Expr::Case {
9919                operand,
9920                branches,
9921                else_branch,
9922            } => {
9923                if let Some(o) = operand {
9924                    self.resolve_expr_subqueries(o, cancel)?;
9925                }
9926                for (w, t) in branches {
9927                    self.resolve_expr_subqueries(w, cancel)?;
9928                    self.resolve_expr_subqueries(t, cancel)?;
9929                }
9930                if let Some(e) = else_branch {
9931                    self.resolve_expr_subqueries(e, cancel)?;
9932                }
9933            }
9934        }
9935        Ok(())
9936    }
9937
9938    /// v4.23: per-row eval that handles correlated subqueries.
9939    /// Equivalent to `eval::eval_expr` when the expression has no
9940    /// subqueries; otherwise clones the expression, substitutes
9941    /// outer-row columns into each surviving subquery node, runs
9942    /// the inner SELECT, and replaces the node with the literal
9943    /// result. Only the WHERE-filter call sites use this path so
9944    /// the uncorrelated fast path is preserved everywhere else.
9945    fn eval_expr_with_correlated(
9946        &self,
9947        expr: &Expr,
9948        row: &Row,
9949        ctx: &EvalContext<'_>,
9950        cancel: CancelToken<'_>,
9951        memo: Option<&mut memoize::MemoizeCache>,
9952    ) -> Result<Value, EngineError> {
9953        if !expr_has_subquery(expr) {
9954            return eval::eval_expr(expr, row, ctx).map_err(EngineError::Eval);
9955        }
9956        let mut e = expr.clone();
9957        self.resolve_correlated_in_expr(&mut e, row, ctx, cancel, memo)?;
9958        eval::eval_expr(&e, row, ctx).map_err(EngineError::Eval)
9959    }
9960
9961    fn resolve_correlated_in_expr(
9962        &self,
9963        e: &mut Expr,
9964        row: &Row,
9965        ctx: &EvalContext<'_>,
9966        cancel: CancelToken<'_>,
9967        mut memo: Option<&mut memoize::MemoizeCache>,
9968    ) -> Result<(), EngineError> {
9969        match e {
9970            Expr::ScalarSubquery(inner) => {
9971                // v6.2.6 — Memoize: build the cache key from the
9972                // pre-substitution subquery repr + the outer row's
9973                // values. Two outer rows with identical correlated
9974                // values hit the same entry.
9975                let cache_key = memo.as_ref().map(|_| memoize::CacheKey {
9976                    subquery_repr: alloc::format!("{}", **inner),
9977                    outer_values: row.values.clone(),
9978                });
9979                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key.as_ref())
9980                    && let Some(cached) = cache.get(k)
9981                {
9982                    *e = value_to_literal_expr(cached)?;
9983                    return Ok(());
9984                }
9985                let mut s = (**inner).clone();
9986                substitute_outer_columns(&mut s, row, ctx);
9987                let r = self.exec_select_cancel(&s, cancel)?;
9988                let QueryResult::Rows { rows, .. } = r else {
9989                    return Err(EngineError::Unsupported(
9990                        "scalar subquery: inner did not return rows".into(),
9991                    ));
9992                };
9993                let value = match rows.as_slice() {
9994                    [] => Value::Null,
9995                    [r0] => r0.values.first().cloned().unwrap_or(Value::Null),
9996                    _ => {
9997                        return Err(EngineError::Unsupported(alloc::format!(
9998                            "scalar subquery returned {} rows; expected 0 or 1",
9999                            rows.len()
10000                        )));
10001                    }
10002                };
10003                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key) {
10004                    cache.insert(k, value.clone());
10005                }
10006                *e = value_to_literal_expr(value)?;
10007            }
10008            Expr::Exists { subquery, negated } => {
10009                let mut s = (**subquery).clone();
10010                substitute_outer_columns(&mut s, row, ctx);
10011                let r = self.exec_select_cancel(&s, cancel)?;
10012                let exists = matches!(r, QueryResult::Rows { rows, .. } if !rows.is_empty());
10013                let bit = if *negated { !exists } else { exists };
10014                *e = Expr::Literal(Literal::Bool(bit));
10015            }
10016            Expr::InSubquery {
10017                expr: lhs,
10018                subquery,
10019                negated,
10020            } => {
10021                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
10022                let lhs_val = eval::eval_expr(lhs, row, ctx).map_err(EngineError::Eval)?;
10023                let mut s = (**subquery).clone();
10024                substitute_outer_columns(&mut s, row, ctx);
10025                let r = self.exec_select_cancel(&s, cancel)?;
10026                let QueryResult::Rows { columns, rows, .. } = r else {
10027                    return Err(EngineError::Unsupported(
10028                        "IN-subquery: inner did not return rows".into(),
10029                    ));
10030                };
10031                if columns.len() != 1 {
10032                    return Err(EngineError::Unsupported(alloc::format!(
10033                        "IN-subquery must project exactly one column; got {}",
10034                        columns.len()
10035                    )));
10036                }
10037                let mut found = false;
10038                let mut any_null = false;
10039                for r0 in rows {
10040                    let v = r0.values.into_iter().next().unwrap_or(Value::Null);
10041                    if v.is_null() {
10042                        any_null = true;
10043                        continue;
10044                    }
10045                    if value_cmp(&v, &lhs_val) == core::cmp::Ordering::Equal {
10046                        found = true;
10047                        break;
10048                    }
10049                }
10050                let bit = if found {
10051                    !*negated
10052                } else if any_null {
10053                    return Err(EngineError::Unsupported(
10054                        "IN-subquery with NULL in result and no match: NULL semantics not yet implemented".into(),
10055                    ));
10056                } else {
10057                    *negated
10058                };
10059                *e = Expr::Literal(Literal::Bool(bit));
10060            }
10061            Expr::Binary { lhs, rhs, .. } => {
10062                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
10063                self.resolve_correlated_in_expr(rhs, row, ctx, cancel, memo.as_deref_mut())?;
10064            }
10065            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10066                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10067            }
10068            Expr::Like { expr, pattern, .. } => {
10069                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10070                self.resolve_correlated_in_expr(pattern, row, ctx, cancel, memo.as_deref_mut())?;
10071            }
10072            Expr::FunctionCall { args, .. } => {
10073                for a in args {
10074                    self.resolve_correlated_in_expr(a, row, ctx, cancel, memo.as_deref_mut())?;
10075                }
10076            }
10077            Expr::Extract { source, .. } => {
10078                self.resolve_correlated_in_expr(source, row, ctx, cancel, memo.as_deref_mut())?;
10079            }
10080            Expr::WindowFunction { .. }
10081            | Expr::Literal(_)
10082            | Expr::Placeholder(_)
10083            | Expr::Column(_) => {}
10084            // v7.10.10 — recurse children.
10085            Expr::Array(items) => {
10086                for elem in items {
10087                    self.resolve_correlated_in_expr(elem, row, ctx, cancel, memo.as_deref_mut())?;
10088                }
10089            }
10090            Expr::ArraySubscript { target, index } => {
10091                self.resolve_correlated_in_expr(target, row, ctx, cancel, memo.as_deref_mut())?;
10092                self.resolve_correlated_in_expr(index, row, ctx, cancel, memo.as_deref_mut())?;
10093            }
10094            Expr::AnyAll { expr, array, .. } => {
10095                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10096                self.resolve_correlated_in_expr(array, row, ctx, cancel, memo.as_deref_mut())?;
10097            }
10098            Expr::Case {
10099                operand,
10100                branches,
10101                else_branch,
10102            } => {
10103                if let Some(o) = operand {
10104                    self.resolve_correlated_in_expr(o, row, ctx, cancel, memo.as_deref_mut())?;
10105                }
10106                for (w, t) in branches {
10107                    self.resolve_correlated_in_expr(w, row, ctx, cancel, memo.as_deref_mut())?;
10108                    self.resolve_correlated_in_expr(t, row, ctx, cancel, memo.as_deref_mut())?;
10109                }
10110                if let Some(e) = else_branch {
10111                    self.resolve_correlated_in_expr(e, row, ctx, cancel, memo.as_deref_mut())?;
10112                }
10113            }
10114        }
10115        Ok(())
10116    }
10117
10118    fn subquery_replacement(
10119        &self,
10120        e: &Expr,
10121        cancel: CancelToken<'_>,
10122    ) -> Result<Option<Expr>, EngineError> {
10123        match e {
10124            Expr::ScalarSubquery(inner) => {
10125                let mut s = (**inner).clone();
10126                // Recurse into the inner SELECT first so nested
10127                // subqueries materialise bottom-up.
10128                self.resolve_select_subqueries(&mut s, cancel)?;
10129                let r = match self.exec_bare_select_cancel(&s, cancel) {
10130                    Ok(r) => r,
10131                    Err(e) if is_correlation_error(&e) => return Ok(None),
10132                    Err(e) => return Err(e),
10133                };
10134                let QueryResult::Rows { rows, .. } = r else {
10135                    return Err(EngineError::Unsupported(
10136                        "scalar subquery: inner statement did not return rows".into(),
10137                    ));
10138                };
10139                let value = match rows.as_slice() {
10140                    [] => Value::Null,
10141                    [row] => row.values.first().cloned().unwrap_or(Value::Null),
10142                    _ => {
10143                        return Err(EngineError::Unsupported(alloc::format!(
10144                            "scalar subquery returned {} rows; expected 0 or 1",
10145                            rows.len()
10146                        )));
10147                    }
10148                };
10149                Ok(Some(value_to_literal_expr(value)?))
10150            }
10151            Expr::Exists { subquery, negated } => {
10152                let mut s = (**subquery).clone();
10153                self.resolve_select_subqueries(&mut s, cancel)?;
10154                let r = match self.exec_bare_select_cancel(&s, cancel) {
10155                    Ok(r) => r,
10156                    Err(e) if is_correlation_error(&e) => return Ok(None),
10157                    Err(e) => return Err(e),
10158                };
10159                let exists = match r {
10160                    QueryResult::Rows { rows, .. } => !rows.is_empty(),
10161                    QueryResult::CommandOk { .. } => false,
10162                };
10163                let bit = if *negated { !exists } else { exists };
10164                Ok(Some(Expr::Literal(Literal::Bool(bit))))
10165            }
10166            Expr::InSubquery {
10167                expr,
10168                subquery,
10169                negated,
10170            } => {
10171                let mut s = (**subquery).clone();
10172                self.resolve_select_subqueries(&mut s, cancel)?;
10173                let r = match self.exec_bare_select_cancel(&s, cancel) {
10174                    Ok(r) => r,
10175                    Err(e) if is_correlation_error(&e) => return Ok(None),
10176                    Err(e) => return Err(e),
10177                };
10178                let QueryResult::Rows { columns, rows, .. } = r else {
10179                    return Err(EngineError::Unsupported(
10180                        "IN-subquery: inner statement did not return rows".into(),
10181                    ));
10182                };
10183                if columns.len() != 1 {
10184                    return Err(EngineError::Unsupported(alloc::format!(
10185                        "IN-subquery must project exactly one column; got {}",
10186                        columns.len()
10187                    )));
10188                }
10189                // Build the same OR-Eq chain the parse-time literal-list
10190                // path constructs, with each value lifted into a Literal.
10191                let mut acc: Option<Expr> = None;
10192                for row in rows {
10193                    let v = row.values.into_iter().next().unwrap_or(Value::Null);
10194                    let lit = value_to_literal_expr(v)?;
10195                    let cmp = Expr::Binary {
10196                        lhs: expr.clone(),
10197                        op: BinOp::Eq,
10198                        rhs: Box::new(lit),
10199                    };
10200                    acc = Some(match acc {
10201                        None => cmp,
10202                        Some(prev) => Expr::Binary {
10203                            lhs: Box::new(prev),
10204                            op: BinOp::Or,
10205                            rhs: Box::new(cmp),
10206                        },
10207                    });
10208                }
10209                let combined = acc.unwrap_or(Expr::Literal(Literal::Bool(false)));
10210                let final_expr = if *negated {
10211                    Expr::Unary {
10212                        op: UnOp::Not,
10213                        expr: Box::new(combined),
10214                    }
10215                } else {
10216                    combined
10217                };
10218                Ok(Some(final_expr))
10219            }
10220            _ => Ok(None),
10221        }
10222    }
10223}
10224
10225// ---- v4.12 window-function helpers ----
10226// The (partition-key, order-key, original-index) tuple shape used
10227// across these helpers is intrinsic to the planner. Factoring it
10228// into a typedef adds indirection without making the code clearer,
10229// so several lints are allowed inline on the affected functions
10230// rather than module-wide.
10231
10232/// v4.22: cheap structural scan for `FROM <name>` (qualified or
10233/// not) inside a SELECT — used to verify the anchor of a WITH
10234/// RECURSIVE CTE doesn't recurse into itself. Conservative: walks
10235/// FROM joins, subqueries, and unions.
10236fn select_refers_to(stmt: &SelectStatement, target: &str) -> bool {
10237    if let Some(from) = &stmt.from
10238        && from_refers_to(from, target)
10239    {
10240        return true;
10241    }
10242    for (_, peer) in &stmt.unions {
10243        if select_refers_to(peer, target) {
10244            return true;
10245        }
10246    }
10247    for item in &stmt.items {
10248        if let SelectItem::Expr { expr, .. } = item
10249            && expr_refers_to(expr, target)
10250        {
10251            return true;
10252        }
10253    }
10254    if let Some(w) = &stmt.where_
10255        && expr_refers_to(w, target)
10256    {
10257        return true;
10258    }
10259    false
10260}
10261
10262fn from_refers_to(from: &FromClause, target: &str) -> bool {
10263    if from.primary.name.eq_ignore_ascii_case(target) {
10264        return true;
10265    }
10266    from.joins
10267        .iter()
10268        .any(|j| j.table.name.eq_ignore_ascii_case(target))
10269}
10270
10271fn expr_refers_to(e: &Expr, target: &str) -> bool {
10272    match e {
10273        Expr::ScalarSubquery(s) => select_refers_to(s, target),
10274        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
10275            select_refers_to(subquery, target)
10276        }
10277        Expr::Binary { lhs, rhs, .. } => expr_refers_to(lhs, target) || expr_refers_to(rhs, target),
10278        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10279            expr_refers_to(expr, target)
10280        }
10281        Expr::Like { expr, pattern, .. } => {
10282            expr_refers_to(expr, target) || expr_refers_to(pattern, target)
10283        }
10284        Expr::FunctionCall { args, .. } => args.iter().any(|a| expr_refers_to(a, target)),
10285        Expr::Extract { source, .. } => expr_refers_to(source, target),
10286        Expr::WindowFunction {
10287            args,
10288            partition_by,
10289            order_by,
10290            ..
10291        } => {
10292            args.iter().any(|a| expr_refers_to(a, target))
10293                || partition_by.iter().any(|p| expr_refers_to(p, target))
10294                || order_by.iter().any(|(o, _)| expr_refers_to(o, target))
10295        }
10296        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
10297        Expr::Array(items) => items.iter().any(|e| expr_refers_to(e, target)),
10298        Expr::ArraySubscript { target: t, index } => {
10299            expr_refers_to(t, target) || expr_refers_to(index, target)
10300        }
10301        Expr::AnyAll { expr, array, .. } => {
10302            expr_refers_to(expr, target) || expr_refers_to(array, target)
10303        }
10304        Expr::Case {
10305            operand,
10306            branches,
10307            else_branch,
10308        } => {
10309            operand
10310                .as_deref()
10311                .is_some_and(|o| expr_refers_to(o, target))
10312                || branches
10313                    .iter()
10314                    .any(|(w, t)| expr_refers_to(w, target) || expr_refers_to(t, target))
10315                || else_branch
10316                    .as_deref()
10317                    .is_some_and(|e| expr_refers_to(e, target))
10318        }
10319    }
10320}
10321
10322/// v4.22: pick more specific column types from observed rows when
10323/// the projection builder defaulted to Text (the v1.x behavior for
10324/// non-column expressions). Lets `WITH t(n) AS (SELECT 1 ...)`
10325/// land an Int column in the CTE storage table rather than failing
10326/// the insert with "expected TEXT, got INT".
10327/// v7.16.2 — map an SPG [`DataType`] to the PG-canonical
10328/// `information_schema.columns.data_type` text. Covers the
10329/// values mailrs's migrations probe (`'ARRAY'`, `'integer'`,
10330/// `'text'`, …). Unknown variants fall back to the SPG name
10331/// downcased — better than panicking on a future DataType.
10332fn pg_data_type_text(ty: DataType) -> alloc::string::String {
10333    let s = match ty {
10334        DataType::Int => "integer",
10335        DataType::BigInt => "bigint",
10336        DataType::SmallInt => "smallint",
10337        DataType::Float => "double precision",
10338        DataType::Bool => "boolean",
10339        DataType::Text => "text",
10340        DataType::Varchar(_) => "character varying",
10341        DataType::Date => "date",
10342        DataType::Timestamp => "timestamp without time zone",
10343        DataType::Timestamptz => "timestamp with time zone",
10344        DataType::Json => "jsonb",
10345        DataType::Bytes => "bytea",
10346        DataType::TextArray | DataType::IntArray | DataType::BigIntArray => "ARRAY",
10347        DataType::TsVector => "tsvector",
10348        DataType::TsQuery => "tsquery",
10349        DataType::Vector { .. } => "USER-DEFINED",
10350        // Non-exhaustive — fall back to "USER-DEFINED" the way
10351        // PG labels any pg_type it doesn't recognise.
10352        _ => "USER-DEFINED",
10353    };
10354    alloc::string::String::from(s)
10355}
10356
10357/// v7.16.2 — synthesise `information_schema.columns`. mailrs
10358/// queries are of shape `SELECT 1 FROM information_schema.columns
10359/// WHERE table_name = … AND column_name = … AND data_type = …` —
10360/// the v7.16.2 view returns the columns mailrs probes; broader
10361/// PG-spec parity (ordinal_position, is_nullable, character_
10362/// maximum_length, udt_name, …) lands as needed.
10363fn synth_information_schema_columns(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10364    let schema = alloc::vec![
10365        ColumnSchema::new("table_catalog", DataType::Text, false),
10366        ColumnSchema::new("table_schema", DataType::Text, false),
10367        ColumnSchema::new("table_name", DataType::Text, false),
10368        ColumnSchema::new("column_name", DataType::Text, false),
10369        ColumnSchema::new("ordinal_position", DataType::Int, false),
10370        ColumnSchema::new("is_nullable", DataType::Text, false),
10371        ColumnSchema::new("data_type", DataType::Text, false),
10372    ];
10373    let mut rows: Vec<Row> = Vec::new();
10374    for tname in cat.table_names() {
10375        let Some(t) = cat.get(&tname) else { continue };
10376        for (i, col) in t.schema().columns.iter().enumerate() {
10377            #[allow(clippy::cast_possible_wrap)]
10378            let ordinal = (i + 1) as i32;
10379            rows.push(Row::new(alloc::vec![
10380                Value::Text("spg".into()),
10381                Value::Text("public".into()),
10382                Value::Text(tname.clone()),
10383                Value::Text(col.name.clone()),
10384                Value::Int(ordinal),
10385                Value::Text(if col.nullable {
10386                    "YES".into()
10387                } else {
10388                    "NO".into()
10389                }),
10390                Value::Text(pg_data_type_text(col.ty)),
10391            ]));
10392        }
10393    }
10394    (schema, rows)
10395}
10396
10397/// v7.16.2 — synthesise `information_schema.tables`.
10398fn synth_information_schema_tables(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10399    let schema = alloc::vec![
10400        ColumnSchema::new("table_catalog", DataType::Text, false),
10401        ColumnSchema::new("table_schema", DataType::Text, false),
10402        ColumnSchema::new("table_name", DataType::Text, false),
10403        ColumnSchema::new("table_type", DataType::Text, false),
10404    ];
10405    let mut rows: Vec<Row> = Vec::new();
10406    for tname in cat.table_names() {
10407        rows.push(Row::new(alloc::vec![
10408            Value::Text("spg".into()),
10409            Value::Text("public".into()),
10410            Value::Text(tname.clone()),
10411            Value::Text("BASE TABLE".into()),
10412        ]));
10413    }
10414    (schema, rows)
10415}
10416
10417/// v7.16.2 — synthesise `pg_catalog.pg_class`. Minimum shape
10418/// for psql `\d` / ORM probes: `relname` + `relkind`. Each
10419/// user table emits one row.
10420fn synth_pg_class(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10421    let schema = alloc::vec![
10422        ColumnSchema::new("relname", DataType::Text, false),
10423        ColumnSchema::new("relkind", DataType::Text, false),
10424        ColumnSchema::new("relnamespace", DataType::BigInt, false),
10425    ];
10426    let mut rows: Vec<Row> = Vec::new();
10427    for tname in cat.table_names() {
10428        rows.push(Row::new(alloc::vec![
10429            Value::Text(tname.clone()),
10430            Value::Text("r".into()),
10431            Value::BigInt(2200), // PG's `public` namespace OID
10432        ]));
10433    }
10434    (schema, rows)
10435}
10436
10437/// v7.16.2 — synthesise `pg_catalog.pg_attribute`. Minimum
10438/// shape: `attrelid` (text — SPG has no OID), `attname`,
10439/// `attnum`, `atttypid` (text), `attnotnull`.
10440fn synth_pg_attribute(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10441    let schema = alloc::vec![
10442        ColumnSchema::new("attrelid", DataType::Text, false),
10443        ColumnSchema::new("attname", DataType::Text, false),
10444        ColumnSchema::new("attnum", DataType::Int, false),
10445        ColumnSchema::new("atttypid", DataType::Text, false),
10446        ColumnSchema::new("attnotnull", DataType::Bool, false),
10447    ];
10448    let mut rows: Vec<Row> = Vec::new();
10449    for tname in cat.table_names() {
10450        let Some(t) = cat.get(&tname) else { continue };
10451        for (i, col) in t.schema().columns.iter().enumerate() {
10452            #[allow(clippy::cast_possible_wrap)]
10453            let ordinal = (i + 1) as i32;
10454            rows.push(Row::new(alloc::vec![
10455                Value::Text(tname.clone()),
10456                Value::Text(col.name.clone()),
10457                Value::Int(ordinal),
10458                Value::Text(pg_data_type_text(col.ty)),
10459                Value::Bool(!col.nullable),
10460            ]));
10461        }
10462    }
10463    (schema, rows)
10464}
10465
10466/// v7.17.0 Phase 3.P0-50 — synthesise `pg_catalog.pg_type`. The
10467/// returned rows cover every built-in scalar / array type sqlx,
10468/// SQLAlchemy, Diesel and pgAdmin look up at compile / connect
10469/// time. PG-canonical schema columns we expose:
10470///   * oid           — type OID (the lookup key sqlx uses)
10471///   * typname       — canonical type name (`int4`, `text`, …)
10472///   * typlen        — width in bytes (-1 for var-length)
10473///   * typtype       — `b`ase / `c`omposite / `e`num / etc.
10474///   * typcategory   — PG type category single-char
10475///   * typelem       — element OID for arrays (0 otherwise)
10476///   * typarray      — array-type OID (0 if no array type)
10477///   * typnamespace  — schema OID (always `public` = 2200)
10478///
10479/// Other pg_type columns (typowner, typinput/typoutput, etc.)
10480/// land in follow-up work — sqlx encoders don't query them at
10481/// connect time.
10482fn synth_pg_type(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10483    let schema = alloc::vec![
10484        ColumnSchema::new("oid", DataType::BigInt, false),
10485        ColumnSchema::new("typname", DataType::Text, false),
10486        ColumnSchema::new("typlen", DataType::SmallInt, false),
10487        ColumnSchema::new("typtype", DataType::Text, false),
10488        ColumnSchema::new("typcategory", DataType::Text, false),
10489        ColumnSchema::new("typelem", DataType::BigInt, false),
10490        ColumnSchema::new("typarray", DataType::BigInt, false),
10491        ColumnSchema::new("typnamespace", DataType::BigInt, false),
10492    ];
10493    // (oid, name, len, type, cat, elem, array_oid). PG OID
10494    // numbers come straight from `pg_type.dat`.
10495    let scalars: &[(i64, &str, i16, &str, &str, i64, i64)] = &[
10496        // bool
10497        (16, "bool", 1, "b", "B", 0, 1000),
10498        (17, "bytea", -1, "b", "U", 0, 1001),
10499        (18, "char", 1, "b", "S", 0, 1002),
10500        (19, "name", 64, "b", "S", 0, 1003),
10501        (20, "int8", 8, "b", "N", 0, 1016),
10502        (21, "int2", 2, "b", "N", 0, 1005),
10503        (23, "int4", 4, "b", "N", 0, 1007),
10504        (24, "regproc", 4, "b", "N", 0, 1008),
10505        (25, "text", -1, "b", "S", 0, 1009),
10506        (26, "oid", 4, "b", "N", 0, 1028),
10507        (114, "json", -1, "b", "U", 0, 199),
10508        (142, "xml", -1, "b", "U", 0, 143),
10509        (700, "float4", 4, "b", "N", 0, 1021),
10510        (701, "float8", 8, "b", "N", 0, 1022),
10511        (650, "cidr", -1, "b", "I", 0, 651),
10512        (869, "inet", -1, "b", "I", 0, 1041),
10513        (829, "macaddr", 6, "b", "U", 0, 1040),
10514        (1042, "bpchar", -1, "b", "S", 0, 1014),
10515        (1043, "varchar", -1, "b", "S", 0, 1015),
10516        (1082, "date", 4, "b", "D", 0, 1182),
10517        (1083, "time", 8, "b", "D", 0, 1183),
10518        (1114, "timestamp", 8, "b", "D", 0, 1115),
10519        (1184, "timestamptz", 8, "b", "D", 0, 1185),
10520        (1186, "interval", 16, "b", "T", 0, 1187),
10521        (1266, "timetz", 12, "b", "D", 0, 1270),
10522        (1700, "numeric", -1, "b", "N", 0, 1231),
10523        (790, "money", 8, "b", "N", 0, 791),
10524        (2950, "uuid", 16, "b", "U", 0, 2951),
10525        (3802, "jsonb", -1, "b", "U", 0, 3807),
10526        (3614, "tsvector", -1, "b", "U", 0, 3643),
10527        (3615, "tsquery", -1, "b", "U", 0, 3645),
10528        // hstore + range types — typcategory 'U' (user) / 'R' (range).
10529        (3908, "tstzrange", -1, "r", "R", 0, 3909),
10530        (3910, "tsrange", -1, "r", "R", 0, 3911),
10531        (3904, "int4range", -1, "r", "R", 0, 3905),
10532        (3926, "int8range", -1, "r", "R", 0, 3927),
10533        (3906, "numrange", -1, "r", "R", 0, 3907),
10534        (3912, "daterange", -1, "r", "R", 0, 3913),
10535    ];
10536    // Array companion types share the typelem / typcategory='A'.
10537    // We emit just the array OIDs the scalars reference.
10538    let arrays: &[(i64, &str, i64)] = &[
10539        (1000, "_bool", 16),
10540        (1001, "_bytea", 17),
10541        (1002, "_char", 18),
10542        (1003, "_name", 19),
10543        (1016, "_int8", 20),
10544        (1005, "_int2", 21),
10545        (1007, "_int4", 23),
10546        (1008, "_regproc", 24),
10547        (1009, "_text", 25),
10548        (1028, "_oid", 26),
10549        (199, "_json", 114),
10550        (143, "_xml", 142),
10551        (1021, "_float4", 700),
10552        (1022, "_float8", 701),
10553        (651, "_cidr", 650),
10554        (1041, "_inet", 869),
10555        (1040, "_macaddr", 829),
10556        (1014, "_bpchar", 1042),
10557        (1015, "_varchar", 1043),
10558        (1182, "_date", 1082),
10559        (1183, "_time", 1083),
10560        (1115, "_timestamp", 1114),
10561        (1185, "_timestamptz", 1184),
10562        (1187, "_interval", 1186),
10563        (1270, "_timetz", 1266),
10564        (1231, "_numeric", 1700),
10565        (791, "_money", 790),
10566        (2951, "_uuid", 2950),
10567        (3807, "_jsonb", 3802),
10568        (3643, "_tsvector", 3614),
10569        (3645, "_tsquery", 3615),
10570    ];
10571    let mut rows: Vec<Row> = Vec::with_capacity(scalars.len() + arrays.len());
10572    for &(oid, name, len, ty, cat, elem, arr) in scalars {
10573        rows.push(Row::new(alloc::vec![
10574            Value::BigInt(oid),
10575            Value::Text(name.into()),
10576            Value::SmallInt(len),
10577            Value::Text(ty.into()),
10578            Value::Text(cat.into()),
10579            Value::BigInt(elem),
10580            Value::BigInt(arr),
10581            Value::BigInt(2200),
10582        ]));
10583    }
10584    for &(oid, name, elem) in arrays {
10585        rows.push(Row::new(alloc::vec![
10586            Value::BigInt(oid),
10587            Value::Text(name.into()),
10588            Value::SmallInt(-1),
10589            Value::Text("b".into()),
10590            Value::Text("A".into()),
10591            Value::BigInt(elem),
10592            Value::BigInt(0),
10593            Value::BigInt(2200),
10594        ]));
10595    }
10596    (schema, rows)
10597}
10598
10599/// v7.17.0 Phase 3.P0-51 — synthesise `pg_catalog.pg_proc`. ORM /
10600/// pgAdmin probes look up functions by name; SPG synthesises rows
10601/// for the built-in scalar functions / aggregates / window funcs
10602/// the engine actually dispatches. SPG has no user-defined
10603/// functions yet so the table is a stable static list.
10604///
10605/// Schema columns exposed:
10606///   * oid (BigInt) — function OID from PG's pg_proc.dat
10607///   * proname (Text) — function name (lowercase)
10608///   * pronamespace (BigInt) — 11 (`pg_catalog`)
10609///   * prokind (Text) — 'f' function, 'a' aggregate, 'w' window
10610///   * pronargs (SmallInt) — declared arg count (-1 for variadic)
10611///   * prorettype (BigInt) — return type OID (matches synth_pg_type)
10612fn synth_pg_proc(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10613    let schema = alloc::vec![
10614        ColumnSchema::new("oid", DataType::BigInt, false),
10615        ColumnSchema::new("proname", DataType::Text, false),
10616        ColumnSchema::new("pronamespace", DataType::BigInt, false),
10617        ColumnSchema::new("prokind", DataType::Text, false),
10618        ColumnSchema::new("pronargs", DataType::Int, false),
10619        ColumnSchema::new("prorettype", DataType::BigInt, false),
10620    ];
10621    // (oid, name, kind, nargs, rettype). OIDs taken from PG's
10622    // pg_proc.dat for the common subset.
10623    let funcs: &[(i64, &str, &str, i32, i64)] = &[
10624        // Scalar functions.
10625        (1318, "length", "f", 1, 23),
10626        (871, "upper", "f", 1, 25),
10627        (870, "lower", "f", 1, 25),
10628        (936, "substring", "f", 3, 25),
10629        (937, "substring", "f", 2, 25),
10630        (3055, "btrim", "f", 1, 25),
10631        (885, "btrim", "f", 2, 25),
10632        (3056, "ltrim", "f", 1, 25),
10633        (875, "ltrim", "f", 2, 25),
10634        (3057, "rtrim", "f", 1, 25),
10635        (876, "rtrim", "f", 2, 25),
10636        (1397, "abs", "f", 1, 23),
10637        (1396, "abs", "f", 1, 20),
10638        (1606, "round", "f", 1, 1700),
10639        (1707, "round", "f", 2, 1700),
10640        (2308, "ceil", "f", 1, 701),
10641        (2309, "ceiling", "f", 1, 701),
10642        (2310, "floor", "f", 1, 701),
10643        (1376, "sqrt", "f", 1, 701),
10644        (1369, "ln", "f", 1, 701),
10645        (1373, "exp", "f", 1, 701),
10646        (1368, "power", "f", 2, 701),
10647        (2228, "random", "f", 0, 701),
10648        // Date / time.
10649        (1299, "now", "f", 0, 1184),
10650        (1274, "current_timestamp", "f", 0, 1184),
10651        (1140, "current_date", "f", 0, 1082),
10652        (2050, "current_time", "f", 0, 1083),
10653        (1158, "date_trunc", "f", 2, 1184),
10654        (1171, "date_part", "f", 2, 701),
10655        (1172, "age", "f", 1, 1186),
10656        (936, "to_char", "f", 2, 25),
10657        // Session / introspection.
10658        (861, "current_database", "f", 0, 19),
10659        (745, "current_user", "f", 0, 19),
10660        (745, "session_user", "f", 0, 19),
10661        (1402, "current_schema", "f", 0, 19),
10662        // String concat / format.
10663        (3058, "concat", "f", -1, 25),
10664        (3059, "concat_ws", "f", -1, 25),
10665        (3539, "format", "f", -1, 25),
10666        // Type introspection.
10667        (2877, "pg_typeof", "f", 1, 2206),
10668        // JSON.
10669        (3198, "json_build_object", "f", -1, 114),
10670        (3199, "jsonb_build_object", "f", -1, 3802),
10671        (3271, "json_build_array", "f", -1, 114),
10672        (3272, "jsonb_build_array", "f", -1, 3802),
10673        // UUID.
10674        (3253, "gen_random_uuid", "f", 0, 2950),
10675        (3252, "uuid_generate_v4", "f", 0, 2950),
10676        // Aggregates.
10677        (2147, "count", "a", 0, 20),
10678        (2803, "count", "a", -1, 20),
10679        (2116, "max", "a", 1, 23),
10680        (2132, "min", "a", 1, 23),
10681        (2108, "sum", "a", 1, 20),
10682        (2100, "avg", "a", 1, 1700),
10683        (2517, "string_agg", "a", 2, 25),
10684        (2747, "array_agg", "a", 1, 1009),
10685        (2517, "bool_and", "a", 1, 16),
10686        (2518, "bool_or", "a", 1, 16),
10687        (2519, "every", "a", 1, 16),
10688        // Window functions.
10689        (3100, "row_number", "w", 0, 20),
10690        (3101, "rank", "w", 0, 20),
10691        (3102, "dense_rank", "w", 0, 20),
10692        (3103, "percent_rank", "w", 0, 701),
10693        (3104, "cume_dist", "w", 0, 701),
10694        (3105, "lag", "w", -1, 2283),
10695        (3106, "lead", "w", -1, 2283),
10696        (3107, "first_value", "w", 1, 2283),
10697        (3108, "last_value", "w", 1, 2283),
10698        (3109, "nth_value", "w", 2, 2283),
10699    ];
10700    let mut rows: Vec<Row> = Vec::with_capacity(funcs.len());
10701    for &(oid, name, kind, nargs, rettype) in funcs {
10702        rows.push(Row::new(alloc::vec![
10703            Value::BigInt(oid),
10704            Value::Text(name.into()),
10705            Value::BigInt(11),
10706            Value::Text(kind.into()),
10707            Value::Int(nargs),
10708            Value::BigInt(rettype),
10709        ]));
10710    }
10711    (schema, rows)
10712}
10713
10714/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.user`. MySQL admin
10715/// queries (`SELECT user, host FROM mysql.user`) probe this at
10716/// connect time to list accounts. SPG ships one row per
10717/// UserStore entry plus a synthetic `root` superuser row for
10718/// MySQL bootstrap compat.
10719fn synth_mysql_user(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
10720    let schema = alloc::vec![
10721        ColumnSchema::new("user", DataType::Text, false),
10722        ColumnSchema::new("host", DataType::Text, false),
10723        ColumnSchema::new("select_priv", DataType::Text, false),
10724    ];
10725    let mut rows: Vec<Row> = Vec::new();
10726    rows.push(Row::new(alloc::vec![
10727        Value::Text("root".into()),
10728        Value::Text("localhost".into()),
10729        Value::Text("Y".into()),
10730    ]));
10731    for (name, _) in engine.users.iter() {
10732        if name != "root" {
10733            rows.push(Row::new(alloc::vec![
10734                Value::Text(name.to_string()),
10735                Value::Text("%".into()),
10736                Value::Text("Y".into()),
10737            ]));
10738        }
10739    }
10740    (schema, rows)
10741}
10742
10743/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.db`. The
10744/// per-database privileges table. SPG is single-database so the
10745/// table surfaces one row per declared user with full privileges
10746/// on the canonical `postgres` database.
10747fn synth_mysql_db() -> (Vec<ColumnSchema>, Vec<Row>) {
10748    let schema = alloc::vec![
10749        ColumnSchema::new("host", DataType::Text, false),
10750        ColumnSchema::new("db", DataType::Text, false),
10751        ColumnSchema::new("user", DataType::Text, false),
10752        ColumnSchema::new("select_priv", DataType::Text, false),
10753    ];
10754    let rows = alloc::vec![Row::new(alloc::vec![
10755        Value::Text("localhost".into()),
10756        Value::Text("postgres".into()),
10757        Value::Text("root".into()),
10758        Value::Text("Y".into()),
10759    ])];
10760    (schema, rows)
10761}
10762
10763/// v7.17.0 Phase 3.P0-63 — synthesise
10764/// `information_schema.KEY_COLUMN_USAGE`. ORM migration tools
10765/// (Alembic, Sequelize, TypeORM) walk this view to discover FK
10766/// relationships in MySQL-flavoured introspection queries.
10767///
10768/// Schema columns exposed:
10769///   * CONSTRAINT_NAME (Text)
10770///   * TABLE_NAME (Text)
10771///   * COLUMN_NAME (Text)
10772///   * ORDINAL_POSITION (Int)
10773///   * REFERENCED_TABLE_NAME (Text) — empty for non-FK rows
10774///   * REFERENCED_COLUMN_NAME (Text) — empty for non-FK rows
10775fn synth_info_key_column_usage(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10776    let schema = alloc::vec![
10777        ColumnSchema::new("constraint_name", DataType::Text, false),
10778        ColumnSchema::new("table_name", DataType::Text, false),
10779        ColumnSchema::new("column_name", DataType::Text, false),
10780        ColumnSchema::new("ordinal_position", DataType::Int, false),
10781        ColumnSchema::new("referenced_table_name", DataType::Text, false),
10782        ColumnSchema::new("referenced_column_name", DataType::Text, false),
10783    ];
10784    let mut rows: Vec<Row> = Vec::new();
10785    for tname in cat.table_names() {
10786        let Some(t) = cat.get(&tname) else { continue };
10787        let cols = &t.schema().columns;
10788        let col_name_at = |pos: usize| -> String {
10789            cols.get(pos)
10790                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
10791        };
10792        // FKs.
10793        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
10794            let conname = fk
10795                .name
10796                .clone()
10797                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
10798            for (i, (&local, &parent)) in fk
10799                .local_columns
10800                .iter()
10801                .zip(fk.parent_columns.iter())
10802                .enumerate()
10803            {
10804                let parent_name = cat
10805                    .get(&fk.parent_table)
10806                    .and_then(|pt| pt.schema().columns.get(parent).map(|c| c.name.clone()))
10807                    .unwrap_or_else(|| alloc::format!("col{parent}"));
10808                #[allow(clippy::cast_possible_wrap)]
10809                let ordinal = (i + 1) as i32;
10810                rows.push(Row::new(alloc::vec![
10811                    Value::Text(conname.clone()),
10812                    Value::Text(tname.clone()),
10813                    Value::Text(col_name_at(local)),
10814                    Value::Int(ordinal),
10815                    Value::Text(fk.parent_table.clone()),
10816                    Value::Text(parent_name),
10817                ]));
10818            }
10819        }
10820        // PK / composite UC entries.
10821        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
10822            let conname = if uc.is_primary_key {
10823                alloc::format!("{}_pkey", tname)
10824            } else {
10825                alloc::format!("{}_uniq{ci}", tname)
10826            };
10827            for (i, &local) in uc.columns.iter().enumerate() {
10828                #[allow(clippy::cast_possible_wrap)]
10829                let ordinal = (i + 1) as i32;
10830                rows.push(Row::new(alloc::vec![
10831                    Value::Text(conname.clone()),
10832                    Value::Text(tname.clone()),
10833                    Value::Text(col_name_at(local)),
10834                    Value::Int(ordinal),
10835                    Value::Text(String::new()),
10836                    Value::Text(String::new()),
10837                ]));
10838            }
10839        }
10840    }
10841    (schema, rows)
10842}
10843
10844/// v7.17.0 Phase 3.P0-64 — synthesise
10845/// `information_schema.REFERENTIAL_CONSTRAINTS`. One row per FK.
10846fn synth_info_referential_constraints(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10847    let schema = alloc::vec![
10848        ColumnSchema::new("constraint_name", DataType::Text, false),
10849        ColumnSchema::new("table_name", DataType::Text, false),
10850        ColumnSchema::new("referenced_table_name", DataType::Text, false),
10851        ColumnSchema::new("update_rule", DataType::Text, false),
10852        ColumnSchema::new("delete_rule", DataType::Text, false),
10853    ];
10854    fn rule_name(a: spg_storage::FkAction) -> &'static str {
10855        match a {
10856            spg_storage::FkAction::Cascade => "CASCADE",
10857            spg_storage::FkAction::SetNull => "SET NULL",
10858            spg_storage::FkAction::SetDefault => "SET DEFAULT",
10859            spg_storage::FkAction::Restrict => "RESTRICT",
10860            spg_storage::FkAction::NoAction => "NO ACTION",
10861        }
10862    }
10863    let mut rows: Vec<Row> = Vec::new();
10864    for tname in cat.table_names() {
10865        let Some(t) = cat.get(&tname) else { continue };
10866        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
10867            let conname = fk
10868                .name
10869                .clone()
10870                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
10871            rows.push(Row::new(alloc::vec![
10872                Value::Text(conname),
10873                Value::Text(tname.clone()),
10874                Value::Text(fk.parent_table.clone()),
10875                Value::Text(rule_name(fk.on_update).into()),
10876                Value::Text(rule_name(fk.on_delete).into()),
10877            ]));
10878        }
10879    }
10880    (schema, rows)
10881}
10882
10883/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.STATISTICS`.
10884/// One row per (index × column) — admin tools walk this to
10885/// surface index-cardinality estimates.
10886fn synth_info_statistics(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10887    let schema = alloc::vec![
10888        ColumnSchema::new("table_name", DataType::Text, false),
10889        ColumnSchema::new("index_name", DataType::Text, false),
10890        ColumnSchema::new("column_name", DataType::Text, false),
10891        ColumnSchema::new("seq_in_index", DataType::Int, false),
10892        ColumnSchema::new("non_unique", DataType::Int, false),
10893        ColumnSchema::new("index_type", DataType::Text, false),
10894    ];
10895    let mut rows: Vec<Row> = Vec::new();
10896    for tname in cat.table_names() {
10897        let Some(t) = cat.get(&tname) else { continue };
10898        for idx in t.indices() {
10899            let col = t
10900                .schema()
10901                .columns
10902                .get(idx.column_position)
10903                .map_or("?".into(), |c| c.name.clone());
10904            rows.push(Row::new(alloc::vec![
10905                Value::Text(tname.clone()),
10906                Value::Text(idx.name.clone()),
10907                Value::Text(col),
10908                Value::Int(1),
10909                Value::Int(i32::from(!idx.is_unique)),
10910                Value::Text("BTREE".into()),
10911            ]));
10912        }
10913    }
10914    (schema, rows)
10915}
10916
10917/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.ROUTINES`.
10918/// SPG has no user-defined functions in v7.17 so the surface is
10919/// always empty; admin tools just need the table to exist.
10920fn synth_info_routines() -> (Vec<ColumnSchema>, Vec<Row>) {
10921    let schema = alloc::vec![
10922        ColumnSchema::new("routine_name", DataType::Text, false),
10923        ColumnSchema::new("routine_type", DataType::Text, false),
10924        ColumnSchema::new("data_type", DataType::Text, false),
10925    ];
10926    (schema, Vec::new())
10927}
10928
10929/// v7.17.0 Phase 3.P0-54 — synthesise `pg_catalog.pg_constraint`.
10930/// ORM compilers (Diesel, sea-orm) and admin tools probe this for
10931/// FK / UNIQUE / PK / CHECK definitions to surface relationship
10932/// graphs and validation rules. SPG ships one row per
10933/// uniqueness constraint + foreign key declared in the catalog.
10934///
10935/// Schema columns exposed:
10936///   * conname (Text) — constraint name (synthetic when anonymous)
10937///   * contype (Text) — `p` PK, `u` UNIQUE, `f` FK, `c` CHECK
10938///   * conrelid (Text) — owner table name
10939///   * confrelid (Text) — referenced parent table (FK only;
10940///     empty string otherwise)
10941///   * conkey (Text) — comma-separated column names
10942///   * confkey (Text) — comma-separated parent column names (FK only)
10943fn synth_pg_constraint(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10944    let schema = alloc::vec![
10945        ColumnSchema::new("conname", DataType::Text, false),
10946        ColumnSchema::new("contype", DataType::Text, false),
10947        ColumnSchema::new("conrelid", DataType::Text, false),
10948        ColumnSchema::new("confrelid", DataType::Text, false),
10949        ColumnSchema::new("conkey", DataType::Text, false),
10950        ColumnSchema::new("confkey", DataType::Text, false),
10951    ];
10952    let mut rows: Vec<Row> = Vec::new();
10953    for tname in cat.table_names() {
10954        let Some(t) = cat.get(&tname) else { continue };
10955        let cols = &t.schema().columns;
10956        let col_name_at = |pos: usize| -> String {
10957            cols.get(pos)
10958                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
10959        };
10960        // Uniqueness constraints (composite UNIQUE / PRIMARY KEY).
10961        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
10962            let kind = if uc.is_primary_key { "p" } else { "u" };
10963            let conname = if uc.is_primary_key {
10964                alloc::format!("{}_pkey", tname)
10965            } else {
10966                alloc::format!("{}_uniq{ci}", tname)
10967            };
10968            let conkey: Vec<String> = uc.columns.iter().map(|&p| col_name_at(p)).collect();
10969            rows.push(Row::new(alloc::vec![
10970                Value::Text(conname),
10971                Value::Text(kind.into()),
10972                Value::Text(tname.clone()),
10973                Value::Text(String::new()),
10974                Value::Text(conkey.join(",")),
10975                Value::Text(String::new()),
10976            ]));
10977        }
10978        // Single-column PK / UNIQUE indexes that have no
10979        // matching entry in `uniqueness_constraints` (the engine
10980        // creates only the BTree index for the bare-column case;
10981        // composite forms ride the UC path above).
10982        for idx in t.indices() {
10983            if !idx.is_unique {
10984                continue;
10985            }
10986            let is_primary = idx.name.ends_with("_pkey");
10987            let conname = idx.name.clone();
10988            let kind = if is_primary { "p" } else { "u" };
10989            let col_name = col_name_at(idx.column_position);
10990            // Skip if already emitted via the UC loop above (same
10991            // tuple shape — single-column).
10992            let already = t
10993                .schema()
10994                .uniqueness_constraints
10995                .iter()
10996                .any(|uc| uc.columns.len() == 1 && uc.columns[0] == idx.column_position);
10997            if already {
10998                continue;
10999            }
11000            rows.push(Row::new(alloc::vec![
11001                Value::Text(conname),
11002                Value::Text(kind.into()),
11003                Value::Text(tname.clone()),
11004                Value::Text(String::new()),
11005                Value::Text(col_name),
11006                Value::Text(String::new()),
11007            ]));
11008        }
11009        // Foreign keys.
11010        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11011            let conname = fk
11012                .name
11013                .clone()
11014                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11015            let conkey: Vec<String> = fk.local_columns.iter().map(|&p| col_name_at(p)).collect();
11016            // Parent column names: look up the parent table's
11017            // schema if it exists; otherwise emit positions.
11018            let confkey: Vec<String> = if let Some(parent) = cat.get(&fk.parent_table) {
11019                fk.parent_columns
11020                    .iter()
11021                    .map(|&p| {
11022                        parent
11023                            .schema()
11024                            .columns
11025                            .get(p)
11026                            .map_or_else(|| alloc::format!("col{p}"), |c| c.name.clone())
11027                    })
11028                    .collect()
11029            } else {
11030                fk.parent_columns
11031                    .iter()
11032                    .map(|p| alloc::format!("col{p}"))
11033                    .collect()
11034            };
11035            rows.push(Row::new(alloc::vec![
11036                Value::Text(conname),
11037                Value::Text("f".into()),
11038                Value::Text(tname.clone()),
11039                Value::Text(fk.parent_table.clone()),
11040                Value::Text(conkey.join(",")),
11041                Value::Text(confkey.join(",")),
11042            ]));
11043        }
11044    }
11045    (schema, rows)
11046}
11047
11048/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_database`.
11049/// SPG is single-database so we surface a single row keyed on the
11050/// canonical `postgres` database name (matching what every PG
11051/// admin tool's startup screen expects to find).
11052fn synth_pg_database(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11053    let schema = alloc::vec![
11054        ColumnSchema::new("oid", DataType::BigInt, false),
11055        ColumnSchema::new("datname", DataType::Text, false),
11056        ColumnSchema::new("datdba", DataType::BigInt, false),
11057        ColumnSchema::new("encoding", DataType::Int, false),
11058        ColumnSchema::new("datcollate", DataType::Text, false),
11059    ];
11060    let rows = alloc::vec![Row::new(alloc::vec![
11061        Value::BigInt(16384),
11062        Value::Text("postgres".into()),
11063        Value::BigInt(10),
11064        Value::Int(6), // UTF8
11065        Value::Text("en_US.UTF-8".into()),
11066    ])];
11067    (schema, rows)
11068}
11069
11070/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_roles`. PG's
11071/// pg_roles is a view over pg_authid showing all roles. SPG ships
11072/// one row per declared user from the engine's UserStore so admin
11073/// tool startup screens can populate.
11074fn synth_pg_roles(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11075    let schema = alloc::vec![
11076        ColumnSchema::new("oid", DataType::BigInt, false),
11077        ColumnSchema::new("rolname", DataType::Text, false),
11078        ColumnSchema::new("rolsuper", DataType::Bool, false),
11079        ColumnSchema::new("rolinherit", DataType::Bool, false),
11080        ColumnSchema::new("rolcanlogin", DataType::Bool, false),
11081    ];
11082    let mut rows: Vec<Row> = Vec::new();
11083    let oid: i64 = 10;
11084    for (i, (name, _)) in engine.users.iter().enumerate() {
11085        rows.push(Row::new(alloc::vec![
11086            Value::BigInt(oid + (i as i64) + 1),
11087            Value::Text(name.to_string()),
11088            Value::Bool(false),
11089            Value::Bool(true),
11090            Value::Bool(true),
11091        ]));
11092    }
11093    // Always include `postgres` as the bootstrap superuser if not
11094    // already present — admin tools probe for it.
11095    if !rows
11096        .iter()
11097        .any(|r| matches!(&r.values[1], Value::Text(s) if s == "postgres"))
11098    {
11099        rows.insert(
11100            0,
11101            Row::new(alloc::vec![
11102                Value::BigInt(10),
11103                Value::Text("postgres".into()),
11104                Value::Bool(true),
11105                Value::Bool(true),
11106                Value::Bool(true),
11107            ]),
11108        );
11109    }
11110    (schema, rows)
11111}
11112
11113/// v7.17.0 Phase 3.P0-56 — synthesise `pg_catalog.pg_views`. PG's
11114/// pg_views is a view listing every catalog view; SPG ships one
11115/// row per declared view + its definition text.
11116fn synth_pg_views(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11117    let schema = alloc::vec![
11118        ColumnSchema::new("schemaname", DataType::Text, false),
11119        ColumnSchema::new("viewname", DataType::Text, false),
11120        ColumnSchema::new("definition", DataType::Text, false),
11121    ];
11122    let mut rows: Vec<Row> = Vec::new();
11123    for (name, def) in cat.views() {
11124        rows.push(Row::new(alloc::vec![
11125            Value::Text("public".into()),
11126            Value::Text(name.clone()),
11127            Value::Text(def.body.clone()),
11128        ]));
11129    }
11130    (schema, rows)
11131}
11132
11133/// v7.17.0 Phase 3.P0-57 — synthesise `pg_catalog.pg_settings`. ORM
11134/// connection-checkers (sqlx pre-flight, Diesel migrator) and admin
11135/// tools read `pg_settings` to discover server-side configuration.
11136/// SPG surfaces every session_param + a small set of canonical PG
11137/// defaults so the pre-flight queries match.
11138fn synth_pg_settings(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11139    let schema = alloc::vec![
11140        ColumnSchema::new("name", DataType::Text, false),
11141        ColumnSchema::new("setting", DataType::Text, false),
11142        ColumnSchema::new("category", DataType::Text, false),
11143    ];
11144    let mut rows: Vec<Row> = Vec::new();
11145    // Canonical defaults every admin tool expects to find.
11146    let defaults: &[(&str, &str, &str)] = &[
11147        ("server_version", "16.0 (spg)", "Preset Options"),
11148        ("server_encoding", "UTF8", "Client Connection Defaults"),
11149        ("client_encoding", "UTF8", "Client Connection Defaults"),
11150        ("DateStyle", "ISO, MDY", "Client Connection Defaults"),
11151        ("TimeZone", "UTC", "Client Connection Defaults"),
11152        ("standard_conforming_strings", "on", "Compatibility"),
11153        ("integer_datetimes", "on", "Compatibility"),
11154        ("max_connections", "100", "Connections and Authentication"),
11155    ];
11156    for &(name, val, cat) in defaults {
11157        rows.push(Row::new(alloc::vec![
11158            Value::Text(name.into()),
11159            Value::Text(val.into()),
11160            Value::Text(cat.into()),
11161        ]));
11162    }
11163    // Session-set params override the static defaults.
11164    for (k, v) in &engine.session_params {
11165        if !defaults
11166            .iter()
11167            .any(|(n, _, _)| (*n).eq_ignore_ascii_case(k))
11168        {
11169            rows.push(Row::new(alloc::vec![
11170                Value::Text(k.clone()),
11171                Value::Text(v.clone()),
11172                Value::Text("Session".into()),
11173            ]));
11174        }
11175    }
11176    (schema, rows)
11177}
11178
11179/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_indexes`.
11180/// PG's pg_indexes is a real view on pg_index + pg_class + pg_attribute.
11181/// SPG ships it as a synthesised flat table so admin tools (pgAdmin,
11182/// DataGrip) can list indexes by tablename without joining four catalogs.
11183///
11184/// Schema columns exposed:
11185///   * schemaname (Text) — always `public`
11186///   * tablename (Text)
11187///   * indexname (Text)
11188///   * indexdef (Text) — best-effort CREATE INDEX DDL
11189fn synth_pg_indexes(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11190    let schema = alloc::vec![
11191        ColumnSchema::new("schemaname", DataType::Text, false),
11192        ColumnSchema::new("tablename", DataType::Text, false),
11193        ColumnSchema::new("indexname", DataType::Text, false),
11194        ColumnSchema::new("indexdef", DataType::Text, false),
11195    ];
11196    let mut rows: Vec<Row> = Vec::new();
11197    for tname in cat.table_names() {
11198        let Some(t) = cat.get(&tname) else { continue };
11199        for idx in t.indices() {
11200            let col_name = t
11201                .schema()
11202                .columns
11203                .get(idx.column_position)
11204                .map_or("?".into(), |c| c.name.clone());
11205            let unique_kw = if idx.is_unique { "UNIQUE " } else { "" };
11206            let indexdef = alloc::format!(
11207                "CREATE {unique_kw}INDEX {} ON public.{} ({})",
11208                idx.name,
11209                tname,
11210                col_name
11211            );
11212            rows.push(Row::new(alloc::vec![
11213                Value::Text("public".into()),
11214                Value::Text(tname.clone()),
11215                Value::Text(idx.name.clone()),
11216                Value::Text(indexdef),
11217            ]));
11218        }
11219    }
11220    (schema, rows)
11221}
11222
11223/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_index`. The
11224/// "raw" pg_index catalog used by PG-internal tooling for index
11225/// flags and ordinal information. SPG ships the columns ORM probes
11226/// actually filter on.
11227///
11228/// Schema columns exposed:
11229///   * indexrelid (BigInt) — index OID (synthetic = position+1)
11230///   * indrelid (BigInt) — table OID (synthetic = position+1)
11231///   * indnatts (Int) — number of indexed columns
11232///   * indisunique (Bool)
11233///   * indisprimary (Bool)
11234fn synth_pg_index_raw(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11235    let schema = alloc::vec![
11236        ColumnSchema::new("indexrelid", DataType::BigInt, false),
11237        ColumnSchema::new("indrelid", DataType::BigInt, false),
11238        ColumnSchema::new("indnatts", DataType::Int, false),
11239        ColumnSchema::new("indisunique", DataType::Bool, false),
11240        ColumnSchema::new("indisprimary", DataType::Bool, false),
11241    ];
11242    let mut rows: Vec<Row> = Vec::new();
11243    let mut idx_oid: i64 = 100_000;
11244    for (table_idx, tname) in cat.table_names().iter().enumerate() {
11245        let Some(t) = cat.get(tname) else { continue };
11246        for idx in t.indices() {
11247            idx_oid += 1;
11248            #[allow(clippy::cast_possible_wrap)]
11249            let nattrs = (1 + idx.extra_column_positions.len()) as i32;
11250            // is_primary: SPG / PG flag the primary via the
11251            // index name convention `<table>_pkey`.
11252            let is_primary = idx.name.ends_with("_pkey");
11253            rows.push(Row::new(alloc::vec![
11254                Value::BigInt(idx_oid),
11255                Value::BigInt((table_idx + 1) as i64),
11256                Value::Int(nattrs),
11257                Value::Bool(idx.is_unique),
11258                Value::Bool(is_primary),
11259            ]));
11260        }
11261    }
11262    (schema, rows)
11263}
11264
11265/// v7.17.0 Phase 3.P0-52 — synthesise `pg_catalog.pg_namespace`.
11266/// SPG is single-schema so we expose the canonical PG schemas:
11267/// `public` (user-facing), `pg_catalog` (built-in), and
11268/// `information_schema` (PG meta).
11269fn synth_pg_namespace(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11270    let schema = alloc::vec![
11271        ColumnSchema::new("oid", DataType::BigInt, false),
11272        ColumnSchema::new("nspname", DataType::Text, false),
11273        ColumnSchema::new("nspowner", DataType::BigInt, false),
11274    ];
11275    let rows = alloc::vec![
11276        Row::new(alloc::vec![
11277            Value::BigInt(11),
11278            Value::Text("pg_catalog".into()),
11279            Value::BigInt(10),
11280        ]),
11281        Row::new(alloc::vec![
11282            Value::BigInt(2200),
11283            Value::Text("public".into()),
11284            Value::BigInt(10),
11285        ]),
11286        Row::new(alloc::vec![
11287            Value::BigInt(13000),
11288            Value::Text("information_schema".into()),
11289            Value::BigInt(10),
11290        ]),
11291    ];
11292    (schema, rows)
11293}
11294
11295/// v7.16.2 — drop the synthesised meta view into the enriched
11296/// catalog so the regular FROM-resolution path can see it.
11297fn materialise_meta_view(
11298    catalog: &mut Catalog,
11299    name: &str,
11300    columns: Vec<ColumnSchema>,
11301    rows: Vec<Row>,
11302) -> Result<(), EngineError> {
11303    let schema = TableSchema::new(name.to_string(), columns);
11304    catalog.create_table(schema).map_err(EngineError::Storage)?;
11305    let table = catalog
11306        .get_mut(name)
11307        .expect("just-created meta view must exist");
11308    for row in rows {
11309        table.insert(row).map_err(EngineError::Storage)?;
11310    }
11311    Ok(())
11312}
11313
11314/// v7.16.2 — true when the SELECT statement references any
11315/// `__spg_info_*` or `__spg_pg_*` synthetic table name (the
11316/// parser produces these for `information_schema.X` /
11317/// `pg_catalog.X`). Used by `exec_select_cancel` to short-
11318/// circuit into the meta-view materialisation path.
11319/// v7.17.0 Phase 1.2 — append the names of any catalog-known
11320/// views referenced by `tref` to `into`. Helper for
11321/// `Engine::expand_views_in_select`. A view that's been already
11322/// materialised as a table (e.g. via the synthetic CTE pass for
11323/// SELECT FROM v) is skipped — the table form wins so the
11324/// recursive exec_select_cancel call inside exec_with_ctes
11325/// doesn't re-expand and trigger the CTE-shadow guard.
11326fn collect_view_refs(
11327    tref: &spg_sql::ast::TableRef,
11328    cat: &spg_storage::Catalog,
11329    into: &mut Vec<String>,
11330) {
11331    if cat.views().contains_key(&tref.name)
11332        && cat.get(&tref.name).is_none()
11333        && !into.iter().any(|n| n == &tref.name)
11334    {
11335        into.push(tref.name.clone());
11336    }
11337}
11338
11339fn select_references_meta_view(stmt: &SelectStatement) -> bool {
11340    fn is_meta(name: &str) -> bool {
11341        name.starts_with("__spg_info_")
11342            || name.starts_with("__spg_pg_")
11343            || name.starts_with("__spg_mysql_")
11344    }
11345    if let Some(from) = &stmt.from {
11346        if is_meta(&from.primary.name) {
11347            return true;
11348        }
11349        for j in &from.joins {
11350            if is_meta(&j.table.name) {
11351                return true;
11352            }
11353        }
11354    }
11355    for cte in &stmt.ctes {
11356        if select_references_meta_view(&cte.body) {
11357            return true;
11358        }
11359    }
11360    false
11361}
11362
11363/// v7.16.2 — collect every meta-view name a SELECT touches.
11364/// Returns a deduplicated, sorted list. Caller materialises
11365/// each one into the enriched catalog before re-running the
11366/// SELECT. Walks JOINs, CTEs, and the primary FROM.
11367fn collect_meta_view_names(
11368    stmt: &SelectStatement,
11369    into: &mut alloc::collections::BTreeSet<String>,
11370) {
11371    fn is_meta(name: &str) -> bool {
11372        name.starts_with("__spg_info_")
11373            || name.starts_with("__spg_pg_")
11374            || name.starts_with("__spg_mysql_")
11375    }
11376    if let Some(from) = &stmt.from {
11377        if is_meta(&from.primary.name) {
11378            into.insert(from.primary.name.clone());
11379        }
11380        for j in &from.joins {
11381            if is_meta(&j.table.name) {
11382                into.insert(j.table.name.clone());
11383            }
11384        }
11385    }
11386    for cte in &stmt.ctes {
11387        collect_meta_view_names(&cte.body, into);
11388    }
11389}
11390
11391fn infer_column_types(columns: &[ColumnSchema], rows: &[Row]) -> Vec<ColumnSchema> {
11392    let mut out = columns.to_vec();
11393    for (col_idx, col) in out.iter_mut().enumerate() {
11394        if col.ty != DataType::Text {
11395            continue;
11396        }
11397        let mut inferred: Option<DataType> = None;
11398        let mut all_null = true;
11399        for row in rows {
11400            let Some(v) = row.values.get(col_idx) else {
11401                continue;
11402            };
11403            let ty = match v {
11404                Value::Null => continue,
11405                Value::SmallInt(_) => DataType::SmallInt,
11406                Value::Int(_) => DataType::Int,
11407                Value::BigInt(_) => DataType::BigInt,
11408                Value::Float(_) => DataType::Float,
11409                Value::Bool(_) => DataType::Bool,
11410                Value::Vector(_) => DataType::Vector {
11411                    dim: 0,
11412                    encoding: VecEncoding::F32,
11413                },
11414                _ => DataType::Text,
11415            };
11416            all_null = false;
11417            inferred = Some(match inferred {
11418                None => ty,
11419                Some(prev) if prev == ty => prev,
11420                Some(_) => DataType::Text,
11421            });
11422        }
11423        if let Some(t) = inferred {
11424            col.ty = t;
11425            col.nullable = true;
11426        } else if all_null {
11427            col.nullable = true;
11428        }
11429    }
11430    out
11431}
11432
11433/// v4.26: render a human-readable plan tree for `EXPLAIN <select>`.
11434/// Lines are pushed into `out`; `depth` controls indentation. We
11435/// describe the rewritten SELECT — what the executor *would* do —
11436/// using the engine handle to spot indexed lookups and table shapes.
11437#[allow(clippy::too_many_lines, clippy::format_push_string)]
11438/// v6.2.4 — Walk every line of the rendered plan tree and append
11439/// per-operator stats. Lines that name a known operator get
11440/// `(rows=N)` (`actual_rows` of the top-level operator equals the
11441/// final result row count; scans report their catalog row count
11442/// as the rows-considered metric). Other lines — Filter / Join /
11443/// GroupBy / OrderBy etc. — are marked `(—)` so the surface is
11444/// complete-by-construction; v6.2.5 fills these in via inline
11445/// executor counters.
11446/// v6.8.3 — surface "CREATE INDEX …" suggestions for every
11447/// `(table, column)` pair the query touches via WHERE / JOIN
11448/// that doesn't already have an index on the owning table.
11449/// Walks the SELECT's FROM clauses + WHERE expression tree;
11450/// returns one line per missing index. Deterministic order:
11451/// FROM-clause iteration order, then column-reference walk
11452/// order inside each WHERE. Each suggestion is a copy-pastable
11453/// DDL string.
11454fn build_index_suggestions(stmt: &SelectStatement, engine: &Engine) -> Vec<String> {
11455    use alloc::collections::BTreeSet;
11456    let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
11457    let mut out: Vec<String> = Vec::new();
11458    let cat = engine.active_catalog();
11459    // Build a (table, qualifier-or-alias) list from the FROM clause
11460    // so unqualified column refs in WHERE resolve to the correct
11461    // table.
11462    let Some(from) = &stmt.from else {
11463        return out;
11464    };
11465    let mut tables: Vec<String> = Vec::new();
11466    tables.push(from.primary.name.clone());
11467    for j in &from.joins {
11468        tables.push(j.table.name.clone());
11469    }
11470    // Collect column refs from the WHERE expression. JOIN ON
11471    // predicates also feed in.
11472    let mut col_refs: Vec<spg_sql::ast::ColumnName> = Vec::new();
11473    if let Some(w) = &stmt.where_ {
11474        collect_column_refs(w, &mut col_refs);
11475    }
11476    for j in &from.joins {
11477        if let Some(on) = &j.on {
11478            collect_column_refs(on, &mut col_refs);
11479        }
11480    }
11481    for cn in &col_refs {
11482        // Resolve owner table: explicit qualifier first, else
11483        // first table in FROM that has a column of this name.
11484        let owner: Option<String> = if let Some(q) = &cn.qualifier {
11485            tables.iter().find(|t| t == &q).cloned()
11486        } else {
11487            tables.iter().find_map(|t| {
11488                cat.get(t).and_then(|tbl| {
11489                    if tbl.schema().column_position(&cn.name).is_some() {
11490                        Some(t.clone())
11491                    } else {
11492                        None
11493                    }
11494                })
11495            })
11496        };
11497        let Some(owner) = owner else {
11498            continue;
11499        };
11500        let Some(tbl) = cat.get(&owner) else {
11501            continue;
11502        };
11503        let Some(col_pos) = tbl.schema().column_position(&cn.name) else {
11504            continue;
11505        };
11506        // Skip if any BTree index already covers this column as
11507        // its key.
11508        let already_indexed = tbl.indices().iter().any(|i| {
11509            matches!(i.kind, spg_storage::IndexKind::BTree(_))
11510                && i.column_position == col_pos
11511                && i.expression.is_none()
11512                && i.partial_predicate.is_none()
11513        });
11514        if already_indexed {
11515            continue;
11516        }
11517        if seen.insert((owner.clone(), cn.name.clone())) {
11518            out.push(alloc::format!(
11519                "SUGGEST: CREATE INDEX ix_{}_{} ON {} ({})",
11520                owner,
11521                cn.name,
11522                owner,
11523                cn.name
11524            ));
11525        }
11526    }
11527    out
11528}
11529
11530/// Walks an `Expr` and pushes every `ColumnName` it references.
11531/// Order is depth-first, left-to-right.
11532fn collect_column_refs(expr: &Expr, out: &mut Vec<spg_sql::ast::ColumnName>) {
11533    match expr {
11534        Expr::Column(cn) => out.push(cn.clone()),
11535        Expr::FunctionCall { args, .. } => {
11536            for a in args {
11537                collect_column_refs(a, out);
11538            }
11539        }
11540        Expr::Binary { lhs, rhs, .. } => {
11541            collect_column_refs(lhs, out);
11542            collect_column_refs(rhs, out);
11543        }
11544        Expr::Unary { expr: e, .. } => collect_column_refs(e, out),
11545        _ => {}
11546    }
11547}
11548
11549fn annotate_explain_lines(lines: &mut [String], total_rows: usize, engine: &Engine) {
11550    let catalog = engine.active_catalog();
11551    let cold_ids = catalog.cold_segment_ids_global();
11552    let any_cold = !cold_ids.is_empty();
11553    let cold_ids_repr = if any_cold {
11554        let mut s = alloc::string::String::from("[");
11555        for (i, id) in cold_ids.iter().enumerate() {
11556            if i > 0 {
11557                s.push(',');
11558            }
11559            s.push_str(&alloc::format!("{id}"));
11560        }
11561        s.push(']');
11562        s
11563    } else {
11564        alloc::string::String::new()
11565    };
11566    for (idx, line) in lines.iter_mut().enumerate() {
11567        let trimmed = line.trim_start();
11568        let is_top_level = idx == 0;
11569        if is_top_level {
11570            line.push_str(&alloc::format!(" (rows={total_rows})"));
11571            continue;
11572        }
11573        if let Some(rest) = trimmed.strip_prefix("From: ") {
11574            let (name, scan_kind) = match rest.split_once(" [") {
11575                Some((n, k)) => (n.trim(), k.trim_end_matches(']')),
11576                None => (rest.trim(), ""),
11577            };
11578            let bare = name.split_whitespace().next().unwrap_or(name);
11579            let hot = catalog.get(bare).map(|t| t.rows().len());
11580            // v6.2.7 — `cold_segments=[id0,id1,…]` enumerates every
11581            // cold-tier segment the scan COULD have walked. v6.2.x
11582            // can tighten to per-table by walking the table's
11583            // BTree-index cold locators.
11584            let annot = match (hot, scan_kind) {
11585                (Some(h), "full scan") => {
11586                    let mut s = alloc::format!(" (hot_rows={h}");
11587                    if any_cold {
11588                        s.push_str(&alloc::format!(
11589                            ", cold_tier=present, cold_segments={cold_ids_repr}"
11590                        ));
11591                    }
11592                    s.push(')');
11593                    s
11594                }
11595                (Some(h), "index seek") => {
11596                    let mut s = alloc::format!(" (hot_rows≤{h}");
11597                    if any_cold {
11598                        s.push_str(&alloc::format!(
11599                            ", cold_tier=present, cold_segments={cold_ids_repr}"
11600                        ));
11601                    }
11602                    s.push(')');
11603                    s
11604                }
11605                _ => " (rows=—)".to_string(),
11606            };
11607            line.push_str(&annot);
11608            continue;
11609        }
11610        // Filter / GroupBy / Having / OrderBy / Limit / Join etc.
11611        line.push_str(" (rows=—)");
11612    }
11613}
11614
11615fn explain_select(stmt: &SelectStatement, engine: &Engine, depth: usize, out: &mut Vec<String>) {
11616    let pad = "  ".repeat(depth);
11617    // 1) Top-level operator label.
11618    let top = if !stmt.ctes.is_empty() {
11619        if stmt.ctes.iter().any(|c| c.recursive) {
11620            "CTEScan (WITH RECURSIVE)"
11621        } else {
11622            "CTEScan (WITH)"
11623        }
11624    } else if !stmt.unions.is_empty() {
11625        "UnionScan"
11626    } else if select_has_window(stmt) {
11627        "WindowAgg"
11628    } else if aggregate::uses_aggregate(stmt) {
11629        "Aggregate"
11630    } else if stmt.distinct {
11631        "Distinct"
11632    } else if stmt.from.is_some() {
11633        "TableScan"
11634    } else {
11635        "Result"
11636    };
11637    out.push(alloc::format!("{pad}{top}"));
11638    let child = "  ".repeat(depth + 1);
11639    // 2) CTE bodies.
11640    for cte in &stmt.ctes {
11641        let head = if cte.recursive {
11642            alloc::format!("{child}CTE (recursive): {}", cte.name)
11643        } else {
11644            alloc::format!("{child}CTE: {}", cte.name)
11645        };
11646        out.push(head);
11647        explain_select(&cte.body, engine, depth + 2, out);
11648    }
11649    // 3) FROM details — primary table + joins, index hits.
11650    if let Some(from) = &stmt.from {
11651        let mut tag = alloc::format!("{child}From: {}", from.primary.name);
11652        if let Some(alias) = &from.primary.alias {
11653            tag.push_str(&alloc::format!(" AS {alias}"));
11654        }
11655        // Try to detect an index-seek opportunity on WHERE against
11656        // the primary table — same heuristic the executor uses.
11657        if let Some(w) = &stmt.where_
11658            && let Some(table) = engine.active_catalog().get(&from.primary.name)
11659        {
11660            let alias = from.primary.alias.as_deref().unwrap_or(&from.primary.name);
11661            let cols = &table.schema().columns;
11662            if try_index_seek(w, cols, engine.active_catalog(), table, alias).is_some() {
11663                tag.push_str(" [index seek]");
11664            } else {
11665                tag.push_str(" [full scan]");
11666            }
11667        } else {
11668            tag.push_str(" [full scan]");
11669        }
11670        out.push(tag);
11671        for j in &from.joins {
11672            let kind = match j.kind {
11673                spg_sql::ast::JoinKind::Inner => "INNER JOIN",
11674                spg_sql::ast::JoinKind::Left => "LEFT JOIN",
11675                spg_sql::ast::JoinKind::Cross => "CROSS JOIN",
11676            };
11677            let mut s = alloc::format!("{child}{kind}: {}", j.table.name);
11678            if let Some(alias) = &j.table.alias {
11679                s.push_str(&alloc::format!(" AS {alias}"));
11680            }
11681            if j.on.is_some() {
11682                s.push_str(" (ON …)");
11683            }
11684            out.push(s);
11685        }
11686    }
11687    // 4) WHERE / GROUP BY / HAVING / ORDER BY / LIMIT / OFFSET.
11688    if let Some(w) = &stmt.where_ {
11689        let mut s = alloc::format!("{child}Filter: {w}");
11690        if expr_has_subquery(w) {
11691            s.push_str(" [subquery]");
11692        }
11693        out.push(s);
11694    }
11695    if let Some(gs) = &stmt.group_by {
11696        let mut parts = Vec::new();
11697        for g in gs {
11698            parts.push(alloc::format!("{g}"));
11699        }
11700        out.push(alloc::format!("{child}GroupBy: {}", parts.join(", ")));
11701    }
11702    if let Some(h) = &stmt.having {
11703        out.push(alloc::format!("{child}Having: {h}"));
11704    }
11705    for o in &stmt.order_by {
11706        let dir = if o.desc { "DESC" } else { "ASC" };
11707        out.push(alloc::format!("{child}OrderBy: {} {dir}", o.expr));
11708    }
11709    if let Some(lim) = stmt.limit {
11710        out.push(alloc::format!("{child}Limit: {lim}"));
11711    }
11712    if let Some(off) = stmt.offset {
11713        out.push(alloc::format!("{child}Offset: {off}"));
11714    }
11715    // 5) Projection — collapse Wildcard or render N items.
11716    if stmt
11717        .items
11718        .iter()
11719        .any(|it| matches!(it, SelectItem::Wildcard))
11720    {
11721        out.push(alloc::format!("{child}Project: *"));
11722    } else {
11723        out.push(alloc::format!(
11724            "{child}Project: {} item(s)",
11725            stmt.items.len()
11726        ));
11727    }
11728    // 6) Recurse into UNION peers.
11729    for (kind, peer) in &stmt.unions {
11730        let label = match kind {
11731            UnionKind::All => "UNION ALL",
11732            UnionKind::Distinct => "UNION",
11733        };
11734        out.push(alloc::format!("{child}{label}"));
11735        explain_select(peer, engine, depth + 2, out);
11736    }
11737}
11738
11739/// v4.23: recognise the engine errors that indicate the inner
11740/// SELECT couldn't be evaluated in isolation because it references
11741/// an outer column — used by `subquery_replacement` to skip
11742/// materialisation and let row-eval handle it instead.
11743fn is_correlation_error(e: &EngineError) -> bool {
11744    matches!(
11745        e,
11746        EngineError::Eval(
11747            eval::EvalError::ColumnNotFound { .. } | eval::EvalError::UnknownQualifier { .. }
11748        )
11749    )
11750}
11751
11752/// v4.23: walk every Expr in `stmt` and replace each Column ref
11753/// that targets the outer scope (qualifier matches the outer
11754/// table alias) with a Literal carrying the outer row's value.
11755/// Conservative: only qualified refs are substituted, so the user
11756/// must write `outer_alias.col` to reference an outer column. This
11757/// matches PG's lexical scoping for correlated subqueries and
11758/// avoids accidentally rebinding inner columns of the same name.
11759/// v7.17.0 Phase 3.P0-41 — LATERAL peer descriptor. Either eagerly
11760/// materialised (every regular table / unnest / generate_series) or
11761/// lateral (subquery re-evaluated per outer row).
11762struct JoinedPeer<'a> {
11763    eager_rows: Option<Vec<Row>>,
11764    cols: Vec<ColumnSchema>,
11765    alias: String,
11766    kind: JoinKind,
11767    on: Option<&'a Expr>,
11768    lateral: Option<&'a SelectStatement>,
11769}
11770
11771/// v7.17.0 Phase 3.P0-41 — synthesise a column name for a LATERAL
11772/// projection item that has no explicit alias. PG names anonymous
11773/// projection items by the function call's name or by `column<i>`.
11774/// SPG mirrors the latter (lower-overhead than walking arbitrary
11775/// Expr shapes) so the probe-schema fallback path produces stable
11776/// names for the lateral peer's columns.
11777fn synth_lateral_col_name(expr: &Expr, idx: usize) -> String {
11778    match expr {
11779        // Bare column reference — use the column's own name.
11780        Expr::Column(c) => c.name.clone(),
11781        // Function call — use the function name (PG canonical:
11782        // `count` / `max` / `lower` …).
11783        Expr::FunctionCall { name, .. } => name.clone(),
11784        // Cast — drill into the inner expression.
11785        Expr::Cast { expr: inner, .. } => synth_lateral_col_name(inner, idx),
11786        // Everything else falls back to PG's `column<N>` placeholder.
11787        _ => alloc::format!("column{}", idx + 1),
11788    }
11789}
11790
11791/// v7.17.0 Phase 3.P0-41 — substitute every `<alias>.<col>` Expr
11792/// reference whose `<alias>.<col>` exists in the outer composite
11793/// schema with the matching value from the outer row. Walks the
11794/// entire SELECT body (items, WHERE, GROUP BY, HAVING, ORDER BY,
11795/// UNION peers) so any depth of outer reference inside the
11796/// LATERAL subquery resolves before execution.
11797fn substitute_outer_columns_multi(
11798    stmt: &mut SelectStatement,
11799    outer_row: &Row,
11800    outer_schema: &[ColumnSchema],
11801) {
11802    substitute_outer_in_select(stmt, outer_row, outer_schema);
11803}
11804
11805fn substitute_outer_in_select(
11806    stmt: &mut SelectStatement,
11807    outer_row: &Row,
11808    outer_schema: &[ColumnSchema],
11809) {
11810    for item in &mut stmt.items {
11811        if let SelectItem::Expr { expr, .. } = item {
11812            substitute_outer_in_expr(expr, outer_row, outer_schema);
11813        }
11814    }
11815    if let Some(w) = &mut stmt.where_ {
11816        substitute_outer_in_expr(w, outer_row, outer_schema);
11817    }
11818    if let Some(gs) = &mut stmt.group_by {
11819        for g in gs {
11820            substitute_outer_in_expr(g, outer_row, outer_schema);
11821        }
11822    }
11823    if let Some(h) = &mut stmt.having {
11824        substitute_outer_in_expr(h, outer_row, outer_schema);
11825    }
11826    for o in &mut stmt.order_by {
11827        substitute_outer_in_expr(&mut o.expr, outer_row, outer_schema);
11828    }
11829    for (_, peer) in &mut stmt.unions {
11830        substitute_outer_in_select(peer, outer_row, outer_schema);
11831    }
11832}
11833
11834fn substitute_outer_in_expr(e: &mut Expr, outer_row: &Row, outer_schema: &[ColumnSchema]) {
11835    if let Expr::Column(c) = e
11836        && let Some(qual) = &c.qualifier
11837    {
11838        let composite = alloc::format!("{qual}.{}", c.name);
11839        if let Some(idx) = outer_schema
11840            .iter()
11841            .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
11842        {
11843            let v = outer_row.values.get(idx).cloned().unwrap_or(Value::Null);
11844            if let Ok(lit) = value_to_literal_expr(v) {
11845                *e = lit;
11846                return;
11847            }
11848        }
11849    }
11850    match e {
11851        Expr::Binary { lhs, rhs, .. } => {
11852            substitute_outer_in_expr(lhs, outer_row, outer_schema);
11853            substitute_outer_in_expr(rhs, outer_row, outer_schema);
11854        }
11855        Expr::Unary { expr: inner, .. } => {
11856            substitute_outer_in_expr(inner, outer_row, outer_schema);
11857        }
11858        Expr::FunctionCall { args, .. } => {
11859            for a in args {
11860                substitute_outer_in_expr(a, outer_row, outer_schema);
11861            }
11862        }
11863        Expr::Cast { expr: inner, .. } => {
11864            substitute_outer_in_expr(inner, outer_row, outer_schema);
11865        }
11866        Expr::Case {
11867            operand,
11868            branches,
11869            else_branch,
11870        } => {
11871            if let Some(op) = operand {
11872                substitute_outer_in_expr(op, outer_row, outer_schema);
11873            }
11874            for (cond, val) in branches {
11875                substitute_outer_in_expr(cond, outer_row, outer_schema);
11876                substitute_outer_in_expr(val, outer_row, outer_schema);
11877            }
11878            if let Some(e) = else_branch {
11879                substitute_outer_in_expr(e, outer_row, outer_schema);
11880            }
11881        }
11882        _ => {}
11883    }
11884}
11885
11886fn substitute_outer_columns(stmt: &mut SelectStatement, row: &Row, ctx: &EvalContext<'_>) {
11887    let Some(outer_alias) = ctx.table_alias else {
11888        return;
11889    };
11890    substitute_in_select(stmt, row, ctx, outer_alias);
11891}
11892
11893fn substitute_in_select(
11894    stmt: &mut SelectStatement,
11895    row: &Row,
11896    ctx: &EvalContext<'_>,
11897    outer_alias: &str,
11898) {
11899    for item in &mut stmt.items {
11900        if let SelectItem::Expr { expr, .. } = item {
11901            substitute_in_expr(expr, row, ctx, outer_alias);
11902        }
11903    }
11904    if let Some(w) = &mut stmt.where_ {
11905        substitute_in_expr(w, row, ctx, outer_alias);
11906    }
11907    if let Some(gs) = &mut stmt.group_by {
11908        for g in gs {
11909            substitute_in_expr(g, row, ctx, outer_alias);
11910        }
11911    }
11912    if let Some(h) = &mut stmt.having {
11913        substitute_in_expr(h, row, ctx, outer_alias);
11914    }
11915    for o in &mut stmt.order_by {
11916        substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
11917    }
11918    for (_, peer) in &mut stmt.unions {
11919        substitute_in_select(peer, row, ctx, outer_alias);
11920    }
11921}
11922
11923fn substitute_in_expr(e: &mut Expr, row: &Row, ctx: &EvalContext<'_>, outer_alias: &str) {
11924    if let Expr::Column(c) = e
11925        && let Some(qual) = &c.qualifier
11926        && qual.eq_ignore_ascii_case(outer_alias)
11927    {
11928        // Look up the column's index in the outer schema.
11929        if let Some(idx) = ctx
11930            .columns
11931            .iter()
11932            .position(|sc| sc.name.eq_ignore_ascii_case(&c.name))
11933        {
11934            let v = row.values.get(idx).cloned().unwrap_or(Value::Null);
11935            if let Ok(lit) = value_to_literal_expr(v) {
11936                *e = lit;
11937                return;
11938            }
11939        }
11940    }
11941    match e {
11942        Expr::Binary { lhs, rhs, .. } => {
11943            substitute_in_expr(lhs, row, ctx, outer_alias);
11944            substitute_in_expr(rhs, row, ctx, outer_alias);
11945        }
11946        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
11947            substitute_in_expr(expr, row, ctx, outer_alias);
11948        }
11949        Expr::Like { expr, pattern, .. } => {
11950            substitute_in_expr(expr, row, ctx, outer_alias);
11951            substitute_in_expr(pattern, row, ctx, outer_alias);
11952        }
11953        Expr::FunctionCall { args, .. } => {
11954            for a in args {
11955                substitute_in_expr(a, row, ctx, outer_alias);
11956            }
11957        }
11958        Expr::Extract { source, .. } => substitute_in_expr(source, row, ctx, outer_alias),
11959        Expr::WindowFunction {
11960            args,
11961            partition_by,
11962            order_by,
11963            ..
11964        } => {
11965            for a in args {
11966                substitute_in_expr(a, row, ctx, outer_alias);
11967            }
11968            for p in partition_by {
11969                substitute_in_expr(p, row, ctx, outer_alias);
11970            }
11971            for (o, _) in order_by {
11972                substitute_in_expr(o, row, ctx, outer_alias);
11973            }
11974        }
11975        Expr::ScalarSubquery(s) => substitute_in_select(s, row, ctx, outer_alias),
11976        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
11977            substitute_in_select(subquery, row, ctx, outer_alias);
11978        }
11979        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
11980        Expr::Array(items) => {
11981            for elem in items {
11982                substitute_in_expr(elem, row, ctx, outer_alias);
11983            }
11984        }
11985        Expr::ArraySubscript { target, index } => {
11986            substitute_in_expr(target, row, ctx, outer_alias);
11987            substitute_in_expr(index, row, ctx, outer_alias);
11988        }
11989        Expr::AnyAll { expr, array, .. } => {
11990            substitute_in_expr(expr, row, ctx, outer_alias);
11991            substitute_in_expr(array, row, ctx, outer_alias);
11992        }
11993        Expr::Case {
11994            operand,
11995            branches,
11996            else_branch,
11997        } => {
11998            if let Some(o) = operand {
11999                substitute_in_expr(o, row, ctx, outer_alias);
12000            }
12001            for (w, t) in branches {
12002                substitute_in_expr(w, row, ctx, outer_alias);
12003                substitute_in_expr(t, row, ctx, outer_alias);
12004            }
12005            if let Some(e) = else_branch {
12006                substitute_in_expr(e, row, ctx, outer_alias);
12007            }
12008        }
12009    }
12010}
12011
12012/// v4.22: encode a Row to a comparable byte key for UNION-DISTINCT
12013/// dedup inside the recursive iteration. Crude but deterministic
12014/// — Debug prints embed type discriminants so NULL ≠ "" ≠ 0.
12015fn encode_row_key(row: &Row) -> Vec<u8> {
12016    let mut out = Vec::new();
12017    for v in &row.values {
12018        let s = alloc::format!("{v:?}|");
12019        out.extend_from_slice(s.as_bytes());
12020    }
12021    out
12022}
12023
12024fn select_has_window(stmt: &SelectStatement) -> bool {
12025    for item in &stmt.items {
12026        if let SelectItem::Expr { expr, .. } = item
12027            && expr_has_window(expr)
12028        {
12029            return true;
12030        }
12031    }
12032    false
12033}
12034
12035fn expr_has_window(e: &Expr) -> bool {
12036    match e {
12037        Expr::WindowFunction { .. } => true,
12038        Expr::Binary { lhs, rhs, .. } => expr_has_window(lhs) || expr_has_window(rhs),
12039        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12040            expr_has_window(expr)
12041        }
12042        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_window),
12043        Expr::Like { expr, pattern, .. } => expr_has_window(expr) || expr_has_window(pattern),
12044        Expr::Extract { source, .. } => expr_has_window(source),
12045        Expr::ScalarSubquery(_)
12046        | Expr::Exists { .. }
12047        | Expr::InSubquery { .. }
12048        | Expr::Literal(_)
12049        | Expr::Placeholder(_)
12050        | Expr::Column(_) => false,
12051        Expr::Array(items) => items.iter().any(expr_has_window),
12052        Expr::ArraySubscript { target, index } => expr_has_window(target) || expr_has_window(index),
12053        Expr::AnyAll { expr, array, .. } => expr_has_window(expr) || expr_has_window(array),
12054        Expr::Case {
12055            operand,
12056            branches,
12057            else_branch,
12058        } => {
12059            operand.as_deref().is_some_and(expr_has_window)
12060                || branches
12061                    .iter()
12062                    .any(|(w, t)| expr_has_window(w) || expr_has_window(t))
12063                || else_branch.as_deref().is_some_and(expr_has_window)
12064        }
12065    }
12066}
12067
12068fn collect_window_nodes(e: &Expr, out: &mut Vec<Expr>) {
12069    if let Expr::WindowFunction { .. } = e {
12070        // Deduplicate by structural equality on the expression
12071        // (cheap because window args + partition + order are
12072        // small). Without dedup we'd recompute identical windows
12073        // once per occurrence in the projection.
12074        if !out.iter().any(|x| x == e) {
12075            out.push(e.clone());
12076        }
12077        return;
12078    }
12079    match e {
12080        // Already handled by the early-return at the top.
12081        Expr::WindowFunction { .. } => unreachable!(),
12082        Expr::Binary { lhs, rhs, .. } => {
12083            collect_window_nodes(lhs, out);
12084            collect_window_nodes(rhs, out);
12085        }
12086        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12087            collect_window_nodes(expr, out);
12088        }
12089        Expr::FunctionCall { args, .. } => {
12090            for a in args {
12091                collect_window_nodes(a, out);
12092            }
12093        }
12094        Expr::Like { expr, pattern, .. } => {
12095            collect_window_nodes(expr, out);
12096            collect_window_nodes(pattern, out);
12097        }
12098        Expr::Extract { source, .. } => collect_window_nodes(source, out),
12099        _ => {}
12100    }
12101}
12102
12103fn rewrite_window_to_columns(e: &mut Expr, window_nodes: &[Expr]) {
12104    if let Expr::WindowFunction { .. } = e
12105        && let Some(idx) = window_nodes.iter().position(|w| w == e)
12106    {
12107        *e = Expr::Column(spg_sql::ast::ColumnName {
12108            qualifier: None,
12109            name: alloc::format!("__win_{idx}"),
12110        });
12111        return;
12112    }
12113    match e {
12114        Expr::Binary { lhs, rhs, .. } => {
12115            rewrite_window_to_columns(lhs, window_nodes);
12116            rewrite_window_to_columns(rhs, window_nodes);
12117        }
12118        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12119            rewrite_window_to_columns(expr, window_nodes);
12120        }
12121        Expr::FunctionCall { args, .. } => {
12122            for a in args {
12123                rewrite_window_to_columns(a, window_nodes);
12124            }
12125        }
12126        Expr::Like { expr, pattern, .. } => {
12127            rewrite_window_to_columns(expr, window_nodes);
12128            rewrite_window_to_columns(pattern, window_nodes);
12129        }
12130        Expr::Extract { source, .. } => rewrite_window_to_columns(source, window_nodes),
12131        _ => {}
12132    }
12133}
12134
12135/// Total order over partition-key tuples. NULL sorts as the
12136/// lowest value (matches the `<` partial order's NULL-last
12137/// behaviour with `INFINITY` flipped).
12138fn partition_key_cmp(a: &[Value], b: &[Value]) -> core::cmp::Ordering {
12139    for (x, y) in a.iter().zip(b.iter()) {
12140        let c = value_cmp(x, y);
12141        if c != core::cmp::Ordering::Equal {
12142            return c;
12143        }
12144    }
12145    a.len().cmp(&b.len())
12146}
12147
12148fn order_key_cmp(a: &[(Value, bool)], b: &[(Value, bool)]) -> core::cmp::Ordering {
12149    for ((va, desc), (vb, _)) in a.iter().zip(b.iter()) {
12150        let c = value_cmp(va, vb);
12151        let c = if *desc { c.reverse() } else { c };
12152        if c != core::cmp::Ordering::Equal {
12153            return c;
12154        }
12155    }
12156    a.len().cmp(&b.len())
12157}
12158
12159/// v7.17.0 Phase 3.10 — true when the Value is one of the
12160/// integer-shaped variants `generate_series` accepts as a start
12161/// / stop / step component. Float / NUMERIC are rejected — PG's
12162/// `generate_series(numeric, numeric)` overload is out of v7.17
12163/// scope.
12164const fn value_is_integer(v: &Value) -> bool {
12165    matches!(v, Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_))
12166}
12167
12168/// v7.17.0 Phase 3.10 — widen any integer-shaped Value to i64 for
12169/// the generate_series iteration loop. Non-integer inputs panic;
12170/// caller guards via `value_is_integer`.
12171const fn value_to_i64(v: &Value) -> i64 {
12172    match v {
12173        Value::SmallInt(n) => *n as i64,
12174        Value::Int(n) => *n as i64,
12175        Value::BigInt(n) => *n,
12176        _ => panic!("value_to_i64 called on non-integer Value"),
12177    }
12178}
12179
12180/// v7.17.0 Phase 3.10 — integer-mode generate_series materialiser.
12181/// Step direction follows the sign: positive step iterates upward
12182/// (stops when current > stop); negative iterates downward; zero
12183/// errors. Caller-facing row stream is `BigInt`-typed so a single
12184/// projection schema covers SmallInt / Int / BigInt callers.
12185fn generate_series_integers(
12186    start: i64,
12187    stop: i64,
12188    step: i64,
12189    cancel: &CancelToken<'_>,
12190) -> Result<alloc::vec::Vec<Row>, EngineError> {
12191    if step == 0 {
12192        return Err(EngineError::Unsupported(
12193            "generate_series(): step argument cannot be zero".into(),
12194        ));
12195    }
12196    let mut out = alloc::vec::Vec::new();
12197    let mut cur = start;
12198    // Hard cap to keep a runaway call from eating all memory. PG
12199    // has no such cap but does honour query timeout; SPG's cancel
12200    // token will fire too — this is a defense-in-depth backstop.
12201    const MAX_ROWS: usize = 10_000_000;
12202    loop {
12203        cancel.check()?;
12204        if step > 0 && cur > stop {
12205            break;
12206        }
12207        if step < 0 && cur < stop {
12208            break;
12209        }
12210        out.push(Row::new(alloc::vec![Value::BigInt(cur)]));
12211        if out.len() > MAX_ROWS {
12212            return Err(EngineError::Unsupported(alloc::format!(
12213                "generate_series(): exceeded {MAX_ROWS} rows; \
12214                 narrow start/stop or use a larger step"
12215            )));
12216        }
12217        cur = match cur.checked_add(step) {
12218            Some(n) => n,
12219            None => break,
12220        };
12221    }
12222    Ok(out)
12223}
12224
12225/// v7.17.0 Phase 3.10 — timestamp-mode generate_series. step is a
12226/// `Value::Interval { months, micros }` per the caller's guard;
12227/// each iteration adds the interval via `apply_binary_interval`
12228/// so month-shifting handles short-month rollover (PG semantics).
12229fn generate_series_timestamps(
12230    start: i64,
12231    stop: i64,
12232    step: Value,
12233    cancel: &CancelToken<'_>,
12234) -> Result<alloc::vec::Vec<Row>, EngineError> {
12235    let (months, micros) = match &step {
12236        Value::Interval { months, micros } => (*months, *micros),
12237        _ => unreachable!("caller guards step.is_interval"),
12238    };
12239    if months == 0 && micros == 0 {
12240        return Err(EngineError::Unsupported(
12241            "generate_series(): INTERVAL step cannot be zero".into(),
12242        ));
12243    }
12244    let ascending = months > 0 || micros > 0;
12245    let mut out = alloc::vec::Vec::new();
12246    let mut cur = Value::Timestamp(start);
12247    const MAX_ROWS: usize = 10_000_000;
12248    loop {
12249        cancel.check()?;
12250        let cur_t = match cur {
12251            Value::Timestamp(t) => t,
12252            _ => unreachable!("loop invariant: cur is Timestamp"),
12253        };
12254        if ascending && cur_t > stop {
12255            break;
12256        }
12257        if !ascending && cur_t < stop {
12258            break;
12259        }
12260        out.push(Row::new(alloc::vec![Value::Timestamp(cur_t)]));
12261        if out.len() > MAX_ROWS {
12262            return Err(EngineError::Unsupported(alloc::format!(
12263                "generate_series(): exceeded {MAX_ROWS} rows; \
12264                 narrow start/stop or use a larger step"
12265            )));
12266        }
12267        let next = eval::apply_binary_interval(
12268            spg_sql::ast::BinOp::Add,
12269            &cur,
12270            &Value::Interval { months, micros },
12271        )
12272        .map_err(EngineError::Eval)?;
12273        cur = match next {
12274            Some(v) => v,
12275            None => break,
12276        };
12277    }
12278    Ok(out)
12279}
12280
12281#[allow(clippy::match_same_arms)] // explicit arms per type document the supported pairs
12282fn value_cmp(a: &Value, b: &Value) -> core::cmp::Ordering {
12283    use core::cmp::Ordering;
12284    match (a, b) {
12285        (Value::Null, Value::Null) => Ordering::Equal,
12286        (Value::Null, _) => Ordering::Less,
12287        (_, Value::Null) => Ordering::Greater,
12288        (Value::Int(x), Value::Int(y)) => x.cmp(y),
12289        (Value::BigInt(x), Value::BigInt(y)) => x.cmp(y),
12290        (Value::SmallInt(x), Value::SmallInt(y)) => x.cmp(y),
12291        (Value::Text(x), Value::Text(y)) => x.cmp(y),
12292        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
12293        (Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
12294        (Value::Date(x), Value::Date(y)) => x.cmp(y),
12295        (Value::Timestamp(x), Value::Timestamp(y)) => x.cmp(y),
12296        // Cross-type compare: fall back to the debug rendering —
12297        // same-partition is the goal, exact order is irrelevant.
12298        _ => alloc::format!("{a:?}").cmp(&alloc::format!("{b:?}")),
12299    }
12300}
12301
12302/// Compute the window function's per-row output for one partition.
12303/// `slice` has (partition key, order key, original-row-index)
12304/// tuples already sorted by order key. `filtered_rows` is the
12305/// full row list indexed by original-row-index. `out_vals` is
12306/// the destination, also indexed by original-row-index.
12307#[allow(
12308    clippy::too_many_arguments,
12309    clippy::cast_possible_truncation,
12310    clippy::cast_possible_wrap,
12311    clippy::cast_precision_loss,
12312    clippy::cast_sign_loss,
12313    clippy::doc_markdown,
12314    clippy::too_many_lines,
12315    clippy::type_complexity,
12316    clippy::match_same_arms
12317)]
12318fn compute_window_partition(
12319    name: &str,
12320    args: &[Expr],
12321    ordered: bool,
12322    frame: Option<&WindowFrame>,
12323    null_treatment: spg_sql::ast::NullTreatment,
12324    slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)],
12325    filtered_rows: &[&Row],
12326    ctx: &EvalContext<'_>,
12327    out_vals: &mut [Value],
12328) -> Result<(), EngineError> {
12329    let ignore_nulls = matches!(null_treatment, spg_sql::ast::NullTreatment::Ignore);
12330    let lower = name.to_ascii_lowercase();
12331    match lower.as_str() {
12332        "row_number" => {
12333            for (rank, (_, _, idx)) in slice.iter().enumerate() {
12334                out_vals[*idx] = Value::BigInt((rank + 1) as i64);
12335            }
12336            Ok(())
12337        }
12338        "rank" => {
12339            let mut prev_key: Option<&[(Value, bool)]> = None;
12340            let mut current_rank: i64 = 1;
12341            for (i, (_, okey, idx)) in slice.iter().enumerate() {
12342                if let Some(p) = prev_key
12343                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
12344                {
12345                    current_rank = (i + 1) as i64;
12346                }
12347                if prev_key.is_none() {
12348                    current_rank = 1;
12349                }
12350                out_vals[*idx] = Value::BigInt(current_rank);
12351                prev_key = Some(okey.as_slice());
12352            }
12353            Ok(())
12354        }
12355        "dense_rank" => {
12356            let mut prev_key: Option<&[(Value, bool)]> = None;
12357            let mut current_rank: i64 = 0;
12358            for (_, okey, idx) in slice {
12359                if prev_key.is_none_or(|p| order_key_cmp(p, okey) != core::cmp::Ordering::Equal) {
12360                    current_rank += 1;
12361                }
12362                out_vals[*idx] = Value::BigInt(current_rank);
12363                prev_key = Some(okey.as_slice());
12364            }
12365            Ok(())
12366        }
12367        "sum" | "avg" | "min" | "max" | "count" | "count_star" => {
12368            // Pre-evaluate the function arg per row in the slice
12369            // (count_star has no arg).
12370            let arg_values: Vec<Value> = if lower == "count_star" || args.is_empty() {
12371                slice.iter().map(|_| Value::Null).collect()
12372            } else {
12373                slice
12374                    .iter()
12375                    .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12376                    .collect::<Result<_, _>>()
12377                    .map_err(EngineError::Eval)?
12378            };
12379            // v4.20: pick the effective frame. Explicit frame
12380            // overrides the implicit default (running for ordered,
12381            // whole-partition for unordered).
12382            let eff = effective_frame(frame, ordered)?;
12383            #[allow(clippy::needless_range_loop)]
12384            for i in 0..slice.len() {
12385                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
12386                let mut sum: f64 = 0.0;
12387                let mut count: i64 = 0;
12388                let mut min_v: Option<f64> = None;
12389                let mut max_v: Option<f64> = None;
12390                let mut row_count: i64 = 0;
12391                if lo <= hi {
12392                    for j in lo..=hi {
12393                        let v = &arg_values[j];
12394                        match lower.as_str() {
12395                            "count_star" => row_count += 1,
12396                            "count" => {
12397                                if !v.is_null() {
12398                                    count += 1;
12399                                }
12400                            }
12401                            _ => {
12402                                if let Some(x) = value_to_f64(v) {
12403                                    sum += x;
12404                                    count += 1;
12405                                    min_v = Some(min_v.map_or(x, |m| m.min(x)));
12406                                    max_v = Some(max_v.map_or(x, |m| m.max(x)));
12407                                }
12408                            }
12409                        }
12410                    }
12411                }
12412                let value = match lower.as_str() {
12413                    "count_star" => Value::BigInt(row_count),
12414                    "count" => Value::BigInt(count),
12415                    "sum" => Value::Float(sum),
12416                    "avg" => {
12417                        if count == 0 {
12418                            Value::Null
12419                        } else {
12420                            Value::Float(sum / count as f64)
12421                        }
12422                    }
12423                    "min" => min_v.map_or(Value::Null, Value::Float),
12424                    "max" => max_v.map_or(Value::Null, Value::Float),
12425                    _ => unreachable!(),
12426                };
12427                let (_, _, idx) = &slice[i];
12428                out_vals[*idx] = value;
12429            }
12430            Ok(())
12431        }
12432        "lag" | "lead" => {
12433            // lag(expr [, offset [, default]])
12434            // lead(expr [, offset [, default]])
12435            if args.is_empty() {
12436                return Err(EngineError::Unsupported(alloc::format!(
12437                    "{lower}() requires at least one argument"
12438                )));
12439            }
12440            let offset: i64 = if args.len() >= 2 {
12441                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
12442                    .map_err(EngineError::Eval)?;
12443                match v {
12444                    Value::SmallInt(n) => i64::from(n),
12445                    Value::Int(n) => i64::from(n),
12446                    Value::BigInt(n) => n,
12447                    _ => {
12448                        return Err(EngineError::Unsupported(alloc::format!(
12449                            "{lower}() offset must be integer"
12450                        )));
12451                    }
12452                }
12453            } else {
12454                1
12455            };
12456            let default: Value = if args.len() >= 3 {
12457                eval::eval_expr(&args[2], filtered_rows[slice[0].2], ctx)
12458                    .map_err(EngineError::Eval)?
12459            } else {
12460                Value::Null
12461            };
12462            let values: Vec<Value> = slice
12463                .iter()
12464                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12465                .collect::<Result<_, _>>()
12466                .map_err(EngineError::Eval)?;
12467            let n = slice.len();
12468            for (i, (_, _, idx)) in slice.iter().enumerate() {
12469                let signed_offset = if lower == "lag" { -offset } else { offset };
12470                let v = if ignore_nulls {
12471                    // v6.4.2 — IGNORE NULLS: walk in the offset direction
12472                    // skipping NULL values; the `offset`-th non-NULL
12473                    // encountered is the result.
12474                    let step: i64 = if signed_offset >= 0 { 1 } else { -1 };
12475                    let needed: i64 = signed_offset.abs();
12476                    if needed == 0 {
12477                        values[i].clone()
12478                    } else {
12479                        let mut j: i64 = i as i64;
12480                        let mut hits: i64 = 0;
12481                        let mut found: Option<Value> = None;
12482                        loop {
12483                            j += step;
12484                            if j < 0 || j >= n as i64 {
12485                                break;
12486                            }
12487                            #[allow(clippy::cast_sign_loss)]
12488                            let v = &values[j as usize];
12489                            if !v.is_null() {
12490                                hits += 1;
12491                                if hits == needed {
12492                                    found = Some(v.clone());
12493                                    break;
12494                                }
12495                            }
12496                        }
12497                        found.unwrap_or_else(|| default.clone())
12498                    }
12499                } else {
12500                    let target_signed = i64::try_from(i).unwrap_or(i64::MAX) + signed_offset;
12501                    if target_signed < 0 || target_signed >= i64::try_from(n).unwrap_or(i64::MAX) {
12502                        default.clone()
12503                    } else {
12504                        #[allow(clippy::cast_sign_loss)]
12505                        {
12506                            values[target_signed as usize].clone()
12507                        }
12508                    }
12509                };
12510                out_vals[*idx] = v;
12511            }
12512            Ok(())
12513        }
12514        "first_value" | "last_value" | "nth_value" => {
12515            if args.is_empty() {
12516                return Err(EngineError::Unsupported(alloc::format!(
12517                    "{lower}() requires at least one argument"
12518                )));
12519            }
12520            let values: Vec<Value> = slice
12521                .iter()
12522                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12523                .collect::<Result<_, _>>()
12524                .map_err(EngineError::Eval)?;
12525            let nth: usize = if lower == "nth_value" {
12526                if args.len() < 2 {
12527                    return Err(EngineError::Unsupported(
12528                        "nth_value() requires (expr, n)".into(),
12529                    ));
12530                }
12531                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
12532                    .map_err(EngineError::Eval)?;
12533                let raw = match v {
12534                    Value::SmallInt(n) => i64::from(n),
12535                    Value::Int(n) => i64::from(n),
12536                    Value::BigInt(n) => n,
12537                    _ => {
12538                        return Err(EngineError::Unsupported(
12539                            "nth_value() n must be integer".into(),
12540                        ));
12541                    }
12542                };
12543                if raw < 1 {
12544                    return Err(EngineError::Unsupported(
12545                        "nth_value() n must be >= 1".into(),
12546                    ));
12547                }
12548                #[allow(clippy::cast_sign_loss)]
12549                {
12550                    raw as usize
12551                }
12552            } else {
12553                0
12554            };
12555            let eff = effective_frame(frame, ordered)?;
12556            for i in 0..slice.len() {
12557                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
12558                let (_, _, idx) = &slice[i];
12559                let v = if lo > hi {
12560                    Value::Null
12561                } else if ignore_nulls && matches!(lower.as_str(), "first_value" | "last_value") {
12562                    // v6.4.2 — IGNORE NULLS: skip NULL cells when
12563                    // selecting the boundary value within the frame.
12564                    if lower == "first_value" {
12565                        (lo..=hi)
12566                            .find_map(|j| {
12567                                let v = &values[j];
12568                                (!v.is_null()).then(|| v.clone())
12569                            })
12570                            .unwrap_or(Value::Null)
12571                    } else {
12572                        (lo..=hi)
12573                            .rev()
12574                            .find_map(|j| {
12575                                let v = &values[j];
12576                                (!v.is_null()).then(|| v.clone())
12577                            })
12578                            .unwrap_or(Value::Null)
12579                    }
12580                } else {
12581                    match lower.as_str() {
12582                        "first_value" => values[lo].clone(),
12583                        "last_value" => values[hi].clone(),
12584                        "nth_value" => {
12585                            let pos = lo + nth - 1;
12586                            if pos > hi {
12587                                Value::Null
12588                            } else {
12589                                values[pos].clone()
12590                            }
12591                        }
12592                        _ => unreachable!(),
12593                    }
12594                };
12595                out_vals[*idx] = v;
12596            }
12597            Ok(())
12598        }
12599        "ntile" => {
12600            if args.is_empty() {
12601                return Err(EngineError::Unsupported(
12602                    "ntile(n) requires an integer argument".into(),
12603                ));
12604            }
12605            let v = eval::eval_expr(&args[0], filtered_rows[slice[0].2], ctx)
12606                .map_err(EngineError::Eval)?;
12607            let bucket_count: i64 = match v {
12608                Value::SmallInt(n) => i64::from(n),
12609                Value::Int(n) => i64::from(n),
12610                Value::BigInt(n) => n,
12611                _ => {
12612                    return Err(EngineError::Unsupported(
12613                        "ntile() argument must be integer".into(),
12614                    ));
12615                }
12616            };
12617            if bucket_count < 1 {
12618                return Err(EngineError::Unsupported(
12619                    "ntile() argument must be >= 1".into(),
12620                ));
12621            }
12622            #[allow(clippy::cast_sign_loss)]
12623            let buckets = bucket_count as usize;
12624            let n = slice.len();
12625            // Each bucket gets `base` rows; the first `extras` buckets
12626            // get one extra. PG semantics.
12627            let base = n / buckets;
12628            let extras = n % buckets;
12629            let mut bucket: usize = 1;
12630            let mut remaining_in_bucket = if extras > 0 { base + 1 } else { base };
12631            let mut buckets_with_extra_remaining = extras;
12632            for (_, _, idx) in slice {
12633                if remaining_in_bucket == 0 {
12634                    bucket += 1;
12635                    buckets_with_extra_remaining = buckets_with_extra_remaining.saturating_sub(1);
12636                    remaining_in_bucket = if buckets_with_extra_remaining > 0 {
12637                        base + 1
12638                    } else {
12639                        base
12640                    };
12641                    // Edge: if base==0 and extras==0, all rows fit;
12642                    // shouldn't reach here, but guard anyway.
12643                    if remaining_in_bucket == 0 {
12644                        remaining_in_bucket = 1;
12645                    }
12646                }
12647                out_vals[*idx] = Value::BigInt(i64::try_from(bucket).unwrap_or(i64::MAX));
12648                remaining_in_bucket -= 1;
12649            }
12650            Ok(())
12651        }
12652        "percent_rank" => {
12653            // (rank - 1) / (n - 1) where rank is the standard RANK().
12654            // Single-row partitions get 0.
12655            let n = slice.len();
12656            let mut prev_key: Option<&[(Value, bool)]> = None;
12657            let mut current_rank: i64 = 1;
12658            for (i, (_, okey, idx)) in slice.iter().enumerate() {
12659                if let Some(p) = prev_key
12660                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
12661                {
12662                    current_rank = i64::try_from(i + 1).unwrap_or(i64::MAX);
12663                }
12664                if prev_key.is_none() {
12665                    current_rank = 1;
12666                }
12667                #[allow(clippy::cast_precision_loss)]
12668                let pr = if n <= 1 {
12669                    0.0
12670                } else {
12671                    (current_rank - 1) as f64 / (n - 1) as f64
12672                };
12673                out_vals[*idx] = Value::Float(pr);
12674                prev_key = Some(okey.as_slice());
12675            }
12676            Ok(())
12677        }
12678        "cume_dist" => {
12679            // # rows up to and including this row's peer group / n.
12680            let n = slice.len();
12681            // First pass: find peer-group-end rank for each row.
12682            for i in 0..slice.len() {
12683                let peer_end = peer_group_end(slice, i);
12684                #[allow(clippy::cast_precision_loss)]
12685                let cd = (peer_end + 1) as f64 / n as f64;
12686                let (_, _, idx) = &slice[i];
12687                out_vals[*idx] = Value::Float(cd);
12688            }
12689            Ok(())
12690        }
12691        other => Err(EngineError::Unsupported(alloc::format!(
12692            "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)"
12693        ))),
12694    }
12695}
12696
12697/// v4.20: resolve the user-provided frame down to a normalised
12698/// `(kind, start, end)`. `None` means default — derive from
12699/// `ordered`: ordered ⇒ RANGE UNBOUNDED PRECEDING AND CURRENT ROW,
12700/// unordered ⇒ ROWS UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
12701/// Single-bound shorthand (e.g. `ROWS 5 PRECEDING`) normalises
12702/// end → CURRENT ROW per the PG spec.
12703fn effective_frame(
12704    frame: Option<&WindowFrame>,
12705    ordered: bool,
12706) -> Result<(FrameKind, FrameBound, FrameBound), EngineError> {
12707    match frame {
12708        None => {
12709            if ordered {
12710                Ok((
12711                    FrameKind::Range,
12712                    FrameBound::UnboundedPreceding,
12713                    FrameBound::CurrentRow,
12714                ))
12715            } else {
12716                Ok((
12717                    FrameKind::Rows,
12718                    FrameBound::UnboundedPreceding,
12719                    FrameBound::UnboundedFollowing,
12720                ))
12721            }
12722        }
12723        Some(fr) => {
12724            let end = fr.end.clone().unwrap_or(FrameBound::CurrentRow);
12725            // Reject start > end (a few impossible combinations).
12726            if matches!(fr.start, FrameBound::UnboundedFollowing)
12727                || matches!(end, FrameBound::UnboundedPreceding)
12728            {
12729                return Err(EngineError::Unsupported(alloc::format!(
12730                    "invalid frame: start={:?} end={:?}",
12731                    fr.start,
12732                    end
12733                )));
12734            }
12735            // RANGE OFFSET PRECEDING / FOLLOWING needs value-typed
12736            // arithmetic on the ORDER BY key (e.g. `RANGE BETWEEN
12737            // INTERVAL '1 day' PRECEDING AND CURRENT ROW`). Not
12738            // implemented in v4.20.
12739            if fr.kind == FrameKind::Range
12740                && (matches!(
12741                    fr.start,
12742                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
12743                ) || matches!(
12744                    end,
12745                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
12746                ))
12747            {
12748                return Err(EngineError::Unsupported(
12749                    "RANGE with explicit offset bounds is not supported (v4.20: only UNBOUNDED / CURRENT ROW for RANGE)".into(),
12750                ));
12751            }
12752            Ok((fr.kind, fr.start.clone(), end))
12753        }
12754    }
12755}
12756
12757/// Compute `(lo, hi)` row-index bounds inside the partition slice
12758/// for the row at position `i`. Inclusive, clamped to
12759/// `[0, slice.len()-1]`. Empty result if `lo > hi`.
12760#[allow(clippy::type_complexity)]
12761fn frame_bounds_for_row(
12762    eff: &(FrameKind, FrameBound, FrameBound),
12763    i: usize,
12764    slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)],
12765) -> (usize, usize) {
12766    let (kind, start, end) = eff;
12767    let n = slice.len();
12768    let last = n.saturating_sub(1);
12769    let (mut lo, mut hi) = match kind {
12770        FrameKind::Rows => {
12771            let lo = match start {
12772                FrameBound::UnboundedPreceding => 0,
12773                FrameBound::OffsetPreceding(k) => {
12774                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
12775                    i.saturating_sub(k)
12776                }
12777                FrameBound::CurrentRow => i,
12778                FrameBound::OffsetFollowing(k) => {
12779                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
12780                    i.saturating_add(k).min(last)
12781                }
12782                FrameBound::UnboundedFollowing => last,
12783            };
12784            let hi = match end {
12785                FrameBound::UnboundedPreceding => 0,
12786                FrameBound::OffsetPreceding(k) => {
12787                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
12788                    i.saturating_sub(k)
12789                }
12790                FrameBound::CurrentRow => i,
12791                FrameBound::OffsetFollowing(k) => {
12792                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
12793                    i.saturating_add(k).min(last)
12794                }
12795                FrameBound::UnboundedFollowing => last,
12796            };
12797            (lo, hi)
12798        }
12799        FrameKind::Range => {
12800            // RANGE bounds are peer-aware. With only UNBOUNDED and
12801            // CURRENT ROW supported (rejected at effective_frame for
12802            // explicit offsets), the start/end map to the
12803            // partition's full extent at the same-order-key peer
12804            // group boundary.
12805            let lo = match start {
12806                FrameBound::UnboundedPreceding => 0,
12807                FrameBound::CurrentRow => peer_group_start(slice, i),
12808                FrameBound::UnboundedFollowing => last,
12809                _ => unreachable!("offset bounds rejected for RANGE"),
12810            };
12811            let hi = match end {
12812                FrameBound::UnboundedPreceding => 0,
12813                FrameBound::CurrentRow => peer_group_end(slice, i),
12814                FrameBound::UnboundedFollowing => last,
12815                _ => unreachable!("offset bounds rejected for RANGE"),
12816            };
12817            (lo, hi)
12818        }
12819    };
12820    if hi >= n {
12821        hi = last;
12822    }
12823    if lo >= n {
12824        lo = last;
12825    }
12826    (lo, hi)
12827}
12828
12829/// Find the inclusive index of the first row with the same ORDER
12830/// BY key as `slice[i]`. Slice is already sorted by partition then
12831/// order, so peers are contiguous.
12832#[allow(clippy::type_complexity)]
12833fn peer_group_start(slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)], i: usize) -> usize {
12834    let key = &slice[i].1;
12835    let mut j = i;
12836    while j > 0 && order_key_cmp(&slice[j - 1].1, key) == core::cmp::Ordering::Equal {
12837        j -= 1;
12838    }
12839    j
12840}
12841
12842/// Find the inclusive index of the last row with the same ORDER
12843/// BY key as `slice[i]`.
12844#[allow(clippy::type_complexity)]
12845fn peer_group_end(slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)], i: usize) -> usize {
12846    let key = &slice[i].1;
12847    let mut j = i;
12848    while j + 1 < slice.len() && order_key_cmp(&slice[j + 1].1, key) == core::cmp::Ordering::Equal {
12849        j += 1;
12850    }
12851    j
12852}
12853
12854fn value_to_f64(v: &Value) -> Option<f64> {
12855    match v {
12856        Value::SmallInt(n) => Some(f64::from(*n)),
12857        Value::Int(n) => Some(f64::from(*n)),
12858        #[allow(clippy::cast_precision_loss)]
12859        Value::BigInt(n) => Some(*n as f64),
12860        Value::Float(x) => Some(*x),
12861        _ => None,
12862    }
12863}
12864
12865/// Quick scan for any subquery-bearing node in a SELECT's WHERE /
12866/// projection / `order_by` — saves cloning the AST when there are
12867/// none (the common case).
12868fn expr_tree_has_subquery(stmt: &SelectStatement) -> bool {
12869    let mut any = false;
12870    for item in &stmt.items {
12871        if let SelectItem::Expr { expr, .. } = item {
12872            any = any || expr_has_subquery(expr);
12873        }
12874    }
12875    if let Some(w) = &stmt.where_ {
12876        any = any || expr_has_subquery(w);
12877    }
12878    if let Some(h) = &stmt.having {
12879        any = any || expr_has_subquery(h);
12880    }
12881    for o in &stmt.order_by {
12882        any = any || expr_has_subquery(&o.expr);
12883    }
12884    for (_, peer) in &stmt.unions {
12885        any = any || expr_tree_has_subquery(peer);
12886    }
12887    any
12888}
12889
12890fn expr_has_subquery(e: &Expr) -> bool {
12891    match e {
12892        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => true,
12893        Expr::Binary { lhs, rhs, .. } => expr_has_subquery(lhs) || expr_has_subquery(rhs),
12894        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12895            expr_has_subquery(expr)
12896        }
12897        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_subquery),
12898        Expr::Like { expr, pattern, .. } => expr_has_subquery(expr) || expr_has_subquery(pattern),
12899        Expr::Extract { source, .. } => expr_has_subquery(source),
12900        Expr::WindowFunction {
12901            args,
12902            partition_by,
12903            order_by,
12904            ..
12905        } => {
12906            args.iter().any(expr_has_subquery)
12907                || partition_by.iter().any(expr_has_subquery)
12908                || order_by.iter().any(|(e, _)| expr_has_subquery(e))
12909        }
12910        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
12911        Expr::Array(items) => items.iter().any(expr_has_subquery),
12912        Expr::ArraySubscript { target, index } => {
12913            expr_has_subquery(target) || expr_has_subquery(index)
12914        }
12915        Expr::AnyAll { expr, array, .. } => expr_has_subquery(expr) || expr_has_subquery(array),
12916        Expr::Case {
12917            operand,
12918            branches,
12919            else_branch,
12920        } => {
12921            operand.as_deref().is_some_and(expr_has_subquery)
12922                || branches
12923                    .iter()
12924                    .any(|(w, t)| expr_has_subquery(w) || expr_has_subquery(t))
12925                || else_branch.as_deref().is_some_and(expr_has_subquery)
12926        }
12927    }
12928}
12929
12930/// v4.10 helper: materialise a runtime `Value` back into an AST
12931/// `Expr::Literal` for the subquery-rewrite path. Supports the
12932/// types `Literal` can represent (Integer / Float / Text / Bool /
12933/// Null). Date / Timestamp / Numeric / Vector / Interval / JSON
12934/// would lose precision through Literal and aren't supported in
12935/// uncorrelated-subquery results; they error with a clear hint.
12936fn value_to_literal_expr(v: Value) -> Result<Expr, EngineError> {
12937    let lit = match v {
12938        Value::Null => Literal::Null,
12939        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
12940        Value::Int(n) => Literal::Integer(i64::from(n)),
12941        Value::BigInt(n) => Literal::Integer(n),
12942        Value::Float(x) => Literal::Float(x),
12943        Value::Text(s) | Value::Json(s) => Literal::String(s),
12944        Value::Bool(b) => Literal::Bool(b),
12945        other => {
12946            return Err(EngineError::Unsupported(alloc::format!(
12947                "subquery result type {:?} not yet materialisable; cast to text or integer in the inner SELECT",
12948                other.data_type()
12949            )));
12950        }
12951    };
12952    Ok(Expr::Literal(lit))
12953}
12954
12955/// v7.13.0 — wider helper used by `INSERT … SELECT` (mailrs
12956/// round-5 G4). Covers the most common `Value` variants. Types
12957/// that need lossy textual round-trip (BYTEA, arrays, ts*)
12958/// surface as an Unsupported error so the caller can add a cast
12959/// in the inner SELECT.
12960fn value_to_literal_expr_permissive(v: Value) -> Result<Expr, EngineError> {
12961    let lit = match v {
12962        Value::Null => Literal::Null,
12963        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
12964        Value::Int(n) => Literal::Integer(i64::from(n)),
12965        Value::BigInt(n) => Literal::Integer(n),
12966        Value::Float(x) => Literal::Float(x),
12967        Value::Text(s) | Value::Json(s) => Literal::String(s),
12968        Value::Bool(b) => Literal::Bool(b),
12969        Value::Vector(xs) => Literal::Vector(xs),
12970        // Date / Timestamp / Timestamptz / Numeric round-trip
12971        // through a TEXT literal that `coerce_value` re-parses
12972        // against the target column type.
12973        Value::Date(days) => {
12974            let micros = (i64::from(days)) * 86_400_000_000;
12975            Literal::String(format_timestamp_micros_as_date(micros))
12976        }
12977        Value::Timestamp(us) => Literal::String(format_timestamp_micros(us)),
12978        Value::Numeric { scaled, scale } => Literal::String(format_numeric(scaled, scale)),
12979        other => {
12980            return Err(EngineError::Unsupported(alloc::format!(
12981                "INSERT … SELECT cannot materialise value of type {:?}; \
12982                 add an explicit CAST in the inner SELECT",
12983                other.data_type()
12984            )));
12985        }
12986    };
12987    Ok(Expr::Literal(lit))
12988}
12989
12990fn format_timestamp_micros(us: i64) -> String {
12991    // Same Y/M/D split used by the wire layer; epoch-relative.
12992    let days = us.div_euclid(86_400_000_000);
12993    let intra_day = us.rem_euclid(86_400_000_000);
12994    let date = format_timestamp_micros_as_date(days * 86_400_000_000);
12995    let secs = intra_day / 1_000_000;
12996    let us_rem = intra_day % 1_000_000;
12997    let h = (secs / 3600) % 24;
12998    let m = (secs / 60) % 60;
12999    let s = secs % 60;
13000    if us_rem == 0 {
13001        alloc::format!("{date} {h:02}:{m:02}:{s:02}")
13002    } else {
13003        alloc::format!("{date} {h:02}:{m:02}:{s:02}.{us_rem:06}")
13004    }
13005}
13006
13007fn format_timestamp_micros_as_date(us: i64) -> String {
13008    // Days since 1970-01-01 → calendar Y-M-D via the proleptic
13009    // Gregorian conversion used by spg-engine's date helpers.
13010    let days = us.div_euclid(86_400_000_000);
13011    // 1970-01-01 = JDN 2440588.
13012    let jdn = days + 2_440_588;
13013    let (y, mo, d) = jdn_to_ymd(jdn);
13014    alloc::format!("{y:04}-{mo:02}-{d:02}")
13015}
13016
13017fn jdn_to_ymd(jdn: i64) -> (i64, u32, u32) {
13018    // Fliegel & Van Flandern (1968) — works for all positive JDNs.
13019    let l = jdn + 68569;
13020    let n = (4 * l) / 146_097;
13021    let l = l - (146_097 * n + 3) / 4;
13022    let i = (4000 * (l + 1)) / 1_461_001;
13023    let l = l - (1461 * i) / 4 + 31;
13024    let j = (80 * l) / 2447;
13025    let day = (l - (2447 * j) / 80) as u32;
13026    let l = j / 11;
13027    let month = (j + 2 - 12 * l) as u32;
13028    let year = 100 * (n - 49) + i + l;
13029    (year, month, day)
13030}
13031
13032fn format_numeric(scaled: i128, scale: u8) -> String {
13033    if scale == 0 {
13034        return alloc::format!("{scaled}");
13035    }
13036    let abs = scaled.unsigned_abs();
13037    let divisor = 10u128.pow(u32::from(scale));
13038    let whole = abs / divisor;
13039    let frac = abs % divisor;
13040    let sign = if scaled < 0 { "-" } else { "" };
13041    alloc::format!("{sign}{whole}.{frac:0width$}", width = usize::from(scale))
13042}
13043
13044/// v6.1.1 — walk the prepared `Statement` AST and replace every
13045/// `Expr::Placeholder(n)` with `Expr::Literal(value_to_literal(
13046/// params[n-1]))`. The dispatch downstream sees a `Statement`
13047/// indistinguishable from a simple-query parse, so the exec path
13048/// stays unchanged.
13049///
13050/// Errors fall into one shape: a `$N` references past the bound
13051/// `params.len()`. Out-of-range happens when the Bind didn't
13052/// supply enough values; pgwire surfaces this as a protocol error
13053/// to the client.
13054/// v7.15.0 — rewrite every (potentially-qualified) column
13055/// identifier matching `old` to `new` in a stored SQL source
13056/// string. Used by `ALTER TABLE … RENAME COLUMN` to patch
13057/// CHECK predicate sources, partial-index predicate sources,
13058/// and runtime DEFAULT expression sources before they get
13059/// re-parsed on the next INSERT/UPDATE.
13060///
13061/// Round-trips through the parser, so the rewritten output is
13062/// the canonical Display form (matches what the engine stores
13063/// for fresh predicates). If the source doesn't parse, surfaces
13064/// the parse error — the invariant that stored predicates are
13065/// in canonical Display form means a parse failure here is a
13066/// real bug, not a user mistake to swallow.
13067fn rewrite_column_in_source(
13068    src: &str,
13069    old: &str,
13070    new: &str,
13071) -> Result<alloc::string::String, EngineError> {
13072    let mut expr = spg_sql::parser::parse_expression(src).map_err(|e| {
13073        EngineError::Unsupported(alloc::format!(
13074            "ALTER TABLE RENAME COLUMN: stored predicate source {src:?} \
13075             failed to parse for rewrite ({e})"
13076        ))
13077    })?;
13078    rewrite_column_in_expr(&mut expr, old, new);
13079    Ok(alloc::format!("{expr}"))
13080}
13081
13082/// v7.15.0 — Expr walker that swaps `Expr::Column { name: old, .. }`
13083/// for `Expr::Column { name: new, .. }`. Qualifier is preserved
13084/// (e.g. `t.old` → `t.new`); a foreign-table qualifier still
13085/// gets rewritten because the AST has no way to tell us this
13086/// predicate is on table T versus table T2 — predicate sources
13087/// in SPG are always scoped to the owning table, so any
13088/// qualifier present is either redundant or wrong.
13089fn rewrite_column_in_expr(e: &mut Expr, old: &str, new: &str) {
13090    match e {
13091        Expr::Column(c) => {
13092            if c.name.eq_ignore_ascii_case(old) {
13093                c.name = new.to_string();
13094            }
13095        }
13096        Expr::Binary { lhs, rhs, .. } => {
13097            rewrite_column_in_expr(lhs, old, new);
13098            rewrite_column_in_expr(rhs, old, new);
13099        }
13100        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13101            rewrite_column_in_expr(expr, old, new);
13102        }
13103        Expr::FunctionCall { args, .. } => {
13104            for a in args {
13105                rewrite_column_in_expr(a, old, new);
13106            }
13107        }
13108        Expr::Like { expr, pattern, .. } => {
13109            rewrite_column_in_expr(expr, old, new);
13110            rewrite_column_in_expr(pattern, old, new);
13111        }
13112        Expr::Extract { source, .. } => rewrite_column_in_expr(source, old, new),
13113        Expr::WindowFunction {
13114            args,
13115            partition_by,
13116            order_by,
13117            ..
13118        } => {
13119            for a in args {
13120                rewrite_column_in_expr(a, old, new);
13121            }
13122            for p in partition_by {
13123                rewrite_column_in_expr(p, old, new);
13124            }
13125            for (o, _) in order_by {
13126                rewrite_column_in_expr(o, old, new);
13127            }
13128        }
13129        Expr::Array(items) => {
13130            for elem in items {
13131                rewrite_column_in_expr(elem, old, new);
13132            }
13133        }
13134        Expr::ArraySubscript { target, index } => {
13135            rewrite_column_in_expr(target, old, new);
13136            rewrite_column_in_expr(index, old, new);
13137        }
13138        Expr::AnyAll { expr, array, .. } => {
13139            rewrite_column_in_expr(expr, old, new);
13140            rewrite_column_in_expr(array, old, new);
13141        }
13142        Expr::Case {
13143            operand,
13144            branches,
13145            else_branch,
13146        } => {
13147            if let Some(o) = operand {
13148                rewrite_column_in_expr(o, old, new);
13149            }
13150            for (w, t) in branches {
13151                rewrite_column_in_expr(w, old, new);
13152                rewrite_column_in_expr(t, old, new);
13153            }
13154            if let Some(e) = else_branch {
13155                rewrite_column_in_expr(e, old, new);
13156            }
13157        }
13158        // Stored predicate sources never contain subqueries —
13159        // CHECK / partial-index / runtime_default are all scalar.
13160        // If a future feature changes that, recurse here.
13161        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => {}
13162        Expr::Literal(_) | Expr::Placeholder(_) => {}
13163    }
13164}
13165
13166/// v7.16.0 — walks a parsed statement and replaces every
13167/// `Expr::Placeholder(N)` with the corresponding `params[N-1]`
13168/// re-encoded as an `Expr::Literal`. Used internally by
13169/// `Engine::execute_prepared` AND surfaced for the spg-embedded
13170/// WAL path (which needs the bind-final AST so replay sees a
13171/// simple-query-shaped statement, not a `$1`-shaped one). Errors
13172/// when a placeholder references an index past the params slice.
13173pub fn substitute_placeholders(stmt: &mut Statement, params: &[Value]) -> Result<(), EngineError> {
13174    match stmt {
13175        Statement::Select(s) => substitute_select(s, params)?,
13176        Statement::Insert(ins) => {
13177            for row in &mut ins.rows {
13178                for e in row {
13179                    substitute_expr(e, params)?;
13180                }
13181            }
13182        }
13183        Statement::Update(u) => {
13184            for (_, e) in &mut u.assignments {
13185                substitute_expr(e, params)?;
13186            }
13187            if let Some(w) = &mut u.where_ {
13188                substitute_expr(w, params)?;
13189            }
13190        }
13191        Statement::Delete(d) => {
13192            if let Some(w) = &mut d.where_ {
13193                substitute_expr(w, params)?;
13194            }
13195        }
13196        Statement::Explain(e) => substitute_select(&mut e.inner, params)?,
13197        // Other statements (CREATE / BEGIN / SHOW / …) have no
13198        // expression slots; no walk needed.
13199        _ => {}
13200    }
13201    Ok(())
13202}
13203
13204fn substitute_select(s: &mut SelectStatement, params: &[Value]) -> Result<(), EngineError> {
13205    for item in &mut s.items {
13206        if let SelectItem::Expr { expr, .. } = item {
13207            substitute_expr(expr, params)?;
13208        }
13209    }
13210    if let Some(w) = &mut s.where_ {
13211        substitute_expr(w, params)?;
13212    }
13213    if let Some(gs) = &mut s.group_by {
13214        for g in gs {
13215            substitute_expr(g, params)?;
13216        }
13217    }
13218    if let Some(h) = &mut s.having {
13219        substitute_expr(h, params)?;
13220    }
13221    for o in &mut s.order_by {
13222        substitute_expr(&mut o.expr, params)?;
13223    }
13224    for (_, peer) in &mut s.unions {
13225        substitute_select(peer, params)?;
13226    }
13227    // v7.9.24 — LIMIT $N / OFFSET $N placeholder resolution.
13228    // mailrs H2. After this pass each LIMIT/OFFSET that was a
13229    // Placeholder is rewritten to Literal so the existing
13230    // `LimitExpr::as_literal` path consumes a concrete u32.
13231    if let Some(le) = s.limit {
13232        s.limit = Some(resolve_limit_placeholder(le, params)?);
13233    }
13234    if let Some(le) = s.offset {
13235        s.offset = Some(resolve_limit_placeholder(le, params)?);
13236    }
13237    Ok(())
13238}
13239
13240fn resolve_limit_placeholder(
13241    le: spg_sql::ast::LimitExpr,
13242    params: &[Value],
13243) -> Result<spg_sql::ast::LimitExpr, EngineError> {
13244    use spg_sql::ast::LimitExpr;
13245    match le {
13246        LimitExpr::Literal(_) => Ok(le),
13247        LimitExpr::Placeholder(n) => {
13248            let idx = usize::from(n).saturating_sub(1);
13249            let v = params.get(idx).ok_or_else(|| {
13250                EngineError::Eval(EvalError::PlaceholderOutOfRange {
13251                    n,
13252                    bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
13253                })
13254            })?;
13255            let int = match v {
13256                Value::SmallInt(x) => Some(i64::from(*x)),
13257                Value::Int(x) => Some(i64::from(*x)),
13258                Value::BigInt(x) => Some(*x),
13259                _ => None,
13260            }
13261            .ok_or_else(|| {
13262                EngineError::Unsupported(alloc::format!(
13263                    "LIMIT/OFFSET ${n} bound to non-integer {v:?}"
13264                ))
13265            })?;
13266            if int < 0 {
13267                return Err(EngineError::Unsupported(alloc::format!(
13268                    "LIMIT/OFFSET ${n} bound to negative value {int}"
13269                )));
13270            }
13271            let bounded = u32::try_from(int).map_err(|_| {
13272                EngineError::Unsupported(alloc::format!(
13273                    "LIMIT/OFFSET ${n} value {int} exceeds u32 range"
13274                ))
13275            })?;
13276            Ok(LimitExpr::Literal(bounded))
13277        }
13278    }
13279}
13280
13281fn substitute_expr(e: &mut Expr, params: &[Value]) -> Result<(), EngineError> {
13282    if let Expr::Placeholder(n) = e {
13283        let idx = usize::from(*n).saturating_sub(1);
13284        let v = params.get(idx).ok_or_else(|| {
13285            EngineError::Eval(EvalError::PlaceholderOutOfRange {
13286                n: *n,
13287                bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
13288            })
13289        })?;
13290        *e = Expr::Literal(value_to_literal(v.clone()));
13291        return Ok(());
13292    }
13293    match e {
13294        Expr::Binary { lhs, rhs, .. } => {
13295            substitute_expr(lhs, params)?;
13296            substitute_expr(rhs, params)?;
13297        }
13298        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13299            substitute_expr(expr, params)?;
13300        }
13301        Expr::FunctionCall { args, .. } => {
13302            for a in args {
13303                substitute_expr(a, params)?;
13304            }
13305        }
13306        Expr::Like { expr, pattern, .. } => {
13307            substitute_expr(expr, params)?;
13308            substitute_expr(pattern, params)?;
13309        }
13310        Expr::Extract { source, .. } => substitute_expr(source, params)?,
13311        Expr::ScalarSubquery(s) => substitute_select(s, params)?,
13312        Expr::Exists { subquery, .. } => substitute_select(subquery, params)?,
13313        Expr::InSubquery { expr, subquery, .. } => {
13314            substitute_expr(expr, params)?;
13315            substitute_select(subquery, params)?;
13316        }
13317        Expr::WindowFunction {
13318            args,
13319            partition_by,
13320            order_by,
13321            ..
13322        } => {
13323            for a in args {
13324                substitute_expr(a, params)?;
13325            }
13326            for p in partition_by {
13327                substitute_expr(p, params)?;
13328            }
13329            for (e, _) in order_by {
13330                substitute_expr(e, params)?;
13331            }
13332        }
13333        Expr::Literal(_) | Expr::Column(_) => {}
13334        // Already handled above.
13335        Expr::Placeholder(_) => unreachable!("Placeholder handled at top of fn"),
13336        Expr::Array(items) => {
13337            for elem in items {
13338                substitute_expr(elem, params)?;
13339            }
13340        }
13341        Expr::ArraySubscript { target, index } => {
13342            substitute_expr(target, params)?;
13343            substitute_expr(index, params)?;
13344        }
13345        Expr::AnyAll { expr, array, .. } => {
13346            substitute_expr(expr, params)?;
13347            substitute_expr(array, params)?;
13348        }
13349        Expr::Case {
13350            operand,
13351            branches,
13352            else_branch,
13353        } => {
13354            if let Some(o) = operand {
13355                substitute_expr(o, params)?;
13356            }
13357            for (w, t) in branches {
13358                substitute_expr(w, params)?;
13359                substitute_expr(t, params)?;
13360            }
13361            if let Some(e) = else_branch {
13362                substitute_expr(e, params)?;
13363            }
13364        }
13365    }
13366    Ok(())
13367}
13368
13369/// v6.1.1 — convert a runtime `Value` into the closest matching
13370/// `Literal` for the substitute walker. Lossless for the simple
13371/// scalars (Int / Float / Text / Bool); Numeric / Date / Timestamp
13372/// / Json / Interval render as their canonical text form so the
13373/// downstream coerce_value can re-parse against the target column
13374/// type. SQ8 / HalfVector cells are NOT expected as bind params;
13375/// pgwire's Bind decodes vector params to the f32 representation
13376/// before they reach this helper.
13377/// v6.2.0 — total ordering on `Value`s used by ANALYZE to sort a
13378/// column's non-NULL sample before histogram building. Cross-type
13379/// pairs (Int vs Float, Date vs Timestamp, …) compare via the
13380/// same widening the eval-side `compare` operator uses; everything
13381/// else (the genuinely-incompatible pairs) falls back to ordering
13382/// by canonical string form so the sort is still total + stable.
13383/// Vector / SQ8 / Half / Json / Numeric / Interval values reach
13384/// here only via the string-fallback path because vector columns
13385/// are filtered out upstream.
13386fn sort_values_for_histogram(a: &Value, b: &Value) -> core::cmp::Ordering {
13387    use core::cmp::Ordering;
13388    match (a, b) {
13389        (Value::SmallInt(a), Value::SmallInt(b)) => a.cmp(b),
13390        (Value::Int(a), Value::Int(b)) => a.cmp(b),
13391        (Value::BigInt(a), Value::BigInt(b)) => a.cmp(b),
13392        (Value::SmallInt(a), Value::Int(b)) => i32::from(*a).cmp(b),
13393        (Value::Int(a), Value::SmallInt(b)) => a.cmp(&i32::from(*b)),
13394        (Value::Int(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
13395        (Value::BigInt(a), Value::Int(b)) => a.cmp(&i64::from(*b)),
13396        (Value::SmallInt(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
13397        (Value::BigInt(a), Value::SmallInt(b)) => a.cmp(&i64::from(*b)),
13398        (Value::Float(a), Value::Float(b)) => a.partial_cmp(b).unwrap_or(Ordering::Equal),
13399        (Value::Text(a), Value::Text(b)) | (Value::Json(a), Value::Json(b)) => a.cmp(b),
13400        (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
13401        (Value::Date(a), Value::Date(b)) => a.cmp(b),
13402        (Value::Timestamp(a), Value::Timestamp(b)) => a.cmp(b),
13403        // Mixed numeric/float — widen to f64 and compare.
13404        (Value::SmallInt(n), Value::Float(x)) => {
13405            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
13406        }
13407        (Value::Float(x), Value::SmallInt(n)) => {
13408            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
13409        }
13410        (Value::Int(n), Value::Float(x)) => {
13411            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
13412        }
13413        (Value::Float(x), Value::Int(n)) => {
13414            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
13415        }
13416        (Value::BigInt(n), Value::Float(x)) => {
13417            #[allow(clippy::cast_precision_loss)]
13418            let nf = *n as f64;
13419            nf.partial_cmp(x).unwrap_or(Ordering::Equal)
13420        }
13421        (Value::Float(x), Value::BigInt(n)) => {
13422            #[allow(clippy::cast_precision_loss)]
13423            let nf = *n as f64;
13424            x.partial_cmp(&nf).unwrap_or(Ordering::Equal)
13425        }
13426        // Cross-type fallback: lexicographic on canonical form.
13427        // Total + stable so the sort is well-defined.
13428        _ => canonical_value_repr(a).cmp(&canonical_value_repr(b)),
13429    }
13430}
13431
13432/// v6.2.0 — render the histogram bounds list as a `[v0, v1, ...]`
13433/// string for the `spg_statistic.histogram_bounds` column. Values
13434/// containing `,` or `[` / `]` are JSON-style escaped so the
13435/// rendering round-trips through a future parser; v6.2.0 only
13436/// uses the rendered form for human consumption, so the escaping
13437/// is conservative.
13438fn render_histogram_bounds(bounds: &[alloc::string::String]) -> alloc::string::String {
13439    let mut out = alloc::string::String::with_capacity(bounds.len() * 8 + 2);
13440    out.push('[');
13441    for (i, b) in bounds.iter().enumerate() {
13442        if i > 0 {
13443            out.push_str(", ");
13444        }
13445        let needs_quote = b.contains([',', '[', ']', '"']) || b.is_empty();
13446        if needs_quote {
13447            out.push('"');
13448            for ch in b.chars() {
13449                if ch == '"' || ch == '\\' {
13450                    out.push('\\');
13451                }
13452                out.push(ch);
13453            }
13454            out.push('"');
13455        } else {
13456            out.push_str(b);
13457        }
13458    }
13459    out.push(']');
13460    out
13461}
13462
13463/// v6.2.0 — canonical textual form of a `Value` for histogram
13464/// bound storage. Strings used by ANALYZE for sort + bound output.
13465/// INT / BIGINT → decimal; FLOAT → shortest-round-trip via
13466/// `{:?}`; TEXT pass-through; BOOL → `t` / `f`; DATE / TIMESTAMP →
13467/// the same form `format_date` / `format_timestamp` produce for
13468/// SQL Display. Vector / SQ8 / Half / Json / Numeric / Interval
13469/// reach this only via a non-Vector column (vector columns are
13470/// skipped upstream); they fall back to a Debug-derived form so
13471/// stats still serialise without crashing.
13472pub(crate) fn canonical_value_repr(v: &Value) -> alloc::string::String {
13473    match v {
13474        Value::Null => "NULL".to_string(),
13475        Value::SmallInt(n) => alloc::format!("{n}"),
13476        Value::Int(n) => alloc::format!("{n}"),
13477        Value::BigInt(n) => alloc::format!("{n}"),
13478        Value::Float(x) => alloc::format!("{x:?}"),
13479        Value::Text(s) | Value::Json(s) => s.clone(),
13480        Value::Bool(b) => if *b { "t" } else { "f" }.to_string(),
13481        Value::Date(d) => eval::format_date(*d),
13482        Value::Timestamp(t) => eval::format_timestamp(*t),
13483        // v7.17.0 Phase 3.P0-32 — PG TIME canonical text form.
13484        Value::Time(us) => eval::format_time(*us),
13485        // v7.17.0 Phase 3.P0-33 — MySQL YEAR 4-digit zero-padded.
13486        Value::Year(y) => alloc::format!("{y:04}"),
13487        // v7.17.0 Phase 3.P0-34 — PG TIMETZ canonical text form.
13488        Value::TimeTz { us, offset_secs } => eval::format_timetz(*us, *offset_secs),
13489        // v7.17.0 Phase 3.P0-35 — PG MONEY canonical en_US text form.
13490        Value::Money(c) => eval::format_money(*c),
13491        // v7.17.0 Phase 3.P0-38 — PG range canonical text form.
13492        v @ Value::Range { .. } => format_range_str(v),
13493        // v7.17.0 Phase 3.P0-39 — PG hstore canonical text form.
13494        Value::Hstore(pairs) => format_hstore_str(pairs),
13495        // v7.17.0 Phase 3.P0-40 — 2D array canonical text form.
13496        Value::IntArray2D(rows) => format_int_2d_text(rows),
13497        Value::BigIntArray2D(rows) => format_bigint_2d_text(rows),
13498        Value::TextArray2D(rows) => format_text_2d_text(rows),
13499        Value::Interval { months, micros } => eval::format_interval(*months, *micros),
13500        Value::Numeric { scaled, scale } => eval::format_numeric(*scaled, *scale),
13501        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
13502            // Unreachable in practice (vector columns are filtered
13503            // out before this). Defensive fallback so a future
13504            // vector-stats path doesn't crash.
13505            alloc::format!("{v:?}")
13506        }
13507        // v7.5.0 — Value is #[non_exhaustive] for downstream
13508        // forward-compat. Future variants fall through to Debug
13509        // form here (same shape as the vector fallback above).
13510        _ => alloc::format!("{v:?}"),
13511    }
13512}
13513
13514/// v6.2.0 — true for engine-managed catalog tables that the bare
13515/// `ANALYZE` (no target) should skip. v6.2.0 has no internal
13516/// tables yet (publications / subscriptions / users / statistics
13517/// all live as engine fields, not catalog tables), so this is a
13518/// reserved future-proofing hook — every existing user table is
13519/// analysed.
13520const fn is_internal_table_name(_name: &str) -> bool {
13521    false
13522}
13523
13524fn value_to_literal(v: Value) -> Literal {
13525    match v {
13526        Value::Null => Literal::Null,
13527        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13528        Value::Int(n) => Literal::Integer(i64::from(n)),
13529        Value::BigInt(n) => Literal::Integer(n),
13530        Value::Float(x) => Literal::Float(x),
13531        Value::Text(s) | Value::Json(s) => Literal::String(s),
13532        Value::Bool(b) => Literal::Bool(b),
13533        Value::Vector(v) => Literal::Vector(v),
13534        Value::Numeric { scaled, scale } => Literal::String(eval::format_numeric(scaled, scale)),
13535        Value::Date(d) => Literal::String(eval::format_date(d)),
13536        Value::Timestamp(t) => Literal::String(eval::format_timestamp(t)),
13537        // v7.17.0 Phase 3.P0-69 — UUID round-trips via canonical
13538        // hyphenated text. Without this arm the fallback below
13539        // renders `Debug` form ("Uuid([85, …])") which the
13540        // engine's Text → Uuid coerce can't parse, breaking
13541        // prepared-bind round-trip from the spg-sqlx adapter.
13542        Value::Uuid(b) => Literal::String(spg_storage::format_uuid(&b)),
13543        // v7.16.0 — BYTEA round-trip for the spg-sqlx Bind path.
13544        // PG-canonical text rep is `\x` + lowercase hex; the
13545        // engine's coerce_value already accepts that on the
13546        // text → bytea direction.
13547        Value::Bytes(b) => Literal::String(eval::format_bytea_hex(&b)),
13548        // v7.16.0 — array round-trip for the spg-sqlx Bind
13549        // path. Render as PG external form `{a,b,c}`; the
13550        // engine's text → array coerce (just below in
13551        // coerce_value) accepts it on the matching column type.
13552        Value::TextArray(items) => Literal::String(eval::format_text_array(&items)),
13553        Value::IntArray(items) => Literal::String(eval::format_int_array(&items)),
13554        Value::BigIntArray(items) => Literal::String(eval::format_bigint_array(&items)),
13555        Value::Interval { months, micros } => Literal::Interval {
13556            months,
13557            micros,
13558            text: eval::format_interval(months, micros),
13559        },
13560        // SQ8 / halfvec cells dequantise to f32 before reaching the
13561        // substitute walker; pgwire's Bind path handles that.
13562        Value::Sq8Vector(q) => Literal::Vector(spg_storage::quantize::dequantize(&q)),
13563        Value::HalfVector(h) => Literal::Vector(h.to_f32_vec()),
13564        // v7.5.0 — Value is #[non_exhaustive]; future variants
13565        // render as Debug-form String literal until explicit
13566        // mapping is added.
13567        v => Literal::String(alloc::format!("{v:?}")),
13568    }
13569}
13570
13571fn rewrite_clock_calls(stmt: &mut Statement, now_micros: Option<i64>) {
13572    let Some(now) = now_micros else {
13573        return;
13574    };
13575    match stmt {
13576        Statement::Select(s) => rewrite_select_clock(s, now),
13577        Statement::Insert(ins) => {
13578            for row in &mut ins.rows {
13579                for e in row {
13580                    rewrite_expr_clock(e, now);
13581                }
13582            }
13583        }
13584        _ => {}
13585    }
13586}
13587
13588fn rewrite_select_clock(s: &mut SelectStatement, now: i64) {
13589    for item in &mut s.items {
13590        if let SelectItem::Expr { expr, .. } = item {
13591            rewrite_expr_clock(expr, now);
13592        }
13593    }
13594    if let Some(w) = &mut s.where_ {
13595        rewrite_expr_clock(w, now);
13596    }
13597    if let Some(gs) = &mut s.group_by {
13598        for g in gs {
13599            rewrite_expr_clock(g, now);
13600        }
13601    }
13602    if let Some(h) = &mut s.having {
13603        rewrite_expr_clock(h, now);
13604    }
13605    for o in &mut s.order_by {
13606        rewrite_expr_clock(&mut o.expr, now);
13607    }
13608    for (_, peer) in &mut s.unions {
13609        rewrite_select_clock(peer, now);
13610    }
13611}
13612
13613/// v3.0.3 hot path: every recursion lands in exactly one `match` arm.
13614/// Literal / Column-with-qualifier (the dominant cases on a typical
13615/// AST) take a single pattern dispatch and exit. The clock-rewrite
13616/// targets (zero-arg `NOW` / `CURRENT_TIMESTAMP` / `CURRENT_DATE`
13617/// functions, and bare `CURRENT_TIMESTAMP` / `CURRENT_DATE` column
13618/// refs) sit on their own arms with match guards so the fall-through
13619/// to the recursive arms is unambiguous.
13620fn rewrite_expr_clock(e: &mut Expr, now: i64) {
13621    // Fast-path test on the no-recursion shapes first. We can't fold
13622    // them into the big match below because they need to *replace* `e`
13623    // outright; the recursive arms below match on its sub-fields.
13624    if let Some(replacement) = clock_replacement_for(e, now) {
13625        *e = replacement;
13626        return;
13627    }
13628    match e {
13629        Expr::Binary { lhs, rhs, .. } => {
13630            rewrite_expr_clock(lhs, now);
13631            rewrite_expr_clock(rhs, now);
13632        }
13633        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13634            rewrite_expr_clock(expr, now);
13635        }
13636        Expr::FunctionCall { args, .. } => {
13637            for a in args {
13638                rewrite_expr_clock(a, now);
13639            }
13640        }
13641        Expr::Like { expr, pattern, .. } => {
13642            rewrite_expr_clock(expr, now);
13643            rewrite_expr_clock(pattern, now);
13644        }
13645        Expr::Extract { source, .. } => rewrite_expr_clock(source, now),
13646        // v4.10 subquery nodes — recurse into the inner SELECT's
13647        // expression slots so e.g. SELECT NOW() in a scalar
13648        // subquery picks up the same instant as the outer query.
13649        Expr::ScalarSubquery(s) => rewrite_select_clock(s, now),
13650        Expr::Exists { subquery, .. } => rewrite_select_clock(subquery, now),
13651        Expr::InSubquery { expr, subquery, .. } => {
13652            rewrite_expr_clock(expr, now);
13653            rewrite_select_clock(subquery, now);
13654        }
13655        // v4.12 window functions — args + PARTITION BY + ORDER BY
13656        // may all reference clock literals.
13657        Expr::WindowFunction {
13658            args,
13659            partition_by,
13660            order_by,
13661            ..
13662        } => {
13663            for a in args {
13664                rewrite_expr_clock(a, now);
13665            }
13666            for p in partition_by {
13667                rewrite_expr_clock(p, now);
13668            }
13669            for (e, _) in order_by {
13670                rewrite_expr_clock(e, now);
13671            }
13672        }
13673        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
13674        Expr::Array(items) => {
13675            for elem in items {
13676                rewrite_expr_clock(elem, now);
13677            }
13678        }
13679        Expr::ArraySubscript { target, index } => {
13680            rewrite_expr_clock(target, now);
13681            rewrite_expr_clock(index, now);
13682        }
13683        Expr::AnyAll { expr, array, .. } => {
13684            rewrite_expr_clock(expr, now);
13685            rewrite_expr_clock(array, now);
13686        }
13687        Expr::Case {
13688            operand,
13689            branches,
13690            else_branch,
13691        } => {
13692            if let Some(o) = operand {
13693                rewrite_expr_clock(o, now);
13694            }
13695            for (w, t) in branches {
13696                rewrite_expr_clock(w, now);
13697                rewrite_expr_clock(t, now);
13698            }
13699            if let Some(e) = else_branch {
13700                rewrite_expr_clock(e, now);
13701            }
13702        }
13703    }
13704}
13705
13706/// Returns `Some(Expr)` when `e` is one of the clock-call shapes that
13707/// must be rewritten; otherwise `None` so the caller falls through to
13708/// the recursive walk. Identifies both function-call forms (`NOW()` /
13709/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()`) and bare-identifier forms
13710/// (`CURRENT_TIMESTAMP` / `CURRENT_DATE` as unqualified column refs,
13711/// which is how PG accepts them without parens).
13712fn clock_replacement_for(e: &Expr, now: i64) -> Option<Expr> {
13713    let (kind, name) = match e {
13714        Expr::FunctionCall { name, args } if args.is_empty() => (ClockSite::Fn, name.as_str()),
13715        Expr::Column(c) if c.qualifier.is_none() => (ClockSite::BareIdent, c.name.as_str()),
13716        _ => return None,
13717    };
13718    // ASCII case-insensitive name match. Each entry decides what
13719    // synthetic literal the call expands to.
13720    //
13721    // v7.17.0 Phase 3.P0-29 — `unix_timestamp` (no args) joins this
13722    // table as MySQL's epoch-seconds equivalent of `now()`. Folded
13723    // to a BigInt literal here so apply_function never needs a
13724    // clock dependency.
13725    enum ClockShape {
13726        Timestamp,
13727        Date,
13728        UnixSeconds,
13729    }
13730    let shape = match name.len() {
13731        3 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("now") => {
13732            Some(ClockShape::Timestamp)
13733        }
13734        12 if name.eq_ignore_ascii_case("current_date") => Some(ClockShape::Date),
13735        14 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("unix_timestamp") => {
13736            Some(ClockShape::UnixSeconds)
13737        }
13738        17 if name.eq_ignore_ascii_case("current_timestamp") => Some(ClockShape::Timestamp),
13739        _ => None,
13740    };
13741    let shape = shape?;
13742    let payload = match shape {
13743        ClockShape::Timestamp => now,
13744        ClockShape::Date => now.div_euclid(86_400_000_000),
13745        ClockShape::UnixSeconds => now.div_euclid(1_000_000),
13746    };
13747    let target = match shape {
13748        ClockShape::Timestamp => spg_sql::ast::CastTarget::Timestamp,
13749        ClockShape::Date => spg_sql::ast::CastTarget::Date,
13750        ClockShape::UnixSeconds => spg_sql::ast::CastTarget::BigInt,
13751    };
13752    Some(Expr::Cast {
13753        expr: alloc::boxed::Box::new(Expr::Literal(spg_sql::ast::Literal::Integer(payload))),
13754        target,
13755    })
13756}
13757
13758#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13759enum ClockSite {
13760    Fn,
13761    BareIdent,
13762}
13763
13764/// `ORDER BY <integer>` references the N-th SELECT item (1-based).
13765/// Swap the integer literal for the matching item's expression so the
13766/// executor doesn't need a special-case branch. Recurses into UNION
13767/// peers because each peer keeps its own SELECT list.
13768/// v6.4.1 — expand `GROUP BY ALL` to every non-aggregate SELECT-list
13769/// item. Mirrors DuckDB / PG 19 semantics. Wildcards (`SELECT * …`)
13770/// are NOT expanded by GROUP BY ALL (PG 19 leaves the wildcard intact
13771/// and groups by whatever explicit non-aggregates remain — none in
13772/// the wildcard-only case, which still works for non-aggregate
13773/// queries).
13774fn expand_group_by_all(s: &mut SelectStatement) {
13775    if !s.group_by_all {
13776        for (_, peer) in &mut s.unions {
13777            expand_group_by_all(peer);
13778        }
13779        return;
13780    }
13781    let mut groups: Vec<Expr> = Vec::new();
13782    for item in &s.items {
13783        if let SelectItem::Expr { expr, .. } = item
13784            && !aggregate::contains_aggregate(expr)
13785        {
13786            groups.push(expr.clone());
13787        }
13788    }
13789    s.group_by = Some(groups);
13790    s.group_by_all = false;
13791    for (_, peer) in &mut s.unions {
13792        expand_group_by_all(peer);
13793    }
13794}
13795
13796fn resolve_order_by_position(s: &mut SelectStatement) {
13797    // v6.4.0 — iterate every ORDER BY key. Position references
13798    // (`ORDER BY 2`) bind to the 1-based projection index;
13799    // identifier references that match a SELECT-list alias bind to
13800    // the projected expression (Step 4 of L3a).
13801    for order in &mut s.order_by {
13802        match &order.expr {
13803            Expr::Literal(Literal::Integer(n)) if *n >= 1 => {
13804                if let Ok(idx_one_based) = usize::try_from(*n) {
13805                    let idx = idx_one_based - 1;
13806                    if idx < s.items.len()
13807                        && let SelectItem::Expr { expr, .. } = &s.items[idx]
13808                    {
13809                        order.expr = expr.clone();
13810                    }
13811                }
13812            }
13813            Expr::Column(c) if c.qualifier.is_none() => {
13814                // Alias-in-ORDER-BY lookup.
13815                for item in &s.items {
13816                    if let SelectItem::Expr {
13817                        expr,
13818                        alias: Some(a),
13819                    } = item
13820                        && a == &c.name
13821                    {
13822                        order.expr = expr.clone();
13823                        break;
13824                    }
13825                }
13826            }
13827            _ => {}
13828        }
13829    }
13830    for (_, peer) in &mut s.unions {
13831        resolve_order_by_position(peer);
13832    }
13833}
13834
13835/// Sort `tagged` by `f64` key, reversing the comparator under DESC.
13836/// Used by the UNION ORDER BY path; per-block paths inline the same
13837/// comparator because they already hold `&OrderBy` directly.
13838/// v3.1.1: partial-sort helper. When `keep` (= offset + limit) is
13839/// strictly less than `tagged.len()`, run `select_nth_unstable_by` to
13840/// partition the prefix in O(n), then sort just that prefix in O(k
13841/// log k). Total O(n + k log k), vs O(n log n) for a full sort. The
13842/// caller decides what `keep` is; passing `None` (no LIMIT) keeps the
13843/// full-sort behaviour.
13844///
13845/// `tagged` holds `(Option<f64>, Row)` (the SELECT path) — `None` keys
13846/// sort last in ascending order, mirroring NULL-sorts-last in SQL.
13847fn partial_sort_tagged(tagged: &mut Vec<(Vec<f64>, Row)>, keep: Option<usize>, descs: &[bool]) {
13848    let cmp = |a: &(Vec<f64>, Row), b: &(Vec<f64>, Row)| cmp_multi_key(&a.0, &b.0, descs);
13849    match keep {
13850        Some(k) if k < tagged.len() && k > 0 => {
13851            let pivot = k - 1;
13852            tagged.select_nth_unstable_by(pivot, cmp);
13853            tagged[..k].sort_by(cmp);
13854            tagged.truncate(k);
13855        }
13856        _ => {
13857            tagged.sort_by(cmp);
13858        }
13859    }
13860}
13861
13862fn sort_by_keys(tagged: &mut [(Vec<f64>, Row)], descs: &[bool]) {
13863    tagged.sort_by(|a, b| cmp_multi_key(&a.0, &b.0, descs));
13864}
13865
13866/// v6.4.0 — multi-key ORDER BY comparator. Each key's per-key DESC
13867/// flag is honored independently. NULL is encoded as `f64::INFINITY`
13868/// so it sorts last in ASC and first in DESC (matches PG default).
13869fn cmp_multi_key(a: &[f64], b: &[f64], descs: &[bool]) -> core::cmp::Ordering {
13870    use core::cmp::Ordering;
13871    for (i, (ka, kb)) in a.iter().zip(b.iter()).enumerate() {
13872        let ord = ka.partial_cmp(kb).unwrap_or(Ordering::Equal);
13873        let ord = if descs.get(i).copied().unwrap_or(false) {
13874            ord.reverse()
13875        } else {
13876            ord
13877        };
13878        if ord != Ordering::Equal {
13879            return ord;
13880        }
13881    }
13882    Ordering::Equal
13883}
13884
13885/// v6.4.0 — eval every ORDER BY expression for a row and pack the
13886/// resulting keys into a `Vec<f64>`. NULL → `f64::INFINITY`.
13887fn build_order_keys(
13888    order_by: &[OrderBy],
13889    row: &Row,
13890    ctx: &EvalContext,
13891) -> Result<Vec<f64>, EngineError> {
13892    let mut keys = Vec::with_capacity(order_by.len());
13893    for o in order_by {
13894        let v = eval::eval_expr(&o.expr, row, ctx)?;
13895        keys.push(value_to_order_key(&v)?);
13896    }
13897    Ok(keys)
13898}
13899
13900/// Drop the first `offset` rows then truncate to `limit`. PG / `MySQL`
13901/// agree: OFFSET applies *after* ORDER BY but *before* LIMIT (so
13902/// `LIMIT 10 OFFSET 5` keeps rows 6..=15).
13903fn apply_offset_and_limit(rows: &mut Vec<Row>, offset: Option<u32>, limit: Option<u32>) {
13904    if let Some(off) = offset {
13905        let off = off as usize;
13906        if off >= rows.len() {
13907            rows.clear();
13908        } else {
13909            rows.drain(..off);
13910        }
13911    }
13912    if let Some(n) = limit {
13913        rows.truncate(n as usize);
13914    }
13915}
13916
13917/// v7.17.0 Phase 3.P0-49 — offset + limit applied to a tagged
13918/// `(order_keys, row)` sequence, with optional SQL:2008 `WITH
13919/// TIES` extension. When `with_ties` is set, the truncated tail
13920/// is extended through every subsequent row whose order keys
13921/// equal the last-kept row's keys (so a "top 3 by score" with
13922/// WITH TIES emits row 4 too when row 4 ties row 3 on `score`).
13923///
13924/// The order-key vector is the per-row sort key the caller already
13925/// computed via `build_order_keys`; equal-key detection therefore
13926/// matches the sort comparator exactly.
13927fn apply_offset_and_limit_tagged(
13928    tagged: &mut Vec<(Vec<f64>, Row)>,
13929    offset: Option<u32>,
13930    limit: Option<u32>,
13931    with_ties: bool,
13932) {
13933    if let Some(off) = offset {
13934        let off = off as usize;
13935        if off >= tagged.len() {
13936            tagged.clear();
13937        } else {
13938            tagged.drain(..off);
13939        }
13940    }
13941    if let Some(n) = limit {
13942        let n = n as usize;
13943        if with_ties && n > 0 && n < tagged.len() {
13944            let cutoff_key = tagged[n - 1].0.clone();
13945            let mut end = n;
13946            while end < tagged.len() && tagged[end].0 == cutoff_key {
13947                end += 1;
13948            }
13949            tagged.truncate(end);
13950        } else {
13951            tagged.truncate(n);
13952        }
13953    }
13954}
13955
13956/// v7.17.0 Phase 3.P0-49 — PG-canonical: `FETCH FIRST <n> ROWS
13957/// WITH TIES` requires an `ORDER BY`. Without one, there's no
13958/// way to identify "ties" deterministically, so PG errors at
13959/// plan time. SPG mirrors that surface so the same DDL / app
13960/// behaviour holds on cutover.
13961fn check_with_ties_requires_order_by(stmt: &SelectStatement) -> Result<(), EngineError> {
13962    if stmt.limit_with_ties && stmt.order_by.is_empty() {
13963        return Err(EngineError::Unsupported(alloc::string::String::from(
13964            "FETCH FIRST … ROWS WITH TIES requires an ORDER BY clause",
13965        )));
13966    }
13967    Ok(())
13968}
13969
13970/// v7.6.1 — resolve a parser-level `ForeignKeyConstraint` (column
13971/// names + parent table name) into the storage-layer shape (column
13972/// indices + same parent table). Validates everything the engine
13973/// needs to know about the FK at CREATE TABLE time:
13974///
13975///   - parent table exists (catalog lookup, unless self-referencing)
13976///   - parent columns exist on the parent table
13977///   - parent column list matches the local arity (defaults to the
13978///     parent's primary index column when omitted)
13979///   - parent columns are covered by a `BTree` UNIQUE-class index
13980///     (SPG's stand-in for `PRIMARY KEY`/`UNIQUE`) — required so
13981///     the v7.6.2 INSERT path can do an O(log n) parent lookup
13982///   - local columns exist on the table being created
13983fn resolve_foreign_key(
13984    local_table_name: &str,
13985    local_cols: &[ColumnSchema],
13986    fk: spg_sql::ast::ForeignKeyConstraint,
13987    catalog: &Catalog,
13988) -> Result<spg_storage::ForeignKeyConstraint, EngineError> {
13989    // Resolve local columns.
13990    let mut local_columns = Vec::with_capacity(fk.columns.len());
13991    for name in &fk.columns {
13992        let pos = local_cols
13993            .iter()
13994            .position(|c| c.name == *name)
13995            .ok_or_else(|| {
13996                EngineError::Unsupported(alloc::format!(
13997                    "FOREIGN KEY references unknown local column {name:?}"
13998                ))
13999            })?;
14000        local_columns.push(pos);
14001    }
14002    // Self-referencing FK: parent table is the one we're creating.
14003    // The parent column resolution uses the local column list since
14004    // the catalog doesn't have this table yet.
14005    let is_self_ref = fk.parent_table == local_table_name;
14006    let (parent_cols_for_lookup, parent_table_str): (&[ColumnSchema], &str) = if is_self_ref {
14007        (local_cols, local_table_name)
14008    } else {
14009        let parent_table = catalog.get(&fk.parent_table).ok_or_else(|| {
14010            EngineError::Storage(StorageError::TableNotFound {
14011                name: fk.parent_table.clone(),
14012            })
14013        })?;
14014        (
14015            parent_table.schema().columns.as_slice(),
14016            fk.parent_table.as_str(),
14017        )
14018    };
14019    // Resolve parent column names → positions. If the FK omitted the
14020    // parent column list, fall back to the parent's primary index
14021    // column (single-column only — composite default is rejected
14022    // because there's no unambiguous "PK" in SPG's index list).
14023    let parent_columns: Vec<usize> = if fk.parent_columns.is_empty() {
14024        if fk.columns.len() != 1 {
14025            return Err(EngineError::Unsupported(
14026                "composite FOREIGN KEY without explicit parent column list is not supported \
14027                 — list the parent columns explicitly"
14028                    .into(),
14029            ));
14030        }
14031        // Find a single BTree index on the parent and use its column.
14032        let pos = pick_pk_index_column(catalog, parent_table_str, is_self_ref, local_cols)
14033            .ok_or_else(|| {
14034                EngineError::Unsupported(alloc::format!(
14035                    "parent table {parent_table_str:?} has no PRIMARY-key / UNIQUE BTree index \
14036                     to default the FOREIGN KEY against"
14037                ))
14038            })?;
14039        alloc::vec![pos]
14040    } else {
14041        let mut out = Vec::with_capacity(fk.parent_columns.len());
14042        for name in &fk.parent_columns {
14043            let pos = parent_cols_for_lookup
14044                .iter()
14045                .position(|c| c.name == *name)
14046                .ok_or_else(|| {
14047                    EngineError::Unsupported(alloc::format!(
14048                        "FOREIGN KEY references unknown parent column \
14049                         {name:?} on table {parent_table_str:?}"
14050                    ))
14051                })?;
14052            out.push(pos);
14053        }
14054        out
14055    };
14056    if parent_columns.len() != local_columns.len() {
14057        return Err(EngineError::Unsupported(alloc::format!(
14058            "FOREIGN KEY arity mismatch: {} local columns vs {} parent columns",
14059            local_columns.len(),
14060            parent_columns.len()
14061        )));
14062    }
14063    // For non-self-referencing FKs, verify the parent column set is
14064    // covered by a BTree index. SPG doesn't have a `PRIMARY KEY`
14065    // declaration; the convention is "the parent column for FK
14066    // purposes must have a BTree index" — which the user creates via
14067    // `CREATE INDEX ... USING btree (col)` (the default). We accept
14068    // any single-column BTree index that covers a parent column;
14069    // composite parent column lists require an index whose `column_position`
14070    // matches the first parent column (multi-column BTree indices
14071    // are not in the v7.x roadmap).
14072    if !is_self_ref {
14073        let parent_table = catalog.get(&fk.parent_table).expect("checked above");
14074        let primary_parent_col = parent_columns[0];
14075        let has_btree = parent_table
14076            .schema()
14077            .columns
14078            .get(primary_parent_col)
14079            .is_some()
14080            && parent_table.indices().iter().any(|idx| {
14081                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14082                    && idx.column_position == primary_parent_col
14083                    && idx.partial_predicate.is_none()
14084            });
14085        if !has_btree {
14086            return Err(EngineError::Unsupported(alloc::format!(
14087                "FOREIGN KEY parent column on {:?} is not covered by an unconditional BTree \
14088                 index — create one with `CREATE INDEX ... ON {} ({})` first",
14089                parent_table_str,
14090                parent_table_str,
14091                parent_table.schema().columns[primary_parent_col].name,
14092            )));
14093        }
14094    }
14095    let on_delete = fk_action_sql_to_storage(fk.on_delete);
14096    let on_update = fk_action_sql_to_storage(fk.on_update);
14097    Ok(spg_storage::ForeignKeyConstraint {
14098        name: fk.name,
14099        local_columns,
14100        parent_table: fk.parent_table,
14101        parent_columns,
14102        on_delete,
14103        on_update,
14104    })
14105}
14106
14107/// v7.6.1 — pick a sentinel "primary key" column from the parent
14108/// table when the FK didn't name parent columns. Picks the first
14109/// single-column unconditional BTree index — that's the closest
14110/// thing SPG has to a PRIMARY KEY today. Self-referencing FKs use
14111/// `local_cols` as the column source.
14112fn pick_pk_index_column(
14113    catalog: &Catalog,
14114    parent_name: &str,
14115    is_self_ref: bool,
14116    local_cols: &[ColumnSchema],
14117) -> Option<usize> {
14118    if is_self_ref {
14119        // Self-ref FK omitted parent columns: pick column 0 by
14120        // convention (no catalog entry yet). Engine will widen this
14121        // when v7.6.7 lands; v7.6.1 only handles the explicit form.
14122        let _ = local_cols;
14123        return Some(0);
14124    }
14125    let parent = catalog.get(parent_name)?;
14126    parent.indices().iter().find_map(|idx| {
14127        if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14128            && idx.partial_predicate.is_none()
14129            && idx.included_columns.is_empty()
14130            && idx.expression.is_none()
14131        {
14132            Some(idx.column_position)
14133        } else {
14134            None
14135        }
14136    })
14137}
14138
14139/// v7.9.8 / v7.9.10 — resolve the column positions that
14140/// identify a conflict for ON CONFLICT. Returns a Vec of
14141/// column positions (1 element for single-column form, N for
14142/// composite). When the user wrote bare `ON CONFLICT DO …`,
14143/// falls back to the table's first unconditional BTree index
14144/// (always single-column today).
14145fn resolve_on_conflict_columns(
14146    catalog: &Catalog,
14147    table_name: &str,
14148    target: &[String],
14149) -> Result<Vec<usize>, EngineError> {
14150    let table = catalog.get(table_name).ok_or_else(|| {
14151        EngineError::Storage(StorageError::TableNotFound {
14152            name: table_name.into(),
14153        })
14154    })?;
14155    if target.is_empty() {
14156        // v7.13.2 — mailrs round-6 S5 follow-up. Composite UNIQUE
14157        // constraints carry a multi-column tuple; the prior code
14158        // path picked only the leading column of the first BTree
14159        // index, which caused `ON CONFLICT DO NOTHING` to dedup
14160        // by leading column alone (3 rows with same group_id but
14161        // different permission collapsed to 1). PG semantics use
14162        // the full tuple. Prefer a UniquenessConstraint's full
14163        // column list when one exists; fall back to the leading
14164        // BTree column for legacy single-column UNIQUE.
14165        if let Some(uc) = table.schema().uniqueness_constraints.first() {
14166            return Ok(uc.columns.clone());
14167        }
14168        let pos = table
14169            .indices()
14170            .iter()
14171            .find_map(|idx| {
14172                if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14173                    && idx.partial_predicate.is_none()
14174                    && idx.included_columns.is_empty()
14175                    && idx.expression.is_none()
14176                {
14177                    Some(idx.column_position)
14178                } else {
14179                    None
14180                }
14181            })
14182            .ok_or_else(|| {
14183                EngineError::Unsupported(alloc::format!(
14184                    "ON CONFLICT without target requires a UNIQUE BTree index on {table_name:?}"
14185                ))
14186            })?;
14187        return Ok(alloc::vec![pos]);
14188    }
14189    let mut out = Vec::with_capacity(target.len());
14190    for name in target {
14191        let pos = table
14192            .schema()
14193            .columns
14194            .iter()
14195            .position(|c| c.name == *name)
14196            .ok_or_else(|| {
14197                EngineError::Unsupported(alloc::format!(
14198                    "ON CONFLICT target column {name:?} not found on {table_name:?}"
14199                ))
14200            })?;
14201        out.push(pos);
14202    }
14203    Ok(out)
14204}
14205
14206/// v7.9.8 — check whether the BTree index on `column_pos` of
14207/// `table_name` already has a row with this key.
14208fn on_conflict_key_exists(
14209    catalog: &Catalog,
14210    table_name: &str,
14211    column_pos: usize,
14212    key: &Value,
14213) -> bool {
14214    let Some(table) = catalog.get(table_name) else {
14215        return false;
14216    };
14217    let Some(idx_key) = spg_storage::IndexKey::from_value(key) else {
14218        return false;
14219    };
14220    table.indices().iter().any(|idx| {
14221        matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14222            && idx.column_position == column_pos
14223            && idx.partial_predicate.is_none()
14224            && !idx.lookup_eq(&idx_key).is_empty()
14225    })
14226}
14227
14228/// v7.9.9 / v7.9.10 — look up an existing row's position by
14229/// matching all `column_positions` against the incoming `key`
14230/// tuple. Single-column shape (one column) reduces to the
14231/// canonical PK lookup; composite shapes scan linearly until
14232/// every position matches.
14233fn lookup_row_position_by_keys(
14234    catalog: &Catalog,
14235    table_name: &str,
14236    column_positions: &[usize],
14237    key: &[&Value],
14238) -> Option<usize> {
14239    let table = catalog.get(table_name)?;
14240    table.rows().iter().position(|r| {
14241        column_positions
14242            .iter()
14243            .enumerate()
14244            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
14245    })
14246}
14247
14248/// v7.9.10 — does the table already contain a row whose
14249/// `column_positions` tuple equals `key`? Single-column shape
14250/// uses the existing BTree fast path; composite shapes fall
14251/// back to a row scan.
14252fn on_conflict_keys_exist(
14253    catalog: &Catalog,
14254    table_name: &str,
14255    column_positions: &[usize],
14256    key: &[&Value],
14257) -> bool {
14258    if column_positions.len() == 1 {
14259        return on_conflict_key_exists(catalog, table_name, column_positions[0], key[0]);
14260    }
14261    let Some(table) = catalog.get(table_name) else {
14262        return false;
14263    };
14264    table.rows().iter().any(|r| {
14265        column_positions
14266            .iter()
14267            .enumerate()
14268            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
14269    })
14270}
14271
14272/// v7.9.9 — apply ON CONFLICT DO UPDATE SET assignments to an
14273/// existing row.
14274///
14275/// `incoming` is the rejected INSERT row (used to resolve
14276/// `EXCLUDED.col` references in the assignment exprs);
14277/// `target_pos` is the position of the existing row in the table.
14278/// Each assignment substitutes `EXCLUDED.col` with the matching
14279/// incoming value, evaluates the resulting expression against
14280/// the existing row, and writes the new value into the
14281/// corresponding column of the returned `Vec<Value>`. If
14282/// `where_` evaluates falsy, returns Ok(None) — PG behaviour:
14283/// the conflicting row is silently kept unchanged.
14284fn apply_on_conflict_assignments(
14285    catalog: &Catalog,
14286    table_name: &str,
14287    target_pos: usize,
14288    incoming: &[Value],
14289    assignments: &[(String, Expr)],
14290    where_: Option<&Expr>,
14291) -> Result<Option<Vec<Value>>, EngineError> {
14292    let table = catalog.get(table_name).ok_or_else(|| {
14293        EngineError::Storage(StorageError::TableNotFound {
14294            name: table_name.into(),
14295        })
14296    })?;
14297    let schema_cols = table.schema().columns.clone();
14298    let existing = table
14299        .rows()
14300        .get(target_pos)
14301        .ok_or_else(|| {
14302            EngineError::Unsupported(alloc::format!(
14303                "ON CONFLICT DO UPDATE: row position {target_pos} out of bounds on {table_name:?}"
14304            ))
14305        })?
14306        .clone();
14307    let ctx = eval::EvalContext::new(&schema_cols, Some(table_name));
14308    // Optional WHERE filter on the conflict row.
14309    if let Some(w) = where_ {
14310        let pred = w.clone();
14311        let pred = substitute_excluded_refs(pred, &schema_cols, incoming);
14312        let v = eval::eval_expr(&pred, &existing, &ctx)?;
14313        if !matches!(v, Value::Bool(true)) {
14314            return Ok(None);
14315        }
14316    }
14317    let mut new_values = existing.values.clone();
14318    for (col_name, expr) in assignments {
14319        let target_idx = schema_cols
14320            .iter()
14321            .position(|c| c.name == *col_name)
14322            .ok_or_else(|| {
14323                EngineError::Eval(EvalError::ColumnNotFound {
14324                    name: col_name.clone(),
14325                })
14326            })?;
14327        let sub = substitute_excluded_refs(expr.clone(), &schema_cols, incoming);
14328        let v = eval::eval_expr(&sub, &existing, &ctx)?;
14329        let coerced = coerce_value(v, schema_cols[target_idx].ty, col_name, target_idx)?;
14330        check_unsigned_range(&coerced, &schema_cols[target_idx], target_idx)?;
14331        new_values[target_idx] = coerced;
14332    }
14333    Ok(Some(new_values))
14334}
14335
14336/// v7.9.9 — walk an `Expr` tree replacing any `Column { qualifier:
14337/// "EXCLUDED", name }` reference with a `Literal` of the matching
14338/// value from the incoming-row vec. Resolution against the
14339/// child-table column list (by name).
14340fn substitute_excluded_refs(expr: Expr, schema_cols: &[ColumnSchema], incoming: &[Value]) -> Expr {
14341    use spg_sql::ast::ColumnName;
14342    match expr {
14343        Expr::Column(ColumnName { qualifier, name })
14344            if qualifier
14345                .as_deref()
14346                .is_some_and(|q| q.eq_ignore_ascii_case("excluded")) =>
14347        {
14348            let pos = schema_cols.iter().position(|c| c.name == name);
14349            match pos {
14350                Some(p) => {
14351                    let v = incoming.get(p).cloned().unwrap_or(Value::Null);
14352                    value_to_literal_expr(v)
14353                        .unwrap_or_else(|_| Expr::Literal(spg_sql::ast::Literal::Null))
14354                }
14355                None => Expr::Column(ColumnName { qualifier, name }),
14356            }
14357        }
14358        Expr::Binary { op, lhs, rhs } => Expr::Binary {
14359            op,
14360            lhs: Box::new(substitute_excluded_refs(*lhs, schema_cols, incoming)),
14361            rhs: Box::new(substitute_excluded_refs(*rhs, schema_cols, incoming)),
14362        },
14363        Expr::Unary { op, expr } => Expr::Unary {
14364            op,
14365            expr: Box::new(substitute_excluded_refs(*expr, schema_cols, incoming)),
14366        },
14367        Expr::FunctionCall { name, args } => Expr::FunctionCall {
14368            name,
14369            args: args
14370                .into_iter()
14371                .map(|a| substitute_excluded_refs(a, schema_cols, incoming))
14372                .collect(),
14373        },
14374        other => other,
14375    }
14376}
14377
14378/// v7.6.2 / v7.6.7 — INSERT-side FK enforcement. For every row
14379/// about to be inserted into `child_table`, every FK declared on
14380/// that table is checked: the row's FK columns must either be
14381/// NULL (SQL spec skip) or match an existing parent row via the
14382/// parent's BTree PK / UNIQUE index.
14383///
14384/// Returns `EngineError::Unsupported` with a `FOREIGN KEY violation`
14385/// payload on first failure.
14386///
14387/// **Self-referencing FKs (v7.6.7 widening):** when `fk.parent_table
14388/// == child_table`, the parent rows visible to this check are
14389///  (a) rows already committed to the table, plus
14390///  (b) earlier rows from the *same* `rows` batch.
14391/// This makes `INSERT INTO tree VALUES (1, NULL), (2, 1), (3, 2)`
14392/// work in a single statement — common pattern for bulk-loading
14393/// hierarchies.
14394/// v7.9.19 — enforce table-level UNIQUE / PRIMARY KEY tuple
14395/// constraints at INSERT time. For each constraint declared on
14396/// the target table, check that no existing row + no earlier row
14397/// in the same batch has the same full-column tuple. NULL in
14398/// any column lifts the row out of the check (SQL spec: NULL
14399/// ≠ NULL for uniqueness). mailrs G1 + G6.
14400fn enforce_uniqueness_inserts(
14401    catalog: &Catalog,
14402    child_table: &str,
14403    constraints: &[spg_storage::UniquenessConstraint],
14404    rows: &[Vec<Value>],
14405) -> Result<(), EngineError> {
14406    if constraints.is_empty() {
14407        return Ok(());
14408    }
14409    let table = catalog.get(child_table).ok_or_else(|| {
14410        EngineError::Storage(StorageError::TableNotFound {
14411            name: child_table.into(),
14412        })
14413    })?;
14414    let schema = table.schema();
14415    for uc in constraints {
14416        for (batch_idx, row_values) in rows.iter().enumerate() {
14417            // v7.17.0 Phase 3.P0-45 — fold each key cell by its
14418            // column's declared Collation before comparing. Phase
14419            // 2.5b wired Collation into GROUP BY / ORDER BY / `=`
14420            // but the UNIQUE-constraint enforcement still compared
14421            // Text byte-wise; a `*_ci` column would let
14422            // `('Foo')` and `('FOO')` coexist when MySQL would
14423            // reject the second. Owned Values so the fold and
14424            // the borrow live in the same scope.
14425            let key: Vec<Value> = uc
14426                .columns
14427                .iter()
14428                .map(|&i| collated_key_cell(&row_values[i], i, schema))
14429                .collect();
14430            let has_null = key.iter().any(|v| matches!(v, Value::Null));
14431            // v7.13.0 — `NULLS NOT DISTINCT` (mailrs round-5 G10,
14432            // PG 15+): two rows whose constrained columns are all
14433            // NULL collide. SQL-standard `NULLS DISTINCT` lets any
14434            // NULL skip the check.
14435            if has_null && !uc.nulls_not_distinct {
14436                continue;
14437            }
14438            // Table-side collision: scan existing rows.
14439            let collides_in_table = table.rows().iter().any(|prow| {
14440                uc.columns.iter().enumerate().all(|(i, &p)| {
14441                    prow.values
14442                        .get(p)
14443                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
14444                })
14445            });
14446            // Batch-side collision: earlier rows in the same INSERT.
14447            let collides_in_batch = rows[..batch_idx].iter().any(|earlier| {
14448                uc.columns.iter().enumerate().all(|(i, &p)| {
14449                    earlier
14450                        .get(p)
14451                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
14452                })
14453            });
14454            if collides_in_table || collides_in_batch {
14455                let kind = if uc.is_primary_key {
14456                    "PRIMARY KEY"
14457                } else {
14458                    "UNIQUE"
14459                };
14460                let col_names: Vec<String> = uc
14461                    .columns
14462                    .iter()
14463                    .map(|&i| table.schema().columns[i].name.clone())
14464                    .collect();
14465                return Err(EngineError::Unsupported(alloc::format!(
14466                    "{kind} violation on {child_table:?} columns {col_names:?}: \
14467                     row #{batch_idx} duplicates an existing key"
14468                )));
14469            }
14470        }
14471    }
14472    Ok(())
14473}
14474
14475/// v7.17.0 Phase 3.P0-45 — return a key cell folded by its column's
14476/// declared `Collation`. For `CaseInsensitive`, fold Text payloads to
14477/// ASCII lowercase (matches Phase 2.5's `*_ci` semantics: ASCII case-
14478/// fold only, non-ASCII bytes stay byte-wise). For `Binary` or non-Text
14479/// values, the cell passes through unchanged. The caller compares the
14480/// folded values with `==`.
14481fn collated_key_cell(
14482    v: &spg_storage::Value,
14483    column_position: usize,
14484    schema: &spg_storage::TableSchema,
14485) -> spg_storage::Value {
14486    match (v, schema.columns.get(column_position).map(|c| c.collation)) {
14487        (spg_storage::Value::Text(s), Some(spg_storage::Collation::CaseInsensitive)) => {
14488            spg_storage::Value::Text(s.to_ascii_lowercase())
14489        }
14490        _ => v.clone(),
14491    }
14492}
14493
14494/// v7.9.29 — `true` iff `v` counts as a truthy SQL value for a
14495/// WHERE-style predicate. NULL → false (three-valued logic
14496/// collapses to "skip this row" for index inclusion). Numeric
14497/// non-zero, BIGINT non-zero, TINYINT non-zero, BOOLEAN true → true.
14498/// Everything else (strings, vectors, JSON, …) is not a valid
14499/// predicate result and surfaces as `false` so a malformed
14500/// predicate degrades to "row not in index" rather than panicking.
14501fn predicate_truthy(v: &spg_storage::Value) -> bool {
14502    use spg_storage::Value as V;
14503    match v {
14504        V::Bool(b) => *b,
14505        V::Int(n) => *n != 0,
14506        V::BigInt(n) => *n != 0,
14507        V::SmallInt(n) => *n != 0,
14508        _ => false,
14509    }
14510}
14511
14512/// v7.9.29 — at CREATE UNIQUE INDEX time, scan the table's
14513/// committed rows for pre-existing duplicates. If any pair of rows
14514/// matches the predicate AND has the same index key, refuse to
14515/// create the index so the user fixes the data before retrying.
14516fn check_existing_unique_violation(
14517    idx: &spg_storage::Index,
14518    schema: &spg_storage::TableSchema,
14519    rows: &[spg_storage::Row],
14520) -> Result<(), EngineError> {
14521    let predicate_expr = match idx.partial_predicate.as_deref() {
14522        Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
14523            EngineError::Unsupported(alloc::format!(
14524                "stored partial predicate {s:?} failed to re-parse: {e:?}"
14525            ))
14526        })?),
14527        None => None,
14528    };
14529    let ctx = eval::EvalContext::new(&schema.columns, None);
14530    let key_positions = unique_key_positions(idx);
14531    let mut seen: alloc::vec::Vec<alloc::vec::Vec<spg_storage::Value>> = alloc::vec::Vec::new();
14532    for row in rows {
14533        if let Some(expr) = &predicate_expr {
14534            let v = eval::eval_expr(expr, row, &ctx).map_err(|e| {
14535                EngineError::Unsupported(alloc::format!(
14536                    "evaluating UNIQUE INDEX predicate against existing row: {e:?}"
14537                ))
14538            })?;
14539            if !predicate_truthy(&v) {
14540                continue;
14541            }
14542        }
14543        let key: alloc::vec::Vec<spg_storage::Value> = key_positions
14544            .iter()
14545            .map(|&p| {
14546                let v = row
14547                    .values
14548                    .get(p)
14549                    .cloned()
14550                    .unwrap_or(spg_storage::Value::Null);
14551                collated_key_cell(&v, p, schema)
14552            })
14553            .collect();
14554        if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
14555            continue;
14556        }
14557        if seen.iter().any(|other| *other == key) {
14558            return Err(EngineError::Unsupported(alloc::format!(
14559                "CREATE UNIQUE INDEX {:?}: existing rows already violate the constraint",
14560                idx.name
14561            )));
14562        }
14563        seen.push(key);
14564    }
14565    Ok(())
14566}
14567
14568/// v7.9.29 — full key tuple for a UNIQUE INDEX (leading +
14569/// extra positions). For single-column indexes this is just
14570/// `[column_position]`.
14571fn unique_key_positions(idx: &spg_storage::Index) -> alloc::vec::Vec<usize> {
14572    let mut out = alloc::vec::Vec::with_capacity(1 + idx.extra_column_positions.len());
14573    out.push(idx.column_position);
14574    out.extend_from_slice(&idx.extra_column_positions);
14575    out
14576}
14577
14578/// v7.9.29 — at INSERT time, walk every `is_unique` index on the
14579/// target table. For each, eval the index's optional predicate
14580/// against (a) the candidate row and (b) every committed row plus
14581/// earlier batch rows; only rows where the predicate is truthy
14582/// participate. A duplicate key among predicate-matching rows is a
14583/// uniqueness violation. NULL keys lift the row out of the check
14584/// (matching PG's "UNIQUE allows multiple NULLs" semantics).
14585fn enforce_unique_index_inserts(
14586    catalog: &Catalog,
14587    table_name: &str,
14588    rows: &[alloc::vec::Vec<spg_storage::Value>],
14589) -> Result<(), EngineError> {
14590    let table = catalog.get(table_name).ok_or_else(|| {
14591        EngineError::Storage(StorageError::TableNotFound {
14592            name: table_name.into(),
14593        })
14594    })?;
14595    let schema = table.schema();
14596    let ctx = eval::EvalContext::new(&schema.columns, None);
14597    for idx in table.indices() {
14598        if !idx.is_unique {
14599            continue;
14600        }
14601        // Re-parse the predicate once per index per batch.
14602        let predicate_expr = match idx.partial_predicate.as_deref() {
14603            Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
14604                EngineError::Unsupported(alloc::format!(
14605                    "UNIQUE INDEX {:?} predicate {s:?} failed to re-parse: {e:?}",
14606                    idx.name
14607                ))
14608            })?),
14609            None => None,
14610        };
14611        let key_positions = unique_key_positions(idx);
14612        let key_of = |values: &[spg_storage::Value]| -> alloc::vec::Vec<spg_storage::Value> {
14613            // v7.17.0 Phase 3.P0-45 — fold per-column collation
14614            // before building the comparison key so a `*_ci`
14615            // column treats `'Foo'` and `'FOO'` as equal.
14616            key_positions
14617                .iter()
14618                .map(|&p| {
14619                    let v = values.get(p).cloned().unwrap_or(spg_storage::Value::Null);
14620                    collated_key_cell(&v, p, schema)
14621                })
14622                .collect()
14623        };
14624        // Helper: does `values` participate in this index? (predicate
14625        // truthy when present.) Wraps `values` into a transient Row
14626        // because eval_expr requires &Row.
14627        let participates = |values: &[spg_storage::Value]| -> Result<bool, EngineError> {
14628            let Some(expr) = &predicate_expr else {
14629                return Ok(true);
14630            };
14631            let tmp_row = spg_storage::Row {
14632                values: values.to_vec(),
14633            };
14634            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
14635                EngineError::Unsupported(alloc::format!(
14636                    "UNIQUE INDEX {:?} predicate eval: {e:?}",
14637                    idx.name
14638                ))
14639            })?;
14640            Ok(predicate_truthy(&v))
14641        };
14642        for (batch_idx, row_values) in rows.iter().enumerate() {
14643            if !participates(row_values)? {
14644                continue;
14645            }
14646            let key = key_of(row_values);
14647            if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
14648                continue;
14649            }
14650            // Committed-table collision.
14651            for prow in table.rows() {
14652                if !participates(&prow.values)? {
14653                    continue;
14654                }
14655                if key_of(&prow.values) == key {
14656                    return Err(EngineError::Unsupported(alloc::format!(
14657                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
14658                         row #{batch_idx} duplicates an existing key",
14659                        idx.name
14660                    )));
14661                }
14662            }
14663            // Within-batch collision: earlier rows in the same INSERT.
14664            for earlier in &rows[..batch_idx] {
14665                if !participates(earlier)? {
14666                    continue;
14667                }
14668                if key_of(earlier) == key {
14669                    return Err(EngineError::Unsupported(alloc::format!(
14670                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
14671                         row #{batch_idx} duplicates an earlier row in the same batch",
14672                        idx.name
14673                    )));
14674                }
14675            }
14676        }
14677    }
14678    Ok(())
14679}
14680
14681/// v7.13.0 — `UPDATE OF cols` filter helper (mailrs round-5 G7).
14682/// Returns `true` when at least one of `filter_cols` has a
14683/// different value in `new_row` vs `old_row`. Column lookup is
14684/// case-insensitive against `schema_cols`; unknown filter columns
14685/// are treated as "not changed" (the trigger therefore won't
14686/// fire on them — surfacing a parse-time error would be too
14687/// strict for catalog reloads where the schema may have drifted).
14688fn any_column_changed(
14689    filter_cols: &[String],
14690    schema_cols: &[ColumnSchema],
14691    old_row: &Row,
14692    new_row: &Row,
14693) -> bool {
14694    for col_name in filter_cols {
14695        let Some(pos) = schema_cols
14696            .iter()
14697            .position(|c| c.name.eq_ignore_ascii_case(col_name))
14698        else {
14699            continue;
14700        };
14701        let old_v = old_row.values.get(pos);
14702        let new_v = new_row.values.get(pos);
14703        if old_v != new_v {
14704            return true;
14705        }
14706    }
14707    false
14708}
14709
14710/// v7.13.0 — evaluate every CHECK predicate on the schema against
14711/// each candidate row. Mirrors PG semantics: a `false` result
14712/// rejects the mutation; a NULL result *passes* (CHECK rejects
14713/// only on definite-false, not on unknown). mailrs round-5 G3.
14714fn enforce_check_constraints(
14715    catalog: &Catalog,
14716    table_name: &str,
14717    rows: &[alloc::vec::Vec<spg_storage::Value>],
14718) -> Result<(), EngineError> {
14719    let table = catalog.get(table_name).ok_or_else(|| {
14720        EngineError::Storage(StorageError::TableNotFound {
14721            name: table_name.into(),
14722        })
14723    })?;
14724    let schema = table.schema();
14725    // v7.17.0 Phase 1.5 — domain-level CHECKs are enforced in
14726    // parallel with table-level CHECKs. Collect both lists up
14727    // front; if neither exists we early-out.
14728    let mut domain_checks_per_col: alloc::vec::Vec<(usize, alloc::vec::Vec<Expr>)> =
14729        alloc::vec::Vec::new();
14730    for (idx, col) in schema.columns.iter().enumerate() {
14731        let Some(dname) = &col.user_domain_type else {
14732            continue;
14733        };
14734        let Some(dom) = catalog.domain_types().get(dname) else {
14735            continue;
14736        };
14737        let mut parsed_for_col: alloc::vec::Vec<Expr> =
14738            alloc::vec::Vec::with_capacity(dom.checks.len());
14739        for src in &dom.checks {
14740            let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
14741                EngineError::Unsupported(alloc::format!(
14742                    "DOMAIN {dname:?} CHECK ({src:?}) on column {:?}: re-parse failed: {e:?}",
14743                    col.name
14744                ))
14745            })?;
14746            parsed_for_col.push(expr);
14747        }
14748        if !parsed_for_col.is_empty() {
14749            domain_checks_per_col.push((idx, parsed_for_col));
14750        }
14751    }
14752    if schema.checks.is_empty() && domain_checks_per_col.is_empty() {
14753        return Ok(());
14754    }
14755    let ctx = eval::EvalContext::new(&schema.columns, None);
14756    let mut parsed: alloc::vec::Vec<(usize, Expr)> = alloc::vec::Vec::new();
14757    for (i, src) in schema.checks.iter().enumerate() {
14758        let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
14759            EngineError::Unsupported(alloc::format!(
14760                "CHECK constraint #{i} on {table_name:?} ({src:?}) failed to re-parse: {e:?}"
14761            ))
14762        })?;
14763        parsed.push((i, expr));
14764    }
14765    for (batch_idx, row_values) in rows.iter().enumerate() {
14766        let tmp_row = spg_storage::Row {
14767            values: row_values.clone(),
14768        };
14769        for (i, expr) in &parsed {
14770            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
14771                EngineError::Unsupported(alloc::format!(
14772                    "CHECK constraint #{i} on {table_name:?} eval at row #{batch_idx}: {e:?}"
14773                ))
14774            })?;
14775            // PG: NULL passes (CHECK rejects on definite-false only).
14776            if matches!(v, spg_storage::Value::Bool(false)) {
14777                return Err(EngineError::Unsupported(alloc::format!(
14778                    "CHECK constraint violation on {table_name:?} (row #{batch_idx}): {:?}",
14779                    schema.checks[*i]
14780                )));
14781            }
14782        }
14783        // v7.17.0 Phase 1.5 — domain-level CHECKs. Each CHECK
14784        // expression references VALUE as a column-name; we
14785        // substitute the per-row cell into the eval context by
14786        // synthesising a single-column row of just that value
14787        // under a temporary `value` column schema.
14788        for (col_idx, checks) in &domain_checks_per_col {
14789            let cell = row_values
14790                .get(*col_idx)
14791                .cloned()
14792                .unwrap_or(spg_storage::Value::Null);
14793            let synth_cols = alloc::vec![spg_storage::ColumnSchema::new(
14794                "value",
14795                schema.columns[*col_idx].ty,
14796                schema.columns[*col_idx].nullable,
14797            )];
14798            let synth_ctx = eval::EvalContext::new(&synth_cols, None);
14799            let synth_row = spg_storage::Row {
14800                values: alloc::vec![cell],
14801            };
14802            for (ci, expr) in checks.iter().enumerate() {
14803                let v = eval::eval_expr(expr, &synth_row, &synth_ctx).map_err(|e| {
14804                    EngineError::Unsupported(alloc::format!(
14805                        "DOMAIN CHECK #{ci} on column {:?} eval at row #{batch_idx}: {e:?}",
14806                        schema.columns[*col_idx].name
14807                    ))
14808                })?;
14809                if matches!(v, spg_storage::Value::Bool(false)) {
14810                    return Err(EngineError::Unsupported(alloc::format!(
14811                        "DOMAIN CHECK violation on column {:?} (row #{batch_idx})",
14812                        schema.columns[*col_idx].name
14813                    )));
14814                }
14815            }
14816        }
14817    }
14818    Ok(())
14819}
14820
14821fn enforce_fk_inserts(
14822    catalog: &Catalog,
14823    child_table: &str,
14824    fks: &[spg_storage::ForeignKeyConstraint],
14825    rows: &[Vec<Value>],
14826) -> Result<(), EngineError> {
14827    for fk in fks {
14828        let parent_is_self = fk.parent_table == child_table;
14829        let parent = if parent_is_self {
14830            // Self-ref: read the current state of the same table.
14831            // The mut borrow on child has been dropped by the caller.
14832            catalog.get(child_table).ok_or_else(|| {
14833                EngineError::Storage(StorageError::TableNotFound {
14834                    name: child_table.into(),
14835                })
14836            })?
14837        } else {
14838            catalog.get(&fk.parent_table).ok_or_else(|| {
14839                EngineError::Storage(StorageError::TableNotFound {
14840                    name: fk.parent_table.clone(),
14841                })
14842            })?
14843        };
14844        for (batch_idx, row_values) in rows.iter().enumerate() {
14845            // Single-column FK fast path: try the parent's BTree
14846            // index for an O(log n) lookup. Composite FKs fall back
14847            // to a parent-row scan.
14848            if fk.local_columns.len() == 1 {
14849                let v = &row_values[fk.local_columns[0]];
14850                if matches!(v, Value::Null) {
14851                    continue;
14852                }
14853                let parent_col = fk.parent_columns[0];
14854                let key = spg_storage::IndexKey::from_value(v).ok_or_else(|| {
14855                    EngineError::Unsupported(alloc::format!(
14856                        "FOREIGN KEY column value of type {:?} is not index-eligible",
14857                        v.data_type()
14858                    ))
14859                })?;
14860                let present_committed = parent.indices().iter().any(|idx| {
14861                    matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14862                        && idx.column_position == parent_col
14863                        && idx.partial_predicate.is_none()
14864                        && !idx.lookup_eq(&key).is_empty()
14865                });
14866                // v7.6.7 self-ref widening: also accept a match
14867                // against earlier rows in this same batch when the
14868                // FK points at the table being inserted into.
14869                let present_in_batch = parent_is_self
14870                    && rows[..batch_idx]
14871                        .iter()
14872                        .any(|earlier| earlier.get(parent_col) == Some(v));
14873                if !(present_committed || present_in_batch) {
14874                    return Err(EngineError::Unsupported(alloc::format!(
14875                        "FOREIGN KEY violation: no parent row in {:?} where {} = {:?}",
14876                        fk.parent_table,
14877                        parent
14878                            .schema()
14879                            .columns
14880                            .get(parent_col)
14881                            .map_or("?", |c| c.name.as_str()),
14882                        v,
14883                    )));
14884                }
14885            } else {
14886                // Composite FK: scan parent rows. v7.6.7 also
14887                // accepts a match against earlier rows in the same
14888                // batch (self-ref bulk-loading of hierarchies).
14889                if fk
14890                    .local_columns
14891                    .iter()
14892                    .all(|&i| matches!(row_values.get(i), Some(Value::Null)))
14893                {
14894                    continue;
14895                }
14896                let local: Vec<&Value> = fk.local_columns.iter().map(|&i| &row_values[i]).collect();
14897                let parent_match_committed = parent.rows().iter().any(|prow| {
14898                    fk.parent_columns
14899                        .iter()
14900                        .enumerate()
14901                        .all(|(i, &pi)| prow.values.get(pi) == Some(local[i]))
14902                });
14903                let parent_match_in_batch = parent_is_self
14904                    && rows[..batch_idx].iter().any(|earlier| {
14905                        fk.parent_columns
14906                            .iter()
14907                            .enumerate()
14908                            .all(|(i, &pi)| earlier.get(pi) == Some(local[i]))
14909                    });
14910                if !(parent_match_committed || parent_match_in_batch) {
14911                    return Err(EngineError::Unsupported(alloc::format!(
14912                        "FOREIGN KEY violation: no parent row in {:?} matching composite key",
14913                        fk.parent_table,
14914                    )));
14915                }
14916            }
14917        }
14918    }
14919    Ok(())
14920}
14921
14922/// v7.6.4 / v7.6.5 — one step of the FK action plan computed for a
14923/// DELETE on a parent. The plan is a list of these steps, stacked
14924/// across the FK graph by `plan_fk_parent_deletions`.
14925#[derive(Debug, Clone)]
14926struct FkChildStep {
14927    child_table: String,
14928    action: FkChildAction,
14929}
14930
14931#[derive(Debug, Clone)]
14932enum FkChildAction {
14933    /// CASCADE — remove these rows. Sorted, deduplicated positions.
14934    Delete { positions: Vec<usize> },
14935    /// SET NULL — for each (row, column) in the flat list, write
14936    /// NULL into that child cell. Multiple FKs on the same row may
14937    /// produce overlapping entries (deduped at plan time).
14938    SetNull {
14939        positions: Vec<usize>,
14940        columns: Vec<usize>,
14941    },
14942    /// SET DEFAULT — same shape as SetNull but writes the column's
14943    /// declared DEFAULT value (resolved at plan time). Columns
14944    /// without a DEFAULT raise an error during planning.
14945    SetDefault {
14946        positions: Vec<usize>,
14947        columns: Vec<usize>,
14948        defaults: Vec<Value>,
14949    },
14950}
14951
14952/// v7.6.3 → v7.6.5 — plan FK fallout for a DELETE on a parent table.
14953///
14954/// Walks every table in the catalog looking for FKs whose
14955/// `parent_table` is `parent_table_name`. For each such FK + each
14956/// to-be-deleted parent row:
14957///
14958///   - RESTRICT / NoAction → error, no plan returned
14959///   - CASCADE → child rows get scheduled for deletion; recursive
14960///   - SetNull → child FK column(s) scheduled to be NULL-ed.
14961///     Verified NULL-able at plan time.
14962///   - SetDefault → child FK column(s) scheduled to be reset to
14963///     their declared DEFAULT. Columns without a DEFAULT raise.
14964///
14965/// SET NULL / SET DEFAULT do NOT cascade further — the child row
14966/// stays; only one of its columns mutates.
14967fn plan_fk_parent_deletions(
14968    catalog: &Catalog,
14969    parent_table_name: &str,
14970    to_delete_positions: &[usize],
14971    to_delete_rows: &[Vec<Value>],
14972) -> Result<Vec<FkChildStep>, EngineError> {
14973    use alloc::collections::{BTreeMap, BTreeSet};
14974    if to_delete_rows.is_empty() {
14975        return Ok(Vec::new());
14976    }
14977    let mut delete_plan: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
14978    // setnull / setdefault keyed by child_table → (row_idx, col_idx) → optional default
14979    let mut setnull_plan: BTreeMap<String, BTreeSet<(usize, usize)>> = BTreeMap::new();
14980    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
14981    let mut visited: BTreeSet<(String, usize)> = BTreeSet::new();
14982    for &p in to_delete_positions {
14983        visited.insert((parent_table_name.to_string(), p));
14984    }
14985    let mut work: Vec<(String, Vec<Value>)> = to_delete_rows
14986        .iter()
14987        .map(|r| (parent_table_name.to_string(), r.clone()))
14988        .collect();
14989    while let Some((cur_parent, parent_row)) = work.pop() {
14990        for child_name in catalog.table_names() {
14991            let child = catalog
14992                .get(&child_name)
14993                .expect("table_names → catalog.get round-trip is total");
14994            for fk in &child.schema().foreign_keys {
14995                if fk.parent_table != cur_parent {
14996                    continue;
14997                }
14998                let parent_key: Vec<&Value> = fk
14999                    .parent_columns
15000                    .iter()
15001                    .map(|&pi| &parent_row[pi])
15002                    .collect();
15003                if parent_key.iter().any(|v| matches!(v, Value::Null)) {
15004                    continue;
15005                }
15006                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
15007                    if child_name == cur_parent
15008                        && visited.contains(&(child_name.clone(), child_row_idx))
15009                    {
15010                        continue;
15011                    }
15012                    let matches_key = fk
15013                        .local_columns
15014                        .iter()
15015                        .enumerate()
15016                        .all(|(i, &li)| child_row.values.get(li) == Some(parent_key[i]));
15017                    if !matches_key {
15018                        continue;
15019                    }
15020                    match fk.on_delete {
15021                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
15022                            return Err(EngineError::Unsupported(alloc::format!(
15023                                "FOREIGN KEY violation: DELETE on {cur_parent:?} is \
15024                                 restricted by FK from {child_name:?}.{:?}",
15025                                fk.local_columns,
15026                            )));
15027                        }
15028                        spg_storage::FkAction::Cascade => {
15029                            if visited.insert((child_name.clone(), child_row_idx)) {
15030                                delete_plan
15031                                    .entry(child_name.clone())
15032                                    .or_default()
15033                                    .insert(child_row_idx);
15034                                work.push((child_name.clone(), child_row.values.clone()));
15035                            }
15036                        }
15037                        spg_storage::FkAction::SetNull => {
15038                            // Verify every local FK column is NULL-able.
15039                            for &li in &fk.local_columns {
15040                                let col = child.schema().columns.get(li).ok_or_else(|| {
15041                                    EngineError::Unsupported(alloc::format!(
15042                                        "FK local column {li} missing in {child_name:?}"
15043                                    ))
15044                                })?;
15045                                if !col.nullable {
15046                                    return Err(EngineError::Unsupported(alloc::format!(
15047                                        "FOREIGN KEY ON DELETE SET NULL: column \
15048                                         {child_name:?}.{:?} is NOT NULL — cannot SET NULL",
15049                                        col.name,
15050                                    )));
15051                                }
15052                            }
15053                            let entry = setnull_plan.entry(child_name.clone()).or_default();
15054                            for &li in &fk.local_columns {
15055                                entry.insert((child_row_idx, li));
15056                            }
15057                        }
15058                        spg_storage::FkAction::SetDefault => {
15059                            // Resolve the DEFAULT for every local FK col.
15060                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
15061                            for &li in &fk.local_columns {
15062                                let col = child.schema().columns.get(li).ok_or_else(|| {
15063                                    EngineError::Unsupported(alloc::format!(
15064                                        "FK local column {li} missing in {child_name:?}"
15065                                    ))
15066                                })?;
15067                                let default = col.default.clone().ok_or_else(|| {
15068                                    EngineError::Unsupported(alloc::format!(
15069                                        "FOREIGN KEY ON DELETE SET DEFAULT: column \
15070                                         {child_name:?}.{:?} has no DEFAULT declared",
15071                                        col.name,
15072                                    ))
15073                                })?;
15074                                entry.insert((child_row_idx, li), default);
15075                            }
15076                        }
15077                    }
15078                }
15079            }
15080        }
15081    }
15082    // Flatten the three plans into the ordered `FkChildStep` list.
15083    // Deletes are applied last per child (after any null/default
15084    // re-writes on the same child) so a child row that's both
15085    // re-written and then cascade-deleted only ends up deleted —
15086    // but in v7.6.5 SetNull/Cascade never overlap on the same row
15087    // (a single FK chooses exactly one action), so the order is
15088    // mostly a precaution.
15089    let mut steps: Vec<FkChildStep> = Vec::new();
15090    for (child_table, entries) in setnull_plan {
15091        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
15092        steps.push(FkChildStep {
15093            child_table,
15094            action: FkChildAction::SetNull { positions, columns },
15095        });
15096    }
15097    for (child_table, entries) in setdefault_plan {
15098        let mut positions = Vec::with_capacity(entries.len());
15099        let mut columns = Vec::with_capacity(entries.len());
15100        let mut defaults = Vec::with_capacity(entries.len());
15101        for ((p, c), v) in entries {
15102            positions.push(p);
15103            columns.push(c);
15104            defaults.push(v);
15105        }
15106        steps.push(FkChildStep {
15107            child_table,
15108            action: FkChildAction::SetDefault {
15109                positions,
15110                columns,
15111                defaults,
15112            },
15113        });
15114    }
15115    for (child_table, positions) in delete_plan {
15116        steps.push(FkChildStep {
15117            child_table,
15118            action: FkChildAction::Delete {
15119                positions: positions.into_iter().collect(),
15120            },
15121        });
15122    }
15123    Ok(steps)
15124}
15125
15126/// v7.6.6 — plan FK fallout for an UPDATE that mutates parent-side
15127/// PK/UNIQUE columns. Walks every other table whose FK references
15128/// `parent_table_name`; for each FK whose parent_columns overlap a
15129/// mutated column, decides the action by `fk.on_update`.
15130///
15131///   - RESTRICT / NoAction → error if any child references the OLD
15132///     value
15133///   - CASCADE → child FK columns get rewritten to the NEW parent
15134///     value (a SetNull-style update step with the new value)
15135///   - SetNull → child FK columns set to NULL
15136///   - SetDefault → child FK columns set to declared default
15137///
15138/// `plan_with_old` is `(row_position, old_values, new_values)` so
15139/// the planner can detect "did this row's parent key actually
15140/// change?" — only rows where at least one referenced parent
15141/// column moved trigger inbound work.
15142fn plan_fk_parent_updates(
15143    catalog: &Catalog,
15144    parent_table_name: &str,
15145    plan_with_old: &[(usize, Vec<Value>, Vec<Value>)],
15146) -> Result<Vec<FkChildStep>, EngineError> {
15147    use alloc::collections::BTreeMap;
15148    if plan_with_old.is_empty() {
15149        return Ok(Vec::new());
15150    }
15151    // For each child table we may touch, build per-child step
15152    // lists. UPDATE never deletes children — `delete_plan` stays
15153    // empty here but is kept structurally aligned with
15154    // `plan_fk_parent_deletions` for future use.
15155    let delete_plan: BTreeMap<String, alloc::collections::BTreeSet<usize>> = BTreeMap::new();
15156    let mut setnull_plan: BTreeMap<String, alloc::collections::BTreeSet<(usize, usize)>> =
15157        BTreeMap::new();
15158    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15159    // Cascade-update plan: child_table → row_idx → col_idx → new_value
15160    let mut cascade_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15161
15162    for child_name in catalog.table_names() {
15163        let child = catalog
15164            .get(&child_name)
15165            .expect("table_names → catalog.get total");
15166        for fk in &child.schema().foreign_keys {
15167            if fk.parent_table != parent_table_name {
15168                continue;
15169            }
15170            for (_pos, old_row, new_row) in plan_with_old {
15171                // Did any parent FK column change?
15172                let key_changed = fk
15173                    .parent_columns
15174                    .iter()
15175                    .any(|&pi| old_row.get(pi) != new_row.get(pi));
15176                if !key_changed {
15177                    continue;
15178                }
15179                // The OLD parent key — used to find referring children.
15180                let old_key: Vec<&Value> =
15181                    fk.parent_columns.iter().map(|&pi| &old_row[pi]).collect();
15182                if old_key.iter().any(|v| matches!(v, Value::Null)) {
15183                    // NULL parent has no children — skip.
15184                    continue;
15185                }
15186                let new_key: Vec<&Value> =
15187                    fk.parent_columns.iter().map(|&pi| &new_row[pi]).collect();
15188                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
15189                    // Self-ref same-row updates: a row updating its
15190                    // own PK doesn't restrict itself.
15191                    if child_name == parent_table_name
15192                        && plan_with_old.iter().any(|(p, _, _)| *p == child_row_idx)
15193                    {
15194                        continue;
15195                    }
15196                    let matches_key = fk
15197                        .local_columns
15198                        .iter()
15199                        .enumerate()
15200                        .all(|(i, &li)| child_row.values.get(li) == Some(old_key[i]));
15201                    if !matches_key {
15202                        continue;
15203                    }
15204                    match fk.on_update {
15205                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
15206                            return Err(EngineError::Unsupported(alloc::format!(
15207                                "FOREIGN KEY violation: UPDATE on {parent_table_name:?} PK is \
15208                                 restricted by FK from {child_name:?}.{:?}",
15209                                fk.local_columns,
15210                            )));
15211                        }
15212                        spg_storage::FkAction::Cascade => {
15213                            // Rewrite child FK columns to new key.
15214                            let entry = cascade_plan.entry(child_name.clone()).or_default();
15215                            for (i, &li) in fk.local_columns.iter().enumerate() {
15216                                entry.insert((child_row_idx, li), new_key[i].clone());
15217                            }
15218                        }
15219                        spg_storage::FkAction::SetNull => {
15220                            for &li in &fk.local_columns {
15221                                let col = child.schema().columns.get(li).ok_or_else(|| {
15222                                    EngineError::Unsupported(alloc::format!(
15223                                        "FK local column {li} missing in {child_name:?}"
15224                                    ))
15225                                })?;
15226                                if !col.nullable {
15227                                    return Err(EngineError::Unsupported(alloc::format!(
15228                                        "FOREIGN KEY ON UPDATE SET NULL: column \
15229                                         {child_name:?}.{:?} is NOT NULL",
15230                                        col.name,
15231                                    )));
15232                                }
15233                            }
15234                            let entry = setnull_plan.entry(child_name.clone()).or_default();
15235                            for &li in &fk.local_columns {
15236                                entry.insert((child_row_idx, li));
15237                            }
15238                        }
15239                        spg_storage::FkAction::SetDefault => {
15240                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
15241                            for &li in &fk.local_columns {
15242                                let col = child.schema().columns.get(li).ok_or_else(|| {
15243                                    EngineError::Unsupported(alloc::format!(
15244                                        "FK local column {li} missing in {child_name:?}"
15245                                    ))
15246                                })?;
15247                                let default = col.default.clone().ok_or_else(|| {
15248                                    EngineError::Unsupported(alloc::format!(
15249                                        "FOREIGN KEY ON UPDATE SET DEFAULT: column \
15250                                         {child_name:?}.{:?} has no DEFAULT",
15251                                        col.name,
15252                                    ))
15253                                })?;
15254                                entry.insert((child_row_idx, li), default);
15255                            }
15256                        }
15257                    }
15258                }
15259            }
15260        }
15261    }
15262    // Flatten into FkChildStep list. UPDATE doesn't produce
15263    // DeleteSteps (CASCADE on UPDATE just rewrites FK values).
15264    let mut steps: Vec<FkChildStep> = Vec::new();
15265    for (child_table, entries) in cascade_plan {
15266        let mut positions = Vec::with_capacity(entries.len());
15267        let mut columns = Vec::with_capacity(entries.len());
15268        let mut defaults = Vec::with_capacity(entries.len());
15269        for ((p, c), v) in entries {
15270            positions.push(p);
15271            columns.push(c);
15272            defaults.push(v);
15273        }
15274        // We reuse `FkChildAction::SetDefault` for cascade-update:
15275        // both shapes are "write a known value into specific cells"
15276        // — `apply_per_cell_writes` doesn't care whether the value
15277        // came from a DEFAULT declaration or a new parent key.
15278        steps.push(FkChildStep {
15279            child_table,
15280            action: FkChildAction::SetDefault {
15281                positions,
15282                columns,
15283                defaults,
15284            },
15285        });
15286    }
15287    for (child_table, entries) in setnull_plan {
15288        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
15289        steps.push(FkChildStep {
15290            child_table,
15291            action: FkChildAction::SetNull { positions, columns },
15292        });
15293    }
15294    for (child_table, entries) in setdefault_plan {
15295        let mut positions = Vec::with_capacity(entries.len());
15296        let mut columns = Vec::with_capacity(entries.len());
15297        let mut defaults = Vec::with_capacity(entries.len());
15298        for ((p, c), v) in entries {
15299            positions.push(p);
15300            columns.push(c);
15301            defaults.push(v);
15302        }
15303        steps.push(FkChildStep {
15304            child_table,
15305            action: FkChildAction::SetDefault {
15306                positions,
15307                columns,
15308                defaults,
15309            },
15310        });
15311    }
15312    let _ = delete_plan; // UPDATE never deletes children.
15313    Ok(steps)
15314}
15315
15316/// v7.6.5 — apply one FK child step to the catalog. Encapsulates
15317/// the three action variants so the DELETE executor stays a
15318/// simple loop over the planned steps.
15319fn apply_fk_child_step(catalog: &mut Catalog, step: &FkChildStep) -> Result<(), EngineError> {
15320    let child = catalog.get_mut(&step.child_table).ok_or_else(|| {
15321        EngineError::Storage(StorageError::TableNotFound {
15322            name: step.child_table.clone(),
15323        })
15324    })?;
15325    match &step.action {
15326        FkChildAction::Delete { positions } => {
15327            let _ = child.delete_rows(positions);
15328        }
15329        FkChildAction::SetNull { positions, columns } => {
15330            apply_per_cell_writes(child, positions, columns, |_| Value::Null)?;
15331        }
15332        FkChildAction::SetDefault {
15333            positions,
15334            columns,
15335            defaults,
15336        } => {
15337            apply_per_cell_writes(child, positions, columns, |i| defaults[i].clone())?;
15338        }
15339    }
15340    Ok(())
15341}
15342
15343/// v7.6.5 — write new values into selected child cells via
15344/// `Table::update_row` (the catalog's existing UPDATE entry).
15345/// Groups writes by row position so multi-column updates on the
15346/// same row only call `update_row` once. `value_for(i)` produces
15347/// the new value for the i-th (position, column) entry.
15348fn apply_per_cell_writes(
15349    child: &mut spg_storage::Table,
15350    positions: &[usize],
15351    columns: &[usize],
15352    mut value_for: impl FnMut(usize) -> Value,
15353) -> Result<(), EngineError> {
15354    use alloc::collections::BTreeMap;
15355    let mut by_row: BTreeMap<usize, Vec<(usize, Value)>> = BTreeMap::new();
15356    for i in 0..positions.len() {
15357        by_row
15358            .entry(positions[i])
15359            .or_default()
15360            .push((columns[i], value_for(i)));
15361    }
15362    for (pos, mutations) in by_row {
15363        let mut new_values = child.rows()[pos].values.clone();
15364        for (col, v) in mutations {
15365            if let Some(slot) = new_values.get_mut(col) {
15366                *slot = v;
15367            }
15368        }
15369        child
15370            .update_row(pos, new_values)
15371            .map_err(EngineError::Storage)?;
15372    }
15373    Ok(())
15374}
15375
15376fn fk_action_sql_to_storage(a: spg_sql::ast::FkAction) -> spg_storage::FkAction {
15377    match a {
15378        spg_sql::ast::FkAction::Restrict => spg_storage::FkAction::Restrict,
15379        spg_sql::ast::FkAction::Cascade => spg_storage::FkAction::Cascade,
15380        spg_sql::ast::FkAction::SetNull => spg_storage::FkAction::SetNull,
15381        spg_sql::ast::FkAction::SetDefault => spg_storage::FkAction::SetDefault,
15382        spg_sql::ast::FkAction::NoAction => spg_storage::FkAction::NoAction,
15383    }
15384}
15385
15386/// v7.9.21 — resolve a column's DEFAULT for INSERT-time
15387/// default-fill. Free fn (rather than `&self`) so callers
15388/// with an active `&mut Table` borrow can still use it.
15389/// Literal defaults take the cached path (`col.default`);
15390/// runtime defaults hit `clock_fn` at each call. mailrs G4.
15391fn resolve_column_default_free(
15392    col: &ColumnSchema,
15393    clock_fn: Option<ClockFn>,
15394) -> Result<Value, EngineError> {
15395    if let Some(rt) = &col.runtime_default {
15396        return eval_runtime_default_free(rt, col.ty, clock_fn);
15397    }
15398    Ok(col.default.clone().unwrap_or(Value::Null))
15399}
15400
15401fn eval_runtime_default_free(
15402    rt: &str,
15403    ty: DataType,
15404    clock_fn: Option<ClockFn>,
15405) -> Result<Value, EngineError> {
15406    let s = rt.trim().to_ascii_lowercase();
15407    // v7.17.0 Phase 2.1 — also strip `(N)` precision suffix
15408    // so MySQL `CURRENT_TIMESTAMP(6)` resolves the same as
15409    // bare `CURRENT_TIMESTAMP`. SPG stores TIMESTAMP at fixed
15410    // microsecond resolution; the precision modifier is
15411    // parser-only.
15412    let with_no_parens = s.trim_end_matches("()");
15413    let canonical: &str = if let Some(open_idx) = with_no_parens.find('(') {
15414        if with_no_parens.ends_with(')') {
15415            &with_no_parens[..open_idx]
15416        } else {
15417            with_no_parens
15418        }
15419    } else {
15420        with_no_parens
15421    };
15422    let now_us = match clock_fn {
15423        Some(f) => f(),
15424        None => 0,
15425    };
15426    let v = match canonical {
15427        "now" | "current_timestamp" | "localtimestamp" => Value::Timestamp(now_us),
15428        "current_date" => Value::Date((now_us / 86_400_000_000) as i32),
15429        "current_time" | "localtime" => Value::Timestamp(now_us),
15430        // v7.17.0 — UUID generators in DEFAULT clauses. Required
15431        // for the canonical Django / Rails / Hibernate `id UUID
15432        // PRIMARY KEY DEFAULT gen_random_uuid()` pattern. Each
15433        // INSERT evaluates the function fresh; the per-row UUID
15434        // is the storage value, not a cached literal.
15435        "gen_random_uuid" | "uuid_generate_v4" => Value::Uuid(eval::gen_random_uuid_bytes()),
15436        other => {
15437            return Err(EngineError::Unsupported(alloc::format!(
15438                "runtime DEFAULT expression {other:?} not supported \
15439                 (v7.17.0 whitelist: now() / current_timestamp / \
15440                 current_date / current_time / localtimestamp / \
15441                 localtime / gen_random_uuid() / \
15442                 uuid_generate_v4())"
15443            )));
15444        }
15445    };
15446    coerce_value(v, ty, "DEFAULT", 0)
15447}
15448
15449/// v7.9.21 — true when a DEFAULT expression needs INSERT-time
15450/// evaluation rather than being cacheable as a literal Value.
15451/// FunctionCall is the immediate case (`now()`,
15452/// `current_timestamp`). Literal expressions and simple sign-
15453/// flipped numerics still take the static-cache path.
15454fn is_runtime_default_expr(expr: &Expr) -> bool {
15455    match expr {
15456        Expr::FunctionCall { .. } => true,
15457        Expr::Unary { expr, .. } => is_runtime_default_expr(expr),
15458        _ => false,
15459    }
15460}
15461
15462/// v7.17.0 Phase 1.4 — INSERT/UPDATE-time enum label check. When
15463/// `col_idx` has a registered label list, the cell value must be
15464/// NULL or one of the labels (case-sensitive per PG).
15465/// v7.17.0 Phase 3.P0-37 — validate + canonicalise a MySQL inline
15466/// SET cell. For non-SET columns this is a no-op pass-through.
15467///
15468/// Semantics:
15469///   * NULL preserved.
15470///   * Empty string → `''` (zero flags).
15471///   * Otherwise split on ',', trim each token, validate every
15472///     token against the column's variant list (error on miss),
15473///     de-dup, then re-emit in DEFINITION order joined by ','.
15474fn canonicalize_set_value(
15475    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
15476    col_idx: usize,
15477    col_name: &str,
15478    value: Value,
15479) -> Result<Value, EngineError> {
15480    let Some(variants) = lookup.get(&col_idx) else {
15481        return Ok(value);
15482    };
15483    match value {
15484        Value::Null => Ok(Value::Null),
15485        Value::Text(s) => {
15486            if s.is_empty() {
15487                return Ok(Value::Text(alloc::string::String::new()));
15488            }
15489            // Collect a presence-set of variant indices to keep
15490            // definition order + handle de-dup in one pass.
15491            let mut present = alloc::vec![false; variants.len()];
15492            for raw in s.split(',') {
15493                let tok = raw.trim();
15494                if tok.is_empty() {
15495                    continue;
15496                }
15497                let idx = variants.iter().position(|v| v == tok).ok_or_else(|| {
15498                    EngineError::Unsupported(alloc::format!(
15499                        "column {col_name:?}: invalid SET token {tok:?}; \
15500                         allowed: {variants:?}"
15501                    ))
15502                })?;
15503                present[idx] = true;
15504            }
15505            // Re-emit in definition order.
15506            let mut out = alloc::string::String::new();
15507            let mut first = true;
15508            for (i, keep) in present.iter().enumerate() {
15509                if !keep {
15510                    continue;
15511                }
15512                if !first {
15513                    out.push(',');
15514                }
15515                first = false;
15516                out.push_str(&variants[i]);
15517            }
15518            Ok(Value::Text(out))
15519        }
15520        other => Err(EngineError::Unsupported(alloc::format!(
15521            "column {col_name:?}: SET-typed column expects TEXT, got {:?}",
15522            other.data_type()
15523        ))),
15524    }
15525}
15526
15527fn enforce_enum_label(
15528    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
15529    col_idx: usize,
15530    col_name: &str,
15531    value: &Value,
15532) -> Result<(), EngineError> {
15533    if let Some(labels) = lookup.get(&col_idx) {
15534        match value {
15535            Value::Null => Ok(()),
15536            Value::Text(s) => {
15537                if labels.iter().any(|l| l == s) {
15538                    Ok(())
15539                } else {
15540                    Err(EngineError::Unsupported(alloc::format!(
15541                        "column {col_name:?}: invalid enum label {s:?}; allowed: {labels:?}"
15542                    )))
15543                }
15544            }
15545            other => Err(EngineError::Unsupported(alloc::format!(
15546                "column {col_name:?}: enum-typed column expects TEXT, got {:?}",
15547                other.data_type()
15548            ))),
15549        }
15550    } else {
15551        Ok(())
15552    }
15553}
15554
15555fn column_def_to_schema(c: ColumnDef) -> Result<ColumnSchema, EngineError> {
15556    let ty = column_type_to_data_type(c.ty);
15557    let mut schema = ColumnSchema::new(c.name.clone(), ty, c.nullable);
15558    // user_type_ref is the raw ident the parser couldn't resolve
15559    // to a built-in; classification into enum vs domain happens
15560    // at exec_create_table where we have catalog access. We
15561    // park it temporarily as user_enum_type and the engine
15562    // promotes domain bindings to user_domain_type before the
15563    // table is stored.
15564    if let Some(name) = c.user_type_ref {
15565        schema.user_enum_type = Some(name);
15566    }
15567    // v7.17.0 Phase 2.1 — render the ON UPDATE expression to
15568    // canonical text (the engine re-parses at UPDATE time).
15569    if let Some(expr) = c.on_update_runtime {
15570        schema.on_update_runtime = Some(alloc::format!("{expr}"));
15571    }
15572    // v7.17.0 Phase 2.5 — bridge the AST `Collation` enum to the
15573    // storage one. Same variants, different crates (spg-storage
15574    // owns no dep on spg-sql).
15575    schema.collation = match c.collation {
15576        spg_sql::ast::Collation::Binary => spg_storage::Collation::Binary,
15577        spg_sql::ast::Collation::CaseInsensitive => spg_storage::Collation::CaseInsensitive,
15578    };
15579    // v7.17.0 Phase 4.4 — MySQL `UNSIGNED` flag propagates to
15580    // storage so engine INSERT / UPDATE can range-check.
15581    schema.is_unsigned = c.is_unsigned;
15582    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant list.
15583    // INSERT validation lives in coerce_value (Text → Text path
15584    // with the column's variant list as the accept-set).
15585    schema.inline_enum_variants = c.inline_enum_variants;
15586    // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
15587    // INSERT canonicalisation (de-dup + sort by definition order)
15588    // lives in the exec_insert path next to the ENUM check.
15589    schema.inline_set_variants = c.inline_set_variants;
15590    if let Some(default_expr) = c.default {
15591        // v7.9.21 — distinguish literal defaults (evaluated once
15592        // at CREATE TABLE) from expression defaults (deferred to
15593        // INSERT). Function calls (`now()`, `current_timestamp`
15594        // — see v7.9.20 keyword promotion) take the runtime path.
15595        // Literals continue to cache. mailrs G4.
15596        if is_runtime_default_expr(&default_expr) {
15597            let display = alloc::format!("{default_expr}");
15598            schema = schema.with_runtime_default(display);
15599        } else {
15600            let raw = literal_expr_to_value(default_expr)?;
15601            let coerced = coerce_value(raw, ty, &c.name, 0)?;
15602            schema = schema.with_default(coerced);
15603        }
15604    }
15605    if c.auto_increment {
15606        // AUTO_INCREMENT only makes sense on integer-shaped columns.
15607        if !matches!(ty, DataType::SmallInt | DataType::Int | DataType::BigInt) {
15608            return Err(EngineError::Unsupported(alloc::format!(
15609                "AUTO_INCREMENT requires an integer column type, got {ty:?}"
15610            )));
15611        }
15612        schema = schema.with_auto_increment();
15613    }
15614    Ok(schema)
15615}
15616
15617/// v7.10.4 — decode a BYTEA literal. Accepts:
15618///   * `\xDEADBEEF` (case-insensitive hex; whitespace stripped)
15619///   * `Hello\000world` (backslash escape form; `\\` for literal backslash)
15620///   * Anything else → raw UTF-8 bytes of the input (PG accepts this too).
15621fn decode_bytea_literal(s: &str) -> Result<alloc::vec::Vec<u8>, &'static str> {
15622    let s = s.trim();
15623    if let Some(hex) = s.strip_prefix("\\x").or_else(|| s.strip_prefix("\\X")) {
15624        // Hex form. Each pair of hex digits → one byte.
15625        let cleaned: alloc::string::String = hex.chars().filter(|c| !c.is_whitespace()).collect();
15626        if cleaned.len() % 2 != 0 {
15627            return Err("odd-length hex literal");
15628        }
15629        let mut out = alloc::vec::Vec::with_capacity(cleaned.len() / 2);
15630        let cleaned_bytes = cleaned.as_bytes();
15631        for i in (0..cleaned_bytes.len()).step_by(2) {
15632            let hi = hex_nibble(cleaned_bytes[i])?;
15633            let lo = hex_nibble(cleaned_bytes[i + 1])?;
15634            out.push((hi << 4) | lo);
15635        }
15636        return Ok(out);
15637    }
15638    // Escape form or raw. Walk char-by-char; `\\` and `\NNN` octal
15639    // sequences decode; anything else is a literal byte.
15640    let bytes = s.as_bytes();
15641    let mut out = alloc::vec::Vec::with_capacity(bytes.len());
15642    let mut i = 0;
15643    while i < bytes.len() {
15644        let b = bytes[i];
15645        if b == b'\\' && i + 1 < bytes.len() {
15646            let n = bytes[i + 1];
15647            if n == b'\\' {
15648                out.push(b'\\');
15649                i += 2;
15650                continue;
15651            }
15652            if n.is_ascii_digit()
15653                && i + 3 < bytes.len()
15654                && bytes[i + 2].is_ascii_digit()
15655                && bytes[i + 3].is_ascii_digit()
15656            {
15657                let oct = |x: u8| (x - b'0') as u32;
15658                let v = oct(n) * 64 + oct(bytes[i + 2]) * 8 + oct(bytes[i + 3]);
15659                if v <= 0xFF {
15660                    out.push(v as u8);
15661                    i += 4;
15662                    continue;
15663                }
15664            }
15665        }
15666        out.push(b);
15667        i += 1;
15668    }
15669    Ok(out)
15670}
15671
15672fn hex_nibble(b: u8) -> Result<u8, &'static str> {
15673    match b {
15674        b'0'..=b'9' => Ok(b - b'0'),
15675        b'a'..=b'f' => Ok(b - b'a' + 10),
15676        b'A'..=b'F' => Ok(b - b'A' + 10),
15677        _ => Err("invalid hex digit"),
15678    }
15679}
15680
15681/// v7.10.11 — decode a PG TEXT[] external array form
15682/// (`{a,b,NULL}` with optional double-quoted elements). The
15683/// engine takes a leading/trailing `{`/`}` and splits at commas.
15684/// Quoted elements (`"hello, world"`) preserve embedded commas;
15685/// `\\` and `\"` decode to literal backslash / quote. Plain
15686/// unquoted `NULL` (case-insensitive) maps to `None`.
15687/// v7.11.13 — pick the array type for `ARRAY[lit, …]` from the
15688/// element values. Single-element-type rules:
15689///   - all NULL / all Text → TextArray
15690///   - all Int (or Int+NULL) → IntArray
15691///   - any BigInt without Text → BigIntArray (widening)
15692///   - any Text → TextArray (fallback; non-string elements
15693///     render as text)
15694fn array_literal_widen(items: alloc::vec::Vec<Value>) -> Value {
15695    let mut has_text = false;
15696    let mut has_bigint = false;
15697    let mut has_int = false;
15698    for v in &items {
15699        match v {
15700            Value::Null => {}
15701            Value::Text(_) | Value::Json(_) => has_text = true,
15702            Value::BigInt(_) => has_bigint = true,
15703            Value::Int(_) | Value::SmallInt(_) => has_int = true,
15704            _ => has_text = true,
15705        }
15706    }
15707    if has_text || (!has_bigint && !has_int) {
15708        let out: alloc::vec::Vec<Option<alloc::string::String>> = items
15709            .into_iter()
15710            .map(|v| match v {
15711                Value::Null => None,
15712                Value::Text(s) | Value::Json(s) => Some(s),
15713                other => Some(alloc::format!("{other:?}")),
15714            })
15715            .collect();
15716        return Value::TextArray(out);
15717    }
15718    if has_bigint {
15719        let out: alloc::vec::Vec<Option<i64>> = items
15720            .into_iter()
15721            .map(|v| match v {
15722                Value::Null => None,
15723                Value::Int(n) => Some(i64::from(n)),
15724                Value::SmallInt(n) => Some(i64::from(n)),
15725                Value::BigInt(n) => Some(n),
15726                _ => unreachable!("widen: unexpected non-integer in BigInt path"),
15727            })
15728            .collect();
15729        return Value::BigIntArray(out);
15730    }
15731    let out: alloc::vec::Vec<Option<i32>> = items
15732        .into_iter()
15733        .map(|v| match v {
15734            Value::Null => None,
15735            Value::Int(n) => Some(n),
15736            Value::SmallInt(n) => Some(i32::from(n)),
15737            _ => unreachable!("widen: unexpected non-i32-compatible in Int path"),
15738        })
15739        .collect();
15740    Value::IntArray(out)
15741}
15742
15743fn decode_text_array_literal(
15744    s: &str,
15745) -> Result<alloc::vec::Vec<Option<alloc::string::String>>, &'static str> {
15746    let trimmed = s.trim();
15747    let inner = trimmed
15748        .strip_prefix('{')
15749        .and_then(|x| x.strip_suffix('}'))
15750        .ok_or("TEXT[] literal must be enclosed in '{...}'")?;
15751    let mut out: alloc::vec::Vec<Option<alloc::string::String>> = alloc::vec::Vec::new();
15752    if inner.trim().is_empty() {
15753        return Ok(out);
15754    }
15755    let bytes = inner.as_bytes();
15756    let mut i = 0;
15757    while i <= bytes.len() {
15758        // Skip leading whitespace.
15759        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
15760            i += 1;
15761        }
15762        // Quoted element.
15763        if i < bytes.len() && bytes[i] == b'"' {
15764            i += 1; // open quote
15765            let mut buf = alloc::string::String::new();
15766            while i < bytes.len() && bytes[i] != b'"' {
15767                if bytes[i] == b'\\' && i + 1 < bytes.len() {
15768                    buf.push(bytes[i + 1] as char);
15769                    i += 2;
15770                } else {
15771                    buf.push(bytes[i] as char);
15772                    i += 1;
15773                }
15774            }
15775            if i >= bytes.len() {
15776                return Err("unterminated quoted element");
15777            }
15778            i += 1; // close quote
15779            out.push(Some(buf));
15780        } else {
15781            // Unquoted element — read until next comma or end.
15782            let start = i;
15783            while i < bytes.len() && bytes[i] != b',' {
15784                i += 1;
15785            }
15786            let raw = inner[start..i].trim();
15787            if raw.eq_ignore_ascii_case("NULL") {
15788                out.push(None);
15789            } else {
15790                out.push(Some(alloc::string::ToString::to_string(raw)));
15791            }
15792        }
15793        // Skip whitespace, expect comma or end.
15794        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
15795            i += 1;
15796        }
15797        if i >= bytes.len() {
15798            break;
15799        }
15800        if bytes[i] != b',' {
15801            return Err("expected ',' between TEXT[] elements");
15802        }
15803        i += 1;
15804    }
15805    Ok(out)
15806}
15807
15808/// v7.10.11 — encode a TEXT[] back into the PG external array
15809/// form. NULL elements become the literal `NULL`; elements
15810/// containing commas, quotes, backslashes, or braces are
15811/// double-quoted with `\\` / `\"` escapes.
15812fn encode_text_array(items: &[Option<alloc::string::String>]) -> alloc::string::String {
15813    let mut out = alloc::string::String::with_capacity(2 + items.len() * 8);
15814    out.push('{');
15815    for (i, item) in items.iter().enumerate() {
15816        if i > 0 {
15817            out.push(',');
15818        }
15819        match item {
15820            None => out.push_str("NULL"),
15821            Some(s) => {
15822                let needs_quote = s.is_empty()
15823                    || s.eq_ignore_ascii_case("NULL")
15824                    || s.chars()
15825                        .any(|c| matches!(c, ',' | '{' | '}' | '"' | '\\' | ' ' | '\t'));
15826                if needs_quote {
15827                    out.push('"');
15828                    for c in s.chars() {
15829                        if c == '"' || c == '\\' {
15830                            out.push('\\');
15831                        }
15832                        out.push(c);
15833                    }
15834                    out.push('"');
15835                } else {
15836                    out.push_str(s);
15837                }
15838            }
15839        }
15840    }
15841    out.push('}');
15842    out
15843}
15844
15845/// v7.10.4 — encode BYTEA bytes in PG hex output format
15846/// (`\x` prefix, lowercase hex pairs). Used by Text-side
15847/// round-trip + the wire layer's text-mode encoder.
15848fn encode_bytea_hex(b: &[u8]) -> alloc::string::String {
15849    let mut out = alloc::string::String::with_capacity(2 + 2 * b.len());
15850    out.push_str("\\x");
15851    for byte in b {
15852        let hi = byte >> 4;
15853        let lo = byte & 0x0F;
15854        out.push(hex_digit(hi));
15855        out.push(hex_digit(lo));
15856    }
15857    out
15858}
15859
15860const fn hex_digit(n: u8) -> char {
15861    match n {
15862        0..=9 => (b'0' + n) as char,
15863        10..=15 => (b'a' + n - 10) as char,
15864        _ => '?',
15865    }
15866}
15867
15868/// v7.17.0 Phase 3.P0-39 — parse a PG `hstore` text literal into
15869/// a flat key→value map. Empty string → empty map. Duplicate
15870/// keys take last-write-wins (matches PG `hstore_in`).
15871///
15872/// Accepted shapes (minimal subset):
15873///   * `'a=>1, b=>2'`            — bareword keys/values
15874///   * `'"a"=>"1", "b"=>"2"'`    — quoted keys/values
15875///   * `'a=>NULL'`               — case-insensitive NULL token
15876///     surfaces as `None` (no quotes around NULL)
15877///
15878/// Returns None on parse failure → caller surfaces as hard error.
15879fn parse_hstore_str(
15880    s: &str,
15881) -> Option<Vec<(alloc::string::String, Option<alloc::string::String>)>> {
15882    let bytes = s.as_bytes();
15883    let mut i = 0;
15884    let mut out: Vec<(alloc::string::String, Option<alloc::string::String>)> = Vec::new();
15885    let skip_ws = |bytes: &[u8], i: &mut usize| {
15886        while *i < bytes.len() && matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r') {
15887            *i += 1;
15888        }
15889    };
15890    let parse_token = |bytes: &[u8], i: &mut usize| -> Option<alloc::string::String> {
15891        if *i >= bytes.len() {
15892            return None;
15893        }
15894        if bytes[*i] == b'"' {
15895            *i += 1;
15896            let mut out = alloc::string::String::new();
15897            while *i < bytes.len() {
15898                match bytes[*i] {
15899                    b'"' => {
15900                        *i += 1;
15901                        return Some(out);
15902                    }
15903                    b'\\' if *i + 1 < bytes.len() => {
15904                        out.push(bytes[*i + 1] as char);
15905                        *i += 2;
15906                    }
15907                    c => {
15908                        out.push(c as char);
15909                        *i += 1;
15910                    }
15911                }
15912            }
15913            None
15914        } else {
15915            let start = *i;
15916            while *i < bytes.len()
15917                && !matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r' | b',' | b'=')
15918            {
15919                *i += 1;
15920            }
15921            if *i == start {
15922                return None;
15923            }
15924            Some(alloc::str::from_utf8(&bytes[start..*i]).ok()?.to_string())
15925        }
15926    };
15927    skip_ws(bytes, &mut i);
15928    while i < bytes.len() {
15929        let key = parse_token(bytes, &mut i)?;
15930        skip_ws(bytes, &mut i);
15931        if i + 1 >= bytes.len() || bytes[i] != b'=' || bytes[i + 1] != b'>' {
15932            return None;
15933        }
15934        i += 2;
15935        skip_ws(bytes, &mut i);
15936        // Check for unquoted NULL token (case-insensitive).
15937        let val_token = if i + 4 <= bytes.len()
15938            && bytes[i..i + 4].eq_ignore_ascii_case(b"NULL")
15939            && (i + 4 == bytes.len() || matches!(bytes[i + 4], b' ' | b'\t' | b',' | b'\n' | b'\r'))
15940        {
15941            i += 4;
15942            None
15943        } else {
15944            Some(parse_token(bytes, &mut i)?)
15945        };
15946        // Replace any existing entry with the same key (last-wins).
15947        if let Some(pos) = out.iter().position(|(k, _)| k == &key) {
15948            out[pos] = (key, val_token);
15949        } else {
15950            out.push((key, val_token));
15951        }
15952        skip_ws(bytes, &mut i);
15953        if i >= bytes.len() {
15954            break;
15955        }
15956        if bytes[i] == b',' {
15957            i += 1;
15958            skip_ws(bytes, &mut i);
15959            continue;
15960        }
15961        return None;
15962    }
15963    Some(out)
15964}
15965
15966/// v7.17.0 Phase 3.P0-39 — render a hstore as canonical PG text
15967/// form `"k"=>"v"` (keys and non-NULL values always quoted;
15968/// NULL token is bare).
15969fn format_hstore_str(
15970    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
15971) -> alloc::string::String {
15972    let mut out = alloc::string::String::new();
15973    for (i, (k, v)) in pairs.iter().enumerate() {
15974        if i > 0 {
15975            out.push_str(", ");
15976        }
15977        out.push('"');
15978        out.push_str(k);
15979        out.push_str("\"=>");
15980        match v {
15981            None => out.push_str("NULL"),
15982            Some(val) => {
15983                out.push('"');
15984                out.push_str(val);
15985                out.push('"');
15986            }
15987        }
15988    }
15989    out
15990}
15991
15992/// v7.17.0 Phase 3.P0-39 — pub re-export so pgwire + sqllogictest
15993/// share the single hstore renderer.
15994pub fn format_hstore_text(
15995    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
15996) -> alloc::string::String {
15997    format_hstore_str(pairs)
15998}
15999
16000// ─── v7.17.0 Phase 3.P0-40 — 2D array parse + display ─────────
16001
16002/// Split a PG external 2D-array literal `'{{a,b},{c,d}}'` into
16003/// per-row token lists. Returns Err on shape mismatch.
16004fn split_2d_literal(s: &str) -> Result<Vec<Vec<alloc::string::String>>, &'static str> {
16005    let s = s.trim();
16006    let outer = s
16007        .strip_prefix('{')
16008        .and_then(|x| x.strip_suffix('}'))
16009        .ok_or("missing outer '{...}' braces")?;
16010    let trimmed = outer.trim();
16011    if trimmed.is_empty() {
16012        return Ok(Vec::new());
16013    }
16014    let mut rows: Vec<Vec<alloc::string::String>> = Vec::new();
16015    let mut i = 0;
16016    let bytes = trimmed.as_bytes();
16017    while i < bytes.len() {
16018        while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r' | b',') {
16019            i += 1;
16020        }
16021        if i >= bytes.len() {
16022            break;
16023        }
16024        if bytes[i] != b'{' {
16025            return Err("expected '{' opening a row");
16026        }
16027        i += 1;
16028        let row_start = i;
16029        let mut depth = 1;
16030        while i < bytes.len() && depth > 0 {
16031            match bytes[i] {
16032                b'{' => depth += 1,
16033                b'}' => depth -= 1,
16034                _ => {}
16035            }
16036            if depth > 0 {
16037                i += 1;
16038            }
16039        }
16040        if depth != 0 {
16041            return Err("unbalanced '{...}' in row");
16042        }
16043        let row_text = &trimmed[row_start..i];
16044        i += 1;
16045        let cells: Vec<alloc::string::String> = if row_text.trim().is_empty() {
16046            Vec::new()
16047        } else {
16048            row_text.split(',').map(|t| t.trim().to_string()).collect()
16049        };
16050        rows.push(cells);
16051    }
16052    if let Some(first) = rows.first() {
16053        let cols = first.len();
16054        for r in &rows {
16055            if r.len() != cols {
16056                return Err("ragged 2D array (rows have different column counts)");
16057            }
16058        }
16059    }
16060    Ok(rows)
16061}
16062
16063fn parse_int_2d_literal(s: &str) -> Result<Vec<Vec<Option<i32>>>, &'static str> {
16064    let raw = split_2d_literal(s)?;
16065    raw.into_iter()
16066        .map(|row| {
16067            row.into_iter()
16068                .map(|cell| {
16069                    if cell.eq_ignore_ascii_case("NULL") {
16070                        Ok(None)
16071                    } else {
16072                        cell.parse::<i32>()
16073                            .map(Some)
16074                            .map_err(|_| "invalid int element")
16075                    }
16076                })
16077                .collect()
16078        })
16079        .collect()
16080}
16081
16082fn parse_bigint_2d_literal(s: &str) -> Result<Vec<Vec<Option<i64>>>, &'static str> {
16083    let raw = split_2d_literal(s)?;
16084    raw.into_iter()
16085        .map(|row| {
16086            row.into_iter()
16087                .map(|cell| {
16088                    if cell.eq_ignore_ascii_case("NULL") {
16089                        Ok(None)
16090                    } else {
16091                        cell.parse::<i64>()
16092                            .map(Some)
16093                            .map_err(|_| "invalid bigint element")
16094                    }
16095                })
16096                .collect()
16097        })
16098        .collect()
16099}
16100
16101fn parse_text_2d_literal(s: &str) -> Result<Vec<Vec<Option<alloc::string::String>>>, &'static str> {
16102    let raw = split_2d_literal(s)?;
16103    Ok(raw
16104        .into_iter()
16105        .map(|row| {
16106            row.into_iter()
16107                .map(|cell| {
16108                    if cell.eq_ignore_ascii_case("NULL") {
16109                        None
16110                    } else {
16111                        Some(cell.trim_matches('"').to_string())
16112                    }
16113                })
16114                .collect()
16115        })
16116        .collect())
16117}
16118
16119fn format_int_2d_text(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
16120    let mut out = alloc::string::String::from("{");
16121    for (i, row) in rows.iter().enumerate() {
16122        if i > 0 {
16123            out.push(',');
16124        }
16125        out.push('{');
16126        for (j, cell) in row.iter().enumerate() {
16127            if j > 0 {
16128                out.push(',');
16129            }
16130            match cell {
16131                None => out.push_str("NULL"),
16132                Some(n) => out.push_str(&alloc::format!("{n}")),
16133            }
16134        }
16135        out.push('}');
16136    }
16137    out.push('}');
16138    out
16139}
16140
16141fn format_bigint_2d_text(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
16142    let mut out = alloc::string::String::from("{");
16143    for (i, row) in rows.iter().enumerate() {
16144        if i > 0 {
16145            out.push(',');
16146        }
16147        out.push('{');
16148        for (j, cell) in row.iter().enumerate() {
16149            if j > 0 {
16150                out.push(',');
16151            }
16152            match cell {
16153                None => out.push_str("NULL"),
16154                Some(n) => out.push_str(&alloc::format!("{n}")),
16155            }
16156        }
16157        out.push('}');
16158    }
16159    out.push('}');
16160    out
16161}
16162
16163fn format_text_2d_text(rows: &[Vec<Option<alloc::string::String>>]) -> alloc::string::String {
16164    let mut out = alloc::string::String::from("{");
16165    for (i, row) in rows.iter().enumerate() {
16166        if i > 0 {
16167            out.push(',');
16168        }
16169        out.push('{');
16170        for (j, cell) in row.iter().enumerate() {
16171            if j > 0 {
16172                out.push(',');
16173            }
16174            match cell {
16175                None => out.push_str("NULL"),
16176                Some(s) => out.push_str(s),
16177            }
16178        }
16179        out.push('}');
16180    }
16181    out.push('}');
16182    out
16183}
16184
16185/// v7.17.0 Phase 3.P0-40 — pub re-exports so pgwire + sqllogictest
16186/// share the single 2D-array renderer.
16187pub fn format_int_2d_text_pub(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
16188    format_int_2d_text(rows)
16189}
16190pub fn format_bigint_2d_text_pub(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
16191    format_bigint_2d_text(rows)
16192}
16193pub fn format_text_2d_text_pub(
16194    rows: &[Vec<Option<alloc::string::String>>],
16195) -> alloc::string::String {
16196    format_text_2d_text(rows)
16197}
16198
16199/// v7.17.0 Phase 3.P0-38 — parse a PG range literal of the form
16200/// `'[lo,up)'` / `'(lo,up]'` / `'[lo,up]'` / `'(lo,up)'` /
16201/// `'empty'`. Lower / upper may be empty (unbounded). Returns
16202/// `None` on any parse failure; caller surfaces as hard error.
16203fn parse_range_str(s: &str, kind: spg_storage::RangeKind) -> Option<Value> {
16204    let s = s.trim();
16205    if s.eq_ignore_ascii_case("empty") {
16206        return Some(Value::Range {
16207            kind,
16208            lower: None,
16209            upper: None,
16210            lower_inc: false,
16211            upper_inc: false,
16212            empty: true,
16213        });
16214    }
16215    let bytes = s.as_bytes();
16216    if bytes.len() < 3 {
16217        return None;
16218    }
16219    let lower_inc = match bytes[0] {
16220        b'[' => true,
16221        b'(' => false,
16222        _ => return None,
16223    };
16224    let upper_inc = match bytes[bytes.len() - 1] {
16225        b']' => true,
16226        b')' => false,
16227        _ => return None,
16228    };
16229    let inner = &s[1..s.len() - 1];
16230    let (lo_text, up_text) = inner.split_once(',')?;
16231    let lower = if lo_text.is_empty() {
16232        None
16233    } else {
16234        Some(alloc::boxed::Box::new(parse_range_element(lo_text, kind)?))
16235    };
16236    let upper = if up_text.is_empty() {
16237        None
16238    } else {
16239        Some(alloc::boxed::Box::new(parse_range_element(up_text, kind)?))
16240    };
16241    Some(Value::Range {
16242        kind,
16243        lower,
16244        upper,
16245        lower_inc,
16246        upper_inc,
16247        empty: false,
16248    })
16249}
16250
16251/// v7.17.0 Phase 3.P0-38 — parse a single range bound text into
16252/// the matching element Value for the RangeKind.
16253fn parse_range_element(text: &str, kind: spg_storage::RangeKind) -> Option<Value> {
16254    let text = text.trim().trim_matches('"');
16255    use spg_storage::RangeKind as K;
16256    match kind {
16257        K::Int4 => text.parse::<i32>().ok().map(Value::Int),
16258        K::Int8 => text.parse::<i64>().ok().map(Value::BigInt),
16259        K::Num => {
16260            // Reuse the Numeric parse via the engine's text-coercion
16261            // path; bail to None on failure.
16262            let dot = text.find('.');
16263            let scale: u8 = dot.map_or(0, |p| (text.len() - p - 1) as u8);
16264            let digits: alloc::string::String = text
16265                .chars()
16266                .filter(|c| *c == '-' || c.is_ascii_digit())
16267                .collect();
16268            let scaled: i128 = digits.parse().ok()?;
16269            Some(Value::Numeric { scaled, scale })
16270        }
16271        K::Ts | K::TsTz => {
16272            // Reuse the existing timestamp parse path. v7.17.0
16273            // expects `'YYYY-MM-DD HH:MM:SS[.ffffff]'` in range
16274            // bounds (TZ offset on TsTz is OOS for the initial
16275            // P0-38; ship plain Timestamp shape).
16276            crate::eval::parse_timestamp_literal(text).map(Value::Timestamp)
16277        }
16278        K::Date => crate::eval::parse_date_literal(text).map(Value::Date),
16279    }
16280}
16281
16282/// v7.17.0 Phase 3.P0-38 — render a Range value as its canonical
16283/// PG text form. Re-exported via [`format_range_text`] for use
16284/// from spg-server's pgwire layer.
16285pub fn format_range_text(v: &Value) -> alloc::string::String {
16286    format_range_str(v)
16287}
16288
16289fn format_range_str(v: &Value) -> alloc::string::String {
16290    let Value::Range {
16291        lower,
16292        upper,
16293        lower_inc,
16294        upper_inc,
16295        empty,
16296        ..
16297    } = v
16298    else {
16299        return alloc::string::String::new();
16300    };
16301    if *empty {
16302        return "empty".into();
16303    }
16304    let mut out = alloc::string::String::new();
16305    out.push(if *lower_inc { '[' } else { '(' });
16306    if let Some(l) = lower {
16307        out.push_str(&format_range_element(l));
16308    }
16309    out.push(',');
16310    if let Some(u) = upper {
16311        out.push_str(&format_range_element(u));
16312    }
16313    out.push(if *upper_inc { ']' } else { ')' });
16314    out
16315}
16316
16317fn format_range_element(v: &Value) -> alloc::string::String {
16318    match v {
16319        Value::Int(n) => alloc::format!("{n}"),
16320        Value::BigInt(n) => alloc::format!("{n}"),
16321        Value::Date(d) => crate::eval::format_date(*d),
16322        Value::Timestamp(t) => crate::eval::format_timestamp(*t),
16323        Value::Numeric { scaled, scale } => crate::eval::format_numeric(*scaled, *scale),
16324        other => alloc::format!("{other:?}"),
16325    }
16326}
16327
16328/// v7.17.0 Phase 3.P0-35 — parse a PG `money` literal into i64
16329/// cents. Accepts:
16330///   * Optional leading `-` (negative)
16331///   * Optional `$` prefix
16332///   * Integer portion with optional `,` thousands separators
16333///   * Optional `.` followed by 1-2 digits (cents); 1 digit
16334///     auto-pads to 2 (`.5` → 50 cents).
16335///
16336/// Returns None on any parse failure — caller surfaces as hard
16337/// SQL error.
16338fn parse_money_str(s: &str) -> Option<i64> {
16339    let s = s.trim();
16340    let (neg, rest) = match s.strip_prefix('-') {
16341        Some(r) => (true, r.trim_start()),
16342        None => (false, s),
16343    };
16344    let rest = rest.strip_prefix('$').unwrap_or(rest).trim_start();
16345    let (int_part, frac_part) = match rest.split_once('.') {
16346        Some((i, f)) => (i, Some(f)),
16347        None => (rest, None),
16348    };
16349    if int_part.is_empty() {
16350        return None;
16351    }
16352    // Validate + strip commas from the integer portion.
16353    let mut int_digits = alloc::string::String::with_capacity(int_part.len());
16354    for b in int_part.bytes() {
16355        match b {
16356            b',' => {}
16357            b'0'..=b'9' => int_digits.push(b as char),
16358            _ => return None,
16359        }
16360    }
16361    if int_digits.is_empty() {
16362        return None;
16363    }
16364    let dollars: i64 = int_digits.parse().ok()?;
16365    let cents: i64 = match frac_part {
16366        None => 0,
16367        Some(f) => {
16368            if f.is_empty() || f.len() > 2 || !f.bytes().all(|b| b.is_ascii_digit()) {
16369                return None;
16370            }
16371            let padded = if f.len() == 1 {
16372                alloc::format!("{f}0")
16373            } else {
16374                f.to_string()
16375            };
16376            padded.parse().ok()?
16377        }
16378    };
16379    let total = dollars.checked_mul(100)?.checked_add(cents)?;
16380    Some(if neg { -total } else { total })
16381}
16382
16383/// v7.17.0 Phase 3.P0-34 — parse a PG `timetz` literal
16384/// `HH:MM:SS[.fraction]±HH[:MM]` into (us, offset_secs).
16385///
16386/// The offset suffix is MANDATORY: SPG doesn't have a session TZ
16387/// wired into eval, so a bare `HH:MM:SS` literal would be
16388/// ambiguous. Returns None for any parse failure or out-of-range
16389/// component — caller surfaces as a hard SQL error.
16390///
16391/// Offset range: ±14 hours (±50400 seconds), matching PG's
16392/// internal limit.
16393fn parse_timetz_str(s: &str) -> Option<(i64, i32)> {
16394    let s = s.trim();
16395    // Find the offset sign — scan from right since the time part
16396    // never contains '+' / '-' (after the optional fractional dot
16397    // it's all digits and ':').
16398    let bytes = s.as_bytes();
16399    let sign_pos = bytes
16400        .iter()
16401        .enumerate()
16402        .rev()
16403        .find(|&(_, &b)| b == b'+' || b == b'-')
16404        .map(|(i, _)| i)?;
16405    if sign_pos == 0 {
16406        return None; // bare sign — no time component
16407    }
16408    let time_part = &s[..sign_pos];
16409    let offset_part = &s[sign_pos..];
16410    let us = parse_time_str(time_part)?;
16411    let sign: i32 = if offset_part.starts_with('+') { 1 } else { -1 };
16412    let offset_body = &offset_part[1..];
16413    let (hh_str, mm_str) = match offset_body.split_once(':') {
16414        Some((h, m)) => (h, m),
16415        None => (offset_body, "0"),
16416    };
16417    let hh: i32 = hh_str.parse().ok()?;
16418    let mm: i32 = mm_str.parse().ok()?;
16419    if !(0..=14).contains(&hh) || !(0..=59).contains(&mm) {
16420        return None;
16421    }
16422    let total = sign * (hh * 3600 + mm * 60);
16423    if total.abs() > 50_400 {
16424        return None;
16425    }
16426    Some((us, total))
16427}
16428
16429/// v7.17.0 Phase 3.P0-33 — funnel an integer literal through MySQL
16430/// YEAR range validation: 0 sentinel or 1901..=2155. Out-of-range
16431/// surfaces as a hard SQL error (no silent truncation, mirrors PG
16432/// `time_in` / `uuid_in` discipline).
16433fn coerce_int_to_year(n: i64, col_name: &str) -> Result<Value, EngineError> {
16434    if n == 0 || (1901..=2155).contains(&n) {
16435        // u16::try_from cannot fail in this range; the cast also
16436        // covers the 0 sentinel.
16437        return Ok(Value::Year(n as u16));
16438    }
16439    Err(EngineError::Eval(EvalError::TypeMismatch {
16440        detail: alloc::format!(
16441            "year value out of range: {n} (column `{col_name}`; \
16442             MySQL accepts 0 or 1901..=2155)"
16443        ),
16444    }))
16445}
16446
16447/// v7.17.0 Phase 3.P0-32 — parse a PG `time` literal
16448/// `HH:MM:SS[.fraction]` into microseconds since 00:00:00.
16449///
16450/// Accepts:
16451///   * `HH:MM:SS`            — exact-second precision
16452///   * `HH:MM:SS.f` .. `.ffffff` — 1-6 fractional digits, right-padded
16453///     with zeros to microseconds
16454///
16455/// Range: hour 0..=23, minute 0..=59, second 0..=59. Anything else
16456/// returns None — caller surfaces as a hard SQL error (no silent
16457/// truncation, matches PG's `time_in` behaviour).
16458fn parse_time_str(s: &str) -> Option<i64> {
16459    let s = s.trim();
16460    let (hms, frac) = match s.split_once('.') {
16461        Some((h, f)) => (h, Some(f)),
16462        None => (s, None),
16463    };
16464    let mut parts = hms.split(':');
16465    let hh: u32 = parts.next()?.parse().ok()?;
16466    let mm: u32 = parts.next()?.parse().ok()?;
16467    let ss: u32 = parts.next()?.parse().ok()?;
16468    if parts.next().is_some() {
16469        return None;
16470    }
16471    if hh > 23 || mm > 59 || ss > 59 {
16472        return None;
16473    }
16474    let frac_us: i64 = match frac {
16475        None => 0,
16476        Some(f) => {
16477            if f.is_empty() || f.len() > 6 || !f.bytes().all(|b| b.is_ascii_digit()) {
16478                return None;
16479            }
16480            // Right-pad with zeros so '.5' = 500000 µsec.
16481            let mut padded = alloc::string::String::with_capacity(6);
16482            padded.push_str(f);
16483            while padded.len() < 6 {
16484                padded.push('0');
16485            }
16486            padded.parse().ok()?
16487        }
16488    };
16489    Some(
16490        i64::from(hh) * 3_600_000_000
16491            + i64::from(mm) * 60_000_000
16492            + i64::from(ss) * 1_000_000
16493            + frac_us,
16494    )
16495}
16496
16497const fn column_type_to_data_type(t: ColumnTypeName) -> DataType {
16498    match t {
16499        ColumnTypeName::SmallInt => DataType::SmallInt,
16500        ColumnTypeName::Int => DataType::Int,
16501        ColumnTypeName::BigInt => DataType::BigInt,
16502        ColumnTypeName::Float => DataType::Float,
16503        ColumnTypeName::Text => DataType::Text,
16504        ColumnTypeName::Varchar(n) => DataType::Varchar(n),
16505        ColumnTypeName::Char(n) => DataType::Char(n),
16506        ColumnTypeName::Bool => DataType::Bool,
16507        ColumnTypeName::Vector { dim, encoding } => DataType::Vector {
16508            dim,
16509            encoding: match encoding {
16510                SqlVecEncoding::F32 => VecEncoding::F32,
16511                SqlVecEncoding::Sq8 => VecEncoding::Sq8,
16512                SqlVecEncoding::F16 => VecEncoding::F16,
16513            },
16514        },
16515        ColumnTypeName::Numeric(precision, scale) => DataType::Numeric { precision, scale },
16516        ColumnTypeName::Date => DataType::Date,
16517        ColumnTypeName::Timestamp => DataType::Timestamp,
16518        ColumnTypeName::Timestamptz => DataType::Timestamptz,
16519        ColumnTypeName::Json => DataType::Json,
16520        ColumnTypeName::Jsonb => DataType::Jsonb,
16521        ColumnTypeName::Bytes => DataType::Bytes,
16522        ColumnTypeName::TextArray => DataType::TextArray,
16523        ColumnTypeName::IntArray => DataType::IntArray,
16524        ColumnTypeName::BigIntArray => DataType::BigIntArray,
16525        ColumnTypeName::TsVector => DataType::TsVector,
16526        ColumnTypeName::TsQuery => DataType::TsQuery,
16527        ColumnTypeName::Uuid => DataType::Uuid,
16528        ColumnTypeName::Time => DataType::Time,
16529        ColumnTypeName::Year => DataType::Year,
16530        ColumnTypeName::TimeTz => DataType::TimeTz,
16531        ColumnTypeName::Money => DataType::Money,
16532        ColumnTypeName::Range(k) => DataType::Range(match k {
16533            spg_sql::ast::RangeKindAst::Int4 => spg_storage::RangeKind::Int4,
16534            spg_sql::ast::RangeKindAst::Int8 => spg_storage::RangeKind::Int8,
16535            spg_sql::ast::RangeKindAst::Num => spg_storage::RangeKind::Num,
16536            spg_sql::ast::RangeKindAst::Ts => spg_storage::RangeKind::Ts,
16537            spg_sql::ast::RangeKindAst::TsTz => spg_storage::RangeKind::TsTz,
16538            spg_sql::ast::RangeKindAst::Date => spg_storage::RangeKind::Date,
16539        }),
16540        ColumnTypeName::Hstore => DataType::Hstore,
16541        ColumnTypeName::IntArray2D => DataType::IntArray2D,
16542        ColumnTypeName::BigIntArray2D => DataType::BigIntArray2D,
16543        ColumnTypeName::TextArray2D => DataType::TextArray2D,
16544    }
16545}
16546
16547/// Convert an INSERT VALUES expression to a storage Value. Supports literal
16548/// expressions, unary-minus over numeric literals, and pgvector-style
16549/// `'[..]'::vector` cast (v1.2). Anything more complex returns `Unsupported`.
16550fn literal_expr_to_value(expr: Expr) -> Result<Value, EngineError> {
16551    match expr {
16552        Expr::Literal(l) => Ok(literal_to_value(l)),
16553        Expr::Cast { expr, target } => {
16554            let inner_value = literal_expr_to_value(*expr)?;
16555            crate::eval::cast_value(inner_value, target).map_err(EngineError::Eval)
16556        }
16557        Expr::Unary {
16558            op: UnOp::Neg,
16559            expr,
16560        } => match *expr {
16561            Expr::Literal(Literal::Integer(n)) => {
16562                // Fold to i32 if it fits, else BigInt. Parser emits Integer(i64)
16563                // — overflow on negate of i64::MIN is the one edge case.
16564                let neg = n.checked_neg().ok_or_else(|| {
16565                    EngineError::Unsupported("integer literal overflow on negation".into())
16566                })?;
16567                Ok(int_value_for(neg))
16568            }
16569            Expr::Literal(Literal::Float(x)) => Ok(Value::Float(-x)),
16570            other => Err(EngineError::Unsupported(alloc::format!(
16571                "unary minus over non-literal expression: {other:?}"
16572            ))),
16573        },
16574        // v7.10.10 — `ARRAY[lit, lit, …]` constructor accepted at
16575        // INSERT-time. Each element must reduce to a Value through
16576        // `literal_expr_to_value`; NULL elements become `None`.
16577        // v7.11.13 — deduce shape from element values: all Int →
16578        // IntArray; any BigInt → BigIntArray (widening); any Text
16579        // → TextArray. Cast targets (`ARRAY[]::INT[]`) flow through
16580        // the outer Cast arm before reaching here and re-coerce.
16581        Expr::Array(items) => {
16582            let mut materialised: alloc::vec::Vec<Value> =
16583                alloc::vec::Vec::with_capacity(items.len());
16584            for elem in items {
16585                materialised.push(literal_expr_to_value(elem)?);
16586            }
16587            Ok(array_literal_widen(materialised))
16588        }
16589        // Any other Expr shape — fall back to a general evaluation
16590        // against an empty row + empty schema. This unblocks the
16591        // app-common patterns where INSERT VALUES carries a
16592        // non-correlated function call:
16593        //   INSERT INTO t VALUES (concat('U-', 42))
16594        //   INSERT INTO t VALUES (now())
16595        //   INSERT INTO t VALUES (format('%s-%s', 'a', 'b'))
16596        // Any expression that references a column or `$N`
16597        // placeholder fails cleanly inside `eval_expr` with a
16598        // descriptive error; literals + casts + ARRAY[…] continue
16599        // to take the fast paths above so the hot INSERT path is
16600        // unchanged on the common case.
16601        other => {
16602            let empty_schema: alloc::vec::Vec<spg_storage::ColumnSchema> = alloc::vec::Vec::new();
16603            let ctx = EvalContext::new(&empty_schema, None);
16604            let empty_row = spg_storage::Row::new(alloc::vec::Vec::new());
16605            crate::eval::eval_expr(&other, &empty_row, &ctx).map_err(EngineError::Eval)
16606        }
16607    }
16608}
16609
16610fn literal_to_value(l: Literal) -> Value {
16611    match l {
16612        Literal::Integer(n) => int_value_for(n),
16613        Literal::Float(x) => Value::Float(x),
16614        Literal::String(s) => Value::Text(s),
16615        Literal::Bool(b) => Value::Bool(b),
16616        Literal::Null => Value::Null,
16617        Literal::Vector(v) => Value::Vector(v),
16618        Literal::Interval { months, micros, .. } => Value::Interval { months, micros },
16619    }
16620}
16621
16622/// Pick `Int` (`i32`) when the literal fits, else `BigInt`. `INT` vs `BIGINT`
16623/// columns will still enforce the right tag downstream — this is just the
16624/// default we synthesise from an unannotated integer literal.
16625fn int_value_for(n: i64) -> Value {
16626    if let Ok(small) = i32::try_from(n) {
16627        Value::Int(small)
16628    } else {
16629        Value::BigInt(n)
16630    }
16631}
16632
16633/// Widen / narrow `v` to fit `expected`. Numerics permit safe widening
16634/// (`Int → BigInt`, `Int/BigInt → Float`) and best-effort narrowing
16635/// (`BigInt → Int` succeeds only when the value fits in `i32`). Everything
16636/// else returns `TypeMismatch` carrying the column name for caller diagnostics.
16637/// `NULL` is always permitted; the nullability check happens later in storage.
16638#[allow(clippy::too_many_lines)]
16639/// v7.17.0 Phase 4.4 — reject negative integer values on UNSIGNED
16640/// columns. Called after `coerce_value` at each INSERT / UPDATE
16641/// site that has ColumnSchema context. NULL passes through (a
16642/// nullable UNSIGNED column can legitimately hold NULL).
16643fn check_unsigned_range(
16644    v: &Value,
16645    schema: &ColumnSchema,
16646    position: usize,
16647) -> Result<(), EngineError> {
16648    if !schema.is_unsigned {
16649        return Ok(());
16650    }
16651    let n = match v {
16652        Value::SmallInt(x) => i64::from(*x),
16653        Value::Int(x) => i64::from(*x),
16654        Value::BigInt(x) => *x,
16655        _ => return Ok(()), // non-integer cells (NULL, default) skip
16656    };
16657    if n < 0 {
16658        return Err(EngineError::Unsupported(alloc::format!(
16659            "column {:?} is UNSIGNED but got negative value {n} at position {position}",
16660            schema.name
16661        )));
16662    }
16663    Ok(())
16664}
16665
16666fn coerce_value(
16667    v: Value,
16668    expected: DataType,
16669    col_name: &str,
16670    position: usize,
16671) -> Result<Value, EngineError> {
16672    if v.is_null() {
16673        return Ok(Value::Null);
16674    }
16675    let actual = v.data_type().expect("non-null");
16676    if actual == expected {
16677        return Ok(v);
16678    }
16679    let coerced = match (v, expected) {
16680        (Value::Int(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
16681        (Value::Int(n), DataType::Float) => Some(Value::Float(f64::from(n))),
16682        (Value::Int(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
16683        (Value::Int(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
16684            i128::from(n),
16685            precision,
16686            scale,
16687            col_name,
16688        )?),
16689        (Value::SmallInt(n), DataType::Int) => Some(Value::Int(i32::from(n))),
16690        (Value::SmallInt(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
16691        (Value::SmallInt(n), DataType::Float) => Some(Value::Float(f64::from(n))),
16692        (Value::SmallInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
16693            i128::from(n),
16694            precision,
16695            scale,
16696            col_name,
16697        )?),
16698        (Value::BigInt(n), DataType::Int) => i32::try_from(n).ok().map(Value::Int),
16699        (Value::BigInt(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
16700        #[allow(clippy::cast_precision_loss)]
16701        (Value::BigInt(n), DataType::Float) => Some(Value::Float(n as f64)),
16702        (Value::BigInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
16703            i128::from(n),
16704            precision,
16705            scale,
16706            col_name,
16707        )?),
16708        (Value::Float(x), DataType::Numeric { precision, scale }) => {
16709            Some(numeric_from_float(x, precision, scale, col_name)?)
16710        }
16711        // v7.17.0 Phase 3.P0-67 — Text → NUMERIC. Parse a
16712        // canonical decimal text (`"-1234.56"` / `"42"` /
16713        // `"0.0001"`) into `(mantissa, source_scale)` and rescale
16714        // to the column's declared scale. Required for prepared
16715        // binds: `value_to_literal` flattens a Value::Numeric
16716        // into a TEXT literal because Literal carries no native
16717        // Numeric variant, so the placeholder substitution path
16718        // reaches coerce_value as Text → Numeric. Without this
16719        // arm the round-trip surfaces a TypeMismatch even though
16720        // the cell already left the engine as a valid Numeric.
16721        (Value::Text(s), DataType::Numeric { precision, scale }) => {
16722            let Some((mantissa, src_scale)) = parse_numeric_text(&s) else {
16723                return Err(EngineError::Eval(EvalError::TypeMismatch {
16724                    detail: alloc::format!("cannot parse {s:?} as NUMERIC for column `{col_name}`"),
16725                }));
16726            };
16727            Some(numeric_rescale(
16728                mantissa, src_scale, precision, scale, col_name,
16729            )?)
16730        }
16731        // Text → DATE / TIMESTAMP: parse canonical text forms.
16732        (Value::Text(s), DataType::Date) => {
16733            let d = eval::parse_date_literal(&s).ok_or_else(|| {
16734                EngineError::Eval(EvalError::TypeMismatch {
16735                    detail: alloc::format!("cannot parse {s:?} as DATE for column `{col_name}`"),
16736                })
16737            })?;
16738            Some(Value::Date(d))
16739        }
16740        // v7.14.0 — MySQL DEFAULT clauses quote integer / float
16741        // / boolean literals (`DEFAULT '0'`, `DEFAULT '1'`,
16742        // `DEFAULT '3.14'`, `DEFAULT 'true'`). Coerce the text
16743        // form to the column's numeric / bool type at DEFAULT-
16744        // installation time so the storage check sees a typed
16745        // value. Parse failures fall through to TypeMismatch.
16746        (Value::Text(s), DataType::SmallInt) => s.parse::<i16>().ok().map(Value::SmallInt),
16747        (Value::Text(s), DataType::Int) => s.parse::<i32>().ok().map(Value::Int),
16748        (Value::Text(s), DataType::BigInt) => s.parse::<i64>().ok().map(Value::BigInt),
16749        (Value::Text(s), DataType::Float) => s.parse::<f64>().ok().map(Value::Float),
16750        (Value::Text(s), DataType::Bool) => match s.to_ascii_lowercase().as_str() {
16751            "0" | "false" | "f" | "no" | "off" => Some(Value::Bool(false)),
16752            "1" | "true" | "t" | "yes" | "on" => Some(Value::Bool(true)),
16753            _ => None,
16754        },
16755        // v7.17.0 Phase 3.P0-46 — MySQL TINYINT(1) (which Phase 4.3
16756        // classifies as DataType::Bool) is the storage shape every
16757        // mysqldump-restored boolean column lands in. mysqldump emits
16758        // the values as integer `0` / `1` literals, so int → bool
16759        // coerce on INSERT is required for a 0-change cutover. MySQL's
16760        // rule is "any non-zero is truthy"; we follow that for all
16761        // signed int widths so the same coerce path serves an
16762        // explicit `BOOLEAN` column too.
16763        (Value::Int(n), DataType::Bool) => Some(Value::Bool(n != 0)),
16764        (Value::SmallInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
16765        (Value::BigInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
16766        // v4.9: Text ↔ JSON coercion. No structural validation —
16767        // any text literal is accepted; the responsibility for
16768        // valid JSON lies with the producer.
16769        (Value::Text(s), DataType::Json | DataType::Jsonb) => Some(Value::Json(s)),
16770        (Value::Json(s), DataType::Text) => Some(Value::Text(s)),
16771        // v7.13.3 — mailrs round-7 S10. SPG's storage represents
16772        // both JSON and JSONB on-disk as `Value::Json(String)` —
16773        // they share the underlying text payload. The cast
16774        // `'<text>'::jsonb` produces a Value::Json that needs to
16775        // satisfy a DataType::Jsonb column. Identity coerce in
16776        // both directions so JSON ↔ JSONB assignments work at all
16777        // INSERT / ALTER COLUMN TYPE / DEFAULT contexts.
16778        (Value::Json(s), DataType::Jsonb | DataType::Json) => Some(Value::Json(s)),
16779        // v7.10.4 — Text → BYTEA. Decode PG-style literal forms:
16780        //   - Hex:    `\x48656c6c6f`  (case-insensitive hex pairs)
16781        //   - Escape: `Hello\\000world`  (backslash + octal triples)
16782        //   - Plain:  any string → raw UTF-8 bytes (PG also accepts)
16783        // Errors surface as TypeMismatch so the operator gets a
16784        // clear "this literal isn't a bytea literal" hint.
16785        (Value::Text(s), DataType::Bytes) => {
16786            let bytes = decode_bytea_literal(&s).map_err(|e| {
16787                EngineError::Eval(EvalError::TypeMismatch {
16788                    detail: alloc::format!(
16789                        "cannot parse {s:?} as BYTEA for column `{col_name}`: {e}"
16790                    ),
16791                })
16792            })?;
16793            Some(Value::Bytes(bytes))
16794        }
16795        // v7.10.4 — BYTEA → Text round-trip uses the PG hex
16796        // output (lowercase, `\x` prefix). Important when a
16797        // SELECT pulls a bytea cell through a Text column path.
16798        (Value::Bytes(b), DataType::Text) => Some(Value::Text(encode_bytea_hex(&b))),
16799        // v7.17.0 — Text → UUID. PG accepts canonical hyphenated,
16800        // unhyphenated, uppercase, and `{...}`-braced forms; we
16801        // funnel all four through `spg_storage::parse_uuid_str`.
16802        // A malformed literal surfaces as a SQL TypeMismatch
16803        // rather than silently inserting garbage — `0-change
16804        // cutover` requires that an app inserting bad UUID text
16805        // sees the same hard error PG would raise.
16806        (Value::Text(s), DataType::Uuid) => match spg_storage::parse_uuid_str(&s) {
16807            Some(b) => Some(Value::Uuid(b)),
16808            None => {
16809                return Err(EngineError::Eval(EvalError::TypeMismatch {
16810                    detail: alloc::format!(
16811                        "invalid input syntax for type uuid: {s:?} (column `{col_name}`)"
16812                    ),
16813                }));
16814            }
16815        },
16816        // v7.17.0 — UUID → Text canonical 8-4-4-4-12 lowercase.
16817        // Surfaces when a SELECT plucks a uuid cell through a
16818        // Text column path (e.g. INSERT INTO log SELECT id::text
16819        // FROM other_table).
16820        (Value::Uuid(b), DataType::Text) => Some(Value::Text(spg_storage::format_uuid(&b))),
16821        // v7.17.0 Phase 3.P0-32 — Text → TIME. Accepts
16822        // `HH:MM:SS` and `HH:MM:SS.ffffff` (1-6 fractional digits).
16823        // Out-of-range hour/min/sec is a hard SQL error (no
16824        // silent truncation — same 0-change-cutover discipline
16825        // we apply to UUID).
16826        (Value::Text(s), DataType::Time) => match parse_time_str(&s) {
16827            Some(us) => Some(Value::Time(us)),
16828            None => {
16829                return Err(EngineError::Eval(EvalError::TypeMismatch {
16830                    detail: alloc::format!(
16831                        "invalid input syntax for type time: {s:?} (column `{col_name}`)"
16832                    ),
16833                }));
16834            }
16835        },
16836        // v7.17.0 Phase 3.P0-32 — TIME → Text canonical `HH:MM:SS[.ffffff]`.
16837        (Value::Time(us), DataType::Text) => Some(Value::Text(eval::format_time(us))),
16838        // v7.17.0 Phase 3.P0-33 — int / bigint → YEAR. Range
16839        // check enforces the MySQL canonical 1901..=2155 + 0
16840        // sentinel; out-of-range is a hard SQL error (no silent
16841        // truncation, mirrors P0-32 / P0-25 discipline).
16842        (Value::SmallInt(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
16843        (Value::Int(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
16844        (Value::BigInt(n), DataType::Year) => Some(coerce_int_to_year(n, col_name)?),
16845        // Text → YEAR. Accepts the 4-digit decimal form only;
16846        // two-digit YEAR (`'99'` → 1999) was deprecated in MySQL
16847        // 5.7 and is out of scope for v7.17.0.
16848        (Value::Text(s), DataType::Year) => match s.trim().parse::<i64>() {
16849            Ok(n) => Some(coerce_int_to_year(n, col_name)?),
16850            Err(_) => {
16851                return Err(EngineError::Eval(EvalError::TypeMismatch {
16852                    detail: alloc::format!(
16853                        "invalid input syntax for type year: {s:?} (column `{col_name}`)"
16854                    ),
16855                }));
16856            }
16857        },
16858        // YEAR → Text 4-digit zero-padded.
16859        (Value::Year(y), DataType::Text) => Some(Value::Text(alloc::format!("{y:04}"))),
16860        // v7.17.0 Phase 3.P0-34 — Text → TIMETZ. Mandatory
16861        // signed offset suffix; missing offset is a hard error
16862        // (SPG has no session TZ wired into eval, unlike PG).
16863        (Value::Text(s), DataType::TimeTz) => match parse_timetz_str(&s) {
16864            Some((us, offset_secs)) => Some(Value::TimeTz { us, offset_secs }),
16865            None => {
16866                return Err(EngineError::Eval(EvalError::TypeMismatch {
16867                    detail: alloc::format!(
16868                        "invalid input syntax for type time with time zone: \
16869                         {s:?} (column `{col_name}`)"
16870                    ),
16871                }));
16872            }
16873        },
16874        // TIMETZ → Text canonical `HH:MM:SS[.ffffff]±HH[:MM]`.
16875        (Value::TimeTz { us, offset_secs }, DataType::Text) => {
16876            Some(Value::Text(eval::format_timetz(us, offset_secs)))
16877        }
16878        // v7.17.0 Phase 3.P0-35 — Text → MONEY. Accepts `$N.NN`,
16879        // `$N,NNN.NN`, optional leading `-`. Bare numeric literals
16880        // arrive via the Int/BigInt/Float/Numeric arms below.
16881        (Value::Text(s), DataType::Money) => match parse_money_str(&s) {
16882            Some(c) => Some(Value::Money(c)),
16883            None => {
16884                return Err(EngineError::Eval(EvalError::TypeMismatch {
16885                    detail: alloc::format!(
16886                        "invalid input syntax for type money: {s:?} (column `{col_name}`)"
16887                    ),
16888                }));
16889            }
16890        },
16891        // Int / BigInt / SmallInt / Float / Numeric → MONEY.
16892        // Bare numeric literal is interpreted as a major-unit
16893        // amount (matches PG: `100`::money → $100.00 = 10000 cents).
16894        (Value::SmallInt(n), DataType::Money) => {
16895            Some(Value::Money(i64::from(n).saturating_mul(100)))
16896        }
16897        (Value::Int(n), DataType::Money) => Some(Value::Money(i64::from(n).saturating_mul(100))),
16898        (Value::BigInt(n), DataType::Money) => Some(Value::Money(n.saturating_mul(100))),
16899        (Value::Float(x), DataType::Money) => {
16900            // Round half-away-from-zero to cents (no_std — no
16901            // `f64::round`, so hand-roll via biased truncation).
16902            let scaled = x * 100.0;
16903            let cents = if scaled >= 0.0 {
16904                (scaled + 0.5) as i64
16905            } else {
16906                (scaled - 0.5) as i64
16907            };
16908            Some(Value::Money(cents))
16909        }
16910        (Value::Numeric { scaled, scale }, DataType::Money) => {
16911            // Convert exact decimal to cents (scale 2). If scale > 2,
16912            // round half-away-from-zero. If scale < 2, multiply up.
16913            let cents = if scale == 2 {
16914                scaled
16915            } else if scale < 2 {
16916                let mult = 10_i128.pow(u32::from(2 - scale));
16917                scaled.saturating_mul(mult)
16918            } else {
16919                let div = 10_i128.pow(u32::from(scale - 2));
16920                let half = div / 2;
16921                let bias = if scaled >= 0 { half } else { -half };
16922                (scaled + bias) / div
16923            };
16924            Some(Value::Money(i64::try_from(cents).unwrap_or(i64::MAX)))
16925        }
16926        // MONEY → Text canonical `$N,NNN.CC`.
16927        (Value::Money(c), DataType::Text) => Some(Value::Text(eval::format_money(c))),
16928        // v7.17.0 Phase 3.P0-38 — Text → Range. Accepts canonical
16929        // PG forms: `'empty'`, `'[a,b)'`, `'(a,b]'`, `'[a,b]'`,
16930        // `'(a,b)'`, with empty lower or upper for unbounded.
16931        (Value::Text(s), DataType::Range(kind)) => match parse_range_str(&s, kind) {
16932            Some(v) => Some(v),
16933            None => {
16934                return Err(EngineError::Eval(EvalError::TypeMismatch {
16935                    detail: alloc::format!(
16936                        "invalid input syntax for range type: {s:?} (column `{col_name}`)"
16937                    ),
16938                }));
16939            }
16940        },
16941        // Range → Text canonical form (`[a,b)`, `'empty'`, etc).
16942        (v @ Value::Range { .. }, DataType::Text) => Some(Value::Text(format_range_str(&v))),
16943        // v7.17.0 Phase 3.P0-39 — Text → Hstore.
16944        (Value::Text(s), DataType::Hstore) => match parse_hstore_str(&s) {
16945            Some(pairs) => Some(Value::Hstore(pairs)),
16946            None => {
16947                return Err(EngineError::Eval(EvalError::TypeMismatch {
16948                    detail: alloc::format!(
16949                        "invalid input syntax for type hstore: {s:?} (column `{col_name}`)"
16950                    ),
16951                }));
16952            }
16953        },
16954        // Hstore → Text canonical `"k"=>"v"` form.
16955        (Value::Hstore(pairs), DataType::Text) => Some(Value::Text(format_hstore_str(&pairs))),
16956        // v7.17.0 Phase 3.P0-40 — Text → 2D arrays via PG
16957        // external `'{{a,b},{c,d}}'` literal.
16958        (Value::Text(s), DataType::IntArray2D) => match parse_int_2d_literal(&s) {
16959            Ok(m) => Some(Value::IntArray2D(m)),
16960            Err(e) => {
16961                return Err(EngineError::Eval(EvalError::TypeMismatch {
16962                    detail: alloc::format!(
16963                        "invalid input syntax for INT[][]: {s:?} (column `{col_name}`): {e}"
16964                    ),
16965                }));
16966            }
16967        },
16968        (Value::Text(s), DataType::BigIntArray2D) => match parse_bigint_2d_literal(&s) {
16969            Ok(m) => Some(Value::BigIntArray2D(m)),
16970            Err(e) => {
16971                return Err(EngineError::Eval(EvalError::TypeMismatch {
16972                    detail: alloc::format!(
16973                        "invalid input syntax for BIGINT[][]: {s:?} (column `{col_name}`): {e}"
16974                    ),
16975                }));
16976            }
16977        },
16978        (Value::Text(s), DataType::TextArray2D) => match parse_text_2d_literal(&s) {
16979            Ok(m) => Some(Value::TextArray2D(m)),
16980            Err(e) => {
16981                return Err(EngineError::Eval(EvalError::TypeMismatch {
16982                    detail: alloc::format!(
16983                        "invalid input syntax for TEXT[][]: {s:?} (column `{col_name}`): {e}"
16984                    ),
16985                }));
16986            }
16987        },
16988        // 2D arrays → Text canonical nested form.
16989        (Value::IntArray2D(rows), DataType::Text) => Some(Value::Text(format_int_2d_text(&rows))),
16990        (Value::BigIntArray2D(rows), DataType::Text) => {
16991            Some(Value::Text(format_bigint_2d_text(&rows)))
16992        }
16993        (Value::TextArray2D(rows), DataType::Text) => Some(Value::Text(format_text_2d_text(&rows))),
16994        // v7.10.11 — Text → TEXT[]. Decode PG's external array
16995        // form `'{a,b,NULL}'`. NULL element token (case-insensitive)
16996        // is the literal `NULL`; everything else is a quoted or
16997        // unquoted text element. mailrs `'{label1,label2}'::TEXT[]`.
16998        (Value::Text(s), DataType::TextArray) => {
16999            let arr = decode_text_array_literal(&s).map_err(|e| {
17000                EngineError::Eval(EvalError::TypeMismatch {
17001                    detail: alloc::format!(
17002                        "cannot parse {s:?} as TEXT[] for column `{col_name}`: {e}"
17003                    ),
17004                })
17005            })?;
17006            Some(Value::TextArray(arr))
17007        }
17008        // v7.16.0 — Text → IntArray / BigIntArray for the
17009        // spg-sqlx Bind path. Decode the PG external form
17010        // `{1,2,3}` as a TEXT array first, then parse each
17011        // element as int. Same shape as the TextArray decode
17012        // above with an element-wise narrow.
17013        (Value::Text(s), DataType::IntArray) => {
17014            let arr = decode_text_array_literal(&s).map_err(|e| {
17015                EngineError::Eval(EvalError::TypeMismatch {
17016                    detail: alloc::format!(
17017                        "cannot parse {s:?} as INT[] for column `{col_name}`: {e}"
17018                    ),
17019                })
17020            })?;
17021            let mut out: Vec<Option<i32>> = Vec::with_capacity(arr.len());
17022            for elem in arr {
17023                match elem {
17024                    None => out.push(None),
17025                    Some(t) => {
17026                        let n: i32 = t.parse().map_err(|_| {
17027                            EngineError::Eval(EvalError::TypeMismatch {
17028                                detail: alloc::format!(
17029                                    "cannot parse {t:?} as INT element for `{col_name}`"
17030                                ),
17031                            })
17032                        })?;
17033                        out.push(Some(n));
17034                    }
17035                }
17036            }
17037            Some(Value::IntArray(out))
17038        }
17039        (Value::Text(s), DataType::BigIntArray) => {
17040            let arr = decode_text_array_literal(&s).map_err(|e| {
17041                EngineError::Eval(EvalError::TypeMismatch {
17042                    detail: alloc::format!(
17043                        "cannot parse {s:?} as BIGINT[] for column `{col_name}`: {e}"
17044                    ),
17045                })
17046            })?;
17047            let mut out: Vec<Option<i64>> = Vec::with_capacity(arr.len());
17048            for elem in arr {
17049                match elem {
17050                    None => out.push(None),
17051                    Some(t) => {
17052                        let n: i64 = t.parse().map_err(|_| {
17053                            EngineError::Eval(EvalError::TypeMismatch {
17054                                detail: alloc::format!(
17055                                    "cannot parse {t:?} as BIGINT element for `{col_name}`"
17056                                ),
17057                            })
17058                        })?;
17059                        out.push(Some(n));
17060                    }
17061                }
17062            }
17063            Some(Value::BigIntArray(out))
17064        }
17065        // v7.10.11 — TEXT[] → Text round-trip uses PG's
17066        // external array form (`{a,b,NULL}`). Lets a SELECT
17067        // pull an array column through any Text-side codepath.
17068        (Value::TextArray(items), DataType::Text) => Some(Value::Text(encode_text_array(&items))),
17069        // v7.17.0 Phase 3.P0-68 — Text → VECTOR auto-coerce.
17070        // Matches the existing Text → TsVector arm and the
17071        // `::vector` cast: PG-canonical pgvector external form
17072        // (`'[1, 2, -3]'`) becomes a typed Vector value at the
17073        // column boundary. Dim mismatch surfaces as TypeMismatch.
17074        // For SQ8 / HALF encodings we chain through the standard
17075        // quantise helpers so the storage shape matches the
17076        // declared encoding without a second coerce pass.
17077        (Value::Text(s), DataType::Vector { dim, encoding }) => {
17078            let parsed = eval::parse_vector_text(&s).ok_or_else(|| {
17079                EngineError::Eval(EvalError::TypeMismatch {
17080                    detail: alloc::format!("cannot parse {s:?} as VECTOR for column `{col_name}`"),
17081                })
17082            })?;
17083            if parsed.len() != dim as usize {
17084                return Err(EngineError::Eval(EvalError::TypeMismatch {
17085                    detail: alloc::format!(
17086                        "VECTOR({dim}) column `{col_name}` rejects literal of length {}",
17087                        parsed.len()
17088                    ),
17089                }));
17090            }
17091            Some(match encoding {
17092                VecEncoding::F32 => Value::Vector(parsed),
17093                VecEncoding::Sq8 => Value::Sq8Vector(spg_storage::quantize::quantize(&parsed)),
17094                VecEncoding::F16 => {
17095                    Value::HalfVector(spg_storage::halfvec::HalfVector::from_f32_slice(&parsed))
17096                }
17097            })
17098        }
17099        // v7.16.1 — Text → TSVECTOR auto-coerce for the
17100        // INSERT-side wire path (mailrs round-9 A.2.a). PG
17101        // implicitly promotes the TEXT literal at INSERT into a
17102        // TSVECTOR column; SPG previously rejected with a hard
17103        // type mismatch, blocking 23,276 pg_dump rows into
17104        // `messages.search_vector`. We route through the same
17105        // `decode_tsvector_external` the `::tsvector` cast
17106        // already uses, so PG-canonical forms (`'word'`,
17107        // `'word:1A,2B'`, multi-lexeme, empty `''`) all parse.
17108        (Value::Text(s), DataType::TsVector) => {
17109            let lexs = eval::decode_tsvector_external(&s).map_err(|e| {
17110                EngineError::Eval(EvalError::TypeMismatch {
17111                    detail: alloc::format!(
17112                        "cannot parse {s:?} as TSVECTOR for column `{col_name}`: {e}"
17113                    ),
17114                })
17115            })?;
17116            Some(Value::TsVector(lexs))
17117        }
17118        (Value::Text(s), DataType::Timestamp | DataType::Timestamptz) => {
17119            let t = eval::parse_timestamp_literal(&s).ok_or_else(|| {
17120                EngineError::Eval(EvalError::TypeMismatch {
17121                    detail: alloc::format!(
17122                        "cannot parse {s:?} as TIMESTAMP for column `{col_name}`"
17123                    ),
17124                })
17125            })?;
17126            Some(Value::Timestamp(t))
17127        }
17128        // DATE ↔ TIMESTAMP convertibility (DATE → midnight,
17129        // TIMESTAMP → day truncation).
17130        (Value::Date(d), DataType::Timestamp | DataType::Timestamptz) => {
17131            Some(Value::Timestamp(i64::from(d) * 86_400_000_000))
17132        }
17133        // v7.9.21 — Value::Timestamp lands in either Timestamp
17134        // or Timestamptz columns; the on-disk layout is the
17135        // same i64 microseconds UTC.
17136        (Value::Timestamp(t), DataType::Timestamptz) => Some(Value::Timestamp(t)),
17137        (Value::Timestamp(t), DataType::Date) => {
17138            let days = t.div_euclid(86_400_000_000);
17139            i32::try_from(days).ok().map(Value::Date)
17140        }
17141        (
17142            Value::Numeric {
17143                scaled,
17144                scale: src_scale,
17145            },
17146            DataType::Numeric { precision, scale },
17147        ) => Some(numeric_rescale(
17148            scaled, src_scale, precision, scale, col_name,
17149        )?),
17150        #[allow(clippy::cast_precision_loss)]
17151        (Value::Numeric { scaled, scale }, DataType::Float) => {
17152            let mut div = 1.0_f64;
17153            for _ in 0..scale {
17154                div *= 10.0;
17155            }
17156            Some(Value::Float((scaled as f64) / div))
17157        }
17158        (Value::Numeric { scaled, scale }, DataType::Int) => {
17159            let truncated = numeric_truncate_to_integer(scaled, scale);
17160            i32::try_from(truncated).ok().map(Value::Int)
17161        }
17162        (Value::Numeric { scaled, scale }, DataType::BigInt) => {
17163            let truncated = numeric_truncate_to_integer(scaled, scale);
17164            i64::try_from(truncated).ok().map(Value::BigInt)
17165        }
17166        (Value::Numeric { scaled, scale }, DataType::SmallInt) => {
17167            let truncated = numeric_truncate_to_integer(scaled, scale);
17168            i16::try_from(truncated).ok().map(Value::SmallInt)
17169        }
17170        // VARCHAR(n) enforces an upper bound on character count.
17171        (Value::Text(s), DataType::Varchar(max)) => {
17172            if u32::try_from(s.chars().count()).unwrap_or(u32::MAX) <= max {
17173                Some(Value::Text(s))
17174            } else {
17175                return Err(EngineError::Unsupported(alloc::format!(
17176                    "value for VARCHAR({max}) column `{col_name}` exceeds length: \
17177                     {} chars",
17178                    s.chars().count()
17179                )));
17180            }
17181        }
17182        // v6.0.1: f32 → SQ8 INSERT-time quantisation. Triggered
17183        // when the column declares `VECTOR(N) USING SQ8` and
17184        // the INSERT VALUES expression yields a raw f32 vector
17185        // (the normal pgvector-shape literal). Dim mismatch
17186        // falls through the `_ => None` arm and surfaces as
17187        // `TypeMismatch` with the expected SQ8 column type —
17188        // matching the F32 path's existing error.
17189        (
17190            Value::Vector(v),
17191            DataType::Vector {
17192                dim,
17193                encoding: VecEncoding::Sq8,
17194            },
17195        ) if v.len() == dim as usize => Some(Value::Sq8Vector(spg_storage::quantize::quantize(&v))),
17196        // v6.0.3: f32 → f16 INSERT-time conversion for HALF
17197        // columns. Bit-exact at the storage layer (modulo
17198        // half-precision rounding); no rerank pass needed at
17199        // search time.
17200        (
17201            Value::Vector(v),
17202            DataType::Vector {
17203                dim,
17204                encoding: VecEncoding::F16,
17205            },
17206        ) if v.len() == dim as usize => Some(Value::HalfVector(
17207            spg_storage::halfvec::HalfVector::from_f32_slice(&v),
17208        )),
17209        // CHAR(n) right-pads with U+0020 to exactly n chars; if the input
17210        // is already longer we reject (PG truncates trailing-space-only;
17211        // staying strict for v1).
17212        (Value::Text(s), DataType::Char(size)) => {
17213            let len = u32::try_from(s.chars().count()).unwrap_or(u32::MAX);
17214            if len > size {
17215                return Err(EngineError::Unsupported(alloc::format!(
17216                    "value for CHAR({size}) column `{col_name}` exceeds length: \
17217                     {len} chars"
17218                )));
17219            }
17220            let need = (size - len) as usize;
17221            let mut padded = s;
17222            padded.reserve(need);
17223            for _ in 0..need {
17224                padded.push(' ');
17225            }
17226            Some(Value::Text(padded))
17227        }
17228        _ => None,
17229    };
17230    coerced.ok_or(EngineError::Storage(StorageError::TypeMismatch {
17231        column: col_name.into(),
17232        expected,
17233        actual,
17234        position,
17235    }))
17236}
17237
17238/// v7.12.4 — render a function arg list into the
17239/// canonical form the storage layer caches as
17240/// [`spg_storage::FunctionDef::args_repr`]. The catalogue uses
17241/// this string for both display + as a coarse signature key
17242/// for the (deferred) overload resolution v7.12.5+ adds.
17243fn render_function_args(args: &[spg_sql::ast::FunctionArg]) -> alloc::string::String {
17244    use core::fmt::Write;
17245    let mut out = alloc::string::String::from("(");
17246    for (i, a) in args.iter().enumerate() {
17247        if i > 0 {
17248            out.push_str(", ");
17249        }
17250        match a.mode {
17251            spg_sql::ast::FunctionArgMode::In => {}
17252            spg_sql::ast::FunctionArgMode::Out => out.push_str("OUT "),
17253            spg_sql::ast::FunctionArgMode::InOut => out.push_str("INOUT "),
17254        }
17255        if let Some(n) = &a.name {
17256            out.push_str(n);
17257            out.push(' ');
17258        }
17259        match &a.ty {
17260            spg_sql::ast::FunctionArgType::Typed(t) => {
17261                let _ = write!(out, "{t}");
17262            }
17263            spg_sql::ast::FunctionArgType::Raw(s) => out.push_str(s),
17264        }
17265    }
17266    out.push(')');
17267    out
17268}
17269
17270#[cfg(test)]
17271mod tests {
17272    use super::*;
17273    use alloc::vec;
17274
17275    fn unwrap_command_ok(r: &QueryResult) -> usize {
17276        match r {
17277            QueryResult::CommandOk { affected, .. } => *affected,
17278            QueryResult::Rows { .. } => panic!("expected CommandOk, got Rows"),
17279        }
17280    }
17281
17282    #[test]
17283    fn create_table_registers_schema() {
17284        let mut e = Engine::new();
17285        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT)")
17286            .unwrap();
17287        assert_eq!(e.catalog().table_count(), 1);
17288        let t = e.catalog().get("foo").unwrap();
17289        assert_eq!(t.schema().columns.len(), 2);
17290        assert_eq!(t.schema().columns[0].ty, DataType::Int);
17291        assert!(!t.schema().columns[0].nullable);
17292        assert_eq!(t.schema().columns[1].ty, DataType::Text);
17293    }
17294
17295    #[test]
17296    fn create_table_vector_default_is_f32_encoded() {
17297        let mut e = Engine::new();
17298        e.execute("CREATE TABLE t (v VECTOR(8))").unwrap();
17299        let t = e.catalog().get("t").unwrap();
17300        assert_eq!(
17301            t.schema().columns[0].ty,
17302            DataType::Vector {
17303                dim: 8,
17304                encoding: VecEncoding::F32,
17305            },
17306        );
17307    }
17308
17309    #[test]
17310    fn create_table_vector_using_sq8_succeeds() {
17311        // v6.0.1 step 3: the step-1 fence in `column_def_to_schema`
17312        // is lifted. CREATE TABLE persists an SQ8 column type in
17313        // the catalog; INSERT (next test) quantises raw f32 input.
17314        let mut e = Engine::new();
17315        e.execute("CREATE TABLE t (v VECTOR(8) USING SQ8)").unwrap();
17316        let t = e.catalog().get("t").unwrap();
17317        assert_eq!(
17318            t.schema().columns[0].ty,
17319            DataType::Vector {
17320                dim: 8,
17321                encoding: VecEncoding::Sq8,
17322            },
17323        );
17324    }
17325
17326    #[test]
17327    fn insert_into_sq8_column_quantises_f32_payload() {
17328        // v6.0.1 step 3: INSERT-time `coerce_value` rewrites a raw
17329        // `Value::Vector(Vec<f32>)` literal into the column's
17330        // quantised representation. The row that lands in the
17331        // catalog must therefore hold a `Value::Sq8Vector`, not the
17332        // original f32 buffer — that's the bit that delivers the
17333        // 4× compression target.
17334        let mut e = Engine::new();
17335        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
17336        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
17337            .unwrap();
17338        let t = e.catalog().get("t").unwrap();
17339        assert_eq!(t.rows().len(), 1);
17340        match &t.rows()[0].values[0] {
17341            Value::Sq8Vector(q) => {
17342                assert_eq!(q.bytes.len(), 4);
17343                // min/max are derived from the payload: min=0.0, max=1.0.
17344                assert!((q.min - 0.0).abs() < 1e-6);
17345                assert!((q.max - 1.0).abs() < 1e-6);
17346            }
17347            other => panic!("expected Sq8Vector cell, got {other:?}"),
17348        }
17349    }
17350
17351    #[test]
17352    fn create_table_vector_using_half_succeeds_and_insert_converts_to_f16() {
17353        // v6.0.3: CREATE TABLE accepts USING HALF; INSERT path
17354        // converts the incoming `Value::Vector(Vec<f32>)` cell
17355        // into `Value::HalfVector(HalfVector)` via the new
17356        // `coerce_value` arm. The dequantised round-trip is
17357        // bit-exact for f16-representable values, so 0.0 / 0.25
17358        // / 0.5 / 1.0 hit their grid points exactly.
17359        let mut e = Engine::new();
17360        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
17361            .unwrap();
17362        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
17363            .unwrap();
17364        let t = e.catalog().get("t").unwrap();
17365        assert_eq!(t.rows().len(), 1);
17366        match &t.rows()[0].values[0] {
17367            Value::HalfVector(h) => {
17368                assert_eq!(h.dim(), 4);
17369                let back = h.to_f32_vec();
17370                let expected = alloc::vec![0.0_f32, 0.25, 0.5, 1.0];
17371                for (g, e) in back.iter().zip(expected.iter()) {
17372                    assert!(
17373                        (g - e).abs() < 1e-6,
17374                        "{g} vs {e} should be exact on f16 grid"
17375                    );
17376                }
17377            }
17378            other => panic!("expected HalfVector cell, got {other:?}"),
17379        }
17380    }
17381
17382    #[test]
17383    fn alter_index_rebuild_in_place_succeeds() {
17384        // v6.0.4: bare REBUILD (no encoding switch) walks every
17385        // row again to rebuild the NSW graph. Verifies the engine
17386        // dispatch + storage helper plumbing without changing any
17387        // cell encoding.
17388        let mut e = Engine::new();
17389        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
17390            .unwrap();
17391        for i in 0..8_i32 {
17392            #[allow(clippy::cast_precision_loss)]
17393            let base = (i as f32) * 0.1;
17394            e.execute(&alloc::format!(
17395                "INSERT INTO t VALUES ({i}, [{base}, {b1}, {b2}])",
17396                b1 = base + 0.01,
17397                b2 = base + 0.02,
17398            ))
17399            .unwrap();
17400        }
17401        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
17402        e.execute("ALTER INDEX t_idx REBUILD").unwrap();
17403        // Schema encoding stays F32 (no encoding clause).
17404        assert_eq!(
17405            e.catalog().get("t").unwrap().schema().columns[1].ty,
17406            DataType::Vector {
17407                dim: 3,
17408                encoding: VecEncoding::F32,
17409            },
17410        );
17411    }
17412
17413    #[test]
17414    fn alter_index_rebuild_with_encoding_switches_cell_type() {
17415        // v6.0.4: REBUILD WITH (encoding = SQ8) recodes every
17416        // stored cell from F32 → SQ8 + rebuilds the graph atop the
17417        // new encoding. Post-rebuild, cells must be Sq8Vector and
17418        // the schema must report encoding = Sq8.
17419        let mut e = Engine::new();
17420        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(4) NOT NULL)")
17421            .unwrap();
17422        e.execute("INSERT INTO t VALUES (1, [0.0, 0.25, 0.5, 1.0])")
17423            .unwrap();
17424        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
17425        e.execute("ALTER INDEX t_idx REBUILD WITH (encoding = SQ8)")
17426            .unwrap();
17427        let t = e.catalog().get("t").unwrap();
17428        assert_eq!(
17429            t.schema().columns[1].ty,
17430            DataType::Vector {
17431                dim: 4,
17432                encoding: VecEncoding::Sq8,
17433            },
17434        );
17435        assert!(matches!(t.rows()[0].values[1], Value::Sq8Vector(_)));
17436    }
17437
17438    #[test]
17439    fn alter_index_rebuild_unknown_index_errors() {
17440        let mut e = Engine::new();
17441        let err = e.execute("ALTER INDEX nope REBUILD").unwrap_err();
17442        assert!(
17443            matches!(
17444                &err,
17445                EngineError::Storage(StorageError::IndexNotFound { name }) if name == "nope"
17446            ),
17447            "got: {err}"
17448        );
17449    }
17450
17451    #[test]
17452    fn alter_index_rebuild_on_btree_index_errors() {
17453        // REBUILD on a B-tree index has no semantic meaning in
17454        // v6.0.4 — rejected at the storage layer with `Unsupported`.
17455        let mut e = Engine::new();
17456        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
17457        e.execute("INSERT INTO t VALUES (1)").unwrap();
17458        e.execute("CREATE INDEX t_idx ON t (id)").unwrap();
17459        let err = e.execute("ALTER INDEX t_idx REBUILD").unwrap_err();
17460        assert!(
17461            matches!(&err, EngineError::Storage(StorageError::Unsupported(_))),
17462            "got: {err}"
17463        );
17464    }
17465
17466    #[test]
17467    fn prepared_insert_substitutes_placeholders() {
17468        // v6.1.1: prepare() parses once; execute_prepared() walks the
17469        // AST and replaces $1/$2 with the param Values BEFORE the
17470        // dispatch sees them. Same logical result as a simple-query
17471        // INSERT, but parse happens once per *statement*, not per
17472        // execution.
17473        let mut e = Engine::new();
17474        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
17475            .unwrap();
17476        let stmt = e.prepare("INSERT INTO t VALUES ($1, $2)").unwrap();
17477        for (id, name) in [(1, "alice"), (2, "bob"), (3, "carol")] {
17478            e.execute_prepared(stmt.clone(), &[Value::Int(id), Value::Text(name.into())])
17479                .unwrap();
17480        }
17481        // Read back via simple-query SELECT.
17482        let rows_result = e.execute("SELECT id, name FROM t").unwrap();
17483        let QueryResult::Rows { rows, .. } = rows_result else {
17484            panic!("expected Rows")
17485        };
17486        assert_eq!(rows.len(), 3);
17487    }
17488
17489    #[test]
17490    fn prepared_select_with_placeholder_filters_rows() {
17491        let mut e = Engine::new();
17492        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
17493            .unwrap();
17494        for i in 0..10_i32 {
17495            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
17496                .unwrap();
17497        }
17498        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
17499        let QueryResult::Rows { rows, .. } = e.execute_prepared(stmt, &[Value::Int(35)]).unwrap()
17500        else {
17501            panic!("expected Rows")
17502        };
17503        // v = 35 means i*7 = 35 → i = 5.
17504        assert_eq!(rows.len(), 1);
17505        assert_eq!(rows[0].values[0], Value::Int(5));
17506    }
17507
17508    #[test]
17509    fn prepared_too_few_params_errors() {
17510        let mut e = Engine::new();
17511        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
17512        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
17513        let err = e.execute_prepared(stmt, &[]).unwrap_err();
17514        assert!(
17515            matches!(
17516                &err,
17517                EngineError::Eval(EvalError::PlaceholderOutOfRange { n: 1, bound: 0 })
17518            ),
17519            "got: {err}"
17520        );
17521    }
17522
17523    #[test]
17524    fn insert_into_half_column_dim_mismatch_errors() {
17525        let mut e = Engine::new();
17526        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
17527            .unwrap();
17528        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
17529        assert!(matches!(
17530            &err,
17531            EngineError::Storage(StorageError::TypeMismatch { .. })
17532        ));
17533    }
17534
17535    #[test]
17536    fn insert_into_sq8_column_dim_mismatch_errors() {
17537        // Dim mismatch falls through the `coerce_value` Vector→Sq8
17538        // arm's guard and surfaces as `TypeMismatch` — the same
17539        // error the F32 path produces today, so client error
17540        // handling stays uniform across encodings.
17541        let mut e = Engine::new();
17542        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
17543        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
17544        assert!(
17545            matches!(
17546                &err,
17547                EngineError::Storage(StorageError::TypeMismatch { .. })
17548            ),
17549            "got: {err}",
17550        );
17551    }
17552
17553    #[test]
17554    fn create_table_duplicate_errors() {
17555        let mut e = Engine::new();
17556        e.execute("CREATE TABLE foo (a INT)").unwrap();
17557        let err = e.execute("CREATE TABLE foo (a INT)").unwrap_err();
17558        assert!(matches!(
17559            err,
17560            EngineError::Storage(StorageError::DuplicateTable { ref name }) if name == "foo"
17561        ));
17562    }
17563
17564    #[test]
17565    fn insert_into_unknown_table_errors() {
17566        let mut e = Engine::new();
17567        let err = e.execute("INSERT INTO ghost VALUES (1)").unwrap_err();
17568        assert!(matches!(
17569            err,
17570            EngineError::Storage(StorageError::TableNotFound { ref name }) if name == "ghost"
17571        ));
17572    }
17573
17574    #[test]
17575    fn insert_happy_path_reports_one_affected() {
17576        let mut e = Engine::new();
17577        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
17578        let r = e.execute("INSERT INTO foo VALUES (42)").unwrap();
17579        assert_eq!(unwrap_command_ok(&r), 1);
17580        assert_eq!(e.catalog().get("foo").unwrap().row_count(), 1);
17581    }
17582
17583    #[test]
17584    fn insert_arity_mismatch_propagates() {
17585        let mut e = Engine::new();
17586        e.execute("CREATE TABLE foo (a INT, b TEXT)").unwrap();
17587        let err = e.execute("INSERT INTO foo VALUES (1)").unwrap_err();
17588        assert!(matches!(
17589            err,
17590            EngineError::Storage(StorageError::ArityMismatch { .. })
17591        ));
17592    }
17593
17594    #[test]
17595    fn insert_negative_integer_via_unary_minus() {
17596        let mut e = Engine::new();
17597        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
17598        e.execute("INSERT INTO foo VALUES (-7)").unwrap();
17599        let rows = e.catalog().get("foo").unwrap().rows();
17600        assert_eq!(rows[0].values[0], Value::Int(-7));
17601    }
17602
17603    #[test]
17604    fn insert_expression_evaluated_against_empty_context() {
17605        // PG-canonical: INSERT VALUES accepts an arbitrary scalar
17606        // expression. The engine evaluates against an empty row
17607        // context — column references would error, but pure
17608        // arithmetic / function calls are fine.
17609        let mut e = Engine::new();
17610        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
17611        e.execute("INSERT INTO foo VALUES (1 + 2)").unwrap();
17612        let rows = e.catalog().get("foo").unwrap().rows();
17613        assert_eq!(rows[0].values[0], Value::Int(3));
17614    }
17615
17616    #[test]
17617    fn select_star_returns_all_rows_in_insertion_order() {
17618        let mut e = Engine::new();
17619        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT NOT NULL)")
17620            .unwrap();
17621        e.execute("INSERT INTO foo VALUES (1, 'one')").unwrap();
17622        e.execute("INSERT INTO foo VALUES (2, 'two')").unwrap();
17623        e.execute("INSERT INTO foo VALUES (3, 'three')").unwrap();
17624
17625        let r = e.execute("SELECT * FROM foo").unwrap();
17626        let QueryResult::Rows { columns, rows } = r else {
17627            panic!("expected Rows")
17628        };
17629        assert_eq!(columns.len(), 2);
17630        assert_eq!(columns[0].name, "a");
17631        assert_eq!(rows.len(), 3);
17632        assert_eq!(
17633            rows[1].values,
17634            vec![Value::Int(2), Value::Text("two".into())]
17635        );
17636    }
17637
17638    #[test]
17639    fn select_star_on_empty_table_returns_zero_rows() {
17640        let mut e = Engine::new();
17641        e.execute("CREATE TABLE foo (a INT)").unwrap();
17642        let r = e.execute("SELECT * FROM foo").unwrap();
17643        match r {
17644            QueryResult::Rows { rows, .. } => assert!(rows.is_empty()),
17645            QueryResult::CommandOk { .. } => panic!("expected Rows"),
17646        }
17647    }
17648
17649    // --- v0.4: WHERE + projection ------------------------------------------
17650
17651    fn make_three_row_users(e: &mut Engine) {
17652        e.execute("CREATE TABLE users (id INT NOT NULL, name TEXT NOT NULL, score INT)")
17653            .unwrap();
17654        e.execute("INSERT INTO users VALUES (1, 'alice', 90)")
17655            .unwrap();
17656        e.execute("INSERT INTO users VALUES (2, 'bob', NULL)")
17657            .unwrap();
17658        e.execute("INSERT INTO users VALUES (3, 'cara', 70)")
17659            .unwrap();
17660    }
17661
17662    fn unwrap_rows(r: QueryResult) -> (Vec<ColumnSchema>, Vec<Row>) {
17663        match r {
17664            QueryResult::Rows { columns, rows } => (columns, rows),
17665            QueryResult::CommandOk { .. } => panic!("expected Rows"),
17666        }
17667    }
17668
17669    #[test]
17670    fn where_filter_passes_only_true_rows() {
17671        let mut e = Engine::new();
17672        make_three_row_users(&mut e);
17673        let r = e.execute("SELECT * FROM users WHERE id > 1").unwrap();
17674        let (_, rows) = unwrap_rows(r);
17675        assert_eq!(rows.len(), 2);
17676        assert_eq!(rows[0].values[0], Value::Int(2));
17677        assert_eq!(rows[1].values[0], Value::Int(3));
17678    }
17679
17680    #[test]
17681    fn where_with_null_result_filters_out_row() {
17682        let mut e = Engine::new();
17683        make_three_row_users(&mut e);
17684        // score is NULL for bob → score > 80 is NULL → row excluded
17685        let r = e.execute("SELECT * FROM users WHERE score > 80").unwrap();
17686        let (_, rows) = unwrap_rows(r);
17687        assert_eq!(rows.len(), 1);
17688        assert_eq!(rows[0].values[1], Value::Text("alice".into()));
17689    }
17690
17691    #[test]
17692    fn projection_named_columns() {
17693        let mut e = Engine::new();
17694        make_three_row_users(&mut e);
17695        let r = e.execute("SELECT name, score FROM users").unwrap();
17696        let (cols, rows) = unwrap_rows(r);
17697        assert_eq!(cols.len(), 2);
17698        assert_eq!(cols[0].name, "name");
17699        assert_eq!(cols[1].name, "score");
17700        assert_eq!(rows.len(), 3);
17701        assert_eq!(
17702            rows[0].values,
17703            vec![Value::Text("alice".into()), Value::Int(90)]
17704        );
17705    }
17706
17707    #[test]
17708    fn projection_with_column_alias() {
17709        let mut e = Engine::new();
17710        make_three_row_users(&mut e);
17711        let r = e
17712            .execute("SELECT name AS who FROM users WHERE id = 1")
17713            .unwrap();
17714        let (cols, rows) = unwrap_rows(r);
17715        assert_eq!(cols[0].name, "who");
17716        assert_eq!(rows.len(), 1);
17717        assert_eq!(rows[0].values[0], Value::Text("alice".into()));
17718    }
17719
17720    #[test]
17721    fn qualified_column_with_table_alias_resolves() {
17722        let mut e = Engine::new();
17723        make_three_row_users(&mut e);
17724        let r = e
17725            .execute("SELECT u.id, u.name FROM users AS u WHERE u.id < 3")
17726            .unwrap();
17727        let (cols, rows) = unwrap_rows(r);
17728        assert_eq!(cols.len(), 2);
17729        assert_eq!(rows.len(), 2);
17730    }
17731
17732    #[test]
17733    fn qualified_column_with_wrong_alias_errors() {
17734        let mut e = Engine::new();
17735        make_three_row_users(&mut e);
17736        let err = e.execute("SELECT x.id FROM users AS u").unwrap_err();
17737        assert!(matches!(
17738            err,
17739            EngineError::Eval(EvalError::UnknownQualifier { ref qualifier }) if qualifier == "x"
17740        ));
17741    }
17742
17743    #[test]
17744    fn select_unknown_column_errors_in_projection() {
17745        let mut e = Engine::new();
17746        make_three_row_users(&mut e);
17747        let err = e.execute("SELECT ghost FROM users").unwrap_err();
17748        assert!(matches!(
17749            err,
17750            EngineError::Eval(EvalError::ColumnNotFound { ref name }) if name == "ghost"
17751        ));
17752    }
17753
17754    #[test]
17755    fn where_unknown_column_errors() {
17756        let mut e = Engine::new();
17757        make_three_row_users(&mut e);
17758        let err = e
17759            .execute("SELECT * FROM users WHERE ghost = 1")
17760            .unwrap_err();
17761        assert!(matches!(
17762            err,
17763            EngineError::Eval(EvalError::ColumnNotFound { .. })
17764        ));
17765    }
17766
17767    #[test]
17768    fn expression_projection_evaluates_and_renders() {
17769        // Compound expressions in the SELECT list are evaluated per row;
17770        // the output column is typed TEXT, name defaults to the expression.
17771        let mut e = Engine::new();
17772        e.execute("CREATE TABLE t (a INT NOT NULL)").unwrap();
17773        e.execute("INSERT INTO t VALUES (3)").unwrap();
17774        let (_, rows) = unwrap_rows(e.execute("SELECT 1 + 2 FROM t").unwrap());
17775        assert_eq!(rows.len(), 1);
17776        // The expression evaluates to integer 3; rendered as the cell value
17777        // (storage::Value::Int(3) since arithmetic kept ints).
17778        assert_eq!(rows[0].values[0], Value::Int(3));
17779    }
17780
17781    #[test]
17782    fn select_unknown_table_errors() {
17783        let mut e = Engine::new();
17784        let err = e.execute("SELECT * FROM ghost").unwrap_err();
17785        assert!(matches!(
17786            err,
17787            EngineError::Storage(StorageError::TableNotFound { .. })
17788        ));
17789    }
17790
17791    #[test]
17792    fn invalid_sql_returns_parse_error() {
17793        // v4.4: UPDATE is now real SQL, so use a true syntactic
17794        // garbage payload for the parse-error path.
17795        let mut e = Engine::new();
17796        let err = e.execute("THIS_IS_NOT_A_KEYWORD foo bar baz").unwrap_err();
17797        assert!(matches!(err, EngineError::Parse(_)));
17798    }
17799
17800    // --- v0.8 CREATE INDEX + index seek ------------------------------------
17801
17802    #[test]
17803    fn create_index_registers_on_table() {
17804        let mut e = Engine::new();
17805        make_three_row_users(&mut e);
17806        e.execute("CREATE INDEX by_name ON users (name)").unwrap();
17807        let t = e.catalog().get("users").unwrap();
17808        assert_eq!(t.indices().len(), 1);
17809        assert_eq!(t.indices()[0].name, "by_name");
17810    }
17811
17812    #[test]
17813    fn create_index_on_unknown_table_errors() {
17814        let mut e = Engine::new();
17815        let err = e.execute("CREATE INDEX i ON ghost (a)").unwrap_err();
17816        assert!(matches!(
17817            err,
17818            EngineError::Storage(StorageError::TableNotFound { .. })
17819        ));
17820    }
17821
17822    #[test]
17823    fn create_index_on_unknown_column_errors() {
17824        let mut e = Engine::new();
17825        make_three_row_users(&mut e);
17826        let err = e.execute("CREATE INDEX i ON users (ghost)").unwrap_err();
17827        assert!(matches!(
17828            err,
17829            EngineError::Storage(StorageError::ColumnNotFound { .. })
17830        ));
17831    }
17832
17833    #[test]
17834    fn select_eq_uses_index_returns_same_rows_as_scan() {
17835        // Build two engines: one with an index, one without. Same query →
17836        // same row set (index is a planner optimisation, not a semantic
17837        // change).
17838        let mut without = Engine::new();
17839        make_three_row_users(&mut without);
17840        let mut with = Engine::new();
17841        make_three_row_users(&mut with);
17842        with.execute("CREATE INDEX by_id ON users (id)").unwrap();
17843
17844        let q = "SELECT * FROM users WHERE id = 2";
17845        let (_, no_idx_rows) = unwrap_rows(without.execute(q).unwrap());
17846        let (_, idx_rows) = unwrap_rows(with.execute(q).unwrap());
17847        assert_eq!(no_idx_rows, idx_rows);
17848        assert_eq!(idx_rows.len(), 1);
17849    }
17850
17851    #[test]
17852    fn select_eq_with_no_matching_index_value_returns_empty() {
17853        let mut e = Engine::new();
17854        make_three_row_users(&mut e);
17855        e.execute("CREATE INDEX by_id ON users (id)").unwrap();
17856        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM users WHERE id = 999").unwrap());
17857        assert_eq!(rows.len(), 0);
17858    }
17859
17860    // --- v0.9 transactions -------------------------------------------------
17861
17862    #[test]
17863    fn begin_sets_in_transaction_flag() {
17864        let mut e = Engine::new();
17865        assert!(!e.in_transaction());
17866        e.execute("BEGIN").unwrap();
17867        assert!(e.in_transaction());
17868    }
17869
17870    #[test]
17871    fn double_begin_errors() {
17872        let mut e = Engine::new();
17873        e.execute("BEGIN").unwrap();
17874        let err = e.execute("BEGIN").unwrap_err();
17875        assert_eq!(err, EngineError::TransactionAlreadyOpen);
17876    }
17877
17878    #[test]
17879    fn commit_without_begin_errors() {
17880        let mut e = Engine::new();
17881        let err = e.execute("COMMIT").unwrap_err();
17882        assert_eq!(err, EngineError::NoActiveTransaction);
17883    }
17884
17885    #[test]
17886    fn rollback_without_begin_errors() {
17887        let mut e = Engine::new();
17888        let err = e.execute("ROLLBACK").unwrap_err();
17889        assert_eq!(err, EngineError::NoActiveTransaction);
17890    }
17891
17892    #[test]
17893    fn commit_applies_shadow_to_committed_catalog() {
17894        let mut e = Engine::new();
17895        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
17896        e.execute("BEGIN").unwrap();
17897        e.execute("INSERT INTO t VALUES (1)").unwrap();
17898        e.execute("INSERT INTO t VALUES (2)").unwrap();
17899        e.execute("COMMIT").unwrap();
17900        assert!(!e.in_transaction());
17901        assert_eq!(e.catalog().get("t").unwrap().row_count(), 2);
17902    }
17903
17904    #[test]
17905    fn rollback_discards_shadow() {
17906        let mut e = Engine::new();
17907        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
17908        e.execute("BEGIN").unwrap();
17909        e.execute("INSERT INTO t VALUES (1)").unwrap();
17910        e.execute("INSERT INTO t VALUES (2)").unwrap();
17911        e.execute("ROLLBACK").unwrap();
17912        assert!(!e.in_transaction());
17913        assert_eq!(e.catalog().get("t").unwrap().row_count(), 0);
17914    }
17915
17916    #[test]
17917    fn select_during_tx_sees_uncommitted_writes_own_session() {
17918        // The shadow catalog is read by SELECTs while a TX is open — the
17919        // session can see its own pending writes.
17920        let mut e = Engine::new();
17921        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
17922        e.execute("BEGIN").unwrap();
17923        e.execute("INSERT INTO t VALUES (42)").unwrap();
17924        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM t").unwrap());
17925        assert_eq!(rows.len(), 1);
17926        assert_eq!(rows[0].values[0], Value::Int(42));
17927    }
17928
17929    #[test]
17930    fn snapshot_with_no_users_is_bare_catalog_format() {
17931        let mut e = Engine::new();
17932        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
17933        let bytes = e.snapshot();
17934        assert_eq!(
17935            &bytes[..8],
17936            b"SPGDB001",
17937            "must be the bare v3.x catalog magic"
17938        );
17939        let e2 = Engine::restore_envelope(&bytes).unwrap();
17940        assert!(e2.users().is_empty());
17941        assert_eq!(e2.catalog().table_count(), 1);
17942    }
17943
17944    #[test]
17945    fn snapshot_with_users_round_trips_both_via_envelope() {
17946        let mut e = Engine::new();
17947        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
17948        e.create_user("alice", "pw1", Role::Admin, [9; 16]).unwrap();
17949        e.create_user("bob", "pw2", Role::ReadOnly, [5; 16])
17950            .unwrap();
17951        let bytes = e.snapshot();
17952        assert_eq!(&bytes[..8], b"SPGENV01", "must be the v4.1 envelope magic");
17953        let e2 = Engine::restore_envelope(&bytes).unwrap();
17954        assert_eq!(e2.users().len(), 2);
17955        assert_eq!(e2.verify_user("alice", "pw1"), Some(Role::Admin));
17956        assert_eq!(e2.verify_user("bob", "pw2"), Some(Role::ReadOnly));
17957        assert_eq!(e2.verify_user("alice", "wrong"), None);
17958        assert_eq!(e2.catalog().table_count(), 1);
17959    }
17960
17961    #[test]
17962    fn ddl_inside_tx_also_rolled_back() {
17963        let mut e = Engine::new();
17964        e.execute("BEGIN").unwrap();
17965        e.execute("CREATE TABLE t (v INT)").unwrap();
17966        // Visible inside the TX.
17967        e.execute("SELECT * FROM t").unwrap();
17968        e.execute("ROLLBACK").unwrap();
17969        // Gone after rollback.
17970        let err = e.execute("SELECT * FROM t").unwrap_err();
17971        assert!(matches!(
17972            err,
17973            EngineError::Storage(StorageError::TableNotFound { .. })
17974        ));
17975    }
17976
17977    // ── v6.1.2: CREATE / DROP PUBLICATION (engine-side) ──────
17978
17979    #[test]
17980    fn create_publication_lands_in_catalog() {
17981        let mut e = Engine::new();
17982        assert!(e.publications().is_empty());
17983        e.execute("CREATE PUBLICATION pub_a").unwrap();
17984        assert_eq!(e.publications().len(), 1);
17985        assert!(e.publications().contains("pub_a"));
17986    }
17987
17988    #[test]
17989    fn create_publication_duplicate_errors() {
17990        let mut e = Engine::new();
17991        e.execute("CREATE PUBLICATION pub_a").unwrap();
17992        let err = e.execute("CREATE PUBLICATION pub_a").unwrap_err();
17993        assert!(
17994            alloc::format!("{err:?}").contains("DuplicateName"),
17995            "got {err:?}"
17996        );
17997    }
17998
17999    #[test]
18000    fn drop_publication_silent_when_absent() {
18001        let mut e = Engine::new();
18002        // PG-compatible: DROP a publication that doesn't exist
18003        // succeeds (no-op) but reports zero affected.
18004        let r = e.execute("DROP PUBLICATION nope").unwrap();
18005        match r {
18006            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
18007            other => panic!("expected CommandOk, got {other:?}"),
18008        }
18009    }
18010
18011    #[test]
18012    fn drop_publication_present_reports_one_affected() {
18013        let mut e = Engine::new();
18014        e.execute("CREATE PUBLICATION pub_a").unwrap();
18015        let r = e.execute("DROP PUBLICATION pub_a").unwrap();
18016        match r {
18017            QueryResult::CommandOk {
18018                affected,
18019                modified_catalog,
18020            } => {
18021                assert_eq!(affected, 1);
18022                assert!(modified_catalog);
18023            }
18024            other => panic!("expected CommandOk, got {other:?}"),
18025        }
18026        assert!(e.publications().is_empty());
18027    }
18028
18029    #[test]
18030    fn publications_persist_across_snapshot_restore() {
18031        // The persist-across-restart ship-gate at the engine layer —
18032        // snapshot → restore_envelope round trip must preserve the
18033        // publication catalog. The spg-server e2e covers the
18034        // process-restart variant.
18035        let mut e = Engine::new();
18036        e.execute("CREATE PUBLICATION pub_a").unwrap();
18037        e.execute("CREATE PUBLICATION pub_b FOR ALL TABLES")
18038            .unwrap();
18039        let snap = e.snapshot();
18040        let e2 = Engine::restore_envelope(&snap).unwrap();
18041        assert_eq!(e2.publications().len(), 2);
18042        assert!(e2.publications().contains("pub_a"));
18043        assert!(e2.publications().contains("pub_b"));
18044    }
18045
18046    #[test]
18047    fn create_publication_allowed_inside_transaction() {
18048        // v6.1.4 dropped the v6.1.2 in-TX guard — PG allows
18049        // CREATE PUBLICATION inside a TX and the auto-commit
18050        // wrap path needs the same allowance.
18051        let mut e = Engine::new();
18052        e.execute("BEGIN").unwrap();
18053        e.execute("CREATE PUBLICATION pub_a").unwrap();
18054        e.execute("COMMIT").unwrap();
18055        assert!(e.publications().contains("pub_a"));
18056    }
18057
18058    // ── v6.1.3: SHOW PUBLICATIONS + FOR-list variants ───────
18059
18060    #[test]
18061    fn create_publication_for_table_list_lands_with_scope() {
18062        let mut e = Engine::new();
18063        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
18064        e.execute("CREATE TABLE t2 (id INT NOT NULL)").unwrap();
18065        e.execute("CREATE PUBLICATION pub_a FOR TABLE t1, t2")
18066            .unwrap();
18067        let scope = e.publications().get("pub_a").cloned();
18068        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = scope else {
18069            panic!("expected ForTables scope, got {scope:?}")
18070        };
18071        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
18072    }
18073
18074    #[test]
18075    fn create_publication_all_tables_except_lands_with_scope() {
18076        let mut e = Engine::new();
18077        e.execute("CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t3")
18078            .unwrap();
18079        let scope = e.publications().get("pub_a").cloned();
18080        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = scope else {
18081            panic!("expected AllTablesExcept scope, got {scope:?}")
18082        };
18083        assert_eq!(ts, alloc::vec!["t3".to_string()]);
18084    }
18085
18086    #[test]
18087    fn show_publications_empty_returns_zero_rows() {
18088        let e = Engine::new();
18089        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
18090        let QueryResult::Rows { rows, columns } = r else {
18091            panic!()
18092        };
18093        assert!(rows.is_empty());
18094        assert_eq!(columns.len(), 3);
18095        assert_eq!(columns[0].name, "name");
18096        assert_eq!(columns[1].name, "scope");
18097        assert_eq!(columns[2].name, "table_count");
18098    }
18099
18100    #[test]
18101    fn show_publications_returns_one_row_per_publication_ordered_by_name() {
18102        let mut e = Engine::new();
18103        e.execute("CREATE PUBLICATION z_pub").unwrap();
18104        e.execute("CREATE PUBLICATION a_pub FOR TABLE t1, t2")
18105            .unwrap();
18106        e.execute("CREATE PUBLICATION m_pub FOR ALL TABLES EXCEPT bad")
18107            .unwrap();
18108        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
18109        let QueryResult::Rows { rows, .. } = r else {
18110            panic!()
18111        };
18112        assert_eq!(rows.len(), 3);
18113        // Alphabetical order: a_pub, m_pub, z_pub.
18114        let names: Vec<&str> = rows
18115            .iter()
18116            .map(|r| {
18117                if let Value::Text(s) = &r.values[0] {
18118                    s.as_str()
18119                } else {
18120                    panic!()
18121                }
18122            })
18123            .collect();
18124        assert_eq!(names, alloc::vec!["a_pub", "m_pub", "z_pub"]);
18125        // Row 0 — a_pub scope summary + table_count = 2.
18126        match &rows[0].values[1] {
18127            Value::Text(s) => assert_eq!(s, "FOR TABLE t1, t2"),
18128            other => panic!("expected Text, got {other:?}"),
18129        }
18130        assert_eq!(rows[0].values[2], Value::Int(2));
18131        // Row 1 — m_pub.
18132        match &rows[1].values[1] {
18133            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES EXCEPT bad"),
18134            other => panic!("expected Text, got {other:?}"),
18135        }
18136        assert_eq!(rows[1].values[2], Value::Int(1));
18137        // Row 2 — z_pub (AllTables → NULL count).
18138        match &rows[2].values[1] {
18139            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES"),
18140            other => panic!("expected Text, got {other:?}"),
18141        }
18142        assert_eq!(rows[2].values[2], Value::Null);
18143    }
18144
18145    #[test]
18146    fn for_list_scopes_persist_across_snapshot() {
18147        // The v6.1.2 envelope-v3 round-trip exercised AllTables;
18148        // v6.1.3 needs the scope-1 / scope-2 tags to survive too.
18149        let mut e = Engine::new();
18150        e.execute("CREATE PUBLICATION p1 FOR TABLE t1, t2").unwrap();
18151        e.execute("CREATE PUBLICATION p2 FOR ALL TABLES EXCEPT bad, worse")
18152            .unwrap();
18153        let snap = e.snapshot();
18154        let e2 = Engine::restore_envelope(&snap).unwrap();
18155        assert_eq!(e2.publications().len(), 2);
18156        let p1 = e2.publications().get("p1").cloned();
18157        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = p1 else {
18158            panic!("p1 scope lost: {p1:?}")
18159        };
18160        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
18161        let p2 = e2.publications().get("p2").cloned();
18162        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = p2 else {
18163            panic!("p2 scope lost: {p2:?}")
18164        };
18165        assert_eq!(ts, alloc::vec!["bad".to_string(), "worse".to_string()]);
18166    }
18167
18168    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW + envelope v4 ─
18169
18170    #[test]
18171    fn create_subscription_lands_in_catalog_with_defaults() {
18172        let mut e = Engine::new();
18173        e.execute(
18174            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
18175        )
18176        .unwrap();
18177        let s = e.subscriptions().get("sub_a").cloned().expect("present");
18178        assert_eq!(s.conn_str, "host=127.0.0.1 port=20002");
18179        assert_eq!(s.publications, alloc::vec!["pub_a".to_string()]);
18180        assert!(s.enabled);
18181        assert_eq!(s.last_received_pos, 0);
18182    }
18183
18184    #[test]
18185    fn create_subscription_duplicate_name_errors() {
18186        let mut e = Engine::new();
18187        e.execute("CREATE SUBSCRIPTION s CONNECTION 'host=x' PUBLICATION p")
18188            .unwrap();
18189        let err = e
18190            .execute("CREATE SUBSCRIPTION s CONNECTION 'host=y' PUBLICATION p")
18191            .unwrap_err();
18192        assert!(
18193            alloc::format!("{err:?}").contains("DuplicateName"),
18194            "got {err:?}"
18195        );
18196    }
18197
18198    #[test]
18199    fn drop_subscription_silent_when_absent() {
18200        let mut e = Engine::new();
18201        let r = e.execute("DROP SUBSCRIPTION never").unwrap();
18202        match r {
18203            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
18204            other => panic!("expected CommandOk, got {other:?}"),
18205        }
18206    }
18207
18208    #[test]
18209    fn subscription_advance_updates_last_pos_monotone() {
18210        let mut e = Engine::new();
18211        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
18212            .unwrap();
18213        assert!(e.subscription_advance("s", 100));
18214        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
18215        assert!(e.subscription_advance("s", 50)); // stale → ignored
18216        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
18217        assert!(e.subscription_advance("s", 200));
18218        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 200);
18219        assert!(!e.subscription_advance("missing", 1));
18220    }
18221
18222    #[test]
18223    fn show_subscriptions_returns_rows_ordered_by_name() {
18224        let mut e = Engine::new();
18225        e.execute("CREATE SUBSCRIPTION z_sub CONNECTION 'h=x' PUBLICATION p1, p2")
18226            .unwrap();
18227        e.execute("CREATE SUBSCRIPTION a_sub CONNECTION 'h=y' PUBLICATION p3")
18228            .unwrap();
18229        let r = e.execute_readonly("SHOW SUBSCRIPTIONS").unwrap();
18230        let QueryResult::Rows { rows, columns } = r else {
18231            panic!()
18232        };
18233        assert_eq!(rows.len(), 2);
18234        assert_eq!(columns.len(), 5);
18235        assert_eq!(columns[0].name, "name");
18236        assert_eq!(columns[4].name, "last_received_pos");
18237        // Alphabetical: a_sub, z_sub.
18238        let names: Vec<&str> = rows
18239            .iter()
18240            .map(|r| {
18241                if let Value::Text(s) = &r.values[0] {
18242                    s.as_str()
18243                } else {
18244                    panic!()
18245                }
18246            })
18247            .collect();
18248        assert_eq!(names, alloc::vec!["a_sub", "z_sub"]);
18249        // Row 0: a_sub
18250        assert_eq!(rows[0].values[1], Value::Text("h=y".to_string()));
18251        assert_eq!(rows[0].values[2], Value::Text("p3".to_string()));
18252        assert_eq!(rows[0].values[3], Value::Bool(true));
18253        assert_eq!(rows[0].values[4], Value::BigInt(0));
18254        // Row 1: z_sub — publications join with ", "
18255        assert_eq!(rows[1].values[2], Value::Text("p1, p2".to_string()));
18256    }
18257
18258    #[test]
18259    fn subscriptions_persist_across_snapshot_envelope_v4() {
18260        let mut e = Engine::new();
18261        e.execute("CREATE SUBSCRIPTION s1 CONNECTION 'h=A' PUBLICATION p1, p2")
18262            .unwrap();
18263        e.execute("CREATE SUBSCRIPTION s2 CONNECTION 'h=B' PUBLICATION p3")
18264            .unwrap();
18265        e.subscription_advance("s2", 42);
18266        let snap = e.snapshot();
18267        let e2 = Engine::restore_envelope(&snap).unwrap();
18268        assert_eq!(e2.subscriptions().len(), 2);
18269        let s1 = e2.subscriptions().get("s1").unwrap();
18270        assert_eq!(s1.conn_str, "h=A");
18271        assert_eq!(
18272            s1.publications,
18273            alloc::vec!["p1".to_string(), "p2".to_string()]
18274        );
18275        assert_eq!(s1.last_received_pos, 0);
18276        let s2 = e2.subscriptions().get("s2").unwrap();
18277        assert_eq!(s2.last_received_pos, 42);
18278    }
18279
18280    #[test]
18281    fn v3_envelope_loads_with_empty_subscriptions() {
18282        // v3 snapshot (publications-only). Forge it by hand so we
18283        // verify v6.1.4 readers don't panic — they must surface
18284        // empty subscriptions and a populated publication table.
18285        let mut e = Engine::new();
18286        e.execute("CREATE PUBLICATION pub_legacy").unwrap();
18287        let catalog = e.catalog.serialize();
18288        let users = crate::users::serialize_users(&e.users);
18289        let pubs = e.publications.serialize();
18290        let mut buf = Vec::new();
18291        buf.extend_from_slice(b"SPGENV01");
18292        buf.push(3u8); // v3
18293        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
18294        buf.extend_from_slice(&catalog);
18295        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
18296        buf.extend_from_slice(&users);
18297        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
18298        buf.extend_from_slice(&pubs);
18299        let crc = spg_crypto::crc32::crc32(&buf);
18300        buf.extend_from_slice(&crc.to_le_bytes());
18301
18302        let e2 = Engine::restore_envelope(&buf).expect("v3 envelope restores under v4 reader");
18303        assert!(e2.subscriptions().is_empty());
18304        assert!(e2.publications().contains("pub_legacy"));
18305    }
18306
18307    #[test]
18308    fn create_subscription_allowed_inside_transaction() {
18309        let mut e = Engine::new();
18310        e.execute("BEGIN").unwrap();
18311        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
18312            .unwrap();
18313        e.execute("COMMIT").unwrap();
18314        assert!(e.subscriptions().contains("s"));
18315    }
18316
18317    // ── v6.2.0: ANALYZE + spg_statistic + envelope v5 ──────────
18318    #[test]
18319    fn analyze_populates_histogram_bounds() {
18320        let mut e = Engine::new();
18321        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT)")
18322            .unwrap();
18323        for i in 0..50 {
18324            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'name{i}')"))
18325                .unwrap();
18326        }
18327        e.execute("ANALYZE t").unwrap();
18328        let stats = e.statistics();
18329        let id_stats = stats.get("t", "id").unwrap();
18330        assert!(id_stats.histogram_bounds.len() >= 2);
18331        assert_eq!(id_stats.histogram_bounds.first().unwrap(), "0");
18332        assert_eq!(id_stats.histogram_bounds.last().unwrap(), "49");
18333        assert!((id_stats.null_frac - 0.0).abs() < 1e-6);
18334        assert_eq!(id_stats.n_distinct, 50);
18335    }
18336
18337    #[test]
18338    fn reanalyze_overwrites_prior_stats() {
18339        let mut e = Engine::new();
18340        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18341        for i in 0..10 {
18342            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18343                .unwrap();
18344        }
18345        e.execute("ANALYZE t").unwrap();
18346        let n1 = e.statistics().get("t", "id").unwrap().n_distinct;
18347        assert_eq!(n1, 10);
18348        for i in 10..30 {
18349            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18350                .unwrap();
18351        }
18352        e.execute("ANALYZE t").unwrap();
18353        let n2 = e.statistics().get("t", "id").unwrap().n_distinct;
18354        assert_eq!(n2, 30);
18355    }
18356
18357    #[test]
18358    fn analyze_unknown_table_errors() {
18359        let mut e = Engine::new();
18360        let err = e.execute("ANALYZE nonexistent").unwrap_err();
18361        assert!(matches!(
18362            err,
18363            EngineError::Storage(StorageError::TableNotFound { .. })
18364        ));
18365    }
18366
18367    #[test]
18368    fn bare_analyze_covers_all_user_tables() {
18369        let mut e = Engine::new();
18370        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
18371        e.execute("CREATE TABLE t2 (name TEXT NOT NULL)").unwrap();
18372        e.execute("INSERT INTO t1 VALUES (1)").unwrap();
18373        e.execute("INSERT INTO t2 VALUES ('alice')").unwrap();
18374        let r = e.execute("ANALYZE").unwrap();
18375        match r {
18376            QueryResult::CommandOk {
18377                affected,
18378                modified_catalog,
18379            } => {
18380                assert_eq!(affected, 2);
18381                assert!(modified_catalog);
18382            }
18383            other => panic!("expected CommandOk, got {other:?}"),
18384        }
18385        assert!(e.statistics().get("t1", "id").is_some());
18386        assert!(e.statistics().get("t2", "name").is_some());
18387    }
18388
18389    #[test]
18390    fn select_from_spg_statistic_returns_rows_per_column() {
18391        let mut e = Engine::new();
18392        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
18393            .unwrap();
18394        e.execute("INSERT INTO t VALUES (1, 'a')").unwrap();
18395        e.execute("INSERT INTO t VALUES (2, 'b')").unwrap();
18396        e.execute("ANALYZE t").unwrap();
18397        let r = e.execute_readonly("SELECT * FROM spg_statistic").unwrap();
18398        let QueryResult::Rows { rows, columns } = r else {
18399            panic!()
18400        };
18401        // v6.7.0 — spg_statistic gained a `cold_row_count` column.
18402        assert_eq!(columns.len(), 6);
18403        assert_eq!(columns[0].name, "table_name");
18404        assert_eq!(columns[4].name, "histogram_bounds");
18405        assert_eq!(columns[5].name, "cold_row_count");
18406        assert_eq!(rows.len(), 2, "one row per column of t");
18407        // Sorted by (table_name, column_name).
18408        match (&rows[0].values[0], &rows[0].values[1]) {
18409            (Value::Text(t), Value::Text(c)) => {
18410                assert_eq!(t, "t");
18411                // BTreeMap orders (table, column); columns "id" < "label".
18412                assert_eq!(c, "id");
18413            }
18414            _ => panic!(),
18415        }
18416    }
18417
18418    #[test]
18419    fn analyze_skips_vector_columns() {
18420        // Vector columns have their own stats shape (HNSW graph);
18421        // ANALYZE leaves them out of spg_statistic.
18422        let mut e = Engine::new();
18423        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
18424            .unwrap();
18425        e.execute("INSERT INTO t VALUES (1, [1, 2, 3])").unwrap();
18426        e.execute("ANALYZE t").unwrap();
18427        assert!(e.statistics().get("t", "id").is_some());
18428        assert!(e.statistics().get("t", "v").is_none());
18429    }
18430
18431    #[test]
18432    fn statistics_persist_across_envelope_v5_round_trip() {
18433        let mut e = Engine::new();
18434        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18435        for i in 0..20 {
18436            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18437                .unwrap();
18438        }
18439        e.execute("ANALYZE").unwrap();
18440        let snap = e.snapshot();
18441        let e2 = Engine::restore_envelope(&snap).unwrap();
18442        let s = e2.statistics().get("t", "id").unwrap();
18443        assert_eq!(s.n_distinct, 20);
18444    }
18445
18446    // ── v6.2.1 auto-analyze threshold ───────────────────────────
18447
18448    #[test]
18449    fn auto_analyze_threshold_fires_after_10pct_of_min_rows_on_small_table() {
18450        // For a table with 0 rows then 10 inserts → modified=10,
18451        // row_count=10. Threshold = 0.1 × max(10, 100) = 10. So
18452        // after the 10th INSERT the threshold is met.
18453        let mut e = Engine::new();
18454        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18455        for i in 0..9 {
18456            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18457                .unwrap();
18458        }
18459        assert!(e.tables_needing_analyze().is_empty(), "9 < threshold");
18460        e.execute("INSERT INTO t VALUES (9)").unwrap();
18461        let needs = e.tables_needing_analyze();
18462        assert_eq!(needs, alloc::vec!["t".to_string()]);
18463    }
18464
18465    #[test]
18466    fn auto_analyze_threshold_uses_10pct_of_row_count_for_large_tables() {
18467        // After ANALYZE on 1000 rows, threshold = 0.1 × row_count.
18468        // Each new INSERT bumps both modified and row_count, so to
18469        // trigger from N=1000 we need modifications ≥ 0.1 × (1000+M),
18470        // i.e. M ≥ 112. The test inserts 50 (no fire), then 150
18471        // more (200 total mods, row_count=1200, threshold=120 → fire).
18472        let mut e = Engine::new();
18473        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18474        for i in 0..1000 {
18475            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18476                .unwrap();
18477        }
18478        e.execute("ANALYZE t").unwrap();
18479        assert!(e.tables_needing_analyze().is_empty(), "fresh ANALYZE");
18480        for i in 1000..1050 {
18481            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18482                .unwrap();
18483        }
18484        assert!(
18485            e.tables_needing_analyze().is_empty(),
18486            "50 inserts < threshold of ~105"
18487        );
18488        for i in 1050..1200 {
18489            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18490                .unwrap();
18491        }
18492        assert_eq!(
18493            e.tables_needing_analyze(),
18494            alloc::vec!["t".to_string()],
18495            "200 inserts > 0.1 × 1200 threshold"
18496        );
18497    }
18498
18499    #[test]
18500    fn auto_analyze_threshold_resets_after_analyze() {
18501        let mut e = Engine::new();
18502        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18503        for i in 0..200 {
18504            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18505                .unwrap();
18506        }
18507        assert!(!e.tables_needing_analyze().is_empty());
18508        e.execute("ANALYZE").unwrap();
18509        assert!(
18510            e.tables_needing_analyze().is_empty(),
18511            "ANALYZE must reset the counter"
18512        );
18513    }
18514
18515    #[test]
18516    fn auto_analyze_threshold_tracks_updates_and_deletes() {
18517        let mut e = Engine::new();
18518        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
18519            .unwrap();
18520        for i in 0..50 {
18521            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'x')"))
18522                .unwrap();
18523        }
18524        e.execute("ANALYZE t").unwrap();
18525        // UPDATE 20 rows + DELETE 5 → modified=25. Threshold = 0.1
18526        // × max(50, 100) = 10. So 25 >= 10 → trigger.
18527        e.execute("UPDATE t SET label = 'y' WHERE id < 20").unwrap();
18528        e.execute("DELETE FROM t WHERE id >= 45").unwrap();
18529        assert_eq!(e.tables_needing_analyze(), alloc::vec!["t".to_string()]);
18530    }
18531
18532    #[test]
18533    fn v4_envelope_loads_with_empty_statistics() {
18534        // Forge a v4 envelope by hand: catalog + users + pubs +
18535        // subs trailer, no statistics. A v6.2.0 reader must accept
18536        // it and surface an empty Statistics.
18537        let mut e = Engine::new();
18538        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
18539            .unwrap();
18540        let catalog = e.catalog.serialize();
18541        let users = crate::users::serialize_users(&e.users);
18542        let pubs = e.publications.serialize();
18543        let subs = e.subscriptions.serialize();
18544        let mut buf = Vec::new();
18545        buf.extend_from_slice(b"SPGENV01");
18546        buf.push(4u8);
18547        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
18548        buf.extend_from_slice(&catalog);
18549        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
18550        buf.extend_from_slice(&users);
18551        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
18552        buf.extend_from_slice(&pubs);
18553        buf.extend_from_slice(&u32::try_from(subs.len()).unwrap().to_le_bytes());
18554        buf.extend_from_slice(&subs);
18555        let crc = spg_crypto::crc32::crc32(&buf);
18556        buf.extend_from_slice(&crc.to_le_bytes());
18557        let e2 = Engine::restore_envelope(&buf).expect("v4 envelope restores");
18558        assert!(e2.statistics().is_empty());
18559    }
18560
18561    #[test]
18562    fn v1_v2_envelope_loads_with_empty_publications() {
18563        // A snapshot taken before v6.1.2 (no publication trailer,
18564        // envelope v2) must still deserialise — and the resulting
18565        // engine must report zero publications. Use the engine's own
18566        // round-trip with no publications: that emits v3 but with an
18567        // empty pubs block. Then forge a v2 envelope by hand to lock
18568        // the back-compat path.
18569        let mut e = Engine::new();
18570        // Force users to be non-empty so the snapshot takes the
18571        // envelope path rather than the bare-catalog fallback.
18572        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
18573            .unwrap();
18574
18575        // Forge an envelope v2: same shape as v3 but no pubs trailer.
18576        let catalog = e.catalog.serialize();
18577        let users = crate::users::serialize_users(&e.users);
18578        let mut buf = Vec::new();
18579        buf.extend_from_slice(b"SPGENV01");
18580        buf.push(2u8); // v2
18581        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
18582        buf.extend_from_slice(&catalog);
18583        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
18584        buf.extend_from_slice(&users);
18585        let crc = spg_crypto::crc32::crc32(&buf);
18586        buf.extend_from_slice(&crc.to_le_bytes());
18587
18588        let e2 = Engine::restore_envelope(&buf).expect("v2 envelope restores");
18589        assert!(e2.publications().is_empty());
18590    }
18591}