Skip to main content

spg_engine/
lib.rs

1//! SPG execution engine — v0.3 wires the SQL front-end to the in-memory
2//! storage layer. Implements `CREATE TABLE`, single-row `INSERT VALUES`, and
3//! `SELECT * FROM <table>` (no WHERE yet — that lands in v0.4 alongside
4//! expression evaluation against rows).
5#![no_std]
6
7extern crate alloc;
8
9pub mod aggregate;
10pub mod describe;
11pub mod eval;
12pub mod fts;
13pub mod json;
14pub mod memoize;
15pub mod plan_cache;
16pub mod publications;
17pub mod query_stats;
18pub mod reorder;
19pub mod selectivity;
20pub mod statistics;
21pub mod subscriptions;
22pub mod triggers;
23pub mod users;
24
25pub use crate::users::{Role, ScramSecrets, UserError, UserStore};
26
27use alloc::borrow::Cow;
28use alloc::boxed::Box;
29use alloc::collections::BTreeMap;
30use alloc::string::{String, ToString};
31use alloc::vec::Vec;
32use core::fmt;
33
34use spg_sql::ast::{
35    BinOp, ColumnDef, ColumnName, ColumnTypeName, CreateIndexStatement, CreatePublicationStatement,
36    CreateSubscriptionStatement, CreateTableStatement, CreateUserStatement, Expr, FrameBound,
37    FrameKind, FromClause, IndexMethod, InsertStatement, JoinKind, Literal, OrderBy, SelectItem,
38    SelectStatement, Statement, TableRef, UnOp, UnionKind, VecEncoding as SqlVecEncoding,
39    WindowFrame,
40};
41// v7.16.0 — re-export the parsed-statement AST so downstream
42// crates (spg-embedded → spg-sqlx) don't need a direct dep on
43// spg-sql for the prepare/bind handle.
44pub use spg_sql::ast::Statement as ParsedStatement;
45use spg_sql::parser::{self, ParseError};
46use spg_storage::{
47    Catalog, ColumnSchema, CompactReport, DataType, IndexKey, IndexKind, Row, StorageError, Table,
48    TableSchema, Value, VecEncoding,
49};
50
51use crate::eval::{EvalContext, EvalError};
52
53/// Result of executing one statement.
54#[derive(Debug, Clone, PartialEq)]
55#[non_exhaustive]
56pub enum QueryResult {
57    /// DDL or DML succeeded.
58    ///
59    /// `affected` is the row count for `INSERT` and 0 elsewhere.
60    /// `modified_catalog` tells the server whether this statement
61    /// caused the *committed* catalog to change — it's the signal to
62    /// snapshot/audit. False for `BEGIN`/`ROLLBACK`, false for writeful
63    /// statements executed inside a transaction (those only touch the
64    /// shadow), and true for `COMMIT` and for writes outside a TX.
65    CommandOk {
66        affected: usize,
67        modified_catalog: bool,
68    },
69    /// `SELECT` returned a (possibly empty) row set.
70    Rows {
71        columns: Vec<ColumnSchema>,
72        rows: Vec<Row>,
73    },
74}
75
76/// All errors the engine can return.
77///
78/// Marked `#[non_exhaustive]` from v7.5.0 onward: external `match`
79/// must include a `_` arm so new variants in subsequent v7.x releases
80/// are not breaking changes.
81#[derive(Debug, Clone, PartialEq)]
82#[non_exhaustive]
83pub enum EngineError {
84    Parse(ParseError),
85    Storage(StorageError),
86    Eval(EvalError),
87    /// Front-end accepted a construct that the v0.x executor doesn't support.
88    Unsupported(String),
89    /// `BEGIN` while another transaction is already open.
90    TransactionAlreadyOpen,
91    /// `COMMIT` / `ROLLBACK` with no active transaction.
92    NoActiveTransaction,
93    /// v4.0 sentinel: `execute_readonly` got a statement that
94    /// mutates engine state (INSERT / CREATE / BEGIN / COMMIT / …).
95    /// The caller should retake the write lock and dispatch through
96    /// `execute(&mut self)` instead.
97    WriteRequired,
98    /// v4.2: a SELECT would have returned more rows than the
99    /// configured `max_query_rows` cap. Carries the cap.
100    RowLimitExceeded(usize),
101    /// v4.5: cooperative cancellation — the host (server's
102    /// per-query watchdog) set the cancel flag while a long-running
103    /// SELECT / UPDATE / DELETE was scanning rows. The partial work
104    /// is discarded; the caller should surface this as a timeout
105    /// to the client.
106    Cancelled,
107}
108
109impl fmt::Display for EngineError {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        match self {
112            Self::Parse(e) => write!(f, "parse: {e}"),
113            Self::Storage(e) => write!(f, "storage: {e}"),
114            Self::Eval(e) => write!(f, "eval: {e}"),
115            Self::Unsupported(s) => write!(f, "unsupported: {s}"),
116            Self::TransactionAlreadyOpen => f.write_str("a transaction is already open"),
117            Self::NoActiveTransaction => f.write_str("no active transaction"),
118            Self::WriteRequired => {
119                f.write_str("statement requires a write lock (use execute, not execute_readonly)")
120            }
121            Self::RowLimitExceeded(n) => {
122                write!(f, "query exceeded max_query_rows={n}")
123            }
124            Self::Cancelled => f.write_str("query cancelled (timeout or client request)"),
125        }
126    }
127}
128
129impl From<ParseError> for EngineError {
130    fn from(e: ParseError) -> Self {
131        Self::Parse(e)
132    }
133}
134impl From<StorageError> for EngineError {
135    fn from(e: StorageError) -> Self {
136        Self::Storage(e)
137    }
138}
139impl From<EvalError> for EngineError {
140    fn from(e: EvalError) -> Self {
141        Self::Eval(e)
142    }
143}
144
145/// The execution engine. Holds the catalog and (later) other server-scope
146/// state. `Engine::new()` is intentionally cheap so callers can construct one
147/// per database, per test.
148/// Function pointer that returns "now" as microseconds since Unix
149/// epoch. The engine is `no_std`, so it can't reach for `std::time`
150/// itself — callers (`spg-server`, the sqllogictest runner) inject a
151/// concrete implementation. `None` means `NOW()` / `CURRENT_*` raise
152/// `Unsupported`.
153pub type ClockFn = fn() -> i64;
154
155/// Function pointer that produces 16 cryptographically random bytes.
156/// Like `ClockFn`, the engine is `no_std` and can't reach for /dev/urandom
157/// itself — host (`spg-server`) injects an OS-backed source. `None`
158/// means SQL-driven `CREATE USER` falls back to a deterministic salt
159/// derived from the username (acceptable in tests; the server always
160/// installs a real RNG so production paths never see this).
161pub type SaltFn = fn() -> [u8; 16];
162
163/// v4.5 cooperative cancellation token. A long-running SELECT /
164/// UPDATE / DELETE checks `is_cancelled` at row-loop checkpoints
165/// and bails with `EngineError::Cancelled`. The host
166/// (`spg-server`) creates an `AtomicBool` per query, spawns a
167/// watchdog thread that sets it after `SPG_QUERY_TIMEOUT_MS`,
168/// and passes it via `execute_with_cancel` / `execute_readonly_with_cancel`.
169///
170/// `CancelToken::none()` is a no-op — used by the legacy `execute`
171/// and `execute_readonly` entry points so existing callers don't
172/// change.
173/// v7.17.0 Phase 2.3 — monotonic time source for deadline-aware
174/// cancellation (PG `statement_timeout`). Returns microseconds
175/// since some host-stable monotonic origin (typically the first
176/// call into `Instant::now()` on the server). The engine never
177/// calls `Instant::now()` directly so the crate stays `#![no_std]`.
178pub type MonotonicNowFn = fn() -> u64;
179
180#[derive(Debug, Clone, Copy)]
181struct Deadline {
182    now_fn: MonotonicNowFn,
183    /// Absolute deadline in `now_fn()` units (microseconds).
184    deadline_us: u64,
185}
186
187#[derive(Debug, Clone, Copy)]
188pub struct CancelToken<'a> {
189    flag: Option<&'a core::sync::atomic::AtomicBool>,
190    // v7.17.0 Phase 2.3 — when set, every existing `cancel.check()`
191    // checkpoint also fires `EngineError::Cancelled` once
192    // `(now_fn)() >= deadline_us`. No new check sites, no thread
193    // spawn per query — the monotonic now-fn read is a vDSO
194    // `clock_gettime(CLOCK_MONOTONIC)` (~20ns) and only runs when
195    // the host actually wired a deadline (statement_timeout > 0).
196    deadline: Option<Deadline>,
197}
198
199impl<'a> CancelToken<'a> {
200    #[must_use]
201    pub const fn none() -> Self {
202        Self {
203            flag: None,
204            deadline: None,
205        }
206    }
207
208    #[must_use]
209    pub const fn from_flag(f: &'a core::sync::atomic::AtomicBool) -> Self {
210        Self {
211            flag: Some(f),
212            deadline: None,
213        }
214    }
215
216    /// v7.17.0 Phase 2.3 — attach a monotonic deadline. `now_fn`
217    /// must return microseconds since a stable origin; the token
218    /// trips when `now_fn() >= deadline_us`. Compose with
219    /// `from_flag(...)` when both a watchdog flag and a per-statement
220    /// timeout are in play (e.g. server-wide `SPG_QUERY_TIMEOUT_MS`
221    /// plus session `statement_timeout`); the tighter of the two
222    /// wins by virtue of either signaling first.
223    #[must_use]
224    pub const fn with_deadline(mut self, now_fn: MonotonicNowFn, deadline_us: u64) -> Self {
225        self.deadline = Some(Deadline {
226            now_fn,
227            deadline_us,
228        });
229        self
230    }
231
232    #[must_use]
233    pub fn is_cancelled(self) -> bool {
234        if self
235            .flag
236            .is_some_and(|f| f.load(core::sync::atomic::Ordering::Relaxed))
237        {
238            return true;
239        }
240        // Deadline check is the second branch so the "no timeout"
241        // hot path (`deadline: None`) elides the now-fn call —
242        // predicted-not-taken on the SLO INSERT loop.
243        if let Some(d) = self.deadline
244            && (d.now_fn)() >= d.deadline_us
245        {
246            return true;
247        }
248        false
249    }
250
251    /// Returns `Err(Cancelled)` if the token has been tripped.
252    /// Used at row-loop checkpoints to bail cooperatively without
253    /// scattering raw `is_cancelled` checks across the executor.
254    #[inline]
255    pub fn check(self) -> Result<(), EngineError> {
256        if self.is_cancelled() {
257            Err(EngineError::Cancelled)
258        } else {
259            Ok(())
260        }
261    }
262}
263
264// ---- snapshot envelope (v4.1, extended with CRC32 in v4.37,  ----
265// ----   publications in v6.1.2 v3, subscriptions in v6.1.4 v4) ----
266//
267// Wraps a catalog blob + a user blob behind a small header so the
268// server can persist both atomically without inventing a new file.
269// Bare catalog blobs (v3.x) still load via `restore_envelope` since
270// the magic check fails fast and the function falls back to
271// `Catalog::deserialize`.
272//
273// Layout — v1 (v4.1, no CRC):
274//   [8 bytes magic "SPGENV01"]
275//   [u8 version = 1]
276//   [u32 catalog_len][catalog bytes]
277//   [u32 users_len][users bytes]
278//
279// Layout — v2 (v4.37, CRC32 of body):
280//   [8 bytes magic "SPGENV01"]
281//   [u8 version = 2]
282//   [u32 catalog_len][catalog bytes]
283//   [u32 users_len][users bytes]
284//   [u32 crc32]                      ← CRC32 of every byte before it.
285//
286// Layout — v3 (v6.1.2, publications trailer):
287//   [8 bytes magic "SPGENV01"]
288//   [u8 version = 3]
289//   [u32 catalog_len][catalog bytes]
290//   [u32 users_len][users bytes]
291//   [u32 pubs_len][publications bytes]
292//   [u32 crc32]
293//
294// Layout — v4 (v6.1.4, subscriptions trailer):
295//   [8 bytes magic "SPGENV01"]
296//   [u8 version = 4]
297//   [u32 catalog_len][catalog bytes]
298//   [u32 users_len][users bytes]
299//   [u32 pubs_len][publications bytes]
300//   [u32 subs_len][subscriptions bytes]
301//   [u32 crc32]
302//
303// Layout — v5 (v6.2.0, statistics trailer):
304//   [8 bytes magic "SPGENV01"]
305//   [u8 version = 5]
306//   [u32 catalog_len][catalog bytes]
307//   [u32 users_len][users bytes]
308//   [u32 pubs_len][publications bytes]
309//   [u32 subs_len][subscriptions bytes]
310//   [u32 stats_len][statistics bytes]      ← NEW
311//   [u32 crc32]
312//
313// Writers emit v5 from v6.2.0 on. Readers accept all of {v1, v2,
314// v3, v4, v5}: v1/v2 load with empty publications / subscriptions /
315// statistics; v3 loads with empty subscriptions + statistics; v4
316// loads with empty statistics; v5 deserialises all three. Older
317// SPG versions reading a v5 envelope fall through the version
318// match to `EnvelopeParse::Bare` — pre-v6.2.0 binaries cannot
319// open v6.2.0+ snapshots (matches the v6.1.2 / v6.1.4 breaks).
320
321const ENVELOPE_MAGIC: &[u8; 8] = b"SPGENV01";
322const ENVELOPE_VERSION_V1: u8 = 1;
323const ENVELOPE_VERSION_V2: u8 = 2;
324const ENVELOPE_VERSION_V3: u8 = 3;
325const ENVELOPE_VERSION_V4: u8 = 4;
326const ENVELOPE_VERSION_V5: u8 = 5;
327
328fn build_envelope(catalog: &[u8], users: &[u8], pubs: &[u8], subs: &[u8], stats: &[u8]) -> Vec<u8> {
329    let mut out = Vec::with_capacity(
330        8 + 1
331            + 4
332            + catalog.len()
333            + 4
334            + users.len()
335            + 4
336            + pubs.len()
337            + 4
338            + subs.len()
339            + 4
340            + stats.len()
341            + 4,
342    );
343    out.extend_from_slice(ENVELOPE_MAGIC);
344    out.push(ENVELOPE_VERSION_V5);
345    out.extend_from_slice(
346        &u32::try_from(catalog.len())
347            .expect("≤ 4G catalog")
348            .to_le_bytes(),
349    );
350    out.extend_from_slice(catalog);
351    out.extend_from_slice(
352        &u32::try_from(users.len())
353            .expect("≤ 4G users")
354            .to_le_bytes(),
355    );
356    out.extend_from_slice(users);
357    out.extend_from_slice(
358        &u32::try_from(pubs.len())
359            .expect("≤ 4G publications")
360            .to_le_bytes(),
361    );
362    out.extend_from_slice(pubs);
363    out.extend_from_slice(
364        &u32::try_from(subs.len())
365            .expect("≤ 4G subscriptions")
366            .to_le_bytes(),
367    );
368    out.extend_from_slice(subs);
369    out.extend_from_slice(
370        &u32::try_from(stats.len())
371            .expect("≤ 4G statistics")
372            .to_le_bytes(),
373    );
374    out.extend_from_slice(stats);
375    let crc = spg_crypto::crc32::crc32(&out);
376    out.extend_from_slice(&crc.to_le_bytes());
377    out
378}
379
380/// Outcome of envelope parsing: either bare-catalog fallback, a
381/// successfully split section trio from a v1/v2/v3 envelope, or an
382/// explicit corruption error from a v2/v3 CRC mismatch. `Bare`
383/// (catalog-only fallback) preserves v3.x readability. v1/v2
384/// envelopes set `publications` to `None`; v3 sets it to the
385/// publications byte slice.
386enum EnvelopeParse<'a> {
387    Bare,
388    Pair {
389        catalog: &'a [u8],
390        users: &'a [u8],
391        publications: Option<&'a [u8]>,
392        subscriptions: Option<&'a [u8]>,
393        statistics: Option<&'a [u8]>,
394    },
395    CrcMismatch {
396        expected: u32,
397        computed: u32,
398    },
399}
400
401/// Returns `EnvelopeParse::Pair` for a valid v1 / v2 / v3 envelope,
402/// `Bare` for a buffer that doesn't look like an envelope (v3.x
403/// bare catalog fallback), and `CrcMismatch` for a v2/v3 envelope
404/// whose trailing CRC32 doesn't match the body.
405fn split_envelope(buf: &[u8]) -> EnvelopeParse<'_> {
406    if buf.len() < 8 + 1 + 4 || &buf[..8] != ENVELOPE_MAGIC {
407        return EnvelopeParse::Bare;
408    }
409    let version = buf[8];
410    if !matches!(
411        version,
412        ENVELOPE_VERSION_V1
413            | ENVELOPE_VERSION_V2
414            | ENVELOPE_VERSION_V3
415            | ENVELOPE_VERSION_V4
416            | ENVELOPE_VERSION_V5
417    ) {
418        return EnvelopeParse::Bare;
419    }
420    let mut p = 9usize;
421    let Some(cat_len_bytes) = buf.get(p..p + 4) else {
422        return EnvelopeParse::Bare;
423    };
424    let Ok(cat_len_arr) = cat_len_bytes.try_into() else {
425        return EnvelopeParse::Bare;
426    };
427    let cat_len = u32::from_le_bytes(cat_len_arr) as usize;
428    p += 4;
429    if p + cat_len + 4 > buf.len() {
430        return EnvelopeParse::Bare;
431    }
432    let catalog = &buf[p..p + cat_len];
433    p += cat_len;
434    let Some(user_len_bytes) = buf.get(p..p + 4) else {
435        return EnvelopeParse::Bare;
436    };
437    let Ok(user_len_arr) = user_len_bytes.try_into() else {
438        return EnvelopeParse::Bare;
439    };
440    let user_len = u32::from_le_bytes(user_len_arr) as usize;
441    p += 4;
442    if p + user_len > buf.len() {
443        return EnvelopeParse::Bare;
444    }
445    let users = &buf[p..p + user_len];
446    p += user_len;
447    let publications = if matches!(
448        version,
449        ENVELOPE_VERSION_V3 | ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5
450    ) {
451        // [u32 pubs_len][publications bytes]
452        let Some(pubs_len_bytes) = buf.get(p..p + 4) else {
453            return EnvelopeParse::Bare;
454        };
455        let Ok(pubs_len_arr) = pubs_len_bytes.try_into() else {
456            return EnvelopeParse::Bare;
457        };
458        let pubs_len = u32::from_le_bytes(pubs_len_arr) as usize;
459        p += 4;
460        if p + pubs_len > buf.len() {
461            return EnvelopeParse::Bare;
462        }
463        let pubs_slice = &buf[p..p + pubs_len];
464        p += pubs_len;
465        Some(pubs_slice)
466    } else {
467        None
468    };
469    let subscriptions = if matches!(version, ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5) {
470        // [u32 subs_len][subscriptions bytes]
471        let Some(subs_len_bytes) = buf.get(p..p + 4) else {
472            return EnvelopeParse::Bare;
473        };
474        let Ok(subs_len_arr) = subs_len_bytes.try_into() else {
475            return EnvelopeParse::Bare;
476        };
477        let subs_len = u32::from_le_bytes(subs_len_arr) as usize;
478        p += 4;
479        if p + subs_len > buf.len() {
480            return EnvelopeParse::Bare;
481        }
482        let subs_slice = &buf[p..p + subs_len];
483        p += subs_len;
484        Some(subs_slice)
485    } else {
486        None
487    };
488    let statistics = if version == ENVELOPE_VERSION_V5 {
489        // [u32 stats_len][statistics bytes]
490        let Some(stats_len_bytes) = buf.get(p..p + 4) else {
491            return EnvelopeParse::Bare;
492        };
493        let Ok(stats_len_arr) = stats_len_bytes.try_into() else {
494            return EnvelopeParse::Bare;
495        };
496        let stats_len = u32::from_le_bytes(stats_len_arr) as usize;
497        p += 4;
498        if p + stats_len > buf.len() {
499            return EnvelopeParse::Bare;
500        }
501        let stats_slice = &buf[p..p + stats_len];
502        p += stats_len;
503        Some(stats_slice)
504    } else {
505        None
506    };
507    if matches!(
508        version,
509        ENVELOPE_VERSION_V2 | ENVELOPE_VERSION_V3 | ENVELOPE_VERSION_V4 | ENVELOPE_VERSION_V5
510    ) {
511        if p + 4 != buf.len() {
512            return EnvelopeParse::Bare;
513        }
514        let Ok(crc_arr) = buf[p..p + 4].try_into() else {
515            return EnvelopeParse::Bare;
516        };
517        let expected = u32::from_le_bytes(crc_arr);
518        let computed = spg_crypto::crc32::crc32(&buf[..p]);
519        if expected != computed {
520            return EnvelopeParse::CrcMismatch { expected, computed };
521        }
522    } else if p != buf.len() {
523        // v1: must end exactly at the users section.
524        return EnvelopeParse::Bare;
525    }
526    EnvelopeParse::Pair {
527        catalog,
528        users,
529        publications,
530        subscriptions,
531        statistics,
532    }
533}
534
535/// v4.41.1 opaque transaction handle. Returned by `Engine::alloc_tx_id`,
536/// threaded through `Engine::execute_in` so dispatch can identify which
537/// in-flight TX a statement belongs to. `IMPLICIT_TX` is the reserved
538/// slot every legacy caller — engine self-tests, spg-cli, spg-embedded,
539/// startup replay — implicitly uses through the unchanged
540/// `Engine::execute(sql)` API. v4.41.1 keeps at most one active slot at
541/// runtime (dispatch holds `engine.write()` across the wrap, same as
542/// v4.34); the map shape is here to let v4.42 turn on N in-flight
543/// implicit TXs without reshuffling the engine internals.
544#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
545pub struct TxId(pub u64);
546
547/// Reserved slot used by `Engine::execute(sql)` — the legacy single-
548/// global-shadow path. New `alloc_tx_id` handles start at 1.
549pub const IMPLICIT_TX: TxId = TxId(0);
550
551/// v6.7.3 — default segment-size threshold used by `COMPACT COLD
552/// SEGMENTS` when no explicit target is supplied. Segments whose
553/// `OwnedSegment::bytes().len()` is **strictly** less than this
554/// value are eligible to merge. spg-server reads
555/// `SPG_COMPACTION_TARGET_SEGMENT_BYTES` to override.
556pub const COMPACTION_TARGET_DEFAULT_BYTES: u64 = 4 * 1024 * 1024;
557
558/// Per-slot transaction state. Held inside `tx_catalogs[tx_id]` for the
559/// lifetime of a BEGIN..COMMIT (or BEGIN..ROLLBACK) window. Drops when
560/// the TX commits (its `catalog` is moved over `Engine.catalog`) or
561/// rolls back (slot removed, catalog discarded).
562#[derive(Debug, Default, Clone)]
563struct TxState {
564    /// The TX's shadow copy of the catalog. Started as a clone of
565    /// `Engine.catalog` at BEGIN time; writes flow into it; COMMIT
566    /// installs it over `Engine.catalog`. `Catalog::clone()` is O(1)
567    /// since v4.40 (`PersistentVec` rows + `PersistentBTreeMap` indices).
568    catalog: Catalog,
569    /// Per-TX savepoint stack. Each entry pairs the savepoint name with
570    /// a clone of `catalog` at the moment `SAVEPOINT <name>` fired.
571    /// `ROLLBACK TO <name>` restores from the entry and pops everything
572    /// after it; `RELEASE <name>` discards the entry and everything
573    /// after; COMMIT/ROLLBACK clears the whole stack.
574    savepoints: Vec<(String, Catalog)>,
575}
576
577/// v7.11.0 — frozen read-only view of the engine's committed state.
578/// Constructed via [`Engine::clone_snapshot`]. Holds clones of the
579/// catalog, statistics, clock function, and row-cap config — the
580/// four fields the `execute_readonly` path actually reads. Cheap to
581/// `Clone` (each clone shares the underlying `PersistentVec` row
582/// storage; only the trie root pointers copy). Send + Sync so a
583/// snapshot can be moved across `tokio::task::spawn_blocking`
584/// boundaries without coordination.
585///
586/// The contract: a snapshot reflects the engine's state at the
587/// moment `clone_snapshot()` returned. Subsequent writes to the
588/// engine are NOT visible. Callers who need fresher data take a
589/// new snapshot.
590#[derive(Debug, Clone)]
591pub struct CatalogSnapshot {
592    catalog: Catalog,
593    statistics: statistics::Statistics,
594    clock: Option<ClockFn>,
595    max_query_rows: Option<usize>,
596}
597
598#[derive(Debug, Default)]
599pub struct Engine {
600    /// Committed catalog — what survives `Engine::snapshot()` and what
601    /// outside-TX `SELECT`s read.
602    catalog: Catalog,
603    /// Active TX slots, keyed by `TxId`. Empty when no TX is in flight.
604    /// v4.41.1 runtime invariant: at most one entry (single-writer
605    /// model unchanged). v4.42 will let dispatch hold multiple entries
606    /// concurrently for group commit + engine MVCC.
607    tx_catalogs: BTreeMap<TxId, TxState>,
608    /// Which slot the next exec_* call should mutate. Set by
609    /// `execute_in(sql, tx_id)` at the entry point; legacy `execute(sql)`
610    /// sets it to `IMPLICIT_TX`. None when no TX is in flight (read /
611    /// write goes straight against `catalog`).
612    current_tx: Option<TxId>,
613    /// Monotonic counter for `alloc_tx_id`. Starts at 1 — slot 0 is
614    /// reserved for `IMPLICIT_TX`.
615    next_tx_id: u64,
616    /// Optional wall clock used to satisfy `NOW()` / `CURRENT_TIMESTAMP`
617    /// / `CURRENT_DATE`. Set by the host environment.
618    clock: Option<ClockFn>,
619    /// v4.1 cryptographic RNG for per-user password salt. Set by the
620    /// host. `None` means SQL-driven `CREATE USER` uses a
621    /// deterministic fallback — see `SaltFn`.
622    salt_fn: Option<SaltFn>,
623    /// v4.2 per-query row cap. `None` = unlimited. When set, a
624    /// SELECT that materialises more than `n` rows returns
625    /// `EngineError::RowLimitExceeded`. Enforced before the result
626    /// is shaped into wire frames so a runaway scan can't blow the
627    /// server's heap.
628    max_query_rows: Option<usize>,
629    /// v4.1 RBAC user table. Empty means "no RBAC configured yet" —
630    /// the server decides what that means at the auth boundary
631    /// (open mode vs legacy single-password mode). User CRUD goes
632    /// through `create_user`/`drop_user`/`verify_user`; persistence
633    /// rides the snapshot envelope alongside the catalog.
634    users: UserStore,
635    /// v6.1.2 logical-replication publication catalog. Empty until
636    /// `CREATE PUBLICATION` runs. Persistence rides the v3 envelope
637    /// trailer (see `build_envelope`).
638    publications: publications::Publications,
639    /// v6.1.4 logical-replication subscription catalog. Empty until
640    /// `CREATE SUBSCRIPTION` runs. Persistence rides the v4 envelope
641    /// trailer.
642    subscriptions: subscriptions::Subscriptions,
643    /// v6.2.0 — per-column statistics for the cost-based optimizer.
644    /// Populated by `ANALYZE`; queried via `spg_statistic` virtual
645    /// table. Persistence rides the v5 envelope trailer.
646    statistics: statistics::Statistics,
647    /// v6.3.0 — engine-level plan cache. Caches the post-`prepare()`
648    /// `Statement` keyed on SQL text. In-memory only — does NOT ride
649    /// the snapshot envelope (rebuilt on demand after restart).
650    plan_cache: plan_cache::PlanCache,
651    /// v6.5.1 — per-distinct-SQL execution stats. In-memory only,
652    /// surfaced via `spg_stat_query` virtual table. Updated by the
653    /// `execute_*` paths after a successful execute.
654    query_stats: query_stats::QueryStats,
655    /// v6.5.2 — connection-state provider callback. spg-server
656    /// registers a function at startup that snapshots its
657    /// per-pgwire-connection registry into `ActivityRow`s; engine
658    /// reads through it on every `SELECT * FROM spg_stat_activity`.
659    /// `None` ⇒ no-data (returns empty rows; matches the no_std
660    /// embedded callers that don't run pgwire).
661    activity_provider: Option<ActivityProvider>,
662    /// v6.5.3 — audit-chain provider + verifier. Same pattern as
663    /// activity_provider: spg-server registers both at startup;
664    /// engine reads through on `SELECT * FROM spg_audit_chain` and
665    /// `SELECT * FROM spg_audit_verify`. `None` ⇒ no-data.
666    audit_chain_provider: Option<AuditChainProvider>,
667    audit_verifier: Option<AuditVerifier>,
668    /// v6.5.6 — slow-query log threshold in microseconds. When set,
669    /// every successful execute whose elapsed exceeds the threshold
670    /// gets fed to the registered slow-query log callback (so
671    /// spg-server can emit a structured log line). Default `None`
672    /// = no slow-query logging.
673    slow_query_threshold_us: Option<u64>,
674    slow_query_logger: Option<SlowQueryLogger>,
675    /// v7.12.1 — session parameters set via `SET <name> = <value>`.
676    /// Only `default_text_search_config` is consumed by the engine
677    /// today (the FTS function dispatcher reads it when
678    /// `to_tsvector(text)` is called without an explicit config).
679    /// All other names are accepted + recorded so PG-dump output
680    /// loads, but have no behavioural effect.
681    session_params: BTreeMap<String, String>,
682    /// v7.12.7 — depth counter for trigger-emitted embedded SQL.
683    /// Each time the engine executes a `DeferredEmbeddedStmt` it
684    /// increments this; the recursive `execute_stmt_with_cancel`
685    /// inside that path checks against [`MAX_TRIGGER_RECURSION`]
686    /// to bound runaway cascades (trigger A's UPDATE on table B
687    /// fires trigger B which UPDATEs table A which fires trigger
688    /// A again…). Reset to 0 once the original DML returns.
689    trigger_recursion_depth: u32,
690    /// v7.14.0 — when `SET FOREIGN_KEY_CHECKS=0` is in effect
691    /// (mysqldump preamble), the FK existence + arity check at
692    /// CREATE TABLE time is deferred. FKs referencing a
693    /// not-yet-existing parent land in `pending_foreign_keys`
694    /// keyed by child table; `SET FOREIGN_KEY_CHECKS=1` drains
695    /// the queue and resolves each FK against the now-complete
696    /// catalog. Empty by default; the queue is drained on every
697    /// `RESET ALL` too.
698    foreign_key_checks: bool,
699    /// v7.16.2 — true on the temp Engine an outer
700    /// `exec_select_with_meta_views` builds, telling that
701    /// temp engine "stop short-circuiting into the meta-view
702    /// path — your catalog already has the materialised
703    /// tables; just run the regular SELECT." Without this we'd
704    /// infinite-loop since the meta-view name (e.g.
705    /// `__spg_info_columns`) still triggers
706    /// `select_references_meta_view`.
707    meta_views_materialised: bool,
708    pending_foreign_keys: Vec<(alloc::string::String, spg_sql::ast::ForeignKeyConstraint)>,
709}
710
711/// v7.12.7 — hard cap on nested trigger-emitted embedded SQL
712/// fires. 16 deep is well past anything a normal trigger graph
713/// uses while still preventing infinite-loop wedging.
714const MAX_TRIGGER_RECURSION: u32 = 16;
715
716/// v6.5.6 — callback signature for slow-query log emission. Called
717/// with `(sql, elapsed_us)` once per successful execute that crosses
718/// the threshold.
719pub type SlowQueryLogger = fn(&str, u64);
720
721/// v6.5.4 — synthesise a `CREATE TABLE` statement from catalog
722/// state. Round-trips through `Engine::execute` to recreate the
723/// same schema (sans data + indexes — indexes are emitted as a
724/// separate `CREATE INDEX` chain in `spg_database_ddl`).
725fn render_create_table(name: &str, columns: &[ColumnSchema]) -> String {
726    let mut out = alloc::format!("CREATE TABLE {name} (");
727    for (i, col) in columns.iter().enumerate() {
728        if i > 0 {
729            out.push_str(", ");
730        }
731        out.push_str(&col.name);
732        out.push(' ');
733        out.push_str(&render_data_type(col.ty));
734        if !col.nullable {
735            out.push_str(" NOT NULL");
736        }
737        if col.auto_increment {
738            out.push_str(" AUTO_INCREMENT");
739        }
740    }
741    out.push(')');
742    out
743}
744
745fn render_data_type(ty: DataType) -> String {
746    match ty {
747        DataType::SmallInt => "SMALLINT".into(),
748        DataType::Int => "INT".into(),
749        DataType::BigInt => "BIGINT".into(),
750        DataType::Float => "FLOAT".into(),
751        DataType::Text => "TEXT".into(),
752        DataType::Varchar(n) => alloc::format!("VARCHAR({n})"),
753        DataType::Char(n) => alloc::format!("CHAR({n})"),
754        DataType::Bool => "BOOL".into(),
755        DataType::Vector { dim, encoding } => match encoding {
756            spg_storage::VecEncoding::F32 => alloc::format!("VECTOR({dim})"),
757            spg_storage::VecEncoding::Sq8 => alloc::format!("VECTOR({dim}) USING SQ8"),
758            spg_storage::VecEncoding::F16 => alloc::format!("VECTOR({dim}) USING HALF"),
759        },
760        DataType::Numeric { precision, scale } => {
761            alloc::format!("NUMERIC({precision},{scale})")
762        }
763        DataType::Date => "DATE".into(),
764        DataType::Timestamp => "TIMESTAMP".into(),
765        DataType::Interval => "INTERVAL".into(),
766        DataType::Json => "JSON".into(),
767        DataType::Jsonb => "JSONB".into(),
768        DataType::Timestamptz => "TIMESTAMPTZ".into(),
769        DataType::Bytes => "BYTEA".into(),
770        DataType::TextArray => "TEXT[]".into(),
771        DataType::IntArray => "INT[]".into(),
772        DataType::BigIntArray => "BIGINT[]".into(),
773        DataType::TsVector => "TSVECTOR".into(),
774        DataType::TsQuery => "TSQUERY".into(),
775        DataType::Uuid => "UUID".into(),
776        DataType::Time => "TIME".into(),
777        DataType::Year => "YEAR".into(),
778        DataType::TimeTz => "TIMETZ".into(),
779        DataType::Money => "MONEY".into(),
780        DataType::Range(k) => k.keyword().into(),
781        DataType::Hstore => "HSTORE".into(),
782        DataType::IntArray2D => "INT[][]".into(),
783        DataType::BigIntArray2D => "BIGINT[][]".into(),
784        DataType::TextArray2D => "TEXT[][]".into(),
785    }
786}
787
788/// v6.5.2 — one row of `spg_stat_activity`. Engine-public so
789/// spg-server can construct rows without re-exporting internal
790/// dispatch types.
791#[derive(Debug, Clone)]
792pub struct ActivityRow {
793    pub pid: u32,
794    pub user: String,
795    pub started_at_us: i64,
796    pub current_sql: String,
797    pub wait_event: String,
798    pub elapsed_us: i64,
799    pub in_transaction: bool,
800    /// v7.17 Phase 2.4 — startup-param `application_name` (or the
801    /// last value the client sent via `SET application_name = '...'`).
802    /// Empty when the client never declared one.
803    pub application_name: String,
804}
805
806/// v6.5.2 — provider callback type. Fresh snapshot returned each
807/// call; engine doesn't cache the slice.
808pub type ActivityProvider = fn() -> Vec<ActivityRow>;
809
810/// v6.5.3 — one row of `spg_audit_chain`. Engine-public so
811/// spg-server can construct rows directly from `AuditEntry`.
812#[derive(Debug, Clone)]
813pub struct AuditRow {
814    pub seq: i64,
815    pub ts_ms: i64,
816    pub prev_hash_hex: String,
817    pub entry_hash_hex: String,
818    pub sql: String,
819}
820
821/// v6.5.3 — chain-table provider + verifier. spg-server registers
822/// fn pointers that snapshot / verify the audit log. `verify`
823/// returns `(verified_count, broken_at_seq)` — `broken_at_seq` is
824/// `-1` on a clean chain.
825pub type AuditChainProvider = fn() -> Vec<AuditRow>;
826pub type AuditVerifier = fn() -> (i64, i64);
827
828impl Engine {
829    pub fn new() -> Self {
830        Self {
831            catalog: Catalog::new(),
832            tx_catalogs: BTreeMap::new(),
833            current_tx: None,
834            next_tx_id: 1,
835            clock: None,
836            salt_fn: None,
837            max_query_rows: None,
838            users: UserStore::new(),
839            publications: publications::Publications::new(),
840            subscriptions: subscriptions::Subscriptions::new(),
841            statistics: statistics::Statistics::new(),
842            plan_cache: plan_cache::PlanCache::new(),
843            query_stats: query_stats::QueryStats::new(),
844            activity_provider: None,
845            audit_chain_provider: None,
846            audit_verifier: None,
847            slow_query_threshold_us: None,
848            slow_query_logger: None,
849            session_params: BTreeMap::new(),
850            trigger_recursion_depth: 0,
851            foreign_key_checks: true,
852            meta_views_materialised: false,
853            pending_foreign_keys: Vec::new(),
854        }
855    }
856
857    /// v7.11.0 — clone the engine's committed catalog + read-time
858    /// state into a frozen `CatalogSnapshot`. Cheap (`Catalog` is
859    /// backed by `PersistentVec`; cloning is O(log n) per table).
860    /// Subsequent writes to this engine are invisible to the
861    /// snapshot; the snapshot is self-contained and can be moved
862    /// to another thread for concurrent `execute_readonly_on_snapshot`
863    /// calls. The basis for [`AsyncReadHandle`] in spg-embedded-tokio
864    /// and any other read-fanout pattern.
865    #[must_use]
866    pub fn clone_snapshot(&self) -> CatalogSnapshot {
867        CatalogSnapshot {
868            catalog: self.active_catalog().clone(),
869            statistics: self.statistics.clone(),
870            clock: self.clock,
871            max_query_rows: self.max_query_rows,
872        }
873    }
874
875    /// v7.11.1 — execute a read-only SQL statement against a
876    /// `CatalogSnapshot` without touching this engine. Same
877    /// semantics as `execute_readonly` but parameterised on the
878    /// snapshot's catalog. Reject DDL/DML the same way
879    /// `execute_readonly` does. Static-on-Self so the caller can
880    /// dispatch without holding an `Engine` borrow alongside the
881    /// snapshot.
882    pub fn execute_readonly_on_snapshot(
883        snapshot: &CatalogSnapshot,
884        sql: &str,
885    ) -> Result<QueryResult, EngineError> {
886        Self::execute_readonly_on_snapshot_with_cancel(snapshot, sql, CancelToken::none())
887    }
888
889    /// v7.11.1 — `execute_readonly_on_snapshot` with cooperative
890    /// cancellation. Builds a transient `Engine` over the snapshot
891    /// state, runs `execute_readonly_with_cancel`, drops. The
892    /// transient engine is cheap to construct (no I/O; everything
893    /// is just struct moves) and lets the existing read path stay
894    /// untouched.
895    pub fn execute_readonly_on_snapshot_with_cancel(
896        snapshot: &CatalogSnapshot,
897        sql: &str,
898        cancel: CancelToken<'_>,
899    ) -> Result<QueryResult, EngineError> {
900        let transient = Engine {
901            catalog: snapshot.catalog.clone(),
902            statistics: snapshot.statistics.clone(),
903            clock: snapshot.clock,
904            max_query_rows: snapshot.max_query_rows,
905            ..Engine::default()
906        };
907        transient.execute_readonly_with_cancel(sql, cancel)
908    }
909
910    /// v7.18 — execute a previously-prepared `Statement` against a
911    /// `CatalogSnapshot` in read-only mode. Mirror of
912    /// [`Engine::execute_prepared`] for the fan-out read path:
913    /// substitutes `Expr::Placeholder(n)` nodes from `params`, then
914    /// dispatches through [`Engine::execute_readonly_stmt_with_cancel`]
915    /// (writes / DDL hit `EngineError::WriteRequired`). Static-on-Self
916    /// so multiple readonly threads can dispatch against the same
917    /// snapshot concurrently without an `Engine` borrow.
918    ///
919    /// **Schema drift contract**. The `Statement` was prepared against
920    /// some prior catalog. If the snapshot's catalog has since
921    /// diverged (DDL renamed / dropped a referenced column / table),
922    /// execution surfaces the normal `EngineError` — same shape as
923    /// PG's "cached plan must not change result type". Caller decides
924    /// whether to re-prepare; engine does NOT auto-retry.
925    pub fn execute_readonly_prepared_on_snapshot(
926        snapshot: &CatalogSnapshot,
927        stmt: Statement,
928        params: &[Value],
929    ) -> Result<QueryResult, EngineError> {
930        Self::execute_readonly_prepared_on_snapshot_with_cancel(
931            snapshot,
932            stmt,
933            params,
934            CancelToken::none(),
935        )
936    }
937
938    /// v7.18 — cancellable variant of
939    /// [`Engine::execute_readonly_prepared_on_snapshot`].
940    pub fn execute_readonly_prepared_on_snapshot_with_cancel(
941        snapshot: &CatalogSnapshot,
942        mut stmt: Statement,
943        params: &[Value],
944        cancel: CancelToken<'_>,
945    ) -> Result<QueryResult, EngineError> {
946        cancel.check()?;
947        substitute_placeholders(&mut stmt, params)?;
948        let transient = Engine {
949            catalog: snapshot.catalog.clone(),
950            statistics: snapshot.statistics.clone(),
951            clock: snapshot.clock,
952            max_query_rows: snapshot.max_query_rows,
953            ..Engine::default()
954        };
955        transient.execute_readonly_stmt_with_cancel(stmt, cancel)
956    }
957
958    /// v7.18 — describe a prepared `Statement` against a
959    /// `CatalogSnapshot`. Same `(parameter_oids, output_columns)`
960    /// shape as [`Engine::describe_prepared`]; resolves names
961    /// against the snapshot's catalog instead of `self`. Pure
962    /// function — no engine state read.
963    pub fn describe_prepared_on_snapshot(
964        snapshot: &CatalogSnapshot,
965        stmt: &Statement,
966    ) -> (Vec<u32>, Vec<ColumnSchema>) {
967        describe::describe_prepared(stmt, &snapshot.catalog)
968    }
969
970    /// v7.18 — does this SQL string classify as read-only? Parses
971    /// `sql` with the engine parser and consults
972    /// `Statement::is_readonly()`. A parse error returns `false`
973    /// (route to the writer path so the user sees the canonical
974    /// parse error from the writer's simple-query dispatch).
975    /// Static-on-Self so the spg-sqlx connection layer can ask
976    /// without an `Engine` borrow.
977    #[must_use]
978    pub fn is_readonly_sql(sql: &str) -> bool {
979        parser::parse_statement(sql)
980            .as_ref()
981            .map(spg_sql::ast::Statement::is_readonly)
982            .unwrap_or(false)
983    }
984
985    /// v7.18 — parse + plan a SQL string against a
986    /// `CatalogSnapshot`. Mirror of [`Engine::prepare`] for the
987    /// readonly fan-out path: applies the same prepare-time
988    /// transforms (clock rewrite, `GROUP BY ALL` expansion, ORDER
989    /// BY position resolve, cost-based JOIN reorder) but resolves
990    /// catalog + statistics against the snapshot, not a live
991    /// engine. Static-on-Self — `AsyncReadHandle::prepare` calls
992    /// this without taking the writer lock so multiple read
993    /// handles can prepare concurrently against frozen views.
994    ///
995    /// # Errors
996    /// Propagates [`ParseError`] from the parser. Schema
997    /// validation deferred to execute time, same as
998    /// [`Engine::prepare`].
999    pub fn prepare_on_snapshot(
1000        snapshot: &CatalogSnapshot,
1001        sql: &str,
1002    ) -> Result<Statement, ParseError> {
1003        let mut stmt = parser::parse_statement(sql)?;
1004        let now_micros = snapshot.clock.map(|f| f());
1005        rewrite_clock_calls(&mut stmt, now_micros);
1006        if let Statement::Select(s) = &mut stmt {
1007            expand_group_by_all(s);
1008            resolve_order_by_position(s);
1009            reorder::reorder_joins(s, &snapshot.catalog, &snapshot.statistics);
1010        }
1011        Ok(stmt)
1012    }
1013
1014    /// Construct an engine restored from a previously-snapshotted catalog
1015    /// (see `snapshot()`).
1016    pub fn restore(catalog: Catalog) -> Self {
1017        Self {
1018            catalog,
1019            tx_catalogs: BTreeMap::new(),
1020            current_tx: None,
1021            next_tx_id: 1,
1022            clock: None,
1023            salt_fn: None,
1024            max_query_rows: None,
1025            users: UserStore::new(),
1026            publications: publications::Publications::new(),
1027            subscriptions: subscriptions::Subscriptions::new(),
1028            statistics: statistics::Statistics::new(),
1029            plan_cache: plan_cache::PlanCache::new(),
1030            query_stats: query_stats::QueryStats::new(),
1031            activity_provider: None,
1032            audit_chain_provider: None,
1033            audit_verifier: None,
1034            slow_query_threshold_us: None,
1035            slow_query_logger: None,
1036            session_params: BTreeMap::new(),
1037            trigger_recursion_depth: 0,
1038            foreign_key_checks: true,
1039            meta_views_materialised: false,
1040            pending_foreign_keys: Vec::new(),
1041        }
1042    }
1043
1044    /// Restore an engine + user table from a v4.1 envelope produced
1045    /// by `snapshot_with_users()`. Falls back to plain catalog-only
1046    /// restore if the envelope magic isn't present (so v3.x snapshot
1047    /// files still load). v6.1.2 adds the optional publications
1048    /// trailer (envelope v3); a v1/v2 envelope deserialises to an
1049    /// empty publication table.
1050    pub fn restore_envelope(buf: &[u8]) -> Result<Self, EngineError> {
1051        match split_envelope(buf) {
1052            EnvelopeParse::Pair {
1053                catalog: catalog_bytes,
1054                users: user_bytes,
1055                publications: pub_bytes,
1056                subscriptions: sub_bytes,
1057                statistics: stats_bytes,
1058            } => {
1059                let catalog = Catalog::deserialize(catalog_bytes).map_err(EngineError::Storage)?;
1060                let users = users::deserialize_users(user_bytes)
1061                    .map_err(|e| EngineError::Unsupported(alloc::format!("users restore: {e}")))?;
1062                let publications = match pub_bytes {
1063                    Some(b) => publications::Publications::deserialize(b).map_err(|e| {
1064                        EngineError::Unsupported(alloc::format!("publications restore: {e:?}"))
1065                    })?,
1066                    None => publications::Publications::new(),
1067                };
1068                let subscriptions = match sub_bytes {
1069                    Some(b) => subscriptions::Subscriptions::deserialize(b).map_err(|e| {
1070                        EngineError::Unsupported(alloc::format!("subscriptions restore: {e:?}"))
1071                    })?,
1072                    None => subscriptions::Subscriptions::new(),
1073                };
1074                let statistics = match stats_bytes {
1075                    Some(b) => statistics::Statistics::deserialize(b).map_err(|e| {
1076                        EngineError::Unsupported(alloc::format!("statistics restore: {e:?}"))
1077                    })?,
1078                    None => statistics::Statistics::new(),
1079                };
1080                Ok(Self {
1081                    catalog,
1082                    tx_catalogs: BTreeMap::new(),
1083                    current_tx: None,
1084                    next_tx_id: 1,
1085                    clock: None,
1086                    salt_fn: None,
1087                    max_query_rows: None,
1088                    users,
1089                    publications,
1090                    subscriptions,
1091                    statistics,
1092                    plan_cache: plan_cache::PlanCache::new(),
1093                    query_stats: query_stats::QueryStats::new(),
1094                    activity_provider: None,
1095                    audit_chain_provider: None,
1096                    audit_verifier: None,
1097                    slow_query_threshold_us: None,
1098                    slow_query_logger: None,
1099                    session_params: BTreeMap::new(),
1100                    trigger_recursion_depth: 0,
1101                    foreign_key_checks: true,
1102                    meta_views_materialised: false,
1103                    pending_foreign_keys: Vec::new(),
1104                })
1105            }
1106            EnvelopeParse::CrcMismatch { expected, computed } => {
1107                Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
1108                    "snapshot envelope CRC32 mismatch (expected={expected:#010x}, computed={computed:#010x})"
1109                ))))
1110            }
1111            EnvelopeParse::Bare => {
1112                let catalog = Catalog::deserialize(buf).map_err(EngineError::Storage)?;
1113                Ok(Self::restore(catalog))
1114            }
1115        }
1116    }
1117
1118    pub const fn users(&self) -> &UserStore {
1119        &self.users
1120    }
1121
1122    /// `salt` is supplied by the caller (the host has a random
1123    /// source; the engine is `no_std`). Caller should pass a fresh
1124    /// 16-byte random value per user.
1125    pub fn create_user(
1126        &mut self,
1127        name: &str,
1128        password: &str,
1129        role: Role,
1130        salt: [u8; 16],
1131    ) -> Result<(), UserError> {
1132        self.users.create(name, password, role, salt)?;
1133        // v4.8: also derive SCRAM-SHA-256 secrets so PG-wire SASL
1134        // auth can verify without re-running PBKDF2 per attempt.
1135        // Uses a fresh salt from the host RNG (falls back to a
1136        // deterministic per-username salt when no RNG is wired, same
1137        // as the legacy hash path).
1138        let scram_salt = self.salt_fn.map_or_else(
1139            || {
1140                let mut s = [0u8; users::SCRAM_SALT_LEN];
1141                let digest = spg_crypto::hash(name.as_bytes());
1142                // Use bytes 16..32 of BLAKE3 so we don't reuse the
1143                // exact same fallback salt as the BLAKE3 hash path.
1144                s.copy_from_slice(&digest[16..32]);
1145                s
1146            },
1147            |f| f(),
1148        );
1149        self.users
1150            .enable_scram(name, password, scram_salt, users::SCRAM_DEFAULT_ITERS)?;
1151        Ok(())
1152    }
1153
1154    pub fn drop_user(&mut self, name: &str) -> Result<(), UserError> {
1155        self.users.drop(name)
1156    }
1157
1158    pub fn verify_user(&self, name: &str, password: &str) -> Option<Role> {
1159        self.users.verify(name, password)
1160    }
1161
1162    /// Builder: attach a wall clock so `NOW()` / `CURRENT_TIMESTAMP` /
1163    /// `CURRENT_DATE` evaluate to a real value instead of erroring out.
1164    #[must_use]
1165    pub const fn with_clock(mut self, clock: ClockFn) -> Self {
1166        self.clock = Some(clock);
1167        self
1168    }
1169
1170    /// Builder: attach an OS-backed RNG for per-user password salts.
1171    /// The host (`spg-server`) typically wires this to `/dev/urandom`.
1172    #[must_use]
1173    pub const fn with_salt_fn(mut self, f: SaltFn) -> Self {
1174        self.salt_fn = Some(f);
1175        self
1176    }
1177
1178    /// Builder: cap the number of rows a single SELECT may return.
1179    /// Exceeding the cap raises `EngineError::RowLimitExceeded` —
1180    /// the bound is checked inside the executor so a runaway
1181    /// catalog scan can't allocate millions of rows before the
1182    /// server gets a chance to reject the result.
1183    #[must_use]
1184    pub const fn with_max_query_rows(mut self, n: usize) -> Self {
1185        self.max_query_rows = Some(n);
1186        self
1187    }
1188
1189    /// The *committed* catalog. Note: during a transaction this returns the
1190    /// pre-TX state — `SELECT` inside a TX goes through `execute()` and reads
1191    /// the shadow. Tests that inspect outside-TX state should use this.
1192    pub const fn catalog(&self) -> &Catalog {
1193        &self.catalog
1194    }
1195
1196    /// Serialize the *committed* catalog to bytes. v0.6 was full-snapshot; v0.9
1197    /// adds the rule that an open TX's shadow is never snapshotted — only the
1198    /// post-COMMIT state is persisted. v4.1 wraps the catalog in an envelope
1199    /// when there are users to persist; an empty user table snapshots as the
1200    /// bare catalog format (backwards-compat with v3.x readers). v6.1.2
1201    /// adds publications to the envelope condition: either non-empty
1202    /// users OR non-empty publications now triggers the envelope path.
1203    pub fn snapshot(&self) -> Vec<u8> {
1204        if self.users.is_empty()
1205            && self.publications.is_empty()
1206            && self.subscriptions.is_empty()
1207            && self.statistics.is_empty()
1208        {
1209            self.catalog.serialize()
1210        } else {
1211            build_envelope(
1212                &self.catalog.serialize(),
1213                &users::serialize_users(&self.users),
1214                &self.publications.serialize(),
1215                &self.subscriptions.serialize(),
1216                &self.statistics.serialize(),
1217            )
1218        }
1219    }
1220
1221    /// True when at least one TX slot is in flight. v4.41.1 runtime
1222    /// invariant: at most one slot active at a time (dispatch holds
1223    /// `engine.write()` across the entire wrap). v4.42 will let this
1224    /// return true with multiple slots concurrently.
1225    pub fn in_transaction(&self) -> bool {
1226        !self.tx_catalogs.is_empty()
1227    }
1228
1229    /// v4.41.1 allocate a fresh TX handle. Used by spg-server dispatch
1230    /// to scope each implicit-wrap BEGIN..stmt..COMMIT to its own slot
1231    /// in `tx_catalogs`. v4.42 — the commit-barrier leader allocates
1232    /// one of these per task in its group, runs `BEGIN`+sql+`COMMIT`
1233    /// sequentially under a single `engine.write()` so each task's
1234    /// mutations accumulate into shared state, then either keeps the
1235    /// accumulated state (fsync OK) or restores the pre-image via
1236    /// `replace_catalog` (fsync err).
1237    pub fn alloc_tx_id(&mut self) -> TxId {
1238        let id = TxId(self.next_tx_id);
1239        self.next_tx_id = self.next_tx_id.saturating_add(1);
1240        id
1241    }
1242
1243    /// v4.42 — atomically replace the live catalog. Used by the
1244    /// commit-barrier leader to roll back a group whose batched
1245    /// fsync failed: the leader snapshots `engine.catalog().clone()`
1246    /// (O(1) Arc bump after the v4.39/v4.40 persistent migration)
1247    /// at group start, sequentially applies each task's BEGIN+sql+
1248    /// COMMIT under the same write lock to accumulate mutations
1249    /// into shared state, batches the WAL bytes, fsyncs once, and
1250    /// on failure calls this with the pre-image to undo every
1251    /// task in the group at once.
1252    ///
1253    /// **Does NOT touch `tx_catalogs` / `current_tx`.** Any
1254    /// explicit-TX slot from a concurrent client (created via the
1255    /// legacy `IMPLICIT_TX`-less dispatch path or via the future
1256    /// MVCC-readers v5+ work) has its own snapshot baked into the
1257    /// slot — restoring `self.catalog` to the pre-image leaves
1258    /// those slots untouched, exactly as they were when the leader
1259    /// took the lock. The leader's own implicit-TX slots are all
1260    /// already discarded (`exec_commit` removed them as each
1261    /// task's COMMIT ran) by the time this is reached.
1262    pub fn replace_catalog(&mut self, catalog: Catalog) {
1263        self.catalog = catalog;
1264    }
1265
1266    /// v6.7.0 — public shim around `Catalog::freeze_oldest_to_cold`
1267    /// so tests + the spg-server freezer can drive a freeze without
1268    /// reaching into the private `active_catalog_mut`. v6.7.4
1269    /// parallel freezer will build on this surface.
1270    ///
1271    /// Marks the table's cached `cold_row_count` stale because the
1272    /// freeze added cold locators that ANALYZE hasn't yet refreshed.
1273    pub fn freeze_oldest_to_cold(
1274        &mut self,
1275        table_name: &str,
1276        index_name: &str,
1277        max_rows: usize,
1278    ) -> Result<spg_storage::FreezeReport, EngineError> {
1279        let report = self
1280            .active_catalog_mut()
1281            .freeze_oldest_to_cold(table_name, index_name, max_rows)
1282            .map_err(EngineError::Storage)?;
1283        if let Some(t) = self.active_catalog_mut().get_mut(table_name) {
1284            t.mark_cold_row_count_stale();
1285        }
1286        Ok(report)
1287    }
1288
1289    /// v6.7.5 — public shim used by the spg-server follower's
1290    /// segment-forwarding receiver. Registers a cold-tier segment
1291    /// at a specific id (the master's id, as transmitted on the
1292    /// wire) so the follower's BTree-Cold locators stay byte-
1293    /// identical with the master's. Wraps
1294    /// `Catalog::load_segment_bytes_at` under the standard
1295    /// clone-mutate-replace pattern.
1296    ///
1297    /// Returns `Ok(())` on success **and** on the "slot already
1298    /// occupied" case — a follower mid-reconnect may receive a
1299    /// segment chunk for a segment_id it already has on disk
1300    /// (forwarded last session); the caller should treat that
1301    /// path as a no-op rather than a fatal error.
1302    pub fn receive_cold_segment(
1303        &mut self,
1304        segment_id: u32,
1305        bytes: Vec<u8>,
1306    ) -> Result<(), EngineError> {
1307        let mut new_cat = self.catalog.clone();
1308        match new_cat.load_segment_bytes_at(segment_id, bytes) {
1309            Ok(()) => {
1310                self.replace_catalog(new_cat);
1311                Ok(())
1312            }
1313            Err(StorageError::Corrupt(msg)) if msg.contains("already occupied") => Ok(()),
1314            Err(e) => Err(EngineError::Storage(e)),
1315        }
1316    }
1317
1318    /// v6.7.3 — public shim around `Catalog::compact_cold_segments`
1319    /// driving every BTree index on every user table. Returns one
1320    /// `(table, index, report)` triple for each merge that
1321    /// actually happened (no-op (table, index) pairs are filtered
1322    /// out so callers can size persist-side work to the live
1323    /// merges). Caller is responsible for persisting each
1324    /// `report.merged_segment_bytes` and updating the on-disk
1325    /// segment registry; engine layer is no_std and never
1326    /// touches disk.
1327    ///
1328    /// Marks every touched table's cached `cold_row_count` stale
1329    /// — compaction GC'd some shadowed rows, so the count must be
1330    /// re-derived on the next ANALYZE.
1331    pub fn compact_cold_segments_with_target(
1332        &mut self,
1333        target_segment_bytes: u64,
1334    ) -> Result<Vec<(String, String, CompactReport)>, EngineError> {
1335        let table_names = self.active_catalog().table_names();
1336        let mut reports: Vec<(String, String, CompactReport)> = Vec::new();
1337        for tname in table_names {
1338            if is_internal_table_name(&tname) {
1339                continue;
1340            }
1341            let idx_names: Vec<String> = {
1342                let Some(t) = self.active_catalog().get(&tname) else {
1343                    continue;
1344                };
1345                t.indices()
1346                    .iter()
1347                    .filter(|i| matches!(i.kind, IndexKind::BTree(_)))
1348                    .map(|i| i.name.clone())
1349                    .collect()
1350            };
1351            for iname in idx_names {
1352                let report = self
1353                    .active_catalog_mut()
1354                    .compact_cold_segments(&tname, &iname, target_segment_bytes)
1355                    .map_err(EngineError::Storage)?;
1356                if report.merged_segment_id.is_some() {
1357                    if let Some(t) = self.active_catalog_mut().get_mut(&tname) {
1358                        t.mark_cold_row_count_stale();
1359                    }
1360                    reports.push((tname.clone(), iname, report));
1361                }
1362            }
1363        }
1364        Ok(reports)
1365    }
1366
1367    fn active_catalog(&self) -> &Catalog {
1368        match self.current_tx {
1369            Some(t) => self
1370                .tx_catalogs
1371                .get(&t)
1372                .map_or(&self.catalog, |s| &s.catalog),
1373            None => &self.catalog,
1374        }
1375    }
1376
1377    /// v7.12.4 — snapshot every row-level trigger on `table` that
1378    /// fires for `event` (`"INSERT"` / `"UPDATE"` / `"DELETE"`) at
1379    /// the given `timing` (`"BEFORE"` / `"AFTER"`), and clone its
1380    /// referenced function definition. Returned as a vec of owned
1381    /// `FunctionDef` so the row-write loop can fire them without
1382    /// holding a borrow on the catalog (which would conflict with
1383    /// the table.insert / update_row / delete mutable borrows).
1384    /// v7.16.2 — top-level DO block executor. Walks the
1385    /// PlPgSqlBlock via [`triggers::execute_do_block_top_level`],
1386    /// then runs each collected EmbeddedSql statement through
1387    /// the engine's regular execute path (NOT deferred — DO is
1388    /// outside any row-write borrow). Errors from any step
1389    /// abort the block and propagate verbatim.
1390    /// v7.16.2 — resolve every subquery inside a PlPgSqlBlock's
1391    /// expression slots so the downstream trigger-flavoured
1392    /// evaluator (which expects pre-resolved Expr::Literal /
1393    /// Binary chains) doesn't trip on raw Exists/ScalarSubquery
1394    /// nodes. Walks IF conditions, Assign values, RAISE args.
1395    /// EmbeddedSql statements re-enter the engine for execution
1396    /// later so their subqueries get the normal SELECT-side
1397    /// resolution.
1398    fn resolve_plpgsql_block_subqueries(
1399        &self,
1400        block: &mut spg_sql::ast::PlPgSqlBlock,
1401        cancel: CancelToken<'_>,
1402    ) -> Result<(), EngineError> {
1403        for d in &mut block.declarations {
1404            if let Some(e) = &mut d.default {
1405                self.resolve_expr_subqueries(e, cancel)?;
1406            }
1407        }
1408        self.resolve_plpgsql_stmts_subqueries(&mut block.statements, cancel)
1409    }
1410
1411    fn resolve_plpgsql_stmts_subqueries(
1412        &self,
1413        stmts: &mut [spg_sql::ast::PlPgSqlStmt],
1414        cancel: CancelToken<'_>,
1415    ) -> Result<(), EngineError> {
1416        use spg_sql::ast::PlPgSqlStmt;
1417        for stmt in stmts {
1418            match stmt {
1419                PlPgSqlStmt::Assign { value, .. } => {
1420                    self.resolve_expr_subqueries(value, cancel)?;
1421                }
1422                PlPgSqlStmt::Return(spg_sql::ast::ReturnTarget::Expr(e)) => {
1423                    self.resolve_expr_subqueries(e, cancel)?;
1424                }
1425                PlPgSqlStmt::Return(_) => {}
1426                PlPgSqlStmt::If {
1427                    branches,
1428                    else_branch,
1429                } => {
1430                    for (cond, body) in branches.iter_mut() {
1431                        self.resolve_expr_subqueries(cond, cancel)?;
1432                        self.resolve_plpgsql_stmts_subqueries(body, cancel)?;
1433                    }
1434                    self.resolve_plpgsql_stmts_subqueries(else_branch, cancel)?;
1435                }
1436                PlPgSqlStmt::Raise { args, .. } => {
1437                    for a in args {
1438                        self.resolve_expr_subqueries(a, cancel)?;
1439                    }
1440                }
1441                PlPgSqlStmt::EmbeddedSql(_) => {
1442                    // Embedded SQL goes back through execute_stmt
1443                    // _with_cancel which runs the SELECT-side
1444                    // resolver itself; nothing to do here.
1445                }
1446                PlPgSqlStmt::SelectInto { body, .. } => {
1447                    // SELECT INTO runs through Engine::execute
1448                    // when reached, so subquery resolution
1449                    // happens via the normal SELECT-side path.
1450                    // Still walk for nested subqueries inside
1451                    // the SELECT body so eval doesn't trip.
1452                    self.resolve_select_subqueries(body, cancel)?;
1453                }
1454            }
1455        }
1456        Ok(())
1457    }
1458
1459    fn exec_do_block(
1460        &mut self,
1461        body: spg_sql::ast::PlPgSqlBlock,
1462    ) -> Result<QueryResult, EngineError> {
1463        // v7.16.2 — pre-resolve every subquery the body's
1464        // expressions reach. `eval::eval_expr` errors on
1465        // unresolved Exists/ScalarSubquery/InSubquery; the
1466        // top-level SELECT path runs `resolve_select_subqueries`
1467        // for the caller — for plpgsql we have to do the
1468        // equivalent before the body walker runs. Catches the
1469        // mailrs idiom `IF EXISTS (SELECT 1 FROM
1470        // information_schema.columns WHERE …) THEN …`.
1471        let mut body = body;
1472        self.resolve_plpgsql_block_subqueries(&mut body, CancelToken::none())?;
1473        let dts = self
1474            .session_param("default_text_search_config")
1475            .map(String::from);
1476        // v7.16.2 — SELECT … INTO resolver. The walker calls
1477        // this synchronously when it hits a SelectInto stmt
1478        // so the IF / locals scope sees the result before the
1479        // next statement. Body walks for trigger paths (no
1480        // resolver) error loudly on SelectInto.
1481        // SAFETY: the closure shares this engine borrow with
1482        // the walker, but the walker only borrows for the
1483        // duration of `execute_do_block_top_level` and doesn't
1484        // reach back into the engine through any other path —
1485        // so the recursive `&mut` is sound. We use a `RefCell`
1486        // for interior mutability since the closure is
1487        // Fn-shaped.
1488        let engine_cell = core::cell::RefCell::new(&mut *self);
1489        let resolver_fn =
1490            |stmt: &spg_sql::ast::Statement| -> Result<Value, triggers::TriggerError> {
1491                let mut eng = engine_cell.borrow_mut();
1492                let r = eng
1493                    .execute_stmt_with_cancel(stmt.clone(), CancelToken::none())
1494                    .map_err(|e| triggers::TriggerError::EvalFailed {
1495                        function: "DO".into(),
1496                        cause: eval::EvalError::TypeMismatch {
1497                            detail: alloc::format!("SELECT … INTO failed: {e}"),
1498                        },
1499                    })?;
1500                match r {
1501                    QueryResult::Rows { rows, .. } => match rows.into_iter().next() {
1502                        Some(row) => Ok(row.values.into_iter().next().unwrap_or(Value::Null)),
1503                        None => Ok(Value::Null),
1504                    },
1505                    _ => Err(triggers::TriggerError::EvalFailed {
1506                        function: "DO".into(),
1507                        cause: eval::EvalError::TypeMismatch {
1508                            detail: "SELECT … INTO body must be a SELECT".into(),
1509                        },
1510                    }),
1511                }
1512            };
1513        let collected =
1514            triggers::execute_do_block_top_level(&body, dts.as_deref(), Some(&resolver_fn))
1515                .map_err(|e| {
1516                    EngineError::Storage(StorageError::Corrupt(alloc::format!("DO: {e}")))
1517                })?;
1518        // engine_cell goes out of scope here, releasing the &mut self borrow
1519        // Run each embedded statement against the engine. The
1520        // statements were already substitute-walked for NEW/OLD/
1521        // locals (those evaluate to engine literals before they
1522        // land here) so dispatch is plain execute_stmt_with_cancel.
1523        for stmt in collected {
1524            // v7.16.2 — preserve current_tx wrap so an outer
1525            // BEGIN/COMMIT around a DO block keeps the
1526            // EmbeddedSql writes inside that same tx slot.
1527            self.execute_stmt_with_cancel(stmt, CancelToken::none())?;
1528        }
1529        Ok(QueryResult::CommandOk {
1530            affected: 0,
1531            modified_catalog: !self.in_transaction(),
1532        })
1533    }
1534
1535    fn snapshot_row_triggers(
1536        &self,
1537        table: &str,
1538        event: &str,
1539        timing: &str,
1540    ) -> Vec<spg_storage::FunctionDef> {
1541        let cat = self.active_catalog();
1542        cat.triggers()
1543            .iter()
1544            .filter(|t| {
1545                // v7.16.1 — skip disabled triggers (mailrs
1546                // round-9 A.2.b — pg_dump --disable-triggers).
1547                t.enabled
1548                    && t.table == table
1549                    && t.timing.eq_ignore_ascii_case(timing)
1550                    && t.for_each.eq_ignore_ascii_case("row")
1551                    && t.events.iter().any(|e| e.eq_ignore_ascii_case(event))
1552            })
1553            .filter_map(|t| cat.functions().get(&t.function).cloned())
1554            .collect()
1555    }
1556
1557    /// v7.13.0 — UPDATE-side snapshot that pairs each trigger's
1558    /// function with its `UPDATE OF cols` filter (mailrs round-5
1559    /// G7). Empty filter Vec means "fire unconditionally", matching
1560    /// the v7.12 behaviour.
1561    fn snapshot_update_row_triggers(
1562        &self,
1563        table: &str,
1564        timing: &str,
1565    ) -> Vec<(spg_storage::FunctionDef, Vec<String>)> {
1566        let cat = self.active_catalog();
1567        cat.triggers()
1568            .iter()
1569            .filter(|t| {
1570                // v7.16.1 — skip disabled triggers.
1571                t.enabled
1572                    && t.table == table
1573                    && t.timing.eq_ignore_ascii_case(timing)
1574                    && t.for_each.eq_ignore_ascii_case("row")
1575                    && t.events.iter().any(|e| e.eq_ignore_ascii_case("UPDATE"))
1576            })
1577            .filter_map(|t| {
1578                cat.functions()
1579                    .get(&t.function)
1580                    .cloned()
1581                    .map(|fd| (fd, t.update_columns.clone()))
1582            })
1583            .collect()
1584    }
1585
1586    /// v7.12.7 — drain the trigger-emitted embedded SQL queue.
1587    /// Called by the INSERT / UPDATE / DELETE executors after
1588    /// their main row-write loop returns. Each statement runs
1589    /// inside the same cancel scope as the firing DML and bumps
1590    /// the recursion counter; nested embedded SQL beyond
1591    /// [`MAX_TRIGGER_RECURSION`] errors with a clear message so
1592    /// a trigger-graph cycle surfaces as a query failure instead
1593    /// of stack-blowing the engine.
1594    fn execute_deferred_trigger_stmts(
1595        &mut self,
1596        deferred: Vec<triggers::DeferredEmbeddedStmt>,
1597        cancel: CancelToken<'_>,
1598    ) -> Result<(), EngineError> {
1599        for d in deferred {
1600            if self.trigger_recursion_depth >= MAX_TRIGGER_RECURSION {
1601                return Err(EngineError::Storage(StorageError::Corrupt(alloc::format!(
1602                    "trigger embedded SQL recursion depth {} exceeded (trigger function \
1603                     {:?} would push past the {} cap — check for trigger cycles)",
1604                    self.trigger_recursion_depth,
1605                    d.function,
1606                    MAX_TRIGGER_RECURSION,
1607                ))));
1608            }
1609            self.trigger_recursion_depth += 1;
1610            let res = self.execute_stmt_with_cancel(d.stmt, cancel);
1611            self.trigger_recursion_depth -= 1;
1612            res?;
1613        }
1614        Ok(())
1615    }
1616
1617    fn active_catalog_mut(&mut self) -> &mut Catalog {
1618        let tx = self.current_tx;
1619        match tx {
1620            Some(t) => match self.tx_catalogs.get_mut(&t) {
1621                Some(s) => &mut s.catalog,
1622                None => &mut self.catalog,
1623            },
1624            None => &mut self.catalog,
1625        }
1626    }
1627
1628    /// Read-only execute path. Succeeds for `SELECT` / `SHOW TABLES`
1629    /// / `SHOW COLUMNS`; returns `EngineError::WriteRequired` for
1630    /// every other statement, so the caller can fall through to the
1631    /// `&mut self` `execute` path under a write lock. Engine state is
1632    /// not mutated even on the success path (`rewrite_clock_calls`
1633    /// and `resolve_order_by_position` both mutate the locally-owned
1634    /// AST, not `self`).
1635    ///
1636    /// **v4.0 concurrency**: this is the entry point the server takes
1637    /// under an `RwLock::read()` so multiple `SELECT` clients run in
1638    /// parallel without serialising on a single mutex.
1639    pub fn execute_readonly(&self, sql: &str) -> Result<QueryResult, EngineError> {
1640        self.execute_readonly_with_cancel(sql, CancelToken::none())
1641    }
1642
1643    /// v4.5 — read path with cooperative cancellation. Token's
1644    /// `is_cancelled` is checked at the start (so a watchdog that
1645    /// already fired returns Cancelled immediately) and at row-loop
1646    /// checkpoints inside `exec_select`. SHOW paths are O(small) and
1647    /// don't bother checking.
1648    pub fn execute_readonly_with_cancel(
1649        &self,
1650        sql: &str,
1651        cancel: CancelToken<'_>,
1652    ) -> Result<QueryResult, EngineError> {
1653        cancel.check()?;
1654        let mut stmt = parser::parse_statement(sql)?;
1655        let now_micros = self.clock.map(|f| f());
1656        rewrite_clock_calls(&mut stmt, now_micros);
1657        if let Statement::Select(s) = &mut stmt {
1658            resolve_order_by_position(s);
1659            // v6.2.3 — cost-based JOIN reorder (read path).
1660            reorder::reorder_joins(s, &self.catalog, &self.statistics);
1661        }
1662        self.execute_readonly_stmt_with_cancel(stmt, cancel)
1663    }
1664
1665    /// v7.18 — readonly dispatch on a pre-parsed `Statement`.
1666    /// Internal helper shared by the SQL-string path
1667    /// ([`Engine::execute_readonly_with_cancel`]) and the prepared-
1668    /// statement path ([`Engine::execute_readonly_prepared_on_snapshot_with_cancel`]).
1669    /// Statement-level transforms (clock rewrite, ORDER BY position,
1670    /// JOIN reorder, placeholder substitution) are the caller's
1671    /// responsibility — this helper assumes the AST is already
1672    /// execution-ready. Writes / DDL hit
1673    /// [`EngineError::WriteRequired`] the same way the SQL path does.
1674    fn execute_readonly_stmt_with_cancel(
1675        &self,
1676        stmt: Statement,
1677        cancel: CancelToken<'_>,
1678    ) -> Result<QueryResult, EngineError> {
1679        let result = match stmt {
1680            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
1681            Statement::ShowTables => Ok(self.exec_show_tables()),
1682            Statement::ShowDatabases => Ok(self.exec_show_databases()),
1683            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
1684            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
1685            Statement::ShowStatus => Ok(self.exec_show_status()),
1686            Statement::ShowVariables => Ok(self.exec_show_variables()),
1687            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
1688            Statement::ShowColumns(table) => self.exec_show_columns(&table),
1689            Statement::ShowUsers => Ok(self.exec_show_users()),
1690            Statement::ShowPublications => Ok(self.exec_show_publications()),
1691            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
1692            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
1693                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
1694            )),
1695            Statement::Explain(e) => self.exec_explain(&e, cancel),
1696            _ => Err(EngineError::WriteRequired),
1697        };
1698        self.enforce_row_limit(result)
1699    }
1700
1701    /// v4.2: cap result-set size. Applied after the executor
1702    /// materialises rows but before they leave the engine — wrapping
1703    /// every Rows-returning exec_* function would scatter the check.
1704    fn enforce_row_limit(
1705        &self,
1706        result: Result<QueryResult, EngineError>,
1707    ) -> Result<QueryResult, EngineError> {
1708        if let (Ok(QueryResult::Rows { rows, .. }), Some(cap)) = (&result, self.max_query_rows)
1709            && rows.len() > cap
1710        {
1711            return Err(EngineError::RowLimitExceeded(cap));
1712        }
1713        result
1714    }
1715
1716    pub fn execute(&mut self, sql: &str) -> Result<QueryResult, EngineError> {
1717        self.execute_in_with_cancel(sql, IMPLICIT_TX, CancelToken::none())
1718    }
1719
1720    /// v4.5 — write path with cooperative cancellation. Same dispatch
1721    /// as `execute_in_with_cancel(sql, IMPLICIT_TX, cancel)`. Kept as
1722    /// a separate entry point for backward-compat with the v4.5
1723    /// public API.
1724    pub fn execute_with_cancel(
1725        &mut self,
1726        sql: &str,
1727        cancel: CancelToken<'_>,
1728    ) -> Result<QueryResult, EngineError> {
1729        self.execute_in_with_cancel(sql, IMPLICIT_TX, cancel)
1730    }
1731
1732    /// v4.41.1 multi-slot write entry. Routes `sql` through the TX
1733    /// slot identified by `tx_id` so spg-server dispatch can scope
1734    /// each implicit-wrap BEGIN..stmt..COMMIT to its own slot in
1735    /// `tx_catalogs`. `IMPLICIT_TX` is the legacy single-slot path
1736    /// every other caller (engine self-tests, replay, spg-embedded)
1737    /// implicitly takes via `execute()` / `execute_with_cancel()`.
1738    pub fn execute_in(&mut self, sql: &str, tx_id: TxId) -> Result<QueryResult, EngineError> {
1739        self.execute_in_with_cancel(sql, tx_id, CancelToken::none())
1740    }
1741
1742    /// v4.41.1 write path with cooperative cancellation + explicit TX
1743    /// scope. Sets `self.current_tx` for the duration of the call so
1744    /// every `exec_*` helper transparently sees its TX's shadow
1745    /// catalog and savepoint stack; restores on exit so the field is
1746    /// only valid mid-call (no leakage across calls).
1747    pub fn execute_in_with_cancel(
1748        &mut self,
1749        sql: &str,
1750        tx_id: TxId,
1751        cancel: CancelToken<'_>,
1752    ) -> Result<QueryResult, EngineError> {
1753        let saved = self.current_tx;
1754        self.current_tx = Some(tx_id);
1755        let result = self.execute_inner_with_cancel(sql, cancel);
1756        self.current_tx = saved;
1757        result
1758    }
1759
1760    /// v6.1.1 — parse and pre-process a SQL string ONCE so the
1761    /// resulting [`Statement`] can be cached and re-executed via
1762    /// [`Engine::execute_prepared`]. Returns the same `Statement`
1763    /// the simple-query path would synthesise internally (clock
1764    /// rewrites + ORDER BY position-ref resolution applied at
1765    /// prepare time, since both are session-independent). The
1766    /// `$N` placeholders in the SQL stay as `Expr::Placeholder(n)`
1767    /// nodes; they're resolved to concrete values per-call by
1768    /// `execute_prepared`'s substitution walk.
1769    ///
1770    /// Pgwire's `Parse` (P) message lands here.
1771    pub fn prepare(&self, sql: &str) -> Result<Statement, ParseError> {
1772        let mut stmt = parser::parse_statement(sql)?;
1773        let now_micros = self.clock.map(|f| f());
1774        rewrite_clock_calls(&mut stmt, now_micros);
1775        if let Statement::Select(s) = &mut stmt {
1776            // v6.4.1 — expand `GROUP BY ALL` to every non-aggregate
1777            // SELECT-list item BEFORE position / alias resolution so
1778            // downstream passes see the explicit list.
1779            expand_group_by_all(s);
1780            resolve_order_by_position(s);
1781            // v6.2.3 — cost-based JOIN reorder. No-op for
1782            // single-table FROMs or any non-INNER join shape.
1783            reorder::reorder_joins(s, &self.catalog, &self.statistics);
1784        }
1785        Ok(stmt)
1786    }
1787
1788    /// v6.3.0 — cached prepare. Returns a cloned `Statement` from
1789    /// the plan cache on hit, runs the full `prepare()` path on miss
1790    /// and inserts the resulting plan before returning. Skipping the
1791    /// parse + JOIN-reorder pipeline on hit is the dominant win for
1792    /// JDBC / sqlx / pgx clients that reuse the same SQL string.
1793    ///
1794    /// Returns a cloned `Statement` (not a borrow) because the
1795    /// pgwire layer owns its `PreparedStmt` map per-session and the
1796    /// engine-level cache must stay available for other sessions.
1797    /// Clone cost on a 5-table JOIN AST is well under the parse cost
1798    /// it replaces.
1799    pub fn prepare_cached(&mut self, sql: &str) -> Result<Statement, ParseError> {
1800        // v6.3.1 — version-aware lookup. If the cached plan was
1801        // prepared before the most recent ANALYZE, evict and replan.
1802        let current_version = self.statistics.version();
1803        if let Some(plan) = self.plan_cache.get(sql) {
1804            if plan.statistics_version == current_version {
1805                return Ok(plan.stmt.clone());
1806            }
1807            // Stale entry — fall through to evict + re-prepare.
1808        }
1809        self.plan_cache.evict(sql);
1810        let stmt = self.prepare(sql)?;
1811        let source_tables = plan_cache::collect_source_tables(&stmt);
1812        let plan = plan_cache::PreparedPlan {
1813            stmt: stmt.clone(),
1814            statistics_version: current_version,
1815            source_tables,
1816            describe_columns: alloc::vec::Vec::new(),
1817        };
1818        self.plan_cache.insert(String::from(sql), plan);
1819        Ok(stmt)
1820    }
1821
1822    /// v6.3.0 — read-only accessor for tests and v6.3.1 invalidation.
1823    pub fn plan_cache(&self) -> &plan_cache::PlanCache {
1824        &self.plan_cache
1825    }
1826
1827    /// v6.3.0 — mutable accessor for v6.3.1 invalidation hooks.
1828    pub fn plan_cache_mut(&mut self) -> &mut plan_cache::PlanCache {
1829        &mut self.plan_cache
1830    }
1831
1832    /// v6.3.3 — Describe a prepared `Statement` without executing.
1833    /// Returns `(parameter_oids, output_columns)`. Empty
1834    /// `output_columns` means the statement has no row-producing
1835    /// shape we could resolve here (JOIN, subquery, non-SELECT, …)
1836    /// — pgwire layer maps that to a `NoData` reply.
1837    pub fn describe_prepared(&self, stmt: &Statement) -> (Vec<u32>, Vec<ColumnSchema>) {
1838        describe::describe_prepared(stmt, self.active_catalog())
1839    }
1840
1841    /// v6.1.1 — execute a [`Statement`] previously returned by
1842    /// [`Engine::prepare`], substituting `Expr::Placeholder(n)`
1843    /// nodes for the corresponding [`Value`] in `params` (1-based
1844    /// per PG: `$1` → `params[0]`). Bind-time string parameters
1845    /// are decoded into typed `Value`s by the pgwire layer before
1846    /// this call so the resulting AST hits the same execution
1847    /// path as a simple query — no SQL re-parse.
1848    ///
1849    /// Pgwire's `Execute` (E) message after a `Bind` (B) lands here.
1850    pub fn execute_prepared(
1851        &mut self,
1852        stmt: Statement,
1853        params: &[Value],
1854    ) -> Result<QueryResult, EngineError> {
1855        self.execute_prepared_with_cancel(stmt, params, CancelToken::none())
1856    }
1857
1858    /// v7.17.0 Phase 2.3 — prepared-statement entry that honors a
1859    /// caller-supplied `CancelToken`. Mirrors `execute_prepared`'s
1860    /// `current_tx` save/restore so the extended-query path stays
1861    /// transactionally consistent with the simple-query path.
1862    pub fn execute_prepared_with_cancel(
1863        &mut self,
1864        mut stmt: Statement,
1865        params: &[Value],
1866        cancel: CancelToken<'_>,
1867    ) -> Result<QueryResult, EngineError> {
1868        substitute_placeholders(&mut stmt, params)?;
1869        // v7.16.0 — set `current_tx` for the duration of the
1870        // dispatch so the `exec_*` helpers see the right TX
1871        // slot (matches what `execute_in_with_cancel` does for
1872        // simple-query). Pre-v7.16 the simple-query path
1873        // worked because every public entry point routed
1874        // through `execute_in_with_cancel`; the prepared path
1875        // skipped the wrap and so its INSERTs/UPDATEs landed
1876        // in the no-tx default slot, silently invisible to a
1877        // BEGIN/COMMIT-bracketed flow. Caught by spg-sqlx's
1878        // first transaction-visibility test.
1879        let saved = self.current_tx;
1880        self.current_tx = Some(IMPLICIT_TX);
1881        let result = self.execute_stmt_with_cancel(stmt, cancel);
1882        self.current_tx = saved;
1883        result
1884    }
1885
1886    fn execute_inner_with_cancel(
1887        &mut self,
1888        sql: &str,
1889        cancel: CancelToken<'_>,
1890    ) -> Result<QueryResult, EngineError> {
1891        cancel.check()?;
1892        let stmt = self.prepare(sql)?;
1893        // v6.5.1 — wrap the executor with a wall-clock window so we
1894        // can record into spg_stat_query. Skip when the engine has
1895        // no clock attached (no_std embedded callers).
1896        let start_us = self.clock.map(|f| f());
1897        let result = self.execute_stmt_with_cancel(stmt, cancel);
1898        if let (Some(t0), Ok(_)) = (start_us, &result) {
1899            let now = self.clock.map_or(t0, |f| f());
1900            let elapsed = now.saturating_sub(t0).max(0) as u64;
1901            self.query_stats.record(sql, elapsed, now as u64);
1902            // v6.5.6 — slow-query log: fire callback when elapsed
1903            // exceeds the configured floor.
1904            if let (Some(threshold), Some(logger)) =
1905                (self.slow_query_threshold_us, self.slow_query_logger)
1906                && elapsed >= threshold
1907            {
1908                logger(sql, elapsed);
1909            }
1910        }
1911        result
1912    }
1913
1914    fn execute_stmt_with_cancel(
1915        &mut self,
1916        stmt: Statement,
1917        cancel: CancelToken<'_>,
1918    ) -> Result<QueryResult, EngineError> {
1919        cancel.check()?;
1920        // v7.17.0 Phase 1.1 — pre-resolve nextval / currval /
1921        // setval calls in the statement tree. Walks SELECT
1922        // projection, INSERT VALUES, UPDATE SET, DELETE WHERE,
1923        // and DEFAULT exprs; replaces sequence FunctionCall
1924        // nodes with concrete Literal values minted against the
1925        // catalog. This is the only place that mutates sequence
1926        // state from a SELECT-shaped path (exec_select_cancel is
1927        // `&self` and can't reach the catalog mutably).
1928        //
1929        // Fast-path: when no sequences exist anywhere in the
1930        // catalog (the typical hot-path INSERT load), skip the
1931        // walker entirely. Single map-emptiness check on the
1932        // catalog beats walking every expression on every call.
1933        let mut stmt = stmt;
1934        // v7.17 dump-compat — the fast-path check
1935        // `sequences().is_empty()` skips pre-resolve when no
1936        // sequence exists in the *currently active* catalog
1937        // snapshot. The committed catalog or the implicit-TX
1938        // catalog may legitimately disagree on this between
1939        // CREATE SEQUENCE and a later setval(): always run the
1940        // resolver — the walk is O(expr-count) and dwarfed by
1941        // the parse cost we just paid.
1942        self.pre_resolve_sequence_calls_in_statement(&mut stmt)?;
1943        let result = match stmt {
1944            Statement::CreateTable(s) => self.exec_create_table(s),
1945            // v7.9.15 — CREATE EXTENSION is a no-op on SPG. Returns
1946            // CommandOk with affected=0; modified_catalog=false so
1947            // the WAL doesn't grow a useless entry. mailrs F3.
1948            Statement::CreateExtension(_) => Ok(QueryResult::CommandOk {
1949                affected: 0,
1950                modified_catalog: false,
1951            }),
1952            // v7.16.2 — DO $$ ... $$ block. mailrs round-10 A.2
1953            // — the pre-v7.9.27 no-op SILENTLY swallowed every
1954            // mailrs migrate-038/-040/-042 idempotent rename
1955            // (the IF EXISTS … THEN ALTER … END block never
1956            // ran). v7.16.2 dispatches to exec_do_block which
1957            // runs the PlPgSqlBlock at top level via the same
1958            // execute_stmts machinery the trigger executor
1959            // uses (NEW=None, OLD=None — DO blocks have no
1960            // row context).
1961            Statement::DoBlock(body) => self.exec_do_block(body),
1962            // v7.14.0 — empty-statement no-op for pg_dump /
1963            // mysqldump preamble lines that collapse to nothing
1964            // after comment-stripping.
1965            Statement::Empty => Ok(QueryResult::CommandOk {
1966                affected: 0,
1967                modified_catalog: false,
1968            }),
1969            Statement::DropTable { names, if_exists } => self.exec_drop_table(names, if_exists),
1970            Statement::DropIndex { name, if_exists } => self.exec_drop_index(name, if_exists),
1971            Statement::CreateIndex(s) => self.exec_create_index(s),
1972            Statement::Insert(s) => self.exec_insert(s),
1973            Statement::Update(s) => self.exec_update_cancel(&s, cancel),
1974            Statement::Delete(s) => self.exec_delete_cancel(&s, cancel),
1975            Statement::Merge(s) => self.exec_merge_cancel(&s, cancel),
1976            Statement::Select(s) => self.exec_select_cancel(&s, cancel),
1977            Statement::Begin => self.exec_begin(),
1978            Statement::Commit => self.exec_commit(),
1979            Statement::Rollback => self.exec_rollback(),
1980            Statement::Savepoint(name) => self.exec_savepoint(name),
1981            Statement::RollbackToSavepoint(name) => self.exec_rollback_to_savepoint(&name),
1982            Statement::ReleaseSavepoint(name) => self.exec_release_savepoint(&name),
1983            Statement::ShowTables => Ok(self.exec_show_tables()),
1984            Statement::ShowDatabases => Ok(self.exec_show_databases()),
1985            Statement::ShowCreateTable(name) => self.exec_show_create_table(&name),
1986            Statement::ShowIndexes(name) => self.exec_show_indexes(&name),
1987            Statement::ShowStatus => Ok(self.exec_show_status()),
1988            Statement::ShowVariables => Ok(self.exec_show_variables()),
1989            Statement::ShowProcesslist => Ok(self.exec_show_processlist()),
1990            Statement::ShowColumns(table) => self.exec_show_columns(&table),
1991            Statement::ShowUsers => Ok(self.exec_show_users()),
1992            Statement::ShowPublications => Ok(self.exec_show_publications()),
1993            Statement::ShowSubscriptions => Ok(self.exec_show_subscriptions()),
1994            Statement::CreateUser(s) => self.exec_create_user(&s),
1995            Statement::DropUser(name) => self.exec_drop_user(&name),
1996            Statement::Explain(e) => self.exec_explain(&e, cancel),
1997            Statement::AlterIndex(s) => self.exec_alter_index(s),
1998            Statement::AlterTable(s) => self.exec_alter_table(s),
1999            Statement::CreatePublication(s) => self.exec_create_publication(s),
2000            Statement::DropPublication(name) => self.exec_drop_publication(&name),
2001            Statement::CreateSubscription(s) => self.exec_create_subscription(s),
2002            Statement::DropSubscription(name) => self.exec_drop_subscription(&name),
2003            // v6.1.7 — WAIT FOR WAL POSITION needs `lag_state`,
2004            // which lives in spg-server's ServerState. The engine
2005            // surfaces a clear error; the server-layer dispatch
2006            // intercepts the SQL before it reaches the engine on
2007            // a server build, so this arm only fires for
2008            // engine-only callers (spg-embedded, lib tests).
2009            Statement::WaitForWalPosition { .. } => Err(EngineError::Unsupported(
2010                "WAIT FOR WAL POSITION must be handled by the server layer".into(),
2011            )),
2012            // v6.2.0 — ANALYZE recomputes per-column histograms.
2013            Statement::Analyze(target) => self.exec_analyze(target.as_deref()),
2014            // v6.7.3 — COMPACT COLD SEGMENTS.
2015            Statement::CompactColdSegments => self.exec_compact_cold_segments(),
2016            // v7.12.1 — SET / RESET session parameter. Engine
2017            // tracks the value in `session_params`; FTS dispatcher
2018            // reads `default_text_search_config`. Everything else
2019            // is a recorded no-op (PG dump compat).
2020            Statement::SetParameter { name, value } => {
2021                self.set_session_param(name, value);
2022                Ok(QueryResult::CommandOk {
2023                    affected: 0,
2024                    modified_catalog: false,
2025                })
2026            }
2027            // v7.14.0 — MySQL multi-assignment SET. Each pair runs
2028            // through `set_session_param` so engine-known params
2029            // (FOREIGN_KEY_CHECKS, session_replication_role, …) take
2030            // effect; unknown pairs (including `@VAR` LHS from the
2031            // mysqldump preamble) are recorded then ignored.
2032            Statement::SetParameterList(pairs) => {
2033                for (name, value) in pairs {
2034                    self.set_session_param(name, value);
2035                }
2036                Ok(QueryResult::CommandOk {
2037                    affected: 0,
2038                    modified_catalog: false,
2039                })
2040            }
2041            // v7.12.4 — CREATE FUNCTION / CREATE TRIGGER / DROP …
2042            // for the PL/pgSQL trigger surface. exec_* methods are
2043            // defined alongside the existing CREATE handlers below.
2044            Statement::CreateFunction(s) => self.exec_create_function(s),
2045            Statement::CreateTrigger(s) => self.exec_create_trigger(s),
2046            Statement::DropTrigger {
2047                name,
2048                table,
2049                if_exists,
2050            } => self.exec_drop_trigger(&name, &table, if_exists),
2051            Statement::DropFunction { name, if_exists } => {
2052                self.exec_drop_function(&name, if_exists)
2053            }
2054            Statement::CreateSequence(s) => self.exec_create_sequence(s),
2055            Statement::AlterSequence(s) => self.exec_alter_sequence(s),
2056            Statement::DropSequence { names, if_exists } => {
2057                self.exec_drop_sequence(&names, if_exists)
2058            }
2059            Statement::CreateView(s) => self.exec_create_view(s),
2060            Statement::DropView { names, if_exists } => self.exec_drop_view(&names, if_exists),
2061            Statement::CreateMaterializedView(s) => self.exec_create_materialized_view(s),
2062            Statement::RefreshMaterializedView { name, with_data } => {
2063                self.exec_refresh_materialized_view(&name, with_data)
2064            }
2065            Statement::DropMaterializedView { names, if_exists } => {
2066                self.exec_drop_materialized_view(&names, if_exists)
2067            }
2068            Statement::CreateType(s) => self.exec_create_type(s),
2069            Statement::DropType { names, if_exists } => self.exec_drop_type(&names, if_exists),
2070            Statement::CreateDomain(s) => self.exec_create_domain(s),
2071            Statement::DropDomain { names, if_exists } => self.exec_drop_domain(&names, if_exists),
2072            Statement::CreateSchema {
2073                name,
2074                if_not_exists,
2075            } => self.exec_create_schema(name, if_not_exists),
2076            Statement::DropSchema { names, if_exists } => self.exec_drop_schema(&names, if_exists),
2077            Statement::ResetParameter(target) => {
2078                match target {
2079                    None => self.session_params.clear(),
2080                    Some(name) => {
2081                        self.session_params.remove(&name.to_ascii_lowercase());
2082                    }
2083                }
2084                Ok(QueryResult::CommandOk {
2085                    affected: 0,
2086                    modified_catalog: false,
2087                })
2088            }
2089        };
2090        self.enforce_row_limit(result)
2091    }
2092
2093    /// v6.1.2 — `CREATE PUBLICATION` runtime path. Duplicate names
2094    /// surface as `EngineError::Unsupported` so the existing PG-wire
2095    /// error mapping stays uniform; the message carries the name so
2096    /// operators can grep replication-log noise. Inside-transaction
2097    /// invocation is rejected (matches `CREATE USER` / `DROP USER`
2098    /// stance) — replication-catalog mutation is a connection-level
2099    /// administrative op, not a transactional one.
2100    fn exec_create_publication(
2101        &mut self,
2102        s: CreatePublicationStatement,
2103    ) -> Result<QueryResult, EngineError> {
2104        // v6.1.4 — the v6.1.2 "no DDL inside a transaction" guard
2105        // was over-cautious: it also blocked the auto-commit wrap
2106        // path (which begins an internal TX around every WAL-
2107        // logged statement). PG itself allows CREATE PUBLICATION
2108        // inside a transaction (it rolls back with the TX).
2109        self.publications
2110            .create(s.name, s.scope)
2111            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE PUBLICATION: {e:?}")))?;
2112        Ok(QueryResult::CommandOk {
2113            affected: 1,
2114            modified_catalog: true,
2115        })
2116    }
2117
2118    /// v6.1.2 — `DROP PUBLICATION` runtime path. PG-compatible silent
2119    /// no-op when the publication doesn't exist (returns `affected=0`
2120    /// in that case so the wire-level command tag distinguishes
2121    /// "dropped" from "no-op", though both succeed).
2122    fn exec_drop_publication(&mut self, name: &str) -> Result<QueryResult, EngineError> {
2123        let removed = self.publications.drop(name);
2124        Ok(QueryResult::CommandOk {
2125            affected: usize::from(removed),
2126            modified_catalog: removed,
2127        })
2128    }
2129
2130    /// v6.1.2 — read access to the publication catalog. Used by
2131    /// the v6.1.5 publisher-side WAL filter, by `SHOW PUBLICATIONS`
2132    /// (v6.1.3+), and by e2e tests that need to assert state without
2133    /// going through the wire.
2134    pub const fn publications(&self) -> &publications::Publications {
2135        &self.publications
2136    }
2137
2138    /// v6.1.4 — `CREATE SUBSCRIPTION` runtime path. Defaults
2139    /// `enabled = true` and `last_received_pos = 0` for a freshly-
2140    /// created subscription. The actual worker thread is spawned
2141    /// by spg-server once the engine returns success.
2142    fn exec_create_subscription(
2143        &mut self,
2144        s: CreateSubscriptionStatement,
2145    ) -> Result<QueryResult, EngineError> {
2146        // See exec_create_publication — the in_transaction gate
2147        // was over-cautious; the auto-commit wrap path holds an
2148        // internal TX that this check was incorrectly blocking.
2149        let sub = subscriptions::Subscription {
2150            conn_str: s.conn_str,
2151            publications: s.publications,
2152            enabled: true,
2153            last_received_pos: 0,
2154        };
2155        self.subscriptions
2156            .create(s.name, sub)
2157            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE SUBSCRIPTION: {e:?}")))?;
2158        Ok(QueryResult::CommandOk {
2159            affected: 1,
2160            modified_catalog: true,
2161        })
2162    }
2163
2164    /// v6.1.4 — `DROP SUBSCRIPTION`. Silent no-op when the name
2165    /// doesn't exist (PG-compatible). The associated worker is
2166    /// torn down by spg-server when it observes the catalog
2167    /// change at the next snapshot or via the engine's
2168    /// subscriptions accessor (the worker polls the catalog on
2169    /// reconnect; v6.1.5's filter-side will tighten this to an
2170    /// explicit signal).
2171    fn exec_drop_subscription(&mut self, name: &str) -> Result<QueryResult, EngineError> {
2172        let removed = self.subscriptions.drop(name);
2173        Ok(QueryResult::CommandOk {
2174            affected: usize::from(removed),
2175            modified_catalog: removed,
2176        })
2177    }
2178
2179    /// v6.1.4 — read access to the subscription catalog. Used by
2180    /// the subscription worker (read its own row to find its
2181    /// publications + last applied position), by SHOW SUBSCRIPTIONS,
2182    /// and by e2e tests asserting state directly.
2183    pub const fn subscriptions(&self) -> &subscriptions::Subscriptions {
2184        &self.subscriptions
2185    }
2186
2187    /// v6.1.4 — write access to `last_received_pos`. Worker
2188    /// calls this after each apply batch (under the engine's
2189    /// write-lock). Returns `false` when the subscription was
2190    /// dropped between when the worker received the record and
2191    /// when this call landed.
2192    pub fn subscription_advance(&mut self, name: &str, pos: u64) -> bool {
2193        self.subscriptions.update_last_received_pos(name, pos)
2194    }
2195
2196    /// v6.1.4 — `SHOW SUBSCRIPTIONS` row materialisation. Returns
2197    /// `(name, conn_str, publications, enabled, last_received_pos)`
2198    /// ordered by subscription name. The `publications` column is
2199    /// the comma-joined list ("p1, p2") for ergonomic SHOW output;
2200    /// callers wanting structured access read `Engine::subscriptions`.
2201    fn exec_show_subscriptions(&self) -> QueryResult {
2202        let columns = alloc::vec![
2203            ColumnSchema::new("name", DataType::Text, false),
2204            ColumnSchema::new("conn_str", DataType::Text, false),
2205            ColumnSchema::new("publications", DataType::Text, false),
2206            ColumnSchema::new("enabled", DataType::Bool, false),
2207            ColumnSchema::new("last_received_pos", DataType::BigInt, false),
2208        ];
2209        let rows: Vec<Row> = self
2210            .subscriptions
2211            .iter()
2212            .map(|(name, sub)| {
2213                Row::new(alloc::vec![
2214                    Value::Text(name.clone()),
2215                    Value::Text(sub.conn_str.clone()),
2216                    Value::Text(sub.publications.join(", ")),
2217                    Value::Bool(sub.enabled),
2218                    Value::BigInt(i64::try_from(sub.last_received_pos).unwrap_or(i64::MAX)),
2219                ])
2220            })
2221            .collect();
2222        QueryResult::Rows { columns, rows }
2223    }
2224
2225    /// v6.2.0 — materialise `spg_statistic` rows. One row per
2226    /// `(table, column)` pair tracked in `Statistics`, with
2227    /// `histogram_bounds` rendered as a `[v0, v1, ...]` string —
2228    /// the same canonical form vector literals use for round-trip.
2229    fn exec_spg_statistic(&self) -> QueryResult {
2230        let columns = alloc::vec![
2231            ColumnSchema::new("table_name", DataType::Text, false),
2232            ColumnSchema::new("column_name", DataType::Text, false),
2233            ColumnSchema::new("null_frac", DataType::Float, false),
2234            ColumnSchema::new("n_distinct", DataType::BigInt, false),
2235            ColumnSchema::new("histogram_bounds", DataType::Text, false),
2236            // v6.7.0 — appended column (v6.2.0 stability contract
2237            // allows APPEND to spg_statistic, not reorder/rename).
2238            // Reports the cached per-table cold-row count; same
2239            // value across every column row of the same table.
2240            ColumnSchema::new("cold_row_count", DataType::BigInt, false),
2241        ];
2242        let rows: Vec<Row> = self
2243            .statistics
2244            .iter()
2245            .map(|((t, c), s)| {
2246                let cold = self
2247                    .catalog
2248                    .get(t)
2249                    .map_or(0, |table| table.cold_row_count());
2250                Row::new(alloc::vec![
2251                    Value::Text(t.clone()),
2252                    Value::Text(c.clone()),
2253                    Value::Float(f64::from(s.null_frac)),
2254                    Value::BigInt(i64::try_from(s.n_distinct).unwrap_or(i64::MAX)),
2255                    Value::Text(render_histogram_bounds(&s.histogram_bounds)),
2256                    Value::BigInt(i64::try_from(cold).unwrap_or(i64::MAX)),
2257                ])
2258            })
2259            .collect();
2260        QueryResult::Rows { columns, rows }
2261    }
2262
2263    /// v6.5.0 — materialise `spg_stat_replication` rows. One row
2264    /// per subscription with `(name, conn_str, publications,
2265    /// last_received_pos, enabled)`. Surface mirrors
2266    /// `SHOW SUBSCRIPTIONS` but follows the virtual-table dispatch
2267    /// shape so it composes with SELECT clauses (WHERE, projection
2268    /// onto specific columns, etc).
2269    fn exec_spg_stat_replication(&self) -> QueryResult {
2270        let columns = alloc::vec![
2271            ColumnSchema::new("name", DataType::Text, false),
2272            ColumnSchema::new("conn_str", DataType::Text, false),
2273            ColumnSchema::new("publications", DataType::Text, false),
2274            ColumnSchema::new("last_received_pos", DataType::BigInt, false),
2275            ColumnSchema::new("enabled", DataType::Bool, false),
2276        ];
2277        let rows: Vec<Row> = self
2278            .subscriptions
2279            .iter()
2280            .map(|(name, sub)| {
2281                Row::new(alloc::vec![
2282                    Value::Text(name.clone()),
2283                    Value::Text(sub.conn_str.clone()),
2284                    Value::Text(sub.publications.join(",")),
2285                    Value::BigInt(i64::try_from(sub.last_received_pos).unwrap_or(i64::MAX)),
2286                    Value::Bool(sub.enabled),
2287                ])
2288            })
2289            .collect();
2290        QueryResult::Rows { columns, rows }
2291    }
2292
2293    /// v6.5.0 — materialise `spg_stat_segment` rows. One row per
2294    /// cold-tier segment with `(segment_id, num_rows, num_pages,
2295    /// total_bytes)`.
2296    ///
2297    /// v6.7.0 — appended `table_name` column resolves the v6.5.0
2298    /// carve-out. Walks every user table's BTree indices to find
2299    /// which table's Cold locators point at each segment. Empty
2300    /// string for orphan segments (loaded via SPG_PRELOAD_COLD_SEGMENT
2301    /// before any index registered a locator). The walk is
2302    /// O(tables × indices × keys); cached per call, not across
2303    /// calls — re-walked on every `SELECT * FROM spg_stat_segment`.
2304    fn exec_spg_stat_segment(&self) -> QueryResult {
2305        let columns = alloc::vec![
2306            ColumnSchema::new("segment_id", DataType::BigInt, false),
2307            ColumnSchema::new("table_name", DataType::Text, false),
2308            ColumnSchema::new("num_rows", DataType::BigInt, false),
2309            ColumnSchema::new("num_pages", DataType::BigInt, false),
2310            ColumnSchema::new("total_bytes", DataType::BigInt, false),
2311        ];
2312        // v6.7.0 — build a segment_id → table_name map by walking
2313        // every user table's BTree indices once. O(tables × indices
2314        // × keys) for the v6.5.0 carve-out resolution; acceptable
2315        // because spg_stat_segment is operator-facing (not on a
2316        // hot-loop path).
2317        let mut segment_owners: alloc::collections::BTreeMap<u32, String> = BTreeMap::new();
2318        for tname in self.catalog.table_names() {
2319            if is_internal_table_name(&tname) {
2320                continue;
2321            }
2322            let Some(t) = self.catalog.get(&tname) else {
2323                continue;
2324            };
2325            for idx in t.indices() {
2326                if let spg_storage::IndexKind::BTree(map) = &idx.kind {
2327                    for (_, locs) in map.iter() {
2328                        for loc in locs {
2329                            if let spg_storage::RowLocator::Cold { segment_id, .. } = loc {
2330                                segment_owners
2331                                    .entry(*segment_id)
2332                                    .or_insert_with(|| tname.clone());
2333                            }
2334                        }
2335                    }
2336                }
2337            }
2338        }
2339        let rows: Vec<Row> = self
2340            .catalog
2341            .cold_segment_ids_global()
2342            .iter()
2343            .filter_map(|&id| {
2344                let seg = self.catalog.cold_segment(id)?;
2345                let meta = seg.meta();
2346                let owner = segment_owners.get(&id).cloned().unwrap_or_default();
2347                Some(Row::new(alloc::vec![
2348                    Value::BigInt(i64::from(id)),
2349                    Value::Text(owner),
2350                    Value::BigInt(i64::try_from(meta.num_rows).unwrap_or(i64::MAX)),
2351                    Value::BigInt(i64::from(meta.num_pages)),
2352                    Value::BigInt(i64::try_from(meta.total_bytes).unwrap_or(i64::MAX)),
2353                ]))
2354            })
2355            .collect();
2356        QueryResult::Rows { columns, rows }
2357    }
2358
2359    /// v6.5.1 — materialise `spg_stat_query` rows. One row per
2360    /// distinct SQL text recorded since the engine booted, capped
2361    /// at `QUERY_STATS_MAX` (1024). Columns:
2362    ///   sql, exec_count, total_us, mean_us, max_us, last_seen_us
2363    /// mean_us = total_us / exec_count (saturating).
2364    fn exec_spg_stat_query(&self) -> QueryResult {
2365        let columns = alloc::vec![
2366            ColumnSchema::new("sql", DataType::Text, false),
2367            ColumnSchema::new("exec_count", DataType::BigInt, false),
2368            ColumnSchema::new("total_us", DataType::BigInt, false),
2369            ColumnSchema::new("mean_us", DataType::BigInt, false),
2370            ColumnSchema::new("max_us", DataType::BigInt, false),
2371            ColumnSchema::new("last_seen_us", DataType::BigInt, false),
2372        ];
2373        let rows: Vec<Row> = self
2374            .query_stats
2375            .snapshot()
2376            .into_iter()
2377            .map(|(sql, s)| {
2378                let mean = if s.exec_count == 0 {
2379                    0
2380                } else {
2381                    s.total_us / s.exec_count
2382                };
2383                Row::new(alloc::vec![
2384                    Value::Text(sql),
2385                    Value::BigInt(i64::try_from(s.exec_count).unwrap_or(i64::MAX)),
2386                    Value::BigInt(i64::try_from(s.total_us).unwrap_or(i64::MAX)),
2387                    Value::BigInt(i64::try_from(mean).unwrap_or(i64::MAX)),
2388                    Value::BigInt(i64::try_from(s.max_us).unwrap_or(i64::MAX)),
2389                    Value::BigInt(i64::try_from(s.last_seen_us).unwrap_or(i64::MAX)),
2390                ])
2391            })
2392            .collect();
2393        QueryResult::Rows { columns, rows }
2394    }
2395
2396    /// v6.5.2 — register a connection-state provider. spg-server
2397    /// calls this at startup with a function that snapshots its
2398    /// per-pgwire-connection registry. Engine reads through the
2399    /// callback on `SELECT * FROM spg_stat_activity`.
2400    #[must_use]
2401    pub const fn with_activity_provider(mut self, f: ActivityProvider) -> Self {
2402        self.activity_provider = Some(f);
2403        self
2404    }
2405
2406    /// v6.5.3 — register audit chain provider + verifier.
2407    #[must_use]
2408    pub const fn with_audit_providers(
2409        mut self,
2410        chain: AuditChainProvider,
2411        verify: AuditVerifier,
2412    ) -> Self {
2413        self.audit_chain_provider = Some(chain);
2414        self.audit_verifier = Some(verify);
2415        self
2416    }
2417
2418    /// v6.5.6 — register a slow-query log callback. `threshold_us`
2419    /// is the floor (in microseconds); only executes above the floor
2420    /// fire the callback. spg-server wires this from
2421    /// `SPG_SLOW_QUERY_THRESHOLD_MS` (default 100 ms).
2422    #[must_use]
2423    pub const fn with_slow_query_log(mut self, threshold_us: u64, logger: SlowQueryLogger) -> Self {
2424        self.slow_query_threshold_us = Some(threshold_us);
2425        self.slow_query_logger = Some(logger);
2426        self
2427    }
2428
2429    /// v6.5.6 — operator knob for plan cache cap. spg-server reads
2430    /// `SPG_PLAN_CACHE_MAX` env at startup; uses this to override
2431    /// the compile-time default of 256.
2432    pub fn set_plan_cache_max(&mut self, n: usize) {
2433        self.plan_cache.set_max_entries(n);
2434    }
2435
2436    /// v6.5.2 — materialise `spg_stat_activity` rows. Pulls a fresh
2437    /// snapshot from the registered `ActivityProvider`. Returns an
2438    /// empty result set when no provider is registered (the no_std
2439    /// embedded path with no pgwire layer).
2440    fn exec_spg_stat_activity(&self) -> QueryResult {
2441        let columns = alloc::vec![
2442            ColumnSchema::new("pid", DataType::Int, false),
2443            ColumnSchema::new("user", DataType::Text, false),
2444            ColumnSchema::new("started_at_us", DataType::BigInt, false),
2445            ColumnSchema::new("current_sql", DataType::Text, false),
2446            ColumnSchema::new("wait_event", DataType::Text, false),
2447            ColumnSchema::new("elapsed_us", DataType::BigInt, false),
2448            ColumnSchema::new("in_transaction", DataType::Bool, false),
2449            ColumnSchema::new("application_name", DataType::Text, false),
2450        ];
2451        let rows: Vec<Row> = self
2452            .activity_provider
2453            .map(|f| f())
2454            .unwrap_or_default()
2455            .into_iter()
2456            .map(|r| {
2457                Row::new(alloc::vec![
2458                    Value::Int(i32::try_from(r.pid).unwrap_or(i32::MAX)),
2459                    Value::Text(r.user),
2460                    Value::BigInt(r.started_at_us),
2461                    Value::Text(r.current_sql),
2462                    Value::Text(r.wait_event),
2463                    Value::BigInt(r.elapsed_us),
2464                    Value::Bool(r.in_transaction),
2465                    Value::Text(r.application_name),
2466                ])
2467            })
2468            .collect();
2469        QueryResult::Rows { columns, rows }
2470    }
2471
2472    /// v6.5.4 — materialise `spg_table_ddl` rows. One row per user
2473    /// table with `(table_name, ddl)`. Reconstructed from catalog
2474    /// state on demand.
2475    fn exec_spg_table_ddl(&self) -> QueryResult {
2476        let columns = alloc::vec![
2477            ColumnSchema::new("table_name", DataType::Text, false),
2478            ColumnSchema::new("ddl", DataType::Text, false),
2479        ];
2480        let rows: Vec<Row> = self
2481            .catalog
2482            .table_names()
2483            .into_iter()
2484            .filter(|n| !is_internal_table_name(n))
2485            .filter_map(|name| {
2486                let table = self.catalog.get(&name)?;
2487                let ddl = render_create_table(&name, &table.schema().columns);
2488                Some(Row::new(alloc::vec![Value::Text(name), Value::Text(ddl),]))
2489            })
2490            .collect();
2491        QueryResult::Rows { columns, rows }
2492    }
2493
2494    /// v6.5.4 — materialise `spg_role_ddl` rows. One row per user
2495    /// with `(role_name, ddl)`. Password is redacted (matches the
2496    /// `Statement::CreateUser` Display which prints `'<redacted>'`).
2497    fn exec_spg_role_ddl(&self) -> QueryResult {
2498        let columns = alloc::vec![
2499            ColumnSchema::new("role_name", DataType::Text, false),
2500            ColumnSchema::new("ddl", DataType::Text, false),
2501        ];
2502        let rows: Vec<Row> = self
2503            .users
2504            .iter()
2505            .map(|(name, rec)| {
2506                let ddl = alloc::format!(
2507                    "CREATE USER {name} WITH PASSWORD '<redacted>' ROLE '{}'",
2508                    rec.role.as_str(),
2509                );
2510                Row::new(alloc::vec![
2511                    Value::Text(String::from(name)),
2512                    Value::Text(ddl)
2513                ])
2514            })
2515            .collect();
2516        QueryResult::Rows { columns, rows }
2517    }
2518
2519    /// v6.5.4 — materialise `spg_database_ddl`: single row whose
2520    /// `ddl` column concatenates every user table's CREATE +
2521    /// every role's CREATE in deterministic catalog order. Suitable
2522    /// for piping back through `Engine::execute` to recreate a
2523    /// schema-equivalent database.
2524    fn exec_spg_database_ddl(&self) -> QueryResult {
2525        let columns = alloc::vec![ColumnSchema::new("ddl", DataType::Text, false)];
2526        let mut out = String::new();
2527        for (name, rec) in self.users.iter() {
2528            out.push_str(&alloc::format!(
2529                "CREATE USER {name} WITH PASSWORD '<redacted>' ROLE '{}';\n",
2530                rec.role.as_str(),
2531            ));
2532        }
2533        for name in self.catalog.table_names() {
2534            if is_internal_table_name(&name) {
2535                continue;
2536            }
2537            if let Some(table) = self.catalog.get(&name) {
2538                out.push_str(&render_create_table(&name, &table.schema().columns));
2539                out.push_str(";\n");
2540            }
2541        }
2542        QueryResult::Rows {
2543            columns,
2544            rows: alloc::vec![Row::new(alloc::vec![Value::Text(out)])],
2545        }
2546    }
2547
2548    /// v6.5.3 — materialise `spg_audit_chain` rows. Pulls a fresh
2549    /// snapshot from the registered provider; empty when no
2550    /// provider is set.
2551    fn exec_spg_audit_chain(&self) -> QueryResult {
2552        let columns = alloc::vec![
2553            ColumnSchema::new("seq", DataType::BigInt, false),
2554            ColumnSchema::new("ts_ms", DataType::BigInt, false),
2555            ColumnSchema::new("prev_hash", DataType::Text, false),
2556            ColumnSchema::new("entry_hash", DataType::Text, false),
2557            ColumnSchema::new("sql", DataType::Text, false),
2558        ];
2559        let rows: Vec<Row> = self
2560            .audit_chain_provider
2561            .map(|f| f())
2562            .unwrap_or_default()
2563            .into_iter()
2564            .map(|r| {
2565                Row::new(alloc::vec![
2566                    Value::BigInt(r.seq),
2567                    Value::BigInt(r.ts_ms),
2568                    Value::Text(r.prev_hash_hex),
2569                    Value::Text(r.entry_hash_hex),
2570                    Value::Text(r.sql),
2571                ])
2572            })
2573            .collect();
2574        QueryResult::Rows { columns, rows }
2575    }
2576
2577    /// v6.5.3 — materialise `spg_audit_verify` single-row result.
2578    /// `(verified_count, broken_at_seq)` — broken_at_seq is `-1`
2579    /// on a clean chain. Returns one row with both values 0 when
2580    /// no verifier is registered (no-data fallback for embedded
2581    /// callers).
2582    fn exec_spg_audit_verify(&self) -> QueryResult {
2583        let columns = alloc::vec![
2584            ColumnSchema::new("verified_count", DataType::BigInt, false),
2585            ColumnSchema::new("broken_at_seq", DataType::BigInt, false),
2586        ];
2587        let (verified, broken) = self.audit_verifier.map(|f| f()).unwrap_or((0, -1));
2588        let row = Row::new(alloc::vec![Value::BigInt(verified), Value::BigInt(broken),]);
2589        QueryResult::Rows {
2590            columns,
2591            rows: alloc::vec![row],
2592        }
2593    }
2594
2595    /// v6.5.1 — read-only accessor for tests + v6.5.6 ops resets.
2596    pub fn query_stats(&self) -> &query_stats::QueryStats {
2597        &self.query_stats
2598    }
2599
2600    /// v6.5.1 — mutable accessor (clear, etc).
2601    pub fn query_stats_mut(&mut self) -> &mut query_stats::QueryStats {
2602        &mut self.query_stats
2603    }
2604
2605    /// v6.2.0 — read access to the per-column statistics table.
2606    /// Used by the planner (v6.2.2 selectivity functions read this),
2607    /// by `SELECT * FROM spg_statistic`, and by e2e tests.
2608    pub const fn statistics(&self) -> &statistics::Statistics {
2609        &self.statistics
2610    }
2611
2612    /// v6.2.1 — return tables whose modified-row count crossed the
2613    /// auto-analyze threshold since the last ANALYZE on that table.
2614    /// The threshold is `0.1 × max(row_count, MIN_ROWS_FOR_AUTO_
2615    /// ANALYZE)` — combines PG-style fractional + absolute lower
2616    /// bound so a fresh / tiny table doesn't get hammered on every
2617    /// INSERT.
2618    ///
2619    /// Designed to be cheap: walks every user table's
2620    /// `Catalog::table_names()` + reads `statistics::modified_
2621    /// since_last_analyze()` (BTreeMap lookup). The background
2622    /// worker calls this under `engine.read()` then drops the lock
2623    /// before re-acquiring `engine.write()` for the actual ANALYZE.
2624    pub fn tables_needing_analyze(&self) -> Vec<String> {
2625        const MIN_ROWS: u64 = 100;
2626        let mut out = Vec::new();
2627        for name in self.catalog.table_names() {
2628            if is_internal_table_name(&name) {
2629                continue;
2630            }
2631            let Some(table) = self.catalog.get(&name) else {
2632                continue;
2633            };
2634            let row_count = table.rows().len() as u64;
2635            let modified = self.statistics.modified_since_last_analyze(&name);
2636            // Threshold: ceil(0.1 × max(row_count, MIN_ROWS)),
2637            // computed in integer arithmetic so spg-engine stays
2638            // no_std without pulling in libm. `(n + 9) / 10` is
2639            // `ceil(n / 10)` for non-negative `n`.
2640            let base = row_count.max(MIN_ROWS);
2641            let threshold = base.saturating_add(9) / 10;
2642            if modified >= threshold {
2643                out.push(name);
2644            }
2645        }
2646        out
2647    }
2648
2649    /// v6.2.0 — `ANALYZE [<table>]` runtime. Bare `ANALYZE` walks
2650    /// every user table; `ANALYZE <name>` re-stats one. For each
2651    /// target table, single-pass scan + per-column histogram +
2652    /// `null_frac` + `n_distinct`. Replaces the table's prior
2653    /// stats; resets the modified-row counter.
2654    ///
2655    /// v6.2.0 doesn't sample — it scans the full table. v6.2.x
2656    /// can add reservoir sampling at the > 100 K-row mark; not a
2657    /// scope blocker for the current commit since rows ≤ 100 K
2658    /// analyse in milliseconds.
2659    fn exec_analyze(&mut self, target: Option<&str>) -> Result<QueryResult, EngineError> {
2660        let names: Vec<String> = if let Some(name) = target {
2661            // Verify the table exists; surface a clear error if not.
2662            if self.catalog.get(name).is_none() {
2663                return Err(EngineError::Storage(StorageError::TableNotFound {
2664                    name: name.to_string(),
2665                }));
2666            }
2667            alloc::vec![name.to_string()]
2668        } else {
2669            self.catalog
2670                .table_names()
2671                .into_iter()
2672                .filter(|n| !is_internal_table_name(n))
2673                .collect()
2674        };
2675        let mut analysed = 0usize;
2676        for table_name in &names {
2677            self.analyze_one_table(table_name)?;
2678            analysed += 1;
2679        }
2680        // v6.3.1 — plan cache invalidation. Bump stats version so
2681        // future lookups see the new generation, and selectively
2682        // evict every plan whose `source_tables` overlap with the
2683        // ANALYZE target set. Bare ANALYZE (all tables) clears the
2684        // whole cache.
2685        if analysed > 0 {
2686            self.statistics.bump_version();
2687            if target.is_some() {
2688                for t in &names {
2689                    self.plan_cache.evict_referencing(t);
2690                }
2691            } else {
2692                self.plan_cache.clear();
2693            }
2694        }
2695        Ok(QueryResult::CommandOk {
2696            affected: analysed,
2697            modified_catalog: true,
2698        })
2699    }
2700
2701    /// v6.7.3 — `COMPACT COLD SEGMENTS` runtime path. Drives the
2702    /// engine-layer compaction shim with the default
2703    /// 4 MiB segment-size threshold. spg-server intercepts the
2704    /// SQL before it reaches the engine on a server build —
2705    /// it reads `SPG_COMPACTION_TARGET_SEGMENT_BYTES`, calls
2706    /// `Engine::compact_cold_segments_with_target` directly with
2707    /// the env value, and persists every merged segment to
2708    /// v7.12.1 — record a `SET <name> = <value>` parameter. Names
2709    /// are case-folded to lowercase to match PG; values keep their
2710    /// caller-supplied form so observability paths see what was
2711    /// requested. Only `default_text_search_config` is consulted by
2712    /// the engine today.
2713    fn set_session_param(&mut self, name: String, value: spg_sql::ast::SetValue) {
2714        let normalised = match value {
2715            spg_sql::ast::SetValue::String(s) => s,
2716            spg_sql::ast::SetValue::Ident(s) => s,
2717            spg_sql::ast::SetValue::Number(s) => s,
2718            spg_sql::ast::SetValue::Default => String::new(),
2719        };
2720        let key = name.to_ascii_lowercase();
2721        // v7.14.0 — mysqldump preamble emits
2722        // `SET FOREIGN_KEY_CHECKS=0` so it can CREATE TABLE in any
2723        // order despite cross-table FK references; the closing
2724        // section emits `SET FOREIGN_KEY_CHECKS=1` (or
2725        // `=@OLD_FOREIGN_KEY_CHECKS` which resolves to "ON" in our
2726        // session-variable-aware path). Match both shapes.
2727        // Also accept PG's `session_replication_role = 'replica'`
2728        // which suppresses trigger + FK enforcement during a
2729        // logical replication apply (pg_dump preserves this for
2730        // schema-only mode but it shows up in some restores).
2731        let value_off = matches!(
2732            normalised.to_ascii_lowercase().as_str(),
2733            "0" | "off" | "false"
2734        );
2735        let value_on = matches!(
2736            normalised.to_ascii_lowercase().as_str(),
2737            "1" | "on" | "true"
2738        );
2739        if key == "foreign_key_checks"
2740            || key == "session_replication_role" && normalised.eq_ignore_ascii_case("replica")
2741        {
2742            if value_off || key == "session_replication_role" {
2743                self.foreign_key_checks = false;
2744            } else if value_on
2745                || (key == "session_replication_role" && normalised.eq_ignore_ascii_case("origin"))
2746            {
2747                self.foreign_key_checks = true;
2748                // Drain pending FK queue against the now-complete
2749                // catalog. Errors here surface as the SET reply —
2750                // caller knows enabling checks revealed orphans.
2751                let _ = self.drain_pending_foreign_keys();
2752            }
2753        }
2754        self.session_params.insert(key, normalised);
2755    }
2756
2757    /// v7.14.0 — resolve every queued FK whose installation was
2758    /// deferred (`SET FOREIGN_KEY_CHECKS=0` window). Called by
2759    /// `set_session_param` when checks flip back on and by the
2760    /// drop-import release gate. Each FK is resolved against the
2761    /// current catalog; remaining missing-parent errors propagate
2762    /// up so the caller knows the import was incomplete.
2763    fn drain_pending_foreign_keys(&mut self) -> Result<(), EngineError> {
2764        let pending = core::mem::take(&mut self.pending_foreign_keys);
2765        for (child, fk) in pending {
2766            // Resolve against the current catalog. Skip silently
2767            // when the child table itself was dropped between
2768            // queue + drain.
2769            let cols_snapshot = match self.active_catalog().get(&child) {
2770                Some(t) => t.schema().columns.clone(),
2771                None => continue,
2772            };
2773            let storage_fk =
2774                resolve_foreign_key(&child, &cols_snapshot, fk, self.active_catalog())?;
2775            let table = self
2776                .active_catalog_mut()
2777                .get_mut(&child)
2778                .expect("checked above");
2779            table.schema_mut().foreign_keys.push(storage_fk);
2780        }
2781        Ok(())
2782    }
2783
2784    /// v7.12.1 — read a session parameter set via `SET`. Used by
2785    /// the FTS function dispatcher to resolve the default config
2786    /// for `to_tsvector(text)` / `plainto_tsquery(text)` etc.
2787    #[must_use]
2788    pub fn session_param(&self, name: &str) -> Option<&str> {
2789        self.session_params
2790            .get(&name.to_ascii_lowercase())
2791            .map(String::as_str)
2792    }
2793
2794    /// v7.12.1 — build an `EvalContext` chained with the session's
2795    /// `default_text_search_config`. Engine-internal callers use
2796    /// this instead of `EvalContext::new` so the FTS function
2797    /// dispatcher sees the SET configuration.
2798    fn ev_ctx<'a>(
2799        &'a self,
2800        columns: &'a [ColumnSchema],
2801        alias: Option<&'a str>,
2802    ) -> EvalContext<'a> {
2803        EvalContext::new(columns, alias)
2804            .with_default_text_search_config(self.session_param("default_text_search_config"))
2805    }
2806
2807    /// `<db>.spg/segments/`. This arm only fires for engine-only
2808    /// callers (spg-embedded, lib tests); in that mode merged
2809    /// segments live in memory and are dropped at process exit.
2810    fn exec_compact_cold_segments(&mut self) -> Result<QueryResult, EngineError> {
2811        let target = COMPACTION_TARGET_DEFAULT_BYTES;
2812        let reports = self.compact_cold_segments_with_target(target)?;
2813        let columns = alloc::vec![
2814            ColumnSchema::new("table_name", DataType::Text, false),
2815            ColumnSchema::new("index_name", DataType::Text, false),
2816            ColumnSchema::new("sources_merged", DataType::BigInt, false),
2817            ColumnSchema::new("merged_segment_id", DataType::BigInt, false),
2818            ColumnSchema::new("merged_rows", DataType::BigInt, false),
2819            ColumnSchema::new("deleted_rows_pruned", DataType::BigInt, false),
2820            ColumnSchema::new("bytes_reclaimed_estimate", DataType::BigInt, false),
2821        ];
2822        let rows: Vec<Row> = reports
2823            .into_iter()
2824            .map(|(tname, iname, report)| {
2825                Row::new(alloc::vec![
2826                    Value::Text(tname),
2827                    Value::Text(iname),
2828                    Value::BigInt(i64::try_from(report.sources.len()).unwrap_or(i64::MAX)),
2829                    Value::BigInt(i64::from(report.merged_segment_id.unwrap_or(0))),
2830                    Value::BigInt(i64::try_from(report.merged_rows).unwrap_or(i64::MAX)),
2831                    Value::BigInt(i64::try_from(report.deleted_rows_pruned).unwrap_or(i64::MAX),),
2832                    Value::BigInt(
2833                        i64::try_from(report.bytes_reclaimed_estimate).unwrap_or(i64::MAX),
2834                    ),
2835                ])
2836            })
2837            .collect();
2838        Ok(QueryResult::Rows { columns, rows })
2839    }
2840
2841    /// Walk a single table's rows once and (re-)populate per-column
2842    /// stats. Drops the existing stats for `table` first so columns
2843    /// that have been DROP-ed between ANALYZEs don't leave stale
2844    /// rows.
2845    fn analyze_one_table(&mut self, table_name: &str) -> Result<(), EngineError> {
2846        let table = self.catalog.get(table_name).ok_or_else(|| {
2847            EngineError::Storage(StorageError::TableNotFound {
2848                name: table_name.to_string(),
2849            })
2850        })?;
2851        let schema = table.schema().clone();
2852        let row_count = table.rows().len();
2853        // For each column, collect (sorted) non-NULL textual values
2854        // + count NULLs; then ask `statistics::build_histogram` to
2855        // produce the 101 bounds and `estimate_n_distinct` the
2856        // distinct count.
2857        self.statistics.clear_table(table_name);
2858        for (col_pos, col_schema) in schema.columns.iter().enumerate() {
2859            // v6.2.0 skip: vector columns have their own stats
2860            // shape (HNSW graph topology). v6.2 deliberation #1.
2861            if matches!(col_schema.ty, DataType::Vector { .. }) {
2862                continue;
2863            }
2864            let mut non_null_values: Vec<Value> = Vec::with_capacity(row_count);
2865            let mut nulls: u64 = 0;
2866            for row in table.rows() {
2867                match row.values.get(col_pos) {
2868                    Some(Value::Null) | None => nulls += 1,
2869                    Some(v) => non_null_values.push(v.clone()),
2870                }
2871            }
2872            // Sort by type-aware ordering (Int as int, Text as
2873            // lex, etc.) so histogram bounds reflect the column's
2874            // natural order — not lexicographic on the string
2875            // representation, which would put "9" after "49".
2876            non_null_values.sort_by(|a, b| sort_values_for_histogram(a, b));
2877            let non_null: Vec<String> = non_null_values.iter().map(canonical_value_repr).collect();
2878            let null_frac = if row_count == 0 {
2879                0.0
2880            } else {
2881                #[allow(clippy::cast_precision_loss)]
2882                let f = nulls as f32 / row_count as f32;
2883                f
2884            };
2885            let n_distinct = statistics::estimate_n_distinct(&non_null);
2886            let histogram_bounds = statistics::build_histogram(&non_null);
2887            self.statistics.set(
2888                table_name.to_string(),
2889                col_schema.name.clone(),
2890                statistics::ColumnStats {
2891                    null_frac,
2892                    n_distinct,
2893                    histogram_bounds,
2894                },
2895            );
2896        }
2897        self.statistics.reset_modified(table_name);
2898        // v6.7.0 — refresh the per-table cold_rows cache. Walk the
2899        // BTree indices and count Cold locators (MAX across
2900        // indices); store the result on the table. Surfaced via
2901        // `spg_statistic.cold_row_count` (new column) and
2902        // `spg_stat_segment.table_name` (new column).
2903        let cold_count = {
2904            let table = self
2905                .active_catalog()
2906                .get(table_name)
2907                .expect("table still present");
2908            table.count_cold_locators()
2909        };
2910        let table_mut = self
2911            .active_catalog_mut()
2912            .get_mut(table_name)
2913            .expect("table still present");
2914        table_mut.set_cold_row_count(cold_count);
2915        Ok(())
2916    }
2917
2918    /// v6.1.3 — `SHOW PUBLICATIONS` row materialisation. Returns
2919    /// `(name, scope, table_count)` ordered by publication name.
2920    ///   - `scope` is the human-readable string:
2921    ///       `"FOR ALL TABLES"` /
2922    ///       `"FOR TABLE t1, t2"` /
2923    ///       `"FOR ALL TABLES EXCEPT t1, t2"`.
2924    ///   - `table_count` is NULL for `AllTables`, the list length
2925    ///     otherwise. NULLability lets clients distinguish "publish
2926    ///     everything" from "publish exactly 0 tables" (the v6.1.3
2927    ///     parser forbids the empty list, but the column shape is
2928    ///     ready for the v6.1.5 publisher-side semantics).
2929    fn exec_show_publications(&self) -> QueryResult {
2930        let columns = alloc::vec![
2931            ColumnSchema::new("name", DataType::Text, false),
2932            ColumnSchema::new("scope", DataType::Text, false),
2933            ColumnSchema::new("table_count", DataType::Int, true),
2934        ];
2935        let rows: Vec<Row> = self
2936            .publications
2937            .iter()
2938            .map(|(name, scope)| {
2939                let (scope_str, count_val) = match scope {
2940                    spg_sql::ast::PublicationScope::AllTables => {
2941                        ("FOR ALL TABLES".to_string(), Value::Null)
2942                    }
2943                    spg_sql::ast::PublicationScope::ForTables(ts) => (
2944                        alloc::format!("FOR TABLE {}", ts.join(", ")),
2945                        Value::Int(i32::try_from(ts.len()).unwrap_or(i32::MAX)),
2946                    ),
2947                    spg_sql::ast::PublicationScope::AllTablesExcept(ts) => (
2948                        alloc::format!("FOR ALL TABLES EXCEPT {}", ts.join(", ")),
2949                        Value::Int(i32::try_from(ts.len()).unwrap_or(i32::MAX)),
2950                    ),
2951                };
2952                Row::new(alloc::vec![
2953                    Value::Text(name.clone()),
2954                    Value::Text(scope_str),
2955                    count_val,
2956                ])
2957            })
2958            .collect();
2959        QueryResult::Rows { columns, rows }
2960    }
2961
2962    /// v4.1 `SHOW USERS` — `(name, role)` per row, ordered by name.
2963    fn exec_show_users(&self) -> QueryResult {
2964        let columns = alloc::vec![
2965            ColumnSchema::new("name", DataType::Text, false),
2966            ColumnSchema::new("role", DataType::Text, false),
2967        ];
2968        let rows: Vec<Row> = self
2969            .users
2970            .iter()
2971            .map(|(name, rec)| {
2972                Row::new(alloc::vec![
2973                    Value::Text(name.to_string()),
2974                    Value::Text(rec.role.as_str().to_string()),
2975                ])
2976            })
2977            .collect();
2978        QueryResult::Rows { columns, rows }
2979    }
2980
2981    fn exec_create_user(&mut self, s: &CreateUserStatement) -> Result<QueryResult, EngineError> {
2982        if self.in_transaction() {
2983            return Err(EngineError::Unsupported(
2984                "CREATE USER is not allowed inside a transaction".into(),
2985            ));
2986        }
2987        let role = users::Role::parse(&s.role).ok_or_else(|| {
2988            EngineError::Unsupported(alloc::format!("invalid role: {:?}", s.role))
2989        })?;
2990        // Prefer the host-injected RNG. Falls back to a deterministic
2991        // salt derived from the username only when no RNG is wired —
2992        // acceptable for tests; the server always installs one.
2993        let salt = self.salt_fn.map_or_else(
2994            || {
2995                let mut s_bytes = [0u8; 16];
2996                let digest = spg_crypto::hash(s.name.as_bytes());
2997                s_bytes.copy_from_slice(&digest[..16]);
2998                s_bytes
2999            },
3000            |f| f(),
3001        );
3002        self.users
3003            .create(&s.name, &s.password, role, salt)
3004            .map_err(|e| EngineError::Unsupported(alloc::format!("CREATE USER: {e}")))?;
3005        Ok(QueryResult::CommandOk {
3006            affected: 1,
3007            modified_catalog: true,
3008        })
3009    }
3010
3011    fn exec_drop_user(&mut self, name: &str) -> Result<QueryResult, EngineError> {
3012        if self.in_transaction() {
3013            return Err(EngineError::Unsupported(
3014                "DROP USER is not allowed inside a transaction".into(),
3015            ));
3016        }
3017        self.users
3018            .drop(name)
3019            .map_err(|e| EngineError::Unsupported(alloc::format!("DROP USER: {e}")))?;
3020        Ok(QueryResult::CommandOk {
3021            affected: 1,
3022            modified_catalog: true,
3023        })
3024    }
3025
3026    /// v7.12.4 — `CREATE [OR REPLACE] FUNCTION`. Stores the
3027    /// function metadata in the catalog. PL/pgSQL bodies are
3028    /// already parsed by the SQL parser; we re-canonicalise the
3029    /// body to source text for storage (the executor re-parses
3030    /// it at trigger fire time — see the trigger fire path).
3031    fn exec_create_function(
3032        &mut self,
3033        s: spg_sql::ast::CreateFunctionStatement,
3034    ) -> Result<QueryResult, EngineError> {
3035        let args_repr = render_function_args(&s.args);
3036        let returns = match &s.returns {
3037            spg_sql::ast::FunctionReturn::Trigger => alloc::string::String::from("TRIGGER"),
3038            spg_sql::ast::FunctionReturn::Void => alloc::string::String::from("VOID"),
3039            spg_sql::ast::FunctionReturn::Type(t) => alloc::format!("{t}"),
3040            spg_sql::ast::FunctionReturn::Other(s) => s.clone(),
3041        };
3042        let body_text = match &s.body {
3043            spg_sql::ast::FunctionBody::PlPgSql(b) => alloc::format!("{b}"),
3044            spg_sql::ast::FunctionBody::Raw(s) => s.clone(),
3045        };
3046        let def = spg_storage::FunctionDef {
3047            name: s.name.clone(),
3048            args_repr,
3049            returns,
3050            language: s.language.clone(),
3051            body: body_text,
3052        };
3053        self.active_catalog_mut()
3054            .create_function(def, s.or_replace)
3055            .map_err(EngineError::Storage)?;
3056        Ok(QueryResult::CommandOk {
3057            affected: 0,
3058            modified_catalog: true,
3059        })
3060    }
3061
3062    /// v7.12.4 — `CREATE [OR REPLACE] TRIGGER`. The referenced
3063    /// function must already exist in the catalog (forward
3064    /// references defer to a later release). Persists the
3065    /// trigger metadata for the row-write hooks below to consult.
3066    fn exec_create_trigger(
3067        &mut self,
3068        s: spg_sql::ast::CreateTriggerStatement,
3069    ) -> Result<QueryResult, EngineError> {
3070        let timing = match s.timing {
3071            spg_sql::ast::TriggerTiming::Before => "BEFORE",
3072            spg_sql::ast::TriggerTiming::After => "AFTER",
3073            spg_sql::ast::TriggerTiming::InsteadOf => "INSTEAD OF",
3074        };
3075        let events: Vec<alloc::string::String> = s
3076            .events
3077            .iter()
3078            .map(|e| match e {
3079                spg_sql::ast::TriggerEvent::Insert => alloc::string::String::from("INSERT"),
3080                spg_sql::ast::TriggerEvent::Update => alloc::string::String::from("UPDATE"),
3081                spg_sql::ast::TriggerEvent::Delete => alloc::string::String::from("DELETE"),
3082                spg_sql::ast::TriggerEvent::Truncate => alloc::string::String::from("TRUNCATE"),
3083            })
3084            .collect();
3085        let for_each = match s.for_each {
3086            spg_sql::ast::TriggerForEach::Row => "ROW",
3087            spg_sql::ast::TriggerForEach::Statement => "STATEMENT",
3088        };
3089        let def = spg_storage::TriggerDef {
3090            name: s.name.clone(),
3091            table: s.table.clone(),
3092            timing: alloc::string::String::from(timing),
3093            events,
3094            for_each: alloc::string::String::from(for_each),
3095            function: s.function.clone(),
3096            update_columns: s.update_columns.clone(),
3097            // v7.16.1 — every trigger is born enabled. Toggled
3098            // by ALTER TABLE … { ENABLE | DISABLE } TRIGGER.
3099            enabled: true,
3100        };
3101        self.active_catalog_mut()
3102            .create_trigger(def, s.or_replace)
3103            .map_err(EngineError::Storage)?;
3104        Ok(QueryResult::CommandOk {
3105            affected: 0,
3106            modified_catalog: true,
3107        })
3108    }
3109
3110    fn exec_drop_trigger(
3111        &mut self,
3112        name: &str,
3113        table: &str,
3114        if_exists: bool,
3115    ) -> Result<QueryResult, EngineError> {
3116        let removed = self.active_catalog_mut().drop_trigger(name, table);
3117        if !removed && !if_exists {
3118            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3119                alloc::format!("trigger {name:?} on {table:?} does not exist"),
3120            )));
3121        }
3122        Ok(QueryResult::CommandOk {
3123            affected: usize::from(removed),
3124            modified_catalog: removed,
3125        })
3126    }
3127
3128    fn exec_drop_function(
3129        &mut self,
3130        name: &str,
3131        if_exists: bool,
3132    ) -> Result<QueryResult, EngineError> {
3133        let removed = self.active_catalog_mut().drop_function(name);
3134        if !removed && !if_exists {
3135            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3136                alloc::format!("function {name:?} does not exist"),
3137            )));
3138        }
3139        Ok(QueryResult::CommandOk {
3140            affected: usize::from(removed),
3141            modified_catalog: removed,
3142        })
3143    }
3144
3145    /// v7.17.0 — `CREATE SEQUENCE` engine path. Resolves
3146    /// `min_value` / `max_value` / `start` against PG defaults
3147    /// when omitted, then installs the SequenceDef in the catalog.
3148    fn exec_create_sequence(
3149        &mut self,
3150        s: spg_sql::ast::CreateSequenceStatement,
3151    ) -> Result<QueryResult, EngineError> {
3152        use spg_sql::ast::{SeqBound, SequenceDataType as AstDt};
3153        use spg_storage::{SequenceDataType, SequenceDef};
3154        let dt = match s.data_type {
3155            None => SequenceDataType::BigInt,
3156            Some(AstDt::SmallInt) => SequenceDataType::SmallInt,
3157            Some(AstDt::Int) => SequenceDataType::Int,
3158            Some(AstDt::BigInt) => SequenceDataType::BigInt,
3159        };
3160        let increment = s.options.increment.unwrap_or(1);
3161        if increment == 0 {
3162            return Err(EngineError::Unsupported(
3163                "INCREMENT must not be zero".into(),
3164            ));
3165        }
3166        let (def_min, def_max) = dt.default_bounds(increment > 0);
3167        let min_value = match s.options.min_value {
3168            None | Some(SeqBound::NoBound) => def_min,
3169            Some(SeqBound::Value(n)) => n,
3170        };
3171        let max_value = match s.options.max_value {
3172            None | Some(SeqBound::NoBound) => def_max,
3173            Some(SeqBound::Value(n)) => n,
3174        };
3175        if min_value > max_value {
3176            return Err(EngineError::Unsupported(alloc::format!(
3177                "MINVALUE ({min_value}) must be <= MAXVALUE ({max_value})"
3178            )));
3179        }
3180        let start = s
3181            .options
3182            .start
3183            .unwrap_or(if increment > 0 { min_value } else { max_value });
3184        if start < min_value || start > max_value {
3185            return Err(EngineError::Unsupported(alloc::format!(
3186                "START WITH ({start}) is outside MINVALUE..MAXVALUE ({min_value}..{max_value})"
3187            )));
3188        }
3189        let cache = s.options.cache.unwrap_or(1);
3190        if cache < 1 {
3191            return Err(EngineError::Unsupported("CACHE must be >= 1".into()));
3192        }
3193        let cycle = s.options.cycle.unwrap_or(false);
3194        let owned_by = match s.options.owned_by {
3195            None | Some(spg_sql::ast::SequenceOwnedBy::None) => None,
3196            Some(spg_sql::ast::SequenceOwnedBy::Column { table, column }) => Some((table, column)),
3197        };
3198        let def = SequenceDef {
3199            name: s.name.clone(),
3200            data_type: dt,
3201            start,
3202            increment,
3203            min_value,
3204            max_value,
3205            cache,
3206            cycle,
3207            owned_by,
3208            last_value: start,
3209            is_called: false,
3210        };
3211        self.active_catalog_mut()
3212            .create_sequence(def, s.if_not_exists)
3213            .map_err(EngineError::Storage)?;
3214        Ok(QueryResult::CommandOk {
3215            affected: 0,
3216            modified_catalog: !self.in_transaction(),
3217        })
3218    }
3219
3220    /// v7.17.0 — `ALTER SEQUENCE` engine path. Re-uses the catalog
3221    /// `alter_sequence` merge helper.
3222    fn exec_alter_sequence(
3223        &mut self,
3224        s: spg_sql::ast::AlterSequenceStatement,
3225    ) -> Result<QueryResult, EngineError> {
3226        use spg_sql::ast::SeqBound;
3227        let cat = self.active_catalog_mut();
3228        if !cat.sequences().contains_key(&s.name) {
3229            if s.if_exists {
3230                return Ok(QueryResult::CommandOk {
3231                    affected: 0,
3232                    modified_catalog: false,
3233                });
3234            }
3235            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3236                alloc::format!("sequence {:?} does not exist", s.name),
3237            )));
3238        }
3239        let min_value = match s.options.min_value {
3240            None => None,
3241            Some(SeqBound::NoBound) => None, // NO MINVALUE → keep current
3242            Some(SeqBound::Value(n)) => Some(n),
3243        };
3244        let max_value = match s.options.max_value {
3245            None => None,
3246            Some(SeqBound::NoBound) => None,
3247            Some(SeqBound::Value(n)) => Some(n),
3248        };
3249        let owned_by = s.options.owned_by.map(|ob| match ob {
3250            spg_sql::ast::SequenceOwnedBy::None => None,
3251            spg_sql::ast::SequenceOwnedBy::Column { table, column } => Some((table, column)),
3252        });
3253        cat.alter_sequence(
3254            &s.name,
3255            s.options.increment,
3256            min_value,
3257            max_value,
3258            s.options.start,
3259            s.options.restart,
3260            s.options.cache,
3261            s.options.cycle,
3262            owned_by,
3263        )
3264        .map_err(EngineError::Storage)?;
3265        Ok(QueryResult::CommandOk {
3266            affected: 0,
3267            modified_catalog: !self.in_transaction(),
3268        })
3269    }
3270
3271    /// v7.17.0 Phase 1.1 — walk a Statement tree and pre-resolve
3272    /// any sequence FunctionCall nodes inside its Expr slots.
3273    /// Delegates per-statement-kind: SELECT projection +
3274    /// WHERE, INSERT VALUES, UPDATE SET, DELETE WHERE.
3275    fn pre_resolve_sequence_calls_in_statement(
3276        &mut self,
3277        stmt: &mut Statement,
3278    ) -> Result<(), EngineError> {
3279        match stmt {
3280            Statement::Select(s) => self.pre_resolve_sequence_calls_in_select(s),
3281            Statement::Insert(s) => {
3282                for tuple in &mut s.rows {
3283                    for cell in tuple.iter_mut() {
3284                        self.resolve_sequence_calls_in_expr(cell)?;
3285                    }
3286                }
3287                Ok(())
3288            }
3289            Statement::Update(s) => {
3290                for (_col, expr) in &mut s.assignments {
3291                    self.resolve_sequence_calls_in_expr(expr)?;
3292                }
3293                if let Some(w) = &mut s.where_ {
3294                    self.resolve_sequence_calls_in_expr(w)?;
3295                }
3296                Ok(())
3297            }
3298            Statement::Delete(s) => {
3299                if let Some(w) = &mut s.where_ {
3300                    self.resolve_sequence_calls_in_expr(w)?;
3301                }
3302                Ok(())
3303            }
3304            _ => Ok(()),
3305        }
3306    }
3307
3308    fn pre_resolve_sequence_calls_in_select(
3309        &mut self,
3310        s: &mut spg_sql::ast::SelectStatement,
3311    ) -> Result<(), EngineError> {
3312        for item in &mut s.items {
3313            match item {
3314                spg_sql::ast::SelectItem::Expr { expr, .. } => {
3315                    self.resolve_sequence_calls_in_expr(expr)?;
3316                }
3317                spg_sql::ast::SelectItem::Wildcard => {}
3318            }
3319        }
3320        if let Some(w) = &mut s.where_ {
3321            self.resolve_sequence_calls_in_expr(w)?;
3322        }
3323        Ok(())
3324    }
3325
3326    /// v7.17.0 Phase 1.1 — walk an Expr tree and pre-resolve any
3327    /// `nextval(name)` / `currval(name)` / `setval(name, value[,
3328    /// is_called])` FunctionCall nodes by calling the catalog and
3329    /// replacing the node with the resulting `Expr::Literal`.
3330    /// Used by INSERT VALUES / UPDATE SET / DEFAULT eval so the
3331    /// row-eval path sees pre-computed sequence values instead of
3332    /// needing mutable catalog access mid-eval.
3333    #[allow(clippy::too_many_lines)]
3334    fn resolve_sequence_calls_in_expr(&mut self, expr: &mut Expr) -> Result<(), EngineError> {
3335        match expr {
3336            Expr::Literal(_) | Expr::Column(_) | Expr::Placeholder(_) => Ok(()),
3337            Expr::FunctionCall { name, args } => {
3338                // Descend first so nested calls — e.g.
3339                // setval('seq', currval('other')) — resolve
3340                // innermost-first.
3341                for a in args.iter_mut() {
3342                    self.resolve_sequence_calls_in_expr(a)?;
3343                }
3344                let lc = name.to_ascii_lowercase();
3345                if lc == "nextval" || lc == "currval" || lc == "setval" {
3346                    let v = self.eval_sequence_call(&lc, args)?;
3347                    *expr = Expr::Literal(value_to_literal(v));
3348                }
3349                Ok(())
3350            }
3351            Expr::Binary { lhs, rhs, .. } => {
3352                self.resolve_sequence_calls_in_expr(lhs)?;
3353                self.resolve_sequence_calls_in_expr(rhs)
3354            }
3355            Expr::Unary { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3356            Expr::Cast { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3357            Expr::IsNull { expr, .. } => self.resolve_sequence_calls_in_expr(expr),
3358            Expr::Like { expr, pattern, .. } => {
3359                self.resolve_sequence_calls_in_expr(expr)?;
3360                self.resolve_sequence_calls_in_expr(pattern)
3361            }
3362            Expr::Extract { source, .. } => self.resolve_sequence_calls_in_expr(source),
3363            Expr::Array(items) => {
3364                for it in items.iter_mut() {
3365                    self.resolve_sequence_calls_in_expr(it)?;
3366                }
3367                Ok(())
3368            }
3369            // Window / subquery / etc — sequence calls inside these
3370            // are uncommon and require separate row-eval; leave
3371            // untouched for now and rely on the eval-time error
3372            // (no sequence_resolver attached).
3373            _ => Ok(()),
3374        }
3375    }
3376
3377    /// v7.17.0 Phase 1.1 — evaluate a single nextval/currval/
3378    /// setval call. `args` are already pre-resolved Expr nodes
3379    /// (literals) — we extract their constant values.
3380    fn eval_sequence_call(&mut self, op: &str, args: &[Expr]) -> Result<Value, EngineError> {
3381        if args.is_empty() {
3382            return Err(EngineError::Unsupported(alloc::format!(
3383                "{op}() takes at least one argument"
3384            )));
3385        }
3386        let seq_name = match &args[0] {
3387            Expr::Literal(spg_sql::ast::Literal::String(s)) => {
3388                // v7.17 dump-compat — pg_dump emits sequence
3389                // names schema-qualified (`'public.posts_id_seq'`).
3390                // SPG is single-schema; strip a leading
3391                // `public.` / `pg_catalog.` so the catalog lookup
3392                // matches the bare-name CREATE SEQUENCE used.
3393                let trimmed = s
3394                    .strip_prefix("public.")
3395                    .or_else(|| s.strip_prefix("pg_catalog."))
3396                    .unwrap_or(s);
3397                trimmed.to_string()
3398            }
3399            // v7.17 dump-compat — pg_dump also emits
3400            // `nextval('public.posts_id_seq'::regclass)`
3401            // where the cast wraps the literal. Peel the cast
3402            // and continue.
3403            Expr::Cast { expr, .. } => {
3404                if let Expr::Literal(spg_sql::ast::Literal::String(s)) = expr.as_ref() {
3405                    let trimmed = s
3406                        .strip_prefix("public.")
3407                        .or_else(|| s.strip_prefix("pg_catalog."))
3408                        .unwrap_or(s);
3409                    trimmed.to_string()
3410                } else {
3411                    return Err(EngineError::Unsupported(alloc::format!(
3412                        "{op}() first argument must be a literal sequence name"
3413                    )));
3414                }
3415            }
3416            other => {
3417                return Err(EngineError::Unsupported(alloc::format!(
3418                    "{op}() first argument must be a literal sequence name, got {other:?}"
3419                )));
3420            }
3421        };
3422        match op {
3423            "nextval" => {
3424                let v = self
3425                    .active_catalog_mut()
3426                    .sequence_next_value(&seq_name)
3427                    .map_err(EngineError::Storage)?;
3428                Ok(Value::BigInt(v))
3429            }
3430            "currval" => {
3431                let v = self
3432                    .active_catalog()
3433                    .sequence_current_value(&seq_name)
3434                    .map_err(EngineError::Storage)?;
3435                Ok(Value::BigInt(v))
3436            }
3437            "setval" => {
3438                if args.len() < 2 || args.len() > 3 {
3439                    return Err(EngineError::Unsupported(alloc::format!(
3440                        "setval() takes 2 or 3 arguments, got {}",
3441                        args.len()
3442                    )));
3443                }
3444                let value = match &args[1] {
3445                    Expr::Literal(spg_sql::ast::Literal::Integer(n)) => *n,
3446                    other => {
3447                        return Err(EngineError::Unsupported(alloc::format!(
3448                            "setval() value argument must be a literal integer, got {other:?}"
3449                        )));
3450                    }
3451                };
3452                let is_called = if args.len() == 3 {
3453                    match &args[2] {
3454                        Expr::Literal(spg_sql::ast::Literal::Bool(b)) => *b,
3455                        other => {
3456                            return Err(EngineError::Unsupported(alloc::format!(
3457                                "setval() is_called argument must be a literal BOOL, got {other:?}"
3458                            )));
3459                        }
3460                    }
3461                } else {
3462                    true
3463                };
3464                let v = self
3465                    .active_catalog_mut()
3466                    .sequence_set_value(&seq_name, value, is_called)
3467                    .map_err(EngineError::Storage)?;
3468                Ok(Value::BigInt(v))
3469            }
3470            other => Err(EngineError::Unsupported(alloc::format!(
3471                "unknown sequence op {other:?}"
3472            ))),
3473        }
3474    }
3475
3476    /// v7.17.0 Phase 1.2 — find every catalog VIEW referenced in
3477    /// the SELECT's FROM / JOIN graph, re-parse each view's body
3478    /// source, and prepend it as a synthetic CTE on the
3479    /// returned SelectStatement. Returns `None` when no view
3480    /// references are found (caller proceeds with the original
3481    /// statement); returns `Some(rewritten)` otherwise (caller
3482    /// re-runs exec_select_cancel on the rewritten form so the
3483    /// regular CTE materialiser handles it).
3484    fn expand_views_in_select(
3485        &self,
3486        stmt: &SelectStatement,
3487    ) -> Result<Option<SelectStatement>, EngineError> {
3488        let cat = self.active_catalog();
3489        let mut referenced: Vec<String> = Vec::new();
3490        if let Some(from) = &stmt.from {
3491            collect_view_refs(&from.primary, cat, &mut referenced);
3492            for j in &from.joins {
3493                collect_view_refs(&j.table, cat, &mut referenced);
3494            }
3495        }
3496        // Don't expand a view name that's already shadowed by a
3497        // CTE on the same SELECT — the CTE wins per PG.
3498        referenced.retain(|n| !stmt.ctes.iter().any(|c| c.name == *n));
3499        if referenced.is_empty() {
3500            return Ok(None);
3501        }
3502        let mut new_ctes: Vec<spg_sql::ast::Cte> = Vec::with_capacity(referenced.len());
3503        for name in &referenced {
3504            let view = cat.views().get(name).ok_or_else(|| {
3505                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3506                    "view {name:?} disappeared mid-expansion"
3507                )))
3508            })?;
3509            let parsed = spg_sql::parser::parse_statement(&view.body).map_err(|e| {
3510                EngineError::Unsupported(alloc::format!("view {name:?} body re-parse failed: {e}"))
3511            })?;
3512            let Statement::Select(body) = parsed else {
3513                return Err(EngineError::Unsupported(alloc::format!(
3514                    "view {name:?} body is not a SELECT (catalog corruption)"
3515                )));
3516            };
3517            new_ctes.push(spg_sql::ast::Cte {
3518                name: name.clone(),
3519                body,
3520                recursive: false,
3521                column_overrides: view.columns.clone(),
3522            });
3523        }
3524        let mut out = stmt.clone();
3525        // Prepend so view CTEs are visible to caller-supplied CTEs.
3526        new_ctes.extend(out.ctes);
3527        out.ctes = new_ctes;
3528        Ok(Some(out))
3529    }
3530
3531    /// v7.17.0 Phase 1.2 — `CREATE VIEW` engine path. Stores the
3532    /// Display-rendered body verbatim in the catalog; SELECT-from-
3533    /// view at exec time re-parses + prepends as a synthetic CTE.
3534    fn exec_create_view(
3535        &mut self,
3536        s: spg_sql::ast::CreateViewStatement,
3537    ) -> Result<QueryResult, EngineError> {
3538        // Render the SELECT body to canonical form so the catalog
3539        // round-trips a deterministic source (no whitespace /
3540        // comment surprises in the on-disk snapshot).
3541        let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body));
3542        let def = spg_storage::ViewDef {
3543            name: s.name.clone(),
3544            columns: s.columns,
3545            body: body_repr,
3546        };
3547        self.active_catalog_mut()
3548            .create_view(def, s.or_replace, s.if_not_exists)
3549            .map_err(EngineError::Storage)?;
3550        Ok(QueryResult::CommandOk {
3551            affected: 0,
3552            modified_catalog: !self.in_transaction(),
3553        })
3554    }
3555
3556    /// v7.17.0 Phase 1.4 — `CREATE TYPE name AS ENUM (…)` engine
3557    /// path. Registers the enum in the catalog with order-
3558    /// preserving labels. PG semantics: CREATE TYPE errors if the
3559    /// name is taken (no IF NOT EXISTS).
3560    fn exec_create_type(
3561        &mut self,
3562        s: spg_sql::ast::CreateTypeStatement,
3563    ) -> Result<QueryResult, EngineError> {
3564        // Name-collision check against tables / sequences / views /
3565        // materialized views.
3566        let cat = self.active_catalog();
3567        if cat.get(&s.name).is_some() {
3568            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3569                alloc::format!("type {:?} would shadow an existing table", s.name),
3570            )));
3571        }
3572        if cat.sequences().contains_key(&s.name) {
3573            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3574                alloc::format!("type {:?} would shadow an existing sequence", s.name),
3575            )));
3576        }
3577        if cat.views().contains_key(&s.name) {
3578            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3579                alloc::format!("type {:?} would shadow an existing view", s.name),
3580            )));
3581        }
3582        let def = match s.kind {
3583            spg_sql::ast::TypeKind::Enum { labels } => {
3584                if labels.is_empty() {
3585                    return Err(EngineError::Unsupported(
3586                        "CREATE TYPE … AS ENUM requires at least one label".into(),
3587                    ));
3588                }
3589                // Reject duplicate labels per PG.
3590                for i in 0..labels.len() {
3591                    for j in (i + 1)..labels.len() {
3592                        if labels[i] == labels[j] {
3593                            return Err(EngineError::Unsupported(alloc::format!(
3594                                "CREATE TYPE {:?}: duplicate ENUM label {:?}",
3595                                s.name,
3596                                labels[i]
3597                            )));
3598                        }
3599                    }
3600                }
3601                spg_storage::EnumDef {
3602                    name: s.name.clone(),
3603                    labels,
3604                }
3605            }
3606        };
3607        self.active_catalog_mut()
3608            .create_enum_type(def)
3609            .map_err(EngineError::Storage)?;
3610        Ok(QueryResult::CommandOk {
3611            affected: 0,
3612            modified_catalog: !self.in_transaction(),
3613        })
3614    }
3615
3616    /// v7.17.0 Phase 1.5 — `CREATE DOMAIN name AS base [DEFAULT
3617    /// expr] [NOT NULL] [CHECK (expr)]*` engine path. Stores the
3618    /// base type + Display-rendered CHECK / DEFAULT sources so
3619    /// INSERT/UPDATE on bound columns can re-eval the checks.
3620    fn exec_create_domain(
3621        &mut self,
3622        s: spg_sql::ast::CreateDomainStatement,
3623    ) -> Result<QueryResult, EngineError> {
3624        let cat = self.active_catalog();
3625        if cat.domain_types().contains_key(&s.name) {
3626            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3627                alloc::format!("domain {:?} already exists", s.name),
3628            )));
3629        }
3630        if cat.get(&s.name).is_some()
3631            || cat.sequences().contains_key(&s.name)
3632            || cat.views().contains_key(&s.name)
3633            || cat.enum_types().contains_key(&s.name)
3634        {
3635            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3636                alloc::format!("domain {:?} would shadow an existing object", s.name),
3637            )));
3638        }
3639        let base_type = column_type_to_data_type(s.base_type);
3640        let default = s.default.as_ref().map(|e| alloc::format!("{e}"));
3641        let checks = s
3642            .checks
3643            .iter()
3644            .map(|e| alloc::format!("{e}"))
3645            .collect::<Vec<_>>();
3646        let def = spg_storage::DomainDef {
3647            name: s.name.clone(),
3648            base_type,
3649            nullable: !s.not_null,
3650            default,
3651            checks,
3652        };
3653        self.active_catalog_mut()
3654            .create_domain_type(def)
3655            .map_err(EngineError::Storage)?;
3656        Ok(QueryResult::CommandOk {
3657            affected: 0,
3658            modified_catalog: !self.in_transaction(),
3659        })
3660    }
3661
3662    /// v7.17.0 Phase 1.5 — `DROP DOMAIN [IF EXISTS] names`.
3663    fn exec_drop_domain(
3664        &mut self,
3665        names: &[String],
3666        if_exists: bool,
3667    ) -> Result<QueryResult, EngineError> {
3668        let mut removed = 0usize;
3669        for name in names {
3670            let was_present = self.active_catalog_mut().drop_domain_type(name);
3671            if was_present {
3672                removed += 1;
3673            } else if !if_exists {
3674                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3675                    alloc::format!("domain {name:?} does not exist"),
3676                )));
3677            }
3678        }
3679        Ok(QueryResult::CommandOk {
3680            affected: removed,
3681            modified_catalog: removed > 0 && !self.in_transaction(),
3682        })
3683    }
3684
3685    /// v7.17.0 Phase 1.6 — `CREATE SCHEMA [IF NOT EXISTS] name`.
3686    /// Registers the schema in the catalog. Schema-qualified
3687    /// table references continue to strip the prefix at lookup
3688    /// time (prefix routing, not isolation — see project-next-
3689    /// docket for the v7.18+ real-isolation tracking).
3690    fn exec_create_schema(
3691        &mut self,
3692        name: String,
3693        if_not_exists: bool,
3694    ) -> Result<QueryResult, EngineError> {
3695        self.active_catalog_mut()
3696            .create_schema(name, if_not_exists)
3697            .map_err(EngineError::Storage)?;
3698        Ok(QueryResult::CommandOk {
3699            affected: 0,
3700            modified_catalog: !self.in_transaction(),
3701        })
3702    }
3703
3704    /// v7.17.0 Phase 1.6 — `DROP SCHEMA [IF EXISTS] names`.
3705    /// Built-in schemas always reject the drop with a clear
3706    /// error.
3707    fn exec_drop_schema(
3708        &mut self,
3709        names: &[String],
3710        if_exists: bool,
3711    ) -> Result<QueryResult, EngineError> {
3712        let mut removed = 0usize;
3713        for name in names {
3714            let was_present = self
3715                .active_catalog_mut()
3716                .drop_schema(name)
3717                .map_err(EngineError::Storage)?;
3718            if was_present {
3719                removed += 1;
3720            } else if !if_exists {
3721                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3722                    alloc::format!("schema {name:?} does not exist"),
3723                )));
3724            }
3725        }
3726        Ok(QueryResult::CommandOk {
3727            affected: removed,
3728            modified_catalog: removed > 0 && !self.in_transaction(),
3729        })
3730    }
3731
3732    /// v7.17.0 Phase 1.4 — `DROP TYPE [IF EXISTS] names`. Only
3733    /// ENUM types are catalogued today; other types silently
3734    /// no-op even outside IF EXISTS to mirror the prior
3735    /// "everything's text" lax stance.
3736    fn exec_drop_type(
3737        &mut self,
3738        names: &[String],
3739        if_exists: bool,
3740    ) -> Result<QueryResult, EngineError> {
3741        let mut removed = 0usize;
3742        for name in names {
3743            let was_present = self.active_catalog_mut().drop_enum_type(name);
3744            if was_present {
3745                removed += 1;
3746            } else if !if_exists {
3747                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3748                    alloc::format!("type {name:?} does not exist"),
3749                )));
3750            }
3751        }
3752        Ok(QueryResult::CommandOk {
3753            affected: removed,
3754            modified_catalog: removed > 0 && !self.in_transaction(),
3755        })
3756    }
3757
3758    /// v7.17.0 Phase 1.3 — `CREATE MATERIALIZED VIEW` engine path.
3759    /// Materialises the body at CREATE time (unless WITH NO DATA),
3760    /// stores the result as a regular `Table`, and registers the
3761    /// body source in the catalog so REFRESH can re-run it.
3762    fn exec_create_materialized_view(
3763        &mut self,
3764        s: spg_sql::ast::CreateMaterializedViewStatement,
3765    ) -> Result<QueryResult, EngineError> {
3766        // Name-collision check (table / view / sequence / mat-view).
3767        let cat = self.active_catalog();
3768        if cat.materialized_views().contains_key(&s.name) || cat.get(&s.name).is_some() {
3769            if s.if_not_exists {
3770                return Ok(QueryResult::CommandOk {
3771                    affected: 0,
3772                    modified_catalog: false,
3773                });
3774            }
3775            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3776                alloc::format!("materialized view {:?} already exists", s.name),
3777            )));
3778        }
3779        if cat.views().contains_key(&s.name) {
3780            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3781                alloc::format!(
3782                    "materialized view {:?} would shadow an existing view",
3783                    s.name
3784                ),
3785            )));
3786        }
3787        if cat.sequences().contains_key(&s.name) {
3788            return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3789                alloc::format!(
3790                    "materialized view {:?} would shadow an existing sequence",
3791                    s.name
3792                ),
3793            )));
3794        }
3795        // Render the body to canonical form for the registry.
3796        let body_repr = alloc::format!("{}", spg_sql::ast::Statement::Select(s.body.clone()));
3797        // Execute the body to learn the columns. With WITH DATA we
3798        // also materialise the rows; with WITH NO DATA we only need
3799        // the schema, so re-use a LIMIT 0 wrap to keep the column
3800        // inference path uniform without paying for the rows.
3801        let result = self.exec_select_cancel(&s.body, CancelToken::none())?;
3802        let (mut cols, rows) = match result {
3803            QueryResult::Rows { columns, rows } => (columns, rows),
3804            other => {
3805                return Err(EngineError::Unsupported(alloc::format!(
3806                    "CREATE MATERIALIZED VIEW body did not return rows: {other:?}"
3807                )));
3808            }
3809        };
3810        // Apply the column-rename list per PG semantics.
3811        if !s.columns.is_empty() {
3812            if s.columns.len() != cols.len() {
3813                return Err(EngineError::Unsupported(alloc::format!(
3814                    "CREATE MATERIALIZED VIEW {:?}: column list has {} names but body returns {}",
3815                    s.name,
3816                    s.columns.len(),
3817                    cols.len()
3818                )));
3819            }
3820            for (c, name) in cols.iter_mut().zip(s.columns.iter()) {
3821                c.name.clone_from(name);
3822            }
3823        }
3824        // Promote any synthetic-Text projections to their actual
3825        // observed types so the backing table accepts the rows.
3826        cols = infer_column_types(&cols, &rows);
3827        let schema = spg_storage::TableSchema::new(s.name.clone(), cols);
3828        let cat = self.active_catalog_mut();
3829        cat.create_table(schema).map_err(EngineError::Storage)?;
3830        if s.with_data {
3831            let table = cat
3832                .get_mut(&s.name)
3833                .expect("just-created materialized-view backing table must exist");
3834            for row in rows {
3835                table.insert(row).map_err(EngineError::Storage)?;
3836            }
3837        }
3838        cat.register_materialized_view(s.name.clone(), body_repr);
3839        Ok(QueryResult::CommandOk {
3840            affected: 0,
3841            modified_catalog: !self.in_transaction(),
3842        })
3843    }
3844
3845    /// v7.17.0 Phase 1.3 — `REFRESH MATERIALIZED VIEW name [WITH
3846    /// [NO] DATA]`. Looks up the source, re-runs it, replaces the
3847    /// backing table's rows.
3848    fn exec_refresh_materialized_view(
3849        &mut self,
3850        name: &str,
3851        with_data: bool,
3852    ) -> Result<QueryResult, EngineError> {
3853        let source = self
3854            .active_catalog()
3855            .materialized_views()
3856            .get(name)
3857            .cloned()
3858            .ok_or_else(|| {
3859                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3860                    "materialized view {name:?} does not exist"
3861                )))
3862            })?;
3863        // Wipe the existing rows first (PG truncates the matview
3864        // and rebuilds; we approximate with an empty INSERT loop).
3865        {
3866            let cat = self.active_catalog_mut();
3867            let table = cat.get_mut(name).ok_or_else(|| {
3868                EngineError::Storage(spg_storage::StorageError::Corrupt(alloc::format!(
3869                    "materialized view {name:?} backing table missing"
3870                )))
3871            })?;
3872            table.truncate();
3873        }
3874        if !with_data {
3875            return Ok(QueryResult::CommandOk {
3876                affected: 0,
3877                modified_catalog: !self.in_transaction(),
3878            });
3879        }
3880        let parsed = spg_sql::parser::parse_statement(&source).map_err(|e| {
3881            EngineError::Unsupported(alloc::format!(
3882                "materialized view {name:?} body re-parse failed: {e}"
3883            ))
3884        })?;
3885        let Statement::Select(body) = parsed else {
3886            return Err(EngineError::Unsupported(alloc::format!(
3887                "materialized view {name:?} body is not a SELECT (catalog corruption)"
3888            )));
3889        };
3890        let rows = match self.exec_select_cancel(&body, CancelToken::none())? {
3891            QueryResult::Rows { rows, .. } => rows,
3892            other => {
3893                return Err(EngineError::Unsupported(alloc::format!(
3894                    "REFRESH MATERIALIZED VIEW {name:?} body did not return rows: {other:?}"
3895                )));
3896            }
3897        };
3898        let cat = self.active_catalog_mut();
3899        let table = cat.get_mut(name).expect("backing table verified above");
3900        let affected = rows.len();
3901        for row in rows {
3902            table.insert(row).map_err(EngineError::Storage)?;
3903        }
3904        Ok(QueryResult::CommandOk {
3905            affected,
3906            modified_catalog: !self.in_transaction(),
3907        })
3908    }
3909
3910    /// v7.17.0 Phase 1.3 — `DROP MATERIALIZED VIEW [IF EXISTS]
3911    /// names`. Drops the backing table + unregisters the source.
3912    fn exec_drop_materialized_view(
3913        &mut self,
3914        names: &[String],
3915        if_exists: bool,
3916    ) -> Result<QueryResult, EngineError> {
3917        let mut removed = 0usize;
3918        for name in names {
3919            let was_present = self
3920                .active_catalog_mut()
3921                .drop_materialized_view_source(name);
3922            if was_present {
3923                // Drop the backing table too.
3924                self.active_catalog_mut().drop_table(name);
3925                removed += 1;
3926            } else if !if_exists {
3927                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3928                    alloc::format!("materialized view {name:?} does not exist"),
3929                )));
3930            }
3931        }
3932        Ok(QueryResult::CommandOk {
3933            affected: removed,
3934            modified_catalog: removed > 0 && !self.in_transaction(),
3935        })
3936    }
3937
3938    /// v7.17.0 Phase 1.2 — `DROP VIEW [IF EXISTS] name [, name…]`.
3939    fn exec_drop_view(
3940        &mut self,
3941        names: &[String],
3942        if_exists: bool,
3943    ) -> Result<QueryResult, EngineError> {
3944        let mut removed = 0usize;
3945        for name in names {
3946            let was_present = self.active_catalog_mut().drop_view(name);
3947            if !was_present && !if_exists {
3948                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3949                    alloc::format!("view {name:?} does not exist"),
3950                )));
3951            }
3952            if was_present {
3953                removed += 1;
3954            }
3955        }
3956        Ok(QueryResult::CommandOk {
3957            affected: removed,
3958            modified_catalog: removed > 0 && !self.in_transaction(),
3959        })
3960    }
3961
3962    /// v7.17.0 — `DROP SEQUENCE [IF EXISTS] name [, name…]`.
3963    fn exec_drop_sequence(
3964        &mut self,
3965        names: &[String],
3966        if_exists: bool,
3967    ) -> Result<QueryResult, EngineError> {
3968        let mut removed = 0usize;
3969        for name in names {
3970            let was_present = self.active_catalog_mut().drop_sequence(name);
3971            if !was_present && !if_exists {
3972                return Err(EngineError::Storage(spg_storage::StorageError::Corrupt(
3973                    alloc::format!("sequence {name:?} does not exist"),
3974                )));
3975            }
3976            if was_present {
3977                removed += 1;
3978            }
3979        }
3980        Ok(QueryResult::CommandOk {
3981            affected: removed,
3982            modified_catalog: removed > 0 && !self.in_transaction(),
3983        })
3984    }
3985
3986    /// v4.4 `UPDATE <table> SET col = expr [, ...] [WHERE cond]`.
3987    /// Filter pass uses the same WHERE eval as `exec_select`. Per
3988    /// matched row, evaluate each RHS expression against the *old*
3989    /// row, then call `Table::update_row` which rebuilds indices.
3990    /// Indexed columns are correctly reflected because rebuild
3991    /// happens after the cell rewrite.
3992    fn exec_update_cancel(
3993        &mut self,
3994        stmt: &spg_sql::ast::UpdateStatement,
3995        cancel: CancelToken<'_>,
3996    ) -> Result<QueryResult, EngineError> {
3997        // v7.12.5 — snapshot BEFORE/AFTER UPDATE row triggers + the
3998        // session FTS config before the table mut-borrow opens (the
3999        // INSERT path uses the same pattern). Empty vecs are the
4000        // common "no triggers on this table" fast path.
4001        // v7.13.0 — UPDATE triggers carry an optional `UPDATE OF
4002        // cols` filter. The filter is paired with each function so
4003        // the per-row fire loop can skip when no listed column
4004        // actually differs between OLD and NEW.
4005        let before_update_triggers = self.snapshot_update_row_triggers(&stmt.table, "BEFORE");
4006        let after_update_triggers = self.snapshot_update_row_triggers(&stmt.table, "AFTER");
4007        let trigger_session_cfg: Option<String> = self
4008            .session_params
4009            .get("default_text_search_config")
4010            .cloned();
4011        // v5.2.3: if the WHERE is a PK equality and matches a cold-
4012        // tier row, promote it back to the hot tier *before* the
4013        // hot-row walk. The promote pushes the row to the end of
4014        // `table.rows`, where the upcoming SET-evaluation loop will
4015        // pick it up and apply the assignments. Lookups for the key
4016        // never observe a gap because `promote_cold_row` inserts the
4017        // hot row before retiring the cold locator.
4018        if let Some(w) = &stmt.where_ {
4019            let schema_cols = self
4020                .active_catalog()
4021                .get(&stmt.table)
4022                .ok_or_else(|| {
4023                    EngineError::Storage(StorageError::TableNotFound {
4024                        name: stmt.table.clone(),
4025                    })
4026                })?
4027                .schema()
4028                .columns
4029                .clone();
4030            if let Some((col_pos, key)) = try_pk_predicate(w, &schema_cols, stmt.table.as_str())
4031                && let Some(idx_name) = self
4032                    .active_catalog()
4033                    .get(&stmt.table)
4034                    .and_then(|t| t.index_on(col_pos).map(|i| i.name.clone()))
4035            {
4036                // Promote may be a no-op (key is hot-only or absent);
4037                // we don't care about the return value here — the
4038                // subsequent hot walk will either match or not.
4039                let _ = self
4040                    .active_catalog_mut()
4041                    .promote_cold_row(&stmt.table, &idx_name, &key);
4042            }
4043        }
4044
4045        // v7.12.1 — cache session FTS config before the table
4046        // mut-borrow (same reason as exec_delete).
4047        let ts_cfg: Option<String> = self
4048            .session_param("default_text_search_config")
4049            .map(String::from);
4050        // v7.17.0 Phase 2.1 — snapshot the clock pointer before
4051        // we hold the catalog mutably so ON UPDATE runtime
4052        // overrides see the engine wall clock.
4053        let clock_for_on_update = self.clock;
4054        let table = self
4055            .active_catalog_mut()
4056            .get_mut(&stmt.table)
4057            .ok_or_else(|| {
4058                EngineError::Storage(StorageError::TableNotFound {
4059                    name: stmt.table.clone(),
4060                })
4061            })?;
4062        let schema_cols: Vec<ColumnSchema> = table.schema().columns.clone();
4063        // Resolve each SET target to a column position once, validate
4064        // up front so a typo'd column doesn't leave a partial mutation
4065        // behind.
4066        let mut targets: Vec<(usize, &Expr)> = Vec::with_capacity(stmt.assignments.len());
4067        for (col, expr) in &stmt.assignments {
4068            let pos = schema_cols
4069                .iter()
4070                .position(|c| c.name == *col)
4071                .ok_or_else(|| {
4072                    EngineError::Eval(EvalError::ColumnNotFound { name: col.clone() })
4073                })?;
4074            targets.push((pos, expr));
4075        }
4076        // v7.17.0 Phase 2.1 — for every column with an
4077        // `ON UPDATE CURRENT_TIMESTAMP` binding that the caller
4078        // did NOT explicitly set, schedule an automatic override.
4079        // Reuses `eval_runtime_default_free` so the same
4080        // canonical runtime-expression whitelist (now /
4081        // current_timestamp / current_date / …) governs both
4082        // DEFAULT and ON UPDATE.
4083        let mut on_update_overrides: Vec<(usize, String)> = Vec::new();
4084        for (i, col) in schema_cols.iter().enumerate() {
4085            if targets.iter().any(|(p, _)| *p == i) {
4086                continue;
4087            }
4088            if let Some(src) = &col.on_update_runtime {
4089                on_update_overrides.push((i, src.clone()));
4090            }
4091        }
4092        let ctx = EvalContext::new(&schema_cols, Some(stmt.table.as_str()))
4093            .with_default_text_search_config(ts_cfg.as_deref());
4094        // Walk every row, evaluate WHERE then SET expressions. We
4095        // gather (position, new_values) tuples first and apply them
4096        // afterwards so the WHERE/RHS evaluation reads the original
4097        // row state — matches PG semantics (UPDATE doesn't see its
4098        // own writes).
4099        let mut planned: Vec<(usize, Vec<Value>)> = Vec::new();
4100        for (i, row) in table.rows().iter().enumerate() {
4101            // v4.5: cooperative cancel checkpoint every 256 rows so
4102            // a runaway UPDATE without WHERE doesn't drag past the
4103            // server's query-timeout watchdog.
4104            if i.is_multiple_of(256) {
4105                cancel.check()?;
4106            }
4107            if let Some(w) = &stmt.where_ {
4108                let cond = eval::eval_expr(w, row, &ctx)?;
4109                if !matches!(cond, Value::Bool(true)) {
4110                    continue;
4111                }
4112            }
4113            let mut new_vals = row.values.clone();
4114            for (pos, expr) in &targets {
4115                let v = eval::eval_expr(expr, row, &ctx)?;
4116                let coerced = coerce_value(v, schema_cols[*pos].ty, &schema_cols[*pos].name, *pos)?;
4117                check_unsigned_range(&coerced, &schema_cols[*pos], *pos)?;
4118                new_vals[*pos] = coerced;
4119            }
4120            // v7.17.0 Phase 2.1 — apply ON UPDATE overrides for
4121            // any column the SET clause didn't touch.
4122            for (pos, src) in &on_update_overrides {
4123                let v = eval_runtime_default_free(src, schema_cols[*pos].ty, clock_for_on_update)?;
4124                new_vals[*pos] = v;
4125            }
4126            planned.push((i, new_vals));
4127        }
4128        // v7.6.6 — capture pre-update row values for the FK
4129        // enforcement passes below. `planned` carries new values
4130        // only; pair them with the old row.
4131        let plan_with_old: Vec<(usize, Vec<Value>, Vec<Value>)> = planned
4132            .iter()
4133            .map(|(pos, new_vals)| (*pos, table.rows()[*pos].values.clone(), new_vals.clone()))
4134            .collect();
4135        let self_fks = table.schema().foreign_keys.clone();
4136        // v7.12.5 — `affected` is computed post-BEFORE-trigger
4137        // below (triggers may RETURN NULL to skip individual
4138        // rows). The pre-trigger len shape is no longer accurate.
4139        // Release mutable borrow on `table` for the FK passes.
4140        let _ = table;
4141        // v7.6.6 — Stage 2a: outbound FK check. For every row whose
4142        // local FK columns changed, the new value must exist in the
4143        // parent.
4144        if !self_fks.is_empty() {
4145            let new_rows: Vec<Vec<Value>> = planned
4146                .iter()
4147                .map(|(_pos, new_vals)| new_vals.clone())
4148                .collect();
4149            enforce_fk_inserts(self.active_catalog(), &stmt.table, &self_fks, &new_rows)?;
4150        }
4151        // v7.13.0 — CHECK constraint enforcement on UPDATE
4152        // (mailrs round-5 G3). Predicates evaluated against the
4153        // candidate post-UPDATE row; false rejects the UPDATE.
4154        {
4155            let new_rows: Vec<Vec<Value>> = planned
4156                .iter()
4157                .map(|(_pos, new_vals)| new_vals.clone())
4158                .collect();
4159            enforce_check_constraints(self.active_catalog(), &stmt.table, &new_rows)?;
4160        }
4161        // v7.6.6 — Stage 2b: inbound FK check. For every row that
4162        // changed value in a column that *some other table* uses as
4163        // a FK parent column, react per `on_update` action.
4164        let child_plan =
4165            plan_fk_parent_updates(self.active_catalog(), &stmt.table, &plan_with_old)?;
4166        // Stage 3a — apply each child-side action.
4167        for step in &child_plan {
4168            apply_fk_child_step(self.active_catalog_mut(), step)?;
4169        }
4170        // Stage 3b — apply the original UPDATE.
4171        let table = self
4172            .active_catalog_mut()
4173            .get_mut(&stmt.table)
4174            .ok_or_else(|| {
4175                EngineError::Storage(StorageError::TableNotFound {
4176                    name: stmt.table.clone(),
4177                })
4178            })?;
4179        // v7.12.5 — fire BEFORE/AFTER UPDATE row-level triggers
4180        // around the apply loop. BEFORE sees NEW=candidate +
4181        // OLD=current; may rewrite NEW or RETURN NULL to skip.
4182        // AFTER sees NEW=post-write + OLD=pre-write (both read-
4183        // only).
4184        //
4185        // Filter `planned` through the BEFORE pass first so the
4186        // RETURNING snapshot reflects what actually got written
4187        // (triggers may rewrite cells, including a cancellation).
4188        let mut applied_after_before: Vec<(usize, Row, Row)> = Vec::with_capacity(planned.len());
4189        // v7.12.7 — embedded SQL queue.
4190        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
4191        for (pos, new_vals) in &planned {
4192            let old_row = table.rows()[*pos].clone();
4193            let mut new_row = Row::new(new_vals.clone());
4194            let mut skip = false;
4195            for (fd, filter) in &before_update_triggers {
4196                // v7.13.0 — `UPDATE OF cols` filter (mailrs round-5
4197                // G7). Skip this trigger when the filter is set and
4198                // no listed column actually differs between OLD and
4199                // NEW for this row.
4200                if !filter.is_empty()
4201                    && !any_column_changed(filter, &schema_cols, &old_row, &new_row)
4202                {
4203                    continue;
4204                }
4205                let (outcome, deferred) = triggers::fire_row_trigger(
4206                    fd,
4207                    Some(new_row.clone()),
4208                    Some(&old_row),
4209                    &stmt.table,
4210                    &schema_cols,
4211                    &[],
4212                    trigger_session_cfg.as_deref(),
4213                    false,
4214                )
4215                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
4216                deferred_embedded.extend(deferred);
4217                match outcome {
4218                    triggers::TriggerOutcome::Row(r) => new_row = r,
4219                    triggers::TriggerOutcome::Skip => {
4220                        skip = true;
4221                        break;
4222                    }
4223                }
4224            }
4225            if !skip {
4226                applied_after_before.push((*pos, new_row, old_row));
4227            }
4228        }
4229        // v7.9.4 — snapshot post-update values for RETURNING (post-
4230        // BEFORE-trigger because triggers can rewrite cells).
4231        let updated_for_returning: Vec<Vec<Value>> = if stmt.returning.is_some() {
4232            applied_after_before
4233                .iter()
4234                .map(|(_pos, new_row, _old)| new_row.values.clone())
4235                .collect()
4236        } else {
4237            Vec::new()
4238        };
4239        let affected = applied_after_before.len();
4240        // Apply, then fire AFTER triggers per row. AFTER runs read-
4241        // only against the freshly-written row; v7.12.4-shape
4242        // assignment errors with a clear message.
4243        for (pos, new_row, old_row) in applied_after_before {
4244            table.update_row(pos, new_row.values.clone())?;
4245            for (fd, filter) in &after_update_triggers {
4246                if !filter.is_empty()
4247                    && !any_column_changed(filter, &schema_cols, &old_row, &new_row)
4248                {
4249                    continue;
4250                }
4251                let (_outcome, deferred) = triggers::fire_row_trigger(
4252                    fd,
4253                    Some(new_row.clone()),
4254                    Some(&old_row),
4255                    &stmt.table,
4256                    &schema_cols,
4257                    &[],
4258                    trigger_session_cfg.as_deref(),
4259                    true,
4260                )
4261                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
4262                deferred_embedded.extend(deferred);
4263            }
4264        }
4265        let _ = table;
4266        // v7.12.7 — drain trigger-emitted embedded SQL for this UPDATE.
4267        self.execute_deferred_trigger_stmts(deferred_embedded, cancel)?;
4268        // v6.2.1 — auto-analyze modified-row tracking for UPDATE.
4269        if !self.in_transaction() && affected > 0 {
4270            self.statistics
4271                .record_modifications(&stmt.table, affected as u64);
4272        }
4273        // v7.9.4 — RETURNING projection.
4274        if let Some(items) = &stmt.returning {
4275            return self.build_returning_rows(&stmt.table, items, updated_for_returning);
4276        }
4277        Ok(QueryResult::CommandOk {
4278            affected,
4279            modified_catalog: !self.in_transaction(),
4280        })
4281    }
4282
4283    /// v4.4 `DELETE FROM <table> [WHERE cond]`. Collects matching
4284    /// positions then delegates to `Table::delete_rows` (single index
4285    /// rebuild for the batch).
4286    /// v7.17.0 Phase 3.P0-42 — SQL:2003 / PG 15+ `MERGE` execution.
4287    ///
4288    /// Semantics:
4289    ///   * Resolve `target` and `source` tables (catalog reads).
4290    ///   * Build a combined `(target_alias.col, source_alias.col)`
4291    ///     schema so the ON / WHEN AND / SET / VALUES expressions
4292    ///     resolve through the standard qualifier-aware resolver.
4293    ///   * Pass 1: walk every source row × every target hot row,
4294    ///     evaluate ON, then pick the first WHEN clause that fits
4295    ///     (`Matched` if any target row matched, `NotMatched`
4296    ///     otherwise; AND-condition must hold). Collect the action
4297    ///     plan as `(deletes, updates, inserts)` so the apply pass
4298    ///     reads the original target row state.
4299    ///   * Pass 2: apply the plan against the target's mutable row
4300    ///     vector. Deletes execute by index in descending order so
4301    ///     earlier indices remain stable; updates next; inserts
4302    ///     last (matching PG's "INSERT branch sees the post-delete
4303    ///     state" behaviour for the common upsert shape).
4304    ///
4305    /// v7.17 simplifications (documented limitations):
4306    ///   * No triggers / WAL plumbing (MVP); MERGE rows don't fire
4307    ///     INSERT / UPDATE / DELETE row triggers in v7.17.
4308    ///   * No cardinality check (PG-canonical: "MERGE command
4309    ///     cannot affect row a second time" — SPG silently applies
4310    ///     the last action for a target row covered twice).
4311    ///   * Source must be a catalog-resolvable table (no subquery
4312    ///     source); RETURNING / BY SOURCE / BY TARGET unsupported.
4313    fn exec_merge_cancel(
4314        &mut self,
4315        stmt: &spg_sql::ast::MergeStatement,
4316        cancel: CancelToken<'_>,
4317    ) -> Result<QueryResult, EngineError> {
4318        let target_alias = stmt
4319            .target_alias
4320            .clone()
4321            .unwrap_or_else(|| stmt.target.clone());
4322        let source_alias = stmt
4323            .source_alias
4324            .clone()
4325            .unwrap_or_else(|| stmt.source.clone());
4326        let (target_cols, target_rows_snapshot) = {
4327            let t = self.active_catalog().get(&stmt.target).ok_or_else(|| {
4328                EngineError::Storage(StorageError::TableNotFound {
4329                    name: stmt.target.clone(),
4330                })
4331            })?;
4332            (
4333                t.schema().columns.clone(),
4334                t.rows().iter().cloned().collect::<Vec<Row>>(),
4335            )
4336        };
4337        let (source_cols, source_rows) = {
4338            let s = self.active_catalog().get(&stmt.source).ok_or_else(|| {
4339                EngineError::Storage(StorageError::TableNotFound {
4340                    name: stmt.source.clone(),
4341                })
4342            })?;
4343            (
4344                s.schema().columns.clone(),
4345                s.rows().iter().cloned().collect::<Vec<Row>>(),
4346            )
4347        };
4348        // Composite schema: target_alias.col ... source_alias.col ...
4349        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
4350        for col in &target_cols {
4351            combined_schema.push(ColumnSchema::new(
4352                alloc::format!("{target_alias}.{}", col.name),
4353                col.ty,
4354                col.nullable,
4355            ));
4356        }
4357        for col in &source_cols {
4358            combined_schema.push(ColumnSchema::new(
4359                alloc::format!("{source_alias}.{}", col.name),
4360                col.ty,
4361                col.nullable,
4362            ));
4363        }
4364        let combined_ctx = EvalContext::new(&combined_schema, None);
4365        // Source-only context for WHEN NOT MATCHED actions (no
4366        // matched target row exists — the source-side qualified
4367        // columns must still resolve).
4368        let mut source_only_schema: Vec<ColumnSchema> = Vec::new();
4369        for col in &target_cols {
4370            source_only_schema.push(ColumnSchema::new(
4371                alloc::format!("{target_alias}.{}", col.name),
4372                col.ty,
4373                col.nullable,
4374            ));
4375        }
4376        for col in &source_cols {
4377            source_only_schema.push(ColumnSchema::new(
4378                alloc::format!("{source_alias}.{}", col.name),
4379                col.ty,
4380                col.nullable,
4381            ));
4382        }
4383        let source_only_ctx = EvalContext::new(&source_only_schema, None);
4384        let target_arity = target_cols.len();
4385        let source_arity = source_cols.len();
4386
4387        // Resolve INSERT column positions once (validate names).
4388        // For each clause that's an INSERT, map column names → target positions.
4389        let mut delete_indices: Vec<usize> = Vec::new();
4390        let mut updates: Vec<(usize, Vec<Value>)> = Vec::new();
4391        let mut inserts: Vec<Vec<Value>> = Vec::new();
4392        let mut affected: usize = 0;
4393
4394        for (src_idx, src_row) in source_rows.iter().enumerate() {
4395            if src_idx.is_multiple_of(256) {
4396                cancel.check()?;
4397            }
4398            // Find every matched target index (per the ON predicate).
4399            let mut matched_targets: Vec<usize> = Vec::new();
4400            for (t_idx, t_row) in target_rows_snapshot.iter().enumerate() {
4401                let mut combined_vals = t_row.values.clone();
4402                combined_vals.extend(src_row.values.iter().cloned());
4403                let combined_row = Row::new(combined_vals);
4404                let cond = eval::eval_expr(&stmt.on, &combined_row, &combined_ctx)?;
4405                if matches!(cond, Value::Bool(true)) {
4406                    matched_targets.push(t_idx);
4407                }
4408            }
4409            let is_matched = !matched_targets.is_empty();
4410            // Pick the first WHEN clause whose kind agrees with
4411            // `is_matched` and whose AND condition (if any) holds.
4412            // AND condition for MATCHED: evaluated against the
4413            // first matched target row × source. For NOT MATCHED:
4414            // evaluated with target side NULL-padded.
4415            let fired_clause = stmt.clauses.iter().find(|c| {
4416                let kind_ok = match c.matched {
4417                    spg_sql::ast::MergeMatched::Matched => is_matched,
4418                    spg_sql::ast::MergeMatched::NotMatched => !is_matched,
4419                };
4420                if !kind_ok {
4421                    return false;
4422                }
4423                let Some(cond_expr) = &c.condition else {
4424                    return true;
4425                };
4426                let row = if is_matched {
4427                    let t = &target_rows_snapshot[matched_targets[0]];
4428                    let mut vals = t.values.clone();
4429                    vals.extend(src_row.values.iter().cloned());
4430                    Row::new(vals)
4431                } else {
4432                    let mut vals: Vec<Value> = (0..target_arity).map(|_| Value::Null).collect();
4433                    vals.extend(src_row.values.iter().cloned());
4434                    Row::new(vals)
4435                };
4436                let ctx_ref = if is_matched {
4437                    &combined_ctx
4438                } else {
4439                    &source_only_ctx
4440                };
4441                matches!(
4442                    eval::eval_expr(cond_expr, &row, ctx_ref),
4443                    Ok(Value::Bool(true))
4444                )
4445            });
4446            let Some(clause) = fired_clause else { continue };
4447            match &clause.action {
4448                spg_sql::ast::MergeAction::DoNothing => {}
4449                spg_sql::ast::MergeAction::Delete => {
4450                    for &t_idx in &matched_targets {
4451                        if !delete_indices.contains(&t_idx) {
4452                            delete_indices.push(t_idx);
4453                            affected += 1;
4454                        }
4455                    }
4456                }
4457                spg_sql::ast::MergeAction::Update { assignments } => {
4458                    // Pre-resolve SET targets to target column positions.
4459                    let mut planned_sets: Vec<(usize, &Expr)> =
4460                        Vec::with_capacity(assignments.len());
4461                    for (col, expr) in assignments {
4462                        let pos =
4463                            target_cols
4464                                .iter()
4465                                .position(|c| c.name == *col)
4466                                .ok_or_else(|| {
4467                                    EngineError::Eval(EvalError::ColumnNotFound {
4468                                        name: col.clone(),
4469                                    })
4470                                })?;
4471                        planned_sets.push((pos, expr));
4472                    }
4473                    for &t_idx in &matched_targets {
4474                        let t_row = &target_rows_snapshot[t_idx];
4475                        let mut new_values = t_row.values.clone();
4476                        let mut combined_vals = t_row.values.clone();
4477                        combined_vals.extend(src_row.values.iter().cloned());
4478                        let combined_row = Row::new(combined_vals);
4479                        for (pos, expr) in &planned_sets {
4480                            let raw = eval::eval_expr(expr, &combined_row, &combined_ctx)?;
4481                            let coerced = coerce_value(
4482                                raw,
4483                                target_cols[*pos].ty,
4484                                &target_cols[*pos].name,
4485                                *pos,
4486                            )?;
4487                            new_values[*pos] = coerced;
4488                        }
4489                        updates.push((t_idx, new_values));
4490                        affected += 1;
4491                    }
4492                }
4493                spg_sql::ast::MergeAction::Insert { columns, values } => {
4494                    // For INSERT NOT MATCHED, target side is NULL-padded.
4495                    let mut vals: Vec<Value> = (0..target_arity).map(|_| Value::Null).collect();
4496                    vals.extend(src_row.values.iter().cloned());
4497                    let synth_row = Row::new(vals);
4498                    let mut new_row_values: Vec<Value> =
4499                        (0..target_arity).map(|_| Value::Null).collect();
4500                    for (col, expr) in columns.iter().zip(values.iter()) {
4501                        let pos =
4502                            target_cols
4503                                .iter()
4504                                .position(|c| c.name == *col)
4505                                .ok_or_else(|| {
4506                                    EngineError::Eval(EvalError::ColumnNotFound {
4507                                        name: col.clone(),
4508                                    })
4509                                })?;
4510                        let raw = eval::eval_expr(expr, &synth_row, &source_only_ctx)?;
4511                        let coerced =
4512                            coerce_value(raw, target_cols[pos].ty, &target_cols[pos].name, pos)?;
4513                        new_row_values[pos] = coerced;
4514                    }
4515                    inserts.push(new_row_values);
4516                    affected += 1;
4517                }
4518            }
4519        }
4520        let _ = source_arity; // captured for symmetry; cancellation cost negligible.
4521
4522        // Apply the plan to the target table.
4523        let table = self
4524            .active_catalog_mut()
4525            .get_mut(&stmt.target)
4526            .ok_or_else(|| {
4527                EngineError::Storage(StorageError::TableNotFound {
4528                    name: stmt.target.clone(),
4529                })
4530            })?;
4531        // Apply updates first (in-place), then deletes (one batch),
4532        // then inserts. The storage API uses `update_row(pos,
4533        // new_values)`, `delete_rows(&[positions])`, and `insert(row)`.
4534        for (idx, new_vals) in &updates {
4535            table
4536                .update_row(*idx, new_vals.clone())
4537                .map_err(EngineError::Storage)?;
4538        }
4539        if !delete_indices.is_empty() {
4540            table.delete_rows(&delete_indices);
4541        }
4542        for vals in inserts {
4543            table.insert(Row::new(vals)).map_err(EngineError::Storage)?;
4544        }
4545        Ok(QueryResult::CommandOk {
4546            affected,
4547            modified_catalog: affected > 0,
4548        })
4549    }
4550
4551    fn exec_delete_cancel(
4552        &mut self,
4553        stmt: &spg_sql::ast::DeleteStatement,
4554        cancel: CancelToken<'_>,
4555    ) -> Result<QueryResult, EngineError> {
4556        // v7.12.5 — snapshot BEFORE/AFTER DELETE row triggers + the
4557        // session FTS config before the mut borrow (same shape as
4558        // INSERT / UPDATE).
4559        let before_delete_triggers = self.snapshot_row_triggers(&stmt.table, "DELETE", "BEFORE");
4560        let after_delete_triggers = self.snapshot_row_triggers(&stmt.table, "DELETE", "AFTER");
4561        let trigger_session_cfg: Option<String> = self
4562            .session_params
4563            .get("default_text_search_config")
4564            .cloned();
4565        // v5.2.3: PK-targeted DELETE → first retire any cold-tier
4566        // locator for the key. The cold row body stays in the
4567        // segment (becoming shadowed garbage that a future
4568        // compaction pass reclaims) but the index no longer
4569        // resolves it. The shadow count contributes to the
4570        // affected total; the subsequent hot walk handles any hot
4571        // rows for the same key.
4572        let mut cold_shadow_count: usize = 0;
4573        if let Some(w) = &stmt.where_ {
4574            let schema_cols = self
4575                .active_catalog()
4576                .get(&stmt.table)
4577                .ok_or_else(|| {
4578                    EngineError::Storage(StorageError::TableNotFound {
4579                        name: stmt.table.clone(),
4580                    })
4581                })?
4582                .schema()
4583                .columns
4584                .clone();
4585            if let Some((col_pos, key)) = try_pk_predicate(w, &schema_cols, stmt.table.as_str())
4586                && let Some(idx_name) = self
4587                    .active_catalog()
4588                    .get(&stmt.table)
4589                    .and_then(|t| t.index_on(col_pos).map(|i| i.name.clone()))
4590            {
4591                cold_shadow_count = self
4592                    .active_catalog_mut()
4593                    .shadow_cold_row(&stmt.table, &idx_name, &key)
4594                    .unwrap_or(0);
4595            }
4596        }
4597
4598        // v7.12.1 — cache the session FTS config as an owned
4599        // String before the mutable table borrow below; the
4600        // ctx-builder then references it via `as_deref` so the
4601        // immutable read of `session_params` doesn't conflict
4602        // with the mut borrow chain.
4603        let ts_cfg: Option<String> = self
4604            .session_param("default_text_search_config")
4605            .map(String::from);
4606        let table = self
4607            .active_catalog_mut()
4608            .get_mut(&stmt.table)
4609            .ok_or_else(|| {
4610                EngineError::Storage(StorageError::TableNotFound {
4611                    name: stmt.table.clone(),
4612                })
4613            })?;
4614        let schema_cols: Vec<ColumnSchema> = table.schema().columns.clone();
4615        let ctx = EvalContext::new(&schema_cols, Some(stmt.table.as_str()))
4616            .with_default_text_search_config(ts_cfg.as_deref());
4617        let mut positions: Vec<usize> = Vec::new();
4618        // v7.6.3 — collect every to-delete row's full Value tuple
4619        // alongside its position, so the FK enforcement pass can
4620        // run after the mut borrow drops.
4621        let mut to_delete_rows: Vec<Vec<Value>> = Vec::new();
4622        for (i, row) in table.rows().iter().enumerate() {
4623            if i.is_multiple_of(256) {
4624                cancel.check()?;
4625            }
4626            let keep = if let Some(w) = &stmt.where_ {
4627                let cond = eval::eval_expr(w, row, &ctx)?;
4628                !matches!(cond, Value::Bool(true))
4629            } else {
4630                false
4631            };
4632            if !keep {
4633                positions.push(i);
4634                to_delete_rows.push(row.values.clone());
4635            }
4636        }
4637        // v7.6.3 / v7.6.4 — Stage 2: FK enforcement on the immutable
4638        // catalog. Release the mut borrow and run reverse-scan
4639        // against every child table whose FK targets this table.
4640        // RESTRICT / NoAction raise an error; CASCADE returns a
4641        // cascade plan that stage 3 applies after the primary delete.
4642        // SET NULL / SET DEFAULT remain Unsupported until v7.6.5.
4643        let _ = table;
4644        // v7.12.5 — BEFORE DELETE row-level triggers. Each fires
4645        // with NEW=None / OLD=pre-delete row; RETURN OLD (or NEW)
4646        // = proceed, RETURN NULL = skip the row entirely. The
4647        // filter must run BEFORE the FK cascade plan so cascaded
4648        // child rows track the trigger's skip-decision on the
4649        // parent.
4650        // v7.12.7 — embedded SQL queue.
4651        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
4652        if !before_delete_triggers.is_empty() {
4653            let mut filtered_positions: Vec<usize> = Vec::with_capacity(positions.len());
4654            let mut filtered_old_rows: Vec<Vec<Value>> = Vec::with_capacity(to_delete_rows.len());
4655            for (pos, old_vals) in positions.iter().zip(to_delete_rows.iter()) {
4656                let old_row = Row::new(old_vals.clone());
4657                let mut cancel_this = false;
4658                for fd in &before_delete_triggers {
4659                    let (outcome, deferred) = triggers::fire_row_trigger(
4660                        fd,
4661                        None,
4662                        Some(&old_row),
4663                        &stmt.table,
4664                        &schema_cols,
4665                        &[],
4666                        trigger_session_cfg.as_deref(),
4667                        false,
4668                    )
4669                    .map_err(|e| {
4670                        EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}")))
4671                    })?;
4672                    deferred_embedded.extend(deferred);
4673                    if matches!(outcome, triggers::TriggerOutcome::Skip) {
4674                        cancel_this = true;
4675                        break;
4676                    }
4677                }
4678                if !cancel_this {
4679                    filtered_positions.push(*pos);
4680                    filtered_old_rows.push(old_vals.clone());
4681                }
4682            }
4683            positions = filtered_positions;
4684            to_delete_rows = filtered_old_rows;
4685        }
4686        let cascade_plan = plan_fk_parent_deletions(
4687            self.active_catalog(),
4688            &stmt.table,
4689            &positions,
4690            &to_delete_rows,
4691        )?;
4692        // Stage 3a — apply each FK child step (SET NULL / SET
4693        // DEFAULT / CASCADE delete) before deleting the parent.
4694        // The plan is already ordered: nulls/defaults first, then
4695        // cascade deletes (so a row mutated and later deleted
4696        // surfaces as deleted — though v7.6.5 doesn't produce
4697        // that overlap today).
4698        for step in &cascade_plan {
4699            apply_fk_child_step(self.active_catalog_mut(), step)?;
4700        }
4701        // Stage 3b — actually delete the original target rows.
4702        let table = self
4703            .active_catalog_mut()
4704            .get_mut(&stmt.table)
4705            .ok_or_else(|| {
4706                EngineError::Storage(StorageError::TableNotFound {
4707                    name: stmt.table.clone(),
4708                })
4709            })?;
4710        let affected = table.delete_rows(&positions) + cold_shadow_count;
4711        let _ = table;
4712        // v7.12.5 — AFTER DELETE row-level triggers fire post-write
4713        // with NEW=None / OLD=pre-delete row (each from the
4714        // already-snapshotted to_delete_rows). Return value is
4715        // ignored (matches PG AFTER semantics).
4716        if !after_delete_triggers.is_empty() {
4717            for old_vals in &to_delete_rows {
4718                let old_row = Row::new(old_vals.clone());
4719                for fd in &after_delete_triggers {
4720                    let (_outcome, deferred) = triggers::fire_row_trigger(
4721                        fd,
4722                        None,
4723                        Some(&old_row),
4724                        &stmt.table,
4725                        &schema_cols,
4726                        &[],
4727                        trigger_session_cfg.as_deref(),
4728                        true,
4729                    )
4730                    .map_err(|e| {
4731                        EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}")))
4732                    })?;
4733                    deferred_embedded.extend(deferred);
4734                }
4735            }
4736        }
4737        // v7.12.7 — drain trigger-emitted embedded SQL for this DELETE.
4738        self.execute_deferred_trigger_stmts(deferred_embedded, cancel)?;
4739        // v6.2.1 — auto-analyze modified-row tracking for DELETE.
4740        if !self.in_transaction() && affected > 0 {
4741            self.statistics
4742                .record_modifications(&stmt.table, affected as u64);
4743        }
4744        // v7.9.4 — RETURNING projection over the soon-to-be-gone
4745        // rows. `to_delete_rows` was snapshotted in stage 1 before
4746        // mutation, so the projection sees the pre-delete state
4747        // (matches PG semantics: DELETE RETURNING returns the row
4748        // as it was just before removal).
4749        if let Some(items) = &stmt.returning {
4750            return self.build_returning_rows(&stmt.table, items, to_delete_rows);
4751        }
4752        Ok(QueryResult::CommandOk {
4753            affected,
4754            modified_catalog: !self.in_transaction(),
4755        })
4756    }
4757
4758    /// `SHOW TABLES` — one row per table in the active catalog.
4759    /// Column name is `name` so result-set consumers can downstream
4760    /// `SELECT name FROM ...` style logic if needed.
4761    /// v4.26: `EXPLAIN [ANALYZE] <select>`. Returns a single-column
4762    /// `QUERY PLAN` text table — first line names the top operator
4763    /// (Scan / Aggregate / Window / etc.), indented children list
4764    /// FROM joins, WHERE filters, ORDER BY / LIMIT, projection
4765    /// shape, and any active index hits. `ANALYZE` execs the inner
4766    /// SELECT and appends actual-row + elapsed-micros annotations.
4767    #[allow(clippy::format_push_string)]
4768    fn exec_explain(
4769        &self,
4770        e: &spg_sql::ast::ExplainStatement,
4771        cancel: CancelToken<'_>,
4772    ) -> Result<QueryResult, EngineError> {
4773        let mut lines = Vec::<String>::new();
4774        explain_select(&e.inner, self, 0, &mut lines);
4775        if e.suggest {
4776            // v6.8.3 — index advisor. Walks the SELECT's FROM
4777            // tables + WHERE column refs; for each (table, column)
4778            // pair that lacks an index, append a SUGGEST line with
4779            // a copy-pastable `CREATE INDEX` statement. This is a
4780            // pure-syntax heuristic — no cardinality estimation —
4781            // matching the v6.8.3 design intent of "tell the
4782            // operator where indexes are missing", not "give the
4783            // mathematically optimal index set".
4784            let suggestions = build_index_suggestions(&e.inner, self);
4785            for s in suggestions {
4786                lines.push(s);
4787            }
4788        } else if e.analyze {
4789            // v6.2.4 — EXPLAIN ANALYZE annotates each operator line
4790            // with `(rows=N)` where the row count is computable
4791            // without re-executing the full query:
4792            //   - Top-level operator (first non-indented line):
4793            //     rows = final result.len()
4794            //   - "From: <table> [full scan]" lines: rows =
4795            //     table.rows().len() (catalog read; no execution)
4796            //   - "From: <table> [index seek]": indeterminate —
4797            //     the index step would need re-execution; v6.2.5
4798            //     adds per-operator wall-clock + hot/cold rows
4799            //     instrumentation that makes this concrete.
4800            //   - Everything else: marked `(—)` so the surface
4801            //     stays well-defined without silently dropping
4802            //     stats. v6.2.5 fills in via inline executor
4803            //     instrumentation.
4804            // Total elapsed lands on a trailing `Total: …` line.
4805            let started = self.clock.map(|f| f());
4806            let exec = self.exec_select_cancel(&e.inner, cancel)?;
4807            let elapsed_micros = match (self.clock, started) {
4808                (Some(f), Some(s)) => Some(f().saturating_sub(s)),
4809                _ => None,
4810            };
4811            let row_count = if let QueryResult::Rows { rows, .. } = &exec {
4812                rows.len()
4813            } else {
4814                0
4815            };
4816            annotate_explain_lines(&mut lines, row_count, self);
4817            let mut total = alloc::format!("Total: rows={row_count}");
4818            if let Some(us) = elapsed_micros {
4819                total.push_str(&alloc::format!(" elapsed={us}us"));
4820            }
4821            lines.push(total);
4822        }
4823        let columns = alloc::vec![ColumnSchema::new("QUERY PLAN", DataType::Text, false)];
4824        let rows: Vec<Row> = lines
4825            .into_iter()
4826            .map(|l| Row::new(alloc::vec![Value::Text(l)]))
4827            .collect();
4828        Ok(QueryResult::Rows { columns, rows })
4829    }
4830
4831    fn exec_show_tables(&self) -> QueryResult {
4832        let columns = alloc::vec![ColumnSchema::new("name", DataType::Text, false)];
4833        let rows: Vec<Row> = self
4834            .active_catalog()
4835            .table_names()
4836            .into_iter()
4837            .map(|n| Row::new(alloc::vec![Value::Text(n)]))
4838            .collect();
4839        QueryResult::Rows { columns, rows }
4840    }
4841
4842    /// v7.17.0 Phase 3.P0-59 — `SHOW CREATE TABLE <t>`. Synthesise
4843    /// a minimal MySQL-flavoured CREATE TABLE DDL from the
4844    /// catalog's TableSchema so mysqldump round-trips load against
4845    /// SPG without splitting init scripts.
4846    fn exec_show_create_table(&self, name: &str) -> Result<QueryResult, EngineError> {
4847        let t = self.active_catalog().get(name).ok_or_else(|| {
4848            EngineError::Storage(StorageError::TableNotFound { name: name.into() })
4849        })?;
4850        let cols: Vec<String> = t
4851            .schema()
4852            .columns
4853            .iter()
4854            .map(|c| {
4855                let ty = render_data_type(c.ty);
4856                let nullable = if c.nullable { "" } else { " NOT NULL" };
4857                alloc::format!("  `{}` {}{}", c.name, ty, nullable)
4858            })
4859            .collect();
4860        let mut body = cols.join(",\n");
4861        // Append UNIQUE / PRIMARY KEY clauses.
4862        for uc in &t.schema().uniqueness_constraints {
4863            let col_names: Vec<String> = uc
4864                .columns
4865                .iter()
4866                .map(|&p| {
4867                    t.schema().columns.get(p).map_or_else(
4868                        || alloc::format!("col{p}"),
4869                        |c| alloc::format!("`{}`", c.name),
4870                    )
4871                })
4872                .collect();
4873            let kw = if uc.is_primary_key {
4874                "PRIMARY KEY"
4875            } else {
4876                "UNIQUE KEY"
4877            };
4878            body.push_str(",\n  ");
4879            body.push_str(&alloc::format!("{kw} ({})", col_names.join(", ")));
4880        }
4881        // Foreign keys.
4882        for fk in &t.schema().foreign_keys {
4883            let local: Vec<String> = fk
4884                .local_columns
4885                .iter()
4886                .map(|&p| {
4887                    t.schema().columns.get(p).map_or_else(
4888                        || alloc::format!("col{p}"),
4889                        |c| alloc::format!("`{}`", c.name),
4890                    )
4891                })
4892                .collect();
4893            let parent_cols: Vec<String> =
4894                if let Some(parent) = self.active_catalog().get(&fk.parent_table) {
4895                    fk.parent_columns
4896                        .iter()
4897                        .map(|&p| {
4898                            parent.schema().columns.get(p).map_or_else(
4899                                || alloc::format!("col{p}"),
4900                                |c| alloc::format!("`{}`", c.name),
4901                            )
4902                        })
4903                        .collect()
4904                } else {
4905                    fk.parent_columns
4906                        .iter()
4907                        .map(|p| alloc::format!("col{p}"))
4908                        .collect()
4909                };
4910            body.push_str(",\n  ");
4911            body.push_str(&alloc::format!(
4912                "FOREIGN KEY ({}) REFERENCES `{}` ({})",
4913                local.join(", "),
4914                fk.parent_table,
4915                parent_cols.join(", ")
4916            ));
4917        }
4918        let ddl = alloc::format!(
4919            "CREATE TABLE `{}` (\n{}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
4920            name,
4921            body
4922        );
4923        let columns = alloc::vec![
4924            ColumnSchema::new("Table", DataType::Text, false),
4925            ColumnSchema::new("Create Table", DataType::Text, false),
4926        ];
4927        let rows = alloc::vec![Row::new(alloc::vec![
4928            Value::Text(name.into()),
4929            Value::Text(ddl),
4930        ])];
4931        Ok(QueryResult::Rows { columns, rows })
4932    }
4933
4934    /// v7.17.0 Phase 3.P0-60 — `SHOW INDEXES FROM <t>`. MySQL
4935    /// surface returns one row per (index × column) with 14
4936    /// columns; v7.17 ships the columns admin probes actually
4937    /// filter on: Table, Non_unique, Key_name, Seq_in_index,
4938    /// Column_name, Null, Index_type.
4939    fn exec_show_indexes(&self, name: &str) -> Result<QueryResult, EngineError> {
4940        let t = self.active_catalog().get(name).ok_or_else(|| {
4941            EngineError::Storage(StorageError::TableNotFound { name: name.into() })
4942        })?;
4943        let columns = alloc::vec![
4944            ColumnSchema::new("Table", DataType::Text, false),
4945            ColumnSchema::new("Non_unique", DataType::Int, false),
4946            ColumnSchema::new("Key_name", DataType::Text, false),
4947            ColumnSchema::new("Seq_in_index", DataType::Int, false),
4948            ColumnSchema::new("Column_name", DataType::Text, false),
4949            ColumnSchema::new("Null", DataType::Text, false),
4950            ColumnSchema::new("Index_type", DataType::Text, false),
4951        ];
4952        let mut rows: Vec<Row> = Vec::new();
4953        for idx in t.indices() {
4954            let col = t
4955                .schema()
4956                .columns
4957                .get(idx.column_position)
4958                .map_or("?".into(), |c| c.name.clone());
4959            let nullable = t
4960                .schema()
4961                .columns
4962                .get(idx.column_position)
4963                .map_or(true, |c| c.nullable);
4964            rows.push(Row::new(alloc::vec![
4965                Value::Text(name.into()),
4966                Value::Int(i32::from(!idx.is_unique)),
4967                Value::Text(idx.name.clone()),
4968                Value::Int(1),
4969                Value::Text(col),
4970                Value::Text(if nullable {
4971                    "YES".into()
4972                } else {
4973                    String::new()
4974                }),
4975                Value::Text("BTREE".into()),
4976            ]));
4977        }
4978        Ok(QueryResult::Rows { columns, rows })
4979    }
4980
4981    /// v7.17.0 Phase 3.P0-61 — `SHOW STATUS`. Returns canonical
4982    /// MySQL server-status counters (2-column `(Variable_name,
4983    /// Value)`).
4984    fn exec_show_status(&self) -> QueryResult {
4985        let columns = alloc::vec![
4986            ColumnSchema::new("Variable_name", DataType::Text, false),
4987            ColumnSchema::new("Value", DataType::Text, false),
4988        ];
4989        let pairs: &[(&str, &str)] = &[
4990            ("Uptime", "0"),
4991            ("Threads_connected", "1"),
4992            ("Threads_running", "1"),
4993            ("Questions", "0"),
4994            ("Slow_queries", "0"),
4995            ("Opened_tables", "0"),
4996            ("Innodb_buffer_pool_pages_total", "0"),
4997        ];
4998        let rows: Vec<Row> = pairs
4999            .iter()
5000            .map(|(k, v)| {
5001                Row::new(alloc::vec![
5002                    Value::Text((*k).into()),
5003                    Value::Text((*v).into())
5004                ])
5005            })
5006            .collect();
5007        QueryResult::Rows { columns, rows }
5008    }
5009
5010    /// v7.17.0 Phase 3.P0-61 — `SHOW VARIABLES`. Returns server-side
5011    /// variables MySQL/MariaDB clients probe at connect time.
5012    fn exec_show_variables(&self) -> QueryResult {
5013        let columns = alloc::vec![
5014            ColumnSchema::new("Variable_name", DataType::Text, false),
5015            ColumnSchema::new("Value", DataType::Text, false),
5016        ];
5017        let mut rows: Vec<Row> = Vec::new();
5018        let canonical: &[(&str, &str)] = &[
5019            ("version", "8.0.35-spg"),
5020            ("version_comment", "SPG dual-stack engine"),
5021            ("character_set_server", "utf8mb4"),
5022            ("collation_server", "utf8mb4_0900_ai_ci"),
5023            ("max_allowed_packet", "67108864"),
5024            ("autocommit", "ON"),
5025            ("sql_mode", "STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION"),
5026            ("time_zone", "SYSTEM"),
5027            ("transaction_isolation", "REPEATABLE-READ"),
5028        ];
5029        for &(k, v) in canonical {
5030            rows.push(Row::new(alloc::vec![
5031                Value::Text(k.into()),
5032                Value::Text(v.into()),
5033            ]));
5034        }
5035        // Session-set parameters surface here too.
5036        for (k, v) in &self.session_params {
5037            if !canonical.iter().any(|(n, _)| (*n).eq_ignore_ascii_case(k)) {
5038                rows.push(Row::new(alloc::vec![
5039                    Value::Text(k.clone()),
5040                    Value::Text(v.clone()),
5041                ]));
5042            }
5043        }
5044        QueryResult::Rows { columns, rows }
5045    }
5046
5047    /// v7.17.0 Phase 3.P0-62 — `SHOW PROCESSLIST`. SPG is
5048    /// single-process so the surface returns one synthetic row
5049    /// describing the current connection (Id, User, Host, db,
5050    /// Command, Time, State, Info).
5051    fn exec_show_processlist(&self) -> QueryResult {
5052        let columns = alloc::vec![
5053            ColumnSchema::new("Id", DataType::Int, false),
5054            ColumnSchema::new("User", DataType::Text, false),
5055            ColumnSchema::new("Host", DataType::Text, false),
5056            ColumnSchema::new("db", DataType::Text, true),
5057            ColumnSchema::new("Command", DataType::Text, false),
5058            ColumnSchema::new("Time", DataType::Int, false),
5059            ColumnSchema::new("State", DataType::Text, true),
5060            ColumnSchema::new("Info", DataType::Text, true),
5061        ];
5062        let rows = alloc::vec![Row::new(alloc::vec![
5063            Value::Int(1),
5064            Value::Text("postgres".into()),
5065            Value::Text("localhost".into()),
5066            Value::Text("postgres".into()),
5067            Value::Text("Query".into()),
5068            Value::Int(0),
5069            Value::Text("executing".into()),
5070            Value::Text("SHOW PROCESSLIST".into()),
5071        ])];
5072        QueryResult::Rows { columns, rows }
5073    }
5074
5075    /// v7.17.0 Phase 3.P0-58 — `SHOW DATABASES` / `SHOW SCHEMAS`.
5076    /// SPG is single-database so the result is the canonical MySQL
5077    /// set every mysql/MariaDB client expects at connect time:
5078    /// `information_schema`, `mysql`, `performance_schema`, `sys`,
5079    /// plus a `postgres` slot so dual-stack callers find their
5080    /// PG-compatible database too.
5081    fn exec_show_databases(&self) -> QueryResult {
5082        let columns = alloc::vec![ColumnSchema::new("Database", DataType::Text, false)];
5083        let names = [
5084            "information_schema",
5085            "mysql",
5086            "performance_schema",
5087            "sys",
5088            "postgres",
5089        ];
5090        let rows: Vec<Row> = names
5091            .iter()
5092            .map(|n| Row::new(alloc::vec![Value::Text((*n).into())]))
5093            .collect();
5094        QueryResult::Rows { columns, rows }
5095    }
5096
5097    /// `SHOW COLUMNS FROM <table>` — one row per column with the
5098    /// declared name, SQL type rendering, and nullability flag.
5099    fn exec_show_columns(&self, table_name: &str) -> Result<QueryResult, EngineError> {
5100        let table =
5101            self.active_catalog()
5102                .get(table_name)
5103                .ok_or_else(|| StorageError::TableNotFound {
5104                    name: table_name.into(),
5105                })?;
5106        let columns = alloc::vec![
5107            ColumnSchema::new("name", DataType::Text, false),
5108            ColumnSchema::new("type", DataType::Text, false),
5109            ColumnSchema::new("nullable", DataType::Bool, false),
5110        ];
5111        let rows: Vec<Row> = table
5112            .schema()
5113            .columns
5114            .iter()
5115            .map(|c| {
5116                Row::new(alloc::vec![
5117                    Value::Text(c.name.clone()),
5118                    Value::Text(alloc::format!("{}", c.ty)),
5119                    Value::Bool(c.nullable),
5120                ])
5121            })
5122            .collect();
5123        Ok(QueryResult::Rows { columns, rows })
5124    }
5125
5126    fn exec_begin(&mut self) -> Result<QueryResult, EngineError> {
5127        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5128        if self.tx_catalogs.contains_key(&tx_id) {
5129            return Err(EngineError::TransactionAlreadyOpen);
5130        }
5131        self.tx_catalogs.insert(
5132            tx_id,
5133            TxState {
5134                catalog: self.catalog.clone(),
5135                savepoints: Vec::new(),
5136            },
5137        );
5138        Ok(QueryResult::CommandOk {
5139            affected: 0,
5140            modified_catalog: false,
5141        })
5142    }
5143
5144    fn exec_commit(&mut self) -> Result<QueryResult, EngineError> {
5145        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5146        let state = self
5147            .tx_catalogs
5148            .remove(&tx_id)
5149            .ok_or(EngineError::NoActiveTransaction)?;
5150        self.catalog = state.catalog;
5151        // All savepoints become permanent at COMMIT and the stack
5152        // resets for the next TX (`state.savepoints` is discarded with
5153        // `state`).
5154        Ok(QueryResult::CommandOk {
5155            affected: 0,
5156            modified_catalog: true,
5157        })
5158    }
5159
5160    fn exec_rollback(&mut self) -> Result<QueryResult, EngineError> {
5161        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5162        if self.tx_catalogs.remove(&tx_id).is_none() {
5163            return Err(EngineError::NoActiveTransaction);
5164        }
5165        // savepoints discarded with the TxState
5166        Ok(QueryResult::CommandOk {
5167            affected: 0,
5168            modified_catalog: false,
5169        })
5170    }
5171
5172    fn exec_savepoint(&mut self, name: String) -> Result<QueryResult, EngineError> {
5173        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5174        let state = self
5175            .tx_catalogs
5176            .get_mut(&tx_id)
5177            .ok_or(EngineError::NoActiveTransaction)?;
5178        // PG re-uses an existing savepoint name by dropping the older
5179        // entry and pushing a fresh one — match that behaviour so
5180        // application code can `SAVEPOINT sp; ...; SAVEPOINT sp` freely.
5181        state.savepoints.retain(|(n, _)| n != &name);
5182        let snapshot = state.catalog.clone();
5183        state.savepoints.push((name, snapshot));
5184        Ok(QueryResult::CommandOk {
5185            affected: 0,
5186            modified_catalog: false,
5187        })
5188    }
5189
5190    fn exec_rollback_to_savepoint(&mut self, name: &str) -> Result<QueryResult, EngineError> {
5191        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5192        let state = self
5193            .tx_catalogs
5194            .get_mut(&tx_id)
5195            .ok_or(EngineError::NoActiveTransaction)?;
5196        let pos = state
5197            .savepoints
5198            .iter()
5199            .rposition(|(n, _)| n == name)
5200            .ok_or_else(|| {
5201                EngineError::Unsupported(alloc::format!("savepoint not found: {name}"))
5202            })?;
5203        // The savepoint stays on the stack (PG semantics): a later
5204        // `RELEASE` or further `ROLLBACK TO` is still allowed. Everything
5205        // after it is discarded.
5206        let snapshot = state.savepoints[pos].1.clone();
5207        state.savepoints.truncate(pos + 1);
5208        state.catalog = snapshot;
5209        Ok(QueryResult::CommandOk {
5210            affected: 0,
5211            modified_catalog: false,
5212        })
5213    }
5214
5215    fn exec_release_savepoint(&mut self, name: &str) -> Result<QueryResult, EngineError> {
5216        let tx_id = self.current_tx.ok_or(EngineError::NoActiveTransaction)?;
5217        let state = self
5218            .tx_catalogs
5219            .get_mut(&tx_id)
5220            .ok_or(EngineError::NoActiveTransaction)?;
5221        let pos = state
5222            .savepoints
5223            .iter()
5224            .rposition(|(n, _)| n == name)
5225            .ok_or_else(|| {
5226                EngineError::Unsupported(alloc::format!("savepoint not found: {name}"))
5227            })?;
5228        // RELEASE keeps the work since the savepoint, just discards the
5229        // bookmark plus everything nested under it.
5230        state.savepoints.truncate(pos);
5231        Ok(QueryResult::CommandOk {
5232            affected: 0,
5233            modified_catalog: false,
5234        })
5235    }
5236
5237    /// v6.0.4 — synchronous `ALTER INDEX <name> REBUILD [WITH
5238    /// (encoding = …)]`. Walks every table in the active catalog
5239    /// looking for an index matching `stmt.name`, then delegates the
5240    /// rebuild (including any encoding switch) to
5241    /// `Table::rebuild_nsw_index`. The "live" non-blocking
5242    /// optimisation is v6.0.4.1 / v6.1.x territory.
5243    /// v6.7.2 — `ALTER TABLE t SET hot_tier_bytes = X`. Dispatch
5244    /// arm. Currently the only setting is `hot_tier_bytes`; later
5245    /// v6.7.x can extend `AlterTableTarget` without touching this
5246    /// arm structure.
5247    fn exec_alter_table(
5248        &mut self,
5249        s: spg_sql::ast::AlterTableStatement,
5250    ) -> Result<QueryResult, EngineError> {
5251        // v7.13.2 — mailrs round-6 S1: apply each subaction in order.
5252        // On first error the statement aborts; subactions already
5253        // applied stay (no transactional rollback in v7.13 — wrap in
5254        // BEGIN/COMMIT if atomicity matters).
5255        let table_name = s.name.clone();
5256        for target in s.targets {
5257            self.exec_alter_table_subaction(&table_name, target)?;
5258        }
5259        Ok(QueryResult::CommandOk {
5260            affected: 0,
5261            modified_catalog: !self.in_transaction(),
5262        })
5263    }
5264
5265    fn exec_alter_table_subaction(
5266        &mut self,
5267        table_name_outer: &str,
5268        target: spg_sql::ast::AlterTableTarget,
5269    ) -> Result<(), EngineError> {
5270        // Inner helper retains the s.name closure shape; alias to `s`
5271        // for minimal diff against the v7.13.0 body.
5272        struct S<'a> {
5273            name: &'a str,
5274        }
5275        let s = S {
5276            name: table_name_outer,
5277        };
5278        match target {
5279            spg_sql::ast::AlterTableTarget::SetHotTierBytes(n) => {
5280                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5281                    EngineError::Storage(StorageError::TableNotFound {
5282                        name: s.name.into(),
5283                    })
5284                })?;
5285                table.schema_mut().hot_tier_bytes = Some(n);
5286            }
5287            spg_sql::ast::AlterTableTarget::AddForeignKey(fk) => {
5288                // v7.6.8 — resolve FK against the live catalog first
5289                // (validates parent table, columns, indices). Then
5290                // verify every existing row in the child table
5291                // satisfies the new constraint. Then install it.
5292                let cols_snapshot = self
5293                    .active_catalog()
5294                    .get(s.name)
5295                    .ok_or_else(|| {
5296                        EngineError::Storage(StorageError::TableNotFound {
5297                            name: s.name.into(),
5298                        })
5299                    })?
5300                    .schema()
5301                    .columns
5302                    .clone();
5303                let storage_fk =
5304                    resolve_foreign_key(s.name, &cols_snapshot, fk, self.active_catalog())?;
5305                // Verify existing rows. Treat them as a virtual
5306                // INSERT batch — reusing the v7.6.2 enforce helper.
5307                let existing_rows: Vec<Vec<Value>> = self
5308                    .active_catalog()
5309                    .get(s.name)
5310                    .expect("checked above")
5311                    .rows()
5312                    .iter()
5313                    .map(|r| r.values.clone())
5314                    .collect();
5315                enforce_fk_inserts(
5316                    self.active_catalog(),
5317                    s.name,
5318                    core::slice::from_ref(&storage_fk),
5319                    &existing_rows,
5320                )?;
5321                // Reject duplicate constraint name.
5322                let table = self
5323                    .active_catalog_mut()
5324                    .get_mut(s.name)
5325                    .expect("checked above");
5326                if let Some(name) = &storage_fk.name
5327                    && table
5328                        .schema()
5329                        .foreign_keys
5330                        .iter()
5331                        .any(|f| f.name.as_ref() == Some(name))
5332                {
5333                    return Err(EngineError::Unsupported(alloc::format!(
5334                        "ALTER TABLE ADD CONSTRAINT: a constraint named {name:?} already exists"
5335                    )));
5336                }
5337                table.schema_mut().foreign_keys.push(storage_fk);
5338            }
5339            spg_sql::ast::AlterTableTarget::DropForeignKey { name, if_exists } => {
5340                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5341                    EngineError::Storage(StorageError::TableNotFound {
5342                        name: s.name.into(),
5343                    })
5344                })?;
5345                let fks = &mut table.schema_mut().foreign_keys;
5346                let before = fks.len();
5347                fks.retain(|f| f.name.as_ref() != Some(&name));
5348                if fks.len() == before && !if_exists {
5349                    return Err(EngineError::Unsupported(alloc::format!(
5350                        "ALTER TABLE DROP CONSTRAINT: no FK named {name:?} on {:?}",
5351                        s.name
5352                    )));
5353                }
5354                // v7.13.2 mailrs round-6 S7: IF EXISTS silences the miss.
5355            }
5356            spg_sql::ast::AlterTableTarget::AddColumn {
5357                column,
5358                if_not_exists,
5359            } => {
5360                // v7.13.0 — mailrs round-5 G1. Append-only column add
5361                // with back-fill of the DEFAULT (or NULL) into every
5362                // existing row. Column positions don't shift, so we
5363                // skip index rebuild.
5364                let clock = self.clock;
5365                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5366                    EngineError::Storage(StorageError::TableNotFound {
5367                        name: s.name.into(),
5368                    })
5369                })?;
5370                if table
5371                    .schema()
5372                    .columns
5373                    .iter()
5374                    .any(|c| c.name.eq_ignore_ascii_case(&column.name))
5375                {
5376                    if if_not_exists {
5377                        return Ok(());
5378                    }
5379                    return Err(EngineError::Unsupported(alloc::format!(
5380                        "ALTER TABLE ADD COLUMN: column {:?} already exists on {:?}",
5381                        column.name,
5382                        s.name
5383                    )));
5384                }
5385                let col_name = column.name.clone();
5386                let nullable = column.nullable;
5387                let has_default = column.default.is_some() || column.auto_increment;
5388                let col_schema = column_def_to_schema(column)?;
5389                let row_count = table.row_count();
5390                // Compute the back-fill value. Literal / runtime DEFAULT
5391                // funnels through the same resolver that INSERT uses
5392                // (v7.9.21 `resolve_column_default_free`). NULL when
5393                // the column is nullable and has no DEFAULT. NOT NULL
5394                // without DEFAULT errors when the table has existing
5395                // rows — same as PG.
5396                let fill_value: Value = if has_default || col_schema.runtime_default.is_some() {
5397                    resolve_column_default_free(&col_schema, clock)?
5398                } else if nullable || row_count == 0 {
5399                    Value::Null
5400                } else {
5401                    return Err(EngineError::Unsupported(alloc::format!(
5402                        "ALTER TABLE ADD COLUMN {col_name:?}: NOT NULL column requires DEFAULT \
5403                         when the table has existing rows"
5404                    )));
5405                };
5406                table.add_column(col_schema, fill_value);
5407            }
5408            spg_sql::ast::AlterTableTarget::AlterColumnType {
5409                column,
5410                new_type,
5411                using,
5412            } => {
5413                // v7.13.0 — mailrs round-5 G8. Re-evaluate each
5414                // row's column value (either through the USING
5415                // expression if supplied, or as a direct CAST of
5416                // the existing value) and re-coerce to the new
5417                // type. Indices on the column get rebuilt.
5418                let new_data_type = column_type_to_data_type(new_type);
5419                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5420                    EngineError::Storage(StorageError::TableNotFound {
5421                        name: s.name.into(),
5422                    })
5423                })?;
5424                let col_pos = table
5425                    .schema()
5426                    .columns
5427                    .iter()
5428                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5429                    .ok_or_else(|| {
5430                        EngineError::Unsupported(alloc::format!(
5431                            "ALTER COLUMN TYPE: column {column:?} not found on {:?}",
5432                            s.name
5433                        ))
5434                    })?;
5435                let schema_cols = table.schema().columns.clone();
5436                let ctx = eval::EvalContext::new(&schema_cols, None);
5437                let mut new_values: alloc::vec::Vec<Value> =
5438                    alloc::vec::Vec::with_capacity(table.row_count());
5439                for row in table.rows().iter() {
5440                    let raw = match &using {
5441                        Some(expr) => eval::eval_expr(expr, row, &ctx).map_err(|e| {
5442                            EngineError::Unsupported(alloc::format!(
5443                                "ALTER COLUMN TYPE: USING expression failed: {e:?}"
5444                            ))
5445                        })?,
5446                        None => row.values.get(col_pos).cloned().unwrap_or(Value::Null),
5447                    };
5448                    let coerced = coerce_value(raw, new_data_type, &column, col_pos)?;
5449                    new_values.push(coerced);
5450                }
5451                table.schema_mut().columns[col_pos].ty = new_data_type;
5452                for (i, v) in new_values.into_iter().enumerate() {
5453                    let mut row_values = table
5454                        .rows()
5455                        .get(i)
5456                        .expect("bounds-checked above")
5457                        .values
5458                        .clone();
5459                    row_values[col_pos] = v;
5460                    table.update_row(i, row_values)?;
5461                }
5462            }
5463            spg_sql::ast::AlterTableTarget::AddTableConstraint(tc) => {
5464                // v7.14.0 — pg_dump emits PKs as a separate
5465                // ALTER TABLE ADD CONSTRAINT post-CREATE-TABLE.
5466                // For PRIMARY KEY / UNIQUE, install a UC entry
5467                // and the implicit BTree index on the leading
5468                // column. CHECK: append predicate to schema.
5469                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5470                    EngineError::Storage(StorageError::TableNotFound {
5471                        name: s.name.into(),
5472                    })
5473                })?;
5474                let is_pk = matches!(tc, spg_sql::ast::TableConstraint::PrimaryKey { .. });
5475                match tc {
5476                    spg_sql::ast::TableConstraint::PrimaryKey { columns, .. }
5477                    | spg_sql::ast::TableConstraint::Unique { columns, .. } => {
5478                        let positions: Vec<usize> = columns
5479                            .iter()
5480                            .map(|c| {
5481                                table
5482                                    .schema()
5483                                    .columns
5484                                    .iter()
5485                                    .position(|sc| sc.name.eq_ignore_ascii_case(c))
5486                                    .ok_or_else(|| {
5487                                        EngineError::Unsupported(alloc::format!(
5488                                            "ALTER TABLE ADD CONSTRAINT: column {c:?} not found on {:?}",
5489                                            s.name
5490                                        ))
5491                                    })
5492                            })
5493                            .collect::<Result<Vec<_>, _>>()?;
5494                        // Skip if an equivalent UC is already there
5495                        // (idempotent — pg_dump's PK + a prior inline
5496                        // PK shouldn't double-install).
5497                        let already = table
5498                            .schema()
5499                            .uniqueness_constraints
5500                            .iter()
5501                            .any(|u| u.columns == positions);
5502                        if !already {
5503                            table.schema_mut().uniqueness_constraints.push(
5504                                spg_storage::UniquenessConstraint {
5505                                    is_primary_key: is_pk,
5506                                    columns: positions.clone(),
5507                                    nulls_not_distinct: false,
5508                                },
5509                            );
5510                            // PK implies NOT NULL on referenced cols.
5511                            if is_pk {
5512                                for p in &positions {
5513                                    if let Some(c) = table.schema_mut().columns.get_mut(*p) {
5514                                        c.nullable = false;
5515                                    }
5516                                }
5517                            }
5518                            // Add a BTree index on the leading
5519                            // column for INSERT-side enforcement.
5520                            let leading = &columns[0];
5521                            let already_idx = table.indices().iter().any(|idx| {
5522                                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
5523                                    && table.schema().columns[idx.column_position].name == *leading
5524                            });
5525                            if !already_idx {
5526                                let suffix = if is_pk { "pkey" } else { "key" };
5527                                let idx_name = alloc::format!("{}_{leading}_{suffix}", s.name);
5528                                let _ = table.add_index(idx_name, leading);
5529                            }
5530                        }
5531                    }
5532                    spg_sql::ast::TableConstraint::Check { expr, .. } => {
5533                        table.schema_mut().checks.push(alloc::format!("{expr}"));
5534                    }
5535                    spg_sql::ast::TableConstraint::Index { name, columns } => {
5536                        // v7.15.0 — ALTER TABLE ADD KEY (cols).
5537                        // mysqldump occasionally emits this
5538                        // post-CREATE-TABLE shape; build a BTree
5539                        // on the leading column using the
5540                        // user-supplied or synthesised name.
5541                        let leading = &columns[0];
5542                        let already_idx = table.indices().iter().any(|idx| {
5543                            matches!(idx.kind, spg_storage::IndexKind::BTree(_))
5544                                && table.schema().columns[idx.column_position].name == *leading
5545                        });
5546                        if !already_idx {
5547                            let idx_name = name
5548                                .clone()
5549                                .unwrap_or_else(|| alloc::format!("{}_{leading}_idx", s.name));
5550                            let _ = table.add_index(idx_name, leading);
5551                        }
5552                    }
5553                    spg_sql::ast::TableConstraint::FulltextIndex { name, columns } => {
5554                        // v7.17.0 Phase 2.2 — ALTER TABLE ADD
5555                        // FULLTEXT KEY (cols). Builds one
5556                        // fulltext-GIN per named column so MATCH
5557                        // AGAINST gets a real inverted index.
5558                        // Multi-column declarations expand to
5559                        // per-column GINs (the leading column
5560                        // drives MATCH AGAINST planning).
5561                        for (k, col) in columns.iter().enumerate() {
5562                            let already_idx = table.indices().iter().any(|idx| {
5563                                matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
5564                                    && table.schema().columns[idx.column_position].name == *col
5565                            });
5566                            if already_idx {
5567                                continue;
5568                            }
5569                            let idx_name = match (&name, columns.len(), k) {
5570                                (Some(n), 1, _) => n.clone(),
5571                                (Some(n), _, k) => alloc::format!("{n}_{k}"),
5572                                (None, _, _) => {
5573                                    alloc::format!("{}_{col}_ftidx", s.name)
5574                                }
5575                            };
5576                            let _ = table.add_gin_fulltext_index(idx_name, col);
5577                        }
5578                    }
5579                }
5580            }
5581            spg_sql::ast::AlterTableTarget::DropColumn {
5582                column,
5583                if_exists,
5584                cascade,
5585            } => {
5586                // v7.13.3 — mailrs round-7 S8. Remove the column +
5587                // every row's value at that position; drop any index
5588                // on the column. RESTRICT (default) rejects when an
5589                // FK on this table or partial-index predicate
5590                // references the column; CASCADE removes those
5591                // dependents first.
5592                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5593                    EngineError::Storage(StorageError::TableNotFound {
5594                        name: s.name.into(),
5595                    })
5596                })?;
5597                let col_pos = match table
5598                    .schema()
5599                    .columns
5600                    .iter()
5601                    .position(|c| c.name.eq_ignore_ascii_case(&column))
5602                {
5603                    Some(p) => p,
5604                    None => {
5605                        if if_exists {
5606                            return Ok(());
5607                        }
5608                        return Err(EngineError::Unsupported(alloc::format!(
5609                            "ALTER TABLE DROP COLUMN: column {column:?} not found on {:?}",
5610                            s.name
5611                        )));
5612                    }
5613                };
5614                // Dependent check: FKs whose local columns include
5615                // col_pos. CASCADE drops them; otherwise reject.
5616                let dependent_fks: Vec<usize> = table
5617                    .schema()
5618                    .foreign_keys
5619                    .iter()
5620                    .enumerate()
5621                    .filter_map(|(i, fk)| {
5622                        if fk.local_columns.contains(&col_pos) {
5623                            Some(i)
5624                        } else {
5625                            None
5626                        }
5627                    })
5628                    .collect();
5629                if !dependent_fks.is_empty() && !cascade {
5630                    return Err(EngineError::Unsupported(alloc::format!(
5631                        "ALTER TABLE DROP COLUMN {column:?}: column has FK dependents; \
5632                         use DROP COLUMN ... CASCADE to remove them"
5633                    )));
5634                }
5635                // CASCADE the FK removals first.
5636                if cascade {
5637                    // Drop in reverse so indices stay valid.
5638                    let mut sorted = dependent_fks.clone();
5639                    sorted.sort();
5640                    sorted.reverse();
5641                    let fks = &mut table.schema_mut().foreign_keys;
5642                    for i in sorted {
5643                        fks.remove(i);
5644                    }
5645                }
5646                // Drop the column. New helper on Table does the
5647                // row + schema + index shift atomically.
5648                table.drop_column(col_pos);
5649            }
5650            spg_sql::ast::AlterTableTarget::SetTriggerEnabled { which, enabled } => {
5651                // v7.16.1 — mailrs round-9 A.2.b. pg_dump
5652                // --disable-triggers wraps each table's data
5653                // block with `ALTER TABLE … DISABLE TRIGGER ALL`
5654                // / `… ENABLE TRIGGER ALL`. Toggle the enabled
5655                // flag on every matching trigger so the row-
5656                // write paths skip them; the catalog snapshot
5657                // persists the new state across restarts.
5658                let table_name = s.name.to_string();
5659                let trigs = self.active_catalog_mut().triggers_mut();
5660                let mut touched = false;
5661                for t in trigs.iter_mut() {
5662                    if !t.table.eq_ignore_ascii_case(&table_name) {
5663                        continue;
5664                    }
5665                    match &which {
5666                        spg_sql::ast::TriggerSelector::All => {
5667                            t.enabled = enabled;
5668                            touched = true;
5669                        }
5670                        spg_sql::ast::TriggerSelector::Named(name) => {
5671                            if t.name.eq_ignore_ascii_case(name) {
5672                                t.enabled = enabled;
5673                                touched = true;
5674                            }
5675                        }
5676                    }
5677                }
5678                // PG semantics: `ALL` on a table with no
5679                // triggers is a no-op (no error). A `Named`
5680                // form pointing at a non-existent trigger
5681                // raises in PG; v7.16.1 also raises so we
5682                // don't silently lose state.
5683                if !touched {
5684                    if let spg_sql::ast::TriggerSelector::Named(name) = &which {
5685                        return Err(EngineError::Unsupported(alloc::format!(
5686                            "ALTER TABLE {table_name:?} {} TRIGGER {name:?}: no such trigger on table",
5687                            if enabled { "ENABLE" } else { "DISABLE" },
5688                        )));
5689                    }
5690                }
5691            }
5692            spg_sql::ast::AlterTableTarget::RenameTable { new } => {
5693                // v7.16.2 — table-level rename (mailrs round-10
5694                // A.5 — used by migrate-042's `ALTER TABLE
5695                // contacts RENAME TO email_contacts`). Storage
5696                // helper updates the schema + by_name index +
5697                // dangling FK / trigger references in one
5698                // atomic step.
5699                let old = s.name.to_string();
5700                self.active_catalog_mut()
5701                    .rename_table(&old, &new)
5702                    .map_err(EngineError::Storage)?;
5703            }
5704            spg_sql::ast::AlterTableTarget::RenameColumn { old, new } => {
5705                // v7.15.0 — `ALTER TABLE t RENAME [COLUMN] old TO
5706                // new`. Rename the column in the schema; rewrite
5707                // every stored source string on this table that
5708                // references it as a (potentially-qualified)
5709                // column identifier: CHECK predicates, partial-
5710                // index predicates, runtime DEFAULT expressions.
5711                // Then walk catalog triggers on this table and
5712                // patch any `UPDATE OF` column list. Function and
5713                // trigger bodies are NOT auto-rewritten — that
5714                // surface is dynamic SQL territory; users update
5715                // those separately (matches PG plpgsql behavior:
5716                // a column rename invalidates name-referencing
5717                // plpgsql at call time, not rename time).
5718                let table = self.active_catalog_mut().get_mut(s.name).ok_or_else(|| {
5719                    EngineError::Storage(StorageError::TableNotFound {
5720                        name: s.name.into(),
5721                    })
5722                })?;
5723                let col_pos = table
5724                    .schema()
5725                    .columns
5726                    .iter()
5727                    .position(|c| c.name.eq_ignore_ascii_case(&old))
5728                    .ok_or_else(|| {
5729                        EngineError::Unsupported(alloc::format!(
5730                            "ALTER TABLE RENAME COLUMN: column {old:?} not found on {:?}",
5731                            s.name
5732                        ))
5733                    })?;
5734                // Reject same-name (case-insensitive) collision.
5735                if table
5736                    .schema()
5737                    .columns
5738                    .iter()
5739                    .enumerate()
5740                    .any(|(i, c)| i != col_pos && c.name.eq_ignore_ascii_case(&new))
5741                {
5742                    return Err(EngineError::Unsupported(alloc::format!(
5743                        "ALTER TABLE RENAME COLUMN: column {new:?} already exists on {:?}",
5744                        s.name
5745                    )));
5746                }
5747                // Schema rename first — even idempotent same-name
5748                // rename (`ALTER TABLE t RENAME a TO a`) needs to
5749                // be a no-op, not an error.
5750                if old.eq_ignore_ascii_case(&new) {
5751                    return Ok(());
5752                }
5753                table.rename_column(col_pos, &new);
5754                // Rewrite per-column runtime_default sources on
5755                // every column of this table — a DEFAULT expression
5756                // on column X may reference column Y by name (rare,
5757                // but legal in PG when the value is supplied via a
5758                // function that takes the row).
5759                let n_cols = table.schema().columns.len();
5760                for i in 0..n_cols {
5761                    let rt = table.schema().columns[i].runtime_default.clone();
5762                    if let Some(src) = rt {
5763                        let rewritten = rewrite_column_in_source(&src, &old, &new)?;
5764                        table.schema_mut().columns[i].runtime_default = Some(rewritten);
5765                    }
5766                }
5767                // Rewrite table-level CHECK predicates.
5768                let checks = table.schema().checks.clone();
5769                let mut new_checks = Vec::with_capacity(checks.len());
5770                for chk in checks {
5771                    new_checks.push(rewrite_column_in_source(&chk, &old, &new)?);
5772                }
5773                table.schema_mut().checks = new_checks;
5774                // Rewrite per-index partial_predicate sources.
5775                let n_idx = table.indices().len();
5776                for i in 0..n_idx {
5777                    let pred = table.indices()[i].partial_predicate.clone();
5778                    if let Some(src) = pred {
5779                        let rewritten = rewrite_column_in_source(&src, &old, &new)?;
5780                        // SAFETY: indices_mut would be cleanest, but
5781                        // partial_predicate is the only mutable field
5782                        // here; reach in via the public mut accessor.
5783                        table.set_partial_predicate(i, Some(rewritten));
5784                    }
5785                }
5786                // Walk catalog triggers; patch `update_columns` on
5787                // triggers attached to this table.
5788                let table_name = s.name.to_string();
5789                for trig in self.active_catalog_mut().triggers_mut() {
5790                    if !trig.table.eq_ignore_ascii_case(&table_name) {
5791                        continue;
5792                    }
5793                    for c in &mut trig.update_columns {
5794                        if c.eq_ignore_ascii_case(&old) {
5795                            *c = new.clone();
5796                        }
5797                    }
5798                }
5799            }
5800        }
5801        Ok(())
5802    }
5803
5804    fn exec_alter_index(
5805        &mut self,
5806        stmt: spg_sql::ast::AlterIndexStatement,
5807    ) -> Result<QueryResult, EngineError> {
5808        // Translate the optional SQL-side encoding choice into the
5809        // storage-side enum; the same SqlVecEncoding -> VecEncoding
5810        // bridge `column_type_to_data_type` uses.
5811        let spg_sql::ast::AlterIndexStatement {
5812            name: idx_name,
5813            target,
5814        } = stmt;
5815        // v7.16.2 — RENAME TO branch (mailrs round-10 migrate-042).
5816        // IF EXISTS makes a missing index a no-op rather than an
5817        // error, mirroring PG semantics.
5818        if let spg_sql::ast::AlterIndexTarget::Rename { new, if_exists } = target {
5819            let renamed = self.active_catalog_mut().rename_index(&idx_name, &new);
5820            return match renamed {
5821                Ok(()) => Ok(QueryResult::CommandOk {
5822                    affected: 0,
5823                    modified_catalog: !self.in_transaction(),
5824                }),
5825                Err(StorageError::IndexNotFound { .. }) if if_exists => {
5826                    Ok(QueryResult::CommandOk {
5827                        affected: 0,
5828                        modified_catalog: false,
5829                    })
5830                }
5831                Err(e) => Err(EngineError::Storage(e)),
5832            };
5833        }
5834        let spg_sql::ast::AlterIndexTarget::Rebuild { encoding } = target else {
5835            unreachable!("Rename branch returned above");
5836        };
5837        let target = encoding.map(|e| match e {
5838            SqlVecEncoding::F32 => VecEncoding::F32,
5839            SqlVecEncoding::Sq8 => VecEncoding::Sq8,
5840            SqlVecEncoding::F16 => VecEncoding::F16,
5841        });
5842        // Linear scan: index names are globally unique within a
5843        // catalog (enforced by add_nsw_index_inner) so the first
5844        // match is the only one. Save the table name to avoid
5845        // borrowing while we then take a mut borrow.
5846        let table_name = {
5847            let cat = self.active_catalog();
5848            let mut found: Option<String> = None;
5849            for tname in cat.table_names() {
5850                if let Some(t) = cat.get(&tname)
5851                    && t.indices().iter().any(|i| i.name == idx_name)
5852                {
5853                    found = Some(tname);
5854                    break;
5855                }
5856            }
5857            found.ok_or_else(|| {
5858                EngineError::Storage(StorageError::IndexNotFound {
5859                    name: idx_name.clone(),
5860                })
5861            })?
5862        };
5863        let table = self
5864            .active_catalog_mut()
5865            .get_mut(&table_name)
5866            .expect("table found above");
5867        table.rebuild_nsw_index(&idx_name, target)?;
5868        // v6.3.1 — ALTER INDEX REBUILD potentially with new encoding
5869        // changes cost characteristics; evict any cached plans.
5870        self.plan_cache.evict_referencing(&table_name);
5871        Ok(QueryResult::CommandOk {
5872            affected: 0,
5873            modified_catalog: !self.in_transaction(),
5874        })
5875    }
5876
5877    fn exec_create_index(
5878        &mut self,
5879        stmt: CreateIndexStatement,
5880    ) -> Result<QueryResult, EngineError> {
5881        let table = self
5882            .active_catalog_mut()
5883            .get_mut(&stmt.table)
5884            .ok_or_else(|| {
5885                EngineError::Storage(StorageError::TableNotFound {
5886                    name: stmt.table.clone(),
5887                })
5888            })?;
5889        // `IF NOT EXISTS` reduces DuplicateIndex to a no-op CommandOk.
5890        if stmt.if_not_exists && table.indices().iter().any(|i| i.name == stmt.name) {
5891            return Ok(QueryResult::CommandOk {
5892                affected: 0,
5893                modified_catalog: false,
5894            });
5895        }
5896        // v7.9.14 — multi-column index parses through; engine
5897        // builds a single-column BTree on the leading column only.
5898        // The extras live on the AST so spg-server's dispatcher
5899        // can emit a PG-wire NoticeResponse / log line. Composite
5900        // BTree keys land in v7.10.
5901        let _ = &stmt.extra_columns; // intentional drop on engine side
5902        let table_name = stmt.table.clone();
5903        // v6.8.0 — resolve INCLUDE column names to positions. Done
5904        // before `add_index` so a typo error surfaces before any
5905        // catalog mutation lands.
5906        let included_positions: Vec<usize> = if stmt.included_columns.is_empty() {
5907            Vec::new()
5908        } else {
5909            let schema = table.schema();
5910            stmt.included_columns
5911                .iter()
5912                .map(|c| {
5913                    schema.column_position(c).ok_or_else(|| {
5914                        EngineError::Storage(StorageError::ColumnNotFound { column: c.clone() })
5915                    })
5916                })
5917                .collect::<Result<Vec<_>, _>>()?
5918        };
5919        match stmt.method {
5920            IndexMethod::BTree => table.add_index(stmt.name.clone(), &stmt.column)?,
5921            IndexMethod::Hnsw => {
5922                if !included_positions.is_empty() {
5923                    return Err(EngineError::Unsupported(
5924                        "INCLUDE columns are not supported on HNSW indexes".into(),
5925                    ));
5926                }
5927                table.add_nsw_index(stmt.name.clone(), &stmt.column, spg_storage::NSW_DEFAULT_M)?;
5928            }
5929            // v6.7.1 — BRIN. Pure metadata; no in-memory data.
5930            IndexMethod::Brin => {
5931                if !included_positions.is_empty() {
5932                    return Err(EngineError::Unsupported(
5933                        "INCLUDE columns are not supported on BRIN indexes".into(),
5934                    ));
5935                }
5936                table.add_brin_index(stmt.name.clone(), &stmt.column)?;
5937            }
5938            // v7.12.3 — GIN inverted index. Real posting-list-backed
5939            // GIN when the indexed column is `tsvector`; falls back
5940            // to a BTree on the leading column for any other column
5941            // type so v7.9.26b's `pg_dump` compatibility (GIN on
5942            // JSONB etc. silently loading as BTree) is preserved.
5943            // Operators see the real GIN only where it matters; old
5944            // schemas keep loading.
5945            IndexMethod::Gin => {
5946                if !included_positions.is_empty() {
5947                    return Err(EngineError::Unsupported(
5948                        "INCLUDE columns are not supported on GIN indexes".into(),
5949                    ));
5950                }
5951                let col_pos = table
5952                    .schema()
5953                    .column_position(&stmt.column)
5954                    .ok_or_else(|| {
5955                        EngineError::Storage(StorageError::ColumnNotFound {
5956                            column: stmt.column.clone(),
5957                        })
5958                    })?;
5959                let col_ty = table.schema().columns[col_pos].ty;
5960                // v7.15.0 — `gin_trgm_ops` on a TEXT/VARCHAR
5961                // column dispatches to the real trigram-shingle
5962                // GIN build (LIKE / similarity acceleration).
5963                // Other GIN opclasses fall through to the regular
5964                // tsvector-vs-BTree split below.
5965                let is_trgm = stmt
5966                    .opclass
5967                    .as_deref()
5968                    .is_some_and(|op| op.eq_ignore_ascii_case("gin_trgm_ops"));
5969                if is_trgm
5970                    && matches!(
5971                        col_ty,
5972                        spg_storage::DataType::Text | spg_storage::DataType::Varchar(_)
5973                    )
5974                {
5975                    table
5976                        .add_gin_trgm_index(stmt.name.clone(), &stmt.column)
5977                        .map_err(EngineError::Storage)?;
5978                } else if col_ty == spg_storage::DataType::TsVector {
5979                    table
5980                        .add_gin_index(stmt.name.clone(), &stmt.column)
5981                        .map_err(EngineError::Storage)?;
5982                } else {
5983                    // v7.9.26b BTree fallback — the catalog still
5984                    // gets an index entry on the leading column so
5985                    // pg_dump scripts that name GIN on JSONB / etc.
5986                    // load clean; query-time gain stays opt-in for
5987                    // tsvector callers.
5988                    table.add_index(stmt.name.clone(), &stmt.column)?;
5989                }
5990            }
5991        }
5992        if !included_positions.is_empty()
5993            && let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name)
5994        {
5995            idx.included_columns = included_positions;
5996        }
5997        // v6.8.1 — persist partial-index predicate. Stored as the
5998        // expression's Display form so the catalog snapshot stays
5999        // pure (storage has no spg-sql dependency). The runtime
6000        // maintenance path treats partial indexes identically to
6001        // full indexes for v6.8.1 (over-maintenance is safe; the
6002        // planner-side "use partial when query WHERE implies the
6003        // predicate" pass is STABILITY carve-out).
6004        if let Some(pred_expr) = &stmt.partial_predicate {
6005            let canonical = pred_expr.to_string();
6006            // v7.13.2 — mailrs round-6 S2. PG's `pg_trgm` uses
6007            // `CREATE INDEX … USING gin(col gin_trgm_ops) WHERE …`
6008            // routinely to slim trigram indexes. SPG now persists
6009            // the predicate for GIN / BRIN / HNSW the same way it
6010            // already does for BTree — same v6.8.1 "over-maintain
6011            // is safe; planner-side partial routing is STABILITY
6012            // carve-out" semantics. HNSW carries an additional
6013            // caveat: the predicate isn't applied at index build
6014            // time (would require per-row eval inside the NSW
6015            // construction loop), so the index oversamples; query
6016            // time the WHERE clause still filters correctly.
6017            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6018                idx.partial_predicate = Some(canonical);
6019            }
6020        }
6021        // v6.8.2 — persist expression index key. Same Display-form
6022        // storage; the runtime maintenance pass evaluates each
6023        // row's expression to derive the index key, but for v6.8.2
6024        // the engine falls through to the bare-column-reference
6025        // path and the expression is preserved for format-layer
6026        // round-trip + future planner work. Carved-out in
6027        // STABILITY § "Out of v6.8".
6028        if let Some(key_expr) = &stmt.expression {
6029            if matches!(
6030                stmt.method,
6031                IndexMethod::Hnsw | IndexMethod::Brin | IndexMethod::Gin
6032            ) {
6033                return Err(EngineError::Unsupported(
6034                    "Expression keys are not supported on HNSW or BRIN indexes".into(),
6035                ));
6036            }
6037            let canonical = key_expr.to_string();
6038            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6039                idx.expression = Some(canonical);
6040            }
6041        }
6042        // v7.9.29 — persist `is_unique` flag on the storage Index.
6043        // Combined with `partial_predicate`, INSERT enforcement
6044        // checks that no other row whose predicate evaluates true
6045        // shares the same indexed key. Parser already rejected
6046        // `UNIQUE` on HNSW / BRIN, so plain BTree here.
6047        // For multi-column UNIQUE INDEX the extras matter (the
6048        // full tuple is the uniqueness key), so resolve them to
6049        // column positions and persist on the index too.
6050        if stmt.is_unique {
6051            let mut extra_positions: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
6052            for col_name in &stmt.extra_columns {
6053                let pos = table
6054                    .schema()
6055                    .columns
6056                    .iter()
6057                    .position(|c| c.name.eq_ignore_ascii_case(col_name))
6058                    .ok_or_else(|| {
6059                        EngineError::Unsupported(alloc::format!(
6060                            "UNIQUE INDEX {:?}: extra column {col_name:?} not in table {:?}",
6061                            stmt.name,
6062                            stmt.table
6063                        ))
6064                    })?;
6065                extra_positions.push(pos);
6066            }
6067            if let Some(idx) = table.indices_mut().iter_mut().find(|i| i.name == stmt.name) {
6068                idx.is_unique = true;
6069                idx.extra_column_positions = extra_positions;
6070            }
6071            // At index-creation time, check the existing rows for
6072            // pre-existing duplicates that would have violated the
6073            // new constraint — otherwise CREATE UNIQUE INDEX would
6074            // silently leave duplicates in place.
6075            let snapshot_indices = table.indices().to_vec();
6076            let snapshot_rows: alloc::vec::Vec<spg_storage::Row> =
6077                table.rows().iter().cloned().collect();
6078            let snapshot_schema = table.schema().clone();
6079            let idx_ref = snapshot_indices
6080                .iter()
6081                .find(|i| i.name == stmt.name)
6082                .expect("just-added index");
6083            check_existing_unique_violation(idx_ref, &snapshot_schema, &snapshot_rows)?;
6084        }
6085        // v6.3.1 — adding an index can change the optimal plan for
6086        // any cached query that references this table.
6087        self.plan_cache.evict_referencing(&table_name);
6088        Ok(QueryResult::CommandOk {
6089            affected: 0,
6090            modified_catalog: !self.in_transaction(),
6091        })
6092    }
6093
6094    /// v7.13.3 — mailrs round-7 S9. SPG-specific reconciliation
6095    /// for `CREATE TABLE IF NOT EXISTS` when the table already
6096    /// exists. Adds missing columns + inline FKs from the new
6097    /// definition; existing columns / constraints stay untouched.
6098    /// New columns with a `NOT NULL` declaration without a
6099    /// `DEFAULT` are reported as a clear error rather than
6100    /// silently dropped — this is the "fail loud on real
6101    /// incompatibility, fail silent on schema-superset" tradeoff.
6102    fn reconcile_table_if_not_exists(
6103        &mut self,
6104        stmt: CreateTableStatement,
6105    ) -> Result<QueryResult, EngineError> {
6106        let table_name = stmt.name.clone();
6107        let clock = self.clock;
6108        let existing_col_names: alloc::collections::BTreeSet<String> = self
6109            .active_catalog()
6110            .get(&table_name)
6111            .expect("checked above")
6112            .schema()
6113            .columns
6114            .iter()
6115            .map(|c| c.name.to_ascii_lowercase())
6116            .collect();
6117        let row_count = self
6118            .active_catalog()
6119            .get(&table_name)
6120            .expect("checked above")
6121            .row_count();
6122        // Collect missing column defs in source order.
6123        let new_columns: alloc::vec::Vec<spg_sql::ast::ColumnDef> = stmt
6124            .columns
6125            .iter()
6126            .filter(|c| !existing_col_names.contains(&c.name.to_ascii_lowercase()))
6127            .cloned()
6128            .collect();
6129        for col_def in new_columns {
6130            let col_name = col_def.name.clone();
6131            let nullable = col_def.nullable;
6132            let has_default = col_def.default.is_some() || col_def.auto_increment;
6133            let col_schema = column_def_to_schema(col_def)?;
6134            let fill_value: Value = if has_default || col_schema.runtime_default.is_some() {
6135                resolve_column_default_free(&col_schema, clock)?
6136            } else if nullable || row_count == 0 {
6137                Value::Null
6138            } else {
6139                return Err(EngineError::Unsupported(alloc::format!(
6140                    "CREATE TABLE IF NOT EXISTS {table_name:?}: reconciling \
6141                     column {col_name:?} requires DEFAULT (existing rows would violate NOT NULL)"
6142                )));
6143            };
6144            let table = self
6145                .active_catalog_mut()
6146                .get_mut(&table_name)
6147                .expect("checked above");
6148            table.add_column(col_schema, fill_value);
6149        }
6150        // Resolve any newly-added inline FKs (column-level
6151        // REFERENCES forms) and install. Skip FKs whose local
6152        // columns we didn't have in the existing table.
6153        let table_cols_now = self
6154            .active_catalog()
6155            .get(&table_name)
6156            .expect("checked above")
6157            .schema()
6158            .columns
6159            .clone();
6160        for fk in stmt.foreign_keys {
6161            // Only install FKs whose every local column resolves
6162            // — older catalogs may have a column the new FK
6163            // references but not the column the new FK declares.
6164            let all_resolved = fk.columns.iter().all(|c| {
6165                table_cols_now
6166                    .iter()
6167                    .any(|sc| sc.name.eq_ignore_ascii_case(c))
6168            });
6169            if !all_resolved {
6170                continue;
6171            }
6172            let already_present = {
6173                let table = self
6174                    .active_catalog()
6175                    .get(&table_name)
6176                    .expect("checked above");
6177                table.schema().foreign_keys.iter().any(|f| {
6178                    f.parent_table.eq_ignore_ascii_case(&fk.parent_table)
6179                        && f.local_columns.len() == fk.columns.len()
6180                })
6181            };
6182            if already_present {
6183                continue;
6184            }
6185            let storage_fk =
6186                resolve_foreign_key(&table_name, &table_cols_now, fk, self.active_catalog())?;
6187            let table = self
6188                .active_catalog_mut()
6189                .get_mut(&table_name)
6190                .expect("checked above");
6191            table.schema_mut().foreign_keys.push(storage_fk);
6192        }
6193        Ok(QueryResult::CommandOk {
6194            affected: 0,
6195            modified_catalog: !self.in_transaction(),
6196        })
6197    }
6198
6199    /// v7.14.0 — DROP TABLE handler (pg_dump / mysqldump preamble).
6200    fn exec_drop_table(
6201        &mut self,
6202        names: Vec<String>,
6203        if_exists: bool,
6204    ) -> Result<QueryResult, EngineError> {
6205        for name in names {
6206            let dropped = self.active_catalog_mut().drop_table(&name);
6207            if !dropped && !if_exists {
6208                return Err(EngineError::Storage(StorageError::TableNotFound { name }));
6209            }
6210        }
6211        Ok(QueryResult::CommandOk {
6212            affected: 0,
6213            modified_catalog: !self.in_transaction(),
6214        })
6215    }
6216
6217    /// v7.14.0 — DROP INDEX handler.
6218    fn exec_drop_index(
6219        &mut self,
6220        name: String,
6221        if_exists: bool,
6222    ) -> Result<QueryResult, EngineError> {
6223        let dropped = self.active_catalog_mut().drop_named_index(&name);
6224        if !dropped && !if_exists {
6225            return Err(EngineError::Storage(StorageError::IndexNotFound { name }));
6226        }
6227        Ok(QueryResult::CommandOk {
6228            affected: 0,
6229            modified_catalog: !self.in_transaction(),
6230        })
6231    }
6232
6233    fn exec_create_table(
6234        &mut self,
6235        stmt: CreateTableStatement,
6236    ) -> Result<QueryResult, EngineError> {
6237        if stmt.if_not_exists && self.active_catalog().get(&stmt.name).is_some() {
6238            // v7.16.2 — PG-strict silent no-op (mailrs round-10
6239            // surfaced this). v7.13.3's "reconcile by adding
6240            // missing columns" was friendly for mailrs round-7
6241            // where init-schema's `contacts` and migrate-023's
6242            // CardDAV `contacts` collided; but it ALSO silently
6243            // added columns to existing tables when later
6244            // migrations had a duplicate `CREATE TABLE IF NOT
6245            // EXISTS <t> (different-shape-cols)` shape. mailrs's
6246            // migrate-030 has exactly that — re-declares
6247            // system_config with `key` even though init-schema
6248            // already created it with `config_key`. PG's silent
6249            // no-op leaves system_config at `config_key`;
6250            // v7.13.3 added a phantom `key` column that then
6251            // tripped migrate-040's idempotent rename guard.
6252            // mailrs v1.7.106 ships the proper PG-style
6253            // contacts rename via DO + IF EXISTS, so SPG can
6254            // revert to PG-strict here without re-breaking the
6255            // round-7 case.
6256            return Ok(QueryResult::CommandOk {
6257                affected: 0,
6258                modified_catalog: false,
6259            });
6260        }
6261        let table_name = stmt.name.clone();
6262        // v7.9.13 — pluck the names of any columns marked
6263        // `PRIMARY KEY` inline so the post-create-table pass can
6264        // build an implicit BTree index. mailrs F1.
6265        let inline_pk_columns: Vec<String> = stmt
6266            .columns
6267            .iter()
6268            .filter(|c| c.is_primary_key)
6269            .map(|c| c.name.clone())
6270            .collect();
6271        // v7.9.19 — table-level constraints: PRIMARY KEY (a, b, ...)
6272        // and UNIQUE (a, b, ...). Each builds a BTree index on the
6273        // leading column (the existing single-column storage tier)
6274        // and registers a UniquenessConstraint on the schema for
6275        // INSERT-time enforcement of the full tuple. mailrs G1/G6.
6276        let cols = stmt
6277            .columns
6278            .into_iter()
6279            .map(column_def_to_schema)
6280            .collect::<Result<Vec<_>, _>>()?;
6281        // v7.17.0 Phase 1.4 + 1.5 — classify every raw
6282        // user_type_ref (parked as user_enum_type by
6283        // column_def_to_schema) into either an enum binding or a
6284        // domain binding. For domains, also rewrite the column's
6285        // base DataType from the placeholder Text to the domain's
6286        // declared base. Unknown idents are still a hard error
6287        // here (same as Phase 1.4) so silent acceptance never
6288        // happens.
6289        let mut cols = cols;
6290        for col in cols.iter_mut() {
6291            let Some(name) = col.user_enum_type.take() else {
6292                continue;
6293            };
6294            let cat = self.active_catalog();
6295            if cat.enum_types().contains_key(&name) {
6296                col.user_enum_type = Some(name);
6297                continue;
6298            }
6299            if let Some(dom) = cat.domain_types().get(&name) {
6300                col.ty = dom.base_type;
6301                col.user_domain_type = Some(name);
6302                if !dom.nullable {
6303                    col.nullable = false;
6304                }
6305                continue;
6306            }
6307            return Err(EngineError::Unsupported(alloc::format!(
6308                "column {:?}: unknown column type {:?} (not a built-in, ENUM, or DOMAIN)",
6309                col.name,
6310                name
6311            )));
6312        }
6313        for tc in &stmt.table_constraints {
6314            if let spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } = tc {
6315                for col_name in columns {
6316                    if let Some(col) = cols.iter_mut().find(|c| c.name == *col_name) {
6317                        col.nullable = false;
6318                    }
6319                }
6320            }
6321        }
6322        // v7.6.1 — resolve every FK in the statement against the
6323        // already-known catalog. Validates: parent table exists,
6324        // parent column names exist, arity matches, parent columns
6325        // have a PK / UNIQUE index. Self-referencing FKs (parent
6326        // table == this table) resolve against the column list we
6327        // just built — they don't need the catalog yet.
6328        let mut fks: Vec<spg_storage::ForeignKeyConstraint> =
6329            Vec::with_capacity(stmt.foreign_keys.len());
6330        for fk in stmt.foreign_keys {
6331            // v7.14.0 — when SET FOREIGN_KEY_CHECKS=0 is in effect
6332            // (mysqldump preamble + bulk imports), defer FK
6333            // resolution if the parent table isn't in the catalog
6334            // yet. The FK is queued and resolved when checks flip
6335            // back on. Self-references stay in-band (the parent is
6336            // the same as the child we're building).
6337            let needs_parent = !fk.parent_table.eq_ignore_ascii_case(&table_name);
6338            if !self.foreign_key_checks
6339                && needs_parent
6340                && self.active_catalog().get(&fk.parent_table).is_none()
6341            {
6342                self.pending_foreign_keys.push((table_name.clone(), fk));
6343                continue;
6344            }
6345            fks.push(resolve_foreign_key(
6346                &table_name,
6347                &cols,
6348                fk,
6349                self.active_catalog(),
6350            )?);
6351        }
6352        let mut schema = TableSchema::new(table_name.clone(), cols);
6353        schema.foreign_keys = fks;
6354        // v7.9.19 — translate AST table_constraints to storage
6355        // UniquenessConstraints (column name → position) so the
6356        // INSERT enforcement helper sees positions directly.
6357        let mut uc_storage: Vec<spg_storage::UniquenessConstraint> = Vec::new();
6358        let mut check_exprs: Vec<String> = Vec::new();
6359        for tc in &stmt.table_constraints {
6360            let (is_pk, names, nnd) = match tc {
6361                spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
6362                    (true, columns.clone(), false)
6363                }
6364                spg_sql::ast::TableConstraint::Unique {
6365                    columns,
6366                    nulls_not_distinct,
6367                    ..
6368                } => (false, columns.clone(), *nulls_not_distinct),
6369                spg_sql::ast::TableConstraint::Check { expr, .. } => {
6370                    // v7.13.0 — collect CHECK predicate sources;
6371                    // they get attached to the schema below.
6372                    check_exprs.push(alloc::format!("{expr}"));
6373                    continue;
6374                }
6375                // v7.15.0 — plain `KEY (cols)` from MySQL inline
6376                // is NOT a uniqueness constraint; skip the UC
6377                // build path entirely. The BTree index lands in
6378                // the post-create loop below alongside the PK/UQ
6379                // implicit indexes.
6380                spg_sql::ast::TableConstraint::Index { .. } => continue,
6381                // v7.17.0 Phase 2.2 — MySQL FULLTEXT KEY is not
6382                // a uniqueness constraint either; its GIN gets
6383                // built in the post-create loop below.
6384                spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
6385            };
6386            let mut positions = Vec::with_capacity(names.len());
6387            for n in &names {
6388                let pos = schema
6389                    .columns
6390                    .iter()
6391                    .position(|c| c.name == *n)
6392                    .ok_or_else(|| {
6393                        EngineError::Unsupported(alloc::format!(
6394                            "table constraint references unknown column {n:?}"
6395                        ))
6396                    })?;
6397                positions.push(pos);
6398            }
6399            uc_storage.push(spg_storage::UniquenessConstraint {
6400                is_primary_key: is_pk,
6401                columns: positions,
6402                nulls_not_distinct: nnd,
6403            });
6404        }
6405        schema.uniqueness_constraints = uc_storage.clone();
6406        schema.checks = check_exprs;
6407        self.active_catalog_mut().create_table(schema)?;
6408        // v7.9.13 — implicit BTree per inline PK column +
6409        // v7.9.19 — implicit BTree on the leading column of every
6410        // table-level PRIMARY KEY / UNIQUE constraint.
6411        let table = self
6412            .active_catalog_mut()
6413            .get_mut(&table_name)
6414            .expect("just created");
6415        for (i, col_name) in inline_pk_columns.iter().enumerate() {
6416            let idx_name = if inline_pk_columns.len() == 1 {
6417                alloc::format!("{table_name}_pkey")
6418            } else {
6419                alloc::format!("{table_name}_pkey_{i}")
6420            };
6421            if let Err(e) = table.add_index(idx_name, col_name) {
6422                return Err(EngineError::Storage(e));
6423            }
6424        }
6425        for (i, tc) in stmt.table_constraints.iter().enumerate() {
6426            // v7.17.0 Phase 2.2 — FULLTEXT KEY lands a real
6427            // tsvector-GIN per declared column instead of the
6428            // BTree the PK / UQ / KEY paths build. Branch early
6429            // so the BTree loop never sees the FULLTEXT shape.
6430            if let spg_sql::ast::TableConstraint::FulltextIndex { name, columns } = tc {
6431                for (k, col) in columns.iter().enumerate() {
6432                    let already = table.indices().iter().any(|idx| {
6433                        matches!(idx.kind, spg_storage::IndexKind::GinFulltext(_))
6434                            && table.schema().columns[idx.column_position].name == *col
6435                    });
6436                    if already {
6437                        continue;
6438                    }
6439                    let idx_name = match (name.as_ref(), columns.len(), k) {
6440                        (Some(n), 1, _) => n.clone(),
6441                        (Some(n), _, k) => alloc::format!("{n}_{k}"),
6442                        (None, _, _) => {
6443                            alloc::format!("{table_name}_{col}_ftidx")
6444                        }
6445                    };
6446                    if let Err(e) = table.add_gin_fulltext_index(idx_name, col) {
6447                        return Err(EngineError::Storage(e));
6448                    }
6449                }
6450                continue;
6451            }
6452            // v7.15.0 — plain KEY/INDEX rides this same loop so
6453            // the implicit BTree gets built. It carries its own
6454            // user-supplied name; PK/UQ still synthesise.
6455            let (suffix, names, explicit_name): (&str, &Vec<String>, Option<&String>) = match tc {
6456                spg_sql::ast::TableConstraint::PrimaryKey { columns, .. } => {
6457                    ("pkey", columns, None)
6458                }
6459                spg_sql::ast::TableConstraint::Unique { columns, .. } => ("key", columns, None),
6460                spg_sql::ast::TableConstraint::Index { name, columns } => {
6461                    ("idx", columns, name.as_ref())
6462                }
6463                spg_sql::ast::TableConstraint::Check { .. } => continue,
6464                // Handled by the early-branch above.
6465                spg_sql::ast::TableConstraint::FulltextIndex { .. } => continue,
6466            };
6467            let leading = &names[0];
6468            // Skip if a same-column BTree already exists (e.g.
6469            // inline PK on the leading column).
6470            let already = table.indices().iter().any(|idx| {
6471                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
6472                    && table.schema().columns[idx.column_position].name == *leading
6473            });
6474            if already {
6475                continue;
6476            }
6477            let idx_name = if let Some(n) = explicit_name {
6478                n.clone()
6479            } else if names.len() == 1 {
6480                alloc::format!("{table_name}_{leading}_{suffix}")
6481            } else {
6482                alloc::format!("{table_name}_{leading}_{suffix}_{i}")
6483            };
6484            if let Err(e) = table.add_index(idx_name, leading) {
6485                return Err(EngineError::Storage(e));
6486            }
6487        }
6488        Ok(QueryResult::CommandOk {
6489            affected: 0,
6490            modified_catalog: !self.in_transaction(),
6491        })
6492    }
6493
6494    fn exec_insert(&mut self, mut stmt: InsertStatement) -> Result<QueryResult, EngineError> {
6495        // v7.17.0 Phase 1.1 — pre-resolve any nextval / currval /
6496        // setval calls against the catalog before the row loop. We
6497        // walk each tuple expression and replace matching
6498        // FunctionCall nodes with their concrete Literal. This
6499        // keeps `literal_expr_to_value` free of `&mut self` and
6500        // lets multi-row INSERT VALUES (… nextval('seq') …)
6501        // mint a separate sequence value per row.
6502        for tuple in &mut stmt.rows {
6503            for cell in tuple.iter_mut() {
6504                self.resolve_sequence_calls_in_expr(cell)?;
6505            }
6506        }
6507        // v7.13.0 — `INSERT INTO t [(cols)] SELECT …` (mailrs
6508        // round-5 G4). Execute the inner SELECT first, then route
6509        // back through the regular VALUES code path with the
6510        // materialised rows.
6511        if let Some(select) = stmt.select_source.clone() {
6512            let select_result = self.exec_select_cancel(&select, CancelToken::none())?;
6513            let rows = match select_result {
6514                QueryResult::Rows { rows, .. } => rows,
6515                other => {
6516                    return Err(EngineError::Unsupported(alloc::format!(
6517                        "INSERT … SELECT: inner statement produced {other:?} instead of a row set"
6518                    )));
6519                }
6520            };
6521            let mut materialised: Vec<Vec<Expr>> = Vec::with_capacity(rows.len());
6522            for row in rows {
6523                let mut tuple: Vec<Expr> = Vec::with_capacity(row.values.len());
6524                for v in row.values {
6525                    tuple.push(value_to_literal_expr_permissive(v)?);
6526                }
6527                materialised.push(tuple);
6528            }
6529            let recurse = InsertStatement {
6530                table: stmt.table,
6531                columns: stmt.columns,
6532                rows: materialised,
6533                select_source: None,
6534                on_conflict: stmt.on_conflict,
6535                returning: stmt.returning,
6536            };
6537            return self.exec_insert(recurse);
6538        }
6539        // v7.9.21 — snapshot the clock fn pointer before the mut
6540        // borrow on the catalog opens; runtime DEFAULT eval needs
6541        // it inside the row hot loop.
6542        let clock = self.clock;
6543        // v7.12.4 — snapshot row-level triggers + their referenced
6544        // functions before the mut borrow on the catalog opens.
6545        // Cloned out so the row hot loop can fire them without
6546        // re-borrowing the catalog (which would conflict with
6547        // table.insert's mutable borrow).
6548        let before_insert_triggers = self.snapshot_row_triggers(&stmt.table, "INSERT", "BEFORE");
6549        let after_insert_triggers = self.snapshot_row_triggers(&stmt.table, "INSERT", "AFTER");
6550        let trigger_session_cfg: Option<alloc::string::String> = self
6551            .session_params
6552            .get("default_text_search_config")
6553            .cloned();
6554        // v7.17.0 Phase 1.4 — snapshot the enum label lookup BEFORE
6555        // opening the mutable borrow on the table below. We need
6556        // catalog-level read access (enum_types lives at the
6557        // catalog level, not the table) and the upcoming mutable
6558        // borrow shadows it.
6559        let pre_borrow_column_meta: Vec<ColumnSchema> = {
6560            let preview_table = self.active_catalog().get(&stmt.table).ok_or_else(|| {
6561                EngineError::Storage(StorageError::TableNotFound {
6562                    name: stmt.table.clone(),
6563                })
6564            })?;
6565            preview_table.schema().columns.clone()
6566        };
6567        let enum_label_lookup: alloc::collections::BTreeMap<usize, Vec<String>> =
6568            pre_borrow_column_meta
6569                .iter()
6570                .enumerate()
6571                .filter_map(|(i, col)| {
6572                    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM
6573                    // variant lists take priority over the PG
6574                    // catalog enum_types lookup (they're
6575                    // column-local and authoritative when set).
6576                    if let Some(inline) = &col.inline_enum_variants {
6577                        return Some((i, inline.clone()));
6578                    }
6579                    col.user_enum_type.as_ref().and_then(|ename| {
6580                        self.active_catalog()
6581                            .enum_types()
6582                            .get(ename)
6583                            .map(|e| (i, e.labels.clone()))
6584                    })
6585                })
6586                .collect();
6587        // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant lists.
6588        // Distinct from enum_label_lookup: SET validates that
6589        // every comma-separated token is in the variant list, and
6590        // canonicalises the cell to definition-order de-duped text.
6591        let set_variant_lookup: alloc::collections::BTreeMap<usize, Vec<String>> =
6592            pre_borrow_column_meta
6593                .iter()
6594                .enumerate()
6595                .filter_map(|(i, col)| col.inline_set_variants.as_ref().map(|vs| (i, vs.clone())))
6596                .collect();
6597        let table = self
6598            .active_catalog_mut()
6599            .get_mut(&stmt.table)
6600            .ok_or_else(|| {
6601                EngineError::Storage(StorageError::TableNotFound {
6602                    name: stmt.table.clone(),
6603                })
6604            })?;
6605        // v3.1.5: clone the columns vector only (not the whole
6606        // TableSchema — saves one String alloc for the table name).
6607        // We need an owned snapshot because we'll call `table.insert`
6608        // (mutable borrow on `table`) inside the row loop while
6609        // reading schema fields.
6610        let column_meta: Vec<ColumnSchema> = table.schema().columns.clone();
6611        let schema_cols_len = column_meta.len();
6612        // Build a permutation `tuple_pos[c] = Some(j)` meaning schema
6613        // column `c` is filled from the `j`-th tuple slot; `None` means
6614        // "fill with NULL". Validated once and reused for every row.
6615        let tuple_pos: Option<Vec<Option<usize>>> = match &stmt.columns {
6616            None => None, // 1-1 mapping, fast path
6617            Some(cols) => {
6618                let mut map = alloc::vec![None; schema_cols_len];
6619                for (j, name) in cols.iter().enumerate() {
6620                    let idx = column_meta
6621                        .iter()
6622                        .position(|c| c.name == *name)
6623                        .ok_or_else(|| {
6624                            EngineError::Eval(EvalError::ColumnNotFound { name: name.clone() })
6625                        })?;
6626                    if map[idx].is_some() {
6627                        return Err(EngineError::Storage(StorageError::ArityMismatch {
6628                            expected: schema_cols_len,
6629                            actual: cols.len(),
6630                        }));
6631                    }
6632                    map[idx] = Some(j);
6633                }
6634                // Omitted columns must either be nullable, carry a
6635                // DEFAULT, or be AUTO_INCREMENT. Catch NOT NULL
6636                // omissions up front so the WAL stays clean.
6637                for (i, col) in column_meta.iter().enumerate() {
6638                    if map[i].is_none()
6639                        && !col.nullable
6640                        && col.default.is_none()
6641                        && col.runtime_default.is_none()
6642                        && !col.auto_increment
6643                    {
6644                        return Err(EngineError::Storage(StorageError::NullInNotNull {
6645                            column: col.name.clone(),
6646                        }));
6647                    }
6648                }
6649                Some(map)
6650            }
6651        };
6652        let expected_tuple_len = stmt.columns.as_ref().map_or(schema_cols_len, Vec::len);
6653        // v7.6.2 — snapshot this table's FK list before the
6654        // mutable-borrow window so we can run parent lookups
6655        // against the immutable catalog after parsing. Empty vec is
6656        // the no-FK fast path; clone cost is O(fks * arity) which
6657        // is < 100 ns for typical schemas.
6658        let fks = table.schema().foreign_keys.clone();
6659        let mut affected = 0usize;
6660        // Stage 1 — parse + AUTO_INC + coerce all rows under the
6661        // single mutable borrow.
6662        let mut all_values: Vec<Vec<Value>> = Vec::with_capacity(stmt.rows.len());
6663        for tuple in stmt.rows {
6664            if tuple.len() != expected_tuple_len {
6665                return Err(EngineError::Storage(StorageError::ArityMismatch {
6666                    expected: expected_tuple_len,
6667                    actual: tuple.len(),
6668                }));
6669            }
6670            // Fast path: no column-list permutation → tuple slot j
6671            // maps to schema column j. We can zip schema with tuple
6672            // and skip the `raw_tuple` staging allocation entirely.
6673            let values: Vec<Value> = if let Some(map) = &tuple_pos {
6674                // Permuted path: still need raw_tuple to index by `map[i]`.
6675                let raw_tuple: Vec<Value> = tuple
6676                    .into_iter()
6677                    .map(literal_expr_to_value)
6678                    .collect::<Result<_, _>>()?;
6679                let mut out = Vec::with_capacity(schema_cols_len);
6680                for (i, col) in column_meta.iter().enumerate() {
6681                    let mut raw = match map[i] {
6682                        Some(j) => raw_tuple[j].clone(),
6683                        None => resolve_column_default_free(col, clock)?,
6684                    };
6685                    if col.auto_increment && raw.is_null() {
6686                        let next = table.next_auto_value(i).ok_or_else(|| {
6687                            EngineError::Unsupported(alloc::format!(
6688                                "AUTO_INCREMENT applies to integer columns only (column `{}`)",
6689                                col.name
6690                            ))
6691                        })?;
6692                        raw = Value::BigInt(next);
6693                    }
6694                    let coerced = coerce_value(raw, col.ty, &col.name, i)?;
6695                    enforce_enum_label(&enum_label_lookup, i, &col.name, &coerced)?;
6696                    let coerced =
6697                        canonicalize_set_value(&set_variant_lookup, i, &col.name, coerced)?;
6698                    check_unsigned_range(&coerced, col, i)?;
6699                    out.push(coerced);
6700                }
6701                out
6702            } else {
6703                // 1-1 mapping fast path: single Vec alloc, no raw_tuple.
6704                let mut out = Vec::with_capacity(schema_cols_len);
6705                for (i, (col, expr)) in column_meta.iter().zip(tuple).enumerate() {
6706                    let mut raw = literal_expr_to_value(expr)?;
6707                    if col.auto_increment && raw.is_null() {
6708                        let next = table.next_auto_value(i).ok_or_else(|| {
6709                            EngineError::Unsupported(alloc::format!(
6710                                "AUTO_INCREMENT applies to integer columns only (column `{}`)",
6711                                col.name
6712                            ))
6713                        })?;
6714                        raw = Value::BigInt(next);
6715                    }
6716                    let coerced = coerce_value(raw, col.ty, &col.name, i)?;
6717                    enforce_enum_label(&enum_label_lookup, i, &col.name, &coerced)?;
6718                    let coerced =
6719                        canonicalize_set_value(&set_variant_lookup, i, &col.name, coerced)?;
6720                    check_unsigned_range(&coerced, col, i)?;
6721                    out.push(coerced);
6722                }
6723                out
6724            };
6725            all_values.push(values);
6726        }
6727        // Stage 2 — FK enforcement on the immutable catalog.
6728        // Non-lexical lifetimes release the mutable borrow on
6729        // `table` here since stage 1 was the last use. The
6730        // parent-table lookup runs before any row is committed.
6731        let uniqueness = table.schema().uniqueness_constraints.clone();
6732        let _ = table;
6733        if !fks.is_empty() {
6734            enforce_fk_inserts(self.active_catalog(), &stmt.table, &fks, &all_values)?;
6735        }
6736        // v7.13.0 — CHECK constraint enforcement (mailrs round-5 G3).
6737        enforce_check_constraints(self.active_catalog(), &stmt.table, &all_values)?;
6738        // v7.9.19 — composite UNIQUE / PRIMARY KEY enforcement.
6739        enforce_uniqueness_inserts(self.active_catalog(), &stmt.table, &uniqueness, &all_values)?;
6740        // v7.9.29 — CREATE UNIQUE INDEX [WHERE pred] enforcement.
6741        // Independent of table-level UniquenessConstraint (which
6742        // can't carry a predicate). Walks the table's indexes;
6743        // for each `is_unique` index, only rows whose
6744        // partial_predicate evaluates truthy are checked for
6745        // collision. mailrs K1.
6746        enforce_unique_index_inserts(self.active_catalog(), &stmt.table, &all_values)?;
6747        // v7.9.8 / v7.9.9 — ON CONFLICT handling.
6748        //   - `DO NOTHING` filters `all_values` to non-conflicting
6749        //     rows + drops within-batch duplicates.
6750        //   - `DO UPDATE SET …` ALSO filters, but for each
6751        //     conflicting row it queues an UPDATE on the existing
6752        //     row using the incoming row's values as `EXCLUDED.*`.
6753        let mut pending_updates: Vec<(usize, Vec<Value>)> = Vec::new();
6754        let mut skipped_count = 0usize;
6755        if let Some(clause) = &stmt.on_conflict {
6756            let conflict_cols = resolve_on_conflict_columns(
6757                self.active_catalog(),
6758                &stmt.table,
6759                clause.target_columns.as_slice(),
6760            )?;
6761            let mut kept: Vec<Vec<Value>> = Vec::with_capacity(all_values.len());
6762            let mut seen_keys: Vec<Vec<Value>> = Vec::new();
6763            for values in all_values {
6764                let key_tuple: Vec<&Value> = conflict_cols.iter().map(|&c| &values[c]).collect();
6765                // SQL spec: NULL in any conflict column means "no
6766                // conflict possible" (NULL ≠ NULL for uniqueness).
6767                let has_null_key = key_tuple.iter().any(|v| matches!(v, Value::Null));
6768                let collides_with_table = !has_null_key
6769                    && on_conflict_keys_exist(
6770                        self.active_catalog(),
6771                        &stmt.table,
6772                        &conflict_cols,
6773                        &key_tuple,
6774                    );
6775                let key_tuple_owned: Vec<Value> = key_tuple.iter().map(|v| (*v).clone()).collect();
6776                let collides_with_batch =
6777                    !has_null_key && seen_keys.iter().any(|k| k == &key_tuple_owned);
6778                let collides = collides_with_table || collides_with_batch;
6779                match (&clause.action, collides) {
6780                    (_, false) => {
6781                        seen_keys.push(key_tuple_owned);
6782                        kept.push(values);
6783                    }
6784                    (spg_sql::ast::OnConflictAction::Nothing, true) => {
6785                        skipped_count += 1;
6786                    }
6787                    (
6788                        spg_sql::ast::OnConflictAction::Update {
6789                            assignments,
6790                            where_,
6791                        },
6792                        true,
6793                    ) => {
6794                        if !collides_with_table {
6795                            skipped_count += 1;
6796                            continue;
6797                        }
6798                        let target_pos = lookup_row_position_by_keys(
6799                            self.active_catalog(),
6800                            &stmt.table,
6801                            &conflict_cols,
6802                            &key_tuple,
6803                        )
6804                        .ok_or_else(|| {
6805                            EngineError::Unsupported(
6806                                "ON CONFLICT DO UPDATE: conflict detected but row \
6807                                 position could not be resolved (cold-tier row?)"
6808                                    .into(),
6809                            )
6810                        })?;
6811                        let updated = apply_on_conflict_assignments(
6812                            self.active_catalog(),
6813                            &stmt.table,
6814                            target_pos,
6815                            &values,
6816                            assignments,
6817                            where_.as_ref(),
6818                        )?;
6819                        if let Some(new_row) = updated {
6820                            pending_updates.push((target_pos, new_row));
6821                        } else {
6822                            skipped_count += 1;
6823                        }
6824                    }
6825                }
6826            }
6827            all_values = kept;
6828        }
6829        // Stage 3 — insert all rows under a fresh mutable borrow.
6830        let table = self
6831            .active_catalog_mut()
6832            .get_mut(&stmt.table)
6833            .ok_or_else(|| {
6834                EngineError::Storage(StorageError::TableNotFound {
6835                    name: stmt.table.clone(),
6836                })
6837            })?;
6838        // v7.9.4 — keep RETURNING projection rows separate per
6839        // INSERT and per UPDATE branch so DO UPDATE pushes the new
6840        // post-update state, not the incoming-only values.
6841        let mut returning_rows: Vec<Vec<Value>> = Vec::new();
6842        // v7.12.7 — collect embedded SQL emitted by any trigger
6843        // fire across the row loop; engine drains the queue after
6844        // the table mut borrow drops.
6845        let mut deferred_embedded: Vec<triggers::DeferredEmbeddedStmt> = Vec::new();
6846        'rowloop: for values in all_values {
6847            let mut row = Row::new(values);
6848            // v7.12.4 — BEFORE INSERT row-level triggers. Each
6849            // trigger may rewrite NEW cells (e.g. populate
6850            // `search_vector := to_tsvector(...)`) and may return
6851            // NULL to skip the row entirely.
6852            for fd in &before_insert_triggers {
6853                let (outcome, deferred) = triggers::fire_row_trigger(
6854                    fd,
6855                    Some(row.clone()),
6856                    None,
6857                    &stmt.table,
6858                    &column_meta,
6859                    &[],
6860                    trigger_session_cfg.as_deref(),
6861                    false,
6862                )
6863                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
6864                deferred_embedded.extend(deferred);
6865                match outcome {
6866                    triggers::TriggerOutcome::Row(r) => row = r,
6867                    triggers::TriggerOutcome::Skip => continue 'rowloop,
6868                }
6869            }
6870            if stmt.returning.is_some() {
6871                returning_rows.push(row.values.clone());
6872            }
6873            // v7.12.4 — clone for the AFTER trigger view; insert
6874            // moves the row into the table.
6875            let inserted = row.clone();
6876            table.insert(row)?;
6877            affected += 1;
6878            // v7.12.4 — AFTER INSERT row-level triggers fire post-
6879            // write. Return value is ignored (PG semantics); we
6880            // surface any error from the body up to the caller.
6881            for fd in &after_insert_triggers {
6882                let (_outcome, deferred) = triggers::fire_row_trigger(
6883                    fd,
6884                    Some(inserted.clone()),
6885                    None,
6886                    &stmt.table,
6887                    &column_meta,
6888                    &[],
6889                    trigger_session_cfg.as_deref(),
6890                    true,
6891                )
6892                .map_err(|e| EngineError::Storage(StorageError::Corrupt(alloc::format!("{e}"))))?;
6893                deferred_embedded.extend(deferred);
6894            }
6895        }
6896        // v7.9.9 — apply ON CONFLICT DO UPDATE rewrites collected
6897        // in the conflict-resolution pass. update_row handles
6898        // index maintenance + body re-encoding.
6899        for (pos, new_row) in pending_updates {
6900            if stmt.returning.is_some() {
6901                returning_rows.push(new_row.clone());
6902            }
6903            table.update_row(pos, new_row)?;
6904            affected += 1;
6905        }
6906        let _ = skipped_count;
6907        // v7.12.7 — drop the table mut borrow and drain any
6908        // trigger-emitted embedded SQL queued during this INSERT.
6909        // The borrow has to release first because each deferred
6910        // stmt may UPDATE / INSERT / DELETE the same (or another)
6911        // table — including, in principle, this one.
6912        let _ = table;
6913        self.execute_deferred_trigger_stmts(deferred_embedded, CancelToken::none())?;
6914        // v7.9.4/v7.9.9 — RETURNING streams the rows that ended
6915        // up in the table after this statement (insert or
6916        // post-update on conflict).
6917        if let Some(items) = &stmt.returning {
6918            return self.build_returning_rows(&stmt.table, items, returning_rows);
6919        }
6920        // v6.2.1 — auto-analyze: track per-table modified-row
6921        // counter so the background sweep can decide when to
6922        // re-ANALYZE. Cheap path on the autocommit-wrap hot loop
6923        // — one BTreeMap entry update per INSERT batch.
6924        if !self.in_transaction() && affected > 0 {
6925            self.statistics
6926                .record_modifications(&stmt.table, affected as u64);
6927        }
6928        Ok(QueryResult::CommandOk {
6929            affected,
6930            modified_catalog: !self.in_transaction(),
6931        })
6932    }
6933
6934    /// v4.5: SELECT with cooperative cancellation. The token is
6935    /// honoured between UNION peers and inside the bare-SELECT row
6936    /// loop; HNSW kNN graph walks and the aggregate executor don't
6937    /// honour it yet (deferred — those paths bound their work
6938    /// internally by `LIMIT k` and `GROUP BY` cardinality).
6939    /// v6.10.2 — cold-tier time-travel scan. Resolves the segment
6940    /// by id, decodes each row body against the table's current
6941    /// schema, applies the SELECT's projection + optional WHERE +
6942    /// optional LIMIT, returns a `Rows` result. JOINs / aggregates
6943    /// / ORDER BY are unsupported on this path (STABILITY carve-
6944    /// out); operators wanting them should restore the segment
6945    /// into a regular table first.
6946    fn exec_select_as_of_segment(
6947        &self,
6948        stmt: &SelectStatement,
6949        from: &spg_sql::ast::FromClause,
6950        segment_id: u32,
6951    ) -> Result<QueryResult, EngineError> {
6952        // v6.10.2 scope: no joins, no aggregates, no ORDER BY,
6953        // no GROUP BY / HAVING / UNION / OFFSET / DISTINCT.
6954        if !from.joins.is_empty()
6955            || stmt.group_by.is_some()
6956            || stmt.having.is_some()
6957            || !stmt.unions.is_empty()
6958            || !stmt.order_by.is_empty()
6959            || stmt.offset.is_some()
6960            || stmt.distinct
6961            || aggregate::uses_aggregate(stmt)
6962        {
6963            return Err(EngineError::Unsupported(
6964                "AS OF SEGMENT supports SELECT projection + WHERE + LIMIT only \
6965                 (joins / aggregates / ORDER BY are STABILITY § \"Out of v6.10\")"
6966                    .into(),
6967            ));
6968        }
6969        let table = self
6970            .active_catalog()
6971            .get(&from.primary.name)
6972            .ok_or_else(|| StorageError::TableNotFound {
6973                name: from.primary.name.clone(),
6974            })?;
6975        let schema = table.schema().clone();
6976        let schema_cols = &schema.columns;
6977        let alias = from
6978            .primary
6979            .alias
6980            .as_deref()
6981            .unwrap_or(from.primary.name.as_str());
6982        let ctx = EvalContext::new(schema_cols, Some(alias));
6983        let seg = self
6984            .active_catalog()
6985            .cold_segment(segment_id)
6986            .ok_or_else(|| {
6987                EngineError::Unsupported(alloc::format!(
6988                    "AS OF SEGMENT: cold segment {segment_id} not registered"
6989                ))
6990            })?;
6991        let mut out_rows: Vec<Row> = Vec::new();
6992        let mut limit_remaining: Option<usize> =
6993            stmt.limit_literal().and_then(|n| usize::try_from(n).ok());
6994        for (_key, body) in seg.scan() {
6995            let (row, _consumed) =
6996                spg_storage::decode_row_body_dense(&body, &schema).map_err(EngineError::Storage)?;
6997            if let Some(where_expr) = &stmt.where_ {
6998                let cond = self.eval_expr_simple(where_expr, &row, &ctx)?;
6999                if !matches!(cond, Value::Bool(true)) {
7000                    continue;
7001                }
7002            }
7003            // Projection.
7004            let projected = self.project_row_simple(&row, &stmt.items, schema_cols, alias)?;
7005            out_rows.push(projected);
7006            if let Some(rem) = limit_remaining.as_mut() {
7007                if *rem == 0 {
7008                    out_rows.pop();
7009                    break;
7010                }
7011                *rem -= 1;
7012            }
7013        }
7014        // Output column schema: derive from SELECT items.
7015        let columns = self.derive_output_columns(&stmt.items, schema_cols, alias);
7016        Ok(QueryResult::Rows {
7017            columns,
7018            rows: out_rows,
7019        })
7020    }
7021
7022    /// v6.10.2 — simple-path WHERE eval that doesn't go through
7023    /// the correlated-subquery / Memoize machinery. AS OF SEGMENT
7024    /// scan paths predicate against a snapshot frozen segment, no
7025    /// cross-row state.
7026    fn eval_expr_simple(
7027        &self,
7028        expr: &Expr,
7029        row: &Row,
7030        ctx: &EvalContext,
7031    ) -> Result<Value, EngineError> {
7032        let cancel = CancelToken::none();
7033        self.eval_expr_with_correlated(expr, row, ctx, cancel, None)
7034    }
7035
7036    /// v7.9.4 — INSERT / UPDATE / DELETE RETURNING projector.
7037    /// Given the table name, the user-supplied projection items,
7038    /// and the mutated rows (post-insert / post-update values, or
7039    /// pre-delete snapshot), build a `QueryResult::Rows` whose
7040    /// schema describes the projected columns. Mailrs migration
7041    /// blocker #1.
7042    fn build_returning_rows(
7043        &self,
7044        table_name: &str,
7045        items: &[SelectItem],
7046        mutated_rows: Vec<Vec<Value>>,
7047    ) -> Result<QueryResult, EngineError> {
7048        let table = self.active_catalog().get(table_name).ok_or_else(|| {
7049            EngineError::Storage(StorageError::TableNotFound {
7050                name: table_name.into(),
7051            })
7052        })?;
7053        let schema_cols = table.schema().columns.clone();
7054        let columns = self.derive_output_columns(items, &schema_cols, table_name);
7055        let mut out_rows: Vec<Row> = Vec::with_capacity(mutated_rows.len());
7056        for values in mutated_rows {
7057            let row = Row::new(values);
7058            let projected = self.project_row_simple(&row, items, &schema_cols, table_name)?;
7059            out_rows.push(projected);
7060        }
7061        Ok(QueryResult::Rows {
7062            columns,
7063            rows: out_rows,
7064        })
7065    }
7066
7067    /// v6.10.2 — projection for AS OF SEGMENT. Resolves
7068    /// `SelectItem::Wildcard` to all schema columns and
7069    /// `SelectItem::Expr` via the regular eval path.
7070    fn project_row_simple(
7071        &self,
7072        row: &Row,
7073        items: &[SelectItem],
7074        schema_cols: &[ColumnSchema],
7075        alias: &str,
7076    ) -> Result<Row, EngineError> {
7077        let ctx = EvalContext::new(schema_cols, Some(alias));
7078        let cancel = CancelToken::none();
7079        let mut out_vals = Vec::new();
7080        for item in items {
7081            match item {
7082                SelectItem::Wildcard => {
7083                    out_vals.extend(row.values.iter().cloned());
7084                }
7085                SelectItem::Expr { expr, .. } => {
7086                    let v = self.eval_expr_with_correlated(expr, row, &ctx, cancel, None)?;
7087                    out_vals.push(v);
7088                }
7089            }
7090        }
7091        Ok(Row::new(out_vals))
7092    }
7093
7094    /// v6.10.2 — derive the output `ColumnSchema` list for an
7095    /// AS OF SEGMENT projection. Wildcards take the full schema;
7096    /// expressions take the alias if present or a synthetic
7097    /// `?column?` (PG convention) otherwise.
7098    fn derive_output_columns(
7099        &self,
7100        items: &[SelectItem],
7101        schema_cols: &[ColumnSchema],
7102        _alias: &str,
7103    ) -> Vec<ColumnSchema> {
7104        let mut out = Vec::new();
7105        for item in items {
7106            match item {
7107                SelectItem::Wildcard => {
7108                    out.extend(schema_cols.iter().cloned());
7109                }
7110                SelectItem::Expr { alias, .. } => {
7111                    let name = alias.clone().unwrap_or_else(|| "?column?".to_string());
7112                    // Default to Text; the caller's row values
7113                    // carry the actual type. v6.10.2 scope.
7114                    out.push(ColumnSchema::new(name, DataType::Text, true));
7115                }
7116            }
7117        }
7118        out
7119    }
7120
7121    fn exec_select_cancel(
7122        &self,
7123        stmt: &SelectStatement,
7124        cancel: CancelToken<'_>,
7125    ) -> Result<QueryResult, EngineError> {
7126        cancel.check()?;
7127        // v7.17.0 Phase 1.2 — user-defined VIEW expansion. If the
7128        // FROM / JOIN graph references any catalogued view name,
7129        // re-parse the view body and prepend it as a synthetic
7130        // CTE. Recurses on views-in-views via the regular CTE
7131        // dispatch below. Fast-path: skip the walker entirely when
7132        // the catalog has no views (the typical OLTP load).
7133        if !self.active_catalog().views().is_empty() {
7134            if let Some(rewritten) = self.expand_views_in_select(stmt)? {
7135                return self.exec_select_cancel(&rewritten, cancel);
7136            }
7137        }
7138        // v7.16.2 — information_schema / pg_catalog virtual
7139        // views (mailrs round-10 A.3). If the SELECT touches a
7140        // synthetic meta-table name (`__spg_info_*` /
7141        // `__spg_pg_*` — produced by the parser for
7142        // `information_schema.X` / `pg_catalog.X`), clone the
7143        // catalog, materialise the requested view as a real
7144        // temporary table, and re-execute against an enriched
7145        // engine. Same pattern as `exec_with_ctes` for CTEs.
7146        if !self.meta_views_materialised && select_references_meta_view(stmt) {
7147            return self.exec_select_with_meta_views(stmt, cancel);
7148        }
7149        // v6.10.2 — cold-tier time-travel short-circuit. When the
7150        // primary TableRef carries `AS OF SEGMENT '<id>'`, run a
7151        // dedicated cold-segment scan instead of the regular
7152        // hot+index path. The scope is intentionally narrow for
7153        // v6.10.2 — bare `SELECT * FROM <t> AS OF SEGMENT 'id'`,
7154        // optionally with a single-column-equality WHERE. JOINs /
7155        // aggregates / ORDER BY / subqueries on top of a time-
7156        // travelled scan are STABILITY § "Out of v6.10".
7157        if let Some(from) = &stmt.from
7158            && let Some(seg_id) = from.primary.as_of_segment
7159        {
7160            return self.exec_select_as_of_segment(stmt, from, seg_id);
7161        }
7162        // v6.2.0 / v6.5.0 — virtual-table short-circuits. Detected
7163        // pre-CTE because they don't read from the catalog and
7164        // shouldn't participate in regular FROM resolution.
7165        if let Some(from) = &stmt.from
7166            && from.joins.is_empty()
7167            && stmt.where_.is_none()
7168            && stmt.group_by.is_none()
7169            && stmt.having.is_none()
7170            && stmt.unions.is_empty()
7171            && stmt.order_by.is_empty()
7172            && stmt.limit.is_none()
7173            && stmt.offset.is_none()
7174            && !stmt.distinct
7175            && stmt.items.iter().all(|i| matches!(i, SelectItem::Wildcard))
7176        {
7177            let lower = from.primary.name.to_ascii_lowercase();
7178            match lower.as_str() {
7179                "spg_statistic" => return Ok(self.exec_spg_statistic()),
7180                // v6.5.0 — observability v2 virtual tables.
7181                "spg_stat_replication" => return Ok(self.exec_spg_stat_replication()),
7182                "spg_stat_segment" => return Ok(self.exec_spg_stat_segment()),
7183                "spg_stat_query" => return Ok(self.exec_spg_stat_query()),
7184                "spg_stat_activity" => return Ok(self.exec_spg_stat_activity()),
7185                "spg_audit_chain" => return Ok(self.exec_spg_audit_chain()),
7186                "spg_audit_verify" => return Ok(self.exec_spg_audit_verify()),
7187                "spg_table_ddl" => return Ok(self.exec_spg_table_ddl()),
7188                "spg_role_ddl" => return Ok(self.exec_spg_role_ddl()),
7189                "spg_database_ddl" => return Ok(self.exec_spg_database_ddl()),
7190                _ => {}
7191            }
7192        }
7193        // v4.11: CTEs materialise into a temporary enriched catalog
7194        // *before* anything else — the body SELECT can then refer
7195        // to CTE names via the regular FROM-clause resolution.
7196        // Uncorrelated only: each CTE body runs once against the
7197        // current catalog, not against later CTEs' results (left-
7198        // to-right materialisation would relax this, but we keep
7199        // it simple for v4.11 MVP).
7200        if !stmt.ctes.is_empty() {
7201            return self.exec_with_ctes(stmt, cancel);
7202        }
7203        // v4.10: subqueries (uncorrelated) are resolved here, before
7204        // the executor sees the row loop. We clone the statement so
7205        // we can mutate without disturbing the caller's AST — most
7206        // queries pass through with no subquery nodes and the clone
7207        // is cheap; with subqueries the materialisation cost
7208        // dominates anyway.
7209        let mut stmt_owned;
7210        let stmt_ref: &SelectStatement = if expr_tree_has_subquery(stmt) {
7211            stmt_owned = stmt.clone();
7212            self.resolve_select_subqueries(&mut stmt_owned, cancel)?;
7213            &stmt_owned
7214        } else {
7215            stmt
7216        };
7217        if stmt_ref.unions.is_empty() {
7218            return self.exec_bare_select_cancel(stmt_ref, cancel);
7219        }
7220        // UNION path: clone-strip the head into a bare block (its own
7221        // DISTINCT and any inner ORDER BY are dropped by parser rule —
7222        // the wrapper SelectStatement carries them), execute, then chain
7223        // peers with left-associative dedup semantics.
7224        let mut head = stmt_ref.clone();
7225        head.unions = Vec::new();
7226        head.order_by = Vec::new();
7227        head.limit = None;
7228        let QueryResult::Rows { columns, mut rows } =
7229            self.exec_bare_select_cancel(&head, cancel)?
7230        else {
7231            unreachable!("bare SELECT cannot return CommandOk")
7232        };
7233        for (kind, peer) in &stmt_ref.unions {
7234            let QueryResult::Rows {
7235                columns: peer_cols,
7236                rows: peer_rows,
7237            } = self.exec_bare_select_cancel(peer, cancel)?
7238            else {
7239                unreachable!("bare SELECT cannot return CommandOk")
7240            };
7241            if peer_cols.len() != columns.len() {
7242                return Err(EngineError::Unsupported(alloc::format!(
7243                    "UNION arity mismatch: head has {} columns, peer has {}",
7244                    columns.len(),
7245                    peer_cols.len()
7246                )));
7247            }
7248            rows.extend(peer_rows);
7249            if matches!(kind, UnionKind::Distinct) {
7250                rows = dedup_rows(rows);
7251            }
7252        }
7253        // ORDER BY at the top of a UNION applies to the combined result.
7254        // Eval against the projected schema (NOT the source table).
7255        if !stmt.order_by.is_empty() {
7256            let synth_ctx = EvalContext::new(&columns, None);
7257            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
7258            let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(rows.len());
7259            for r in rows {
7260                let keys = build_order_keys(&stmt.order_by, &r, &synth_ctx)?;
7261                tagged.push((keys, r));
7262            }
7263            sort_by_keys(&mut tagged, &descs);
7264            rows = tagged.into_iter().map(|(_, r)| r).collect();
7265        }
7266        apply_offset_and_limit(&mut rows, stmt.offset_literal(), stmt.limit_literal());
7267        Ok(QueryResult::Rows { columns, rows })
7268    }
7269
7270    #[allow(clippy::too_many_lines)]
7271    #[allow(clippy::too_many_lines)] // huge match — splitting fragments the planner
7272    /// v7.11.7 — execute `SELECT … FROM unnest(expr) [AS] alias …`.
7273    /// Synthesises a single-column virtual table whose column type
7274    /// is TEXT and whose rows are the array elements. Routes
7275    /// through the regular projection / WHERE / ORDER BY / LIMIT
7276    /// machinery so set-returning UNNEST composes naturally with
7277    /// the rest of the SELECT surface.
7278    fn exec_select_unnest(
7279        &self,
7280        stmt: &SelectStatement,
7281        primary: &TableRef,
7282        cancel: CancelToken<'_>,
7283    ) -> Result<QueryResult, EngineError> {
7284        let expr = primary
7285            .unnest_expr
7286            .as_deref()
7287            .expect("caller guards unnest_expr.is_some()");
7288        // Evaluate the array expression once. Empty schema / empty
7289        // row — uncorrelated UNNEST cannot reference outer columns.
7290        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7291        let ctx = EvalContext::new(&empty_schema, None);
7292        let dummy_row = Row::new(alloc::vec::Vec::new());
7293        // v7.11.13 — unnest dispatches per array element type so
7294        // INT[] / BIGINT[] surface their PG types in projection.
7295        let (elem_dtype, rows): (DataType, alloc::vec::Vec<Row>) =
7296            match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
7297                Value::Null => (DataType::Text, alloc::vec::Vec::new()),
7298                Value::TextArray(items) => {
7299                    let rows = items
7300                        .into_iter()
7301                        .map(|item| {
7302                            Row::new(alloc::vec![match item {
7303                                Some(s) => Value::Text(s),
7304                                None => Value::Null,
7305                            }])
7306                        })
7307                        .collect();
7308                    (DataType::Text, rows)
7309                }
7310                Value::IntArray(items) => {
7311                    let rows = items
7312                        .into_iter()
7313                        .map(|item| {
7314                            Row::new(alloc::vec![match item {
7315                                Some(n) => Value::Int(n),
7316                                None => Value::Null,
7317                            }])
7318                        })
7319                        .collect();
7320                    (DataType::Int, rows)
7321                }
7322                Value::BigIntArray(items) => {
7323                    let rows = items
7324                        .into_iter()
7325                        .map(|item| {
7326                            Row::new(alloc::vec![match item {
7327                                Some(n) => Value::BigInt(n),
7328                                None => Value::Null,
7329                            }])
7330                        })
7331                        .collect();
7332                    (DataType::BigInt, rows)
7333                }
7334                other => {
7335                    return Err(EngineError::Unsupported(alloc::format!(
7336                        "unnest() expects an array argument, got {:?}",
7337                        other.data_type()
7338                    )));
7339                }
7340            };
7341        let alias = primary
7342            .alias
7343            .clone()
7344            .unwrap_or_else(|| "unnest".to_string());
7345        // v7.13.2 — mailrs round-6 S5. Honour PG-standard
7346        // `UNNEST(arr) AS p(col_name)` column-list aliasing: the
7347        // first entry overrides the projected column's name.
7348        // Without the column list, fall back to the table alias
7349        // (pre-v7.13.2 behaviour).
7350        let col_name = primary
7351            .unnest_column_aliases
7352            .first()
7353            .cloned()
7354            .unwrap_or_else(|| alias.clone());
7355        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7356        let schema_cols = alloc::vec![col_schema.clone()];
7357        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7358        // Apply WHERE.
7359        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7360            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7361            for row in rows {
7362                cancel.check()?;
7363                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7364                if matches!(v, Value::Bool(true)) {
7365                    out.push(row);
7366                }
7367            }
7368            out
7369        } else {
7370            rows
7371        };
7372        // v7.17.0 Phase 3.P0-48 — aggregate dispatch over the
7373        // unnest source. Same routing the relational scan path
7374        // already takes — without it `SELECT COUNT(*) FROM
7375        // unnest(ARRAY[…])` either errored at projection time or
7376        // returned the wrong shape.
7377        if aggregate::uses_aggregate(stmt) {
7378            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7379            let mut agg = aggregate::run(stmt, &filtered_refs, &schema_cols, Some(&alias))?;
7380            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7381            return Ok(QueryResult::Rows {
7382                columns: agg.columns,
7383                rows: agg.rows,
7384            });
7385        }
7386        // Projection.
7387        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7388        let mut projected_rows: alloc::vec::Vec<Row> =
7389            alloc::vec::Vec::with_capacity(filtered.len());
7390        for row in &filtered {
7391            let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7392            for p in &projection {
7393                vals.push(eval::eval_expr(&p.expr, row, &scan_ctx).map_err(EngineError::Eval)?);
7394            }
7395            projected_rows.push(Row::new(vals));
7396        }
7397        // ORDER BY / LIMIT — apply on the projected rows (cheap;
7398        // unnest result sets are small by design).
7399        let columns: alloc::vec::Vec<ColumnSchema> = projection
7400            .iter()
7401            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7402            .collect();
7403        // Re-evaluate ORDER BY against the source schema (pre-projection
7404        // so col refs by name still resolve through `scan_ctx`).
7405        if !stmt.order_by.is_empty() {
7406            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7407                .iter()
7408                .enumerate()
7409                .map(|(i, r)| -> Result<_, EngineError> {
7410                    let keys: Result<Vec<Value>, EngineError> = stmt
7411                        .order_by
7412                        .iter()
7413                        .map(|ob| {
7414                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7415                        })
7416                        .collect();
7417                    Ok((i, keys?))
7418                })
7419                .collect::<Result<_, _>>()?;
7420            indexed.sort_by(|a, b| {
7421                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7422                    let mut cmp = value_cmp(ka, kb);
7423                    if stmt.order_by[idx].desc {
7424                        cmp = cmp.reverse();
7425                    }
7426                    if cmp != core::cmp::Ordering::Equal {
7427                        return cmp;
7428                    }
7429                }
7430                core::cmp::Ordering::Equal
7431            });
7432            projected_rows = indexed
7433                .into_iter()
7434                .map(|(i, _)| projected_rows[i].clone())
7435                .collect();
7436        }
7437        // LIMIT / OFFSET — apply at the tail.
7438        if let Some(offset) = stmt.offset_literal() {
7439            let off = (offset as usize).min(projected_rows.len());
7440            projected_rows.drain(..off);
7441        }
7442        if let Some(limit) = stmt.limit_literal() {
7443            projected_rows.truncate(limit as usize);
7444        }
7445        Ok(QueryResult::Rows {
7446            columns,
7447            rows: projected_rows,
7448        })
7449    }
7450
7451    /// v7.17.0 Phase 3.10 — `FROM generate_series(start, stop [,
7452    /// step])` set-returning source. Mirrors `exec_select_unnest`'s
7453    /// shape: evaluate the arg list once against an empty row,
7454    /// materialise the row stream by stepping start → stop, then
7455    /// route through the standard WHERE / projection / ORDER BY /
7456    /// LIMIT pipeline. Two arg-type combos in v7.17:
7457    ///   * integer / integer [/ integer] — SmallInt, Int, BigInt
7458    ///     (widened to BigInt internally; step defaults to 1)
7459    ///   * timestamp / timestamp / interval — date-range
7460    ///     iteration (mailrs's daily-report pattern)
7461    fn exec_select_generate_series(
7462        &self,
7463        stmt: &SelectStatement,
7464        primary: &TableRef,
7465        cancel: CancelToken<'_>,
7466    ) -> Result<QueryResult, EngineError> {
7467        let args = primary
7468            .generate_series_args
7469            .as_ref()
7470            .expect("caller guards generate_series_args.is_some()");
7471        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7472        let ctx = EvalContext::new(&empty_schema, None);
7473        let dummy_row = Row::new(alloc::vec::Vec::new());
7474        let mut arg_values: alloc::vec::Vec<Value> = alloc::vec::Vec::with_capacity(args.len());
7475        for a in args {
7476            arg_values.push(eval::eval_expr(a, &dummy_row, &ctx).map_err(EngineError::Eval)?);
7477        }
7478        // Dispatch on the start value's shape. Reject mixed-shape
7479        // calls early (e.g. start = timestamp, stop = integer) so
7480        // the caller gets a clean error rather than a panic.
7481        let (elem_dtype, rows) = match arg_values.as_slice() {
7482            [Value::Timestamp(start), Value::Timestamp(stop), step] => {
7483                let interval_step = match step {
7484                    Value::Interval { .. } => step.clone(),
7485                    other => {
7486                        return Err(EngineError::Unsupported(alloc::format!(
7487                            "generate_series(timestamp, timestamp, …): \
7488                             step must be INTERVAL, got {:?}",
7489                            other.data_type()
7490                        )));
7491                    }
7492                };
7493                let rows = generate_series_timestamps(*start, *stop, interval_step, &cancel)?;
7494                (DataType::Timestamp, rows)
7495            }
7496            [start, stop, step]
7497                if value_is_integer(start) && value_is_integer(stop) && value_is_integer(step) =>
7498            {
7499                let s = value_to_i64(start);
7500                let e = value_to_i64(stop);
7501                let st = value_to_i64(step);
7502                let rows = generate_series_integers(s, e, st, &cancel)?;
7503                (DataType::BigInt, rows)
7504            }
7505            [start, stop] if value_is_integer(start) && value_is_integer(stop) => {
7506                let s = value_to_i64(start);
7507                let e = value_to_i64(stop);
7508                let rows = generate_series_integers(s, e, 1, &cancel)?;
7509                (DataType::BigInt, rows)
7510            }
7511            _ => {
7512                return Err(EngineError::Unsupported(alloc::format!(
7513                    "generate_series(): v7.17 supports integer or (timestamp, timestamp, interval) \
7514                     argument shapes; got {:?}",
7515                    arg_values
7516                        .iter()
7517                        .map(|v| v.data_type())
7518                        .collect::<alloc::vec::Vec<_>>()
7519                )));
7520            }
7521        };
7522        let alias = primary
7523            .alias
7524            .clone()
7525            .unwrap_or_else(|| "generate_series".to_string());
7526        let col_name = alias.clone();
7527        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7528        let schema_cols = alloc::vec![col_schema.clone()];
7529        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7530        // WHERE.
7531        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7532            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7533            for row in rows {
7534                cancel.check()?;
7535                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7536                if matches!(v, Value::Bool(true)) {
7537                    out.push(row);
7538                }
7539            }
7540            out
7541        } else {
7542            rows
7543        };
7544        // v7.17.0 Phase 3.P0-48 — aggregate dispatch for set-
7545        // returning sources. When the SELECT projection contains
7546        // aggregate functions (COUNT/SUM/MIN/MAX/AVG/string_agg/
7547        // …) we route the filtered row stream through the same
7548        // aggregate executor the relational scan path uses, so
7549        // `SELECT COUNT(*) FROM generate_series(1, 100)` returns
7550        // a single 100 row instead of erroring at projection
7551        // time. GROUP BY / HAVING / ORDER BY over the aggregate
7552        // output all ride through `aggregate::run`.
7553        if aggregate::uses_aggregate(stmt) {
7554            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7555            let mut agg = aggregate::run(stmt, &filtered_refs, &schema_cols, Some(&alias))?;
7556            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7557            return Ok(QueryResult::Rows {
7558                columns: agg.columns,
7559                rows: agg.rows,
7560            });
7561        }
7562        // Projection.
7563        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7564        let mut projected_rows: alloc::vec::Vec<Row> =
7565            alloc::vec::Vec::with_capacity(filtered.len());
7566        for row in &filtered {
7567            let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7568            for p in &projection {
7569                vals.push(eval::eval_expr(&p.expr, row, &scan_ctx).map_err(EngineError::Eval)?);
7570            }
7571            projected_rows.push(Row::new(vals));
7572        }
7573        let columns: alloc::vec::Vec<ColumnSchema> = projection
7574            .iter()
7575            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7576            .collect();
7577        // ORDER BY against the source schema.
7578        if !stmt.order_by.is_empty() {
7579            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7580                .iter()
7581                .enumerate()
7582                .map(|(i, r)| -> Result<_, EngineError> {
7583                    let keys: Result<Vec<Value>, EngineError> = stmt
7584                        .order_by
7585                        .iter()
7586                        .map(|ob| {
7587                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7588                        })
7589                        .collect();
7590                    Ok((i, keys?))
7591                })
7592                .collect::<Result<_, _>>()?;
7593            indexed.sort_by(|a, b| {
7594                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7595                    let mut cmp = value_cmp(ka, kb);
7596                    if stmt.order_by[idx].desc {
7597                        cmp = cmp.reverse();
7598                    }
7599                    if cmp != core::cmp::Ordering::Equal {
7600                        return cmp;
7601                    }
7602                }
7603                core::cmp::Ordering::Equal
7604            });
7605            projected_rows = indexed
7606                .into_iter()
7607                .map(|(i, _)| projected_rows[i].clone())
7608                .collect();
7609        }
7610        if let Some(offset) = stmt.offset_literal() {
7611            let off = (offset as usize).min(projected_rows.len());
7612            projected_rows.drain(..off);
7613        }
7614        if let Some(limit) = stmt.limit_literal() {
7615            projected_rows.truncate(limit as usize);
7616        }
7617        Ok(QueryResult::Rows {
7618            columns,
7619            rows: projected_rows,
7620        })
7621    }
7622
7623    fn exec_bare_select_cancel(
7624        &self,
7625        stmt: &SelectStatement,
7626        cancel: CancelToken<'_>,
7627    ) -> Result<QueryResult, EngineError> {
7628        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST N ROWS WITH TIES`
7629        // is meaningless without an ORDER BY; PG raises a hard
7630        // error and SPG mirrors the surface so the same DDL/app
7631        // path behaves identically on cutover.
7632        check_with_ties_requires_order_by(stmt)?;
7633        // v7.16.2 — same meta-view dispatch as
7634        // `exec_select_cancel`, applied here too because
7635        // `subquery_replacement` enters this function directly
7636        // for Exists / ScalarSubquery / InSubquery resolution
7637        // (bypassing the top-level entry to avoid double
7638        // subquery walking). Without this dispatch the subquery
7639        // hits `__spg_info_columns` and reports TableNotFound.
7640        if !self.meta_views_materialised && select_references_meta_view(stmt) {
7641            return self.exec_select_with_meta_views(stmt, cancel);
7642        }
7643        // v4.12: window-function path. When the projection contains
7644        // any `name(args) OVER (...)` we route to the dedicated
7645        // executor — partition + sort + per-row window value before
7646        // the regular projection.
7647        if select_has_window(stmt) {
7648            return self.exec_select_with_window(stmt, cancel);
7649        }
7650        // Constant SELECT (no FROM) — evaluate each item once against an
7651        // empty dummy row. Useful for `SELECT 1`, `SELECT coalesce(...)`,
7652        // `SELECT '7'::INT`. Column references will surface as
7653        // ColumnNotFound on eval since the schema is empty.
7654        let Some(from) = &stmt.from else {
7655            let empty_schema: Vec<ColumnSchema> = Vec::new();
7656            let ctx = self.ev_ctx(&empty_schema, None);
7657            let projection = build_projection(&stmt.items, &empty_schema, "")?;
7658            let dummy_row = Row::new(Vec::new());
7659            let mut values = Vec::with_capacity(projection.len());
7660            for p in &projection {
7661                values.push(eval::eval_expr(&p.expr, &dummy_row, &ctx)?);
7662            }
7663            let columns: Vec<ColumnSchema> = projection
7664                .into_iter()
7665                .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
7666                .collect();
7667            return Ok(QueryResult::Rows {
7668                columns,
7669                rows: alloc::vec![Row::new(values)],
7670            });
7671        };
7672        // Multi-table FROM (one or more joined peers) goes through the
7673        // nested-loop join executor. Single-table FROM stays on the
7674        // existing scan + index-seek path.
7675        if !from.joins.is_empty() {
7676            return self.exec_joined_select(stmt, from);
7677        }
7678        // v7.11.7 — `FROM unnest(<expr>) [AS] <alias>`. Synthesise a
7679        // single-column table at SELECT entry by evaluating the
7680        // expression once against the empty row (UNNEST is
7681        // uncorrelated in v7.11; correlated / LATERAL unnest is a
7682        // v7.12 carve-out). Build a virtual `Table` in a heap-only
7683        // catalog, then route to the regular scan path.
7684        if from.primary.unnest_expr.is_some() {
7685            return self.exec_select_unnest(stmt, &from.primary, cancel);
7686        }
7687        // v7.17.0 Phase 3.10 — `FROM generate_series(start, stop
7688        // [, step])` set-returning source. Dispatch mirrors UNNEST:
7689        // materialise the row stream from a single eval pass, then
7690        // run the regular projection / WHERE / ORDER BY / LIMIT
7691        // pipeline over the synthetic single-column table.
7692        if from.primary.generate_series_args.is_some() {
7693            return self.exec_select_generate_series(stmt, &from.primary, cancel);
7694        }
7695        let primary = &from.primary;
7696        let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
7697            StorageError::TableNotFound {
7698                name: primary.name.clone(),
7699            }
7700        })?;
7701        let schema_cols = &table.schema().columns;
7702        // The qualifier accepted on column refs is the alias (if any) else the
7703        // bare table name.
7704        let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
7705        let ctx = self.ev_ctx(schema_cols, Some(alias));
7706
7707        // NSW kNN planner: `ORDER BY col <-> literal LIMIT k` with no
7708        // WHERE and an NSW index on `col` skips the full scan. The
7709        // walk returns rows already in ascending-distance order, so
7710        // ORDER BY / LIMIT are honoured implicitly.
7711        if let Some(nsw_rows) = try_nsw_knn(stmt, table, schema_cols, alias) {
7712            return materialise_in_order(stmt, table, schema_cols, alias, &nsw_rows);
7713        }
7714
7715        // Index seek: if WHERE is `col = literal` (or commuted) and the
7716        // referenced column has an index, dispatch each locator through
7717        // the catalog (hot tier → borrow, cold tier → page-read +
7718        // decode) and iterate just those rows. Otherwise fall back to a
7719        // full scan over the hot tier (cold-tier rows are only reached
7720        // via index seek in v5.1 — full table scans against cold-tier
7721        // data ship in v5.2 with the freezer's per-segment scan API).
7722        let indexed_rows: Option<Vec<Cow<'_, Row>>> = stmt.where_.as_ref().and_then(|w| {
7723            // BTree / col=literal seek first — covers the v7.11.3 multi-
7724            // column AND case and the leading-column equality lookup.
7725            try_index_seek(w, schema_cols, self.active_catalog(), table, alias)
7726                .or_else(|| {
7727                    // v7.12.3 — GIN-accelerated `WHERE col @@
7728                    // tsquery` when the column has a `USING gin`
7729                    // index. Returns an over-approximate candidate
7730                    // set; the WHERE re-eval loop below verifies
7731                    // the full `@@` predicate per row.
7732                    try_gin_seek(w, schema_cols, self.active_catalog(), table, alias, &ctx)
7733                })
7734                .or_else(|| {
7735                    // v7.15.0 — trigram-GIN-accelerated
7736                    // `WHERE col LIKE / ILIKE '<pat>'` when the
7737                    // column has a `gin_trgm_ops` GIN index.
7738                    // Over-approximate candidate set; the WHERE
7739                    // re-eval verifies the LIKE per row.
7740                    try_trgm_seek(w, schema_cols, table, alias)
7741                })
7742        });
7743
7744        // Aggregate path: filter rows first, then hand off to the
7745        // aggregate executor which does its own projection + ORDER BY.
7746        if aggregate::uses_aggregate(stmt) {
7747            let mut filtered: Vec<&Row> = Vec::new();
7748            // v6.2.6 — Memoize: per-query LRU cache for correlated
7749            // scalar subqueries. Fresh per row-loop entry so each
7750            // SELECT execution gets an isolated cache.
7751            let mut memo = memoize::MemoizeCache::new();
7752            if let Some(rows) = &indexed_rows {
7753                for cow in rows {
7754                    let row = cow.as_ref();
7755                    if let Some(where_expr) = &stmt.where_ {
7756                        let cond = self.eval_expr_with_correlated(
7757                            where_expr,
7758                            row,
7759                            &ctx,
7760                            cancel,
7761                            Some(&mut memo),
7762                        )?;
7763                        if !matches!(cond, Value::Bool(true)) {
7764                            continue;
7765                        }
7766                    }
7767                    filtered.push(row);
7768                }
7769            } else {
7770                for i in 0..table.row_count() {
7771                    let row = &table.rows()[i];
7772                    if let Some(where_expr) = &stmt.where_ {
7773                        let cond = self.eval_expr_with_correlated(
7774                            where_expr,
7775                            row,
7776                            &ctx,
7777                            cancel,
7778                            Some(&mut memo),
7779                        )?;
7780                        if !matches!(cond, Value::Bool(true)) {
7781                            continue;
7782                        }
7783                    }
7784                    filtered.push(row);
7785                }
7786            }
7787            let mut agg = aggregate::run(stmt, &filtered, schema_cols, Some(alias))?;
7788            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7789            return Ok(QueryResult::Rows {
7790                columns: agg.columns,
7791                rows: agg.rows,
7792            });
7793        }
7794
7795        let projection = build_projection(&stmt.items, schema_cols, alias)?;
7796
7797        // Materialise the filter pass into `(order_key, projected_row)`
7798        // tuples. The order key is `None` when there's no ORDER BY clause.
7799        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
7800        // v6.2.6 — Memoize per-row WHERE eval shares one cache.
7801        let mut memo = memoize::MemoizeCache::new();
7802        // Inline the per-row work in a closure so the indexed and full-
7803        // scan branches share the body.
7804        let mut process_row = |row: &Row, loop_idx: usize| -> Result<(), EngineError> {
7805            if loop_idx.is_multiple_of(256) {
7806                cancel.check()?;
7807            }
7808            if let Some(where_expr) = &stmt.where_ {
7809                let cond =
7810                    self.eval_expr_with_correlated(where_expr, row, &ctx, cancel, Some(&mut memo))?;
7811                if !matches!(cond, Value::Bool(true)) {
7812                    return Ok(());
7813                }
7814            }
7815            let mut values = Vec::with_capacity(projection.len());
7816            for p in &projection {
7817                values.push(eval::eval_expr(&p.expr, row, &ctx)?);
7818            }
7819            let order_keys = if stmt.order_by.is_empty() {
7820                Vec::new()
7821            } else {
7822                build_order_keys(&stmt.order_by, row, &ctx)?
7823            };
7824            tagged.push((order_keys, Row::new(values)));
7825            Ok(())
7826        };
7827        if let Some(rows) = &indexed_rows {
7828            for (loop_idx, cow) in rows.iter().enumerate() {
7829                process_row(cow.as_ref(), loop_idx)?;
7830            }
7831        } else {
7832            for i in 0..table.row_count() {
7833                process_row(&table.rows()[i], i)?;
7834            }
7835        }
7836
7837        if !stmt.order_by.is_empty() {
7838            // Partial-sort fast path: when LIMIT is small relative to
7839            // the row count, select_nth_unstable + sort just the
7840            // prefix is O(n + k log k) instead of O(n log n). DISTINCT
7841            // requires the full sort because de-dup happens after.
7842            // WITH TIES likewise needs the full sort so the tie
7843            // extension can scan past `limit` to find rows that
7844            // share the last-kept row's key.
7845            let keep = if stmt.distinct || stmt.limit_with_ties {
7846                None
7847            } else {
7848                stmt.limit_literal()
7849                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
7850            };
7851            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
7852            partial_sort_tagged(&mut tagged, keep, &descs);
7853        }
7854
7855        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST … WITH TIES` extends
7856        // past the truncated tail through every row that shares the
7857        // last-kept row's ORDER BY key. The tie check uses the
7858        // already-computed `(order_keys, row)` pairs so it matches
7859        // the sort comparator exactly. DISTINCT + WITH TIES falls
7860        // through to the no-ties path (PG also disallows their
7861        // combination; SPG silently drops the tie extension here so
7862        // the customer doesn't see a hard error mid-query — the
7863        // user-visible result is still correct, just narrower).
7864        let output_rows: Vec<Row> = if stmt.limit_with_ties && !stmt.distinct {
7865            apply_offset_and_limit_tagged(
7866                &mut tagged,
7867                stmt.offset_literal(),
7868                stmt.limit_literal(),
7869                true,
7870            );
7871            tagged.into_iter().map(|(_, r)| r).collect()
7872        } else {
7873            let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
7874            if stmt.distinct {
7875                output_rows = dedup_rows(output_rows);
7876            }
7877            apply_offset_and_limit(
7878                &mut output_rows,
7879                stmt.offset_literal(),
7880                stmt.limit_literal(),
7881            );
7882            output_rows
7883        };
7884
7885        let columns: Vec<ColumnSchema> = projection
7886            .into_iter()
7887            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
7888            .collect();
7889
7890        Ok(QueryResult::Rows {
7891            columns,
7892            rows: output_rows,
7893        })
7894    }
7895
7896    /// Multi-table SELECT executor (one or more JOIN peers).
7897    ///
7898    /// v1.10 builds the joined row set up-front via nested-loop joins,
7899    /// then runs WHERE + projection + ORDER BY against the combined
7900    /// rows. No index seek. Aggregates and DISTINCT still work because
7901    /// the executor delegates projection through the same shared paths.
7902    #[allow(clippy::too_many_lines)]
7903    /// v7.13.2 — mailrs round-6 S5. Resolve a TableRef into an
7904    /// owned (rows, schema) pair. Catalog tables clone their hot
7905    /// rows + schema; UNNEST table refs evaluate their array
7906    /// expression once and synthesise a single-column row set
7907    /// using the same dispatch as `exec_select_unnest`. Used by
7908    /// the joined-select path so UNNEST can appear in any FROM
7909    /// position, not just as the primary.
7910    fn materialise_table_ref(
7911        &self,
7912        tref: &TableRef,
7913    ) -> Result<(Vec<Row>, Vec<ColumnSchema>), EngineError> {
7914        if let Some(expr) = tref.unnest_expr.as_deref() {
7915            let empty_schema: Vec<ColumnSchema> = Vec::new();
7916            let ctx = EvalContext::new(&empty_schema, None);
7917            let dummy_row = Row::new(Vec::new());
7918            let (elem_dtype, rows) =
7919                match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
7920                    Value::Null => (DataType::Text, Vec::new()),
7921                    Value::TextArray(items) => (
7922                        DataType::Text,
7923                        items
7924                            .into_iter()
7925                            .map(|item| {
7926                                Row::new(alloc::vec![match item {
7927                                    Some(s) => Value::Text(s),
7928                                    None => Value::Null,
7929                                }])
7930                            })
7931                            .collect(),
7932                    ),
7933                    Value::IntArray(items) => (
7934                        DataType::Int,
7935                        items
7936                            .into_iter()
7937                            .map(|item| {
7938                                Row::new(alloc::vec![match item {
7939                                    Some(n) => Value::Int(n),
7940                                    None => Value::Null,
7941                                }])
7942                            })
7943                            .collect(),
7944                    ),
7945                    Value::BigIntArray(items) => (
7946                        DataType::BigInt,
7947                        items
7948                            .into_iter()
7949                            .map(|item| {
7950                                Row::new(alloc::vec![match item {
7951                                    Some(n) => Value::BigInt(n),
7952                                    None => Value::Null,
7953                                }])
7954                            })
7955                            .collect(),
7956                    ),
7957                    other => {
7958                        return Err(EngineError::Unsupported(alloc::format!(
7959                            "unnest() expects an array argument, got {:?}",
7960                            other.data_type()
7961                        )));
7962                    }
7963                };
7964            let alias = tref.alias.clone().unwrap_or_else(|| "unnest".to_string());
7965            let col_name = tref.unnest_column_aliases.first().cloned().unwrap_or(alias);
7966            return Ok((
7967                rows,
7968                alloc::vec![ColumnSchema::new(col_name, elem_dtype, true)],
7969            ));
7970        }
7971        let table =
7972            self.active_catalog()
7973                .get(&tref.name)
7974                .ok_or_else(|| StorageError::TableNotFound {
7975                    name: tref.name.clone(),
7976                })?;
7977        let rows: Vec<Row> = table.rows().iter().cloned().collect();
7978        let cols = table.schema().columns.clone();
7979        Ok((rows, cols))
7980    }
7981
7982    /// v7.17.0 Phase 3.P0-43 — materialise a `FROM` with one or more
7983    /// JOINs into `(combined_schema, filtered_rows)`. The combined
7984    /// schema uses composite `alias.col` column names so the
7985    /// qualifier-aware column resolver finds every join peer by
7986    /// exact match; the filtered rows are the join cross-product
7987    /// after the optional WHERE clause is applied.
7988    ///
7989    /// Shared by `exec_joined_select` and the JOIN branch of
7990    /// `exec_select_with_window`; both paths used to inline the
7991    /// same nested-loop logic and the window path rejected JOIN
7992    /// outright.
7993    fn build_joined_filtered_rows(
7994        &self,
7995        from: &FromClause,
7996        where_: Option<&Expr>,
7997    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
7998        let (primary_rows, primary_cols) = self.materialise_table_ref(&from.primary)?;
7999        let primary_alias = from
8000            .primary
8001            .alias
8002            .as_deref()
8003            .unwrap_or(from.primary.name.as_str())
8004            .to_string();
8005        // v7.17.0 Phase 3.P0-41 — LATERAL peers can't be
8006        // pre-materialised because their rows depend on outer
8007        // columns. For each peer, build either an eager
8008        // (rows, schema) pair or a "lateral" sentinel carrying
8009        // just the schema and the inner SELECT to re-run per
8010        // outer row.
8011        #[allow(clippy::type_complexity)]
8012        let mut joined: Vec<JoinedPeer<'_>> = Vec::new();
8013        for j in &from.joins {
8014            let a = j
8015                .table
8016                .alias
8017                .as_deref()
8018                .unwrap_or(j.table.name.as_str())
8019                .to_string();
8020            if let Some(inner_box) = &j.table.lateral_subquery {
8021                // Probe schema by running the inner SELECT against a
8022                // NULL-padded outer context. The probe gives us the
8023                // projection's column shape; rows materialise per
8024                // left-row below.
8025                let schema = self.lateral_probe_schema(inner_box)?;
8026                joined.push(JoinedPeer {
8027                    eager_rows: None,
8028                    cols: schema,
8029                    alias: a,
8030                    kind: j.kind,
8031                    on: j.on.as_ref(),
8032                    lateral: Some(inner_box.as_ref()),
8033                });
8034            } else {
8035                let (rows, cols) = self.materialise_table_ref(&j.table)?;
8036                joined.push(JoinedPeer {
8037                    eager_rows: Some(rows),
8038                    cols,
8039                    alias: a,
8040                    kind: j.kind,
8041                    on: j.on.as_ref(),
8042                    lateral: None,
8043                });
8044            }
8045        }
8046        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
8047        for col in &primary_cols {
8048            combined_schema.push(ColumnSchema::new(
8049                alloc::format!("{primary_alias}.{}", col.name),
8050                col.ty,
8051                col.nullable,
8052            ));
8053        }
8054        for peer in &joined {
8055            for col in &peer.cols {
8056                combined_schema.push(ColumnSchema::new(
8057                    alloc::format!("{}.{}", peer.alias, col.name),
8058                    col.ty,
8059                    col.nullable,
8060                ));
8061            }
8062        }
8063        let ctx = EvalContext::new(&combined_schema, None);
8064        let mut working: Vec<Row> = primary_rows;
8065        // Track the per-row width consumed by the outer left side so
8066        // each lateral evaluation sees the correct schema slice.
8067        let mut consumed_cols = primary_cols.len();
8068        for peer in &joined {
8069            let right_arity = peer.cols.len();
8070            let mut next: Vec<Row> = Vec::new();
8071            for left in &working {
8072                let mut left_matched = false;
8073                let per_left_rrows: alloc::borrow::Cow<'_, [Row]> = match peer.lateral {
8074                    Some(inner) => {
8075                        // Substitute outer columns and run the inner
8076                        // SELECT against the current left row's slice
8077                        // of the combined schema.
8078                        let outer_schema = &combined_schema[..consumed_cols];
8079                        let rows = self.materialise_lateral_for_outer(inner, outer_schema, left)?;
8080                        alloc::borrow::Cow::Owned(rows)
8081                    }
8082                    None => {
8083                        let r = peer.eager_rows.as_ref().expect("non-lateral peer eager");
8084                        alloc::borrow::Cow::Borrowed(r.as_slice())
8085                    }
8086                };
8087                for right in per_left_rrows.as_ref() {
8088                    let mut combined_vals = left.values.clone();
8089                    combined_vals.extend(right.values.iter().cloned());
8090                    let combined = Row::new(combined_vals);
8091                    let keep = if let Some(on_expr) = peer.on {
8092                        let cond = eval::eval_expr(on_expr, &combined, &ctx)?;
8093                        matches!(cond, Value::Bool(true))
8094                    } else {
8095                        true
8096                    };
8097                    if keep {
8098                        next.push(combined);
8099                        left_matched = true;
8100                    }
8101                }
8102                if !left_matched && matches!(peer.kind, JoinKind::Left) {
8103                    let mut combined_vals = left.values.clone();
8104                    for _ in 0..right_arity {
8105                        combined_vals.push(Value::Null);
8106                    }
8107                    next.push(Row::new(combined_vals));
8108                }
8109            }
8110            working = next;
8111            consumed_cols += right_arity;
8112            debug_assert!(consumed_cols <= combined_schema.len());
8113        }
8114        let mut filtered: Vec<Row> = Vec::new();
8115        for row in working {
8116            if let Some(where_expr) = where_ {
8117                let cond = eval::eval_expr(where_expr, &row, &ctx)?;
8118                if !matches!(cond, Value::Bool(true)) {
8119                    continue;
8120                }
8121            }
8122            filtered.push(row);
8123        }
8124        Ok((combined_schema, filtered))
8125    }
8126
8127    /// v7.17.0 Phase 3.P0-41 — probe a LATERAL subquery's projection
8128    /// schema by running it once with a NULL-padded outer context.
8129    /// The probe never materialises real outer rows; it just executes
8130    /// the inner SELECT with `outer_alias.col` references substituted
8131    /// to NULL so the projection's type inference is exercised.
8132    fn lateral_probe_schema(
8133        &self,
8134        inner: &SelectStatement,
8135    ) -> Result<Vec<ColumnSchema>, EngineError> {
8136        // Substitute every qualified column reference whose qualifier
8137        // does NOT match an in-subquery FROM alias with NULL. The
8138        // safest probe is to walk the inner SELECT and replace any
8139        // `<qual>.<col>` whose qual isn't bound inside the subquery
8140        // with a Null literal. For the v7.17 probe we just run the
8141        // unmodified subquery and surface the columns; if it fails
8142        // (e.g. references an outer column the probe can't resolve),
8143        // we synthesise a best-effort schema from the SELECT items
8144        // by inferring a single Text-typed column per projection.
8145        match self.execute_readonly_select_for_lateral_probe(inner) {
8146            Ok(QueryResult::Rows { columns, .. }) => Ok(columns),
8147            // Best-effort fallback: each SELECT item becomes a TEXT
8148            // column. Real schemas only differ when the inner SELECT
8149            // references outer columns at projection-time; those
8150            // queries surface via the substitution path during
8151            // per-row execution and still return the right values.
8152            _ => {
8153                let mut out: Vec<ColumnSchema> = Vec::new();
8154                for (i, item) in inner.items.iter().enumerate() {
8155                    let name = match item {
8156                        SelectItem::Expr { alias: Some(a), .. } => a.clone(),
8157                        SelectItem::Expr { expr, .. } => synth_lateral_col_name(expr, i),
8158                        SelectItem::Wildcard => alloc::format!("col{i}"),
8159                    };
8160                    out.push(ColumnSchema::new(name, DataType::Text, true));
8161                }
8162                Ok(out)
8163            }
8164        }
8165    }
8166
8167    /// v7.17.0 Phase 3.P0-41 — try the inner LATERAL subquery against
8168    /// the engine in read-only mode for schema-probe purposes. Failure
8169    /// is expected when the subquery references an outer column the
8170    /// probe can't resolve; the caller falls back to a best-effort
8171    /// schema based on the SELECT items.
8172    fn execute_readonly_select_for_lateral_probe(
8173        &self,
8174        inner: &SelectStatement,
8175    ) -> Result<QueryResult, EngineError> {
8176        self.exec_bare_select_cancel(inner, CancelToken::none())
8177    }
8178
8179    /// v7.17.0 Phase 3.P0-41 — materialise a LATERAL subquery's rows
8180    /// for one outer-row context. Walks the inner SELECT, replaces
8181    /// every `<outer_alias>.<col>` reference whose alias appears in
8182    /// the outer schema with the literal value from the outer row,
8183    /// then runs the rewritten SELECT against the engine.
8184    fn materialise_lateral_for_outer(
8185        &self,
8186        inner: &SelectStatement,
8187        outer_schema: &[ColumnSchema],
8188        outer_row: &Row,
8189    ) -> Result<Vec<Row>, EngineError> {
8190        let mut substituted = inner.clone();
8191        substitute_outer_columns_multi(&mut substituted, outer_row, outer_schema);
8192        let result = self.exec_bare_select_cancel(&substituted, CancelToken::none())?;
8193        match result {
8194            QueryResult::Rows { rows, .. } => Ok(rows),
8195            _ => Err(EngineError::Unsupported(
8196                "LATERAL subquery must be a SELECT (cannot be a write statement)".into(),
8197            )),
8198        }
8199    }
8200
8201    fn exec_joined_select(
8202        &self,
8203        stmt: &SelectStatement,
8204        from: &FromClause,
8205    ) -> Result<QueryResult, EngineError> {
8206        // v7.17.0 Phase 3.P0-43 + P0-41 — delegate the join +
8207        // WHERE materialisation to the shared helper so the LATERAL
8208        // / UNNEST / regular-catalog paths route through one place.
8209        // (`build_joined_filtered_rows` carries LATERAL support as
8210        // of Phase 3.P0-41.) Downstream we still handle aggregate /
8211        // projection / ORDER BY / DISTINCT / LIMIT inline because
8212        // those depend on the SelectStatement's items list.
8213        let (combined_schema, filtered) =
8214            self.build_joined_filtered_rows(from, stmt.where_.as_ref())?;
8215        let ctx = EvalContext::new(&combined_schema, None);
8216        // Aggregate path: handle GROUP BY / aggregate calls over the
8217        // joined+filtered rows.
8218        if aggregate::uses_aggregate(stmt) {
8219            let refs: Vec<&Row> = filtered.iter().collect();
8220            let mut agg = aggregate::run(stmt, &refs, &combined_schema, None)?;
8221            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
8222            return Ok(QueryResult::Rows {
8223                columns: agg.columns,
8224                rows: agg.rows,
8225            });
8226        }
8227
8228        let projection = build_projection(&stmt.items, &combined_schema, "")?;
8229        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
8230        for row in &filtered {
8231            let mut values = Vec::with_capacity(projection.len());
8232            for p in &projection {
8233                values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8234            }
8235            let order_keys = if stmt.order_by.is_empty() {
8236                Vec::new()
8237            } else {
8238                build_order_keys(&stmt.order_by, row, &ctx)?
8239            };
8240            tagged.push((order_keys, Row::new(values)));
8241        }
8242        if !stmt.order_by.is_empty() {
8243            let keep = if stmt.distinct {
8244                None
8245            } else {
8246                stmt.limit_literal()
8247                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
8248            };
8249            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
8250            partial_sort_tagged(&mut tagged, keep, &descs);
8251        }
8252        let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
8253        if stmt.distinct {
8254            output_rows = dedup_rows(output_rows);
8255        }
8256        apply_offset_and_limit(
8257            &mut output_rows,
8258            stmt.offset_literal(),
8259            stmt.limit_literal(),
8260        );
8261        let columns: Vec<ColumnSchema> = projection
8262            .into_iter()
8263            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8264            .collect();
8265        Ok(QueryResult::Rows {
8266            columns,
8267            rows: output_rows,
8268        })
8269    }
8270}
8271
8272/// One row-producing projection: an expression to evaluate, the resulting
8273/// column's user-visible name, its inferred type, and nullability.
8274#[derive(Debug, Clone)]
8275struct ProjectedItem {
8276    expr: Expr,
8277    output_name: String,
8278    ty: DataType,
8279    nullable: bool,
8280}
8281
8282/// Dedupe a row set, preserving first-seen order. `Row`'s `PartialEq` is
8283/// structural (`Vec<Value>` ⇒ pairwise `Value` equality), which gives SQL
8284/// `NULL = NULL → TRUE` and `NaN = NaN → FALSE`. The first agrees with
8285/// the spec's "two NULLs are not distinct"; the second is a tolerated
8286/// quirk for v1 (no NaN literals are reachable from the SQL surface).
8287fn dedup_rows(rows: Vec<Row>) -> Vec<Row> {
8288    let mut out: Vec<Row> = Vec::with_capacity(rows.len());
8289    for r in rows {
8290        if !out.iter().any(|seen| seen == &r) {
8291            out.push(r);
8292        }
8293    }
8294    out
8295}
8296
8297/// Coerce a `Value` to an `f64` sort key for ORDER BY. Numbers map directly;
8298/// NULL sorts last (treated as `+∞`); booleans are 0.0 / 1.0; text uses lex
8299/// order via the byte values; vectors are not sortable.
8300fn value_to_order_key(v: &Value) -> Result<f64, EngineError> {
8301    match v {
8302        Value::Null => Ok(f64::INFINITY),
8303        Value::SmallInt(n) => Ok(f64::from(*n)),
8304        Value::Int(n) => Ok(f64::from(*n)),
8305        Value::Date(d) => Ok(f64::from(*d)),
8306        #[allow(clippy::cast_precision_loss)]
8307        Value::Timestamp(t) => Ok(*t as f64),
8308        // v7.17.0 Phase 3.P0-32 — PG TIME ordered by underlying
8309        // i64 microseconds (matches wall-clock ordering).
8310        #[allow(clippy::cast_precision_loss)]
8311        Value::Time(us) => Ok(*us as f64),
8312        // v7.17.0 Phase 3.P0-33 — MySQL YEAR ordered by underlying
8313        // u16 (matches calendar ordering; zero-year sentinel
8314        // sorts before 1901).
8315        Value::Year(y) => Ok(f64::from(*y)),
8316        // v7.17.0 Phase 3.P0-34 — PG TIMETZ ordered by the
8317        // UTC-equivalent microseconds (local wall - offset). Two
8318        // values for the same physical instant in different zones
8319        // sort equal — matches PG TIMETZ index behaviour.
8320        #[allow(clippy::cast_precision_loss)]
8321        Value::TimeTz { us, offset_secs } => Ok((us - i64::from(*offset_secs) * 1_000_000) as f64),
8322        // v7.17.0 Phase 3.P0-35 — PG MONEY ordered by i64 cents.
8323        #[allow(clippy::cast_precision_loss)]
8324        Value::Money(c) => Ok(*c as f64),
8325        // v7.17.0 Phase 3.P0-38 — range ordering is not supported
8326        // in v7.17.0 (needs lex-then-inclusivity tiebreak).
8327        Value::Range { .. } => Err(EngineError::Unsupported(
8328            "ORDER BY of a range value is not supported in v7.17.0".into(),
8329        )),
8330        // v7.17.0 Phase 3.P0-39 — hstore is not orderable.
8331        Value::Hstore(_) => Err(EngineError::Unsupported(
8332            "ORDER BY of a hstore value is not supported".into(),
8333        )),
8334        // v7.17.0 Phase 3.P0-40 — 2D arrays not orderable.
8335        Value::IntArray2D(_) | Value::BigIntArray2D(_) | Value::TextArray2D(_) => Err(
8336            EngineError::Unsupported("ORDER BY of a 2D array is not supported in v7.17.0".into()),
8337        ),
8338        #[allow(clippy::cast_precision_loss)]
8339        Value::Numeric { scaled, scale } => {
8340            // Scaled integer / 10^scale, computed via f64 for sort
8341            // ordering only. Precision losses here only matter for
8342            // ORDER BY tie-breaks well past 15 significant digits.
8343            // `f64::powi` lives in std; we hand-roll the loop so the
8344            // no_std engine crate doesn't need it.
8345            let mut divisor = 1.0_f64;
8346            for _ in 0..*scale {
8347                divisor *= 10.0;
8348            }
8349            Ok((*scaled as f64) / divisor)
8350        }
8351        #[allow(clippy::cast_precision_loss)]
8352        Value::BigInt(n) => Ok(*n as f64),
8353        Value::Float(x) => Ok(*x),
8354        Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
8355        Value::Text(s) => {
8356            // Lex order by codepoints — good enough for ORDER BY name.
8357            // Map first 8 bytes packed into u64 as a coarse key; ties fall to
8358            // partial_cmp Equal. v1.x can swap in a real string comparator.
8359            let mut key: u64 = 0;
8360            for &b in s.as_bytes().iter().take(8) {
8361                key = (key << 8) | u64::from(b);
8362            }
8363            #[allow(clippy::cast_precision_loss)]
8364            Ok(key as f64)
8365        }
8366        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
8367            Err(EngineError::Unsupported(
8368                "ORDER BY of a raw vector column is not meaningful — use `<->`".into(),
8369            ))
8370        }
8371        Value::Interval { .. } => Err(EngineError::Unsupported(
8372            "ORDER BY of an INTERVAL is not supported in v2.11 \
8373             (months vs micros has no single canonical ordering)"
8374                .into(),
8375        )),
8376        Value::Json(_) => Err(EngineError::Unsupported(
8377            "ORDER BY of a JSON value is not supported — cast the document to text first".into(),
8378        )),
8379        // v7.5.0 — Value is #[non_exhaustive]; future variants need
8380        // an explicit ORDER BY mapping. Surface as Unsupported until
8381        // engine support is added.
8382        _ => Err(EngineError::Unsupported(
8383            "ORDER BY of this value type is not supported".into(),
8384        )),
8385    }
8386}
8387
8388/// Try to plan a WHERE clause as an equality lookup against an existing
8389/// index. Returns the candidate row indices on success; `None` means the
8390/// caller should fall back to a full scan.
8391///
8392/// v0.8 recognises a single top-level `col = literal` (in either operand
8393/// order). AND chains and range scans land in later milestones.
8394/// Look for `ORDER BY col <dist-op> literal LIMIT k` against an
8395/// NSW-indexed vector column. Recognised distance ops: `<->` (L2),
8396/// `<#>` (inner product), `<=>` (cosine). When a WHERE clause is
8397/// present, the planner does an "over-fetch and filter" pass — it
8398/// asks the graph for `k * over_fetch` candidates, evaluates WHERE
8399/// against each, and trims back to `k`. Returns the row indices in
8400/// ascending-distance order when the plan applies.
8401fn try_nsw_knn(
8402    stmt: &SelectStatement,
8403    table: &Table,
8404    schema_cols: &[ColumnSchema],
8405    table_alias: &str,
8406) -> Option<Vec<usize>> {
8407    if stmt.distinct {
8408        return None;
8409    }
8410    let limit = usize::try_from(stmt.limit_literal()?).ok()?;
8411    if limit == 0 {
8412        return None;
8413    }
8414    // v6.4.0 — NSW kNN dispatch needs a single ORDER BY key on the
8415    // distance metric. Multi-key ORDER BY falls through to the
8416    // generic sort path.
8417    if stmt.order_by.len() != 1 {
8418        return None;
8419    }
8420    let order = &stmt.order_by[0];
8421    // NSW kNN returns rows ascending by distance — DESC inverts the
8422    // natural order, so the planner can't handle it without a sort
8423    // pass. Fall back to the generic ORDER BY path.
8424    if order.desc {
8425        return None;
8426    }
8427    let Expr::Binary { lhs, op, rhs } = &order.expr else {
8428        return None;
8429    };
8430    let metric = match op {
8431        BinOp::L2Distance => spg_storage::NswMetric::L2,
8432        BinOp::InnerProduct => spg_storage::NswMetric::InnerProduct,
8433        BinOp::CosineDistance => spg_storage::NswMetric::Cosine,
8434        _ => return None,
8435    };
8436    // Accept both `col <op> literal` and `literal <op> col`.
8437    let ((Expr::Column(col), literal) | (literal, Expr::Column(col))) =
8438        (lhs.as_ref(), rhs.as_ref())
8439    else {
8440        return None;
8441    };
8442    if let Some(q) = &col.qualifier
8443        && q != table_alias
8444    {
8445        return None;
8446    }
8447    let col_pos = schema_cols.iter().position(|s| s.name == col.name)?;
8448    let query = literal_to_vector(literal)?;
8449    let idx = spg_storage::nsw_index_on(table, col_pos)?;
8450    if let Some(where_expr) = &stmt.where_ {
8451        // Over-fetch and filter. The factor (10×) is a heuristic that
8452        // covers typical selectivity for the corpus tests; v2.x will
8453        // make it configurable.
8454        let over_fetch = limit.saturating_mul(10).max(NSW_OVER_FETCH_FLOOR);
8455        let candidates = spg_storage::nsw_query(table, &idx.name, &query, over_fetch, metric);
8456        let ctx = EvalContext::new(schema_cols, Some(table_alias));
8457        let mut kept: Vec<usize> = Vec::with_capacity(limit);
8458        for i in candidates {
8459            let row = &table.rows()[i];
8460            let cond = eval::eval_expr(where_expr, row, &ctx).ok()?;
8461            if matches!(cond, Value::Bool(true)) {
8462                kept.push(i);
8463                if kept.len() >= limit {
8464                    break;
8465                }
8466            }
8467        }
8468        Some(kept)
8469    } else {
8470        Some(spg_storage::nsw_query(
8471            table, &idx.name, &query, limit, metric,
8472        ))
8473    }
8474}
8475
8476/// Lower bound on the over-fetch pool when WHERE is present — even
8477/// for tiny `LIMIT 1` queries we keep enough candidates to absorb a
8478/// few WHERE rejections.
8479const NSW_OVER_FETCH_FLOOR: usize = 32;
8480
8481/// Pull a `Vec<f32>` out of a literal-or-cast expression. Returns
8482/// `None` for anything we can't fold at plan time.
8483fn literal_to_vector(e: &Expr) -> Option<Vec<f32>> {
8484    match e {
8485        Expr::Literal(Literal::Vector(v)) => Some(v.clone()),
8486        Expr::Cast { expr, .. } => literal_to_vector(expr),
8487        _ => None,
8488    }
8489}
8490
8491/// Materialise rows in a planner-supplied order (used by the NSW path)
8492/// without re-running ORDER BY. The projection + LIMIT slot mirror the
8493/// equivalent block in `exec_bare_select`.
8494fn materialise_in_order(
8495    stmt: &SelectStatement,
8496    table: &Table,
8497    schema_cols: &[ColumnSchema],
8498    table_alias: &str,
8499    ordered_rows: &[usize],
8500) -> Result<QueryResult, EngineError> {
8501    let ctx = EvalContext::new(schema_cols, Some(table_alias));
8502    let projection = build_projection(&stmt.items, schema_cols, table_alias)?;
8503    let mut output_rows: Vec<Row> = Vec::with_capacity(ordered_rows.len());
8504    for &i in ordered_rows {
8505        let row = &table.rows()[i];
8506        let mut values = Vec::with_capacity(projection.len());
8507        for p in &projection {
8508            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8509        }
8510        output_rows.push(Row::new(values));
8511    }
8512    apply_offset_and_limit(
8513        &mut output_rows,
8514        stmt.offset_literal(),
8515        stmt.limit_literal(),
8516    );
8517    let columns: Vec<ColumnSchema> = projection
8518        .into_iter()
8519        .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8520        .collect();
8521    Ok(QueryResult::Rows {
8522        columns,
8523        rows: output_rows,
8524    })
8525}
8526
8527fn try_index_seek<'a>(
8528    where_expr: &Expr,
8529    schema_cols: &[ColumnSchema],
8530    catalog: &'a Catalog,
8531    table: &'a Table,
8532    table_alias: &str,
8533) -> Option<Vec<Cow<'a, Row>>> {
8534    // v7.11.3 — recurse through top-level `AND` so a PG-style
8535    // composite predicate like `WHERE id = 1 AND created_at > $1`
8536    // still hits the index on `id`. The caller re-applies the
8537    // full WHERE expression to each returned row, so dropping the
8538    // residual conjuncts here is correct — the index just narrows
8539    // the candidate set.
8540    if let Expr::Binary {
8541        lhs,
8542        op: BinOp::And,
8543        rhs,
8544    } = where_expr
8545    {
8546        // Try LHS first (typical convention: leading equality on
8547        // the indexed column comes first in user-written SQL).
8548        if let Some(rows) = try_index_seek(lhs, schema_cols, catalog, table, table_alias) {
8549            return Some(rows);
8550        }
8551        return try_index_seek(rhs, schema_cols, catalog, table, table_alias);
8552    }
8553    let Expr::Binary {
8554        lhs,
8555        op: BinOp::Eq,
8556        rhs,
8557    } = where_expr
8558    else {
8559        return None;
8560    };
8561    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
8562        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
8563    let idx = table.index_on(col_pos)?;
8564    let key = IndexKey::from_value(&value)?;
8565    let locators = idx.lookup_eq(&key);
8566    let table_name = table.schema().name.as_str();
8567    // v5.1: each locator dispatches to either the hot tier (zero-
8568    // copy borrow of `table.rows()[i]`) or a cold-tier segment
8569    // (one page read + dense row decode, ~µs scale). Cold rows are
8570    // returned as `Cow::Owned` so the caller's `&Row` iteration
8571    // doesn't see a tier distinction; pre-freezer (no cold
8572    // segments loaded) every locator is `Hot` and every entry is
8573    // `Cow::Borrowed` — identical cost to the pre-v5.1 path.
8574    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(locators.len());
8575    for loc in locators {
8576        match *loc {
8577            spg_storage::RowLocator::Hot(i) => {
8578                if let Some(row) = table.rows().get(i) {
8579                    out.push(Cow::Borrowed(row));
8580                }
8581            }
8582            spg_storage::RowLocator::Cold { segment_id, .. } => {
8583                if let Some(row) = catalog.resolve_cold_locator(table_name, segment_id, &key) {
8584                    out.push(Cow::Owned(row));
8585                }
8586            }
8587        }
8588    }
8589    Some(out)
8590}
8591
8592/// v7.12.3 — GIN-accelerated candidate seek for `WHERE col @@ <ts_query>`.
8593///
8594/// Recurses through top-level `AND` like [`try_index_seek`] so a
8595/// composite predicate `WHERE search_vector @@ q AND id > $1` still
8596/// hits the GIN index on `search_vector` — the caller re-applies the
8597/// full WHERE expression to each returned candidate, so dropping the
8598/// `id > $1` residual here stays semantically correct.
8599///
8600/// Returns `None` when:
8601///   - no leaf is a `col @@ <rhs>` shape on a GIN-indexed column;
8602///   - the RHS can't be const-evaluated to a `Value::TsQuery`
8603///     (typically because it references row columns);
8604///   - the resolved `TsQuery` uses query shapes the MVP doesn't
8605///     accelerate (`Not`, `Phrase` — those fall through to full scan).
8606///
8607/// On `Some(rows)` the caller iterates only `rows` and re-evaluates
8608/// the full `@@` predicate per row, so an over-approximate candidate
8609/// set is safe.
8610fn try_gin_seek<'a>(
8611    where_expr: &Expr,
8612    schema_cols: &[ColumnSchema],
8613    catalog: &'a Catalog,
8614    table: &'a Table,
8615    table_alias: &str,
8616    ctx: &eval::EvalContext<'_>,
8617) -> Option<Vec<Cow<'a, Row>>> {
8618    if let Expr::Binary {
8619        lhs,
8620        op: BinOp::And,
8621        rhs,
8622    } = where_expr
8623    {
8624        if let Some(rows) = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx) {
8625            return Some(rows);
8626        }
8627        return try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx);
8628    }
8629    // v7.17.0 Phase 3.P0-44 — MySQL `MATCH(col1, col2) AGAINST (...)`
8630    // desugars into `(to_tsvector(col1) @@ q) OR (to_tsvector(col2) @@ q)`
8631    // in the parser. To accelerate the multi-column case, walk OR the same
8632    // way we walk AND: only emit a candidate set if BOTH sides can seek
8633    // (otherwise the OR result is unbounded and we must fall through to
8634    // the full scan). Candidates are union'd; the caller's WHERE re-eval
8635    // verifies the full predicate per row, so duplicates / supersets stay
8636    // semantically safe.
8637    if let Expr::Binary {
8638        lhs,
8639        op: BinOp::Or,
8640        rhs,
8641    } = where_expr
8642    {
8643        let left = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx)?;
8644        let right = try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx)?;
8645        let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(left.len() + right.len());
8646        out.extend(left);
8647        out.extend(right);
8648        return Some(out);
8649    }
8650    let Expr::Binary {
8651        lhs,
8652        op: BinOp::TsMatch,
8653        rhs,
8654    } = where_expr
8655    else {
8656        return None;
8657    };
8658    // Either side can be the column; pgvector idiom (`vec @@ q`)
8659    // hits the first arm, FROM-clause-derived (`plainto_tsquery($1)
8660    // q ... WHERE search_vector @@ q`) the same. CROSS JOIN derived
8661    // tables resolve `q` to a Column too.
8662    let (col_pos, query) = resolve_gin_col_query(lhs, rhs, schema_cols, table_alias, ctx)
8663        .or_else(|| resolve_gin_col_query(rhs, lhs, schema_cols, table_alias, ctx))?;
8664    // v7.17.0 Phase 3.P0-44 — MySQL `FULLTEXT KEY` builds a
8665    // `IndexKind::GinFulltext` posting list (Phase 2.2). It shares
8666    // the same `gin_lookup_word` shape as the tsvector-typed GIN,
8667    // so the MATCH-AGAINST `@@` predicate (desugared by the parser
8668    // into `to_tsvector(col) @@ plainto_tsquery('term')`) routes
8669    // through the same candidate-set seek.
8670    let idx = table
8671        .indices()
8672        .iter()
8673        .find(|i| i.column_position == col_pos && (i.is_gin() || i.is_gin_fulltext()))?;
8674    let candidates = gin_query_candidates(idx, &query)?;
8675    let _ = catalog; // cold-tier row resolution unused in MVP; see below.
8676    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(candidates.len());
8677    for loc in candidates {
8678        match loc {
8679            spg_storage::RowLocator::Hot(i) => {
8680                if let Some(row) = table.rows().get(i) {
8681                    out.push(Cow::Borrowed(row));
8682                }
8683            }
8684            // GIN cold-tier rows in the MVP: skipped, matching the
8685            // full-scan `@@` path which itself only iterates
8686            // `table.rows()` (hot tier). When v7.13+ adds cold-tier
8687            // scan-time materialisation for `@@`, the parallel
8688            // resolution lands here; until then both paths see the
8689            // same hot-only candidate set so correctness is preserved.
8690            spg_storage::RowLocator::Cold { .. } => {}
8691        }
8692    }
8693    Some(out)
8694}
8695
8696/// v7.15.0 — trigram-GIN-accelerated candidate seek for
8697/// `WHERE col LIKE '<pat>'` and `WHERE col ILIKE '<pat>'` when
8698/// the column has a `gin_trgm_ops` GIN index.
8699///
8700/// Walks top-level `AND` so multi-predicate WHEREs (`col LIKE
8701/// 'foo%' AND id > 1`) still hit the trigram index; the caller
8702/// re-evaluates the full WHERE per candidate row, so dropping
8703/// non-LIKE conjuncts here stays semantically correct.
8704///
8705/// Returns `None` when:
8706///   - no leaf is `col LIKE/ILIKE <literal>` on a trigram-GIN-
8707///     indexed column;
8708///   - the pattern's literal runs are too short to constrain
8709///     (pattern decomposes into `< 3`-char runs, e.g. `%ab%`);
8710///   - the pattern doesn't const-evaluate to a TEXT.
8711fn try_trgm_seek<'a>(
8712    where_expr: &Expr,
8713    schema_cols: &[ColumnSchema],
8714    table: &'a Table,
8715    table_alias: &str,
8716) -> Option<Vec<Cow<'a, Row>>> {
8717    if let Expr::Binary {
8718        lhs,
8719        op: BinOp::And,
8720        rhs,
8721    } = where_expr
8722    {
8723        if let Some(rows) = try_trgm_seek(lhs, schema_cols, table, table_alias) {
8724            return Some(rows);
8725        }
8726        return try_trgm_seek(rhs, schema_cols, table, table_alias);
8727    }
8728    // LIKE node is what carries the column reference + pattern.
8729    // ILIKE is the same AST node — PG's LIKE/ILIKE both lower
8730    // through `Expr::Like { expr, pattern, negated }`. The trigram
8731    // index posting-list keys are already lower-cased and
8732    // case-folded, so we only need the pattern's literal runs.
8733    let Expr::Like { expr, pattern, .. } = where_expr else {
8734        return None;
8735    };
8736    // Column side.
8737    let Expr::Column(c) = expr.as_ref() else {
8738        return None;
8739    };
8740    if let Some(q) = &c.qualifier
8741        && q != table_alias
8742    {
8743        return None;
8744    }
8745    let col_pos = schema_cols
8746        .iter()
8747        .position(|s| s.name.eq_ignore_ascii_case(&c.name))?;
8748    // Index must exist on that column AND be a trigram-GIN.
8749    let idx = table
8750        .indices()
8751        .iter()
8752        .find(|i| i.column_position == col_pos && i.is_gin_trgm())?;
8753    // Pattern side must be a literal TEXT — anything else (column
8754    // ref, function call, parameter that hasn't been bound yet)
8755    // falls through to full scan.
8756    let Expr::Literal(spg_sql::ast::Literal::String(pat)) = pattern.as_ref() else {
8757        return None;
8758    };
8759    let trigrams = spg_storage::trgm::trigrams_from_like_pattern(pat)?;
8760    // Intersect every trigram's posting list. Empty intersection
8761    // → empty candidate set (caller short-circuits its row loop).
8762    let mut iter = trigrams.iter();
8763    let first = iter.next()?;
8764    let mut acc: Vec<spg_storage::RowLocator> = {
8765        let mut v = idx.gin_trgm_lookup(first).to_vec();
8766        v.sort_by_key(locator_sort_key);
8767        v.dedup_by_key(|l| locator_sort_key(l));
8768        v
8769    };
8770    for tri in iter {
8771        let mut next: Vec<spg_storage::RowLocator> = idx.gin_trgm_lookup(tri).to_vec();
8772        next.sort_by_key(locator_sort_key);
8773        next.dedup_by_key(|l| locator_sort_key(l));
8774        // Sorted-merge intersection.
8775        let mut merged: Vec<spg_storage::RowLocator> =
8776            Vec::with_capacity(acc.len().min(next.len()));
8777        let (mut i, mut j) = (0usize, 0usize);
8778        while i < acc.len() && j < next.len() {
8779            let lk = locator_sort_key(&acc[i]);
8780            let rk = locator_sort_key(&next[j]);
8781            match lk.cmp(&rk) {
8782                core::cmp::Ordering::Less => i += 1,
8783                core::cmp::Ordering::Greater => j += 1,
8784                core::cmp::Ordering::Equal => {
8785                    merged.push(acc[i]);
8786                    i += 1;
8787                    j += 1;
8788                }
8789            }
8790        }
8791        acc = merged;
8792        if acc.is_empty() {
8793            break;
8794        }
8795    }
8796    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(acc.len());
8797    for loc in acc {
8798        if let spg_storage::RowLocator::Hot(i) = loc
8799            && let Some(row) = table.rows().get(i)
8800        {
8801            out.push(Cow::Borrowed(row));
8802        }
8803        // Cold-tier rows: skipped in MVP (same as try_gin_seek).
8804    }
8805    Some(out)
8806}
8807
8808/// v7.12.3 — extract `(column_position, TsQueryAst)` when one side of
8809/// the binary is a column reference to a GIN-indexed tsvector column
8810/// and the other side const-evaluates to a `Value::TsQuery`. Returns
8811/// `None` if the column reference is for the wrong table alias, or if
8812/// the RHS expression depends on row data.
8813fn resolve_gin_col_query(
8814    col_side: &Expr,
8815    query_side: &Expr,
8816    schema_cols: &[ColumnSchema],
8817    table_alias: &str,
8818    ctx: &eval::EvalContext<'_>,
8819) -> Option<(usize, spg_storage::TsQueryAst)> {
8820    // v7.17.0 Phase 3.P0-44 — the MATCH AGAINST desugar wraps the
8821    // column in `to_tsvector('simple', col)`, so we peel that wrapper
8822    // before the column lookup. Direct `col @@ tsquery` paths (the
8823    // tsvector-typed v7.12 surface) skip the wrapper entirely.
8824    let column = match col_side {
8825        Expr::Column(c) => c,
8826        Expr::FunctionCall { name, args }
8827            if name.eq_ignore_ascii_case("to_tsvector") && !args.is_empty() =>
8828        {
8829            // PG `to_tsvector` accepts either `to_tsvector(col)` or
8830            // `to_tsvector(config, col)`. In both shapes the column
8831            // we care about is the final argument.
8832            if let Expr::Column(c) = args.last().unwrap() {
8833                c
8834            } else {
8835                return None;
8836            }
8837        }
8838        _ => return None,
8839    };
8840    let c = column;
8841    if let Some(q) = &c.qualifier
8842        && q != table_alias
8843    {
8844        return None;
8845    }
8846    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
8847    // Const-evaluate the query side with an empty row — fails fast
8848    // (with a `ColumnNotFound` / similar) if the expression actually
8849    // depends on row data, which is exactly the bail signal we want.
8850    let empty_row = Row::new(Vec::new());
8851    let v = eval::eval_expr(query_side, &empty_row, ctx).ok()?;
8852    let Value::TsQuery(q) = v else { return None };
8853    Some((pos, q))
8854}
8855
8856/// v7.12.3 — walk a `TsQueryAst` against an [`IndexKind::Gin`] index
8857/// to produce a candidate row-locator set. Returns `None` for query
8858/// shapes the MVP doesn't accelerate (`Not` / `Phrase` — both bail to
8859/// full scan since their semantics need either complementation across
8860/// the whole row set or positional verification beyond what the
8861/// posting list carries).
8862///
8863/// Candidate sets are over-approximate — the caller re-applies the
8864/// full `@@` predicate per row, so reporting "row was in some
8865/// posting list" without verifying positions / weights stays correct.
8866fn gin_query_candidates(
8867    idx: &spg_storage::Index,
8868    query: &spg_storage::TsQueryAst,
8869) -> Option<Vec<spg_storage::RowLocator>> {
8870    use spg_storage::TsQueryAst;
8871    match query {
8872        TsQueryAst::Term { word, .. } => {
8873            let mut v: Vec<spg_storage::RowLocator> = idx.gin_lookup_word(word).to_vec();
8874            v.sort_by_key(locator_sort_key);
8875            v.dedup_by_key(|l| locator_sort_key(l));
8876            Some(v)
8877        }
8878        TsQueryAst::And(l, r) => {
8879            let mut left = gin_query_candidates(idx, l)?;
8880            let mut right = gin_query_candidates(idx, r)?;
8881            left.sort_by_key(locator_sort_key);
8882            right.sort_by_key(locator_sort_key);
8883            // Sorted-merge intersection.
8884            let mut out: Vec<spg_storage::RowLocator> = Vec::new();
8885            let (mut i, mut j) = (0usize, 0usize);
8886            while i < left.len() && j < right.len() {
8887                let lk = locator_sort_key(&left[i]);
8888                let rk = locator_sort_key(&right[j]);
8889                match lk.cmp(&rk) {
8890                    core::cmp::Ordering::Less => i += 1,
8891                    core::cmp::Ordering::Greater => j += 1,
8892                    core::cmp::Ordering::Equal => {
8893                        out.push(left[i]);
8894                        i += 1;
8895                        j += 1;
8896                    }
8897                }
8898            }
8899            Some(out)
8900        }
8901        TsQueryAst::Or(l, r) => {
8902            let mut out = gin_query_candidates(idx, l)?;
8903            out.extend(gin_query_candidates(idx, r)?);
8904            out.sort_by_key(locator_sort_key);
8905            out.dedup_by_key(|l| locator_sort_key(l));
8906            Some(out)
8907        }
8908        // Not / Phrase bail to full scan in the MVP. Not needs
8909        // complementation against the whole row set (not represented
8910        // in the posting-list view); Phrase needs positional
8911        // verification beyond what `word → rows` carries.
8912        TsQueryAst::Not(_) | TsQueryAst::Phrase { .. } => None,
8913    }
8914}
8915
8916/// v7.12.3 — total ordering on `RowLocator` for sort/dedup purposes
8917/// inside the GIN intersection / union loops. Hot rows order by their
8918/// row index; Cold rows order after all Hot rows, then by
8919/// `(segment_id, the cold sub-key)`.
8920fn locator_sort_key(l: &spg_storage::RowLocator) -> (u8, u64, u64) {
8921    match *l {
8922        spg_storage::RowLocator::Hot(i) => (0, i as u64, 0),
8923        spg_storage::RowLocator::Cold {
8924            segment_id,
8925            page_offset,
8926        } => (1, u64::from(segment_id), u64::from(page_offset)),
8927    }
8928}
8929
8930/// v5.2.3: extract `(column_position, IndexKey)` when `where_expr`
8931/// is a simple `col = literal` predicate suitable for a `BTree` index
8932/// seek. Used by `exec_update_cancel` / `exec_delete_cancel` to
8933/// decide whether a write touches a cold-tier row (which requires
8934/// promote-on-write / shadow-on-delete) before falling through to
8935/// the hot-tier row walk.
8936///
8937/// Returns `None` for any predicate shape the planner can't push
8938/// down to an index seek — complex WHERE clauses always take the
8939/// hot-only path (cold rows are immutable to non-indexed writes
8940/// until a future scan-fanout sub-version).
8941fn try_pk_predicate(
8942    where_expr: &Expr,
8943    schema_cols: &[ColumnSchema],
8944    table_alias: &str,
8945) -> Option<(usize, IndexKey)> {
8946    let Expr::Binary {
8947        lhs,
8948        op: BinOp::Eq,
8949        rhs,
8950    } = where_expr
8951    else {
8952        return None;
8953    };
8954    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
8955        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
8956    let key = IndexKey::from_value(&value)?;
8957    Some((col_pos, key))
8958}
8959
8960fn resolve_col_literal_pair(
8961    col_side: &Expr,
8962    lit_side: &Expr,
8963    schema_cols: &[ColumnSchema],
8964    table_alias: &str,
8965) -> Option<(usize, Value)> {
8966    let Expr::Column(c) = col_side else {
8967        return None;
8968    };
8969    if let Some(q) = &c.qualifier
8970        && q != table_alias
8971    {
8972        return None;
8973    }
8974    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
8975    let Expr::Literal(l) = lit_side else {
8976        return None;
8977    };
8978    let v = match l {
8979        Literal::Integer(n) => {
8980            if let Ok(small) = i32::try_from(*n) {
8981                Value::Int(small)
8982            } else {
8983                Value::BigInt(*n)
8984            }
8985        }
8986        Literal::Float(x) => Value::Float(*x),
8987        Literal::String(s) => Value::Text(s.clone()),
8988        Literal::Bool(b) => Value::Bool(*b),
8989        Literal::Null => Value::Null,
8990        // Vector and Interval literals can't be used as B-tree index keys.
8991        // Tell the planner to fall back to full-scan.
8992        Literal::Vector(_) | Literal::Interval { .. } => return None,
8993    };
8994    Some((pos, v))
8995}
8996
8997/// Find the schema entry that a SELECT-list `Expr::Column` refers to.
8998/// Mirrors `resolve_column` in `eval.rs`, but returns a proper
8999/// `EngineError` so the projection-build path keeps `UnknownQualifier`
9000/// vs `ColumnNotFound` distinct.
9001fn resolve_projection_column<'a>(
9002    c: &ColumnName,
9003    schema_cols: &'a [ColumnSchema],
9004    table_alias: &str,
9005) -> Result<&'a ColumnSchema, EngineError> {
9006    if let Some(q) = &c.qualifier {
9007        let composite = alloc::format!("{q}.{name}", name = c.name);
9008        if let Some(s) = schema_cols.iter().find(|s| s.name == composite) {
9009            return Ok(s);
9010        }
9011        // Single-table case: the qualifier may equal the active alias —
9012        // then look for the bare column name.
9013        if q == table_alias
9014            && let Some(s) = schema_cols.iter().find(|s| s.name == c.name)
9015        {
9016            return Ok(s);
9017        }
9018        // For multi-table schemas the qualifier is unknown only if no
9019        // column bears the "<q>." prefix. For single-table, the alias
9020        // mismatch alone is enough.
9021        let prefix = alloc::format!("{q}.");
9022        let qualifier_known =
9023            q == table_alias || schema_cols.iter().any(|s| s.name.starts_with(&prefix));
9024        if !qualifier_known {
9025            return Err(EngineError::Eval(EvalError::UnknownQualifier {
9026                qualifier: q.clone(),
9027            }));
9028        }
9029        return Err(EngineError::Eval(EvalError::ColumnNotFound {
9030            name: c.name.clone(),
9031        }));
9032    }
9033    if let Some(s) = schema_cols.iter().find(|s| s.name == c.name) {
9034        return Ok(s);
9035    }
9036    let suffix = alloc::format!(".{name}", name = c.name);
9037    let mut matches = schema_cols.iter().filter(|s| s.name.ends_with(&suffix));
9038    let first = matches.next();
9039    let extra = matches.next();
9040    match (first, extra) {
9041        (Some(s), None) => Ok(s),
9042        (Some(_), Some(_)) => Err(EngineError::Eval(EvalError::TypeMismatch {
9043            detail: alloc::format!("ambiguous column reference: {}", c.name),
9044        })),
9045        _ => Err(EngineError::Eval(EvalError::ColumnNotFound {
9046            name: c.name.clone(),
9047        })),
9048    }
9049}
9050
9051fn build_projection(
9052    items: &[SelectItem],
9053    schema_cols: &[ColumnSchema],
9054    table_alias: &str,
9055) -> Result<Vec<ProjectedItem>, EngineError> {
9056    let mut out = Vec::new();
9057    for item in items {
9058        match item {
9059            SelectItem::Wildcard => {
9060                for col in schema_cols {
9061                    out.push(ProjectedItem {
9062                        expr: Expr::Column(ColumnName {
9063                            qualifier: None,
9064                            name: col.name.clone(),
9065                        }),
9066                        output_name: col.name.clone(),
9067                        ty: col.ty,
9068                        nullable: col.nullable,
9069                    });
9070                }
9071            }
9072            SelectItem::Expr { expr, alias } => {
9073                // Plain column ref keeps full schema info (real type +
9074                // nullability). For compound expressions try the
9075                // describe-side function-return-type table first
9076                // (e.g. `SELECT now()` → Timestamptz, `SELECT
9077                // concat(…)` → Text). Falls back to nullable Text
9078                // for shapes the describe path can't resolve.
9079                if let Expr::Column(c) = expr {
9080                    let sch = resolve_projection_column(c, schema_cols, table_alias)?;
9081                    let output_name = alias.clone().unwrap_or_else(|| c.name.clone());
9082                    out.push(ProjectedItem {
9083                        expr: expr.clone(),
9084                        output_name,
9085                        ty: sch.ty,
9086                        nullable: sch.nullable,
9087                    });
9088                } else if let Some(shape) = describe::describe_expr(expr, schema_cols) {
9089                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
9090                    out.push(ProjectedItem {
9091                        expr: expr.clone(),
9092                        output_name,
9093                        ty: shape.ty,
9094                        nullable: shape.nullable,
9095                    });
9096                } else {
9097                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
9098                    out.push(ProjectedItem {
9099                        expr: expr.clone(),
9100                        output_name,
9101                        ty: DataType::Text,
9102                        nullable: true,
9103                    });
9104                }
9105            }
9106        }
9107    }
9108    Ok(out)
9109}
9110
9111/// Promote an integer to a NUMERIC value at the requested scale.
9112/// Rejects values that, after scaling, would overflow the column's
9113/// precision budget.
9114fn numeric_from_integer(
9115    n: i128,
9116    precision: u8,
9117    scale: u8,
9118    col_name: &str,
9119) -> Result<Value, EngineError> {
9120    let factor = pow10_i128(scale);
9121    let scaled = n.checked_mul(factor).ok_or_else(|| {
9122        EngineError::Unsupported(alloc::format!(
9123            "integer overflow scaling value for column `{col_name}` to scale {scale}"
9124        ))
9125    })?;
9126    check_precision(scaled, precision, col_name)?;
9127    Ok(Value::Numeric { scaled, scale })
9128}
9129
9130/// Float → NUMERIC. Uses round-half-away-from-zero on `x * 10^scale`,
9131/// then verifies the result fits the column's precision.
9132#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
9133fn numeric_from_float(
9134    x: f64,
9135    precision: u8,
9136    scale: u8,
9137    col_name: &str,
9138) -> Result<Value, EngineError> {
9139    if !x.is_finite() {
9140        return Err(EngineError::Unsupported(alloc::format!(
9141            "cannot store non-finite float in NUMERIC column `{col_name}`"
9142        )));
9143    }
9144    let mut factor = 1.0_f64;
9145    for _ in 0..scale {
9146        factor *= 10.0;
9147    }
9148    // Round half-away-from-zero by biasing then casting (`as i128`
9149    // truncates toward zero, so the bias + truncation gives the
9150    // desired rounding). `f64::floor` / `ceil` live in std; we don't
9151    // need them — the cast handles the truncation step.
9152    let shifted = x * factor;
9153    let biased = if shifted >= 0.0 {
9154        shifted + 0.5
9155    } else {
9156        shifted - 0.5
9157    };
9158    // Range-check before casting back to i128 — the cast itself is
9159    // saturating in Rust, which would silently truncate huge inputs.
9160    if !(-1e38..=1e38).contains(&biased) {
9161        return Err(EngineError::Unsupported(alloc::format!(
9162            "value {x} overflows NUMERIC range for column `{col_name}`"
9163        )));
9164    }
9165    let scaled = biased as i128;
9166    check_precision(scaled, precision, col_name)?;
9167    Ok(Value::Numeric { scaled, scale })
9168}
9169
9170/// v7.17.0 Phase 3.P0-67 — parse PG-canonical decimal text into
9171/// `(mantissa: i128, source_scale: u8)`. Accepts optional sign,
9172/// optional integer part, optional fractional part. Rejects
9173/// scientific notation, embedded spaces, locale-specific
9174/// thousand separators. Returns None on bad input — coerce_value
9175/// turns that into a TypeMismatch error.
9176fn parse_numeric_text(s: &str) -> Option<(i128, u8)> {
9177    let s = s.trim();
9178    if s.is_empty() {
9179        return None;
9180    }
9181    let (negative, rest) = match s.as_bytes()[0] {
9182        b'-' => (true, &s[1..]),
9183        b'+' => (false, &s[1..]),
9184        _ => (false, s),
9185    };
9186    if rest.is_empty() {
9187        return None;
9188    }
9189    // Reject scientific notation — bigdecimal collapses it before
9190    // hitting the wire, and we want a clear error if a stray `e`
9191    // sneaks in.
9192    if rest.bytes().any(|b| b == b'e' || b == b'E') {
9193        return None;
9194    }
9195    let (int_part, frac_part) = match rest.find('.') {
9196        Some(idx) => (&rest[..idx], &rest[idx + 1..]),
9197        None => (rest, ""),
9198    };
9199    if int_part.is_empty() && frac_part.is_empty() {
9200        return None;
9201    }
9202    if int_part.bytes().any(|b| !b.is_ascii_digit()) {
9203        return None;
9204    }
9205    if frac_part.bytes().any(|b| !b.is_ascii_digit()) {
9206        return None;
9207    }
9208    let scale_u32 = u32::try_from(frac_part.len()).ok()?;
9209    if scale_u32 > u32::from(u8::MAX) {
9210        return None;
9211    }
9212    let scale = scale_u32 as u8;
9213    let mut digits = alloc::string::String::with_capacity(int_part.len() + frac_part.len() + 1);
9214    if negative {
9215        digits.push('-');
9216    }
9217    digits.push_str(int_part);
9218    digits.push_str(frac_part);
9219    // Strip a leading "+0..0" so parse doesn't choke on "00" etc.
9220    let digits = if digits == "-" {
9221        return None;
9222    } else if digits.is_empty() {
9223        "0"
9224    } else {
9225        digits.as_str()
9226    };
9227    let mantissa: i128 = digits.parse().ok()?;
9228    Some((mantissa, scale))
9229}
9230
9231/// Move a Numeric value from `src_scale` to `dst_scale`. Going up
9232/// multiplies by 10; going down rounds half-away-from-zero.
9233fn numeric_rescale(
9234    scaled: i128,
9235    src_scale: u8,
9236    precision: u8,
9237    dst_scale: u8,
9238    col_name: &str,
9239) -> Result<Value, EngineError> {
9240    let new_scaled = if dst_scale >= src_scale {
9241        let bump = pow10_i128(dst_scale - src_scale);
9242        scaled.checked_mul(bump).ok_or_else(|| {
9243            EngineError::Unsupported(alloc::format!(
9244                "overflow rescaling NUMERIC for column `{col_name}`"
9245            ))
9246        })?
9247    } else {
9248        let drop = pow10_i128(src_scale - dst_scale);
9249        let half = drop / 2;
9250        if scaled >= 0 {
9251            (scaled + half) / drop
9252        } else {
9253            (scaled - half) / drop
9254        }
9255    };
9256    check_precision(new_scaled, precision, col_name)?;
9257    Ok(Value::Numeric {
9258        scaled: new_scaled,
9259        scale: dst_scale,
9260    })
9261}
9262
9263/// Drop the fractional part of a scaled integer, returning the integer
9264/// portion (toward zero). Used for NUMERIC → INT casts.
9265const fn numeric_truncate_to_integer(scaled: i128, scale: u8) -> i128 {
9266    if scale == 0 {
9267        return scaled;
9268    }
9269    let factor = pow10_i128_const(scale);
9270    scaled / factor
9271}
9272
9273/// Verify a scaled NUMERIC value fits the column's declared precision.
9274/// `precision == 0` is the "unconstrained" form (bare `NUMERIC`); we
9275/// skip the check there.
9276fn check_precision(scaled: i128, precision: u8, col_name: &str) -> Result<(), EngineError> {
9277    if precision == 0 {
9278        return Ok(());
9279    }
9280    let limit = pow10_i128(precision);
9281    if scaled.unsigned_abs() >= limit.unsigned_abs() {
9282        return Err(EngineError::Unsupported(alloc::format!(
9283            "NUMERIC value exceeds precision {precision} for column `{col_name}`"
9284        )));
9285    }
9286    Ok(())
9287}
9288
9289const fn pow10_i128_const(p: u8) -> i128 {
9290    let mut acc: i128 = 1;
9291    let mut i = 0;
9292    while i < p {
9293        acc *= 10;
9294        i += 1;
9295    }
9296    acc
9297}
9298
9299fn pow10_i128(p: u8) -> i128 {
9300    pow10_i128_const(p)
9301}
9302
9303/// Walk a parsed `Statement`, swapping any `NOW()` /
9304/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()` function calls for a
9305/// literal cast that wraps the engine's per-statement clock reading.
9306/// When `now_micros` is `None`, calls stay as-is and surface as
9307/// `unknown function` at eval time — keeps the error path explicit.
9308/// v4.10: pre-walk the WHERE / projection / etc. of a SELECT and
9309/// replace every subquery node with a materialised literal. SPG
9310/// only supports uncorrelated subqueries — the inner SELECT does
9311/// not see outer-row columns, so the result is the same for every
9312/// outer row and can be evaluated once.
9313///
9314/// Returns the rewritten statement; the caller passes this to the
9315/// regular row-loop executor which no longer sees Subquery nodes
9316/// in its tree.
9317impl Engine {
9318    /// v4.12 window executor. Implements `ROW_NUMBER` / `RANK` /
9319    /// `DENSE_RANK` and the partition-aware aggregates `SUM` /
9320    /// `AVG` / `COUNT` / `MIN` / `MAX`. The plan is:
9321    /// 1. Apply the WHERE filter.
9322    /// 2. For each unique `WindowFunction` node in the projection,
9323    ///    partition + sort, compute the per-row value.
9324    /// 3. Append the window values as synthetic columns (`__win_N`)
9325    ///    to the row schema.
9326    /// 4. Rewrite the projection to read those columns.
9327    /// 5. Hand off to the regular project / ORDER BY / LIMIT pipe.
9328    #[allow(
9329        clippy::too_many_lines,
9330        clippy::type_complexity,
9331        clippy::needless_range_loop
9332    )] // window-eval is one cohesive pipe; splitting fragments
9333    fn exec_select_with_window(
9334        &self,
9335        stmt: &SelectStatement,
9336        cancel: CancelToken<'_>,
9337    ) -> Result<QueryResult, EngineError> {
9338        let from = stmt.from.as_ref().ok_or_else(|| {
9339            EngineError::Unsupported("window functions require a FROM clause".into())
9340        })?;
9341        // v7.17.0 Phase 3.P0-43 — JOIN + window functions. Phase
9342        // 3.6 rejected this combination outright ("queued for
9343        // v5.x"); P0-43 materialises the join + WHERE through the
9344        // existing nested-loop helper and runs the window pipeline
9345        // on the joined row set with the combined `alias.col`
9346        // schema. The window expressions resolve through the
9347        // qualifier-aware column resolver same as the aggregate /
9348        // projection paths on JOIN.
9349        let (schema_cols_owned, alias_opt): (Vec<ColumnSchema>, Option<&str>);
9350        let filtered: Vec<Row>;
9351        if from.joins.is_empty() {
9352            let primary = &from.primary;
9353            let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
9354                StorageError::TableNotFound {
9355                    name: primary.name.clone(),
9356                }
9357            })?;
9358            let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
9359            schema_cols_owned = table.schema().columns.clone();
9360            alias_opt = Some(alias);
9361            // Materialise WHERE-filtered rows owned so the JOIN
9362            // and single-table paths share a single downstream
9363            // shape. The clone is cheap relative to the window
9364            // computation that follows.
9365            let ctx = self.ev_ctx(&schema_cols_owned, alias_opt);
9366            let mut owned: Vec<Row> = Vec::new();
9367            for (i, row) in table.rows().iter().enumerate() {
9368                if i.is_multiple_of(256) {
9369                    cancel.check()?;
9370                }
9371                if let Some(w) = &stmt.where_ {
9372                    let cond = eval::eval_expr(w, row, &ctx)?;
9373                    if !matches!(cond, Value::Bool(true)) {
9374                        continue;
9375                    }
9376                }
9377                owned.push(row.clone());
9378            }
9379            filtered = owned;
9380        } else {
9381            let (combined_schema, rows) =
9382                self.build_joined_filtered_rows(from, stmt.where_.as_ref())?;
9383            schema_cols_owned = combined_schema;
9384            alias_opt = None;
9385            filtered = rows;
9386        }
9387        let schema_cols = &schema_cols_owned;
9388        let ctx = self.ev_ctx(schema_cols, alias_opt);
9389        let alias = alias_opt.unwrap_or("");
9390        let n_rows = filtered.len();
9391        // Borrow refs into the owned row vec once so the downstream
9392        // `compute_window_partition` call (which takes `&[&Row]`) and
9393        // the per-row eval loops share a single backing buffer.
9394        let filtered_refs: Vec<&Row> = filtered.iter().collect();
9395
9396        // 2) Collect unique window function nodes from projection.
9397        let mut window_nodes: Vec<Expr> = Vec::new();
9398        for item in &stmt.items {
9399            if let SelectItem::Expr { expr, .. } = item {
9400                collect_window_nodes(expr, &mut window_nodes);
9401            }
9402        }
9403
9404        // 3) For each window, compute per-row value.
9405        // Index: same order as window_nodes; for row i, win_vals[w][i].
9406        let mut win_vals: Vec<Vec<Value>> = Vec::with_capacity(window_nodes.len());
9407        for wnode in &window_nodes {
9408            let Expr::WindowFunction {
9409                name,
9410                args,
9411                partition_by,
9412                order_by,
9413                frame,
9414                null_treatment,
9415            } = wnode
9416            else {
9417                unreachable!("collect_window_nodes pushes only WindowFunction");
9418            };
9419            // Compute (partition_key, order_key, original_index) for each row.
9420            let mut indexed: Vec<(Vec<Value>, Vec<(Value, bool)>, usize)> =
9421                Vec::with_capacity(n_rows);
9422            for (i, row) in filtered.iter().enumerate() {
9423                let pkey: Vec<Value> = partition_by
9424                    .iter()
9425                    .map(|p| eval::eval_expr(p, row, &ctx))
9426                    .collect::<Result<_, _>>()?;
9427                let okey: Vec<(Value, bool)> = order_by
9428                    .iter()
9429                    .map(|(e, desc)| eval::eval_expr(e, row, &ctx).map(|v| (v, *desc)))
9430                    .collect::<Result<_, _>>()?;
9431                indexed.push((pkey, okey, i));
9432            }
9433            // Sort by (partition_key, order_key). Partition key uses
9434            // a stable encoded form; order key respects ASC/DESC.
9435            indexed.sort_by(|a, b| {
9436                let p_cmp = partition_key_cmp(&a.0, &b.0);
9437                if p_cmp != core::cmp::Ordering::Equal {
9438                    return p_cmp;
9439                }
9440                order_key_cmp(&a.1, &b.1)
9441            });
9442            // Per-partition compute.
9443            let mut out_vals: Vec<Value> = alloc::vec![Value::Null; n_rows];
9444            let mut p_start = 0;
9445            while p_start < indexed.len() {
9446                let mut p_end = p_start + 1;
9447                while p_end < indexed.len()
9448                    && partition_key_cmp(&indexed[p_start].0, &indexed[p_end].0)
9449                        == core::cmp::Ordering::Equal
9450                {
9451                    p_end += 1;
9452                }
9453                // Compute the function within this partition slice.
9454                compute_window_partition(
9455                    name,
9456                    args,
9457                    !order_by.is_empty(),
9458                    frame.as_ref(),
9459                    *null_treatment,
9460                    &indexed[p_start..p_end],
9461                    &filtered_refs,
9462                    &ctx,
9463                    &mut out_vals,
9464                )?;
9465                p_start = p_end;
9466            }
9467            win_vals.push(out_vals);
9468        }
9469
9470        // 4) Build extended schema: original columns + synthetic.
9471        let mut ext_cols = schema_cols.clone();
9472        for i in 0..window_nodes.len() {
9473            ext_cols.push(ColumnSchema::new(
9474                alloc::format!("__win_{i}"),
9475                DataType::Text, // type doesn't matter for projection eval
9476                true,
9477            ));
9478        }
9479        // 5) Build extended rows: each row gets its window values appended.
9480        let mut ext_rows: Vec<Row> = Vec::with_capacity(n_rows);
9481        for i in 0..n_rows {
9482            let mut values = filtered[i].values.clone();
9483            for w in 0..window_nodes.len() {
9484                values.push(win_vals[w][i].clone());
9485            }
9486            ext_rows.push(Row::new(values));
9487        }
9488        // 6) Rewrite the projection: WindowFunction nodes → Column(__win_N).
9489        let mut rewritten_items: Vec<SelectItem> = Vec::with_capacity(stmt.items.len());
9490        for item in &stmt.items {
9491            let new_item = match item {
9492                SelectItem::Wildcard => SelectItem::Wildcard,
9493                SelectItem::Expr { expr, alias } => {
9494                    let mut e = expr.clone();
9495                    rewrite_window_to_columns(&mut e, &window_nodes);
9496                    SelectItem::Expr {
9497                        expr: e,
9498                        alias: alias.clone(),
9499                    }
9500                }
9501            };
9502            rewritten_items.push(new_item);
9503        }
9504
9505        // 7) Project into final rows. JOIN case uses None so the
9506        // qualifier check in `resolve_column` falls through to the
9507        // composite `alias.col` schema lookup; single-table case
9508        // keeps the bare alias so `bare_col` resolution still
9509        // works for the projection's per-row column references.
9510        let ext_ctx = EvalContext::new(&ext_cols, alias_opt);
9511        let projection = build_projection(&rewritten_items, &ext_cols, alias)?;
9512        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(n_rows);
9513        for (i, row) in ext_rows.iter().enumerate() {
9514            if i.is_multiple_of(256) {
9515                cancel.check()?;
9516            }
9517            let mut values = Vec::with_capacity(projection.len());
9518            for p in &projection {
9519                values.push(eval::eval_expr(&p.expr, row, &ext_ctx)?);
9520            }
9521            let order_keys = if stmt.order_by.is_empty() {
9522                Vec::new()
9523            } else {
9524                let mut keys = Vec::with_capacity(stmt.order_by.len());
9525                for o in &stmt.order_by {
9526                    let mut e = o.expr.clone();
9527                    rewrite_window_to_columns(&mut e, &window_nodes);
9528                    let key = eval::eval_expr(&e, row, &ext_ctx)?;
9529                    keys.push(value_to_order_key(&key)?);
9530                }
9531                keys
9532            };
9533            tagged.push((order_keys, Row::new(values)));
9534        }
9535        // ORDER BY + LIMIT/OFFSET on the projected rows.
9536        if !stmt.order_by.is_empty() {
9537            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
9538            sort_by_keys(&mut tagged, &descs);
9539        }
9540        let mut out_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
9541        apply_offset_and_limit(&mut out_rows, stmt.offset_literal(), stmt.limit_literal());
9542        let final_cols: Vec<ColumnSchema> = projection
9543            .into_iter()
9544            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
9545            .collect();
9546        Ok(QueryResult::Rows {
9547            columns: final_cols,
9548            rows: out_rows,
9549        })
9550    }
9551
9552    /// v4.11: materialise each CTE into a temp table inside a
9553    /// cloned catalog, then run the body SELECT against a fresh
9554    /// engine instance that owns the enriched catalog. The clone
9555    /// is moderately expensive — only paid by CTE-bearing queries.
9556    /// Subqueries inside CTE bodies / the main body resolve as
9557    /// usual; `clock_fn` is propagated so `NOW()` lines up.
9558    /// v7.16.2 — mailrs round-10 A.3. Materialise the
9559    /// `information_schema.*` / `pg_catalog.*` virtual views
9560    /// the SELECT references, then re-execute the SELECT
9561    /// against an enriched catalog where those views are real
9562    /// tables. Same pattern as `exec_with_ctes`. The temp
9563    /// engine carries `meta_views_materialised = true` so its
9564    /// own meta-dispatch short-circuits — without that we'd
9565    /// infinite-recurse since the temp catalog's view name
9566    /// still starts with `__spg_info_` and re-triggers the
9567    /// check.
9568    fn exec_select_with_meta_views(
9569        &self,
9570        stmt: &SelectStatement,
9571        cancel: CancelToken<'_>,
9572    ) -> Result<QueryResult, EngineError> {
9573        let mut needed: alloc::collections::BTreeSet<String> = alloc::collections::BTreeSet::new();
9574        collect_meta_view_names(stmt, &mut needed);
9575        let mut catalog = self.active_catalog().clone();
9576        for view in &needed {
9577            if catalog.get(view).is_some() {
9578                continue;
9579            }
9580            match view.as_str() {
9581                "__spg_info_columns" => {
9582                    let (schema, rows) = synth_information_schema_columns(self.active_catalog());
9583                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9584                }
9585                "__spg_info_tables" => {
9586                    let (schema, rows) = synth_information_schema_tables(self.active_catalog());
9587                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9588                }
9589                "__spg_pg_class" => {
9590                    let (schema, rows) = synth_pg_class(self.active_catalog());
9591                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9592                }
9593                "__spg_pg_attribute" => {
9594                    let (schema, rows) = synth_pg_attribute(self.active_catalog());
9595                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9596                }
9597                // v7.17.0 Phase 3.P0-50 — pg_catalog.pg_type for
9598                // sqlx / SQLAlchemy / Diesel / pgAdmin lookups.
9599                "__spg_pg_type" => {
9600                    let (schema, rows) = synth_pg_type(self.active_catalog());
9601                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9602                }
9603                // v7.17.0 Phase 3.P0-51 — pg_catalog.pg_proc for
9604                // function-name introspection (ORM / pgAdmin).
9605                "__spg_pg_proc" => {
9606                    let (schema, rows) = synth_pg_proc(self.active_catalog());
9607                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9608                }
9609                // v7.17.0 Phase 3.P0-52 — pg_catalog.pg_namespace
9610                // (schema list for admin tools' tree views).
9611                "__spg_pg_namespace" => {
9612                    let (schema, rows) = synth_pg_namespace(self.active_catalog());
9613                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9614                }
9615                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_indexes view
9616                // for pgAdmin / DataGrip "indexes per table" listings.
9617                "__spg_pg_indexes" => {
9618                    let (schema, rows) = synth_pg_indexes(self.active_catalog());
9619                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9620                }
9621                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_index (raw)
9622                // for index introspection by ORM compilers.
9623                "__spg_pg_index" => {
9624                    let (schema, rows) = synth_pg_index_raw(self.active_catalog());
9625                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9626                }
9627                // v7.17.0 Phase 3.P0-54 — pg_catalog.pg_constraint
9628                // for FK / UNIQUE / PK / CHECK introspection.
9629                "__spg_pg_constraint" => {
9630                    let (schema, rows) = synth_pg_constraint(self.active_catalog());
9631                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9632                }
9633                // v7.17.0 Phase 3.P0-55 — pg_catalog.pg_database /
9634                // pg_roles / pg_user. SPG is single-database so
9635                // pg_database surfaces just `postgres`; pg_roles
9636                // / pg_user walk the engine's UserStore.
9637                "__spg_pg_database" => {
9638                    let (schema, rows) = synth_pg_database(self.active_catalog());
9639                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9640                }
9641                "__spg_pg_roles" | "__spg_pg_user" => {
9642                    let (schema, rows) = synth_pg_roles(self);
9643                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9644                }
9645                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_views. PG's
9646                // pg_views surfaces every CREATE VIEW result; SPG
9647                // ships one row per declared view from the catalog.
9648                "__spg_pg_views" => {
9649                    let (schema, rows) = synth_pg_views(self.active_catalog());
9650                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9651                }
9652                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_matviews.
9653                // SPG has no materialised view surface yet so the
9654                // table shares pg_views's schema but stays empty.
9655                "__spg_pg_matviews" => {
9656                    let (schema, _) = synth_pg_views(self.active_catalog());
9657                    materialise_meta_view(&mut catalog, view, schema, Vec::new())?;
9658                }
9659                // v7.17.0 Phase 3.P0-57 — pg_catalog.pg_settings.
9660                "__spg_pg_settings" => {
9661                    let (schema, rows) = synth_pg_settings(self);
9662                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9663                }
9664                // v7.17.0 Phase 3.P0-63 — information_schema.KEY_COLUMN_USAGE.
9665                "__spg_info_key_column_usage" => {
9666                    let (schema, rows) = synth_info_key_column_usage(self.active_catalog());
9667                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9668                }
9669                // v7.17.0 Phase 3.P0-64 — information_schema.REFERENTIAL_CONSTRAINTS.
9670                "__spg_info_referential_constraints" => {
9671                    let (schema, rows) = synth_info_referential_constraints(self.active_catalog());
9672                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9673                }
9674                // v7.17.0 Phase 3.P0-64 — information_schema.STATISTICS.
9675                "__spg_info_statistics" => {
9676                    let (schema, rows) = synth_info_statistics(self.active_catalog());
9677                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9678                }
9679                // v7.17.0 Phase 3.P0-64 — information_schema.ROUTINES.
9680                "__spg_info_routines" => {
9681                    let (schema, rows) = synth_info_routines();
9682                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9683                }
9684                // v7.17.0 Phase 3.P0-65 — mysql.user / mysql.db.
9685                "__spg_mysql_user" => {
9686                    let (schema, rows) = synth_mysql_user(self);
9687                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9688                }
9689                "__spg_mysql_db" => {
9690                    let (schema, rows) = synth_mysql_db();
9691                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9692                }
9693                _ => {
9694                    return Err(EngineError::Unsupported(alloc::format!(
9695                        "meta view {view:?} is not yet materialisable; \
9696                         v7.16.2 covers information_schema.columns / .tables \
9697                         and pg_catalog.pg_class / pg_attribute; \
9698                         v7.17.0 P0-50..P0-57 add pg_type / pg_proc / pg_namespace / \
9699                         pg_indexes / pg_index / pg_constraint / pg_database / pg_roles / \
9700                         pg_user / pg_views / pg_matviews / pg_settings"
9701                    )));
9702                }
9703            }
9704        }
9705        let mut temp = Engine::restore(catalog);
9706        if let Some(c) = self.clock {
9707            temp = temp.with_clock(c);
9708        }
9709        if let Some(f) = self.salt_fn {
9710            temp = temp.with_salt_fn(f);
9711        }
9712        temp.meta_views_materialised = true;
9713        temp.exec_select_cancel(stmt, cancel)
9714    }
9715
9716    fn exec_with_ctes(
9717        &self,
9718        stmt: &SelectStatement,
9719        cancel: CancelToken<'_>,
9720    ) -> Result<QueryResult, EngineError> {
9721        cancel.check()?;
9722        let mut catalog = self.active_catalog().clone();
9723        for cte in &stmt.ctes {
9724            if catalog.get(&cte.name).is_some() {
9725                return Err(EngineError::Unsupported(alloc::format!(
9726                    "CTE name {:?} shadows an existing table; rename the CTE",
9727                    cte.name
9728                )));
9729            }
9730            let (columns, rows) = if cte.recursive {
9731                self.materialise_recursive_cte(cte, &catalog, cancel)?
9732            } else {
9733                let body_result = self.exec_select_cancel(&cte.body, cancel)?;
9734                let QueryResult::Rows { columns, rows } = body_result else {
9735                    return Err(EngineError::Unsupported(alloc::format!(
9736                        "CTE {:?} body did not return rows",
9737                        cte.name
9738                    )));
9739                };
9740                (columns, rows)
9741            };
9742            // v4.22: the projection builder labels any non-column
9743            // expression as Text — including literal SELECT 1.
9744            // Promote each column's type to whatever the rows
9745            // actually carry so the CTE storage table accepts them.
9746            let inferred = infer_column_types(&columns, &rows);
9747            let mut columns = inferred;
9748            // v4.22: apply optional `WITH name(a, b, c)` overrides.
9749            if !cte.column_overrides.is_empty() {
9750                if cte.column_overrides.len() != columns.len() {
9751                    return Err(EngineError::Unsupported(alloc::format!(
9752                        "CTE {:?} column list has {} names but body returns {} columns",
9753                        cte.name,
9754                        cte.column_overrides.len(),
9755                        columns.len()
9756                    )));
9757                }
9758                for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
9759                    col.name.clone_from(name);
9760                }
9761            }
9762            let schema = TableSchema::new(cte.name.clone(), columns);
9763            catalog.create_table(schema).map_err(EngineError::Storage)?;
9764            let table = catalog
9765                .get_mut(&cte.name)
9766                .expect("just-created CTE table must exist");
9767            for row in rows {
9768                table.insert(row).map_err(EngineError::Storage)?;
9769            }
9770        }
9771        // Strip CTEs from the body before running on the temp engine
9772        // so we don't recurse forever.
9773        let mut body = stmt.clone();
9774        body.ctes = Vec::new();
9775        let mut temp = Engine::restore(catalog);
9776        if let Some(c) = self.clock {
9777            temp = temp.with_clock(c);
9778        }
9779        if let Some(f) = self.salt_fn {
9780            temp = temp.with_salt_fn(f);
9781        }
9782        temp.exec_select_cancel(&body, cancel)
9783    }
9784
9785    /// v4.22: materialise a WITH RECURSIVE CTE. The body must be a
9786    /// UNION (or UNION ALL) of an anchor that does not reference
9787    /// the CTE name, and one or more recursive terms that do. The
9788    /// anchor runs first; each subsequent iteration runs the
9789    /// recursive term against a temp catalog where the CTE name is
9790    /// bound to the *previous* iteration's output. Iteration stops
9791    /// when the recursive term yields no rows; UNION (DISTINCT)
9792    /// deduplicates against the accumulated result, UNION ALL does
9793    /// not. A hard cap on total rows prevents runaway queries.
9794    #[allow(clippy::too_many_lines)]
9795    fn materialise_recursive_cte(
9796        &self,
9797        cte: &spg_sql::ast::Cte,
9798        base_catalog: &Catalog,
9799        cancel: CancelToken<'_>,
9800    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
9801        const MAX_TOTAL_ROWS: usize = 1_000_000;
9802        const MAX_ITERATIONS: usize = 100_000;
9803        cancel.check()?;
9804        if cte.body.unions.is_empty() {
9805            return Err(EngineError::Unsupported(alloc::format!(
9806                "WITH RECURSIVE {:?} body must be a UNION of an anchor and a recursive term",
9807                cte.name
9808            )));
9809        }
9810        // Anchor: the body's leading SELECT, with unions stripped.
9811        let mut anchor = cte.body.clone();
9812        let union_terms = core::mem::take(&mut anchor.unions);
9813        anchor.ctes = Vec::new();
9814        // Anchor must not reference the CTE name.
9815        if select_refers_to(&anchor, &cte.name) {
9816            return Err(EngineError::Unsupported(alloc::format!(
9817                "WITH RECURSIVE {:?}: the anchor must not reference the CTE itself",
9818                cte.name
9819            )));
9820        }
9821        let anchor_result = self.exec_select_cancel(&anchor, cancel)?;
9822        let QueryResult::Rows {
9823            columns: anchor_cols,
9824            rows: anchor_rows,
9825        } = anchor_result
9826        else {
9827            return Err(EngineError::Unsupported(alloc::format!(
9828                "WITH RECURSIVE {:?}: anchor did not return rows",
9829                cte.name
9830            )));
9831        };
9832        // The projection builder labels non-column expressions Text;
9833        // refine column types from the anchor's actual values so the
9834        // intermediate iter-catalog tables accept them.
9835        let mut columns = infer_column_types(&anchor_cols, &anchor_rows);
9836        if !cte.column_overrides.is_empty() {
9837            if cte.column_overrides.len() != columns.len() {
9838                return Err(EngineError::Unsupported(alloc::format!(
9839                    "CTE {:?} column list has {} names but anchor returns {} columns",
9840                    cte.name,
9841                    cte.column_overrides.len(),
9842                    columns.len()
9843                )));
9844            }
9845            for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
9846                col.name.clone_from(name);
9847            }
9848        }
9849        let mut all_rows: Vec<Row> = anchor_rows.clone();
9850        let mut working_set: Vec<Row> = anchor_rows;
9851        let mut seen: alloc::collections::BTreeSet<Vec<u8>> = alloc::collections::BTreeSet::new();
9852        // Track at least one "all UNION ALL" flag — if every union
9853        // kind is ALL we skip the dedup step (faster + matches PG).
9854        let all_union_all = union_terms.iter().all(|(k, _)| matches!(k, UnionKind::All));
9855        if !all_union_all {
9856            for r in &all_rows {
9857                seen.insert(encode_row_key(r));
9858            }
9859        }
9860        for iter in 0..MAX_ITERATIONS {
9861            cancel.check()?;
9862            if working_set.is_empty() {
9863                break;
9864            }
9865            // Build a fresh catalog: base + CTE bound to working_set.
9866            let mut iter_catalog = base_catalog.clone();
9867            let schema = TableSchema::new(cte.name.clone(), columns.clone());
9868            iter_catalog
9869                .create_table(schema)
9870                .map_err(EngineError::Storage)?;
9871            {
9872                let table = iter_catalog.get_mut(&cte.name).expect("just-created");
9873                for row in &working_set {
9874                    table.insert(row.clone()).map_err(EngineError::Storage)?;
9875                }
9876            }
9877            let mut iter_engine = Engine::restore(iter_catalog);
9878            if let Some(c) = self.clock {
9879                iter_engine = iter_engine.with_clock(c);
9880            }
9881            if let Some(f) = self.salt_fn {
9882                iter_engine = iter_engine.with_salt_fn(f);
9883            }
9884            // Run each recursive term in sequence and collect new rows.
9885            let mut next_set: Vec<Row> = Vec::new();
9886            for (_, term) in &union_terms {
9887                let mut term = term.clone();
9888                term.ctes = Vec::new();
9889                let r = iter_engine.exec_select_cancel(&term, cancel)?;
9890                let QueryResult::Rows {
9891                    columns: rc,
9892                    rows: rs,
9893                } = r
9894                else {
9895                    return Err(EngineError::Unsupported(alloc::format!(
9896                        "WITH RECURSIVE {:?}: recursive term did not return rows",
9897                        cte.name
9898                    )));
9899                };
9900                if rc.len() != columns.len() {
9901                    return Err(EngineError::Unsupported(alloc::format!(
9902                        "WITH RECURSIVE {:?}: column count of recursive term ({}) does not match anchor ({})",
9903                        cte.name,
9904                        rc.len(),
9905                        columns.len()
9906                    )));
9907                }
9908                for row in rs {
9909                    if !all_union_all {
9910                        let key = encode_row_key(&row);
9911                        if !seen.insert(key) {
9912                            continue;
9913                        }
9914                    }
9915                    next_set.push(row);
9916                }
9917            }
9918            if next_set.is_empty() {
9919                break;
9920            }
9921            all_rows.extend(next_set.iter().cloned());
9922            working_set = next_set;
9923            if all_rows.len() > MAX_TOTAL_ROWS {
9924                return Err(EngineError::Unsupported(alloc::format!(
9925                    "WITH RECURSIVE {:?}: produced more than {MAX_TOTAL_ROWS} rows — likely runaway recursion",
9926                    cte.name
9927                )));
9928            }
9929            if iter + 1 == MAX_ITERATIONS {
9930                return Err(EngineError::Unsupported(alloc::format!(
9931                    "WITH RECURSIVE {:?}: exceeded {MAX_ITERATIONS} iterations",
9932                    cte.name
9933                )));
9934            }
9935        }
9936        Ok((columns, all_rows))
9937    }
9938
9939    fn resolve_select_subqueries(
9940        &self,
9941        stmt: &mut SelectStatement,
9942        cancel: CancelToken<'_>,
9943    ) -> Result<(), EngineError> {
9944        for item in &mut stmt.items {
9945            if let SelectItem::Expr { expr, .. } = item {
9946                self.resolve_expr_subqueries(expr, cancel)?;
9947            }
9948        }
9949        if let Some(w) = &mut stmt.where_ {
9950            self.resolve_expr_subqueries(w, cancel)?;
9951        }
9952        if let Some(gs) = &mut stmt.group_by {
9953            for g in gs {
9954                self.resolve_expr_subqueries(g, cancel)?;
9955            }
9956        }
9957        if let Some(h) = &mut stmt.having {
9958            self.resolve_expr_subqueries(h, cancel)?;
9959        }
9960        for o in &mut stmt.order_by {
9961            self.resolve_expr_subqueries(&mut o.expr, cancel)?;
9962        }
9963        for (_, peer) in &mut stmt.unions {
9964            self.resolve_select_subqueries(peer, cancel)?;
9965        }
9966        Ok(())
9967    }
9968
9969    #[allow(clippy::only_used_in_recursion)] // engine handle reads aren't really pure
9970    fn resolve_expr_subqueries(
9971        &self,
9972        e: &mut Expr,
9973        cancel: CancelToken<'_>,
9974    ) -> Result<(), EngineError> {
9975        // Replace-on-this-node cases first.
9976        if let Some(replacement) = self.subquery_replacement(e, cancel)? {
9977            *e = replacement;
9978            return Ok(());
9979        }
9980        match e {
9981            Expr::Binary { lhs, rhs, .. } => {
9982                self.resolve_expr_subqueries(lhs, cancel)?;
9983                self.resolve_expr_subqueries(rhs, cancel)?;
9984            }
9985            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
9986                self.resolve_expr_subqueries(expr, cancel)?;
9987            }
9988            Expr::FunctionCall { args, .. } => {
9989                for a in args {
9990                    self.resolve_expr_subqueries(a, cancel)?;
9991                }
9992            }
9993            Expr::Like { expr, pattern, .. } => {
9994                self.resolve_expr_subqueries(expr, cancel)?;
9995                self.resolve_expr_subqueries(pattern, cancel)?;
9996            }
9997            Expr::Extract { source, .. } => self.resolve_expr_subqueries(source, cancel)?,
9998            // v4.12 window functions — recurse into args + ORDER BY
9999            // + PARTITION BY in case they carry inner subqueries.
10000            Expr::WindowFunction {
10001                args,
10002                partition_by,
10003                order_by,
10004                ..
10005            } => {
10006                for a in args {
10007                    self.resolve_expr_subqueries(a, cancel)?;
10008                }
10009                for p in partition_by {
10010                    self.resolve_expr_subqueries(p, cancel)?;
10011                }
10012                for (e, _) in order_by {
10013                    self.resolve_expr_subqueries(e, cancel)?;
10014                }
10015            }
10016            // Subquery nodes are handled in subquery_replacement
10017            // (which returned None — defensive no-op); Literal /
10018            // Column are leaves.
10019            Expr::ScalarSubquery(_)
10020            | Expr::Exists { .. }
10021            | Expr::InSubquery { .. }
10022            | Expr::Literal(_)
10023            | Expr::Placeholder(_)
10024            | Expr::Column(_) => {}
10025            // v7.10.10 — recurse children.
10026            Expr::Array(items) => {
10027                for elem in items {
10028                    self.resolve_expr_subqueries(elem, cancel)?;
10029                }
10030            }
10031            Expr::ArraySubscript { target, index } => {
10032                self.resolve_expr_subqueries(target, cancel)?;
10033                self.resolve_expr_subqueries(index, cancel)?;
10034            }
10035            Expr::AnyAll { expr, array, .. } => {
10036                self.resolve_expr_subqueries(expr, cancel)?;
10037                self.resolve_expr_subqueries(array, cancel)?;
10038            }
10039            Expr::Case {
10040                operand,
10041                branches,
10042                else_branch,
10043            } => {
10044                if let Some(o) = operand {
10045                    self.resolve_expr_subqueries(o, cancel)?;
10046                }
10047                for (w, t) in branches {
10048                    self.resolve_expr_subqueries(w, cancel)?;
10049                    self.resolve_expr_subqueries(t, cancel)?;
10050                }
10051                if let Some(e) = else_branch {
10052                    self.resolve_expr_subqueries(e, cancel)?;
10053                }
10054            }
10055        }
10056        Ok(())
10057    }
10058
10059    /// v4.23: per-row eval that handles correlated subqueries.
10060    /// Equivalent to `eval::eval_expr` when the expression has no
10061    /// subqueries; otherwise clones the expression, substitutes
10062    /// outer-row columns into each surviving subquery node, runs
10063    /// the inner SELECT, and replaces the node with the literal
10064    /// result. Only the WHERE-filter call sites use this path so
10065    /// the uncorrelated fast path is preserved everywhere else.
10066    fn eval_expr_with_correlated(
10067        &self,
10068        expr: &Expr,
10069        row: &Row,
10070        ctx: &EvalContext<'_>,
10071        cancel: CancelToken<'_>,
10072        memo: Option<&mut memoize::MemoizeCache>,
10073    ) -> Result<Value, EngineError> {
10074        if !expr_has_subquery(expr) {
10075            return eval::eval_expr(expr, row, ctx).map_err(EngineError::Eval);
10076        }
10077        let mut e = expr.clone();
10078        self.resolve_correlated_in_expr(&mut e, row, ctx, cancel, memo)?;
10079        eval::eval_expr(&e, row, ctx).map_err(EngineError::Eval)
10080    }
10081
10082    fn resolve_correlated_in_expr(
10083        &self,
10084        e: &mut Expr,
10085        row: &Row,
10086        ctx: &EvalContext<'_>,
10087        cancel: CancelToken<'_>,
10088        mut memo: Option<&mut memoize::MemoizeCache>,
10089    ) -> Result<(), EngineError> {
10090        match e {
10091            Expr::ScalarSubquery(inner) => {
10092                // v6.2.6 — Memoize: build the cache key from the
10093                // pre-substitution subquery repr + the outer row's
10094                // values. Two outer rows with identical correlated
10095                // values hit the same entry.
10096                let cache_key = memo.as_ref().map(|_| memoize::CacheKey {
10097                    subquery_repr: alloc::format!("{}", **inner),
10098                    outer_values: row.values.clone(),
10099                });
10100                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key.as_ref())
10101                    && let Some(cached) = cache.get(k)
10102                {
10103                    *e = value_to_literal_expr(cached)?;
10104                    return Ok(());
10105                }
10106                let mut s = (**inner).clone();
10107                substitute_outer_columns(&mut s, row, ctx);
10108                let r = self.exec_select_cancel(&s, cancel)?;
10109                let QueryResult::Rows { rows, .. } = r else {
10110                    return Err(EngineError::Unsupported(
10111                        "scalar subquery: inner did not return rows".into(),
10112                    ));
10113                };
10114                let value = match rows.as_slice() {
10115                    [] => Value::Null,
10116                    [r0] => r0.values.first().cloned().unwrap_or(Value::Null),
10117                    _ => {
10118                        return Err(EngineError::Unsupported(alloc::format!(
10119                            "scalar subquery returned {} rows; expected 0 or 1",
10120                            rows.len()
10121                        )));
10122                    }
10123                };
10124                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key) {
10125                    cache.insert(k, value.clone());
10126                }
10127                *e = value_to_literal_expr(value)?;
10128            }
10129            Expr::Exists { subquery, negated } => {
10130                let mut s = (**subquery).clone();
10131                substitute_outer_columns(&mut s, row, ctx);
10132                let r = self.exec_select_cancel(&s, cancel)?;
10133                let exists = matches!(r, QueryResult::Rows { rows, .. } if !rows.is_empty());
10134                let bit = if *negated { !exists } else { exists };
10135                *e = Expr::Literal(Literal::Bool(bit));
10136            }
10137            Expr::InSubquery {
10138                expr: lhs,
10139                subquery,
10140                negated,
10141            } => {
10142                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
10143                let lhs_val = eval::eval_expr(lhs, row, ctx).map_err(EngineError::Eval)?;
10144                let mut s = (**subquery).clone();
10145                substitute_outer_columns(&mut s, row, ctx);
10146                let r = self.exec_select_cancel(&s, cancel)?;
10147                let QueryResult::Rows { columns, rows, .. } = r else {
10148                    return Err(EngineError::Unsupported(
10149                        "IN-subquery: inner did not return rows".into(),
10150                    ));
10151                };
10152                if columns.len() != 1 {
10153                    return Err(EngineError::Unsupported(alloc::format!(
10154                        "IN-subquery must project exactly one column; got {}",
10155                        columns.len()
10156                    )));
10157                }
10158                let mut found = false;
10159                let mut any_null = false;
10160                for r0 in rows {
10161                    let v = r0.values.into_iter().next().unwrap_or(Value::Null);
10162                    if v.is_null() {
10163                        any_null = true;
10164                        continue;
10165                    }
10166                    if value_cmp(&v, &lhs_val) == core::cmp::Ordering::Equal {
10167                        found = true;
10168                        break;
10169                    }
10170                }
10171                let bit = if found {
10172                    !*negated
10173                } else if any_null {
10174                    return Err(EngineError::Unsupported(
10175                        "IN-subquery with NULL in result and no match: NULL semantics not yet implemented".into(),
10176                    ));
10177                } else {
10178                    *negated
10179                };
10180                *e = Expr::Literal(Literal::Bool(bit));
10181            }
10182            Expr::Binary { lhs, rhs, .. } => {
10183                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
10184                self.resolve_correlated_in_expr(rhs, row, ctx, cancel, memo.as_deref_mut())?;
10185            }
10186            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10187                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10188            }
10189            Expr::Like { expr, pattern, .. } => {
10190                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10191                self.resolve_correlated_in_expr(pattern, row, ctx, cancel, memo.as_deref_mut())?;
10192            }
10193            Expr::FunctionCall { args, .. } => {
10194                for a in args {
10195                    self.resolve_correlated_in_expr(a, row, ctx, cancel, memo.as_deref_mut())?;
10196                }
10197            }
10198            Expr::Extract { source, .. } => {
10199                self.resolve_correlated_in_expr(source, row, ctx, cancel, memo.as_deref_mut())?;
10200            }
10201            Expr::WindowFunction { .. }
10202            | Expr::Literal(_)
10203            | Expr::Placeholder(_)
10204            | Expr::Column(_) => {}
10205            // v7.10.10 — recurse children.
10206            Expr::Array(items) => {
10207                for elem in items {
10208                    self.resolve_correlated_in_expr(elem, row, ctx, cancel, memo.as_deref_mut())?;
10209                }
10210            }
10211            Expr::ArraySubscript { target, index } => {
10212                self.resolve_correlated_in_expr(target, row, ctx, cancel, memo.as_deref_mut())?;
10213                self.resolve_correlated_in_expr(index, row, ctx, cancel, memo.as_deref_mut())?;
10214            }
10215            Expr::AnyAll { expr, array, .. } => {
10216                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10217                self.resolve_correlated_in_expr(array, row, ctx, cancel, memo.as_deref_mut())?;
10218            }
10219            Expr::Case {
10220                operand,
10221                branches,
10222                else_branch,
10223            } => {
10224                if let Some(o) = operand {
10225                    self.resolve_correlated_in_expr(o, row, ctx, cancel, memo.as_deref_mut())?;
10226                }
10227                for (w, t) in branches {
10228                    self.resolve_correlated_in_expr(w, row, ctx, cancel, memo.as_deref_mut())?;
10229                    self.resolve_correlated_in_expr(t, row, ctx, cancel, memo.as_deref_mut())?;
10230                }
10231                if let Some(e) = else_branch {
10232                    self.resolve_correlated_in_expr(e, row, ctx, cancel, memo.as_deref_mut())?;
10233                }
10234            }
10235        }
10236        Ok(())
10237    }
10238
10239    fn subquery_replacement(
10240        &self,
10241        e: &Expr,
10242        cancel: CancelToken<'_>,
10243    ) -> Result<Option<Expr>, EngineError> {
10244        match e {
10245            Expr::ScalarSubquery(inner) => {
10246                let mut s = (**inner).clone();
10247                // Recurse into the inner SELECT first so nested
10248                // subqueries materialise bottom-up.
10249                self.resolve_select_subqueries(&mut s, cancel)?;
10250                let r = match self.exec_bare_select_cancel(&s, cancel) {
10251                    Ok(r) => r,
10252                    Err(e) if is_correlation_error(&e) => return Ok(None),
10253                    Err(e) => return Err(e),
10254                };
10255                let QueryResult::Rows { rows, .. } = r else {
10256                    return Err(EngineError::Unsupported(
10257                        "scalar subquery: inner statement did not return rows".into(),
10258                    ));
10259                };
10260                let value = match rows.as_slice() {
10261                    [] => Value::Null,
10262                    [row] => row.values.first().cloned().unwrap_or(Value::Null),
10263                    _ => {
10264                        return Err(EngineError::Unsupported(alloc::format!(
10265                            "scalar subquery returned {} rows; expected 0 or 1",
10266                            rows.len()
10267                        )));
10268                    }
10269                };
10270                Ok(Some(value_to_literal_expr(value)?))
10271            }
10272            Expr::Exists { subquery, negated } => {
10273                let mut s = (**subquery).clone();
10274                self.resolve_select_subqueries(&mut s, cancel)?;
10275                let r = match self.exec_bare_select_cancel(&s, cancel) {
10276                    Ok(r) => r,
10277                    Err(e) if is_correlation_error(&e) => return Ok(None),
10278                    Err(e) => return Err(e),
10279                };
10280                let exists = match r {
10281                    QueryResult::Rows { rows, .. } => !rows.is_empty(),
10282                    QueryResult::CommandOk { .. } => false,
10283                };
10284                let bit = if *negated { !exists } else { exists };
10285                Ok(Some(Expr::Literal(Literal::Bool(bit))))
10286            }
10287            Expr::InSubquery {
10288                expr,
10289                subquery,
10290                negated,
10291            } => {
10292                let mut s = (**subquery).clone();
10293                self.resolve_select_subqueries(&mut s, cancel)?;
10294                let r = match self.exec_bare_select_cancel(&s, cancel) {
10295                    Ok(r) => r,
10296                    Err(e) if is_correlation_error(&e) => return Ok(None),
10297                    Err(e) => return Err(e),
10298                };
10299                let QueryResult::Rows { columns, rows, .. } = r else {
10300                    return Err(EngineError::Unsupported(
10301                        "IN-subquery: inner statement did not return rows".into(),
10302                    ));
10303                };
10304                if columns.len() != 1 {
10305                    return Err(EngineError::Unsupported(alloc::format!(
10306                        "IN-subquery must project exactly one column; got {}",
10307                        columns.len()
10308                    )));
10309                }
10310                // Build the same OR-Eq chain the parse-time literal-list
10311                // path constructs, with each value lifted into a Literal.
10312                let mut acc: Option<Expr> = None;
10313                for row in rows {
10314                    let v = row.values.into_iter().next().unwrap_or(Value::Null);
10315                    let lit = value_to_literal_expr(v)?;
10316                    let cmp = Expr::Binary {
10317                        lhs: expr.clone(),
10318                        op: BinOp::Eq,
10319                        rhs: Box::new(lit),
10320                    };
10321                    acc = Some(match acc {
10322                        None => cmp,
10323                        Some(prev) => Expr::Binary {
10324                            lhs: Box::new(prev),
10325                            op: BinOp::Or,
10326                            rhs: Box::new(cmp),
10327                        },
10328                    });
10329                }
10330                let combined = acc.unwrap_or(Expr::Literal(Literal::Bool(false)));
10331                let final_expr = if *negated {
10332                    Expr::Unary {
10333                        op: UnOp::Not,
10334                        expr: Box::new(combined),
10335                    }
10336                } else {
10337                    combined
10338                };
10339                Ok(Some(final_expr))
10340            }
10341            _ => Ok(None),
10342        }
10343    }
10344}
10345
10346// ---- v4.12 window-function helpers ----
10347// The (partition-key, order-key, original-index) tuple shape used
10348// across these helpers is intrinsic to the planner. Factoring it
10349// into a typedef adds indirection without making the code clearer,
10350// so several lints are allowed inline on the affected functions
10351// rather than module-wide.
10352
10353/// v4.22: cheap structural scan for `FROM <name>` (qualified or
10354/// not) inside a SELECT — used to verify the anchor of a WITH
10355/// RECURSIVE CTE doesn't recurse into itself. Conservative: walks
10356/// FROM joins, subqueries, and unions.
10357fn select_refers_to(stmt: &SelectStatement, target: &str) -> bool {
10358    if let Some(from) = &stmt.from
10359        && from_refers_to(from, target)
10360    {
10361        return true;
10362    }
10363    for (_, peer) in &stmt.unions {
10364        if select_refers_to(peer, target) {
10365            return true;
10366        }
10367    }
10368    for item in &stmt.items {
10369        if let SelectItem::Expr { expr, .. } = item
10370            && expr_refers_to(expr, target)
10371        {
10372            return true;
10373        }
10374    }
10375    if let Some(w) = &stmt.where_
10376        && expr_refers_to(w, target)
10377    {
10378        return true;
10379    }
10380    false
10381}
10382
10383fn from_refers_to(from: &FromClause, target: &str) -> bool {
10384    if from.primary.name.eq_ignore_ascii_case(target) {
10385        return true;
10386    }
10387    from.joins
10388        .iter()
10389        .any(|j| j.table.name.eq_ignore_ascii_case(target))
10390}
10391
10392fn expr_refers_to(e: &Expr, target: &str) -> bool {
10393    match e {
10394        Expr::ScalarSubquery(s) => select_refers_to(s, target),
10395        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
10396            select_refers_to(subquery, target)
10397        }
10398        Expr::Binary { lhs, rhs, .. } => expr_refers_to(lhs, target) || expr_refers_to(rhs, target),
10399        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10400            expr_refers_to(expr, target)
10401        }
10402        Expr::Like { expr, pattern, .. } => {
10403            expr_refers_to(expr, target) || expr_refers_to(pattern, target)
10404        }
10405        Expr::FunctionCall { args, .. } => args.iter().any(|a| expr_refers_to(a, target)),
10406        Expr::Extract { source, .. } => expr_refers_to(source, target),
10407        Expr::WindowFunction {
10408            args,
10409            partition_by,
10410            order_by,
10411            ..
10412        } => {
10413            args.iter().any(|a| expr_refers_to(a, target))
10414                || partition_by.iter().any(|p| expr_refers_to(p, target))
10415                || order_by.iter().any(|(o, _)| expr_refers_to(o, target))
10416        }
10417        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
10418        Expr::Array(items) => items.iter().any(|e| expr_refers_to(e, target)),
10419        Expr::ArraySubscript { target: t, index } => {
10420            expr_refers_to(t, target) || expr_refers_to(index, target)
10421        }
10422        Expr::AnyAll { expr, array, .. } => {
10423            expr_refers_to(expr, target) || expr_refers_to(array, target)
10424        }
10425        Expr::Case {
10426            operand,
10427            branches,
10428            else_branch,
10429        } => {
10430            operand
10431                .as_deref()
10432                .is_some_and(|o| expr_refers_to(o, target))
10433                || branches
10434                    .iter()
10435                    .any(|(w, t)| expr_refers_to(w, target) || expr_refers_to(t, target))
10436                || else_branch
10437                    .as_deref()
10438                    .is_some_and(|e| expr_refers_to(e, target))
10439        }
10440    }
10441}
10442
10443/// v4.22: pick more specific column types from observed rows when
10444/// the projection builder defaulted to Text (the v1.x behavior for
10445/// non-column expressions). Lets `WITH t(n) AS (SELECT 1 ...)`
10446/// land an Int column in the CTE storage table rather than failing
10447/// the insert with "expected TEXT, got INT".
10448/// v7.16.2 — map an SPG [`DataType`] to the PG-canonical
10449/// `information_schema.columns.data_type` text. Covers the
10450/// values mailrs's migrations probe (`'ARRAY'`, `'integer'`,
10451/// `'text'`, …). Unknown variants fall back to the SPG name
10452/// downcased — better than panicking on a future DataType.
10453fn pg_data_type_text(ty: DataType) -> alloc::string::String {
10454    let s = match ty {
10455        DataType::Int => "integer",
10456        DataType::BigInt => "bigint",
10457        DataType::SmallInt => "smallint",
10458        DataType::Float => "double precision",
10459        DataType::Bool => "boolean",
10460        DataType::Text => "text",
10461        DataType::Varchar(_) => "character varying",
10462        DataType::Date => "date",
10463        DataType::Timestamp => "timestamp without time zone",
10464        DataType::Timestamptz => "timestamp with time zone",
10465        DataType::Json => "jsonb",
10466        DataType::Bytes => "bytea",
10467        DataType::TextArray | DataType::IntArray | DataType::BigIntArray => "ARRAY",
10468        DataType::TsVector => "tsvector",
10469        DataType::TsQuery => "tsquery",
10470        DataType::Vector { .. } => "USER-DEFINED",
10471        // Non-exhaustive — fall back to "USER-DEFINED" the way
10472        // PG labels any pg_type it doesn't recognise.
10473        _ => "USER-DEFINED",
10474    };
10475    alloc::string::String::from(s)
10476}
10477
10478/// v7.16.2 — synthesise `information_schema.columns`. mailrs
10479/// queries are of shape `SELECT 1 FROM information_schema.columns
10480/// WHERE table_name = … AND column_name = … AND data_type = …` —
10481/// the v7.16.2 view returns the columns mailrs probes; broader
10482/// PG-spec parity (ordinal_position, is_nullable, character_
10483/// maximum_length, udt_name, …) lands as needed.
10484fn synth_information_schema_columns(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10485    let schema = alloc::vec![
10486        ColumnSchema::new("table_catalog", DataType::Text, false),
10487        ColumnSchema::new("table_schema", DataType::Text, false),
10488        ColumnSchema::new("table_name", DataType::Text, false),
10489        ColumnSchema::new("column_name", DataType::Text, false),
10490        ColumnSchema::new("ordinal_position", DataType::Int, false),
10491        ColumnSchema::new("is_nullable", DataType::Text, false),
10492        ColumnSchema::new("data_type", DataType::Text, false),
10493    ];
10494    let mut rows: Vec<Row> = Vec::new();
10495    for tname in cat.table_names() {
10496        let Some(t) = cat.get(&tname) else { continue };
10497        for (i, col) in t.schema().columns.iter().enumerate() {
10498            #[allow(clippy::cast_possible_wrap)]
10499            let ordinal = (i + 1) as i32;
10500            rows.push(Row::new(alloc::vec![
10501                Value::Text("spg".into()),
10502                Value::Text("public".into()),
10503                Value::Text(tname.clone()),
10504                Value::Text(col.name.clone()),
10505                Value::Int(ordinal),
10506                Value::Text(if col.nullable {
10507                    "YES".into()
10508                } else {
10509                    "NO".into()
10510                }),
10511                Value::Text(pg_data_type_text(col.ty)),
10512            ]));
10513        }
10514    }
10515    (schema, rows)
10516}
10517
10518/// v7.16.2 — synthesise `information_schema.tables`.
10519fn synth_information_schema_tables(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10520    let schema = alloc::vec![
10521        ColumnSchema::new("table_catalog", DataType::Text, false),
10522        ColumnSchema::new("table_schema", DataType::Text, false),
10523        ColumnSchema::new("table_name", DataType::Text, false),
10524        ColumnSchema::new("table_type", DataType::Text, false),
10525    ];
10526    let mut rows: Vec<Row> = Vec::new();
10527    for tname in cat.table_names() {
10528        rows.push(Row::new(alloc::vec![
10529            Value::Text("spg".into()),
10530            Value::Text("public".into()),
10531            Value::Text(tname.clone()),
10532            Value::Text("BASE TABLE".into()),
10533        ]));
10534    }
10535    (schema, rows)
10536}
10537
10538/// v7.16.2 — synthesise `pg_catalog.pg_class`. Minimum shape
10539/// for psql `\d` / ORM probes: `relname` + `relkind`. Each
10540/// user table emits one row.
10541fn synth_pg_class(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10542    let schema = alloc::vec![
10543        ColumnSchema::new("relname", DataType::Text, false),
10544        ColumnSchema::new("relkind", DataType::Text, false),
10545        ColumnSchema::new("relnamespace", DataType::BigInt, false),
10546    ];
10547    let mut rows: Vec<Row> = Vec::new();
10548    for tname in cat.table_names() {
10549        rows.push(Row::new(alloc::vec![
10550            Value::Text(tname.clone()),
10551            Value::Text("r".into()),
10552            Value::BigInt(2200), // PG's `public` namespace OID
10553        ]));
10554    }
10555    (schema, rows)
10556}
10557
10558/// v7.16.2 — synthesise `pg_catalog.pg_attribute`. Minimum
10559/// shape: `attrelid` (text — SPG has no OID), `attname`,
10560/// `attnum`, `atttypid` (text), `attnotnull`.
10561fn synth_pg_attribute(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10562    let schema = alloc::vec![
10563        ColumnSchema::new("attrelid", DataType::Text, false),
10564        ColumnSchema::new("attname", DataType::Text, false),
10565        ColumnSchema::new("attnum", DataType::Int, false),
10566        ColumnSchema::new("atttypid", DataType::Text, false),
10567        ColumnSchema::new("attnotnull", DataType::Bool, false),
10568    ];
10569    let mut rows: Vec<Row> = Vec::new();
10570    for tname in cat.table_names() {
10571        let Some(t) = cat.get(&tname) else { continue };
10572        for (i, col) in t.schema().columns.iter().enumerate() {
10573            #[allow(clippy::cast_possible_wrap)]
10574            let ordinal = (i + 1) as i32;
10575            rows.push(Row::new(alloc::vec![
10576                Value::Text(tname.clone()),
10577                Value::Text(col.name.clone()),
10578                Value::Int(ordinal),
10579                Value::Text(pg_data_type_text(col.ty)),
10580                Value::Bool(!col.nullable),
10581            ]));
10582        }
10583    }
10584    (schema, rows)
10585}
10586
10587/// v7.17.0 Phase 3.P0-50 — synthesise `pg_catalog.pg_type`. The
10588/// returned rows cover every built-in scalar / array type sqlx,
10589/// SQLAlchemy, Diesel and pgAdmin look up at compile / connect
10590/// time. PG-canonical schema columns we expose:
10591///   * oid           — type OID (the lookup key sqlx uses)
10592///   * typname       — canonical type name (`int4`, `text`, …)
10593///   * typlen        — width in bytes (-1 for var-length)
10594///   * typtype       — `b`ase / `c`omposite / `e`num / etc.
10595///   * typcategory   — PG type category single-char
10596///   * typelem       — element OID for arrays (0 otherwise)
10597///   * typarray      — array-type OID (0 if no array type)
10598///   * typnamespace  — schema OID (always `public` = 2200)
10599///
10600/// Other pg_type columns (typowner, typinput/typoutput, etc.)
10601/// land in follow-up work — sqlx encoders don't query them at
10602/// connect time.
10603fn synth_pg_type(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10604    let schema = alloc::vec![
10605        ColumnSchema::new("oid", DataType::BigInt, false),
10606        ColumnSchema::new("typname", DataType::Text, false),
10607        ColumnSchema::new("typlen", DataType::SmallInt, false),
10608        ColumnSchema::new("typtype", DataType::Text, false),
10609        ColumnSchema::new("typcategory", DataType::Text, false),
10610        ColumnSchema::new("typelem", DataType::BigInt, false),
10611        ColumnSchema::new("typarray", DataType::BigInt, false),
10612        ColumnSchema::new("typnamespace", DataType::BigInt, false),
10613    ];
10614    // (oid, name, len, type, cat, elem, array_oid). PG OID
10615    // numbers come straight from `pg_type.dat`.
10616    let scalars: &[(i64, &str, i16, &str, &str, i64, i64)] = &[
10617        // bool
10618        (16, "bool", 1, "b", "B", 0, 1000),
10619        (17, "bytea", -1, "b", "U", 0, 1001),
10620        (18, "char", 1, "b", "S", 0, 1002),
10621        (19, "name", 64, "b", "S", 0, 1003),
10622        (20, "int8", 8, "b", "N", 0, 1016),
10623        (21, "int2", 2, "b", "N", 0, 1005),
10624        (23, "int4", 4, "b", "N", 0, 1007),
10625        (24, "regproc", 4, "b", "N", 0, 1008),
10626        (25, "text", -1, "b", "S", 0, 1009),
10627        (26, "oid", 4, "b", "N", 0, 1028),
10628        (114, "json", -1, "b", "U", 0, 199),
10629        (142, "xml", -1, "b", "U", 0, 143),
10630        (700, "float4", 4, "b", "N", 0, 1021),
10631        (701, "float8", 8, "b", "N", 0, 1022),
10632        (650, "cidr", -1, "b", "I", 0, 651),
10633        (869, "inet", -1, "b", "I", 0, 1041),
10634        (829, "macaddr", 6, "b", "U", 0, 1040),
10635        (1042, "bpchar", -1, "b", "S", 0, 1014),
10636        (1043, "varchar", -1, "b", "S", 0, 1015),
10637        (1082, "date", 4, "b", "D", 0, 1182),
10638        (1083, "time", 8, "b", "D", 0, 1183),
10639        (1114, "timestamp", 8, "b", "D", 0, 1115),
10640        (1184, "timestamptz", 8, "b", "D", 0, 1185),
10641        (1186, "interval", 16, "b", "T", 0, 1187),
10642        (1266, "timetz", 12, "b", "D", 0, 1270),
10643        (1700, "numeric", -1, "b", "N", 0, 1231),
10644        (790, "money", 8, "b", "N", 0, 791),
10645        (2950, "uuid", 16, "b", "U", 0, 2951),
10646        (3802, "jsonb", -1, "b", "U", 0, 3807),
10647        (3614, "tsvector", -1, "b", "U", 0, 3643),
10648        (3615, "tsquery", -1, "b", "U", 0, 3645),
10649        // hstore + range types — typcategory 'U' (user) / 'R' (range).
10650        (3908, "tstzrange", -1, "r", "R", 0, 3909),
10651        (3910, "tsrange", -1, "r", "R", 0, 3911),
10652        (3904, "int4range", -1, "r", "R", 0, 3905),
10653        (3926, "int8range", -1, "r", "R", 0, 3927),
10654        (3906, "numrange", -1, "r", "R", 0, 3907),
10655        (3912, "daterange", -1, "r", "R", 0, 3913),
10656    ];
10657    // Array companion types share the typelem / typcategory='A'.
10658    // We emit just the array OIDs the scalars reference.
10659    let arrays: &[(i64, &str, i64)] = &[
10660        (1000, "_bool", 16),
10661        (1001, "_bytea", 17),
10662        (1002, "_char", 18),
10663        (1003, "_name", 19),
10664        (1016, "_int8", 20),
10665        (1005, "_int2", 21),
10666        (1007, "_int4", 23),
10667        (1008, "_regproc", 24),
10668        (1009, "_text", 25),
10669        (1028, "_oid", 26),
10670        (199, "_json", 114),
10671        (143, "_xml", 142),
10672        (1021, "_float4", 700),
10673        (1022, "_float8", 701),
10674        (651, "_cidr", 650),
10675        (1041, "_inet", 869),
10676        (1040, "_macaddr", 829),
10677        (1014, "_bpchar", 1042),
10678        (1015, "_varchar", 1043),
10679        (1182, "_date", 1082),
10680        (1183, "_time", 1083),
10681        (1115, "_timestamp", 1114),
10682        (1185, "_timestamptz", 1184),
10683        (1187, "_interval", 1186),
10684        (1270, "_timetz", 1266),
10685        (1231, "_numeric", 1700),
10686        (791, "_money", 790),
10687        (2951, "_uuid", 2950),
10688        (3807, "_jsonb", 3802),
10689        (3643, "_tsvector", 3614),
10690        (3645, "_tsquery", 3615),
10691    ];
10692    let mut rows: Vec<Row> = Vec::with_capacity(scalars.len() + arrays.len());
10693    for &(oid, name, len, ty, cat, elem, arr) in scalars {
10694        rows.push(Row::new(alloc::vec![
10695            Value::BigInt(oid),
10696            Value::Text(name.into()),
10697            Value::SmallInt(len),
10698            Value::Text(ty.into()),
10699            Value::Text(cat.into()),
10700            Value::BigInt(elem),
10701            Value::BigInt(arr),
10702            Value::BigInt(2200),
10703        ]));
10704    }
10705    for &(oid, name, elem) in arrays {
10706        rows.push(Row::new(alloc::vec![
10707            Value::BigInt(oid),
10708            Value::Text(name.into()),
10709            Value::SmallInt(-1),
10710            Value::Text("b".into()),
10711            Value::Text("A".into()),
10712            Value::BigInt(elem),
10713            Value::BigInt(0),
10714            Value::BigInt(2200),
10715        ]));
10716    }
10717    (schema, rows)
10718}
10719
10720/// v7.17.0 Phase 3.P0-51 — synthesise `pg_catalog.pg_proc`. ORM /
10721/// pgAdmin probes look up functions by name; SPG synthesises rows
10722/// for the built-in scalar functions / aggregates / window funcs
10723/// the engine actually dispatches. SPG has no user-defined
10724/// functions yet so the table is a stable static list.
10725///
10726/// Schema columns exposed:
10727///   * oid (BigInt) — function OID from PG's pg_proc.dat
10728///   * proname (Text) — function name (lowercase)
10729///   * pronamespace (BigInt) — 11 (`pg_catalog`)
10730///   * prokind (Text) — 'f' function, 'a' aggregate, 'w' window
10731///   * pronargs (SmallInt) — declared arg count (-1 for variadic)
10732///   * prorettype (BigInt) — return type OID (matches synth_pg_type)
10733fn synth_pg_proc(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10734    let schema = alloc::vec![
10735        ColumnSchema::new("oid", DataType::BigInt, false),
10736        ColumnSchema::new("proname", DataType::Text, false),
10737        ColumnSchema::new("pronamespace", DataType::BigInt, false),
10738        ColumnSchema::new("prokind", DataType::Text, false),
10739        ColumnSchema::new("pronargs", DataType::Int, false),
10740        ColumnSchema::new("prorettype", DataType::BigInt, false),
10741    ];
10742    // (oid, name, kind, nargs, rettype). OIDs taken from PG's
10743    // pg_proc.dat for the common subset.
10744    let funcs: &[(i64, &str, &str, i32, i64)] = &[
10745        // Scalar functions.
10746        (1318, "length", "f", 1, 23),
10747        (871, "upper", "f", 1, 25),
10748        (870, "lower", "f", 1, 25),
10749        (936, "substring", "f", 3, 25),
10750        (937, "substring", "f", 2, 25),
10751        (3055, "btrim", "f", 1, 25),
10752        (885, "btrim", "f", 2, 25),
10753        (3056, "ltrim", "f", 1, 25),
10754        (875, "ltrim", "f", 2, 25),
10755        (3057, "rtrim", "f", 1, 25),
10756        (876, "rtrim", "f", 2, 25),
10757        (1397, "abs", "f", 1, 23),
10758        (1396, "abs", "f", 1, 20),
10759        (1606, "round", "f", 1, 1700),
10760        (1707, "round", "f", 2, 1700),
10761        (2308, "ceil", "f", 1, 701),
10762        (2309, "ceiling", "f", 1, 701),
10763        (2310, "floor", "f", 1, 701),
10764        (1376, "sqrt", "f", 1, 701),
10765        (1369, "ln", "f", 1, 701),
10766        (1373, "exp", "f", 1, 701),
10767        (1368, "power", "f", 2, 701),
10768        (2228, "random", "f", 0, 701),
10769        // Date / time.
10770        (1299, "now", "f", 0, 1184),
10771        (1274, "current_timestamp", "f", 0, 1184),
10772        (1140, "current_date", "f", 0, 1082),
10773        (2050, "current_time", "f", 0, 1083),
10774        (1158, "date_trunc", "f", 2, 1184),
10775        (1171, "date_part", "f", 2, 701),
10776        (1172, "age", "f", 1, 1186),
10777        (936, "to_char", "f", 2, 25),
10778        // Session / introspection.
10779        (861, "current_database", "f", 0, 19),
10780        (745, "current_user", "f", 0, 19),
10781        (745, "session_user", "f", 0, 19),
10782        (1402, "current_schema", "f", 0, 19),
10783        // String concat / format.
10784        (3058, "concat", "f", -1, 25),
10785        (3059, "concat_ws", "f", -1, 25),
10786        (3539, "format", "f", -1, 25),
10787        // Type introspection.
10788        (2877, "pg_typeof", "f", 1, 2206),
10789        // JSON.
10790        (3198, "json_build_object", "f", -1, 114),
10791        (3199, "jsonb_build_object", "f", -1, 3802),
10792        (3271, "json_build_array", "f", -1, 114),
10793        (3272, "jsonb_build_array", "f", -1, 3802),
10794        // UUID.
10795        (3253, "gen_random_uuid", "f", 0, 2950),
10796        (3252, "uuid_generate_v4", "f", 0, 2950),
10797        // Aggregates.
10798        (2147, "count", "a", 0, 20),
10799        (2803, "count", "a", -1, 20),
10800        (2116, "max", "a", 1, 23),
10801        (2132, "min", "a", 1, 23),
10802        (2108, "sum", "a", 1, 20),
10803        (2100, "avg", "a", 1, 1700),
10804        (2517, "string_agg", "a", 2, 25),
10805        (2747, "array_agg", "a", 1, 1009),
10806        (2517, "bool_and", "a", 1, 16),
10807        (2518, "bool_or", "a", 1, 16),
10808        (2519, "every", "a", 1, 16),
10809        // Window functions.
10810        (3100, "row_number", "w", 0, 20),
10811        (3101, "rank", "w", 0, 20),
10812        (3102, "dense_rank", "w", 0, 20),
10813        (3103, "percent_rank", "w", 0, 701),
10814        (3104, "cume_dist", "w", 0, 701),
10815        (3105, "lag", "w", -1, 2283),
10816        (3106, "lead", "w", -1, 2283),
10817        (3107, "first_value", "w", 1, 2283),
10818        (3108, "last_value", "w", 1, 2283),
10819        (3109, "nth_value", "w", 2, 2283),
10820    ];
10821    let mut rows: Vec<Row> = Vec::with_capacity(funcs.len());
10822    for &(oid, name, kind, nargs, rettype) in funcs {
10823        rows.push(Row::new(alloc::vec![
10824            Value::BigInt(oid),
10825            Value::Text(name.into()),
10826            Value::BigInt(11),
10827            Value::Text(kind.into()),
10828            Value::Int(nargs),
10829            Value::BigInt(rettype),
10830        ]));
10831    }
10832    (schema, rows)
10833}
10834
10835/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.user`. MySQL admin
10836/// queries (`SELECT user, host FROM mysql.user`) probe this at
10837/// connect time to list accounts. SPG ships one row per
10838/// UserStore entry plus a synthetic `root` superuser row for
10839/// MySQL bootstrap compat.
10840fn synth_mysql_user(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
10841    let schema = alloc::vec![
10842        ColumnSchema::new("user", DataType::Text, false),
10843        ColumnSchema::new("host", DataType::Text, false),
10844        ColumnSchema::new("select_priv", DataType::Text, false),
10845    ];
10846    let mut rows: Vec<Row> = Vec::new();
10847    rows.push(Row::new(alloc::vec![
10848        Value::Text("root".into()),
10849        Value::Text("localhost".into()),
10850        Value::Text("Y".into()),
10851    ]));
10852    for (name, _) in engine.users.iter() {
10853        if name != "root" {
10854            rows.push(Row::new(alloc::vec![
10855                Value::Text(name.to_string()),
10856                Value::Text("%".into()),
10857                Value::Text("Y".into()),
10858            ]));
10859        }
10860    }
10861    (schema, rows)
10862}
10863
10864/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.db`. The
10865/// per-database privileges table. SPG is single-database so the
10866/// table surfaces one row per declared user with full privileges
10867/// on the canonical `postgres` database.
10868fn synth_mysql_db() -> (Vec<ColumnSchema>, Vec<Row>) {
10869    let schema = alloc::vec![
10870        ColumnSchema::new("host", DataType::Text, false),
10871        ColumnSchema::new("db", DataType::Text, false),
10872        ColumnSchema::new("user", DataType::Text, false),
10873        ColumnSchema::new("select_priv", DataType::Text, false),
10874    ];
10875    let rows = alloc::vec![Row::new(alloc::vec![
10876        Value::Text("localhost".into()),
10877        Value::Text("postgres".into()),
10878        Value::Text("root".into()),
10879        Value::Text("Y".into()),
10880    ])];
10881    (schema, rows)
10882}
10883
10884/// v7.17.0 Phase 3.P0-63 — synthesise
10885/// `information_schema.KEY_COLUMN_USAGE`. ORM migration tools
10886/// (Alembic, Sequelize, TypeORM) walk this view to discover FK
10887/// relationships in MySQL-flavoured introspection queries.
10888///
10889/// Schema columns exposed:
10890///   * CONSTRAINT_NAME (Text)
10891///   * TABLE_NAME (Text)
10892///   * COLUMN_NAME (Text)
10893///   * ORDINAL_POSITION (Int)
10894///   * REFERENCED_TABLE_NAME (Text) — empty for non-FK rows
10895///   * REFERENCED_COLUMN_NAME (Text) — empty for non-FK rows
10896fn synth_info_key_column_usage(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10897    let schema = alloc::vec![
10898        ColumnSchema::new("constraint_name", DataType::Text, false),
10899        ColumnSchema::new("table_name", DataType::Text, false),
10900        ColumnSchema::new("column_name", DataType::Text, false),
10901        ColumnSchema::new("ordinal_position", DataType::Int, false),
10902        ColumnSchema::new("referenced_table_name", DataType::Text, false),
10903        ColumnSchema::new("referenced_column_name", DataType::Text, false),
10904    ];
10905    let mut rows: Vec<Row> = Vec::new();
10906    for tname in cat.table_names() {
10907        let Some(t) = cat.get(&tname) else { continue };
10908        let cols = &t.schema().columns;
10909        let col_name_at = |pos: usize| -> String {
10910            cols.get(pos)
10911                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
10912        };
10913        // FKs.
10914        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
10915            let conname = fk
10916                .name
10917                .clone()
10918                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
10919            for (i, (&local, &parent)) in fk
10920                .local_columns
10921                .iter()
10922                .zip(fk.parent_columns.iter())
10923                .enumerate()
10924            {
10925                let parent_name = cat
10926                    .get(&fk.parent_table)
10927                    .and_then(|pt| pt.schema().columns.get(parent).map(|c| c.name.clone()))
10928                    .unwrap_or_else(|| alloc::format!("col{parent}"));
10929                #[allow(clippy::cast_possible_wrap)]
10930                let ordinal = (i + 1) as i32;
10931                rows.push(Row::new(alloc::vec![
10932                    Value::Text(conname.clone()),
10933                    Value::Text(tname.clone()),
10934                    Value::Text(col_name_at(local)),
10935                    Value::Int(ordinal),
10936                    Value::Text(fk.parent_table.clone()),
10937                    Value::Text(parent_name),
10938                ]));
10939            }
10940        }
10941        // PK / composite UC entries.
10942        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
10943            let conname = if uc.is_primary_key {
10944                alloc::format!("{}_pkey", tname)
10945            } else {
10946                alloc::format!("{}_uniq{ci}", tname)
10947            };
10948            for (i, &local) in uc.columns.iter().enumerate() {
10949                #[allow(clippy::cast_possible_wrap)]
10950                let ordinal = (i + 1) as i32;
10951                rows.push(Row::new(alloc::vec![
10952                    Value::Text(conname.clone()),
10953                    Value::Text(tname.clone()),
10954                    Value::Text(col_name_at(local)),
10955                    Value::Int(ordinal),
10956                    Value::Text(String::new()),
10957                    Value::Text(String::new()),
10958                ]));
10959            }
10960        }
10961    }
10962    (schema, rows)
10963}
10964
10965/// v7.17.0 Phase 3.P0-64 — synthesise
10966/// `information_schema.REFERENTIAL_CONSTRAINTS`. One row per FK.
10967fn synth_info_referential_constraints(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10968    let schema = alloc::vec![
10969        ColumnSchema::new("constraint_name", DataType::Text, false),
10970        ColumnSchema::new("table_name", DataType::Text, false),
10971        ColumnSchema::new("referenced_table_name", DataType::Text, false),
10972        ColumnSchema::new("update_rule", DataType::Text, false),
10973        ColumnSchema::new("delete_rule", DataType::Text, false),
10974    ];
10975    fn rule_name(a: spg_storage::FkAction) -> &'static str {
10976        match a {
10977            spg_storage::FkAction::Cascade => "CASCADE",
10978            spg_storage::FkAction::SetNull => "SET NULL",
10979            spg_storage::FkAction::SetDefault => "SET DEFAULT",
10980            spg_storage::FkAction::Restrict => "RESTRICT",
10981            spg_storage::FkAction::NoAction => "NO ACTION",
10982        }
10983    }
10984    let mut rows: Vec<Row> = Vec::new();
10985    for tname in cat.table_names() {
10986        let Some(t) = cat.get(&tname) else { continue };
10987        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
10988            let conname = fk
10989                .name
10990                .clone()
10991                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
10992            rows.push(Row::new(alloc::vec![
10993                Value::Text(conname),
10994                Value::Text(tname.clone()),
10995                Value::Text(fk.parent_table.clone()),
10996                Value::Text(rule_name(fk.on_update).into()),
10997                Value::Text(rule_name(fk.on_delete).into()),
10998            ]));
10999        }
11000    }
11001    (schema, rows)
11002}
11003
11004/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.STATISTICS`.
11005/// One row per (index × column) — admin tools walk this to
11006/// surface index-cardinality estimates.
11007fn synth_info_statistics(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11008    let schema = alloc::vec![
11009        ColumnSchema::new("table_name", DataType::Text, false),
11010        ColumnSchema::new("index_name", DataType::Text, false),
11011        ColumnSchema::new("column_name", DataType::Text, false),
11012        ColumnSchema::new("seq_in_index", DataType::Int, false),
11013        ColumnSchema::new("non_unique", DataType::Int, false),
11014        ColumnSchema::new("index_type", DataType::Text, false),
11015    ];
11016    let mut rows: Vec<Row> = Vec::new();
11017    for tname in cat.table_names() {
11018        let Some(t) = cat.get(&tname) else { continue };
11019        for idx in t.indices() {
11020            let col = t
11021                .schema()
11022                .columns
11023                .get(idx.column_position)
11024                .map_or("?".into(), |c| c.name.clone());
11025            rows.push(Row::new(alloc::vec![
11026                Value::Text(tname.clone()),
11027                Value::Text(idx.name.clone()),
11028                Value::Text(col),
11029                Value::Int(1),
11030                Value::Int(i32::from(!idx.is_unique)),
11031                Value::Text("BTREE".into()),
11032            ]));
11033        }
11034    }
11035    (schema, rows)
11036}
11037
11038/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.ROUTINES`.
11039/// SPG has no user-defined functions in v7.17 so the surface is
11040/// always empty; admin tools just need the table to exist.
11041fn synth_info_routines() -> (Vec<ColumnSchema>, Vec<Row>) {
11042    let schema = alloc::vec![
11043        ColumnSchema::new("routine_name", DataType::Text, false),
11044        ColumnSchema::new("routine_type", DataType::Text, false),
11045        ColumnSchema::new("data_type", DataType::Text, false),
11046    ];
11047    (schema, Vec::new())
11048}
11049
11050/// v7.17.0 Phase 3.P0-54 — synthesise `pg_catalog.pg_constraint`.
11051/// ORM compilers (Diesel, sea-orm) and admin tools probe this for
11052/// FK / UNIQUE / PK / CHECK definitions to surface relationship
11053/// graphs and validation rules. SPG ships one row per
11054/// uniqueness constraint + foreign key declared in the catalog.
11055///
11056/// Schema columns exposed:
11057///   * conname (Text) — constraint name (synthetic when anonymous)
11058///   * contype (Text) — `p` PK, `u` UNIQUE, `f` FK, `c` CHECK
11059///   * conrelid (Text) — owner table name
11060///   * confrelid (Text) — referenced parent table (FK only;
11061///     empty string otherwise)
11062///   * conkey (Text) — comma-separated column names
11063///   * confkey (Text) — comma-separated parent column names (FK only)
11064fn synth_pg_constraint(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11065    let schema = alloc::vec![
11066        ColumnSchema::new("conname", DataType::Text, false),
11067        ColumnSchema::new("contype", DataType::Text, false),
11068        ColumnSchema::new("conrelid", DataType::Text, false),
11069        ColumnSchema::new("confrelid", DataType::Text, false),
11070        ColumnSchema::new("conkey", DataType::Text, false),
11071        ColumnSchema::new("confkey", DataType::Text, false),
11072    ];
11073    let mut rows: Vec<Row> = Vec::new();
11074    for tname in cat.table_names() {
11075        let Some(t) = cat.get(&tname) else { continue };
11076        let cols = &t.schema().columns;
11077        let col_name_at = |pos: usize| -> String {
11078            cols.get(pos)
11079                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
11080        };
11081        // Uniqueness constraints (composite UNIQUE / PRIMARY KEY).
11082        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
11083            let kind = if uc.is_primary_key { "p" } else { "u" };
11084            let conname = if uc.is_primary_key {
11085                alloc::format!("{}_pkey", tname)
11086            } else {
11087                alloc::format!("{}_uniq{ci}", tname)
11088            };
11089            let conkey: Vec<String> = uc.columns.iter().map(|&p| col_name_at(p)).collect();
11090            rows.push(Row::new(alloc::vec![
11091                Value::Text(conname),
11092                Value::Text(kind.into()),
11093                Value::Text(tname.clone()),
11094                Value::Text(String::new()),
11095                Value::Text(conkey.join(",")),
11096                Value::Text(String::new()),
11097            ]));
11098        }
11099        // Single-column PK / UNIQUE indexes that have no
11100        // matching entry in `uniqueness_constraints` (the engine
11101        // creates only the BTree index for the bare-column case;
11102        // composite forms ride the UC path above).
11103        for idx in t.indices() {
11104            if !idx.is_unique {
11105                continue;
11106            }
11107            let is_primary = idx.name.ends_with("_pkey");
11108            let conname = idx.name.clone();
11109            let kind = if is_primary { "p" } else { "u" };
11110            let col_name = col_name_at(idx.column_position);
11111            // Skip if already emitted via the UC loop above (same
11112            // tuple shape — single-column).
11113            let already = t
11114                .schema()
11115                .uniqueness_constraints
11116                .iter()
11117                .any(|uc| uc.columns.len() == 1 && uc.columns[0] == idx.column_position);
11118            if already {
11119                continue;
11120            }
11121            rows.push(Row::new(alloc::vec![
11122                Value::Text(conname),
11123                Value::Text(kind.into()),
11124                Value::Text(tname.clone()),
11125                Value::Text(String::new()),
11126                Value::Text(col_name),
11127                Value::Text(String::new()),
11128            ]));
11129        }
11130        // Foreign keys.
11131        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11132            let conname = fk
11133                .name
11134                .clone()
11135                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11136            let conkey: Vec<String> = fk.local_columns.iter().map(|&p| col_name_at(p)).collect();
11137            // Parent column names: look up the parent table's
11138            // schema if it exists; otherwise emit positions.
11139            let confkey: Vec<String> = if let Some(parent) = cat.get(&fk.parent_table) {
11140                fk.parent_columns
11141                    .iter()
11142                    .map(|&p| {
11143                        parent
11144                            .schema()
11145                            .columns
11146                            .get(p)
11147                            .map_or_else(|| alloc::format!("col{p}"), |c| c.name.clone())
11148                    })
11149                    .collect()
11150            } else {
11151                fk.parent_columns
11152                    .iter()
11153                    .map(|p| alloc::format!("col{p}"))
11154                    .collect()
11155            };
11156            rows.push(Row::new(alloc::vec![
11157                Value::Text(conname),
11158                Value::Text("f".into()),
11159                Value::Text(tname.clone()),
11160                Value::Text(fk.parent_table.clone()),
11161                Value::Text(conkey.join(",")),
11162                Value::Text(confkey.join(",")),
11163            ]));
11164        }
11165    }
11166    (schema, rows)
11167}
11168
11169/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_database`.
11170/// SPG is single-database so we surface a single row keyed on the
11171/// canonical `postgres` database name (matching what every PG
11172/// admin tool's startup screen expects to find).
11173fn synth_pg_database(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11174    let schema = alloc::vec![
11175        ColumnSchema::new("oid", DataType::BigInt, false),
11176        ColumnSchema::new("datname", DataType::Text, false),
11177        ColumnSchema::new("datdba", DataType::BigInt, false),
11178        ColumnSchema::new("encoding", DataType::Int, false),
11179        ColumnSchema::new("datcollate", DataType::Text, false),
11180    ];
11181    let rows = alloc::vec![Row::new(alloc::vec![
11182        Value::BigInt(16384),
11183        Value::Text("postgres".into()),
11184        Value::BigInt(10),
11185        Value::Int(6), // UTF8
11186        Value::Text("en_US.UTF-8".into()),
11187    ])];
11188    (schema, rows)
11189}
11190
11191/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_roles`. PG's
11192/// pg_roles is a view over pg_authid showing all roles. SPG ships
11193/// one row per declared user from the engine's UserStore so admin
11194/// tool startup screens can populate.
11195fn synth_pg_roles(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11196    let schema = alloc::vec![
11197        ColumnSchema::new("oid", DataType::BigInt, false),
11198        ColumnSchema::new("rolname", DataType::Text, false),
11199        ColumnSchema::new("rolsuper", DataType::Bool, false),
11200        ColumnSchema::new("rolinherit", DataType::Bool, false),
11201        ColumnSchema::new("rolcanlogin", DataType::Bool, false),
11202    ];
11203    let mut rows: Vec<Row> = Vec::new();
11204    let oid: i64 = 10;
11205    for (i, (name, _)) in engine.users.iter().enumerate() {
11206        rows.push(Row::new(alloc::vec![
11207            Value::BigInt(oid + (i as i64) + 1),
11208            Value::Text(name.to_string()),
11209            Value::Bool(false),
11210            Value::Bool(true),
11211            Value::Bool(true),
11212        ]));
11213    }
11214    // Always include `postgres` as the bootstrap superuser if not
11215    // already present — admin tools probe for it.
11216    if !rows
11217        .iter()
11218        .any(|r| matches!(&r.values[1], Value::Text(s) if s == "postgres"))
11219    {
11220        rows.insert(
11221            0,
11222            Row::new(alloc::vec![
11223                Value::BigInt(10),
11224                Value::Text("postgres".into()),
11225                Value::Bool(true),
11226                Value::Bool(true),
11227                Value::Bool(true),
11228            ]),
11229        );
11230    }
11231    (schema, rows)
11232}
11233
11234/// v7.17.0 Phase 3.P0-56 — synthesise `pg_catalog.pg_views`. PG's
11235/// pg_views is a view listing every catalog view; SPG ships one
11236/// row per declared view + its definition text.
11237fn synth_pg_views(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11238    let schema = alloc::vec![
11239        ColumnSchema::new("schemaname", DataType::Text, false),
11240        ColumnSchema::new("viewname", DataType::Text, false),
11241        ColumnSchema::new("definition", DataType::Text, false),
11242    ];
11243    let mut rows: Vec<Row> = Vec::new();
11244    for (name, def) in cat.views() {
11245        rows.push(Row::new(alloc::vec![
11246            Value::Text("public".into()),
11247            Value::Text(name.clone()),
11248            Value::Text(def.body.clone()),
11249        ]));
11250    }
11251    (schema, rows)
11252}
11253
11254/// v7.17.0 Phase 3.P0-57 — synthesise `pg_catalog.pg_settings`. ORM
11255/// connection-checkers (sqlx pre-flight, Diesel migrator) and admin
11256/// tools read `pg_settings` to discover server-side configuration.
11257/// SPG surfaces every session_param + a small set of canonical PG
11258/// defaults so the pre-flight queries match.
11259fn synth_pg_settings(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11260    let schema = alloc::vec![
11261        ColumnSchema::new("name", DataType::Text, false),
11262        ColumnSchema::new("setting", DataType::Text, false),
11263        ColumnSchema::new("category", DataType::Text, false),
11264    ];
11265    let mut rows: Vec<Row> = Vec::new();
11266    // Canonical defaults every admin tool expects to find.
11267    let defaults: &[(&str, &str, &str)] = &[
11268        ("server_version", "16.0 (spg)", "Preset Options"),
11269        ("server_encoding", "UTF8", "Client Connection Defaults"),
11270        ("client_encoding", "UTF8", "Client Connection Defaults"),
11271        ("DateStyle", "ISO, MDY", "Client Connection Defaults"),
11272        ("TimeZone", "UTC", "Client Connection Defaults"),
11273        ("standard_conforming_strings", "on", "Compatibility"),
11274        ("integer_datetimes", "on", "Compatibility"),
11275        ("max_connections", "100", "Connections and Authentication"),
11276    ];
11277    for &(name, val, cat) in defaults {
11278        rows.push(Row::new(alloc::vec![
11279            Value::Text(name.into()),
11280            Value::Text(val.into()),
11281            Value::Text(cat.into()),
11282        ]));
11283    }
11284    // Session-set params override the static defaults.
11285    for (k, v) in &engine.session_params {
11286        if !defaults
11287            .iter()
11288            .any(|(n, _, _)| (*n).eq_ignore_ascii_case(k))
11289        {
11290            rows.push(Row::new(alloc::vec![
11291                Value::Text(k.clone()),
11292                Value::Text(v.clone()),
11293                Value::Text("Session".into()),
11294            ]));
11295        }
11296    }
11297    (schema, rows)
11298}
11299
11300/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_indexes`.
11301/// PG's pg_indexes is a real view on pg_index + pg_class + pg_attribute.
11302/// SPG ships it as a synthesised flat table so admin tools (pgAdmin,
11303/// DataGrip) can list indexes by tablename without joining four catalogs.
11304///
11305/// Schema columns exposed:
11306///   * schemaname (Text) — always `public`
11307///   * tablename (Text)
11308///   * indexname (Text)
11309///   * indexdef (Text) — best-effort CREATE INDEX DDL
11310fn synth_pg_indexes(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11311    let schema = alloc::vec![
11312        ColumnSchema::new("schemaname", DataType::Text, false),
11313        ColumnSchema::new("tablename", DataType::Text, false),
11314        ColumnSchema::new("indexname", DataType::Text, false),
11315        ColumnSchema::new("indexdef", DataType::Text, false),
11316    ];
11317    let mut rows: Vec<Row> = Vec::new();
11318    for tname in cat.table_names() {
11319        let Some(t) = cat.get(&tname) else { continue };
11320        for idx in t.indices() {
11321            let col_name = t
11322                .schema()
11323                .columns
11324                .get(idx.column_position)
11325                .map_or("?".into(), |c| c.name.clone());
11326            let unique_kw = if idx.is_unique { "UNIQUE " } else { "" };
11327            let indexdef = alloc::format!(
11328                "CREATE {unique_kw}INDEX {} ON public.{} ({})",
11329                idx.name,
11330                tname,
11331                col_name
11332            );
11333            rows.push(Row::new(alloc::vec![
11334                Value::Text("public".into()),
11335                Value::Text(tname.clone()),
11336                Value::Text(idx.name.clone()),
11337                Value::Text(indexdef),
11338            ]));
11339        }
11340    }
11341    (schema, rows)
11342}
11343
11344/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_index`. The
11345/// "raw" pg_index catalog used by PG-internal tooling for index
11346/// flags and ordinal information. SPG ships the columns ORM probes
11347/// actually filter on.
11348///
11349/// Schema columns exposed:
11350///   * indexrelid (BigInt) — index OID (synthetic = position+1)
11351///   * indrelid (BigInt) — table OID (synthetic = position+1)
11352///   * indnatts (Int) — number of indexed columns
11353///   * indisunique (Bool)
11354///   * indisprimary (Bool)
11355fn synth_pg_index_raw(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11356    let schema = alloc::vec![
11357        ColumnSchema::new("indexrelid", DataType::BigInt, false),
11358        ColumnSchema::new("indrelid", DataType::BigInt, false),
11359        ColumnSchema::new("indnatts", DataType::Int, false),
11360        ColumnSchema::new("indisunique", DataType::Bool, false),
11361        ColumnSchema::new("indisprimary", DataType::Bool, false),
11362    ];
11363    let mut rows: Vec<Row> = Vec::new();
11364    let mut idx_oid: i64 = 100_000;
11365    for (table_idx, tname) in cat.table_names().iter().enumerate() {
11366        let Some(t) = cat.get(tname) else { continue };
11367        for idx in t.indices() {
11368            idx_oid += 1;
11369            #[allow(clippy::cast_possible_wrap)]
11370            let nattrs = (1 + idx.extra_column_positions.len()) as i32;
11371            // is_primary: SPG / PG flag the primary via the
11372            // index name convention `<table>_pkey`.
11373            let is_primary = idx.name.ends_with("_pkey");
11374            rows.push(Row::new(alloc::vec![
11375                Value::BigInt(idx_oid),
11376                Value::BigInt((table_idx + 1) as i64),
11377                Value::Int(nattrs),
11378                Value::Bool(idx.is_unique),
11379                Value::Bool(is_primary),
11380            ]));
11381        }
11382    }
11383    (schema, rows)
11384}
11385
11386/// v7.17.0 Phase 3.P0-52 — synthesise `pg_catalog.pg_namespace`.
11387/// SPG is single-schema so we expose the canonical PG schemas:
11388/// `public` (user-facing), `pg_catalog` (built-in), and
11389/// `information_schema` (PG meta).
11390fn synth_pg_namespace(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11391    let schema = alloc::vec![
11392        ColumnSchema::new("oid", DataType::BigInt, false),
11393        ColumnSchema::new("nspname", DataType::Text, false),
11394        ColumnSchema::new("nspowner", DataType::BigInt, false),
11395    ];
11396    let rows = alloc::vec![
11397        Row::new(alloc::vec![
11398            Value::BigInt(11),
11399            Value::Text("pg_catalog".into()),
11400            Value::BigInt(10),
11401        ]),
11402        Row::new(alloc::vec![
11403            Value::BigInt(2200),
11404            Value::Text("public".into()),
11405            Value::BigInt(10),
11406        ]),
11407        Row::new(alloc::vec![
11408            Value::BigInt(13000),
11409            Value::Text("information_schema".into()),
11410            Value::BigInt(10),
11411        ]),
11412    ];
11413    (schema, rows)
11414}
11415
11416/// v7.16.2 — drop the synthesised meta view into the enriched
11417/// catalog so the regular FROM-resolution path can see it.
11418fn materialise_meta_view(
11419    catalog: &mut Catalog,
11420    name: &str,
11421    columns: Vec<ColumnSchema>,
11422    rows: Vec<Row>,
11423) -> Result<(), EngineError> {
11424    let schema = TableSchema::new(name.to_string(), columns);
11425    catalog.create_table(schema).map_err(EngineError::Storage)?;
11426    let table = catalog
11427        .get_mut(name)
11428        .expect("just-created meta view must exist");
11429    for row in rows {
11430        table.insert(row).map_err(EngineError::Storage)?;
11431    }
11432    Ok(())
11433}
11434
11435/// v7.16.2 — true when the SELECT statement references any
11436/// `__spg_info_*` or `__spg_pg_*` synthetic table name (the
11437/// parser produces these for `information_schema.X` /
11438/// `pg_catalog.X`). Used by `exec_select_cancel` to short-
11439/// circuit into the meta-view materialisation path.
11440/// v7.17.0 Phase 1.2 — append the names of any catalog-known
11441/// views referenced by `tref` to `into`. Helper for
11442/// `Engine::expand_views_in_select`. A view that's been already
11443/// materialised as a table (e.g. via the synthetic CTE pass for
11444/// SELECT FROM v) is skipped — the table form wins so the
11445/// recursive exec_select_cancel call inside exec_with_ctes
11446/// doesn't re-expand and trigger the CTE-shadow guard.
11447fn collect_view_refs(
11448    tref: &spg_sql::ast::TableRef,
11449    cat: &spg_storage::Catalog,
11450    into: &mut Vec<String>,
11451) {
11452    if cat.views().contains_key(&tref.name)
11453        && cat.get(&tref.name).is_none()
11454        && !into.iter().any(|n| n == &tref.name)
11455    {
11456        into.push(tref.name.clone());
11457    }
11458}
11459
11460fn select_references_meta_view(stmt: &SelectStatement) -> bool {
11461    fn is_meta(name: &str) -> bool {
11462        name.starts_with("__spg_info_")
11463            || name.starts_with("__spg_pg_")
11464            || name.starts_with("__spg_mysql_")
11465    }
11466    if let Some(from) = &stmt.from {
11467        if is_meta(&from.primary.name) {
11468            return true;
11469        }
11470        for j in &from.joins {
11471            if is_meta(&j.table.name) {
11472                return true;
11473            }
11474        }
11475    }
11476    for cte in &stmt.ctes {
11477        if select_references_meta_view(&cte.body) {
11478            return true;
11479        }
11480    }
11481    false
11482}
11483
11484/// v7.16.2 — collect every meta-view name a SELECT touches.
11485/// Returns a deduplicated, sorted list. Caller materialises
11486/// each one into the enriched catalog before re-running the
11487/// SELECT. Walks JOINs, CTEs, and the primary FROM.
11488fn collect_meta_view_names(
11489    stmt: &SelectStatement,
11490    into: &mut alloc::collections::BTreeSet<String>,
11491) {
11492    fn is_meta(name: &str) -> bool {
11493        name.starts_with("__spg_info_")
11494            || name.starts_with("__spg_pg_")
11495            || name.starts_with("__spg_mysql_")
11496    }
11497    if let Some(from) = &stmt.from {
11498        if is_meta(&from.primary.name) {
11499            into.insert(from.primary.name.clone());
11500        }
11501        for j in &from.joins {
11502            if is_meta(&j.table.name) {
11503                into.insert(j.table.name.clone());
11504            }
11505        }
11506    }
11507    for cte in &stmt.ctes {
11508        collect_meta_view_names(&cte.body, into);
11509    }
11510}
11511
11512fn infer_column_types(columns: &[ColumnSchema], rows: &[Row]) -> Vec<ColumnSchema> {
11513    let mut out = columns.to_vec();
11514    for (col_idx, col) in out.iter_mut().enumerate() {
11515        if col.ty != DataType::Text {
11516            continue;
11517        }
11518        let mut inferred: Option<DataType> = None;
11519        let mut all_null = true;
11520        for row in rows {
11521            let Some(v) = row.values.get(col_idx) else {
11522                continue;
11523            };
11524            let ty = match v {
11525                Value::Null => continue,
11526                Value::SmallInt(_) => DataType::SmallInt,
11527                Value::Int(_) => DataType::Int,
11528                Value::BigInt(_) => DataType::BigInt,
11529                Value::Float(_) => DataType::Float,
11530                Value::Bool(_) => DataType::Bool,
11531                Value::Vector(_) => DataType::Vector {
11532                    dim: 0,
11533                    encoding: VecEncoding::F32,
11534                },
11535                _ => DataType::Text,
11536            };
11537            all_null = false;
11538            inferred = Some(match inferred {
11539                None => ty,
11540                Some(prev) if prev == ty => prev,
11541                Some(_) => DataType::Text,
11542            });
11543        }
11544        if let Some(t) = inferred {
11545            col.ty = t;
11546            col.nullable = true;
11547        } else if all_null {
11548            col.nullable = true;
11549        }
11550    }
11551    out
11552}
11553
11554/// v4.26: render a human-readable plan tree for `EXPLAIN <select>`.
11555/// Lines are pushed into `out`; `depth` controls indentation. We
11556/// describe the rewritten SELECT — what the executor *would* do —
11557/// using the engine handle to spot indexed lookups and table shapes.
11558#[allow(clippy::too_many_lines, clippy::format_push_string)]
11559/// v6.2.4 — Walk every line of the rendered plan tree and append
11560/// per-operator stats. Lines that name a known operator get
11561/// `(rows=N)` (`actual_rows` of the top-level operator equals the
11562/// final result row count; scans report their catalog row count
11563/// as the rows-considered metric). Other lines — Filter / Join /
11564/// GroupBy / OrderBy etc. — are marked `(—)` so the surface is
11565/// complete-by-construction; v6.2.5 fills these in via inline
11566/// executor counters.
11567/// v6.8.3 — surface "CREATE INDEX …" suggestions for every
11568/// `(table, column)` pair the query touches via WHERE / JOIN
11569/// that doesn't already have an index on the owning table.
11570/// Walks the SELECT's FROM clauses + WHERE expression tree;
11571/// returns one line per missing index. Deterministic order:
11572/// FROM-clause iteration order, then column-reference walk
11573/// order inside each WHERE. Each suggestion is a copy-pastable
11574/// DDL string.
11575fn build_index_suggestions(stmt: &SelectStatement, engine: &Engine) -> Vec<String> {
11576    use alloc::collections::BTreeSet;
11577    let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
11578    let mut out: Vec<String> = Vec::new();
11579    let cat = engine.active_catalog();
11580    // Build a (table, qualifier-or-alias) list from the FROM clause
11581    // so unqualified column refs in WHERE resolve to the correct
11582    // table.
11583    let Some(from) = &stmt.from else {
11584        return out;
11585    };
11586    let mut tables: Vec<String> = Vec::new();
11587    tables.push(from.primary.name.clone());
11588    for j in &from.joins {
11589        tables.push(j.table.name.clone());
11590    }
11591    // Collect column refs from the WHERE expression. JOIN ON
11592    // predicates also feed in.
11593    let mut col_refs: Vec<spg_sql::ast::ColumnName> = Vec::new();
11594    if let Some(w) = &stmt.where_ {
11595        collect_column_refs(w, &mut col_refs);
11596    }
11597    for j in &from.joins {
11598        if let Some(on) = &j.on {
11599            collect_column_refs(on, &mut col_refs);
11600        }
11601    }
11602    for cn in &col_refs {
11603        // Resolve owner table: explicit qualifier first, else
11604        // first table in FROM that has a column of this name.
11605        let owner: Option<String> = if let Some(q) = &cn.qualifier {
11606            tables.iter().find(|t| t == &q).cloned()
11607        } else {
11608            tables.iter().find_map(|t| {
11609                cat.get(t).and_then(|tbl| {
11610                    if tbl.schema().column_position(&cn.name).is_some() {
11611                        Some(t.clone())
11612                    } else {
11613                        None
11614                    }
11615                })
11616            })
11617        };
11618        let Some(owner) = owner else {
11619            continue;
11620        };
11621        let Some(tbl) = cat.get(&owner) else {
11622            continue;
11623        };
11624        let Some(col_pos) = tbl.schema().column_position(&cn.name) else {
11625            continue;
11626        };
11627        // Skip if any BTree index already covers this column as
11628        // its key.
11629        let already_indexed = tbl.indices().iter().any(|i| {
11630            matches!(i.kind, spg_storage::IndexKind::BTree(_))
11631                && i.column_position == col_pos
11632                && i.expression.is_none()
11633                && i.partial_predicate.is_none()
11634        });
11635        if already_indexed {
11636            continue;
11637        }
11638        if seen.insert((owner.clone(), cn.name.clone())) {
11639            out.push(alloc::format!(
11640                "SUGGEST: CREATE INDEX ix_{}_{} ON {} ({})",
11641                owner,
11642                cn.name,
11643                owner,
11644                cn.name
11645            ));
11646        }
11647    }
11648    out
11649}
11650
11651/// Walks an `Expr` and pushes every `ColumnName` it references.
11652/// Order is depth-first, left-to-right.
11653fn collect_column_refs(expr: &Expr, out: &mut Vec<spg_sql::ast::ColumnName>) {
11654    match expr {
11655        Expr::Column(cn) => out.push(cn.clone()),
11656        Expr::FunctionCall { args, .. } => {
11657            for a in args {
11658                collect_column_refs(a, out);
11659            }
11660        }
11661        Expr::Binary { lhs, rhs, .. } => {
11662            collect_column_refs(lhs, out);
11663            collect_column_refs(rhs, out);
11664        }
11665        Expr::Unary { expr: e, .. } => collect_column_refs(e, out),
11666        _ => {}
11667    }
11668}
11669
11670fn annotate_explain_lines(lines: &mut [String], total_rows: usize, engine: &Engine) {
11671    let catalog = engine.active_catalog();
11672    let cold_ids = catalog.cold_segment_ids_global();
11673    let any_cold = !cold_ids.is_empty();
11674    let cold_ids_repr = if any_cold {
11675        let mut s = alloc::string::String::from("[");
11676        for (i, id) in cold_ids.iter().enumerate() {
11677            if i > 0 {
11678                s.push(',');
11679            }
11680            s.push_str(&alloc::format!("{id}"));
11681        }
11682        s.push(']');
11683        s
11684    } else {
11685        alloc::string::String::new()
11686    };
11687    for (idx, line) in lines.iter_mut().enumerate() {
11688        let trimmed = line.trim_start();
11689        let is_top_level = idx == 0;
11690        if is_top_level {
11691            line.push_str(&alloc::format!(" (rows={total_rows})"));
11692            continue;
11693        }
11694        if let Some(rest) = trimmed.strip_prefix("From: ") {
11695            let (name, scan_kind) = match rest.split_once(" [") {
11696                Some((n, k)) => (n.trim(), k.trim_end_matches(']')),
11697                None => (rest.trim(), ""),
11698            };
11699            let bare = name.split_whitespace().next().unwrap_or(name);
11700            let hot = catalog.get(bare).map(|t| t.rows().len());
11701            // v6.2.7 — `cold_segments=[id0,id1,…]` enumerates every
11702            // cold-tier segment the scan COULD have walked. v6.2.x
11703            // can tighten to per-table by walking the table's
11704            // BTree-index cold locators.
11705            let annot = match (hot, scan_kind) {
11706                (Some(h), "full scan") => {
11707                    let mut s = alloc::format!(" (hot_rows={h}");
11708                    if any_cold {
11709                        s.push_str(&alloc::format!(
11710                            ", cold_tier=present, cold_segments={cold_ids_repr}"
11711                        ));
11712                    }
11713                    s.push(')');
11714                    s
11715                }
11716                (Some(h), "index seek") => {
11717                    let mut s = alloc::format!(" (hot_rows≤{h}");
11718                    if any_cold {
11719                        s.push_str(&alloc::format!(
11720                            ", cold_tier=present, cold_segments={cold_ids_repr}"
11721                        ));
11722                    }
11723                    s.push(')');
11724                    s
11725                }
11726                _ => " (rows=—)".to_string(),
11727            };
11728            line.push_str(&annot);
11729            continue;
11730        }
11731        // Filter / GroupBy / Having / OrderBy / Limit / Join etc.
11732        line.push_str(" (rows=—)");
11733    }
11734}
11735
11736fn explain_select(stmt: &SelectStatement, engine: &Engine, depth: usize, out: &mut Vec<String>) {
11737    let pad = "  ".repeat(depth);
11738    // 1) Top-level operator label.
11739    let top = if !stmt.ctes.is_empty() {
11740        if stmt.ctes.iter().any(|c| c.recursive) {
11741            "CTEScan (WITH RECURSIVE)"
11742        } else {
11743            "CTEScan (WITH)"
11744        }
11745    } else if !stmt.unions.is_empty() {
11746        "UnionScan"
11747    } else if select_has_window(stmt) {
11748        "WindowAgg"
11749    } else if aggregate::uses_aggregate(stmt) {
11750        "Aggregate"
11751    } else if stmt.distinct {
11752        "Distinct"
11753    } else if stmt.from.is_some() {
11754        "TableScan"
11755    } else {
11756        "Result"
11757    };
11758    out.push(alloc::format!("{pad}{top}"));
11759    let child = "  ".repeat(depth + 1);
11760    // 2) CTE bodies.
11761    for cte in &stmt.ctes {
11762        let head = if cte.recursive {
11763            alloc::format!("{child}CTE (recursive): {}", cte.name)
11764        } else {
11765            alloc::format!("{child}CTE: {}", cte.name)
11766        };
11767        out.push(head);
11768        explain_select(&cte.body, engine, depth + 2, out);
11769    }
11770    // 3) FROM details — primary table + joins, index hits.
11771    if let Some(from) = &stmt.from {
11772        let mut tag = alloc::format!("{child}From: {}", from.primary.name);
11773        if let Some(alias) = &from.primary.alias {
11774            tag.push_str(&alloc::format!(" AS {alias}"));
11775        }
11776        // Try to detect an index-seek opportunity on WHERE against
11777        // the primary table — same heuristic the executor uses.
11778        if let Some(w) = &stmt.where_
11779            && let Some(table) = engine.active_catalog().get(&from.primary.name)
11780        {
11781            let alias = from.primary.alias.as_deref().unwrap_or(&from.primary.name);
11782            let cols = &table.schema().columns;
11783            if try_index_seek(w, cols, engine.active_catalog(), table, alias).is_some() {
11784                tag.push_str(" [index seek]");
11785            } else {
11786                tag.push_str(" [full scan]");
11787            }
11788        } else {
11789            tag.push_str(" [full scan]");
11790        }
11791        out.push(tag);
11792        for j in &from.joins {
11793            let kind = match j.kind {
11794                spg_sql::ast::JoinKind::Inner => "INNER JOIN",
11795                spg_sql::ast::JoinKind::Left => "LEFT JOIN",
11796                spg_sql::ast::JoinKind::Cross => "CROSS JOIN",
11797            };
11798            let mut s = alloc::format!("{child}{kind}: {}", j.table.name);
11799            if let Some(alias) = &j.table.alias {
11800                s.push_str(&alloc::format!(" AS {alias}"));
11801            }
11802            if j.on.is_some() {
11803                s.push_str(" (ON …)");
11804            }
11805            out.push(s);
11806        }
11807    }
11808    // 4) WHERE / GROUP BY / HAVING / ORDER BY / LIMIT / OFFSET.
11809    if let Some(w) = &stmt.where_ {
11810        let mut s = alloc::format!("{child}Filter: {w}");
11811        if expr_has_subquery(w) {
11812            s.push_str(" [subquery]");
11813        }
11814        out.push(s);
11815    }
11816    if let Some(gs) = &stmt.group_by {
11817        let mut parts = Vec::new();
11818        for g in gs {
11819            parts.push(alloc::format!("{g}"));
11820        }
11821        out.push(alloc::format!("{child}GroupBy: {}", parts.join(", ")));
11822    }
11823    if let Some(h) = &stmt.having {
11824        out.push(alloc::format!("{child}Having: {h}"));
11825    }
11826    for o in &stmt.order_by {
11827        let dir = if o.desc { "DESC" } else { "ASC" };
11828        out.push(alloc::format!("{child}OrderBy: {} {dir}", o.expr));
11829    }
11830    if let Some(lim) = stmt.limit {
11831        out.push(alloc::format!("{child}Limit: {lim}"));
11832    }
11833    if let Some(off) = stmt.offset {
11834        out.push(alloc::format!("{child}Offset: {off}"));
11835    }
11836    // 5) Projection — collapse Wildcard or render N items.
11837    if stmt
11838        .items
11839        .iter()
11840        .any(|it| matches!(it, SelectItem::Wildcard))
11841    {
11842        out.push(alloc::format!("{child}Project: *"));
11843    } else {
11844        out.push(alloc::format!(
11845            "{child}Project: {} item(s)",
11846            stmt.items.len()
11847        ));
11848    }
11849    // 6) Recurse into UNION peers.
11850    for (kind, peer) in &stmt.unions {
11851        let label = match kind {
11852            UnionKind::All => "UNION ALL",
11853            UnionKind::Distinct => "UNION",
11854        };
11855        out.push(alloc::format!("{child}{label}"));
11856        explain_select(peer, engine, depth + 2, out);
11857    }
11858}
11859
11860/// v4.23: recognise the engine errors that indicate the inner
11861/// SELECT couldn't be evaluated in isolation because it references
11862/// an outer column — used by `subquery_replacement` to skip
11863/// materialisation and let row-eval handle it instead.
11864fn is_correlation_error(e: &EngineError) -> bool {
11865    matches!(
11866        e,
11867        EngineError::Eval(
11868            eval::EvalError::ColumnNotFound { .. } | eval::EvalError::UnknownQualifier { .. }
11869        )
11870    )
11871}
11872
11873/// v4.23: walk every Expr in `stmt` and replace each Column ref
11874/// that targets the outer scope (qualifier matches the outer
11875/// table alias) with a Literal carrying the outer row's value.
11876/// Conservative: only qualified refs are substituted, so the user
11877/// must write `outer_alias.col` to reference an outer column. This
11878/// matches PG's lexical scoping for correlated subqueries and
11879/// avoids accidentally rebinding inner columns of the same name.
11880/// v7.17.0 Phase 3.P0-41 — LATERAL peer descriptor. Either eagerly
11881/// materialised (every regular table / unnest / generate_series) or
11882/// lateral (subquery re-evaluated per outer row).
11883struct JoinedPeer<'a> {
11884    eager_rows: Option<Vec<Row>>,
11885    cols: Vec<ColumnSchema>,
11886    alias: String,
11887    kind: JoinKind,
11888    on: Option<&'a Expr>,
11889    lateral: Option<&'a SelectStatement>,
11890}
11891
11892/// v7.17.0 Phase 3.P0-41 — synthesise a column name for a LATERAL
11893/// projection item that has no explicit alias. PG names anonymous
11894/// projection items by the function call's name or by `column<i>`.
11895/// SPG mirrors the latter (lower-overhead than walking arbitrary
11896/// Expr shapes) so the probe-schema fallback path produces stable
11897/// names for the lateral peer's columns.
11898fn synth_lateral_col_name(expr: &Expr, idx: usize) -> String {
11899    match expr {
11900        // Bare column reference — use the column's own name.
11901        Expr::Column(c) => c.name.clone(),
11902        // Function call — use the function name (PG canonical:
11903        // `count` / `max` / `lower` …).
11904        Expr::FunctionCall { name, .. } => name.clone(),
11905        // Cast — drill into the inner expression.
11906        Expr::Cast { expr: inner, .. } => synth_lateral_col_name(inner, idx),
11907        // Everything else falls back to PG's `column<N>` placeholder.
11908        _ => alloc::format!("column{}", idx + 1),
11909    }
11910}
11911
11912/// v7.17.0 Phase 3.P0-41 — substitute every `<alias>.<col>` Expr
11913/// reference whose `<alias>.<col>` exists in the outer composite
11914/// schema with the matching value from the outer row. Walks the
11915/// entire SELECT body (items, WHERE, GROUP BY, HAVING, ORDER BY,
11916/// UNION peers) so any depth of outer reference inside the
11917/// LATERAL subquery resolves before execution.
11918fn substitute_outer_columns_multi(
11919    stmt: &mut SelectStatement,
11920    outer_row: &Row,
11921    outer_schema: &[ColumnSchema],
11922) {
11923    substitute_outer_in_select(stmt, outer_row, outer_schema);
11924}
11925
11926fn substitute_outer_in_select(
11927    stmt: &mut SelectStatement,
11928    outer_row: &Row,
11929    outer_schema: &[ColumnSchema],
11930) {
11931    for item in &mut stmt.items {
11932        if let SelectItem::Expr { expr, .. } = item {
11933            substitute_outer_in_expr(expr, outer_row, outer_schema);
11934        }
11935    }
11936    if let Some(w) = &mut stmt.where_ {
11937        substitute_outer_in_expr(w, outer_row, outer_schema);
11938    }
11939    if let Some(gs) = &mut stmt.group_by {
11940        for g in gs {
11941            substitute_outer_in_expr(g, outer_row, outer_schema);
11942        }
11943    }
11944    if let Some(h) = &mut stmt.having {
11945        substitute_outer_in_expr(h, outer_row, outer_schema);
11946    }
11947    for o in &mut stmt.order_by {
11948        substitute_outer_in_expr(&mut o.expr, outer_row, outer_schema);
11949    }
11950    for (_, peer) in &mut stmt.unions {
11951        substitute_outer_in_select(peer, outer_row, outer_schema);
11952    }
11953}
11954
11955fn substitute_outer_in_expr(e: &mut Expr, outer_row: &Row, outer_schema: &[ColumnSchema]) {
11956    if let Expr::Column(c) = e
11957        && let Some(qual) = &c.qualifier
11958    {
11959        let composite = alloc::format!("{qual}.{}", c.name);
11960        if let Some(idx) = outer_schema
11961            .iter()
11962            .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
11963        {
11964            let v = outer_row.values.get(idx).cloned().unwrap_or(Value::Null);
11965            if let Ok(lit) = value_to_literal_expr(v) {
11966                *e = lit;
11967                return;
11968            }
11969        }
11970    }
11971    match e {
11972        Expr::Binary { lhs, rhs, .. } => {
11973            substitute_outer_in_expr(lhs, outer_row, outer_schema);
11974            substitute_outer_in_expr(rhs, outer_row, outer_schema);
11975        }
11976        Expr::Unary { expr: inner, .. } => {
11977            substitute_outer_in_expr(inner, outer_row, outer_schema);
11978        }
11979        Expr::FunctionCall { args, .. } => {
11980            for a in args {
11981                substitute_outer_in_expr(a, outer_row, outer_schema);
11982            }
11983        }
11984        Expr::Cast { expr: inner, .. } => {
11985            substitute_outer_in_expr(inner, outer_row, outer_schema);
11986        }
11987        Expr::Case {
11988            operand,
11989            branches,
11990            else_branch,
11991        } => {
11992            if let Some(op) = operand {
11993                substitute_outer_in_expr(op, outer_row, outer_schema);
11994            }
11995            for (cond, val) in branches {
11996                substitute_outer_in_expr(cond, outer_row, outer_schema);
11997                substitute_outer_in_expr(val, outer_row, outer_schema);
11998            }
11999            if let Some(e) = else_branch {
12000                substitute_outer_in_expr(e, outer_row, outer_schema);
12001            }
12002        }
12003        _ => {}
12004    }
12005}
12006
12007fn substitute_outer_columns(stmt: &mut SelectStatement, row: &Row, ctx: &EvalContext<'_>) {
12008    let Some(outer_alias) = ctx.table_alias else {
12009        return;
12010    };
12011    substitute_in_select(stmt, row, ctx, outer_alias);
12012}
12013
12014fn substitute_in_select(
12015    stmt: &mut SelectStatement,
12016    row: &Row,
12017    ctx: &EvalContext<'_>,
12018    outer_alias: &str,
12019) {
12020    for item in &mut stmt.items {
12021        if let SelectItem::Expr { expr, .. } = item {
12022            substitute_in_expr(expr, row, ctx, outer_alias);
12023        }
12024    }
12025    if let Some(w) = &mut stmt.where_ {
12026        substitute_in_expr(w, row, ctx, outer_alias);
12027    }
12028    if let Some(gs) = &mut stmt.group_by {
12029        for g in gs {
12030            substitute_in_expr(g, row, ctx, outer_alias);
12031        }
12032    }
12033    if let Some(h) = &mut stmt.having {
12034        substitute_in_expr(h, row, ctx, outer_alias);
12035    }
12036    for o in &mut stmt.order_by {
12037        substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
12038    }
12039    for (_, peer) in &mut stmt.unions {
12040        substitute_in_select(peer, row, ctx, outer_alias);
12041    }
12042}
12043
12044fn substitute_in_expr(e: &mut Expr, row: &Row, ctx: &EvalContext<'_>, outer_alias: &str) {
12045    if let Expr::Column(c) = e
12046        && let Some(qual) = &c.qualifier
12047        && qual.eq_ignore_ascii_case(outer_alias)
12048    {
12049        // Look up the column's index in the outer schema.
12050        if let Some(idx) = ctx
12051            .columns
12052            .iter()
12053            .position(|sc| sc.name.eq_ignore_ascii_case(&c.name))
12054        {
12055            let v = row.values.get(idx).cloned().unwrap_or(Value::Null);
12056            if let Ok(lit) = value_to_literal_expr(v) {
12057                *e = lit;
12058                return;
12059            }
12060        }
12061    }
12062    match e {
12063        Expr::Binary { lhs, rhs, .. } => {
12064            substitute_in_expr(lhs, row, ctx, outer_alias);
12065            substitute_in_expr(rhs, row, ctx, outer_alias);
12066        }
12067        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12068            substitute_in_expr(expr, row, ctx, outer_alias);
12069        }
12070        Expr::Like { expr, pattern, .. } => {
12071            substitute_in_expr(expr, row, ctx, outer_alias);
12072            substitute_in_expr(pattern, row, ctx, outer_alias);
12073        }
12074        Expr::FunctionCall { args, .. } => {
12075            for a in args {
12076                substitute_in_expr(a, row, ctx, outer_alias);
12077            }
12078        }
12079        Expr::Extract { source, .. } => substitute_in_expr(source, row, ctx, outer_alias),
12080        Expr::WindowFunction {
12081            args,
12082            partition_by,
12083            order_by,
12084            ..
12085        } => {
12086            for a in args {
12087                substitute_in_expr(a, row, ctx, outer_alias);
12088            }
12089            for p in partition_by {
12090                substitute_in_expr(p, row, ctx, outer_alias);
12091            }
12092            for (o, _) in order_by {
12093                substitute_in_expr(o, row, ctx, outer_alias);
12094            }
12095        }
12096        Expr::ScalarSubquery(s) => substitute_in_select(s, row, ctx, outer_alias),
12097        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
12098            substitute_in_select(subquery, row, ctx, outer_alias);
12099        }
12100        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
12101        Expr::Array(items) => {
12102            for elem in items {
12103                substitute_in_expr(elem, row, ctx, outer_alias);
12104            }
12105        }
12106        Expr::ArraySubscript { target, index } => {
12107            substitute_in_expr(target, row, ctx, outer_alias);
12108            substitute_in_expr(index, row, ctx, outer_alias);
12109        }
12110        Expr::AnyAll { expr, array, .. } => {
12111            substitute_in_expr(expr, row, ctx, outer_alias);
12112            substitute_in_expr(array, row, ctx, outer_alias);
12113        }
12114        Expr::Case {
12115            operand,
12116            branches,
12117            else_branch,
12118        } => {
12119            if let Some(o) = operand {
12120                substitute_in_expr(o, row, ctx, outer_alias);
12121            }
12122            for (w, t) in branches {
12123                substitute_in_expr(w, row, ctx, outer_alias);
12124                substitute_in_expr(t, row, ctx, outer_alias);
12125            }
12126            if let Some(e) = else_branch {
12127                substitute_in_expr(e, row, ctx, outer_alias);
12128            }
12129        }
12130    }
12131}
12132
12133/// v4.22: encode a Row to a comparable byte key for UNION-DISTINCT
12134/// dedup inside the recursive iteration. Crude but deterministic
12135/// — Debug prints embed type discriminants so NULL ≠ "" ≠ 0.
12136fn encode_row_key(row: &Row) -> Vec<u8> {
12137    let mut out = Vec::new();
12138    for v in &row.values {
12139        let s = alloc::format!("{v:?}|");
12140        out.extend_from_slice(s.as_bytes());
12141    }
12142    out
12143}
12144
12145fn select_has_window(stmt: &SelectStatement) -> bool {
12146    for item in &stmt.items {
12147        if let SelectItem::Expr { expr, .. } = item
12148            && expr_has_window(expr)
12149        {
12150            return true;
12151        }
12152    }
12153    false
12154}
12155
12156fn expr_has_window(e: &Expr) -> bool {
12157    match e {
12158        Expr::WindowFunction { .. } => true,
12159        Expr::Binary { lhs, rhs, .. } => expr_has_window(lhs) || expr_has_window(rhs),
12160        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12161            expr_has_window(expr)
12162        }
12163        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_window),
12164        Expr::Like { expr, pattern, .. } => expr_has_window(expr) || expr_has_window(pattern),
12165        Expr::Extract { source, .. } => expr_has_window(source),
12166        Expr::ScalarSubquery(_)
12167        | Expr::Exists { .. }
12168        | Expr::InSubquery { .. }
12169        | Expr::Literal(_)
12170        | Expr::Placeholder(_)
12171        | Expr::Column(_) => false,
12172        Expr::Array(items) => items.iter().any(expr_has_window),
12173        Expr::ArraySubscript { target, index } => expr_has_window(target) || expr_has_window(index),
12174        Expr::AnyAll { expr, array, .. } => expr_has_window(expr) || expr_has_window(array),
12175        Expr::Case {
12176            operand,
12177            branches,
12178            else_branch,
12179        } => {
12180            operand.as_deref().is_some_and(expr_has_window)
12181                || branches
12182                    .iter()
12183                    .any(|(w, t)| expr_has_window(w) || expr_has_window(t))
12184                || else_branch.as_deref().is_some_and(expr_has_window)
12185        }
12186    }
12187}
12188
12189fn collect_window_nodes(e: &Expr, out: &mut Vec<Expr>) {
12190    if let Expr::WindowFunction { .. } = e {
12191        // Deduplicate by structural equality on the expression
12192        // (cheap because window args + partition + order are
12193        // small). Without dedup we'd recompute identical windows
12194        // once per occurrence in the projection.
12195        if !out.iter().any(|x| x == e) {
12196            out.push(e.clone());
12197        }
12198        return;
12199    }
12200    match e {
12201        // Already handled by the early-return at the top.
12202        Expr::WindowFunction { .. } => unreachable!(),
12203        Expr::Binary { lhs, rhs, .. } => {
12204            collect_window_nodes(lhs, out);
12205            collect_window_nodes(rhs, out);
12206        }
12207        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12208            collect_window_nodes(expr, out);
12209        }
12210        Expr::FunctionCall { args, .. } => {
12211            for a in args {
12212                collect_window_nodes(a, out);
12213            }
12214        }
12215        Expr::Like { expr, pattern, .. } => {
12216            collect_window_nodes(expr, out);
12217            collect_window_nodes(pattern, out);
12218        }
12219        Expr::Extract { source, .. } => collect_window_nodes(source, out),
12220        _ => {}
12221    }
12222}
12223
12224fn rewrite_window_to_columns(e: &mut Expr, window_nodes: &[Expr]) {
12225    if let Expr::WindowFunction { .. } = e
12226        && let Some(idx) = window_nodes.iter().position(|w| w == e)
12227    {
12228        *e = Expr::Column(spg_sql::ast::ColumnName {
12229            qualifier: None,
12230            name: alloc::format!("__win_{idx}"),
12231        });
12232        return;
12233    }
12234    match e {
12235        Expr::Binary { lhs, rhs, .. } => {
12236            rewrite_window_to_columns(lhs, window_nodes);
12237            rewrite_window_to_columns(rhs, window_nodes);
12238        }
12239        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12240            rewrite_window_to_columns(expr, window_nodes);
12241        }
12242        Expr::FunctionCall { args, .. } => {
12243            for a in args {
12244                rewrite_window_to_columns(a, window_nodes);
12245            }
12246        }
12247        Expr::Like { expr, pattern, .. } => {
12248            rewrite_window_to_columns(expr, window_nodes);
12249            rewrite_window_to_columns(pattern, window_nodes);
12250        }
12251        Expr::Extract { source, .. } => rewrite_window_to_columns(source, window_nodes),
12252        _ => {}
12253    }
12254}
12255
12256/// Total order over partition-key tuples. NULL sorts as the
12257/// lowest value (matches the `<` partial order's NULL-last
12258/// behaviour with `INFINITY` flipped).
12259fn partition_key_cmp(a: &[Value], b: &[Value]) -> core::cmp::Ordering {
12260    for (x, y) in a.iter().zip(b.iter()) {
12261        let c = value_cmp(x, y);
12262        if c != core::cmp::Ordering::Equal {
12263            return c;
12264        }
12265    }
12266    a.len().cmp(&b.len())
12267}
12268
12269fn order_key_cmp(a: &[(Value, bool)], b: &[(Value, bool)]) -> core::cmp::Ordering {
12270    for ((va, desc), (vb, _)) in a.iter().zip(b.iter()) {
12271        let c = value_cmp(va, vb);
12272        let c = if *desc { c.reverse() } else { c };
12273        if c != core::cmp::Ordering::Equal {
12274            return c;
12275        }
12276    }
12277    a.len().cmp(&b.len())
12278}
12279
12280/// v7.17.0 Phase 3.10 — true when the Value is one of the
12281/// integer-shaped variants `generate_series` accepts as a start
12282/// / stop / step component. Float / NUMERIC are rejected — PG's
12283/// `generate_series(numeric, numeric)` overload is out of v7.17
12284/// scope.
12285const fn value_is_integer(v: &Value) -> bool {
12286    matches!(v, Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_))
12287}
12288
12289/// v7.17.0 Phase 3.10 — widen any integer-shaped Value to i64 for
12290/// the generate_series iteration loop. Non-integer inputs panic;
12291/// caller guards via `value_is_integer`.
12292const fn value_to_i64(v: &Value) -> i64 {
12293    match v {
12294        Value::SmallInt(n) => *n as i64,
12295        Value::Int(n) => *n as i64,
12296        Value::BigInt(n) => *n,
12297        _ => panic!("value_to_i64 called on non-integer Value"),
12298    }
12299}
12300
12301/// v7.17.0 Phase 3.10 — integer-mode generate_series materialiser.
12302/// Step direction follows the sign: positive step iterates upward
12303/// (stops when current > stop); negative iterates downward; zero
12304/// errors. Caller-facing row stream is `BigInt`-typed so a single
12305/// projection schema covers SmallInt / Int / BigInt callers.
12306fn generate_series_integers(
12307    start: i64,
12308    stop: i64,
12309    step: i64,
12310    cancel: &CancelToken<'_>,
12311) -> Result<alloc::vec::Vec<Row>, EngineError> {
12312    if step == 0 {
12313        return Err(EngineError::Unsupported(
12314            "generate_series(): step argument cannot be zero".into(),
12315        ));
12316    }
12317    let mut out = alloc::vec::Vec::new();
12318    let mut cur = start;
12319    // Hard cap to keep a runaway call from eating all memory. PG
12320    // has no such cap but does honour query timeout; SPG's cancel
12321    // token will fire too — this is a defense-in-depth backstop.
12322    const MAX_ROWS: usize = 10_000_000;
12323    loop {
12324        cancel.check()?;
12325        if step > 0 && cur > stop {
12326            break;
12327        }
12328        if step < 0 && cur < stop {
12329            break;
12330        }
12331        out.push(Row::new(alloc::vec![Value::BigInt(cur)]));
12332        if out.len() > MAX_ROWS {
12333            return Err(EngineError::Unsupported(alloc::format!(
12334                "generate_series(): exceeded {MAX_ROWS} rows; \
12335                 narrow start/stop or use a larger step"
12336            )));
12337        }
12338        cur = match cur.checked_add(step) {
12339            Some(n) => n,
12340            None => break,
12341        };
12342    }
12343    Ok(out)
12344}
12345
12346/// v7.17.0 Phase 3.10 — timestamp-mode generate_series. step is a
12347/// `Value::Interval { months, micros }` per the caller's guard;
12348/// each iteration adds the interval via `apply_binary_interval`
12349/// so month-shifting handles short-month rollover (PG semantics).
12350fn generate_series_timestamps(
12351    start: i64,
12352    stop: i64,
12353    step: Value,
12354    cancel: &CancelToken<'_>,
12355) -> Result<alloc::vec::Vec<Row>, EngineError> {
12356    let (months, micros) = match &step {
12357        Value::Interval { months, micros } => (*months, *micros),
12358        _ => unreachable!("caller guards step.is_interval"),
12359    };
12360    if months == 0 && micros == 0 {
12361        return Err(EngineError::Unsupported(
12362            "generate_series(): INTERVAL step cannot be zero".into(),
12363        ));
12364    }
12365    let ascending = months > 0 || micros > 0;
12366    let mut out = alloc::vec::Vec::new();
12367    let mut cur = Value::Timestamp(start);
12368    const MAX_ROWS: usize = 10_000_000;
12369    loop {
12370        cancel.check()?;
12371        let cur_t = match cur {
12372            Value::Timestamp(t) => t,
12373            _ => unreachable!("loop invariant: cur is Timestamp"),
12374        };
12375        if ascending && cur_t > stop {
12376            break;
12377        }
12378        if !ascending && cur_t < stop {
12379            break;
12380        }
12381        out.push(Row::new(alloc::vec![Value::Timestamp(cur_t)]));
12382        if out.len() > MAX_ROWS {
12383            return Err(EngineError::Unsupported(alloc::format!(
12384                "generate_series(): exceeded {MAX_ROWS} rows; \
12385                 narrow start/stop or use a larger step"
12386            )));
12387        }
12388        let next = eval::apply_binary_interval(
12389            spg_sql::ast::BinOp::Add,
12390            &cur,
12391            &Value::Interval { months, micros },
12392        )
12393        .map_err(EngineError::Eval)?;
12394        cur = match next {
12395            Some(v) => v,
12396            None => break,
12397        };
12398    }
12399    Ok(out)
12400}
12401
12402#[allow(clippy::match_same_arms)] // explicit arms per type document the supported pairs
12403fn value_cmp(a: &Value, b: &Value) -> core::cmp::Ordering {
12404    use core::cmp::Ordering;
12405    match (a, b) {
12406        (Value::Null, Value::Null) => Ordering::Equal,
12407        (Value::Null, _) => Ordering::Less,
12408        (_, Value::Null) => Ordering::Greater,
12409        (Value::Int(x), Value::Int(y)) => x.cmp(y),
12410        (Value::BigInt(x), Value::BigInt(y)) => x.cmp(y),
12411        (Value::SmallInt(x), Value::SmallInt(y)) => x.cmp(y),
12412        (Value::Text(x), Value::Text(y)) => x.cmp(y),
12413        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
12414        (Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
12415        (Value::Date(x), Value::Date(y)) => x.cmp(y),
12416        (Value::Timestamp(x), Value::Timestamp(y)) => x.cmp(y),
12417        // Cross-type compare: fall back to the debug rendering —
12418        // same-partition is the goal, exact order is irrelevant.
12419        _ => alloc::format!("{a:?}").cmp(&alloc::format!("{b:?}")),
12420    }
12421}
12422
12423/// Compute the window function's per-row output for one partition.
12424/// `slice` has (partition key, order key, original-row-index)
12425/// tuples already sorted by order key. `filtered_rows` is the
12426/// full row list indexed by original-row-index. `out_vals` is
12427/// the destination, also indexed by original-row-index.
12428#[allow(
12429    clippy::too_many_arguments,
12430    clippy::cast_possible_truncation,
12431    clippy::cast_possible_wrap,
12432    clippy::cast_precision_loss,
12433    clippy::cast_sign_loss,
12434    clippy::doc_markdown,
12435    clippy::too_many_lines,
12436    clippy::type_complexity,
12437    clippy::match_same_arms
12438)]
12439fn compute_window_partition(
12440    name: &str,
12441    args: &[Expr],
12442    ordered: bool,
12443    frame: Option<&WindowFrame>,
12444    null_treatment: spg_sql::ast::NullTreatment,
12445    slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)],
12446    filtered_rows: &[&Row],
12447    ctx: &EvalContext<'_>,
12448    out_vals: &mut [Value],
12449) -> Result<(), EngineError> {
12450    let ignore_nulls = matches!(null_treatment, spg_sql::ast::NullTreatment::Ignore);
12451    let lower = name.to_ascii_lowercase();
12452    match lower.as_str() {
12453        "row_number" => {
12454            for (rank, (_, _, idx)) in slice.iter().enumerate() {
12455                out_vals[*idx] = Value::BigInt((rank + 1) as i64);
12456            }
12457            Ok(())
12458        }
12459        "rank" => {
12460            let mut prev_key: Option<&[(Value, bool)]> = None;
12461            let mut current_rank: i64 = 1;
12462            for (i, (_, okey, idx)) in slice.iter().enumerate() {
12463                if let Some(p) = prev_key
12464                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
12465                {
12466                    current_rank = (i + 1) as i64;
12467                }
12468                if prev_key.is_none() {
12469                    current_rank = 1;
12470                }
12471                out_vals[*idx] = Value::BigInt(current_rank);
12472                prev_key = Some(okey.as_slice());
12473            }
12474            Ok(())
12475        }
12476        "dense_rank" => {
12477            let mut prev_key: Option<&[(Value, bool)]> = None;
12478            let mut current_rank: i64 = 0;
12479            for (_, okey, idx) in slice {
12480                if prev_key.is_none_or(|p| order_key_cmp(p, okey) != core::cmp::Ordering::Equal) {
12481                    current_rank += 1;
12482                }
12483                out_vals[*idx] = Value::BigInt(current_rank);
12484                prev_key = Some(okey.as_slice());
12485            }
12486            Ok(())
12487        }
12488        "sum" | "avg" | "min" | "max" | "count" | "count_star" => {
12489            // Pre-evaluate the function arg per row in the slice
12490            // (count_star has no arg).
12491            let arg_values: Vec<Value> = if lower == "count_star" || args.is_empty() {
12492                slice.iter().map(|_| Value::Null).collect()
12493            } else {
12494                slice
12495                    .iter()
12496                    .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12497                    .collect::<Result<_, _>>()
12498                    .map_err(EngineError::Eval)?
12499            };
12500            // v4.20: pick the effective frame. Explicit frame
12501            // overrides the implicit default (running for ordered,
12502            // whole-partition for unordered).
12503            let eff = effective_frame(frame, ordered)?;
12504            #[allow(clippy::needless_range_loop)]
12505            for i in 0..slice.len() {
12506                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
12507                let mut sum: f64 = 0.0;
12508                let mut count: i64 = 0;
12509                let mut min_v: Option<f64> = None;
12510                let mut max_v: Option<f64> = None;
12511                let mut row_count: i64 = 0;
12512                if lo <= hi {
12513                    for j in lo..=hi {
12514                        let v = &arg_values[j];
12515                        match lower.as_str() {
12516                            "count_star" => row_count += 1,
12517                            "count" => {
12518                                if !v.is_null() {
12519                                    count += 1;
12520                                }
12521                            }
12522                            _ => {
12523                                if let Some(x) = value_to_f64(v) {
12524                                    sum += x;
12525                                    count += 1;
12526                                    min_v = Some(min_v.map_or(x, |m| m.min(x)));
12527                                    max_v = Some(max_v.map_or(x, |m| m.max(x)));
12528                                }
12529                            }
12530                        }
12531                    }
12532                }
12533                let value = match lower.as_str() {
12534                    "count_star" => Value::BigInt(row_count),
12535                    "count" => Value::BigInt(count),
12536                    "sum" => Value::Float(sum),
12537                    "avg" => {
12538                        if count == 0 {
12539                            Value::Null
12540                        } else {
12541                            Value::Float(sum / count as f64)
12542                        }
12543                    }
12544                    "min" => min_v.map_or(Value::Null, Value::Float),
12545                    "max" => max_v.map_or(Value::Null, Value::Float),
12546                    _ => unreachable!(),
12547                };
12548                let (_, _, idx) = &slice[i];
12549                out_vals[*idx] = value;
12550            }
12551            Ok(())
12552        }
12553        "lag" | "lead" => {
12554            // lag(expr [, offset [, default]])
12555            // lead(expr [, offset [, default]])
12556            if args.is_empty() {
12557                return Err(EngineError::Unsupported(alloc::format!(
12558                    "{lower}() requires at least one argument"
12559                )));
12560            }
12561            let offset: i64 = if args.len() >= 2 {
12562                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
12563                    .map_err(EngineError::Eval)?;
12564                match v {
12565                    Value::SmallInt(n) => i64::from(n),
12566                    Value::Int(n) => i64::from(n),
12567                    Value::BigInt(n) => n,
12568                    _ => {
12569                        return Err(EngineError::Unsupported(alloc::format!(
12570                            "{lower}() offset must be integer"
12571                        )));
12572                    }
12573                }
12574            } else {
12575                1
12576            };
12577            let default: Value = if args.len() >= 3 {
12578                eval::eval_expr(&args[2], filtered_rows[slice[0].2], ctx)
12579                    .map_err(EngineError::Eval)?
12580            } else {
12581                Value::Null
12582            };
12583            let values: Vec<Value> = slice
12584                .iter()
12585                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12586                .collect::<Result<_, _>>()
12587                .map_err(EngineError::Eval)?;
12588            let n = slice.len();
12589            for (i, (_, _, idx)) in slice.iter().enumerate() {
12590                let signed_offset = if lower == "lag" { -offset } else { offset };
12591                let v = if ignore_nulls {
12592                    // v6.4.2 — IGNORE NULLS: walk in the offset direction
12593                    // skipping NULL values; the `offset`-th non-NULL
12594                    // encountered is the result.
12595                    let step: i64 = if signed_offset >= 0 { 1 } else { -1 };
12596                    let needed: i64 = signed_offset.abs();
12597                    if needed == 0 {
12598                        values[i].clone()
12599                    } else {
12600                        let mut j: i64 = i as i64;
12601                        let mut hits: i64 = 0;
12602                        let mut found: Option<Value> = None;
12603                        loop {
12604                            j += step;
12605                            if j < 0 || j >= n as i64 {
12606                                break;
12607                            }
12608                            #[allow(clippy::cast_sign_loss)]
12609                            let v = &values[j as usize];
12610                            if !v.is_null() {
12611                                hits += 1;
12612                                if hits == needed {
12613                                    found = Some(v.clone());
12614                                    break;
12615                                }
12616                            }
12617                        }
12618                        found.unwrap_or_else(|| default.clone())
12619                    }
12620                } else {
12621                    let target_signed = i64::try_from(i).unwrap_or(i64::MAX) + signed_offset;
12622                    if target_signed < 0 || target_signed >= i64::try_from(n).unwrap_or(i64::MAX) {
12623                        default.clone()
12624                    } else {
12625                        #[allow(clippy::cast_sign_loss)]
12626                        {
12627                            values[target_signed as usize].clone()
12628                        }
12629                    }
12630                };
12631                out_vals[*idx] = v;
12632            }
12633            Ok(())
12634        }
12635        "first_value" | "last_value" | "nth_value" => {
12636            if args.is_empty() {
12637                return Err(EngineError::Unsupported(alloc::format!(
12638                    "{lower}() requires at least one argument"
12639                )));
12640            }
12641            let values: Vec<Value> = slice
12642                .iter()
12643                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12644                .collect::<Result<_, _>>()
12645                .map_err(EngineError::Eval)?;
12646            let nth: usize = if lower == "nth_value" {
12647                if args.len() < 2 {
12648                    return Err(EngineError::Unsupported(
12649                        "nth_value() requires (expr, n)".into(),
12650                    ));
12651                }
12652                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
12653                    .map_err(EngineError::Eval)?;
12654                let raw = match v {
12655                    Value::SmallInt(n) => i64::from(n),
12656                    Value::Int(n) => i64::from(n),
12657                    Value::BigInt(n) => n,
12658                    _ => {
12659                        return Err(EngineError::Unsupported(
12660                            "nth_value() n must be integer".into(),
12661                        ));
12662                    }
12663                };
12664                if raw < 1 {
12665                    return Err(EngineError::Unsupported(
12666                        "nth_value() n must be >= 1".into(),
12667                    ));
12668                }
12669                #[allow(clippy::cast_sign_loss)]
12670                {
12671                    raw as usize
12672                }
12673            } else {
12674                0
12675            };
12676            let eff = effective_frame(frame, ordered)?;
12677            for i in 0..slice.len() {
12678                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
12679                let (_, _, idx) = &slice[i];
12680                let v = if lo > hi {
12681                    Value::Null
12682                } else if ignore_nulls && matches!(lower.as_str(), "first_value" | "last_value") {
12683                    // v6.4.2 — IGNORE NULLS: skip NULL cells when
12684                    // selecting the boundary value within the frame.
12685                    if lower == "first_value" {
12686                        (lo..=hi)
12687                            .find_map(|j| {
12688                                let v = &values[j];
12689                                (!v.is_null()).then(|| v.clone())
12690                            })
12691                            .unwrap_or(Value::Null)
12692                    } else {
12693                        (lo..=hi)
12694                            .rev()
12695                            .find_map(|j| {
12696                                let v = &values[j];
12697                                (!v.is_null()).then(|| v.clone())
12698                            })
12699                            .unwrap_or(Value::Null)
12700                    }
12701                } else {
12702                    match lower.as_str() {
12703                        "first_value" => values[lo].clone(),
12704                        "last_value" => values[hi].clone(),
12705                        "nth_value" => {
12706                            let pos = lo + nth - 1;
12707                            if pos > hi {
12708                                Value::Null
12709                            } else {
12710                                values[pos].clone()
12711                            }
12712                        }
12713                        _ => unreachable!(),
12714                    }
12715                };
12716                out_vals[*idx] = v;
12717            }
12718            Ok(())
12719        }
12720        "ntile" => {
12721            if args.is_empty() {
12722                return Err(EngineError::Unsupported(
12723                    "ntile(n) requires an integer argument".into(),
12724                ));
12725            }
12726            let v = eval::eval_expr(&args[0], filtered_rows[slice[0].2], ctx)
12727                .map_err(EngineError::Eval)?;
12728            let bucket_count: i64 = match v {
12729                Value::SmallInt(n) => i64::from(n),
12730                Value::Int(n) => i64::from(n),
12731                Value::BigInt(n) => n,
12732                _ => {
12733                    return Err(EngineError::Unsupported(
12734                        "ntile() argument must be integer".into(),
12735                    ));
12736                }
12737            };
12738            if bucket_count < 1 {
12739                return Err(EngineError::Unsupported(
12740                    "ntile() argument must be >= 1".into(),
12741                ));
12742            }
12743            #[allow(clippy::cast_sign_loss)]
12744            let buckets = bucket_count as usize;
12745            let n = slice.len();
12746            // Each bucket gets `base` rows; the first `extras` buckets
12747            // get one extra. PG semantics.
12748            let base = n / buckets;
12749            let extras = n % buckets;
12750            let mut bucket: usize = 1;
12751            let mut remaining_in_bucket = if extras > 0 { base + 1 } else { base };
12752            let mut buckets_with_extra_remaining = extras;
12753            for (_, _, idx) in slice {
12754                if remaining_in_bucket == 0 {
12755                    bucket += 1;
12756                    buckets_with_extra_remaining = buckets_with_extra_remaining.saturating_sub(1);
12757                    remaining_in_bucket = if buckets_with_extra_remaining > 0 {
12758                        base + 1
12759                    } else {
12760                        base
12761                    };
12762                    // Edge: if base==0 and extras==0, all rows fit;
12763                    // shouldn't reach here, but guard anyway.
12764                    if remaining_in_bucket == 0 {
12765                        remaining_in_bucket = 1;
12766                    }
12767                }
12768                out_vals[*idx] = Value::BigInt(i64::try_from(bucket).unwrap_or(i64::MAX));
12769                remaining_in_bucket -= 1;
12770            }
12771            Ok(())
12772        }
12773        "percent_rank" => {
12774            // (rank - 1) / (n - 1) where rank is the standard RANK().
12775            // Single-row partitions get 0.
12776            let n = slice.len();
12777            let mut prev_key: Option<&[(Value, bool)]> = None;
12778            let mut current_rank: i64 = 1;
12779            for (i, (_, okey, idx)) in slice.iter().enumerate() {
12780                if let Some(p) = prev_key
12781                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
12782                {
12783                    current_rank = i64::try_from(i + 1).unwrap_or(i64::MAX);
12784                }
12785                if prev_key.is_none() {
12786                    current_rank = 1;
12787                }
12788                #[allow(clippy::cast_precision_loss)]
12789                let pr = if n <= 1 {
12790                    0.0
12791                } else {
12792                    (current_rank - 1) as f64 / (n - 1) as f64
12793                };
12794                out_vals[*idx] = Value::Float(pr);
12795                prev_key = Some(okey.as_slice());
12796            }
12797            Ok(())
12798        }
12799        "cume_dist" => {
12800            // # rows up to and including this row's peer group / n.
12801            let n = slice.len();
12802            // First pass: find peer-group-end rank for each row.
12803            for i in 0..slice.len() {
12804                let peer_end = peer_group_end(slice, i);
12805                #[allow(clippy::cast_precision_loss)]
12806                let cd = (peer_end + 1) as f64 / n as f64;
12807                let (_, _, idx) = &slice[i];
12808                out_vals[*idx] = Value::Float(cd);
12809            }
12810            Ok(())
12811        }
12812        other => Err(EngineError::Unsupported(alloc::format!(
12813            "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)"
12814        ))),
12815    }
12816}
12817
12818/// v4.20: resolve the user-provided frame down to a normalised
12819/// `(kind, start, end)`. `None` means default — derive from
12820/// `ordered`: ordered ⇒ RANGE UNBOUNDED PRECEDING AND CURRENT ROW,
12821/// unordered ⇒ ROWS UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
12822/// Single-bound shorthand (e.g. `ROWS 5 PRECEDING`) normalises
12823/// end → CURRENT ROW per the PG spec.
12824fn effective_frame(
12825    frame: Option<&WindowFrame>,
12826    ordered: bool,
12827) -> Result<(FrameKind, FrameBound, FrameBound), EngineError> {
12828    match frame {
12829        None => {
12830            if ordered {
12831                Ok((
12832                    FrameKind::Range,
12833                    FrameBound::UnboundedPreceding,
12834                    FrameBound::CurrentRow,
12835                ))
12836            } else {
12837                Ok((
12838                    FrameKind::Rows,
12839                    FrameBound::UnboundedPreceding,
12840                    FrameBound::UnboundedFollowing,
12841                ))
12842            }
12843        }
12844        Some(fr) => {
12845            let end = fr.end.clone().unwrap_or(FrameBound::CurrentRow);
12846            // Reject start > end (a few impossible combinations).
12847            if matches!(fr.start, FrameBound::UnboundedFollowing)
12848                || matches!(end, FrameBound::UnboundedPreceding)
12849            {
12850                return Err(EngineError::Unsupported(alloc::format!(
12851                    "invalid frame: start={:?} end={:?}",
12852                    fr.start,
12853                    end
12854                )));
12855            }
12856            // RANGE OFFSET PRECEDING / FOLLOWING needs value-typed
12857            // arithmetic on the ORDER BY key (e.g. `RANGE BETWEEN
12858            // INTERVAL '1 day' PRECEDING AND CURRENT ROW`). Not
12859            // implemented in v4.20.
12860            if fr.kind == FrameKind::Range
12861                && (matches!(
12862                    fr.start,
12863                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
12864                ) || matches!(
12865                    end,
12866                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
12867                ))
12868            {
12869                return Err(EngineError::Unsupported(
12870                    "RANGE with explicit offset bounds is not supported (v4.20: only UNBOUNDED / CURRENT ROW for RANGE)".into(),
12871                ));
12872            }
12873            Ok((fr.kind, fr.start.clone(), end))
12874        }
12875    }
12876}
12877
12878/// Compute `(lo, hi)` row-index bounds inside the partition slice
12879/// for the row at position `i`. Inclusive, clamped to
12880/// `[0, slice.len()-1]`. Empty result if `lo > hi`.
12881#[allow(clippy::type_complexity)]
12882fn frame_bounds_for_row(
12883    eff: &(FrameKind, FrameBound, FrameBound),
12884    i: usize,
12885    slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)],
12886) -> (usize, usize) {
12887    let (kind, start, end) = eff;
12888    let n = slice.len();
12889    let last = n.saturating_sub(1);
12890    let (mut lo, mut hi) = match kind {
12891        FrameKind::Rows => {
12892            let lo = match start {
12893                FrameBound::UnboundedPreceding => 0,
12894                FrameBound::OffsetPreceding(k) => {
12895                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
12896                    i.saturating_sub(k)
12897                }
12898                FrameBound::CurrentRow => i,
12899                FrameBound::OffsetFollowing(k) => {
12900                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
12901                    i.saturating_add(k).min(last)
12902                }
12903                FrameBound::UnboundedFollowing => last,
12904            };
12905            let hi = match end {
12906                FrameBound::UnboundedPreceding => 0,
12907                FrameBound::OffsetPreceding(k) => {
12908                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
12909                    i.saturating_sub(k)
12910                }
12911                FrameBound::CurrentRow => i,
12912                FrameBound::OffsetFollowing(k) => {
12913                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
12914                    i.saturating_add(k).min(last)
12915                }
12916                FrameBound::UnboundedFollowing => last,
12917            };
12918            (lo, hi)
12919        }
12920        FrameKind::Range => {
12921            // RANGE bounds are peer-aware. With only UNBOUNDED and
12922            // CURRENT ROW supported (rejected at effective_frame for
12923            // explicit offsets), the start/end map to the
12924            // partition's full extent at the same-order-key peer
12925            // group boundary.
12926            let lo = match start {
12927                FrameBound::UnboundedPreceding => 0,
12928                FrameBound::CurrentRow => peer_group_start(slice, i),
12929                FrameBound::UnboundedFollowing => last,
12930                _ => unreachable!("offset bounds rejected for RANGE"),
12931            };
12932            let hi = match end {
12933                FrameBound::UnboundedPreceding => 0,
12934                FrameBound::CurrentRow => peer_group_end(slice, i),
12935                FrameBound::UnboundedFollowing => last,
12936                _ => unreachable!("offset bounds rejected for RANGE"),
12937            };
12938            (lo, hi)
12939        }
12940    };
12941    if hi >= n {
12942        hi = last;
12943    }
12944    if lo >= n {
12945        lo = last;
12946    }
12947    (lo, hi)
12948}
12949
12950/// Find the inclusive index of the first row with the same ORDER
12951/// BY key as `slice[i]`. Slice is already sorted by partition then
12952/// order, so peers are contiguous.
12953#[allow(clippy::type_complexity)]
12954fn peer_group_start(slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)], i: usize) -> usize {
12955    let key = &slice[i].1;
12956    let mut j = i;
12957    while j > 0 && order_key_cmp(&slice[j - 1].1, key) == core::cmp::Ordering::Equal {
12958        j -= 1;
12959    }
12960    j
12961}
12962
12963/// Find the inclusive index of the last row with the same ORDER
12964/// BY key as `slice[i]`.
12965#[allow(clippy::type_complexity)]
12966fn peer_group_end(slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)], i: usize) -> usize {
12967    let key = &slice[i].1;
12968    let mut j = i;
12969    while j + 1 < slice.len() && order_key_cmp(&slice[j + 1].1, key) == core::cmp::Ordering::Equal {
12970        j += 1;
12971    }
12972    j
12973}
12974
12975fn value_to_f64(v: &Value) -> Option<f64> {
12976    match v {
12977        Value::SmallInt(n) => Some(f64::from(*n)),
12978        Value::Int(n) => Some(f64::from(*n)),
12979        #[allow(clippy::cast_precision_loss)]
12980        Value::BigInt(n) => Some(*n as f64),
12981        Value::Float(x) => Some(*x),
12982        _ => None,
12983    }
12984}
12985
12986/// Quick scan for any subquery-bearing node in a SELECT's WHERE /
12987/// projection / `order_by` — saves cloning the AST when there are
12988/// none (the common case).
12989fn expr_tree_has_subquery(stmt: &SelectStatement) -> bool {
12990    let mut any = false;
12991    for item in &stmt.items {
12992        if let SelectItem::Expr { expr, .. } = item {
12993            any = any || expr_has_subquery(expr);
12994        }
12995    }
12996    if let Some(w) = &stmt.where_ {
12997        any = any || expr_has_subquery(w);
12998    }
12999    if let Some(h) = &stmt.having {
13000        any = any || expr_has_subquery(h);
13001    }
13002    for o in &stmt.order_by {
13003        any = any || expr_has_subquery(&o.expr);
13004    }
13005    for (_, peer) in &stmt.unions {
13006        any = any || expr_tree_has_subquery(peer);
13007    }
13008    any
13009}
13010
13011fn expr_has_subquery(e: &Expr) -> bool {
13012    match e {
13013        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => true,
13014        Expr::Binary { lhs, rhs, .. } => expr_has_subquery(lhs) || expr_has_subquery(rhs),
13015        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13016            expr_has_subquery(expr)
13017        }
13018        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_subquery),
13019        Expr::Like { expr, pattern, .. } => expr_has_subquery(expr) || expr_has_subquery(pattern),
13020        Expr::Extract { source, .. } => expr_has_subquery(source),
13021        Expr::WindowFunction {
13022            args,
13023            partition_by,
13024            order_by,
13025            ..
13026        } => {
13027            args.iter().any(expr_has_subquery)
13028                || partition_by.iter().any(expr_has_subquery)
13029                || order_by.iter().any(|(e, _)| expr_has_subquery(e))
13030        }
13031        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
13032        Expr::Array(items) => items.iter().any(expr_has_subquery),
13033        Expr::ArraySubscript { target, index } => {
13034            expr_has_subquery(target) || expr_has_subquery(index)
13035        }
13036        Expr::AnyAll { expr, array, .. } => expr_has_subquery(expr) || expr_has_subquery(array),
13037        Expr::Case {
13038            operand,
13039            branches,
13040            else_branch,
13041        } => {
13042            operand.as_deref().is_some_and(expr_has_subquery)
13043                || branches
13044                    .iter()
13045                    .any(|(w, t)| expr_has_subquery(w) || expr_has_subquery(t))
13046                || else_branch.as_deref().is_some_and(expr_has_subquery)
13047        }
13048    }
13049}
13050
13051/// v4.10 helper: materialise a runtime `Value` back into an AST
13052/// `Expr::Literal` for the subquery-rewrite path. Supports the
13053/// types `Literal` can represent (Integer / Float / Text / Bool /
13054/// Null). Date / Timestamp / Numeric / Vector / Interval / JSON
13055/// would lose precision through Literal and aren't supported in
13056/// uncorrelated-subquery results; they error with a clear hint.
13057fn value_to_literal_expr(v: Value) -> Result<Expr, EngineError> {
13058    let lit = match v {
13059        Value::Null => Literal::Null,
13060        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13061        Value::Int(n) => Literal::Integer(i64::from(n)),
13062        Value::BigInt(n) => Literal::Integer(n),
13063        Value::Float(x) => Literal::Float(x),
13064        Value::Text(s) | Value::Json(s) => Literal::String(s),
13065        Value::Bool(b) => Literal::Bool(b),
13066        other => {
13067            return Err(EngineError::Unsupported(alloc::format!(
13068                "subquery result type {:?} not yet materialisable; cast to text or integer in the inner SELECT",
13069                other.data_type()
13070            )));
13071        }
13072    };
13073    Ok(Expr::Literal(lit))
13074}
13075
13076/// v7.13.0 — wider helper used by `INSERT … SELECT` (mailrs
13077/// round-5 G4). Covers the most common `Value` variants. Types
13078/// that need lossy textual round-trip (BYTEA, arrays, ts*)
13079/// surface as an Unsupported error so the caller can add a cast
13080/// in the inner SELECT.
13081fn value_to_literal_expr_permissive(v: Value) -> Result<Expr, EngineError> {
13082    let lit = match v {
13083        Value::Null => Literal::Null,
13084        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13085        Value::Int(n) => Literal::Integer(i64::from(n)),
13086        Value::BigInt(n) => Literal::Integer(n),
13087        Value::Float(x) => Literal::Float(x),
13088        Value::Text(s) | Value::Json(s) => Literal::String(s),
13089        Value::Bool(b) => Literal::Bool(b),
13090        Value::Vector(xs) => Literal::Vector(xs),
13091        // Date / Timestamp / Timestamptz / Numeric round-trip
13092        // through a TEXT literal that `coerce_value` re-parses
13093        // against the target column type.
13094        Value::Date(days) => {
13095            let micros = (i64::from(days)) * 86_400_000_000;
13096            Literal::String(format_timestamp_micros_as_date(micros))
13097        }
13098        Value::Timestamp(us) => Literal::String(format_timestamp_micros(us)),
13099        Value::Numeric { scaled, scale } => Literal::String(format_numeric(scaled, scale)),
13100        other => {
13101            return Err(EngineError::Unsupported(alloc::format!(
13102                "INSERT … SELECT cannot materialise value of type {:?}; \
13103                 add an explicit CAST in the inner SELECT",
13104                other.data_type()
13105            )));
13106        }
13107    };
13108    Ok(Expr::Literal(lit))
13109}
13110
13111fn format_timestamp_micros(us: i64) -> String {
13112    // Same Y/M/D split used by the wire layer; epoch-relative.
13113    let days = us.div_euclid(86_400_000_000);
13114    let intra_day = us.rem_euclid(86_400_000_000);
13115    let date = format_timestamp_micros_as_date(days * 86_400_000_000);
13116    let secs = intra_day / 1_000_000;
13117    let us_rem = intra_day % 1_000_000;
13118    let h = (secs / 3600) % 24;
13119    let m = (secs / 60) % 60;
13120    let s = secs % 60;
13121    if us_rem == 0 {
13122        alloc::format!("{date} {h:02}:{m:02}:{s:02}")
13123    } else {
13124        alloc::format!("{date} {h:02}:{m:02}:{s:02}.{us_rem:06}")
13125    }
13126}
13127
13128fn format_timestamp_micros_as_date(us: i64) -> String {
13129    // Days since 1970-01-01 → calendar Y-M-D via the proleptic
13130    // Gregorian conversion used by spg-engine's date helpers.
13131    let days = us.div_euclid(86_400_000_000);
13132    // 1970-01-01 = JDN 2440588.
13133    let jdn = days + 2_440_588;
13134    let (y, mo, d) = jdn_to_ymd(jdn);
13135    alloc::format!("{y:04}-{mo:02}-{d:02}")
13136}
13137
13138fn jdn_to_ymd(jdn: i64) -> (i64, u32, u32) {
13139    // Fliegel & Van Flandern (1968) — works for all positive JDNs.
13140    let l = jdn + 68569;
13141    let n = (4 * l) / 146_097;
13142    let l = l - (146_097 * n + 3) / 4;
13143    let i = (4000 * (l + 1)) / 1_461_001;
13144    let l = l - (1461 * i) / 4 + 31;
13145    let j = (80 * l) / 2447;
13146    let day = (l - (2447 * j) / 80) as u32;
13147    let l = j / 11;
13148    let month = (j + 2 - 12 * l) as u32;
13149    let year = 100 * (n - 49) + i + l;
13150    (year, month, day)
13151}
13152
13153fn format_numeric(scaled: i128, scale: u8) -> String {
13154    if scale == 0 {
13155        return alloc::format!("{scaled}");
13156    }
13157    let abs = scaled.unsigned_abs();
13158    let divisor = 10u128.pow(u32::from(scale));
13159    let whole = abs / divisor;
13160    let frac = abs % divisor;
13161    let sign = if scaled < 0 { "-" } else { "" };
13162    alloc::format!("{sign}{whole}.{frac:0width$}", width = usize::from(scale))
13163}
13164
13165/// v6.1.1 — walk the prepared `Statement` AST and replace every
13166/// `Expr::Placeholder(n)` with `Expr::Literal(value_to_literal(
13167/// params[n-1]))`. The dispatch downstream sees a `Statement`
13168/// indistinguishable from a simple-query parse, so the exec path
13169/// stays unchanged.
13170///
13171/// Errors fall into one shape: a `$N` references past the bound
13172/// `params.len()`. Out-of-range happens when the Bind didn't
13173/// supply enough values; pgwire surfaces this as a protocol error
13174/// to the client.
13175/// v7.15.0 — rewrite every (potentially-qualified) column
13176/// identifier matching `old` to `new` in a stored SQL source
13177/// string. Used by `ALTER TABLE … RENAME COLUMN` to patch
13178/// CHECK predicate sources, partial-index predicate sources,
13179/// and runtime DEFAULT expression sources before they get
13180/// re-parsed on the next INSERT/UPDATE.
13181///
13182/// Round-trips through the parser, so the rewritten output is
13183/// the canonical Display form (matches what the engine stores
13184/// for fresh predicates). If the source doesn't parse, surfaces
13185/// the parse error — the invariant that stored predicates are
13186/// in canonical Display form means a parse failure here is a
13187/// real bug, not a user mistake to swallow.
13188fn rewrite_column_in_source(
13189    src: &str,
13190    old: &str,
13191    new: &str,
13192) -> Result<alloc::string::String, EngineError> {
13193    let mut expr = spg_sql::parser::parse_expression(src).map_err(|e| {
13194        EngineError::Unsupported(alloc::format!(
13195            "ALTER TABLE RENAME COLUMN: stored predicate source {src:?} \
13196             failed to parse for rewrite ({e})"
13197        ))
13198    })?;
13199    rewrite_column_in_expr(&mut expr, old, new);
13200    Ok(alloc::format!("{expr}"))
13201}
13202
13203/// v7.15.0 — Expr walker that swaps `Expr::Column { name: old, .. }`
13204/// for `Expr::Column { name: new, .. }`. Qualifier is preserved
13205/// (e.g. `t.old` → `t.new`); a foreign-table qualifier still
13206/// gets rewritten because the AST has no way to tell us this
13207/// predicate is on table T versus table T2 — predicate sources
13208/// in SPG are always scoped to the owning table, so any
13209/// qualifier present is either redundant or wrong.
13210fn rewrite_column_in_expr(e: &mut Expr, old: &str, new: &str) {
13211    match e {
13212        Expr::Column(c) => {
13213            if c.name.eq_ignore_ascii_case(old) {
13214                c.name = new.to_string();
13215            }
13216        }
13217        Expr::Binary { lhs, rhs, .. } => {
13218            rewrite_column_in_expr(lhs, old, new);
13219            rewrite_column_in_expr(rhs, old, new);
13220        }
13221        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13222            rewrite_column_in_expr(expr, old, new);
13223        }
13224        Expr::FunctionCall { args, .. } => {
13225            for a in args {
13226                rewrite_column_in_expr(a, old, new);
13227            }
13228        }
13229        Expr::Like { expr, pattern, .. } => {
13230            rewrite_column_in_expr(expr, old, new);
13231            rewrite_column_in_expr(pattern, old, new);
13232        }
13233        Expr::Extract { source, .. } => rewrite_column_in_expr(source, old, new),
13234        Expr::WindowFunction {
13235            args,
13236            partition_by,
13237            order_by,
13238            ..
13239        } => {
13240            for a in args {
13241                rewrite_column_in_expr(a, old, new);
13242            }
13243            for p in partition_by {
13244                rewrite_column_in_expr(p, old, new);
13245            }
13246            for (o, _) in order_by {
13247                rewrite_column_in_expr(o, old, new);
13248            }
13249        }
13250        Expr::Array(items) => {
13251            for elem in items {
13252                rewrite_column_in_expr(elem, old, new);
13253            }
13254        }
13255        Expr::ArraySubscript { target, index } => {
13256            rewrite_column_in_expr(target, old, new);
13257            rewrite_column_in_expr(index, old, new);
13258        }
13259        Expr::AnyAll { expr, array, .. } => {
13260            rewrite_column_in_expr(expr, old, new);
13261            rewrite_column_in_expr(array, old, new);
13262        }
13263        Expr::Case {
13264            operand,
13265            branches,
13266            else_branch,
13267        } => {
13268            if let Some(o) = operand {
13269                rewrite_column_in_expr(o, old, new);
13270            }
13271            for (w, t) in branches {
13272                rewrite_column_in_expr(w, old, new);
13273                rewrite_column_in_expr(t, old, new);
13274            }
13275            if let Some(e) = else_branch {
13276                rewrite_column_in_expr(e, old, new);
13277            }
13278        }
13279        // Stored predicate sources never contain subqueries —
13280        // CHECK / partial-index / runtime_default are all scalar.
13281        // If a future feature changes that, recurse here.
13282        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => {}
13283        Expr::Literal(_) | Expr::Placeholder(_) => {}
13284    }
13285}
13286
13287/// v7.16.0 — walks a parsed statement and replaces every
13288/// `Expr::Placeholder(N)` with the corresponding `params[N-1]`
13289/// re-encoded as an `Expr::Literal`. Used internally by
13290/// `Engine::execute_prepared` AND surfaced for the spg-embedded
13291/// WAL path (which needs the bind-final AST so replay sees a
13292/// simple-query-shaped statement, not a `$1`-shaped one). Errors
13293/// when a placeholder references an index past the params slice.
13294pub fn substitute_placeholders(stmt: &mut Statement, params: &[Value]) -> Result<(), EngineError> {
13295    match stmt {
13296        Statement::Select(s) => substitute_select(s, params)?,
13297        Statement::Insert(ins) => {
13298            for row in &mut ins.rows {
13299                for e in row {
13300                    substitute_expr(e, params)?;
13301                }
13302            }
13303        }
13304        Statement::Update(u) => {
13305            for (_, e) in &mut u.assignments {
13306                substitute_expr(e, params)?;
13307            }
13308            if let Some(w) = &mut u.where_ {
13309                substitute_expr(w, params)?;
13310            }
13311        }
13312        Statement::Delete(d) => {
13313            if let Some(w) = &mut d.where_ {
13314                substitute_expr(w, params)?;
13315            }
13316        }
13317        Statement::Explain(e) => substitute_select(&mut e.inner, params)?,
13318        // Other statements (CREATE / BEGIN / SHOW / …) have no
13319        // expression slots; no walk needed.
13320        _ => {}
13321    }
13322    Ok(())
13323}
13324
13325fn substitute_select(s: &mut SelectStatement, params: &[Value]) -> Result<(), EngineError> {
13326    for item in &mut s.items {
13327        if let SelectItem::Expr { expr, .. } = item {
13328            substitute_expr(expr, params)?;
13329        }
13330    }
13331    if let Some(w) = &mut s.where_ {
13332        substitute_expr(w, params)?;
13333    }
13334    if let Some(gs) = &mut s.group_by {
13335        for g in gs {
13336            substitute_expr(g, params)?;
13337        }
13338    }
13339    if let Some(h) = &mut s.having {
13340        substitute_expr(h, params)?;
13341    }
13342    for o in &mut s.order_by {
13343        substitute_expr(&mut o.expr, params)?;
13344    }
13345    for (_, peer) in &mut s.unions {
13346        substitute_select(peer, params)?;
13347    }
13348    // v7.9.24 — LIMIT $N / OFFSET $N placeholder resolution.
13349    // mailrs H2. After this pass each LIMIT/OFFSET that was a
13350    // Placeholder is rewritten to Literal so the existing
13351    // `LimitExpr::as_literal` path consumes a concrete u32.
13352    if let Some(le) = s.limit {
13353        s.limit = Some(resolve_limit_placeholder(le, params)?);
13354    }
13355    if let Some(le) = s.offset {
13356        s.offset = Some(resolve_limit_placeholder(le, params)?);
13357    }
13358    Ok(())
13359}
13360
13361fn resolve_limit_placeholder(
13362    le: spg_sql::ast::LimitExpr,
13363    params: &[Value],
13364) -> Result<spg_sql::ast::LimitExpr, EngineError> {
13365    use spg_sql::ast::LimitExpr;
13366    match le {
13367        LimitExpr::Literal(_) => Ok(le),
13368        LimitExpr::Placeholder(n) => {
13369            let idx = usize::from(n).saturating_sub(1);
13370            let v = params.get(idx).ok_or_else(|| {
13371                EngineError::Eval(EvalError::PlaceholderOutOfRange {
13372                    n,
13373                    bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
13374                })
13375            })?;
13376            let int = match v {
13377                Value::SmallInt(x) => Some(i64::from(*x)),
13378                Value::Int(x) => Some(i64::from(*x)),
13379                Value::BigInt(x) => Some(*x),
13380                _ => None,
13381            }
13382            .ok_or_else(|| {
13383                EngineError::Unsupported(alloc::format!(
13384                    "LIMIT/OFFSET ${n} bound to non-integer {v:?}"
13385                ))
13386            })?;
13387            if int < 0 {
13388                return Err(EngineError::Unsupported(alloc::format!(
13389                    "LIMIT/OFFSET ${n} bound to negative value {int}"
13390                )));
13391            }
13392            let bounded = u32::try_from(int).map_err(|_| {
13393                EngineError::Unsupported(alloc::format!(
13394                    "LIMIT/OFFSET ${n} value {int} exceeds u32 range"
13395                ))
13396            })?;
13397            Ok(LimitExpr::Literal(bounded))
13398        }
13399    }
13400}
13401
13402fn substitute_expr(e: &mut Expr, params: &[Value]) -> Result<(), EngineError> {
13403    if let Expr::Placeholder(n) = e {
13404        let idx = usize::from(*n).saturating_sub(1);
13405        let v = params.get(idx).ok_or_else(|| {
13406            EngineError::Eval(EvalError::PlaceholderOutOfRange {
13407                n: *n,
13408                bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
13409            })
13410        })?;
13411        *e = Expr::Literal(value_to_literal(v.clone()));
13412        return Ok(());
13413    }
13414    match e {
13415        Expr::Binary { lhs, rhs, .. } => {
13416            substitute_expr(lhs, params)?;
13417            substitute_expr(rhs, params)?;
13418        }
13419        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13420            substitute_expr(expr, params)?;
13421        }
13422        Expr::FunctionCall { args, .. } => {
13423            for a in args {
13424                substitute_expr(a, params)?;
13425            }
13426        }
13427        Expr::Like { expr, pattern, .. } => {
13428            substitute_expr(expr, params)?;
13429            substitute_expr(pattern, params)?;
13430        }
13431        Expr::Extract { source, .. } => substitute_expr(source, params)?,
13432        Expr::ScalarSubquery(s) => substitute_select(s, params)?,
13433        Expr::Exists { subquery, .. } => substitute_select(subquery, params)?,
13434        Expr::InSubquery { expr, subquery, .. } => {
13435            substitute_expr(expr, params)?;
13436            substitute_select(subquery, params)?;
13437        }
13438        Expr::WindowFunction {
13439            args,
13440            partition_by,
13441            order_by,
13442            ..
13443        } => {
13444            for a in args {
13445                substitute_expr(a, params)?;
13446            }
13447            for p in partition_by {
13448                substitute_expr(p, params)?;
13449            }
13450            for (e, _) in order_by {
13451                substitute_expr(e, params)?;
13452            }
13453        }
13454        Expr::Literal(_) | Expr::Column(_) => {}
13455        // Already handled above.
13456        Expr::Placeholder(_) => unreachable!("Placeholder handled at top of fn"),
13457        Expr::Array(items) => {
13458            for elem in items {
13459                substitute_expr(elem, params)?;
13460            }
13461        }
13462        Expr::ArraySubscript { target, index } => {
13463            substitute_expr(target, params)?;
13464            substitute_expr(index, params)?;
13465        }
13466        Expr::AnyAll { expr, array, .. } => {
13467            substitute_expr(expr, params)?;
13468            substitute_expr(array, params)?;
13469        }
13470        Expr::Case {
13471            operand,
13472            branches,
13473            else_branch,
13474        } => {
13475            if let Some(o) = operand {
13476                substitute_expr(o, params)?;
13477            }
13478            for (w, t) in branches {
13479                substitute_expr(w, params)?;
13480                substitute_expr(t, params)?;
13481            }
13482            if let Some(e) = else_branch {
13483                substitute_expr(e, params)?;
13484            }
13485        }
13486    }
13487    Ok(())
13488}
13489
13490/// v6.1.1 — convert a runtime `Value` into the closest matching
13491/// `Literal` for the substitute walker. Lossless for the simple
13492/// scalars (Int / Float / Text / Bool); Numeric / Date / Timestamp
13493/// / Json / Interval render as their canonical text form so the
13494/// downstream coerce_value can re-parse against the target column
13495/// type. SQ8 / HalfVector cells are NOT expected as bind params;
13496/// pgwire's Bind decodes vector params to the f32 representation
13497/// before they reach this helper.
13498/// v6.2.0 — total ordering on `Value`s used by ANALYZE to sort a
13499/// column's non-NULL sample before histogram building. Cross-type
13500/// pairs (Int vs Float, Date vs Timestamp, …) compare via the
13501/// same widening the eval-side `compare` operator uses; everything
13502/// else (the genuinely-incompatible pairs) falls back to ordering
13503/// by canonical string form so the sort is still total + stable.
13504/// Vector / SQ8 / Half / Json / Numeric / Interval values reach
13505/// here only via the string-fallback path because vector columns
13506/// are filtered out upstream.
13507fn sort_values_for_histogram(a: &Value, b: &Value) -> core::cmp::Ordering {
13508    use core::cmp::Ordering;
13509    match (a, b) {
13510        (Value::SmallInt(a), Value::SmallInt(b)) => a.cmp(b),
13511        (Value::Int(a), Value::Int(b)) => a.cmp(b),
13512        (Value::BigInt(a), Value::BigInt(b)) => a.cmp(b),
13513        (Value::SmallInt(a), Value::Int(b)) => i32::from(*a).cmp(b),
13514        (Value::Int(a), Value::SmallInt(b)) => a.cmp(&i32::from(*b)),
13515        (Value::Int(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
13516        (Value::BigInt(a), Value::Int(b)) => a.cmp(&i64::from(*b)),
13517        (Value::SmallInt(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
13518        (Value::BigInt(a), Value::SmallInt(b)) => a.cmp(&i64::from(*b)),
13519        (Value::Float(a), Value::Float(b)) => a.partial_cmp(b).unwrap_or(Ordering::Equal),
13520        (Value::Text(a), Value::Text(b)) | (Value::Json(a), Value::Json(b)) => a.cmp(b),
13521        (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
13522        (Value::Date(a), Value::Date(b)) => a.cmp(b),
13523        (Value::Timestamp(a), Value::Timestamp(b)) => a.cmp(b),
13524        // Mixed numeric/float — widen to f64 and compare.
13525        (Value::SmallInt(n), Value::Float(x)) => {
13526            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
13527        }
13528        (Value::Float(x), Value::SmallInt(n)) => {
13529            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
13530        }
13531        (Value::Int(n), Value::Float(x)) => {
13532            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
13533        }
13534        (Value::Float(x), Value::Int(n)) => {
13535            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
13536        }
13537        (Value::BigInt(n), Value::Float(x)) => {
13538            #[allow(clippy::cast_precision_loss)]
13539            let nf = *n as f64;
13540            nf.partial_cmp(x).unwrap_or(Ordering::Equal)
13541        }
13542        (Value::Float(x), Value::BigInt(n)) => {
13543            #[allow(clippy::cast_precision_loss)]
13544            let nf = *n as f64;
13545            x.partial_cmp(&nf).unwrap_or(Ordering::Equal)
13546        }
13547        // Cross-type fallback: lexicographic on canonical form.
13548        // Total + stable so the sort is well-defined.
13549        _ => canonical_value_repr(a).cmp(&canonical_value_repr(b)),
13550    }
13551}
13552
13553/// v6.2.0 — render the histogram bounds list as a `[v0, v1, ...]`
13554/// string for the `spg_statistic.histogram_bounds` column. Values
13555/// containing `,` or `[` / `]` are JSON-style escaped so the
13556/// rendering round-trips through a future parser; v6.2.0 only
13557/// uses the rendered form for human consumption, so the escaping
13558/// is conservative.
13559fn render_histogram_bounds(bounds: &[alloc::string::String]) -> alloc::string::String {
13560    let mut out = alloc::string::String::with_capacity(bounds.len() * 8 + 2);
13561    out.push('[');
13562    for (i, b) in bounds.iter().enumerate() {
13563        if i > 0 {
13564            out.push_str(", ");
13565        }
13566        let needs_quote = b.contains([',', '[', ']', '"']) || b.is_empty();
13567        if needs_quote {
13568            out.push('"');
13569            for ch in b.chars() {
13570                if ch == '"' || ch == '\\' {
13571                    out.push('\\');
13572                }
13573                out.push(ch);
13574            }
13575            out.push('"');
13576        } else {
13577            out.push_str(b);
13578        }
13579    }
13580    out.push(']');
13581    out
13582}
13583
13584/// v6.2.0 — canonical textual form of a `Value` for histogram
13585/// bound storage. Strings used by ANALYZE for sort + bound output.
13586/// INT / BIGINT → decimal; FLOAT → shortest-round-trip via
13587/// `{:?}`; TEXT pass-through; BOOL → `t` / `f`; DATE / TIMESTAMP →
13588/// the same form `format_date` / `format_timestamp` produce for
13589/// SQL Display. Vector / SQ8 / Half / Json / Numeric / Interval
13590/// reach this only via a non-Vector column (vector columns are
13591/// skipped upstream); they fall back to a Debug-derived form so
13592/// stats still serialise without crashing.
13593pub(crate) fn canonical_value_repr(v: &Value) -> alloc::string::String {
13594    match v {
13595        Value::Null => "NULL".to_string(),
13596        Value::SmallInt(n) => alloc::format!("{n}"),
13597        Value::Int(n) => alloc::format!("{n}"),
13598        Value::BigInt(n) => alloc::format!("{n}"),
13599        Value::Float(x) => alloc::format!("{x:?}"),
13600        Value::Text(s) | Value::Json(s) => s.clone(),
13601        Value::Bool(b) => if *b { "t" } else { "f" }.to_string(),
13602        Value::Date(d) => eval::format_date(*d),
13603        Value::Timestamp(t) => eval::format_timestamp(*t),
13604        // v7.17.0 Phase 3.P0-32 — PG TIME canonical text form.
13605        Value::Time(us) => eval::format_time(*us),
13606        // v7.17.0 Phase 3.P0-33 — MySQL YEAR 4-digit zero-padded.
13607        Value::Year(y) => alloc::format!("{y:04}"),
13608        // v7.17.0 Phase 3.P0-34 — PG TIMETZ canonical text form.
13609        Value::TimeTz { us, offset_secs } => eval::format_timetz(*us, *offset_secs),
13610        // v7.17.0 Phase 3.P0-35 — PG MONEY canonical en_US text form.
13611        Value::Money(c) => eval::format_money(*c),
13612        // v7.17.0 Phase 3.P0-38 — PG range canonical text form.
13613        v @ Value::Range { .. } => format_range_str(v),
13614        // v7.17.0 Phase 3.P0-39 — PG hstore canonical text form.
13615        Value::Hstore(pairs) => format_hstore_str(pairs),
13616        // v7.17.0 Phase 3.P0-40 — 2D array canonical text form.
13617        Value::IntArray2D(rows) => format_int_2d_text(rows),
13618        Value::BigIntArray2D(rows) => format_bigint_2d_text(rows),
13619        Value::TextArray2D(rows) => format_text_2d_text(rows),
13620        Value::Interval { months, micros } => eval::format_interval(*months, *micros),
13621        Value::Numeric { scaled, scale } => eval::format_numeric(*scaled, *scale),
13622        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
13623            // Unreachable in practice (vector columns are filtered
13624            // out before this). Defensive fallback so a future
13625            // vector-stats path doesn't crash.
13626            alloc::format!("{v:?}")
13627        }
13628        // v7.5.0 — Value is #[non_exhaustive] for downstream
13629        // forward-compat. Future variants fall through to Debug
13630        // form here (same shape as the vector fallback above).
13631        _ => alloc::format!("{v:?}"),
13632    }
13633}
13634
13635/// v6.2.0 — true for engine-managed catalog tables that the bare
13636/// `ANALYZE` (no target) should skip. v6.2.0 has no internal
13637/// tables yet (publications / subscriptions / users / statistics
13638/// all live as engine fields, not catalog tables), so this is a
13639/// reserved future-proofing hook — every existing user table is
13640/// analysed.
13641const fn is_internal_table_name(_name: &str) -> bool {
13642    false
13643}
13644
13645fn value_to_literal(v: Value) -> Literal {
13646    match v {
13647        Value::Null => Literal::Null,
13648        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13649        Value::Int(n) => Literal::Integer(i64::from(n)),
13650        Value::BigInt(n) => Literal::Integer(n),
13651        Value::Float(x) => Literal::Float(x),
13652        Value::Text(s) | Value::Json(s) => Literal::String(s),
13653        Value::Bool(b) => Literal::Bool(b),
13654        Value::Vector(v) => Literal::Vector(v),
13655        Value::Numeric { scaled, scale } => Literal::String(eval::format_numeric(scaled, scale)),
13656        Value::Date(d) => Literal::String(eval::format_date(d)),
13657        Value::Timestamp(t) => Literal::String(eval::format_timestamp(t)),
13658        // v7.17.0 Phase 3.P0-69 — UUID round-trips via canonical
13659        // hyphenated text. Without this arm the fallback below
13660        // renders `Debug` form ("Uuid([85, …])") which the
13661        // engine's Text → Uuid coerce can't parse, breaking
13662        // prepared-bind round-trip from the spg-sqlx adapter.
13663        Value::Uuid(b) => Literal::String(spg_storage::format_uuid(&b)),
13664        // v7.16.0 — BYTEA round-trip for the spg-sqlx Bind path.
13665        // PG-canonical text rep is `\x` + lowercase hex; the
13666        // engine's coerce_value already accepts that on the
13667        // text → bytea direction.
13668        Value::Bytes(b) => Literal::String(eval::format_bytea_hex(&b)),
13669        // v7.16.0 — array round-trip for the spg-sqlx Bind
13670        // path. Render as PG external form `{a,b,c}`; the
13671        // engine's text → array coerce (just below in
13672        // coerce_value) accepts it on the matching column type.
13673        Value::TextArray(items) => Literal::String(eval::format_text_array(&items)),
13674        Value::IntArray(items) => Literal::String(eval::format_int_array(&items)),
13675        Value::BigIntArray(items) => Literal::String(eval::format_bigint_array(&items)),
13676        Value::Interval { months, micros } => Literal::Interval {
13677            months,
13678            micros,
13679            text: eval::format_interval(months, micros),
13680        },
13681        // SQ8 / halfvec cells dequantise to f32 before reaching the
13682        // substitute walker; pgwire's Bind path handles that.
13683        Value::Sq8Vector(q) => Literal::Vector(spg_storage::quantize::dequantize(&q)),
13684        Value::HalfVector(h) => Literal::Vector(h.to_f32_vec()),
13685        // v7.5.0 — Value is #[non_exhaustive]; future variants
13686        // render as Debug-form String literal until explicit
13687        // mapping is added.
13688        v => Literal::String(alloc::format!("{v:?}")),
13689    }
13690}
13691
13692fn rewrite_clock_calls(stmt: &mut Statement, now_micros: Option<i64>) {
13693    let Some(now) = now_micros else {
13694        return;
13695    };
13696    match stmt {
13697        Statement::Select(s) => rewrite_select_clock(s, now),
13698        Statement::Insert(ins) => {
13699            for row in &mut ins.rows {
13700                for e in row {
13701                    rewrite_expr_clock(e, now);
13702                }
13703            }
13704        }
13705        _ => {}
13706    }
13707}
13708
13709fn rewrite_select_clock(s: &mut SelectStatement, now: i64) {
13710    for item in &mut s.items {
13711        if let SelectItem::Expr { expr, .. } = item {
13712            rewrite_expr_clock(expr, now);
13713        }
13714    }
13715    if let Some(w) = &mut s.where_ {
13716        rewrite_expr_clock(w, now);
13717    }
13718    if let Some(gs) = &mut s.group_by {
13719        for g in gs {
13720            rewrite_expr_clock(g, now);
13721        }
13722    }
13723    if let Some(h) = &mut s.having {
13724        rewrite_expr_clock(h, now);
13725    }
13726    for o in &mut s.order_by {
13727        rewrite_expr_clock(&mut o.expr, now);
13728    }
13729    for (_, peer) in &mut s.unions {
13730        rewrite_select_clock(peer, now);
13731    }
13732}
13733
13734/// v3.0.3 hot path: every recursion lands in exactly one `match` arm.
13735/// Literal / Column-with-qualifier (the dominant cases on a typical
13736/// AST) take a single pattern dispatch and exit. The clock-rewrite
13737/// targets (zero-arg `NOW` / `CURRENT_TIMESTAMP` / `CURRENT_DATE`
13738/// functions, and bare `CURRENT_TIMESTAMP` / `CURRENT_DATE` column
13739/// refs) sit on their own arms with match guards so the fall-through
13740/// to the recursive arms is unambiguous.
13741fn rewrite_expr_clock(e: &mut Expr, now: i64) {
13742    // Fast-path test on the no-recursion shapes first. We can't fold
13743    // them into the big match below because they need to *replace* `e`
13744    // outright; the recursive arms below match on its sub-fields.
13745    if let Some(replacement) = clock_replacement_for(e, now) {
13746        *e = replacement;
13747        return;
13748    }
13749    match e {
13750        Expr::Binary { lhs, rhs, .. } => {
13751            rewrite_expr_clock(lhs, now);
13752            rewrite_expr_clock(rhs, now);
13753        }
13754        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13755            rewrite_expr_clock(expr, now);
13756        }
13757        Expr::FunctionCall { args, .. } => {
13758            for a in args {
13759                rewrite_expr_clock(a, now);
13760            }
13761        }
13762        Expr::Like { expr, pattern, .. } => {
13763            rewrite_expr_clock(expr, now);
13764            rewrite_expr_clock(pattern, now);
13765        }
13766        Expr::Extract { source, .. } => rewrite_expr_clock(source, now),
13767        // v4.10 subquery nodes — recurse into the inner SELECT's
13768        // expression slots so e.g. SELECT NOW() in a scalar
13769        // subquery picks up the same instant as the outer query.
13770        Expr::ScalarSubquery(s) => rewrite_select_clock(s, now),
13771        Expr::Exists { subquery, .. } => rewrite_select_clock(subquery, now),
13772        Expr::InSubquery { expr, subquery, .. } => {
13773            rewrite_expr_clock(expr, now);
13774            rewrite_select_clock(subquery, now);
13775        }
13776        // v4.12 window functions — args + PARTITION BY + ORDER BY
13777        // may all reference clock literals.
13778        Expr::WindowFunction {
13779            args,
13780            partition_by,
13781            order_by,
13782            ..
13783        } => {
13784            for a in args {
13785                rewrite_expr_clock(a, now);
13786            }
13787            for p in partition_by {
13788                rewrite_expr_clock(p, now);
13789            }
13790            for (e, _) in order_by {
13791                rewrite_expr_clock(e, now);
13792            }
13793        }
13794        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
13795        Expr::Array(items) => {
13796            for elem in items {
13797                rewrite_expr_clock(elem, now);
13798            }
13799        }
13800        Expr::ArraySubscript { target, index } => {
13801            rewrite_expr_clock(target, now);
13802            rewrite_expr_clock(index, now);
13803        }
13804        Expr::AnyAll { expr, array, .. } => {
13805            rewrite_expr_clock(expr, now);
13806            rewrite_expr_clock(array, now);
13807        }
13808        Expr::Case {
13809            operand,
13810            branches,
13811            else_branch,
13812        } => {
13813            if let Some(o) = operand {
13814                rewrite_expr_clock(o, now);
13815            }
13816            for (w, t) in branches {
13817                rewrite_expr_clock(w, now);
13818                rewrite_expr_clock(t, now);
13819            }
13820            if let Some(e) = else_branch {
13821                rewrite_expr_clock(e, now);
13822            }
13823        }
13824    }
13825}
13826
13827/// Returns `Some(Expr)` when `e` is one of the clock-call shapes that
13828/// must be rewritten; otherwise `None` so the caller falls through to
13829/// the recursive walk. Identifies both function-call forms (`NOW()` /
13830/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()`) and bare-identifier forms
13831/// (`CURRENT_TIMESTAMP` / `CURRENT_DATE` as unqualified column refs,
13832/// which is how PG accepts them without parens).
13833fn clock_replacement_for(e: &Expr, now: i64) -> Option<Expr> {
13834    let (kind, name) = match e {
13835        Expr::FunctionCall { name, args } if args.is_empty() => (ClockSite::Fn, name.as_str()),
13836        Expr::Column(c) if c.qualifier.is_none() => (ClockSite::BareIdent, c.name.as_str()),
13837        _ => return None,
13838    };
13839    // ASCII case-insensitive name match. Each entry decides what
13840    // synthetic literal the call expands to.
13841    //
13842    // v7.17.0 Phase 3.P0-29 — `unix_timestamp` (no args) joins this
13843    // table as MySQL's epoch-seconds equivalent of `now()`. Folded
13844    // to a BigInt literal here so apply_function never needs a
13845    // clock dependency.
13846    enum ClockShape {
13847        Timestamp,
13848        Date,
13849        UnixSeconds,
13850    }
13851    let shape = match name.len() {
13852        3 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("now") => {
13853            Some(ClockShape::Timestamp)
13854        }
13855        12 if name.eq_ignore_ascii_case("current_date") => Some(ClockShape::Date),
13856        14 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("unix_timestamp") => {
13857            Some(ClockShape::UnixSeconds)
13858        }
13859        17 if name.eq_ignore_ascii_case("current_timestamp") => Some(ClockShape::Timestamp),
13860        _ => None,
13861    };
13862    let shape = shape?;
13863    let payload = match shape {
13864        ClockShape::Timestamp => now,
13865        ClockShape::Date => now.div_euclid(86_400_000_000),
13866        ClockShape::UnixSeconds => now.div_euclid(1_000_000),
13867    };
13868    let target = match shape {
13869        ClockShape::Timestamp => spg_sql::ast::CastTarget::Timestamp,
13870        ClockShape::Date => spg_sql::ast::CastTarget::Date,
13871        ClockShape::UnixSeconds => spg_sql::ast::CastTarget::BigInt,
13872    };
13873    Some(Expr::Cast {
13874        expr: alloc::boxed::Box::new(Expr::Literal(spg_sql::ast::Literal::Integer(payload))),
13875        target,
13876    })
13877}
13878
13879#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13880enum ClockSite {
13881    Fn,
13882    BareIdent,
13883}
13884
13885/// `ORDER BY <integer>` references the N-th SELECT item (1-based).
13886/// Swap the integer literal for the matching item's expression so the
13887/// executor doesn't need a special-case branch. Recurses into UNION
13888/// peers because each peer keeps its own SELECT list.
13889/// v6.4.1 — expand `GROUP BY ALL` to every non-aggregate SELECT-list
13890/// item. Mirrors DuckDB / PG 19 semantics. Wildcards (`SELECT * …`)
13891/// are NOT expanded by GROUP BY ALL (PG 19 leaves the wildcard intact
13892/// and groups by whatever explicit non-aggregates remain — none in
13893/// the wildcard-only case, which still works for non-aggregate
13894/// queries).
13895fn expand_group_by_all(s: &mut SelectStatement) {
13896    if !s.group_by_all {
13897        for (_, peer) in &mut s.unions {
13898            expand_group_by_all(peer);
13899        }
13900        return;
13901    }
13902    let mut groups: Vec<Expr> = Vec::new();
13903    for item in &s.items {
13904        if let SelectItem::Expr { expr, .. } = item
13905            && !aggregate::contains_aggregate(expr)
13906        {
13907            groups.push(expr.clone());
13908        }
13909    }
13910    s.group_by = Some(groups);
13911    s.group_by_all = false;
13912    for (_, peer) in &mut s.unions {
13913        expand_group_by_all(peer);
13914    }
13915}
13916
13917fn resolve_order_by_position(s: &mut SelectStatement) {
13918    // v6.4.0 — iterate every ORDER BY key. Position references
13919    // (`ORDER BY 2`) bind to the 1-based projection index;
13920    // identifier references that match a SELECT-list alias bind to
13921    // the projected expression (Step 4 of L3a).
13922    for order in &mut s.order_by {
13923        match &order.expr {
13924            Expr::Literal(Literal::Integer(n)) if *n >= 1 => {
13925                if let Ok(idx_one_based) = usize::try_from(*n) {
13926                    let idx = idx_one_based - 1;
13927                    if idx < s.items.len()
13928                        && let SelectItem::Expr { expr, .. } = &s.items[idx]
13929                    {
13930                        order.expr = expr.clone();
13931                    }
13932                }
13933            }
13934            Expr::Column(c) if c.qualifier.is_none() => {
13935                // Alias-in-ORDER-BY lookup.
13936                for item in &s.items {
13937                    if let SelectItem::Expr {
13938                        expr,
13939                        alias: Some(a),
13940                    } = item
13941                        && a == &c.name
13942                    {
13943                        order.expr = expr.clone();
13944                        break;
13945                    }
13946                }
13947            }
13948            _ => {}
13949        }
13950    }
13951    for (_, peer) in &mut s.unions {
13952        resolve_order_by_position(peer);
13953    }
13954}
13955
13956/// Sort `tagged` by `f64` key, reversing the comparator under DESC.
13957/// Used by the UNION ORDER BY path; per-block paths inline the same
13958/// comparator because they already hold `&OrderBy` directly.
13959/// v3.1.1: partial-sort helper. When `keep` (= offset + limit) is
13960/// strictly less than `tagged.len()`, run `select_nth_unstable_by` to
13961/// partition the prefix in O(n), then sort just that prefix in O(k
13962/// log k). Total O(n + k log k), vs O(n log n) for a full sort. The
13963/// caller decides what `keep` is; passing `None` (no LIMIT) keeps the
13964/// full-sort behaviour.
13965///
13966/// `tagged` holds `(Option<f64>, Row)` (the SELECT path) — `None` keys
13967/// sort last in ascending order, mirroring NULL-sorts-last in SQL.
13968fn partial_sort_tagged(tagged: &mut Vec<(Vec<f64>, Row)>, keep: Option<usize>, descs: &[bool]) {
13969    let cmp = |a: &(Vec<f64>, Row), b: &(Vec<f64>, Row)| cmp_multi_key(&a.0, &b.0, descs);
13970    match keep {
13971        Some(k) if k < tagged.len() && k > 0 => {
13972            let pivot = k - 1;
13973            tagged.select_nth_unstable_by(pivot, cmp);
13974            tagged[..k].sort_by(cmp);
13975            tagged.truncate(k);
13976        }
13977        _ => {
13978            tagged.sort_by(cmp);
13979        }
13980    }
13981}
13982
13983fn sort_by_keys(tagged: &mut [(Vec<f64>, Row)], descs: &[bool]) {
13984    tagged.sort_by(|a, b| cmp_multi_key(&a.0, &b.0, descs));
13985}
13986
13987/// v6.4.0 — multi-key ORDER BY comparator. Each key's per-key DESC
13988/// flag is honored independently. NULL is encoded as `f64::INFINITY`
13989/// so it sorts last in ASC and first in DESC (matches PG default).
13990fn cmp_multi_key(a: &[f64], b: &[f64], descs: &[bool]) -> core::cmp::Ordering {
13991    use core::cmp::Ordering;
13992    for (i, (ka, kb)) in a.iter().zip(b.iter()).enumerate() {
13993        let ord = ka.partial_cmp(kb).unwrap_or(Ordering::Equal);
13994        let ord = if descs.get(i).copied().unwrap_or(false) {
13995            ord.reverse()
13996        } else {
13997            ord
13998        };
13999        if ord != Ordering::Equal {
14000            return ord;
14001        }
14002    }
14003    Ordering::Equal
14004}
14005
14006/// v6.4.0 — eval every ORDER BY expression for a row and pack the
14007/// resulting keys into a `Vec<f64>`. NULL → `f64::INFINITY`.
14008fn build_order_keys(
14009    order_by: &[OrderBy],
14010    row: &Row,
14011    ctx: &EvalContext,
14012) -> Result<Vec<f64>, EngineError> {
14013    let mut keys = Vec::with_capacity(order_by.len());
14014    for o in order_by {
14015        let v = eval::eval_expr(&o.expr, row, ctx)?;
14016        keys.push(value_to_order_key(&v)?);
14017    }
14018    Ok(keys)
14019}
14020
14021/// Drop the first `offset` rows then truncate to `limit`. PG / `MySQL`
14022/// agree: OFFSET applies *after* ORDER BY but *before* LIMIT (so
14023/// `LIMIT 10 OFFSET 5` keeps rows 6..=15).
14024fn apply_offset_and_limit(rows: &mut Vec<Row>, offset: Option<u32>, limit: Option<u32>) {
14025    if let Some(off) = offset {
14026        let off = off as usize;
14027        if off >= rows.len() {
14028            rows.clear();
14029        } else {
14030            rows.drain(..off);
14031        }
14032    }
14033    if let Some(n) = limit {
14034        rows.truncate(n as usize);
14035    }
14036}
14037
14038/// v7.17.0 Phase 3.P0-49 — offset + limit applied to a tagged
14039/// `(order_keys, row)` sequence, with optional SQL:2008 `WITH
14040/// TIES` extension. When `with_ties` is set, the truncated tail
14041/// is extended through every subsequent row whose order keys
14042/// equal the last-kept row's keys (so a "top 3 by score" with
14043/// WITH TIES emits row 4 too when row 4 ties row 3 on `score`).
14044///
14045/// The order-key vector is the per-row sort key the caller already
14046/// computed via `build_order_keys`; equal-key detection therefore
14047/// matches the sort comparator exactly.
14048fn apply_offset_and_limit_tagged(
14049    tagged: &mut Vec<(Vec<f64>, Row)>,
14050    offset: Option<u32>,
14051    limit: Option<u32>,
14052    with_ties: bool,
14053) {
14054    if let Some(off) = offset {
14055        let off = off as usize;
14056        if off >= tagged.len() {
14057            tagged.clear();
14058        } else {
14059            tagged.drain(..off);
14060        }
14061    }
14062    if let Some(n) = limit {
14063        let n = n as usize;
14064        if with_ties && n > 0 && n < tagged.len() {
14065            let cutoff_key = tagged[n - 1].0.clone();
14066            let mut end = n;
14067            while end < tagged.len() && tagged[end].0 == cutoff_key {
14068                end += 1;
14069            }
14070            tagged.truncate(end);
14071        } else {
14072            tagged.truncate(n);
14073        }
14074    }
14075}
14076
14077/// v7.17.0 Phase 3.P0-49 — PG-canonical: `FETCH FIRST <n> ROWS
14078/// WITH TIES` requires an `ORDER BY`. Without one, there's no
14079/// way to identify "ties" deterministically, so PG errors at
14080/// plan time. SPG mirrors that surface so the same DDL / app
14081/// behaviour holds on cutover.
14082fn check_with_ties_requires_order_by(stmt: &SelectStatement) -> Result<(), EngineError> {
14083    if stmt.limit_with_ties && stmt.order_by.is_empty() {
14084        return Err(EngineError::Unsupported(alloc::string::String::from(
14085            "FETCH FIRST … ROWS WITH TIES requires an ORDER BY clause",
14086        )));
14087    }
14088    Ok(())
14089}
14090
14091/// v7.6.1 — resolve a parser-level `ForeignKeyConstraint` (column
14092/// names + parent table name) into the storage-layer shape (column
14093/// indices + same parent table). Validates everything the engine
14094/// needs to know about the FK at CREATE TABLE time:
14095///
14096///   - parent table exists (catalog lookup, unless self-referencing)
14097///   - parent columns exist on the parent table
14098///   - parent column list matches the local arity (defaults to the
14099///     parent's primary index column when omitted)
14100///   - parent columns are covered by a `BTree` UNIQUE-class index
14101///     (SPG's stand-in for `PRIMARY KEY`/`UNIQUE`) — required so
14102///     the v7.6.2 INSERT path can do an O(log n) parent lookup
14103///   - local columns exist on the table being created
14104fn resolve_foreign_key(
14105    local_table_name: &str,
14106    local_cols: &[ColumnSchema],
14107    fk: spg_sql::ast::ForeignKeyConstraint,
14108    catalog: &Catalog,
14109) -> Result<spg_storage::ForeignKeyConstraint, EngineError> {
14110    // Resolve local columns.
14111    let mut local_columns = Vec::with_capacity(fk.columns.len());
14112    for name in &fk.columns {
14113        let pos = local_cols
14114            .iter()
14115            .position(|c| c.name == *name)
14116            .ok_or_else(|| {
14117                EngineError::Unsupported(alloc::format!(
14118                    "FOREIGN KEY references unknown local column {name:?}"
14119                ))
14120            })?;
14121        local_columns.push(pos);
14122    }
14123    // Self-referencing FK: parent table is the one we're creating.
14124    // The parent column resolution uses the local column list since
14125    // the catalog doesn't have this table yet.
14126    let is_self_ref = fk.parent_table == local_table_name;
14127    let (parent_cols_for_lookup, parent_table_str): (&[ColumnSchema], &str) = if is_self_ref {
14128        (local_cols, local_table_name)
14129    } else {
14130        let parent_table = catalog.get(&fk.parent_table).ok_or_else(|| {
14131            EngineError::Storage(StorageError::TableNotFound {
14132                name: fk.parent_table.clone(),
14133            })
14134        })?;
14135        (
14136            parent_table.schema().columns.as_slice(),
14137            fk.parent_table.as_str(),
14138        )
14139    };
14140    // Resolve parent column names → positions. If the FK omitted the
14141    // parent column list, fall back to the parent's primary index
14142    // column (single-column only — composite default is rejected
14143    // because there's no unambiguous "PK" in SPG's index list).
14144    let parent_columns: Vec<usize> = if fk.parent_columns.is_empty() {
14145        if fk.columns.len() != 1 {
14146            return Err(EngineError::Unsupported(
14147                "composite FOREIGN KEY without explicit parent column list is not supported \
14148                 — list the parent columns explicitly"
14149                    .into(),
14150            ));
14151        }
14152        // Find a single BTree index on the parent and use its column.
14153        let pos = pick_pk_index_column(catalog, parent_table_str, is_self_ref, local_cols)
14154            .ok_or_else(|| {
14155                EngineError::Unsupported(alloc::format!(
14156                    "parent table {parent_table_str:?} has no PRIMARY-key / UNIQUE BTree index \
14157                     to default the FOREIGN KEY against"
14158                ))
14159            })?;
14160        alloc::vec![pos]
14161    } else {
14162        let mut out = Vec::with_capacity(fk.parent_columns.len());
14163        for name in &fk.parent_columns {
14164            let pos = parent_cols_for_lookup
14165                .iter()
14166                .position(|c| c.name == *name)
14167                .ok_or_else(|| {
14168                    EngineError::Unsupported(alloc::format!(
14169                        "FOREIGN KEY references unknown parent column \
14170                         {name:?} on table {parent_table_str:?}"
14171                    ))
14172                })?;
14173            out.push(pos);
14174        }
14175        out
14176    };
14177    if parent_columns.len() != local_columns.len() {
14178        return Err(EngineError::Unsupported(alloc::format!(
14179            "FOREIGN KEY arity mismatch: {} local columns vs {} parent columns",
14180            local_columns.len(),
14181            parent_columns.len()
14182        )));
14183    }
14184    // For non-self-referencing FKs, verify the parent column set is
14185    // covered by a BTree index. SPG doesn't have a `PRIMARY KEY`
14186    // declaration; the convention is "the parent column for FK
14187    // purposes must have a BTree index" — which the user creates via
14188    // `CREATE INDEX ... USING btree (col)` (the default). We accept
14189    // any single-column BTree index that covers a parent column;
14190    // composite parent column lists require an index whose `column_position`
14191    // matches the first parent column (multi-column BTree indices
14192    // are not in the v7.x roadmap).
14193    if !is_self_ref {
14194        let parent_table = catalog.get(&fk.parent_table).expect("checked above");
14195        let primary_parent_col = parent_columns[0];
14196        let has_btree = parent_table
14197            .schema()
14198            .columns
14199            .get(primary_parent_col)
14200            .is_some()
14201            && parent_table.indices().iter().any(|idx| {
14202                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14203                    && idx.column_position == primary_parent_col
14204                    && idx.partial_predicate.is_none()
14205            });
14206        if !has_btree {
14207            return Err(EngineError::Unsupported(alloc::format!(
14208                "FOREIGN KEY parent column on {:?} is not covered by an unconditional BTree \
14209                 index — create one with `CREATE INDEX ... ON {} ({})` first",
14210                parent_table_str,
14211                parent_table_str,
14212                parent_table.schema().columns[primary_parent_col].name,
14213            )));
14214        }
14215    }
14216    let on_delete = fk_action_sql_to_storage(fk.on_delete);
14217    let on_update = fk_action_sql_to_storage(fk.on_update);
14218    Ok(spg_storage::ForeignKeyConstraint {
14219        name: fk.name,
14220        local_columns,
14221        parent_table: fk.parent_table,
14222        parent_columns,
14223        on_delete,
14224        on_update,
14225    })
14226}
14227
14228/// v7.6.1 — pick a sentinel "primary key" column from the parent
14229/// table when the FK didn't name parent columns. Picks the first
14230/// single-column unconditional BTree index — that's the closest
14231/// thing SPG has to a PRIMARY KEY today. Self-referencing FKs use
14232/// `local_cols` as the column source.
14233fn pick_pk_index_column(
14234    catalog: &Catalog,
14235    parent_name: &str,
14236    is_self_ref: bool,
14237    local_cols: &[ColumnSchema],
14238) -> Option<usize> {
14239    if is_self_ref {
14240        // Self-ref FK omitted parent columns: pick column 0 by
14241        // convention (no catalog entry yet). Engine will widen this
14242        // when v7.6.7 lands; v7.6.1 only handles the explicit form.
14243        let _ = local_cols;
14244        return Some(0);
14245    }
14246    let parent = catalog.get(parent_name)?;
14247    parent.indices().iter().find_map(|idx| {
14248        if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14249            && idx.partial_predicate.is_none()
14250            && idx.included_columns.is_empty()
14251            && idx.expression.is_none()
14252        {
14253            Some(idx.column_position)
14254        } else {
14255            None
14256        }
14257    })
14258}
14259
14260/// v7.9.8 / v7.9.10 — resolve the column positions that
14261/// identify a conflict for ON CONFLICT. Returns a Vec of
14262/// column positions (1 element for single-column form, N for
14263/// composite). When the user wrote bare `ON CONFLICT DO …`,
14264/// falls back to the table's first unconditional BTree index
14265/// (always single-column today).
14266fn resolve_on_conflict_columns(
14267    catalog: &Catalog,
14268    table_name: &str,
14269    target: &[String],
14270) -> Result<Vec<usize>, EngineError> {
14271    let table = catalog.get(table_name).ok_or_else(|| {
14272        EngineError::Storage(StorageError::TableNotFound {
14273            name: table_name.into(),
14274        })
14275    })?;
14276    if target.is_empty() {
14277        // v7.13.2 — mailrs round-6 S5 follow-up. Composite UNIQUE
14278        // constraints carry a multi-column tuple; the prior code
14279        // path picked only the leading column of the first BTree
14280        // index, which caused `ON CONFLICT DO NOTHING` to dedup
14281        // by leading column alone (3 rows with same group_id but
14282        // different permission collapsed to 1). PG semantics use
14283        // the full tuple. Prefer a UniquenessConstraint's full
14284        // column list when one exists; fall back to the leading
14285        // BTree column for legacy single-column UNIQUE.
14286        if let Some(uc) = table.schema().uniqueness_constraints.first() {
14287            return Ok(uc.columns.clone());
14288        }
14289        let pos = table
14290            .indices()
14291            .iter()
14292            .find_map(|idx| {
14293                if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14294                    && idx.partial_predicate.is_none()
14295                    && idx.included_columns.is_empty()
14296                    && idx.expression.is_none()
14297                {
14298                    Some(idx.column_position)
14299                } else {
14300                    None
14301                }
14302            })
14303            .ok_or_else(|| {
14304                EngineError::Unsupported(alloc::format!(
14305                    "ON CONFLICT without target requires a UNIQUE BTree index on {table_name:?}"
14306                ))
14307            })?;
14308        return Ok(alloc::vec![pos]);
14309    }
14310    let mut out = Vec::with_capacity(target.len());
14311    for name in target {
14312        let pos = table
14313            .schema()
14314            .columns
14315            .iter()
14316            .position(|c| c.name == *name)
14317            .ok_or_else(|| {
14318                EngineError::Unsupported(alloc::format!(
14319                    "ON CONFLICT target column {name:?} not found on {table_name:?}"
14320                ))
14321            })?;
14322        out.push(pos);
14323    }
14324    Ok(out)
14325}
14326
14327/// v7.9.8 — check whether the BTree index on `column_pos` of
14328/// `table_name` already has a row with this key.
14329fn on_conflict_key_exists(
14330    catalog: &Catalog,
14331    table_name: &str,
14332    column_pos: usize,
14333    key: &Value,
14334) -> bool {
14335    let Some(table) = catalog.get(table_name) else {
14336        return false;
14337    };
14338    let Some(idx_key) = spg_storage::IndexKey::from_value(key) else {
14339        return false;
14340    };
14341    table.indices().iter().any(|idx| {
14342        matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14343            && idx.column_position == column_pos
14344            && idx.partial_predicate.is_none()
14345            && !idx.lookup_eq(&idx_key).is_empty()
14346    })
14347}
14348
14349/// v7.9.9 / v7.9.10 — look up an existing row's position by
14350/// matching all `column_positions` against the incoming `key`
14351/// tuple. Single-column shape (one column) reduces to the
14352/// canonical PK lookup; composite shapes scan linearly until
14353/// every position matches.
14354fn lookup_row_position_by_keys(
14355    catalog: &Catalog,
14356    table_name: &str,
14357    column_positions: &[usize],
14358    key: &[&Value],
14359) -> Option<usize> {
14360    let table = catalog.get(table_name)?;
14361    table.rows().iter().position(|r| {
14362        column_positions
14363            .iter()
14364            .enumerate()
14365            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
14366    })
14367}
14368
14369/// v7.9.10 — does the table already contain a row whose
14370/// `column_positions` tuple equals `key`? Single-column shape
14371/// uses the existing BTree fast path; composite shapes fall
14372/// back to a row scan.
14373fn on_conflict_keys_exist(
14374    catalog: &Catalog,
14375    table_name: &str,
14376    column_positions: &[usize],
14377    key: &[&Value],
14378) -> bool {
14379    if column_positions.len() == 1 {
14380        return on_conflict_key_exists(catalog, table_name, column_positions[0], key[0]);
14381    }
14382    let Some(table) = catalog.get(table_name) else {
14383        return false;
14384    };
14385    table.rows().iter().any(|r| {
14386        column_positions
14387            .iter()
14388            .enumerate()
14389            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
14390    })
14391}
14392
14393/// v7.9.9 — apply ON CONFLICT DO UPDATE SET assignments to an
14394/// existing row.
14395///
14396/// `incoming` is the rejected INSERT row (used to resolve
14397/// `EXCLUDED.col` references in the assignment exprs);
14398/// `target_pos` is the position of the existing row in the table.
14399/// Each assignment substitutes `EXCLUDED.col` with the matching
14400/// incoming value, evaluates the resulting expression against
14401/// the existing row, and writes the new value into the
14402/// corresponding column of the returned `Vec<Value>`. If
14403/// `where_` evaluates falsy, returns Ok(None) — PG behaviour:
14404/// the conflicting row is silently kept unchanged.
14405fn apply_on_conflict_assignments(
14406    catalog: &Catalog,
14407    table_name: &str,
14408    target_pos: usize,
14409    incoming: &[Value],
14410    assignments: &[(String, Expr)],
14411    where_: Option<&Expr>,
14412) -> Result<Option<Vec<Value>>, EngineError> {
14413    let table = catalog.get(table_name).ok_or_else(|| {
14414        EngineError::Storage(StorageError::TableNotFound {
14415            name: table_name.into(),
14416        })
14417    })?;
14418    let schema_cols = table.schema().columns.clone();
14419    let existing = table
14420        .rows()
14421        .get(target_pos)
14422        .ok_or_else(|| {
14423            EngineError::Unsupported(alloc::format!(
14424                "ON CONFLICT DO UPDATE: row position {target_pos} out of bounds on {table_name:?}"
14425            ))
14426        })?
14427        .clone();
14428    let ctx = eval::EvalContext::new(&schema_cols, Some(table_name));
14429    // Optional WHERE filter on the conflict row.
14430    if let Some(w) = where_ {
14431        let pred = w.clone();
14432        let pred = substitute_excluded_refs(pred, &schema_cols, incoming);
14433        let v = eval::eval_expr(&pred, &existing, &ctx)?;
14434        if !matches!(v, Value::Bool(true)) {
14435            return Ok(None);
14436        }
14437    }
14438    let mut new_values = existing.values.clone();
14439    for (col_name, expr) in assignments {
14440        let target_idx = schema_cols
14441            .iter()
14442            .position(|c| c.name == *col_name)
14443            .ok_or_else(|| {
14444                EngineError::Eval(EvalError::ColumnNotFound {
14445                    name: col_name.clone(),
14446                })
14447            })?;
14448        let sub = substitute_excluded_refs(expr.clone(), &schema_cols, incoming);
14449        let v = eval::eval_expr(&sub, &existing, &ctx)?;
14450        let coerced = coerce_value(v, schema_cols[target_idx].ty, col_name, target_idx)?;
14451        check_unsigned_range(&coerced, &schema_cols[target_idx], target_idx)?;
14452        new_values[target_idx] = coerced;
14453    }
14454    Ok(Some(new_values))
14455}
14456
14457/// v7.9.9 — walk an `Expr` tree replacing any `Column { qualifier:
14458/// "EXCLUDED", name }` reference with a `Literal` of the matching
14459/// value from the incoming-row vec. Resolution against the
14460/// child-table column list (by name).
14461fn substitute_excluded_refs(expr: Expr, schema_cols: &[ColumnSchema], incoming: &[Value]) -> Expr {
14462    use spg_sql::ast::ColumnName;
14463    match expr {
14464        Expr::Column(ColumnName { qualifier, name })
14465            if qualifier
14466                .as_deref()
14467                .is_some_and(|q| q.eq_ignore_ascii_case("excluded")) =>
14468        {
14469            let pos = schema_cols.iter().position(|c| c.name == name);
14470            match pos {
14471                Some(p) => {
14472                    let v = incoming.get(p).cloned().unwrap_or(Value::Null);
14473                    value_to_literal_expr(v)
14474                        .unwrap_or_else(|_| Expr::Literal(spg_sql::ast::Literal::Null))
14475                }
14476                None => Expr::Column(ColumnName { qualifier, name }),
14477            }
14478        }
14479        Expr::Binary { op, lhs, rhs } => Expr::Binary {
14480            op,
14481            lhs: Box::new(substitute_excluded_refs(*lhs, schema_cols, incoming)),
14482            rhs: Box::new(substitute_excluded_refs(*rhs, schema_cols, incoming)),
14483        },
14484        Expr::Unary { op, expr } => Expr::Unary {
14485            op,
14486            expr: Box::new(substitute_excluded_refs(*expr, schema_cols, incoming)),
14487        },
14488        Expr::FunctionCall { name, args } => Expr::FunctionCall {
14489            name,
14490            args: args
14491                .into_iter()
14492                .map(|a| substitute_excluded_refs(a, schema_cols, incoming))
14493                .collect(),
14494        },
14495        other => other,
14496    }
14497}
14498
14499/// v7.6.2 / v7.6.7 — INSERT-side FK enforcement. For every row
14500/// about to be inserted into `child_table`, every FK declared on
14501/// that table is checked: the row's FK columns must either be
14502/// NULL (SQL spec skip) or match an existing parent row via the
14503/// parent's BTree PK / UNIQUE index.
14504///
14505/// Returns `EngineError::Unsupported` with a `FOREIGN KEY violation`
14506/// payload on first failure.
14507///
14508/// **Self-referencing FKs (v7.6.7 widening):** when `fk.parent_table
14509/// == child_table`, the parent rows visible to this check are
14510///  (a) rows already committed to the table, plus
14511///  (b) earlier rows from the *same* `rows` batch.
14512/// This makes `INSERT INTO tree VALUES (1, NULL), (2, 1), (3, 2)`
14513/// work in a single statement — common pattern for bulk-loading
14514/// hierarchies.
14515/// v7.9.19 — enforce table-level UNIQUE / PRIMARY KEY tuple
14516/// constraints at INSERT time. For each constraint declared on
14517/// the target table, check that no existing row + no earlier row
14518/// in the same batch has the same full-column tuple. NULL in
14519/// any column lifts the row out of the check (SQL spec: NULL
14520/// ≠ NULL for uniqueness). mailrs G1 + G6.
14521fn enforce_uniqueness_inserts(
14522    catalog: &Catalog,
14523    child_table: &str,
14524    constraints: &[spg_storage::UniquenessConstraint],
14525    rows: &[Vec<Value>],
14526) -> Result<(), EngineError> {
14527    if constraints.is_empty() {
14528        return Ok(());
14529    }
14530    let table = catalog.get(child_table).ok_or_else(|| {
14531        EngineError::Storage(StorageError::TableNotFound {
14532            name: child_table.into(),
14533        })
14534    })?;
14535    let schema = table.schema();
14536    for uc in constraints {
14537        for (batch_idx, row_values) in rows.iter().enumerate() {
14538            // v7.17.0 Phase 3.P0-45 — fold each key cell by its
14539            // column's declared Collation before comparing. Phase
14540            // 2.5b wired Collation into GROUP BY / ORDER BY / `=`
14541            // but the UNIQUE-constraint enforcement still compared
14542            // Text byte-wise; a `*_ci` column would let
14543            // `('Foo')` and `('FOO')` coexist when MySQL would
14544            // reject the second. Owned Values so the fold and
14545            // the borrow live in the same scope.
14546            let key: Vec<Value> = uc
14547                .columns
14548                .iter()
14549                .map(|&i| collated_key_cell(&row_values[i], i, schema))
14550                .collect();
14551            let has_null = key.iter().any(|v| matches!(v, Value::Null));
14552            // v7.13.0 — `NULLS NOT DISTINCT` (mailrs round-5 G10,
14553            // PG 15+): two rows whose constrained columns are all
14554            // NULL collide. SQL-standard `NULLS DISTINCT` lets any
14555            // NULL skip the check.
14556            if has_null && !uc.nulls_not_distinct {
14557                continue;
14558            }
14559            // Table-side collision: scan existing rows.
14560            let collides_in_table = table.rows().iter().any(|prow| {
14561                uc.columns.iter().enumerate().all(|(i, &p)| {
14562                    prow.values
14563                        .get(p)
14564                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
14565                })
14566            });
14567            // Batch-side collision: earlier rows in the same INSERT.
14568            let collides_in_batch = rows[..batch_idx].iter().any(|earlier| {
14569                uc.columns.iter().enumerate().all(|(i, &p)| {
14570                    earlier
14571                        .get(p)
14572                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
14573                })
14574            });
14575            if collides_in_table || collides_in_batch {
14576                let kind = if uc.is_primary_key {
14577                    "PRIMARY KEY"
14578                } else {
14579                    "UNIQUE"
14580                };
14581                let col_names: Vec<String> = uc
14582                    .columns
14583                    .iter()
14584                    .map(|&i| table.schema().columns[i].name.clone())
14585                    .collect();
14586                return Err(EngineError::Unsupported(alloc::format!(
14587                    "{kind} violation on {child_table:?} columns {col_names:?}: \
14588                     row #{batch_idx} duplicates an existing key"
14589                )));
14590            }
14591        }
14592    }
14593    Ok(())
14594}
14595
14596/// v7.17.0 Phase 3.P0-45 — return a key cell folded by its column's
14597/// declared `Collation`. For `CaseInsensitive`, fold Text payloads to
14598/// ASCII lowercase (matches Phase 2.5's `*_ci` semantics: ASCII case-
14599/// fold only, non-ASCII bytes stay byte-wise). For `Binary` or non-Text
14600/// values, the cell passes through unchanged. The caller compares the
14601/// folded values with `==`.
14602fn collated_key_cell(
14603    v: &spg_storage::Value,
14604    column_position: usize,
14605    schema: &spg_storage::TableSchema,
14606) -> spg_storage::Value {
14607    match (v, schema.columns.get(column_position).map(|c| c.collation)) {
14608        (spg_storage::Value::Text(s), Some(spg_storage::Collation::CaseInsensitive)) => {
14609            spg_storage::Value::Text(s.to_ascii_lowercase())
14610        }
14611        _ => v.clone(),
14612    }
14613}
14614
14615/// v7.9.29 — `true` iff `v` counts as a truthy SQL value for a
14616/// WHERE-style predicate. NULL → false (three-valued logic
14617/// collapses to "skip this row" for index inclusion). Numeric
14618/// non-zero, BIGINT non-zero, TINYINT non-zero, BOOLEAN true → true.
14619/// Everything else (strings, vectors, JSON, …) is not a valid
14620/// predicate result and surfaces as `false` so a malformed
14621/// predicate degrades to "row not in index" rather than panicking.
14622fn predicate_truthy(v: &spg_storage::Value) -> bool {
14623    use spg_storage::Value as V;
14624    match v {
14625        V::Bool(b) => *b,
14626        V::Int(n) => *n != 0,
14627        V::BigInt(n) => *n != 0,
14628        V::SmallInt(n) => *n != 0,
14629        _ => false,
14630    }
14631}
14632
14633/// v7.9.29 — at CREATE UNIQUE INDEX time, scan the table's
14634/// committed rows for pre-existing duplicates. If any pair of rows
14635/// matches the predicate AND has the same index key, refuse to
14636/// create the index so the user fixes the data before retrying.
14637fn check_existing_unique_violation(
14638    idx: &spg_storage::Index,
14639    schema: &spg_storage::TableSchema,
14640    rows: &[spg_storage::Row],
14641) -> Result<(), EngineError> {
14642    let predicate_expr = match idx.partial_predicate.as_deref() {
14643        Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
14644            EngineError::Unsupported(alloc::format!(
14645                "stored partial predicate {s:?} failed to re-parse: {e:?}"
14646            ))
14647        })?),
14648        None => None,
14649    };
14650    let ctx = eval::EvalContext::new(&schema.columns, None);
14651    let key_positions = unique_key_positions(idx);
14652    let mut seen: alloc::vec::Vec<alloc::vec::Vec<spg_storage::Value>> = alloc::vec::Vec::new();
14653    for row in rows {
14654        if let Some(expr) = &predicate_expr {
14655            let v = eval::eval_expr(expr, row, &ctx).map_err(|e| {
14656                EngineError::Unsupported(alloc::format!(
14657                    "evaluating UNIQUE INDEX predicate against existing row: {e:?}"
14658                ))
14659            })?;
14660            if !predicate_truthy(&v) {
14661                continue;
14662            }
14663        }
14664        let key: alloc::vec::Vec<spg_storage::Value> = key_positions
14665            .iter()
14666            .map(|&p| {
14667                let v = row
14668                    .values
14669                    .get(p)
14670                    .cloned()
14671                    .unwrap_or(spg_storage::Value::Null);
14672                collated_key_cell(&v, p, schema)
14673            })
14674            .collect();
14675        if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
14676            continue;
14677        }
14678        if seen.iter().any(|other| *other == key) {
14679            return Err(EngineError::Unsupported(alloc::format!(
14680                "CREATE UNIQUE INDEX {:?}: existing rows already violate the constraint",
14681                idx.name
14682            )));
14683        }
14684        seen.push(key);
14685    }
14686    Ok(())
14687}
14688
14689/// v7.9.29 — full key tuple for a UNIQUE INDEX (leading +
14690/// extra positions). For single-column indexes this is just
14691/// `[column_position]`.
14692fn unique_key_positions(idx: &spg_storage::Index) -> alloc::vec::Vec<usize> {
14693    let mut out = alloc::vec::Vec::with_capacity(1 + idx.extra_column_positions.len());
14694    out.push(idx.column_position);
14695    out.extend_from_slice(&idx.extra_column_positions);
14696    out
14697}
14698
14699/// v7.9.29 — at INSERT time, walk every `is_unique` index on the
14700/// target table. For each, eval the index's optional predicate
14701/// against (a) the candidate row and (b) every committed row plus
14702/// earlier batch rows; only rows where the predicate is truthy
14703/// participate. A duplicate key among predicate-matching rows is a
14704/// uniqueness violation. NULL keys lift the row out of the check
14705/// (matching PG's "UNIQUE allows multiple NULLs" semantics).
14706fn enforce_unique_index_inserts(
14707    catalog: &Catalog,
14708    table_name: &str,
14709    rows: &[alloc::vec::Vec<spg_storage::Value>],
14710) -> Result<(), EngineError> {
14711    let table = catalog.get(table_name).ok_or_else(|| {
14712        EngineError::Storage(StorageError::TableNotFound {
14713            name: table_name.into(),
14714        })
14715    })?;
14716    let schema = table.schema();
14717    let ctx = eval::EvalContext::new(&schema.columns, None);
14718    for idx in table.indices() {
14719        if !idx.is_unique {
14720            continue;
14721        }
14722        // Re-parse the predicate once per index per batch.
14723        let predicate_expr = match idx.partial_predicate.as_deref() {
14724            Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
14725                EngineError::Unsupported(alloc::format!(
14726                    "UNIQUE INDEX {:?} predicate {s:?} failed to re-parse: {e:?}",
14727                    idx.name
14728                ))
14729            })?),
14730            None => None,
14731        };
14732        let key_positions = unique_key_positions(idx);
14733        let key_of = |values: &[spg_storage::Value]| -> alloc::vec::Vec<spg_storage::Value> {
14734            // v7.17.0 Phase 3.P0-45 — fold per-column collation
14735            // before building the comparison key so a `*_ci`
14736            // column treats `'Foo'` and `'FOO'` as equal.
14737            key_positions
14738                .iter()
14739                .map(|&p| {
14740                    let v = values.get(p).cloned().unwrap_or(spg_storage::Value::Null);
14741                    collated_key_cell(&v, p, schema)
14742                })
14743                .collect()
14744        };
14745        // Helper: does `values` participate in this index? (predicate
14746        // truthy when present.) Wraps `values` into a transient Row
14747        // because eval_expr requires &Row.
14748        let participates = |values: &[spg_storage::Value]| -> Result<bool, EngineError> {
14749            let Some(expr) = &predicate_expr else {
14750                return Ok(true);
14751            };
14752            let tmp_row = spg_storage::Row {
14753                values: values.to_vec(),
14754            };
14755            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
14756                EngineError::Unsupported(alloc::format!(
14757                    "UNIQUE INDEX {:?} predicate eval: {e:?}",
14758                    idx.name
14759                ))
14760            })?;
14761            Ok(predicate_truthy(&v))
14762        };
14763        for (batch_idx, row_values) in rows.iter().enumerate() {
14764            if !participates(row_values)? {
14765                continue;
14766            }
14767            let key = key_of(row_values);
14768            if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
14769                continue;
14770            }
14771            // Committed-table collision.
14772            for prow in table.rows() {
14773                if !participates(&prow.values)? {
14774                    continue;
14775                }
14776                if key_of(&prow.values) == key {
14777                    return Err(EngineError::Unsupported(alloc::format!(
14778                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
14779                         row #{batch_idx} duplicates an existing key",
14780                        idx.name
14781                    )));
14782                }
14783            }
14784            // Within-batch collision: earlier rows in the same INSERT.
14785            for earlier in &rows[..batch_idx] {
14786                if !participates(earlier)? {
14787                    continue;
14788                }
14789                if key_of(earlier) == key {
14790                    return Err(EngineError::Unsupported(alloc::format!(
14791                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
14792                         row #{batch_idx} duplicates an earlier row in the same batch",
14793                        idx.name
14794                    )));
14795                }
14796            }
14797        }
14798    }
14799    Ok(())
14800}
14801
14802/// v7.13.0 — `UPDATE OF cols` filter helper (mailrs round-5 G7).
14803/// Returns `true` when at least one of `filter_cols` has a
14804/// different value in `new_row` vs `old_row`. Column lookup is
14805/// case-insensitive against `schema_cols`; unknown filter columns
14806/// are treated as "not changed" (the trigger therefore won't
14807/// fire on them — surfacing a parse-time error would be too
14808/// strict for catalog reloads where the schema may have drifted).
14809fn any_column_changed(
14810    filter_cols: &[String],
14811    schema_cols: &[ColumnSchema],
14812    old_row: &Row,
14813    new_row: &Row,
14814) -> bool {
14815    for col_name in filter_cols {
14816        let Some(pos) = schema_cols
14817            .iter()
14818            .position(|c| c.name.eq_ignore_ascii_case(col_name))
14819        else {
14820            continue;
14821        };
14822        let old_v = old_row.values.get(pos);
14823        let new_v = new_row.values.get(pos);
14824        if old_v != new_v {
14825            return true;
14826        }
14827    }
14828    false
14829}
14830
14831/// v7.13.0 — evaluate every CHECK predicate on the schema against
14832/// each candidate row. Mirrors PG semantics: a `false` result
14833/// rejects the mutation; a NULL result *passes* (CHECK rejects
14834/// only on definite-false, not on unknown). mailrs round-5 G3.
14835fn enforce_check_constraints(
14836    catalog: &Catalog,
14837    table_name: &str,
14838    rows: &[alloc::vec::Vec<spg_storage::Value>],
14839) -> Result<(), EngineError> {
14840    let table = catalog.get(table_name).ok_or_else(|| {
14841        EngineError::Storage(StorageError::TableNotFound {
14842            name: table_name.into(),
14843        })
14844    })?;
14845    let schema = table.schema();
14846    // v7.17.0 Phase 1.5 — domain-level CHECKs are enforced in
14847    // parallel with table-level CHECKs. Collect both lists up
14848    // front; if neither exists we early-out.
14849    let mut domain_checks_per_col: alloc::vec::Vec<(usize, alloc::vec::Vec<Expr>)> =
14850        alloc::vec::Vec::new();
14851    for (idx, col) in schema.columns.iter().enumerate() {
14852        let Some(dname) = &col.user_domain_type else {
14853            continue;
14854        };
14855        let Some(dom) = catalog.domain_types().get(dname) else {
14856            continue;
14857        };
14858        let mut parsed_for_col: alloc::vec::Vec<Expr> =
14859            alloc::vec::Vec::with_capacity(dom.checks.len());
14860        for src in &dom.checks {
14861            let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
14862                EngineError::Unsupported(alloc::format!(
14863                    "DOMAIN {dname:?} CHECK ({src:?}) on column {:?}: re-parse failed: {e:?}",
14864                    col.name
14865                ))
14866            })?;
14867            parsed_for_col.push(expr);
14868        }
14869        if !parsed_for_col.is_empty() {
14870            domain_checks_per_col.push((idx, parsed_for_col));
14871        }
14872    }
14873    if schema.checks.is_empty() && domain_checks_per_col.is_empty() {
14874        return Ok(());
14875    }
14876    let ctx = eval::EvalContext::new(&schema.columns, None);
14877    let mut parsed: alloc::vec::Vec<(usize, Expr)> = alloc::vec::Vec::new();
14878    for (i, src) in schema.checks.iter().enumerate() {
14879        let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
14880            EngineError::Unsupported(alloc::format!(
14881                "CHECK constraint #{i} on {table_name:?} ({src:?}) failed to re-parse: {e:?}"
14882            ))
14883        })?;
14884        parsed.push((i, expr));
14885    }
14886    for (batch_idx, row_values) in rows.iter().enumerate() {
14887        let tmp_row = spg_storage::Row {
14888            values: row_values.clone(),
14889        };
14890        for (i, expr) in &parsed {
14891            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
14892                EngineError::Unsupported(alloc::format!(
14893                    "CHECK constraint #{i} on {table_name:?} eval at row #{batch_idx}: {e:?}"
14894                ))
14895            })?;
14896            // PG: NULL passes (CHECK rejects on definite-false only).
14897            if matches!(v, spg_storage::Value::Bool(false)) {
14898                return Err(EngineError::Unsupported(alloc::format!(
14899                    "CHECK constraint violation on {table_name:?} (row #{batch_idx}): {:?}",
14900                    schema.checks[*i]
14901                )));
14902            }
14903        }
14904        // v7.17.0 Phase 1.5 — domain-level CHECKs. Each CHECK
14905        // expression references VALUE as a column-name; we
14906        // substitute the per-row cell into the eval context by
14907        // synthesising a single-column row of just that value
14908        // under a temporary `value` column schema.
14909        for (col_idx, checks) in &domain_checks_per_col {
14910            let cell = row_values
14911                .get(*col_idx)
14912                .cloned()
14913                .unwrap_or(spg_storage::Value::Null);
14914            let synth_cols = alloc::vec![spg_storage::ColumnSchema::new(
14915                "value",
14916                schema.columns[*col_idx].ty,
14917                schema.columns[*col_idx].nullable,
14918            )];
14919            let synth_ctx = eval::EvalContext::new(&synth_cols, None);
14920            let synth_row = spg_storage::Row {
14921                values: alloc::vec![cell],
14922            };
14923            for (ci, expr) in checks.iter().enumerate() {
14924                let v = eval::eval_expr(expr, &synth_row, &synth_ctx).map_err(|e| {
14925                    EngineError::Unsupported(alloc::format!(
14926                        "DOMAIN CHECK #{ci} on column {:?} eval at row #{batch_idx}: {e:?}",
14927                        schema.columns[*col_idx].name
14928                    ))
14929                })?;
14930                if matches!(v, spg_storage::Value::Bool(false)) {
14931                    return Err(EngineError::Unsupported(alloc::format!(
14932                        "DOMAIN CHECK violation on column {:?} (row #{batch_idx})",
14933                        schema.columns[*col_idx].name
14934                    )));
14935                }
14936            }
14937        }
14938    }
14939    Ok(())
14940}
14941
14942fn enforce_fk_inserts(
14943    catalog: &Catalog,
14944    child_table: &str,
14945    fks: &[spg_storage::ForeignKeyConstraint],
14946    rows: &[Vec<Value>],
14947) -> Result<(), EngineError> {
14948    for fk in fks {
14949        let parent_is_self = fk.parent_table == child_table;
14950        let parent = if parent_is_self {
14951            // Self-ref: read the current state of the same table.
14952            // The mut borrow on child has been dropped by the caller.
14953            catalog.get(child_table).ok_or_else(|| {
14954                EngineError::Storage(StorageError::TableNotFound {
14955                    name: child_table.into(),
14956                })
14957            })?
14958        } else {
14959            catalog.get(&fk.parent_table).ok_or_else(|| {
14960                EngineError::Storage(StorageError::TableNotFound {
14961                    name: fk.parent_table.clone(),
14962                })
14963            })?
14964        };
14965        for (batch_idx, row_values) in rows.iter().enumerate() {
14966            // Single-column FK fast path: try the parent's BTree
14967            // index for an O(log n) lookup. Composite FKs fall back
14968            // to a parent-row scan.
14969            if fk.local_columns.len() == 1 {
14970                let v = &row_values[fk.local_columns[0]];
14971                if matches!(v, Value::Null) {
14972                    continue;
14973                }
14974                let parent_col = fk.parent_columns[0];
14975                let key = spg_storage::IndexKey::from_value(v).ok_or_else(|| {
14976                    EngineError::Unsupported(alloc::format!(
14977                        "FOREIGN KEY column value of type {:?} is not index-eligible",
14978                        v.data_type()
14979                    ))
14980                })?;
14981                let present_committed = parent.indices().iter().any(|idx| {
14982                    matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14983                        && idx.column_position == parent_col
14984                        && idx.partial_predicate.is_none()
14985                        && !idx.lookup_eq(&key).is_empty()
14986                });
14987                // v7.6.7 self-ref widening: also accept a match
14988                // against earlier rows in this same batch when the
14989                // FK points at the table being inserted into.
14990                let present_in_batch = parent_is_self
14991                    && rows[..batch_idx]
14992                        .iter()
14993                        .any(|earlier| earlier.get(parent_col) == Some(v));
14994                if !(present_committed || present_in_batch) {
14995                    return Err(EngineError::Unsupported(alloc::format!(
14996                        "FOREIGN KEY violation: no parent row in {:?} where {} = {:?}",
14997                        fk.parent_table,
14998                        parent
14999                            .schema()
15000                            .columns
15001                            .get(parent_col)
15002                            .map_or("?", |c| c.name.as_str()),
15003                        v,
15004                    )));
15005                }
15006            } else {
15007                // Composite FK: scan parent rows. v7.6.7 also
15008                // accepts a match against earlier rows in the same
15009                // batch (self-ref bulk-loading of hierarchies).
15010                if fk
15011                    .local_columns
15012                    .iter()
15013                    .all(|&i| matches!(row_values.get(i), Some(Value::Null)))
15014                {
15015                    continue;
15016                }
15017                let local: Vec<&Value> = fk.local_columns.iter().map(|&i| &row_values[i]).collect();
15018                let parent_match_committed = parent.rows().iter().any(|prow| {
15019                    fk.parent_columns
15020                        .iter()
15021                        .enumerate()
15022                        .all(|(i, &pi)| prow.values.get(pi) == Some(local[i]))
15023                });
15024                let parent_match_in_batch = parent_is_self
15025                    && rows[..batch_idx].iter().any(|earlier| {
15026                        fk.parent_columns
15027                            .iter()
15028                            .enumerate()
15029                            .all(|(i, &pi)| earlier.get(pi) == Some(local[i]))
15030                    });
15031                if !(parent_match_committed || parent_match_in_batch) {
15032                    return Err(EngineError::Unsupported(alloc::format!(
15033                        "FOREIGN KEY violation: no parent row in {:?} matching composite key",
15034                        fk.parent_table,
15035                    )));
15036                }
15037            }
15038        }
15039    }
15040    Ok(())
15041}
15042
15043/// v7.6.4 / v7.6.5 — one step of the FK action plan computed for a
15044/// DELETE on a parent. The plan is a list of these steps, stacked
15045/// across the FK graph by `plan_fk_parent_deletions`.
15046#[derive(Debug, Clone)]
15047struct FkChildStep {
15048    child_table: String,
15049    action: FkChildAction,
15050}
15051
15052#[derive(Debug, Clone)]
15053enum FkChildAction {
15054    /// CASCADE — remove these rows. Sorted, deduplicated positions.
15055    Delete { positions: Vec<usize> },
15056    /// SET NULL — for each (row, column) in the flat list, write
15057    /// NULL into that child cell. Multiple FKs on the same row may
15058    /// produce overlapping entries (deduped at plan time).
15059    SetNull {
15060        positions: Vec<usize>,
15061        columns: Vec<usize>,
15062    },
15063    /// SET DEFAULT — same shape as SetNull but writes the column's
15064    /// declared DEFAULT value (resolved at plan time). Columns
15065    /// without a DEFAULT raise an error during planning.
15066    SetDefault {
15067        positions: Vec<usize>,
15068        columns: Vec<usize>,
15069        defaults: Vec<Value>,
15070    },
15071}
15072
15073/// v7.6.3 → v7.6.5 — plan FK fallout for a DELETE on a parent table.
15074///
15075/// Walks every table in the catalog looking for FKs whose
15076/// `parent_table` is `parent_table_name`. For each such FK + each
15077/// to-be-deleted parent row:
15078///
15079///   - RESTRICT / NoAction → error, no plan returned
15080///   - CASCADE → child rows get scheduled for deletion; recursive
15081///   - SetNull → child FK column(s) scheduled to be NULL-ed.
15082///     Verified NULL-able at plan time.
15083///   - SetDefault → child FK column(s) scheduled to be reset to
15084///     their declared DEFAULT. Columns without a DEFAULT raise.
15085///
15086/// SET NULL / SET DEFAULT do NOT cascade further — the child row
15087/// stays; only one of its columns mutates.
15088fn plan_fk_parent_deletions(
15089    catalog: &Catalog,
15090    parent_table_name: &str,
15091    to_delete_positions: &[usize],
15092    to_delete_rows: &[Vec<Value>],
15093) -> Result<Vec<FkChildStep>, EngineError> {
15094    use alloc::collections::{BTreeMap, BTreeSet};
15095    if to_delete_rows.is_empty() {
15096        return Ok(Vec::new());
15097    }
15098    let mut delete_plan: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
15099    // setnull / setdefault keyed by child_table → (row_idx, col_idx) → optional default
15100    let mut setnull_plan: BTreeMap<String, BTreeSet<(usize, usize)>> = BTreeMap::new();
15101    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15102    let mut visited: BTreeSet<(String, usize)> = BTreeSet::new();
15103    for &p in to_delete_positions {
15104        visited.insert((parent_table_name.to_string(), p));
15105    }
15106    let mut work: Vec<(String, Vec<Value>)> = to_delete_rows
15107        .iter()
15108        .map(|r| (parent_table_name.to_string(), r.clone()))
15109        .collect();
15110    while let Some((cur_parent, parent_row)) = work.pop() {
15111        for child_name in catalog.table_names() {
15112            let child = catalog
15113                .get(&child_name)
15114                .expect("table_names → catalog.get round-trip is total");
15115            for fk in &child.schema().foreign_keys {
15116                if fk.parent_table != cur_parent {
15117                    continue;
15118                }
15119                let parent_key: Vec<&Value> = fk
15120                    .parent_columns
15121                    .iter()
15122                    .map(|&pi| &parent_row[pi])
15123                    .collect();
15124                if parent_key.iter().any(|v| matches!(v, Value::Null)) {
15125                    continue;
15126                }
15127                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
15128                    if child_name == cur_parent
15129                        && visited.contains(&(child_name.clone(), child_row_idx))
15130                    {
15131                        continue;
15132                    }
15133                    let matches_key = fk
15134                        .local_columns
15135                        .iter()
15136                        .enumerate()
15137                        .all(|(i, &li)| child_row.values.get(li) == Some(parent_key[i]));
15138                    if !matches_key {
15139                        continue;
15140                    }
15141                    match fk.on_delete {
15142                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
15143                            return Err(EngineError::Unsupported(alloc::format!(
15144                                "FOREIGN KEY violation: DELETE on {cur_parent:?} is \
15145                                 restricted by FK from {child_name:?}.{:?}",
15146                                fk.local_columns,
15147                            )));
15148                        }
15149                        spg_storage::FkAction::Cascade => {
15150                            if visited.insert((child_name.clone(), child_row_idx)) {
15151                                delete_plan
15152                                    .entry(child_name.clone())
15153                                    .or_default()
15154                                    .insert(child_row_idx);
15155                                work.push((child_name.clone(), child_row.values.clone()));
15156                            }
15157                        }
15158                        spg_storage::FkAction::SetNull => {
15159                            // Verify every local FK column is NULL-able.
15160                            for &li in &fk.local_columns {
15161                                let col = child.schema().columns.get(li).ok_or_else(|| {
15162                                    EngineError::Unsupported(alloc::format!(
15163                                        "FK local column {li} missing in {child_name:?}"
15164                                    ))
15165                                })?;
15166                                if !col.nullable {
15167                                    return Err(EngineError::Unsupported(alloc::format!(
15168                                        "FOREIGN KEY ON DELETE SET NULL: column \
15169                                         {child_name:?}.{:?} is NOT NULL — cannot SET NULL",
15170                                        col.name,
15171                                    )));
15172                                }
15173                            }
15174                            let entry = setnull_plan.entry(child_name.clone()).or_default();
15175                            for &li in &fk.local_columns {
15176                                entry.insert((child_row_idx, li));
15177                            }
15178                        }
15179                        spg_storage::FkAction::SetDefault => {
15180                            // Resolve the DEFAULT for every local FK col.
15181                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
15182                            for &li in &fk.local_columns {
15183                                let col = child.schema().columns.get(li).ok_or_else(|| {
15184                                    EngineError::Unsupported(alloc::format!(
15185                                        "FK local column {li} missing in {child_name:?}"
15186                                    ))
15187                                })?;
15188                                let default = col.default.clone().ok_or_else(|| {
15189                                    EngineError::Unsupported(alloc::format!(
15190                                        "FOREIGN KEY ON DELETE SET DEFAULT: column \
15191                                         {child_name:?}.{:?} has no DEFAULT declared",
15192                                        col.name,
15193                                    ))
15194                                })?;
15195                                entry.insert((child_row_idx, li), default);
15196                            }
15197                        }
15198                    }
15199                }
15200            }
15201        }
15202    }
15203    // Flatten the three plans into the ordered `FkChildStep` list.
15204    // Deletes are applied last per child (after any null/default
15205    // re-writes on the same child) so a child row that's both
15206    // re-written and then cascade-deleted only ends up deleted —
15207    // but in v7.6.5 SetNull/Cascade never overlap on the same row
15208    // (a single FK chooses exactly one action), so the order is
15209    // mostly a precaution.
15210    let mut steps: Vec<FkChildStep> = Vec::new();
15211    for (child_table, entries) in setnull_plan {
15212        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
15213        steps.push(FkChildStep {
15214            child_table,
15215            action: FkChildAction::SetNull { positions, columns },
15216        });
15217    }
15218    for (child_table, entries) in setdefault_plan {
15219        let mut positions = Vec::with_capacity(entries.len());
15220        let mut columns = Vec::with_capacity(entries.len());
15221        let mut defaults = Vec::with_capacity(entries.len());
15222        for ((p, c), v) in entries {
15223            positions.push(p);
15224            columns.push(c);
15225            defaults.push(v);
15226        }
15227        steps.push(FkChildStep {
15228            child_table,
15229            action: FkChildAction::SetDefault {
15230                positions,
15231                columns,
15232                defaults,
15233            },
15234        });
15235    }
15236    for (child_table, positions) in delete_plan {
15237        steps.push(FkChildStep {
15238            child_table,
15239            action: FkChildAction::Delete {
15240                positions: positions.into_iter().collect(),
15241            },
15242        });
15243    }
15244    Ok(steps)
15245}
15246
15247/// v7.6.6 — plan FK fallout for an UPDATE that mutates parent-side
15248/// PK/UNIQUE columns. Walks every other table whose FK references
15249/// `parent_table_name`; for each FK whose parent_columns overlap a
15250/// mutated column, decides the action by `fk.on_update`.
15251///
15252///   - RESTRICT / NoAction → error if any child references the OLD
15253///     value
15254///   - CASCADE → child FK columns get rewritten to the NEW parent
15255///     value (a SetNull-style update step with the new value)
15256///   - SetNull → child FK columns set to NULL
15257///   - SetDefault → child FK columns set to declared default
15258///
15259/// `plan_with_old` is `(row_position, old_values, new_values)` so
15260/// the planner can detect "did this row's parent key actually
15261/// change?" — only rows where at least one referenced parent
15262/// column moved trigger inbound work.
15263fn plan_fk_parent_updates(
15264    catalog: &Catalog,
15265    parent_table_name: &str,
15266    plan_with_old: &[(usize, Vec<Value>, Vec<Value>)],
15267) -> Result<Vec<FkChildStep>, EngineError> {
15268    use alloc::collections::BTreeMap;
15269    if plan_with_old.is_empty() {
15270        return Ok(Vec::new());
15271    }
15272    // For each child table we may touch, build per-child step
15273    // lists. UPDATE never deletes children — `delete_plan` stays
15274    // empty here but is kept structurally aligned with
15275    // `plan_fk_parent_deletions` for future use.
15276    let delete_plan: BTreeMap<String, alloc::collections::BTreeSet<usize>> = BTreeMap::new();
15277    let mut setnull_plan: BTreeMap<String, alloc::collections::BTreeSet<(usize, usize)>> =
15278        BTreeMap::new();
15279    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15280    // Cascade-update plan: child_table → row_idx → col_idx → new_value
15281    let mut cascade_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15282
15283    for child_name in catalog.table_names() {
15284        let child = catalog
15285            .get(&child_name)
15286            .expect("table_names → catalog.get total");
15287        for fk in &child.schema().foreign_keys {
15288            if fk.parent_table != parent_table_name {
15289                continue;
15290            }
15291            for (_pos, old_row, new_row) in plan_with_old {
15292                // Did any parent FK column change?
15293                let key_changed = fk
15294                    .parent_columns
15295                    .iter()
15296                    .any(|&pi| old_row.get(pi) != new_row.get(pi));
15297                if !key_changed {
15298                    continue;
15299                }
15300                // The OLD parent key — used to find referring children.
15301                let old_key: Vec<&Value> =
15302                    fk.parent_columns.iter().map(|&pi| &old_row[pi]).collect();
15303                if old_key.iter().any(|v| matches!(v, Value::Null)) {
15304                    // NULL parent has no children — skip.
15305                    continue;
15306                }
15307                let new_key: Vec<&Value> =
15308                    fk.parent_columns.iter().map(|&pi| &new_row[pi]).collect();
15309                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
15310                    // Self-ref same-row updates: a row updating its
15311                    // own PK doesn't restrict itself.
15312                    if child_name == parent_table_name
15313                        && plan_with_old.iter().any(|(p, _, _)| *p == child_row_idx)
15314                    {
15315                        continue;
15316                    }
15317                    let matches_key = fk
15318                        .local_columns
15319                        .iter()
15320                        .enumerate()
15321                        .all(|(i, &li)| child_row.values.get(li) == Some(old_key[i]));
15322                    if !matches_key {
15323                        continue;
15324                    }
15325                    match fk.on_update {
15326                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
15327                            return Err(EngineError::Unsupported(alloc::format!(
15328                                "FOREIGN KEY violation: UPDATE on {parent_table_name:?} PK is \
15329                                 restricted by FK from {child_name:?}.{:?}",
15330                                fk.local_columns,
15331                            )));
15332                        }
15333                        spg_storage::FkAction::Cascade => {
15334                            // Rewrite child FK columns to new key.
15335                            let entry = cascade_plan.entry(child_name.clone()).or_default();
15336                            for (i, &li) in fk.local_columns.iter().enumerate() {
15337                                entry.insert((child_row_idx, li), new_key[i].clone());
15338                            }
15339                        }
15340                        spg_storage::FkAction::SetNull => {
15341                            for &li in &fk.local_columns {
15342                                let col = child.schema().columns.get(li).ok_or_else(|| {
15343                                    EngineError::Unsupported(alloc::format!(
15344                                        "FK local column {li} missing in {child_name:?}"
15345                                    ))
15346                                })?;
15347                                if !col.nullable {
15348                                    return Err(EngineError::Unsupported(alloc::format!(
15349                                        "FOREIGN KEY ON UPDATE SET NULL: column \
15350                                         {child_name:?}.{:?} is NOT NULL",
15351                                        col.name,
15352                                    )));
15353                                }
15354                            }
15355                            let entry = setnull_plan.entry(child_name.clone()).or_default();
15356                            for &li in &fk.local_columns {
15357                                entry.insert((child_row_idx, li));
15358                            }
15359                        }
15360                        spg_storage::FkAction::SetDefault => {
15361                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
15362                            for &li in &fk.local_columns {
15363                                let col = child.schema().columns.get(li).ok_or_else(|| {
15364                                    EngineError::Unsupported(alloc::format!(
15365                                        "FK local column {li} missing in {child_name:?}"
15366                                    ))
15367                                })?;
15368                                let default = col.default.clone().ok_or_else(|| {
15369                                    EngineError::Unsupported(alloc::format!(
15370                                        "FOREIGN KEY ON UPDATE SET DEFAULT: column \
15371                                         {child_name:?}.{:?} has no DEFAULT",
15372                                        col.name,
15373                                    ))
15374                                })?;
15375                                entry.insert((child_row_idx, li), default);
15376                            }
15377                        }
15378                    }
15379                }
15380            }
15381        }
15382    }
15383    // Flatten into FkChildStep list. UPDATE doesn't produce
15384    // DeleteSteps (CASCADE on UPDATE just rewrites FK values).
15385    let mut steps: Vec<FkChildStep> = Vec::new();
15386    for (child_table, entries) in cascade_plan {
15387        let mut positions = Vec::with_capacity(entries.len());
15388        let mut columns = Vec::with_capacity(entries.len());
15389        let mut defaults = Vec::with_capacity(entries.len());
15390        for ((p, c), v) in entries {
15391            positions.push(p);
15392            columns.push(c);
15393            defaults.push(v);
15394        }
15395        // We reuse `FkChildAction::SetDefault` for cascade-update:
15396        // both shapes are "write a known value into specific cells"
15397        // — `apply_per_cell_writes` doesn't care whether the value
15398        // came from a DEFAULT declaration or a new parent key.
15399        steps.push(FkChildStep {
15400            child_table,
15401            action: FkChildAction::SetDefault {
15402                positions,
15403                columns,
15404                defaults,
15405            },
15406        });
15407    }
15408    for (child_table, entries) in setnull_plan {
15409        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
15410        steps.push(FkChildStep {
15411            child_table,
15412            action: FkChildAction::SetNull { positions, columns },
15413        });
15414    }
15415    for (child_table, entries) in setdefault_plan {
15416        let mut positions = Vec::with_capacity(entries.len());
15417        let mut columns = Vec::with_capacity(entries.len());
15418        let mut defaults = Vec::with_capacity(entries.len());
15419        for ((p, c), v) in entries {
15420            positions.push(p);
15421            columns.push(c);
15422            defaults.push(v);
15423        }
15424        steps.push(FkChildStep {
15425            child_table,
15426            action: FkChildAction::SetDefault {
15427                positions,
15428                columns,
15429                defaults,
15430            },
15431        });
15432    }
15433    let _ = delete_plan; // UPDATE never deletes children.
15434    Ok(steps)
15435}
15436
15437/// v7.6.5 — apply one FK child step to the catalog. Encapsulates
15438/// the three action variants so the DELETE executor stays a
15439/// simple loop over the planned steps.
15440fn apply_fk_child_step(catalog: &mut Catalog, step: &FkChildStep) -> Result<(), EngineError> {
15441    let child = catalog.get_mut(&step.child_table).ok_or_else(|| {
15442        EngineError::Storage(StorageError::TableNotFound {
15443            name: step.child_table.clone(),
15444        })
15445    })?;
15446    match &step.action {
15447        FkChildAction::Delete { positions } => {
15448            let _ = child.delete_rows(positions);
15449        }
15450        FkChildAction::SetNull { positions, columns } => {
15451            apply_per_cell_writes(child, positions, columns, |_| Value::Null)?;
15452        }
15453        FkChildAction::SetDefault {
15454            positions,
15455            columns,
15456            defaults,
15457        } => {
15458            apply_per_cell_writes(child, positions, columns, |i| defaults[i].clone())?;
15459        }
15460    }
15461    Ok(())
15462}
15463
15464/// v7.6.5 — write new values into selected child cells via
15465/// `Table::update_row` (the catalog's existing UPDATE entry).
15466/// Groups writes by row position so multi-column updates on the
15467/// same row only call `update_row` once. `value_for(i)` produces
15468/// the new value for the i-th (position, column) entry.
15469fn apply_per_cell_writes(
15470    child: &mut spg_storage::Table,
15471    positions: &[usize],
15472    columns: &[usize],
15473    mut value_for: impl FnMut(usize) -> Value,
15474) -> Result<(), EngineError> {
15475    use alloc::collections::BTreeMap;
15476    let mut by_row: BTreeMap<usize, Vec<(usize, Value)>> = BTreeMap::new();
15477    for i in 0..positions.len() {
15478        by_row
15479            .entry(positions[i])
15480            .or_default()
15481            .push((columns[i], value_for(i)));
15482    }
15483    for (pos, mutations) in by_row {
15484        let mut new_values = child.rows()[pos].values.clone();
15485        for (col, v) in mutations {
15486            if let Some(slot) = new_values.get_mut(col) {
15487                *slot = v;
15488            }
15489        }
15490        child
15491            .update_row(pos, new_values)
15492            .map_err(EngineError::Storage)?;
15493    }
15494    Ok(())
15495}
15496
15497fn fk_action_sql_to_storage(a: spg_sql::ast::FkAction) -> spg_storage::FkAction {
15498    match a {
15499        spg_sql::ast::FkAction::Restrict => spg_storage::FkAction::Restrict,
15500        spg_sql::ast::FkAction::Cascade => spg_storage::FkAction::Cascade,
15501        spg_sql::ast::FkAction::SetNull => spg_storage::FkAction::SetNull,
15502        spg_sql::ast::FkAction::SetDefault => spg_storage::FkAction::SetDefault,
15503        spg_sql::ast::FkAction::NoAction => spg_storage::FkAction::NoAction,
15504    }
15505}
15506
15507/// v7.9.21 — resolve a column's DEFAULT for INSERT-time
15508/// default-fill. Free fn (rather than `&self`) so callers
15509/// with an active `&mut Table` borrow can still use it.
15510/// Literal defaults take the cached path (`col.default`);
15511/// runtime defaults hit `clock_fn` at each call. mailrs G4.
15512fn resolve_column_default_free(
15513    col: &ColumnSchema,
15514    clock_fn: Option<ClockFn>,
15515) -> Result<Value, EngineError> {
15516    if let Some(rt) = &col.runtime_default {
15517        return eval_runtime_default_free(rt, col.ty, clock_fn);
15518    }
15519    Ok(col.default.clone().unwrap_or(Value::Null))
15520}
15521
15522fn eval_runtime_default_free(
15523    rt: &str,
15524    ty: DataType,
15525    clock_fn: Option<ClockFn>,
15526) -> Result<Value, EngineError> {
15527    let s = rt.trim().to_ascii_lowercase();
15528    // v7.17.0 Phase 2.1 — also strip `(N)` precision suffix
15529    // so MySQL `CURRENT_TIMESTAMP(6)` resolves the same as
15530    // bare `CURRENT_TIMESTAMP`. SPG stores TIMESTAMP at fixed
15531    // microsecond resolution; the precision modifier is
15532    // parser-only.
15533    let with_no_parens = s.trim_end_matches("()");
15534    let canonical: &str = if let Some(open_idx) = with_no_parens.find('(') {
15535        if with_no_parens.ends_with(')') {
15536            &with_no_parens[..open_idx]
15537        } else {
15538            with_no_parens
15539        }
15540    } else {
15541        with_no_parens
15542    };
15543    let now_us = match clock_fn {
15544        Some(f) => f(),
15545        None => 0,
15546    };
15547    let v = match canonical {
15548        "now" | "current_timestamp" | "localtimestamp" => Value::Timestamp(now_us),
15549        "current_date" => Value::Date((now_us / 86_400_000_000) as i32),
15550        "current_time" | "localtime" => Value::Timestamp(now_us),
15551        // v7.17.0 — UUID generators in DEFAULT clauses. Required
15552        // for the canonical Django / Rails / Hibernate `id UUID
15553        // PRIMARY KEY DEFAULT gen_random_uuid()` pattern. Each
15554        // INSERT evaluates the function fresh; the per-row UUID
15555        // is the storage value, not a cached literal.
15556        "gen_random_uuid" | "uuid_generate_v4" => Value::Uuid(eval::gen_random_uuid_bytes()),
15557        other => {
15558            return Err(EngineError::Unsupported(alloc::format!(
15559                "runtime DEFAULT expression {other:?} not supported \
15560                 (v7.17.0 whitelist: now() / current_timestamp / \
15561                 current_date / current_time / localtimestamp / \
15562                 localtime / gen_random_uuid() / \
15563                 uuid_generate_v4())"
15564            )));
15565        }
15566    };
15567    coerce_value(v, ty, "DEFAULT", 0)
15568}
15569
15570/// v7.9.21 — true when a DEFAULT expression needs INSERT-time
15571/// evaluation rather than being cacheable as a literal Value.
15572/// FunctionCall is the immediate case (`now()`,
15573/// `current_timestamp`). Literal expressions and simple sign-
15574/// flipped numerics still take the static-cache path.
15575fn is_runtime_default_expr(expr: &Expr) -> bool {
15576    match expr {
15577        Expr::FunctionCall { .. } => true,
15578        Expr::Unary { expr, .. } => is_runtime_default_expr(expr),
15579        _ => false,
15580    }
15581}
15582
15583/// v7.17.0 Phase 1.4 — INSERT/UPDATE-time enum label check. When
15584/// `col_idx` has a registered label list, the cell value must be
15585/// NULL or one of the labels (case-sensitive per PG).
15586/// v7.17.0 Phase 3.P0-37 — validate + canonicalise a MySQL inline
15587/// SET cell. For non-SET columns this is a no-op pass-through.
15588///
15589/// Semantics:
15590///   * NULL preserved.
15591///   * Empty string → `''` (zero flags).
15592///   * Otherwise split on ',', trim each token, validate every
15593///     token against the column's variant list (error on miss),
15594///     de-dup, then re-emit in DEFINITION order joined by ','.
15595fn canonicalize_set_value(
15596    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
15597    col_idx: usize,
15598    col_name: &str,
15599    value: Value,
15600) -> Result<Value, EngineError> {
15601    let Some(variants) = lookup.get(&col_idx) else {
15602        return Ok(value);
15603    };
15604    match value {
15605        Value::Null => Ok(Value::Null),
15606        Value::Text(s) => {
15607            if s.is_empty() {
15608                return Ok(Value::Text(alloc::string::String::new()));
15609            }
15610            // Collect a presence-set of variant indices to keep
15611            // definition order + handle de-dup in one pass.
15612            let mut present = alloc::vec![false; variants.len()];
15613            for raw in s.split(',') {
15614                let tok = raw.trim();
15615                if tok.is_empty() {
15616                    continue;
15617                }
15618                let idx = variants.iter().position(|v| v == tok).ok_or_else(|| {
15619                    EngineError::Unsupported(alloc::format!(
15620                        "column {col_name:?}: invalid SET token {tok:?}; \
15621                         allowed: {variants:?}"
15622                    ))
15623                })?;
15624                present[idx] = true;
15625            }
15626            // Re-emit in definition order.
15627            let mut out = alloc::string::String::new();
15628            let mut first = true;
15629            for (i, keep) in present.iter().enumerate() {
15630                if !keep {
15631                    continue;
15632                }
15633                if !first {
15634                    out.push(',');
15635                }
15636                first = false;
15637                out.push_str(&variants[i]);
15638            }
15639            Ok(Value::Text(out))
15640        }
15641        other => Err(EngineError::Unsupported(alloc::format!(
15642            "column {col_name:?}: SET-typed column expects TEXT, got {:?}",
15643            other.data_type()
15644        ))),
15645    }
15646}
15647
15648fn enforce_enum_label(
15649    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
15650    col_idx: usize,
15651    col_name: &str,
15652    value: &Value,
15653) -> Result<(), EngineError> {
15654    if let Some(labels) = lookup.get(&col_idx) {
15655        match value {
15656            Value::Null => Ok(()),
15657            Value::Text(s) => {
15658                if labels.iter().any(|l| l == s) {
15659                    Ok(())
15660                } else {
15661                    Err(EngineError::Unsupported(alloc::format!(
15662                        "column {col_name:?}: invalid enum label {s:?}; allowed: {labels:?}"
15663                    )))
15664                }
15665            }
15666            other => Err(EngineError::Unsupported(alloc::format!(
15667                "column {col_name:?}: enum-typed column expects TEXT, got {:?}",
15668                other.data_type()
15669            ))),
15670        }
15671    } else {
15672        Ok(())
15673    }
15674}
15675
15676fn column_def_to_schema(c: ColumnDef) -> Result<ColumnSchema, EngineError> {
15677    let ty = column_type_to_data_type(c.ty);
15678    let mut schema = ColumnSchema::new(c.name.clone(), ty, c.nullable);
15679    // user_type_ref is the raw ident the parser couldn't resolve
15680    // to a built-in; classification into enum vs domain happens
15681    // at exec_create_table where we have catalog access. We
15682    // park it temporarily as user_enum_type and the engine
15683    // promotes domain bindings to user_domain_type before the
15684    // table is stored.
15685    if let Some(name) = c.user_type_ref {
15686        schema.user_enum_type = Some(name);
15687    }
15688    // v7.17.0 Phase 2.1 — render the ON UPDATE expression to
15689    // canonical text (the engine re-parses at UPDATE time).
15690    if let Some(expr) = c.on_update_runtime {
15691        schema.on_update_runtime = Some(alloc::format!("{expr}"));
15692    }
15693    // v7.17.0 Phase 2.5 — bridge the AST `Collation` enum to the
15694    // storage one. Same variants, different crates (spg-storage
15695    // owns no dep on spg-sql).
15696    schema.collation = match c.collation {
15697        spg_sql::ast::Collation::Binary => spg_storage::Collation::Binary,
15698        spg_sql::ast::Collation::CaseInsensitive => spg_storage::Collation::CaseInsensitive,
15699    };
15700    // v7.17.0 Phase 4.4 — MySQL `UNSIGNED` flag propagates to
15701    // storage so engine INSERT / UPDATE can range-check.
15702    schema.is_unsigned = c.is_unsigned;
15703    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant list.
15704    // INSERT validation lives in coerce_value (Text → Text path
15705    // with the column's variant list as the accept-set).
15706    schema.inline_enum_variants = c.inline_enum_variants;
15707    // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
15708    // INSERT canonicalisation (de-dup + sort by definition order)
15709    // lives in the exec_insert path next to the ENUM check.
15710    schema.inline_set_variants = c.inline_set_variants;
15711    if let Some(default_expr) = c.default {
15712        // v7.9.21 — distinguish literal defaults (evaluated once
15713        // at CREATE TABLE) from expression defaults (deferred to
15714        // INSERT). Function calls (`now()`, `current_timestamp`
15715        // — see v7.9.20 keyword promotion) take the runtime path.
15716        // Literals continue to cache. mailrs G4.
15717        if is_runtime_default_expr(&default_expr) {
15718            let display = alloc::format!("{default_expr}");
15719            schema = schema.with_runtime_default(display);
15720        } else {
15721            let raw = literal_expr_to_value(default_expr)?;
15722            let coerced = coerce_value(raw, ty, &c.name, 0)?;
15723            schema = schema.with_default(coerced);
15724        }
15725    }
15726    if c.auto_increment {
15727        // AUTO_INCREMENT only makes sense on integer-shaped columns.
15728        if !matches!(ty, DataType::SmallInt | DataType::Int | DataType::BigInt) {
15729            return Err(EngineError::Unsupported(alloc::format!(
15730                "AUTO_INCREMENT requires an integer column type, got {ty:?}"
15731            )));
15732        }
15733        schema = schema.with_auto_increment();
15734    }
15735    Ok(schema)
15736}
15737
15738/// v7.10.4 — decode a BYTEA literal. Accepts:
15739///   * `\xDEADBEEF` (case-insensitive hex; whitespace stripped)
15740///   * `Hello\000world` (backslash escape form; `\\` for literal backslash)
15741///   * Anything else → raw UTF-8 bytes of the input (PG accepts this too).
15742fn decode_bytea_literal(s: &str) -> Result<alloc::vec::Vec<u8>, &'static str> {
15743    let s = s.trim();
15744    if let Some(hex) = s.strip_prefix("\\x").or_else(|| s.strip_prefix("\\X")) {
15745        // Hex form. Each pair of hex digits → one byte.
15746        let cleaned: alloc::string::String = hex.chars().filter(|c| !c.is_whitespace()).collect();
15747        if cleaned.len() % 2 != 0 {
15748            return Err("odd-length hex literal");
15749        }
15750        let mut out = alloc::vec::Vec::with_capacity(cleaned.len() / 2);
15751        let cleaned_bytes = cleaned.as_bytes();
15752        for i in (0..cleaned_bytes.len()).step_by(2) {
15753            let hi = hex_nibble(cleaned_bytes[i])?;
15754            let lo = hex_nibble(cleaned_bytes[i + 1])?;
15755            out.push((hi << 4) | lo);
15756        }
15757        return Ok(out);
15758    }
15759    // Escape form or raw. Walk char-by-char; `\\` and `\NNN` octal
15760    // sequences decode; anything else is a literal byte.
15761    let bytes = s.as_bytes();
15762    let mut out = alloc::vec::Vec::with_capacity(bytes.len());
15763    let mut i = 0;
15764    while i < bytes.len() {
15765        let b = bytes[i];
15766        if b == b'\\' && i + 1 < bytes.len() {
15767            let n = bytes[i + 1];
15768            if n == b'\\' {
15769                out.push(b'\\');
15770                i += 2;
15771                continue;
15772            }
15773            if n.is_ascii_digit()
15774                && i + 3 < bytes.len()
15775                && bytes[i + 2].is_ascii_digit()
15776                && bytes[i + 3].is_ascii_digit()
15777            {
15778                let oct = |x: u8| (x - b'0') as u32;
15779                let v = oct(n) * 64 + oct(bytes[i + 2]) * 8 + oct(bytes[i + 3]);
15780                if v <= 0xFF {
15781                    out.push(v as u8);
15782                    i += 4;
15783                    continue;
15784                }
15785            }
15786        }
15787        out.push(b);
15788        i += 1;
15789    }
15790    Ok(out)
15791}
15792
15793fn hex_nibble(b: u8) -> Result<u8, &'static str> {
15794    match b {
15795        b'0'..=b'9' => Ok(b - b'0'),
15796        b'a'..=b'f' => Ok(b - b'a' + 10),
15797        b'A'..=b'F' => Ok(b - b'A' + 10),
15798        _ => Err("invalid hex digit"),
15799    }
15800}
15801
15802/// v7.10.11 — decode a PG TEXT[] external array form
15803/// (`{a,b,NULL}` with optional double-quoted elements). The
15804/// engine takes a leading/trailing `{`/`}` and splits at commas.
15805/// Quoted elements (`"hello, world"`) preserve embedded commas;
15806/// `\\` and `\"` decode to literal backslash / quote. Plain
15807/// unquoted `NULL` (case-insensitive) maps to `None`.
15808/// v7.11.13 — pick the array type for `ARRAY[lit, …]` from the
15809/// element values. Single-element-type rules:
15810///   - all NULL / all Text → TextArray
15811///   - all Int (or Int+NULL) → IntArray
15812///   - any BigInt without Text → BigIntArray (widening)
15813///   - any Text → TextArray (fallback; non-string elements
15814///     render as text)
15815fn array_literal_widen(items: alloc::vec::Vec<Value>) -> Value {
15816    let mut has_text = false;
15817    let mut has_bigint = false;
15818    let mut has_int = false;
15819    for v in &items {
15820        match v {
15821            Value::Null => {}
15822            Value::Text(_) | Value::Json(_) => has_text = true,
15823            Value::BigInt(_) => has_bigint = true,
15824            Value::Int(_) | Value::SmallInt(_) => has_int = true,
15825            _ => has_text = true,
15826        }
15827    }
15828    if has_text || (!has_bigint && !has_int) {
15829        let out: alloc::vec::Vec<Option<alloc::string::String>> = items
15830            .into_iter()
15831            .map(|v| match v {
15832                Value::Null => None,
15833                Value::Text(s) | Value::Json(s) => Some(s),
15834                other => Some(alloc::format!("{other:?}")),
15835            })
15836            .collect();
15837        return Value::TextArray(out);
15838    }
15839    if has_bigint {
15840        let out: alloc::vec::Vec<Option<i64>> = items
15841            .into_iter()
15842            .map(|v| match v {
15843                Value::Null => None,
15844                Value::Int(n) => Some(i64::from(n)),
15845                Value::SmallInt(n) => Some(i64::from(n)),
15846                Value::BigInt(n) => Some(n),
15847                _ => unreachable!("widen: unexpected non-integer in BigInt path"),
15848            })
15849            .collect();
15850        return Value::BigIntArray(out);
15851    }
15852    let out: alloc::vec::Vec<Option<i32>> = items
15853        .into_iter()
15854        .map(|v| match v {
15855            Value::Null => None,
15856            Value::Int(n) => Some(n),
15857            Value::SmallInt(n) => Some(i32::from(n)),
15858            _ => unreachable!("widen: unexpected non-i32-compatible in Int path"),
15859        })
15860        .collect();
15861    Value::IntArray(out)
15862}
15863
15864fn decode_text_array_literal(
15865    s: &str,
15866) -> Result<alloc::vec::Vec<Option<alloc::string::String>>, &'static str> {
15867    let trimmed = s.trim();
15868    let inner = trimmed
15869        .strip_prefix('{')
15870        .and_then(|x| x.strip_suffix('}'))
15871        .ok_or("TEXT[] literal must be enclosed in '{...}'")?;
15872    let mut out: alloc::vec::Vec<Option<alloc::string::String>> = alloc::vec::Vec::new();
15873    if inner.trim().is_empty() {
15874        return Ok(out);
15875    }
15876    let bytes = inner.as_bytes();
15877    let mut i = 0;
15878    while i <= bytes.len() {
15879        // Skip leading whitespace.
15880        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
15881            i += 1;
15882        }
15883        // Quoted element.
15884        if i < bytes.len() && bytes[i] == b'"' {
15885            i += 1; // open quote
15886            let mut buf = alloc::string::String::new();
15887            while i < bytes.len() && bytes[i] != b'"' {
15888                if bytes[i] == b'\\' && i + 1 < bytes.len() {
15889                    buf.push(bytes[i + 1] as char);
15890                    i += 2;
15891                } else {
15892                    buf.push(bytes[i] as char);
15893                    i += 1;
15894                }
15895            }
15896            if i >= bytes.len() {
15897                return Err("unterminated quoted element");
15898            }
15899            i += 1; // close quote
15900            out.push(Some(buf));
15901        } else {
15902            // Unquoted element — read until next comma or end.
15903            let start = i;
15904            while i < bytes.len() && bytes[i] != b',' {
15905                i += 1;
15906            }
15907            let raw = inner[start..i].trim();
15908            if raw.eq_ignore_ascii_case("NULL") {
15909                out.push(None);
15910            } else {
15911                out.push(Some(alloc::string::ToString::to_string(raw)));
15912            }
15913        }
15914        // Skip whitespace, expect comma or end.
15915        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
15916            i += 1;
15917        }
15918        if i >= bytes.len() {
15919            break;
15920        }
15921        if bytes[i] != b',' {
15922            return Err("expected ',' between TEXT[] elements");
15923        }
15924        i += 1;
15925    }
15926    Ok(out)
15927}
15928
15929/// v7.10.11 — encode a TEXT[] back into the PG external array
15930/// form. NULL elements become the literal `NULL`; elements
15931/// containing commas, quotes, backslashes, or braces are
15932/// double-quoted with `\\` / `\"` escapes.
15933fn encode_text_array(items: &[Option<alloc::string::String>]) -> alloc::string::String {
15934    let mut out = alloc::string::String::with_capacity(2 + items.len() * 8);
15935    out.push('{');
15936    for (i, item) in items.iter().enumerate() {
15937        if i > 0 {
15938            out.push(',');
15939        }
15940        match item {
15941            None => out.push_str("NULL"),
15942            Some(s) => {
15943                let needs_quote = s.is_empty()
15944                    || s.eq_ignore_ascii_case("NULL")
15945                    || s.chars()
15946                        .any(|c| matches!(c, ',' | '{' | '}' | '"' | '\\' | ' ' | '\t'));
15947                if needs_quote {
15948                    out.push('"');
15949                    for c in s.chars() {
15950                        if c == '"' || c == '\\' {
15951                            out.push('\\');
15952                        }
15953                        out.push(c);
15954                    }
15955                    out.push('"');
15956                } else {
15957                    out.push_str(s);
15958                }
15959            }
15960        }
15961    }
15962    out.push('}');
15963    out
15964}
15965
15966/// v7.10.4 — encode BYTEA bytes in PG hex output format
15967/// (`\x` prefix, lowercase hex pairs). Used by Text-side
15968/// round-trip + the wire layer's text-mode encoder.
15969fn encode_bytea_hex(b: &[u8]) -> alloc::string::String {
15970    let mut out = alloc::string::String::with_capacity(2 + 2 * b.len());
15971    out.push_str("\\x");
15972    for byte in b {
15973        let hi = byte >> 4;
15974        let lo = byte & 0x0F;
15975        out.push(hex_digit(hi));
15976        out.push(hex_digit(lo));
15977    }
15978    out
15979}
15980
15981const fn hex_digit(n: u8) -> char {
15982    match n {
15983        0..=9 => (b'0' + n) as char,
15984        10..=15 => (b'a' + n - 10) as char,
15985        _ => '?',
15986    }
15987}
15988
15989/// v7.17.0 Phase 3.P0-39 — parse a PG `hstore` text literal into
15990/// a flat key→value map. Empty string → empty map. Duplicate
15991/// keys take last-write-wins (matches PG `hstore_in`).
15992///
15993/// Accepted shapes (minimal subset):
15994///   * `'a=>1, b=>2'`            — bareword keys/values
15995///   * `'"a"=>"1", "b"=>"2"'`    — quoted keys/values
15996///   * `'a=>NULL'`               — case-insensitive NULL token
15997///     surfaces as `None` (no quotes around NULL)
15998///
15999/// Returns None on parse failure → caller surfaces as hard error.
16000fn parse_hstore_str(
16001    s: &str,
16002) -> Option<Vec<(alloc::string::String, Option<alloc::string::String>)>> {
16003    let bytes = s.as_bytes();
16004    let mut i = 0;
16005    let mut out: Vec<(alloc::string::String, Option<alloc::string::String>)> = Vec::new();
16006    let skip_ws = |bytes: &[u8], i: &mut usize| {
16007        while *i < bytes.len() && matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r') {
16008            *i += 1;
16009        }
16010    };
16011    let parse_token = |bytes: &[u8], i: &mut usize| -> Option<alloc::string::String> {
16012        if *i >= bytes.len() {
16013            return None;
16014        }
16015        if bytes[*i] == b'"' {
16016            *i += 1;
16017            let mut out = alloc::string::String::new();
16018            while *i < bytes.len() {
16019                match bytes[*i] {
16020                    b'"' => {
16021                        *i += 1;
16022                        return Some(out);
16023                    }
16024                    b'\\' if *i + 1 < bytes.len() => {
16025                        out.push(bytes[*i + 1] as char);
16026                        *i += 2;
16027                    }
16028                    c => {
16029                        out.push(c as char);
16030                        *i += 1;
16031                    }
16032                }
16033            }
16034            None
16035        } else {
16036            let start = *i;
16037            while *i < bytes.len()
16038                && !matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r' | b',' | b'=')
16039            {
16040                *i += 1;
16041            }
16042            if *i == start {
16043                return None;
16044            }
16045            Some(alloc::str::from_utf8(&bytes[start..*i]).ok()?.to_string())
16046        }
16047    };
16048    skip_ws(bytes, &mut i);
16049    while i < bytes.len() {
16050        let key = parse_token(bytes, &mut i)?;
16051        skip_ws(bytes, &mut i);
16052        if i + 1 >= bytes.len() || bytes[i] != b'=' || bytes[i + 1] != b'>' {
16053            return None;
16054        }
16055        i += 2;
16056        skip_ws(bytes, &mut i);
16057        // Check for unquoted NULL token (case-insensitive).
16058        let val_token = if i + 4 <= bytes.len()
16059            && bytes[i..i + 4].eq_ignore_ascii_case(b"NULL")
16060            && (i + 4 == bytes.len() || matches!(bytes[i + 4], b' ' | b'\t' | b',' | b'\n' | b'\r'))
16061        {
16062            i += 4;
16063            None
16064        } else {
16065            Some(parse_token(bytes, &mut i)?)
16066        };
16067        // Replace any existing entry with the same key (last-wins).
16068        if let Some(pos) = out.iter().position(|(k, _)| k == &key) {
16069            out[pos] = (key, val_token);
16070        } else {
16071            out.push((key, val_token));
16072        }
16073        skip_ws(bytes, &mut i);
16074        if i >= bytes.len() {
16075            break;
16076        }
16077        if bytes[i] == b',' {
16078            i += 1;
16079            skip_ws(bytes, &mut i);
16080            continue;
16081        }
16082        return None;
16083    }
16084    Some(out)
16085}
16086
16087/// v7.17.0 Phase 3.P0-39 — render a hstore as canonical PG text
16088/// form `"k"=>"v"` (keys and non-NULL values always quoted;
16089/// NULL token is bare).
16090fn format_hstore_str(
16091    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
16092) -> alloc::string::String {
16093    let mut out = alloc::string::String::new();
16094    for (i, (k, v)) in pairs.iter().enumerate() {
16095        if i > 0 {
16096            out.push_str(", ");
16097        }
16098        out.push('"');
16099        out.push_str(k);
16100        out.push_str("\"=>");
16101        match v {
16102            None => out.push_str("NULL"),
16103            Some(val) => {
16104                out.push('"');
16105                out.push_str(val);
16106                out.push('"');
16107            }
16108        }
16109    }
16110    out
16111}
16112
16113/// v7.17.0 Phase 3.P0-39 — pub re-export so pgwire + sqllogictest
16114/// share the single hstore renderer.
16115pub fn format_hstore_text(
16116    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
16117) -> alloc::string::String {
16118    format_hstore_str(pairs)
16119}
16120
16121// ─── v7.17.0 Phase 3.P0-40 — 2D array parse + display ─────────
16122
16123/// Split a PG external 2D-array literal `'{{a,b},{c,d}}'` into
16124/// per-row token lists. Returns Err on shape mismatch.
16125fn split_2d_literal(s: &str) -> Result<Vec<Vec<alloc::string::String>>, &'static str> {
16126    let s = s.trim();
16127    let outer = s
16128        .strip_prefix('{')
16129        .and_then(|x| x.strip_suffix('}'))
16130        .ok_or("missing outer '{...}' braces")?;
16131    let trimmed = outer.trim();
16132    if trimmed.is_empty() {
16133        return Ok(Vec::new());
16134    }
16135    let mut rows: Vec<Vec<alloc::string::String>> = Vec::new();
16136    let mut i = 0;
16137    let bytes = trimmed.as_bytes();
16138    while i < bytes.len() {
16139        while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r' | b',') {
16140            i += 1;
16141        }
16142        if i >= bytes.len() {
16143            break;
16144        }
16145        if bytes[i] != b'{' {
16146            return Err("expected '{' opening a row");
16147        }
16148        i += 1;
16149        let row_start = i;
16150        let mut depth = 1;
16151        while i < bytes.len() && depth > 0 {
16152            match bytes[i] {
16153                b'{' => depth += 1,
16154                b'}' => depth -= 1,
16155                _ => {}
16156            }
16157            if depth > 0 {
16158                i += 1;
16159            }
16160        }
16161        if depth != 0 {
16162            return Err("unbalanced '{...}' in row");
16163        }
16164        let row_text = &trimmed[row_start..i];
16165        i += 1;
16166        let cells: Vec<alloc::string::String> = if row_text.trim().is_empty() {
16167            Vec::new()
16168        } else {
16169            row_text.split(',').map(|t| t.trim().to_string()).collect()
16170        };
16171        rows.push(cells);
16172    }
16173    if let Some(first) = rows.first() {
16174        let cols = first.len();
16175        for r in &rows {
16176            if r.len() != cols {
16177                return Err("ragged 2D array (rows have different column counts)");
16178            }
16179        }
16180    }
16181    Ok(rows)
16182}
16183
16184fn parse_int_2d_literal(s: &str) -> Result<Vec<Vec<Option<i32>>>, &'static str> {
16185    let raw = split_2d_literal(s)?;
16186    raw.into_iter()
16187        .map(|row| {
16188            row.into_iter()
16189                .map(|cell| {
16190                    if cell.eq_ignore_ascii_case("NULL") {
16191                        Ok(None)
16192                    } else {
16193                        cell.parse::<i32>()
16194                            .map(Some)
16195                            .map_err(|_| "invalid int element")
16196                    }
16197                })
16198                .collect()
16199        })
16200        .collect()
16201}
16202
16203fn parse_bigint_2d_literal(s: &str) -> Result<Vec<Vec<Option<i64>>>, &'static str> {
16204    let raw = split_2d_literal(s)?;
16205    raw.into_iter()
16206        .map(|row| {
16207            row.into_iter()
16208                .map(|cell| {
16209                    if cell.eq_ignore_ascii_case("NULL") {
16210                        Ok(None)
16211                    } else {
16212                        cell.parse::<i64>()
16213                            .map(Some)
16214                            .map_err(|_| "invalid bigint element")
16215                    }
16216                })
16217                .collect()
16218        })
16219        .collect()
16220}
16221
16222fn parse_text_2d_literal(s: &str) -> Result<Vec<Vec<Option<alloc::string::String>>>, &'static str> {
16223    let raw = split_2d_literal(s)?;
16224    Ok(raw
16225        .into_iter()
16226        .map(|row| {
16227            row.into_iter()
16228                .map(|cell| {
16229                    if cell.eq_ignore_ascii_case("NULL") {
16230                        None
16231                    } else {
16232                        Some(cell.trim_matches('"').to_string())
16233                    }
16234                })
16235                .collect()
16236        })
16237        .collect())
16238}
16239
16240fn format_int_2d_text(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
16241    let mut out = alloc::string::String::from("{");
16242    for (i, row) in rows.iter().enumerate() {
16243        if i > 0 {
16244            out.push(',');
16245        }
16246        out.push('{');
16247        for (j, cell) in row.iter().enumerate() {
16248            if j > 0 {
16249                out.push(',');
16250            }
16251            match cell {
16252                None => out.push_str("NULL"),
16253                Some(n) => out.push_str(&alloc::format!("{n}")),
16254            }
16255        }
16256        out.push('}');
16257    }
16258    out.push('}');
16259    out
16260}
16261
16262fn format_bigint_2d_text(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
16263    let mut out = alloc::string::String::from("{");
16264    for (i, row) in rows.iter().enumerate() {
16265        if i > 0 {
16266            out.push(',');
16267        }
16268        out.push('{');
16269        for (j, cell) in row.iter().enumerate() {
16270            if j > 0 {
16271                out.push(',');
16272            }
16273            match cell {
16274                None => out.push_str("NULL"),
16275                Some(n) => out.push_str(&alloc::format!("{n}")),
16276            }
16277        }
16278        out.push('}');
16279    }
16280    out.push('}');
16281    out
16282}
16283
16284fn format_text_2d_text(rows: &[Vec<Option<alloc::string::String>>]) -> alloc::string::String {
16285    let mut out = alloc::string::String::from("{");
16286    for (i, row) in rows.iter().enumerate() {
16287        if i > 0 {
16288            out.push(',');
16289        }
16290        out.push('{');
16291        for (j, cell) in row.iter().enumerate() {
16292            if j > 0 {
16293                out.push(',');
16294            }
16295            match cell {
16296                None => out.push_str("NULL"),
16297                Some(s) => out.push_str(s),
16298            }
16299        }
16300        out.push('}');
16301    }
16302    out.push('}');
16303    out
16304}
16305
16306/// v7.17.0 Phase 3.P0-40 — pub re-exports so pgwire + sqllogictest
16307/// share the single 2D-array renderer.
16308pub fn format_int_2d_text_pub(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
16309    format_int_2d_text(rows)
16310}
16311pub fn format_bigint_2d_text_pub(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
16312    format_bigint_2d_text(rows)
16313}
16314pub fn format_text_2d_text_pub(
16315    rows: &[Vec<Option<alloc::string::String>>],
16316) -> alloc::string::String {
16317    format_text_2d_text(rows)
16318}
16319
16320/// v7.17.0 Phase 3.P0-38 — parse a PG range literal of the form
16321/// `'[lo,up)'` / `'(lo,up]'` / `'[lo,up]'` / `'(lo,up)'` /
16322/// `'empty'`. Lower / upper may be empty (unbounded). Returns
16323/// `None` on any parse failure; caller surfaces as hard error.
16324fn parse_range_str(s: &str, kind: spg_storage::RangeKind) -> Option<Value> {
16325    let s = s.trim();
16326    if s.eq_ignore_ascii_case("empty") {
16327        return Some(Value::Range {
16328            kind,
16329            lower: None,
16330            upper: None,
16331            lower_inc: false,
16332            upper_inc: false,
16333            empty: true,
16334        });
16335    }
16336    let bytes = s.as_bytes();
16337    if bytes.len() < 3 {
16338        return None;
16339    }
16340    let lower_inc = match bytes[0] {
16341        b'[' => true,
16342        b'(' => false,
16343        _ => return None,
16344    };
16345    let upper_inc = match bytes[bytes.len() - 1] {
16346        b']' => true,
16347        b')' => false,
16348        _ => return None,
16349    };
16350    let inner = &s[1..s.len() - 1];
16351    let (lo_text, up_text) = inner.split_once(',')?;
16352    let lower = if lo_text.is_empty() {
16353        None
16354    } else {
16355        Some(alloc::boxed::Box::new(parse_range_element(lo_text, kind)?))
16356    };
16357    let upper = if up_text.is_empty() {
16358        None
16359    } else {
16360        Some(alloc::boxed::Box::new(parse_range_element(up_text, kind)?))
16361    };
16362    Some(Value::Range {
16363        kind,
16364        lower,
16365        upper,
16366        lower_inc,
16367        upper_inc,
16368        empty: false,
16369    })
16370}
16371
16372/// v7.17.0 Phase 3.P0-38 — parse a single range bound text into
16373/// the matching element Value for the RangeKind.
16374fn parse_range_element(text: &str, kind: spg_storage::RangeKind) -> Option<Value> {
16375    let text = text.trim().trim_matches('"');
16376    use spg_storage::RangeKind as K;
16377    match kind {
16378        K::Int4 => text.parse::<i32>().ok().map(Value::Int),
16379        K::Int8 => text.parse::<i64>().ok().map(Value::BigInt),
16380        K::Num => {
16381            // Reuse the Numeric parse via the engine's text-coercion
16382            // path; bail to None on failure.
16383            let dot = text.find('.');
16384            let scale: u8 = dot.map_or(0, |p| (text.len() - p - 1) as u8);
16385            let digits: alloc::string::String = text
16386                .chars()
16387                .filter(|c| *c == '-' || c.is_ascii_digit())
16388                .collect();
16389            let scaled: i128 = digits.parse().ok()?;
16390            Some(Value::Numeric { scaled, scale })
16391        }
16392        K::Ts | K::TsTz => {
16393            // Reuse the existing timestamp parse path. v7.17.0
16394            // expects `'YYYY-MM-DD HH:MM:SS[.ffffff]'` in range
16395            // bounds (TZ offset on TsTz is OOS for the initial
16396            // P0-38; ship plain Timestamp shape).
16397            crate::eval::parse_timestamp_literal(text).map(Value::Timestamp)
16398        }
16399        K::Date => crate::eval::parse_date_literal(text).map(Value::Date),
16400    }
16401}
16402
16403/// v7.17.0 Phase 3.P0-38 — render a Range value as its canonical
16404/// PG text form. Re-exported via [`format_range_text`] for use
16405/// from spg-server's pgwire layer.
16406pub fn format_range_text(v: &Value) -> alloc::string::String {
16407    format_range_str(v)
16408}
16409
16410fn format_range_str(v: &Value) -> alloc::string::String {
16411    let Value::Range {
16412        lower,
16413        upper,
16414        lower_inc,
16415        upper_inc,
16416        empty,
16417        ..
16418    } = v
16419    else {
16420        return alloc::string::String::new();
16421    };
16422    if *empty {
16423        return "empty".into();
16424    }
16425    let mut out = alloc::string::String::new();
16426    out.push(if *lower_inc { '[' } else { '(' });
16427    if let Some(l) = lower {
16428        out.push_str(&format_range_element(l));
16429    }
16430    out.push(',');
16431    if let Some(u) = upper {
16432        out.push_str(&format_range_element(u));
16433    }
16434    out.push(if *upper_inc { ']' } else { ')' });
16435    out
16436}
16437
16438fn format_range_element(v: &Value) -> alloc::string::String {
16439    match v {
16440        Value::Int(n) => alloc::format!("{n}"),
16441        Value::BigInt(n) => alloc::format!("{n}"),
16442        Value::Date(d) => crate::eval::format_date(*d),
16443        Value::Timestamp(t) => crate::eval::format_timestamp(*t),
16444        Value::Numeric { scaled, scale } => crate::eval::format_numeric(*scaled, *scale),
16445        other => alloc::format!("{other:?}"),
16446    }
16447}
16448
16449/// v7.17.0 Phase 3.P0-35 — parse a PG `money` literal into i64
16450/// cents. Accepts:
16451///   * Optional leading `-` (negative)
16452///   * Optional `$` prefix
16453///   * Integer portion with optional `,` thousands separators
16454///   * Optional `.` followed by 1-2 digits (cents); 1 digit
16455///     auto-pads to 2 (`.5` → 50 cents).
16456///
16457/// Returns None on any parse failure — caller surfaces as hard
16458/// SQL error.
16459fn parse_money_str(s: &str) -> Option<i64> {
16460    let s = s.trim();
16461    let (neg, rest) = match s.strip_prefix('-') {
16462        Some(r) => (true, r.trim_start()),
16463        None => (false, s),
16464    };
16465    let rest = rest.strip_prefix('$').unwrap_or(rest).trim_start();
16466    let (int_part, frac_part) = match rest.split_once('.') {
16467        Some((i, f)) => (i, Some(f)),
16468        None => (rest, None),
16469    };
16470    if int_part.is_empty() {
16471        return None;
16472    }
16473    // Validate + strip commas from the integer portion.
16474    let mut int_digits = alloc::string::String::with_capacity(int_part.len());
16475    for b in int_part.bytes() {
16476        match b {
16477            b',' => {}
16478            b'0'..=b'9' => int_digits.push(b as char),
16479            _ => return None,
16480        }
16481    }
16482    if int_digits.is_empty() {
16483        return None;
16484    }
16485    let dollars: i64 = int_digits.parse().ok()?;
16486    let cents: i64 = match frac_part {
16487        None => 0,
16488        Some(f) => {
16489            if f.is_empty() || f.len() > 2 || !f.bytes().all(|b| b.is_ascii_digit()) {
16490                return None;
16491            }
16492            let padded = if f.len() == 1 {
16493                alloc::format!("{f}0")
16494            } else {
16495                f.to_string()
16496            };
16497            padded.parse().ok()?
16498        }
16499    };
16500    let total = dollars.checked_mul(100)?.checked_add(cents)?;
16501    Some(if neg { -total } else { total })
16502}
16503
16504/// v7.17.0 Phase 3.P0-34 — parse a PG `timetz` literal
16505/// `HH:MM:SS[.fraction]±HH[:MM]` into (us, offset_secs).
16506///
16507/// The offset suffix is MANDATORY: SPG doesn't have a session TZ
16508/// wired into eval, so a bare `HH:MM:SS` literal would be
16509/// ambiguous. Returns None for any parse failure or out-of-range
16510/// component — caller surfaces as a hard SQL error.
16511///
16512/// Offset range: ±14 hours (±50400 seconds), matching PG's
16513/// internal limit.
16514fn parse_timetz_str(s: &str) -> Option<(i64, i32)> {
16515    let s = s.trim();
16516    // Find the offset sign — scan from right since the time part
16517    // never contains '+' / '-' (after the optional fractional dot
16518    // it's all digits and ':').
16519    let bytes = s.as_bytes();
16520    let sign_pos = bytes
16521        .iter()
16522        .enumerate()
16523        .rev()
16524        .find(|&(_, &b)| b == b'+' || b == b'-')
16525        .map(|(i, _)| i)?;
16526    if sign_pos == 0 {
16527        return None; // bare sign — no time component
16528    }
16529    let time_part = &s[..sign_pos];
16530    let offset_part = &s[sign_pos..];
16531    let us = parse_time_str(time_part)?;
16532    let sign: i32 = if offset_part.starts_with('+') { 1 } else { -1 };
16533    let offset_body = &offset_part[1..];
16534    let (hh_str, mm_str) = match offset_body.split_once(':') {
16535        Some((h, m)) => (h, m),
16536        None => (offset_body, "0"),
16537    };
16538    let hh: i32 = hh_str.parse().ok()?;
16539    let mm: i32 = mm_str.parse().ok()?;
16540    if !(0..=14).contains(&hh) || !(0..=59).contains(&mm) {
16541        return None;
16542    }
16543    let total = sign * (hh * 3600 + mm * 60);
16544    if total.abs() > 50_400 {
16545        return None;
16546    }
16547    Some((us, total))
16548}
16549
16550/// v7.17.0 Phase 3.P0-33 — funnel an integer literal through MySQL
16551/// YEAR range validation: 0 sentinel or 1901..=2155. Out-of-range
16552/// surfaces as a hard SQL error (no silent truncation, mirrors PG
16553/// `time_in` / `uuid_in` discipline).
16554fn coerce_int_to_year(n: i64, col_name: &str) -> Result<Value, EngineError> {
16555    if n == 0 || (1901..=2155).contains(&n) {
16556        // u16::try_from cannot fail in this range; the cast also
16557        // covers the 0 sentinel.
16558        return Ok(Value::Year(n as u16));
16559    }
16560    Err(EngineError::Eval(EvalError::TypeMismatch {
16561        detail: alloc::format!(
16562            "year value out of range: {n} (column `{col_name}`; \
16563             MySQL accepts 0 or 1901..=2155)"
16564        ),
16565    }))
16566}
16567
16568/// v7.17.0 Phase 3.P0-32 — parse a PG `time` literal
16569/// `HH:MM:SS[.fraction]` into microseconds since 00:00:00.
16570///
16571/// Accepts:
16572///   * `HH:MM:SS`            — exact-second precision
16573///   * `HH:MM:SS.f` .. `.ffffff` — 1-6 fractional digits, right-padded
16574///     with zeros to microseconds
16575///
16576/// Range: hour 0..=23, minute 0..=59, second 0..=59. Anything else
16577/// returns None — caller surfaces as a hard SQL error (no silent
16578/// truncation, matches PG's `time_in` behaviour).
16579fn parse_time_str(s: &str) -> Option<i64> {
16580    let s = s.trim();
16581    let (hms, frac) = match s.split_once('.') {
16582        Some((h, f)) => (h, Some(f)),
16583        None => (s, None),
16584    };
16585    let mut parts = hms.split(':');
16586    let hh: u32 = parts.next()?.parse().ok()?;
16587    let mm: u32 = parts.next()?.parse().ok()?;
16588    let ss: u32 = parts.next()?.parse().ok()?;
16589    if parts.next().is_some() {
16590        return None;
16591    }
16592    if hh > 23 || mm > 59 || ss > 59 {
16593        return None;
16594    }
16595    let frac_us: i64 = match frac {
16596        None => 0,
16597        Some(f) => {
16598            if f.is_empty() || f.len() > 6 || !f.bytes().all(|b| b.is_ascii_digit()) {
16599                return None;
16600            }
16601            // Right-pad with zeros so '.5' = 500000 µsec.
16602            let mut padded = alloc::string::String::with_capacity(6);
16603            padded.push_str(f);
16604            while padded.len() < 6 {
16605                padded.push('0');
16606            }
16607            padded.parse().ok()?
16608        }
16609    };
16610    Some(
16611        i64::from(hh) * 3_600_000_000
16612            + i64::from(mm) * 60_000_000
16613            + i64::from(ss) * 1_000_000
16614            + frac_us,
16615    )
16616}
16617
16618const fn column_type_to_data_type(t: ColumnTypeName) -> DataType {
16619    match t {
16620        ColumnTypeName::SmallInt => DataType::SmallInt,
16621        ColumnTypeName::Int => DataType::Int,
16622        ColumnTypeName::BigInt => DataType::BigInt,
16623        ColumnTypeName::Float => DataType::Float,
16624        ColumnTypeName::Text => DataType::Text,
16625        ColumnTypeName::Varchar(n) => DataType::Varchar(n),
16626        ColumnTypeName::Char(n) => DataType::Char(n),
16627        ColumnTypeName::Bool => DataType::Bool,
16628        ColumnTypeName::Vector { dim, encoding } => DataType::Vector {
16629            dim,
16630            encoding: match encoding {
16631                SqlVecEncoding::F32 => VecEncoding::F32,
16632                SqlVecEncoding::Sq8 => VecEncoding::Sq8,
16633                SqlVecEncoding::F16 => VecEncoding::F16,
16634            },
16635        },
16636        ColumnTypeName::Numeric(precision, scale) => DataType::Numeric { precision, scale },
16637        ColumnTypeName::Date => DataType::Date,
16638        ColumnTypeName::Timestamp => DataType::Timestamp,
16639        ColumnTypeName::Timestamptz => DataType::Timestamptz,
16640        ColumnTypeName::Json => DataType::Json,
16641        ColumnTypeName::Jsonb => DataType::Jsonb,
16642        ColumnTypeName::Bytes => DataType::Bytes,
16643        ColumnTypeName::TextArray => DataType::TextArray,
16644        ColumnTypeName::IntArray => DataType::IntArray,
16645        ColumnTypeName::BigIntArray => DataType::BigIntArray,
16646        ColumnTypeName::TsVector => DataType::TsVector,
16647        ColumnTypeName::TsQuery => DataType::TsQuery,
16648        ColumnTypeName::Uuid => DataType::Uuid,
16649        ColumnTypeName::Time => DataType::Time,
16650        ColumnTypeName::Year => DataType::Year,
16651        ColumnTypeName::TimeTz => DataType::TimeTz,
16652        ColumnTypeName::Money => DataType::Money,
16653        ColumnTypeName::Range(k) => DataType::Range(match k {
16654            spg_sql::ast::RangeKindAst::Int4 => spg_storage::RangeKind::Int4,
16655            spg_sql::ast::RangeKindAst::Int8 => spg_storage::RangeKind::Int8,
16656            spg_sql::ast::RangeKindAst::Num => spg_storage::RangeKind::Num,
16657            spg_sql::ast::RangeKindAst::Ts => spg_storage::RangeKind::Ts,
16658            spg_sql::ast::RangeKindAst::TsTz => spg_storage::RangeKind::TsTz,
16659            spg_sql::ast::RangeKindAst::Date => spg_storage::RangeKind::Date,
16660        }),
16661        ColumnTypeName::Hstore => DataType::Hstore,
16662        ColumnTypeName::IntArray2D => DataType::IntArray2D,
16663        ColumnTypeName::BigIntArray2D => DataType::BigIntArray2D,
16664        ColumnTypeName::TextArray2D => DataType::TextArray2D,
16665    }
16666}
16667
16668/// Convert an INSERT VALUES expression to a storage Value. Supports literal
16669/// expressions, unary-minus over numeric literals, and pgvector-style
16670/// `'[..]'::vector` cast (v1.2). Anything more complex returns `Unsupported`.
16671fn literal_expr_to_value(expr: Expr) -> Result<Value, EngineError> {
16672    match expr {
16673        Expr::Literal(l) => Ok(literal_to_value(l)),
16674        Expr::Cast { expr, target } => {
16675            let inner_value = literal_expr_to_value(*expr)?;
16676            crate::eval::cast_value(inner_value, target).map_err(EngineError::Eval)
16677        }
16678        Expr::Unary {
16679            op: UnOp::Neg,
16680            expr,
16681        } => match *expr {
16682            Expr::Literal(Literal::Integer(n)) => {
16683                // Fold to i32 if it fits, else BigInt. Parser emits Integer(i64)
16684                // — overflow on negate of i64::MIN is the one edge case.
16685                let neg = n.checked_neg().ok_or_else(|| {
16686                    EngineError::Unsupported("integer literal overflow on negation".into())
16687                })?;
16688                Ok(int_value_for(neg))
16689            }
16690            Expr::Literal(Literal::Float(x)) => Ok(Value::Float(-x)),
16691            other => Err(EngineError::Unsupported(alloc::format!(
16692                "unary minus over non-literal expression: {other:?}"
16693            ))),
16694        },
16695        // v7.10.10 — `ARRAY[lit, lit, …]` constructor accepted at
16696        // INSERT-time. Each element must reduce to a Value through
16697        // `literal_expr_to_value`; NULL elements become `None`.
16698        // v7.11.13 — deduce shape from element values: all Int →
16699        // IntArray; any BigInt → BigIntArray (widening); any Text
16700        // → TextArray. Cast targets (`ARRAY[]::INT[]`) flow through
16701        // the outer Cast arm before reaching here and re-coerce.
16702        Expr::Array(items) => {
16703            let mut materialised: alloc::vec::Vec<Value> =
16704                alloc::vec::Vec::with_capacity(items.len());
16705            for elem in items {
16706                materialised.push(literal_expr_to_value(elem)?);
16707            }
16708            Ok(array_literal_widen(materialised))
16709        }
16710        // Any other Expr shape — fall back to a general evaluation
16711        // against an empty row + empty schema. This unblocks the
16712        // app-common patterns where INSERT VALUES carries a
16713        // non-correlated function call:
16714        //   INSERT INTO t VALUES (concat('U-', 42))
16715        //   INSERT INTO t VALUES (now())
16716        //   INSERT INTO t VALUES (format('%s-%s', 'a', 'b'))
16717        // Any expression that references a column or `$N`
16718        // placeholder fails cleanly inside `eval_expr` with a
16719        // descriptive error; literals + casts + ARRAY[…] continue
16720        // to take the fast paths above so the hot INSERT path is
16721        // unchanged on the common case.
16722        other => {
16723            let empty_schema: alloc::vec::Vec<spg_storage::ColumnSchema> = alloc::vec::Vec::new();
16724            let ctx = EvalContext::new(&empty_schema, None);
16725            let empty_row = spg_storage::Row::new(alloc::vec::Vec::new());
16726            crate::eval::eval_expr(&other, &empty_row, &ctx).map_err(EngineError::Eval)
16727        }
16728    }
16729}
16730
16731fn literal_to_value(l: Literal) -> Value {
16732    match l {
16733        Literal::Integer(n) => int_value_for(n),
16734        Literal::Float(x) => Value::Float(x),
16735        Literal::String(s) => Value::Text(s),
16736        Literal::Bool(b) => Value::Bool(b),
16737        Literal::Null => Value::Null,
16738        Literal::Vector(v) => Value::Vector(v),
16739        Literal::Interval { months, micros, .. } => Value::Interval { months, micros },
16740    }
16741}
16742
16743/// Pick `Int` (`i32`) when the literal fits, else `BigInt`. `INT` vs `BIGINT`
16744/// columns will still enforce the right tag downstream — this is just the
16745/// default we synthesise from an unannotated integer literal.
16746fn int_value_for(n: i64) -> Value {
16747    if let Ok(small) = i32::try_from(n) {
16748        Value::Int(small)
16749    } else {
16750        Value::BigInt(n)
16751    }
16752}
16753
16754/// Widen / narrow `v` to fit `expected`. Numerics permit safe widening
16755/// (`Int → BigInt`, `Int/BigInt → Float`) and best-effort narrowing
16756/// (`BigInt → Int` succeeds only when the value fits in `i32`). Everything
16757/// else returns `TypeMismatch` carrying the column name for caller diagnostics.
16758/// `NULL` is always permitted; the nullability check happens later in storage.
16759#[allow(clippy::too_many_lines)]
16760/// v7.17.0 Phase 4.4 — reject negative integer values on UNSIGNED
16761/// columns. Called after `coerce_value` at each INSERT / UPDATE
16762/// site that has ColumnSchema context. NULL passes through (a
16763/// nullable UNSIGNED column can legitimately hold NULL).
16764fn check_unsigned_range(
16765    v: &Value,
16766    schema: &ColumnSchema,
16767    position: usize,
16768) -> Result<(), EngineError> {
16769    if !schema.is_unsigned {
16770        return Ok(());
16771    }
16772    let n = match v {
16773        Value::SmallInt(x) => i64::from(*x),
16774        Value::Int(x) => i64::from(*x),
16775        Value::BigInt(x) => *x,
16776        _ => return Ok(()), // non-integer cells (NULL, default) skip
16777    };
16778    if n < 0 {
16779        return Err(EngineError::Unsupported(alloc::format!(
16780            "column {:?} is UNSIGNED but got negative value {n} at position {position}",
16781            schema.name
16782        )));
16783    }
16784    Ok(())
16785}
16786
16787fn coerce_value(
16788    v: Value,
16789    expected: DataType,
16790    col_name: &str,
16791    position: usize,
16792) -> Result<Value, EngineError> {
16793    if v.is_null() {
16794        return Ok(Value::Null);
16795    }
16796    let actual = v.data_type().expect("non-null");
16797    if actual == expected {
16798        return Ok(v);
16799    }
16800    let coerced = match (v, expected) {
16801        (Value::Int(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
16802        (Value::Int(n), DataType::Float) => Some(Value::Float(f64::from(n))),
16803        (Value::Int(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
16804        (Value::Int(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
16805            i128::from(n),
16806            precision,
16807            scale,
16808            col_name,
16809        )?),
16810        (Value::SmallInt(n), DataType::Int) => Some(Value::Int(i32::from(n))),
16811        (Value::SmallInt(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
16812        (Value::SmallInt(n), DataType::Float) => Some(Value::Float(f64::from(n))),
16813        (Value::SmallInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
16814            i128::from(n),
16815            precision,
16816            scale,
16817            col_name,
16818        )?),
16819        (Value::BigInt(n), DataType::Int) => i32::try_from(n).ok().map(Value::Int),
16820        (Value::BigInt(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
16821        #[allow(clippy::cast_precision_loss)]
16822        (Value::BigInt(n), DataType::Float) => Some(Value::Float(n as f64)),
16823        (Value::BigInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
16824            i128::from(n),
16825            precision,
16826            scale,
16827            col_name,
16828        )?),
16829        (Value::Float(x), DataType::Numeric { precision, scale }) => {
16830            Some(numeric_from_float(x, precision, scale, col_name)?)
16831        }
16832        // v7.17.0 Phase 3.P0-67 — Text → NUMERIC. Parse a
16833        // canonical decimal text (`"-1234.56"` / `"42"` /
16834        // `"0.0001"`) into `(mantissa, source_scale)` and rescale
16835        // to the column's declared scale. Required for prepared
16836        // binds: `value_to_literal` flattens a Value::Numeric
16837        // into a TEXT literal because Literal carries no native
16838        // Numeric variant, so the placeholder substitution path
16839        // reaches coerce_value as Text → Numeric. Without this
16840        // arm the round-trip surfaces a TypeMismatch even though
16841        // the cell already left the engine as a valid Numeric.
16842        (Value::Text(s), DataType::Numeric { precision, scale }) => {
16843            let Some((mantissa, src_scale)) = parse_numeric_text(&s) else {
16844                return Err(EngineError::Eval(EvalError::TypeMismatch {
16845                    detail: alloc::format!("cannot parse {s:?} as NUMERIC for column `{col_name}`"),
16846                }));
16847            };
16848            Some(numeric_rescale(
16849                mantissa, src_scale, precision, scale, col_name,
16850            )?)
16851        }
16852        // Text → DATE / TIMESTAMP: parse canonical text forms.
16853        (Value::Text(s), DataType::Date) => {
16854            let d = eval::parse_date_literal(&s).ok_or_else(|| {
16855                EngineError::Eval(EvalError::TypeMismatch {
16856                    detail: alloc::format!("cannot parse {s:?} as DATE for column `{col_name}`"),
16857                })
16858            })?;
16859            Some(Value::Date(d))
16860        }
16861        // v7.14.0 — MySQL DEFAULT clauses quote integer / float
16862        // / boolean literals (`DEFAULT '0'`, `DEFAULT '1'`,
16863        // `DEFAULT '3.14'`, `DEFAULT 'true'`). Coerce the text
16864        // form to the column's numeric / bool type at DEFAULT-
16865        // installation time so the storage check sees a typed
16866        // value. Parse failures fall through to TypeMismatch.
16867        (Value::Text(s), DataType::SmallInt) => s.parse::<i16>().ok().map(Value::SmallInt),
16868        (Value::Text(s), DataType::Int) => s.parse::<i32>().ok().map(Value::Int),
16869        (Value::Text(s), DataType::BigInt) => s.parse::<i64>().ok().map(Value::BigInt),
16870        (Value::Text(s), DataType::Float) => s.parse::<f64>().ok().map(Value::Float),
16871        (Value::Text(s), DataType::Bool) => match s.to_ascii_lowercase().as_str() {
16872            "0" | "false" | "f" | "no" | "off" => Some(Value::Bool(false)),
16873            "1" | "true" | "t" | "yes" | "on" => Some(Value::Bool(true)),
16874            _ => None,
16875        },
16876        // v7.17.0 Phase 3.P0-46 — MySQL TINYINT(1) (which Phase 4.3
16877        // classifies as DataType::Bool) is the storage shape every
16878        // mysqldump-restored boolean column lands in. mysqldump emits
16879        // the values as integer `0` / `1` literals, so int → bool
16880        // coerce on INSERT is required for a 0-change cutover. MySQL's
16881        // rule is "any non-zero is truthy"; we follow that for all
16882        // signed int widths so the same coerce path serves an
16883        // explicit `BOOLEAN` column too.
16884        (Value::Int(n), DataType::Bool) => Some(Value::Bool(n != 0)),
16885        (Value::SmallInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
16886        (Value::BigInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
16887        // v4.9: Text ↔ JSON coercion. No structural validation —
16888        // any text literal is accepted; the responsibility for
16889        // valid JSON lies with the producer.
16890        (Value::Text(s), DataType::Json | DataType::Jsonb) => Some(Value::Json(s)),
16891        (Value::Json(s), DataType::Text) => Some(Value::Text(s)),
16892        // v7.13.3 — mailrs round-7 S10. SPG's storage represents
16893        // both JSON and JSONB on-disk as `Value::Json(String)` —
16894        // they share the underlying text payload. The cast
16895        // `'<text>'::jsonb` produces a Value::Json that needs to
16896        // satisfy a DataType::Jsonb column. Identity coerce in
16897        // both directions so JSON ↔ JSONB assignments work at all
16898        // INSERT / ALTER COLUMN TYPE / DEFAULT contexts.
16899        (Value::Json(s), DataType::Jsonb | DataType::Json) => Some(Value::Json(s)),
16900        // v7.10.4 — Text → BYTEA. Decode PG-style literal forms:
16901        //   - Hex:    `\x48656c6c6f`  (case-insensitive hex pairs)
16902        //   - Escape: `Hello\\000world`  (backslash + octal triples)
16903        //   - Plain:  any string → raw UTF-8 bytes (PG also accepts)
16904        // Errors surface as TypeMismatch so the operator gets a
16905        // clear "this literal isn't a bytea literal" hint.
16906        (Value::Text(s), DataType::Bytes) => {
16907            let bytes = decode_bytea_literal(&s).map_err(|e| {
16908                EngineError::Eval(EvalError::TypeMismatch {
16909                    detail: alloc::format!(
16910                        "cannot parse {s:?} as BYTEA for column `{col_name}`: {e}"
16911                    ),
16912                })
16913            })?;
16914            Some(Value::Bytes(bytes))
16915        }
16916        // v7.10.4 — BYTEA → Text round-trip uses the PG hex
16917        // output (lowercase, `\x` prefix). Important when a
16918        // SELECT pulls a bytea cell through a Text column path.
16919        (Value::Bytes(b), DataType::Text) => Some(Value::Text(encode_bytea_hex(&b))),
16920        // v7.17.0 — Text → UUID. PG accepts canonical hyphenated,
16921        // unhyphenated, uppercase, and `{...}`-braced forms; we
16922        // funnel all four through `spg_storage::parse_uuid_str`.
16923        // A malformed literal surfaces as a SQL TypeMismatch
16924        // rather than silently inserting garbage — `0-change
16925        // cutover` requires that an app inserting bad UUID text
16926        // sees the same hard error PG would raise.
16927        (Value::Text(s), DataType::Uuid) => match spg_storage::parse_uuid_str(&s) {
16928            Some(b) => Some(Value::Uuid(b)),
16929            None => {
16930                return Err(EngineError::Eval(EvalError::TypeMismatch {
16931                    detail: alloc::format!(
16932                        "invalid input syntax for type uuid: {s:?} (column `{col_name}`)"
16933                    ),
16934                }));
16935            }
16936        },
16937        // v7.17.0 — UUID → Text canonical 8-4-4-4-12 lowercase.
16938        // Surfaces when a SELECT plucks a uuid cell through a
16939        // Text column path (e.g. INSERT INTO log SELECT id::text
16940        // FROM other_table).
16941        (Value::Uuid(b), DataType::Text) => Some(Value::Text(spg_storage::format_uuid(&b))),
16942        // v7.17.0 Phase 3.P0-32 — Text → TIME. Accepts
16943        // `HH:MM:SS` and `HH:MM:SS.ffffff` (1-6 fractional digits).
16944        // Out-of-range hour/min/sec is a hard SQL error (no
16945        // silent truncation — same 0-change-cutover discipline
16946        // we apply to UUID).
16947        (Value::Text(s), DataType::Time) => match parse_time_str(&s) {
16948            Some(us) => Some(Value::Time(us)),
16949            None => {
16950                return Err(EngineError::Eval(EvalError::TypeMismatch {
16951                    detail: alloc::format!(
16952                        "invalid input syntax for type time: {s:?} (column `{col_name}`)"
16953                    ),
16954                }));
16955            }
16956        },
16957        // v7.17.0 Phase 3.P0-32 — TIME → Text canonical `HH:MM:SS[.ffffff]`.
16958        (Value::Time(us), DataType::Text) => Some(Value::Text(eval::format_time(us))),
16959        // v7.17.0 Phase 3.P0-33 — int / bigint → YEAR. Range
16960        // check enforces the MySQL canonical 1901..=2155 + 0
16961        // sentinel; out-of-range is a hard SQL error (no silent
16962        // truncation, mirrors P0-32 / P0-25 discipline).
16963        (Value::SmallInt(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
16964        (Value::Int(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
16965        (Value::BigInt(n), DataType::Year) => Some(coerce_int_to_year(n, col_name)?),
16966        // Text → YEAR. Accepts the 4-digit decimal form only;
16967        // two-digit YEAR (`'99'` → 1999) was deprecated in MySQL
16968        // 5.7 and is out of scope for v7.17.0.
16969        (Value::Text(s), DataType::Year) => match s.trim().parse::<i64>() {
16970            Ok(n) => Some(coerce_int_to_year(n, col_name)?),
16971            Err(_) => {
16972                return Err(EngineError::Eval(EvalError::TypeMismatch {
16973                    detail: alloc::format!(
16974                        "invalid input syntax for type year: {s:?} (column `{col_name}`)"
16975                    ),
16976                }));
16977            }
16978        },
16979        // YEAR → Text 4-digit zero-padded.
16980        (Value::Year(y), DataType::Text) => Some(Value::Text(alloc::format!("{y:04}"))),
16981        // v7.17.0 Phase 3.P0-34 — Text → TIMETZ. Mandatory
16982        // signed offset suffix; missing offset is a hard error
16983        // (SPG has no session TZ wired into eval, unlike PG).
16984        (Value::Text(s), DataType::TimeTz) => match parse_timetz_str(&s) {
16985            Some((us, offset_secs)) => Some(Value::TimeTz { us, offset_secs }),
16986            None => {
16987                return Err(EngineError::Eval(EvalError::TypeMismatch {
16988                    detail: alloc::format!(
16989                        "invalid input syntax for type time with time zone: \
16990                         {s:?} (column `{col_name}`)"
16991                    ),
16992                }));
16993            }
16994        },
16995        // TIMETZ → Text canonical `HH:MM:SS[.ffffff]±HH[:MM]`.
16996        (Value::TimeTz { us, offset_secs }, DataType::Text) => {
16997            Some(Value::Text(eval::format_timetz(us, offset_secs)))
16998        }
16999        // v7.17.0 Phase 3.P0-35 — Text → MONEY. Accepts `$N.NN`,
17000        // `$N,NNN.NN`, optional leading `-`. Bare numeric literals
17001        // arrive via the Int/BigInt/Float/Numeric arms below.
17002        (Value::Text(s), DataType::Money) => match parse_money_str(&s) {
17003            Some(c) => Some(Value::Money(c)),
17004            None => {
17005                return Err(EngineError::Eval(EvalError::TypeMismatch {
17006                    detail: alloc::format!(
17007                        "invalid input syntax for type money: {s:?} (column `{col_name}`)"
17008                    ),
17009                }));
17010            }
17011        },
17012        // Int / BigInt / SmallInt / Float / Numeric → MONEY.
17013        // Bare numeric literal is interpreted as a major-unit
17014        // amount (matches PG: `100`::money → $100.00 = 10000 cents).
17015        (Value::SmallInt(n), DataType::Money) => {
17016            Some(Value::Money(i64::from(n).saturating_mul(100)))
17017        }
17018        (Value::Int(n), DataType::Money) => Some(Value::Money(i64::from(n).saturating_mul(100))),
17019        (Value::BigInt(n), DataType::Money) => Some(Value::Money(n.saturating_mul(100))),
17020        (Value::Float(x), DataType::Money) => {
17021            // Round half-away-from-zero to cents (no_std — no
17022            // `f64::round`, so hand-roll via biased truncation).
17023            let scaled = x * 100.0;
17024            let cents = if scaled >= 0.0 {
17025                (scaled + 0.5) as i64
17026            } else {
17027                (scaled - 0.5) as i64
17028            };
17029            Some(Value::Money(cents))
17030        }
17031        (Value::Numeric { scaled, scale }, DataType::Money) => {
17032            // Convert exact decimal to cents (scale 2). If scale > 2,
17033            // round half-away-from-zero. If scale < 2, multiply up.
17034            let cents = if scale == 2 {
17035                scaled
17036            } else if scale < 2 {
17037                let mult = 10_i128.pow(u32::from(2 - scale));
17038                scaled.saturating_mul(mult)
17039            } else {
17040                let div = 10_i128.pow(u32::from(scale - 2));
17041                let half = div / 2;
17042                let bias = if scaled >= 0 { half } else { -half };
17043                (scaled + bias) / div
17044            };
17045            Some(Value::Money(i64::try_from(cents).unwrap_or(i64::MAX)))
17046        }
17047        // MONEY → Text canonical `$N,NNN.CC`.
17048        (Value::Money(c), DataType::Text) => Some(Value::Text(eval::format_money(c))),
17049        // v7.17.0 Phase 3.P0-38 — Text → Range. Accepts canonical
17050        // PG forms: `'empty'`, `'[a,b)'`, `'(a,b]'`, `'[a,b]'`,
17051        // `'(a,b)'`, with empty lower or upper for unbounded.
17052        (Value::Text(s), DataType::Range(kind)) => match parse_range_str(&s, kind) {
17053            Some(v) => Some(v),
17054            None => {
17055                return Err(EngineError::Eval(EvalError::TypeMismatch {
17056                    detail: alloc::format!(
17057                        "invalid input syntax for range type: {s:?} (column `{col_name}`)"
17058                    ),
17059                }));
17060            }
17061        },
17062        // Range → Text canonical form (`[a,b)`, `'empty'`, etc).
17063        (v @ Value::Range { .. }, DataType::Text) => Some(Value::Text(format_range_str(&v))),
17064        // v7.17.0 Phase 3.P0-39 — Text → Hstore.
17065        (Value::Text(s), DataType::Hstore) => match parse_hstore_str(&s) {
17066            Some(pairs) => Some(Value::Hstore(pairs)),
17067            None => {
17068                return Err(EngineError::Eval(EvalError::TypeMismatch {
17069                    detail: alloc::format!(
17070                        "invalid input syntax for type hstore: {s:?} (column `{col_name}`)"
17071                    ),
17072                }));
17073            }
17074        },
17075        // Hstore → Text canonical `"k"=>"v"` form.
17076        (Value::Hstore(pairs), DataType::Text) => Some(Value::Text(format_hstore_str(&pairs))),
17077        // v7.17.0 Phase 3.P0-40 — Text → 2D arrays via PG
17078        // external `'{{a,b},{c,d}}'` literal.
17079        (Value::Text(s), DataType::IntArray2D) => match parse_int_2d_literal(&s) {
17080            Ok(m) => Some(Value::IntArray2D(m)),
17081            Err(e) => {
17082                return Err(EngineError::Eval(EvalError::TypeMismatch {
17083                    detail: alloc::format!(
17084                        "invalid input syntax for INT[][]: {s:?} (column `{col_name}`): {e}"
17085                    ),
17086                }));
17087            }
17088        },
17089        (Value::Text(s), DataType::BigIntArray2D) => match parse_bigint_2d_literal(&s) {
17090            Ok(m) => Some(Value::BigIntArray2D(m)),
17091            Err(e) => {
17092                return Err(EngineError::Eval(EvalError::TypeMismatch {
17093                    detail: alloc::format!(
17094                        "invalid input syntax for BIGINT[][]: {s:?} (column `{col_name}`): {e}"
17095                    ),
17096                }));
17097            }
17098        },
17099        (Value::Text(s), DataType::TextArray2D) => match parse_text_2d_literal(&s) {
17100            Ok(m) => Some(Value::TextArray2D(m)),
17101            Err(e) => {
17102                return Err(EngineError::Eval(EvalError::TypeMismatch {
17103                    detail: alloc::format!(
17104                        "invalid input syntax for TEXT[][]: {s:?} (column `{col_name}`): {e}"
17105                    ),
17106                }));
17107            }
17108        },
17109        // 2D arrays → Text canonical nested form.
17110        (Value::IntArray2D(rows), DataType::Text) => Some(Value::Text(format_int_2d_text(&rows))),
17111        (Value::BigIntArray2D(rows), DataType::Text) => {
17112            Some(Value::Text(format_bigint_2d_text(&rows)))
17113        }
17114        (Value::TextArray2D(rows), DataType::Text) => Some(Value::Text(format_text_2d_text(&rows))),
17115        // v7.10.11 — Text → TEXT[]. Decode PG's external array
17116        // form `'{a,b,NULL}'`. NULL element token (case-insensitive)
17117        // is the literal `NULL`; everything else is a quoted or
17118        // unquoted text element. mailrs `'{label1,label2}'::TEXT[]`.
17119        (Value::Text(s), DataType::TextArray) => {
17120            let arr = decode_text_array_literal(&s).map_err(|e| {
17121                EngineError::Eval(EvalError::TypeMismatch {
17122                    detail: alloc::format!(
17123                        "cannot parse {s:?} as TEXT[] for column `{col_name}`: {e}"
17124                    ),
17125                })
17126            })?;
17127            Some(Value::TextArray(arr))
17128        }
17129        // v7.16.0 — Text → IntArray / BigIntArray for the
17130        // spg-sqlx Bind path. Decode the PG external form
17131        // `{1,2,3}` as a TEXT array first, then parse each
17132        // element as int. Same shape as the TextArray decode
17133        // above with an element-wise narrow.
17134        (Value::Text(s), DataType::IntArray) => {
17135            let arr = decode_text_array_literal(&s).map_err(|e| {
17136                EngineError::Eval(EvalError::TypeMismatch {
17137                    detail: alloc::format!(
17138                        "cannot parse {s:?} as INT[] for column `{col_name}`: {e}"
17139                    ),
17140                })
17141            })?;
17142            let mut out: Vec<Option<i32>> = Vec::with_capacity(arr.len());
17143            for elem in arr {
17144                match elem {
17145                    None => out.push(None),
17146                    Some(t) => {
17147                        let n: i32 = t.parse().map_err(|_| {
17148                            EngineError::Eval(EvalError::TypeMismatch {
17149                                detail: alloc::format!(
17150                                    "cannot parse {t:?} as INT element for `{col_name}`"
17151                                ),
17152                            })
17153                        })?;
17154                        out.push(Some(n));
17155                    }
17156                }
17157            }
17158            Some(Value::IntArray(out))
17159        }
17160        (Value::Text(s), DataType::BigIntArray) => {
17161            let arr = decode_text_array_literal(&s).map_err(|e| {
17162                EngineError::Eval(EvalError::TypeMismatch {
17163                    detail: alloc::format!(
17164                        "cannot parse {s:?} as BIGINT[] for column `{col_name}`: {e}"
17165                    ),
17166                })
17167            })?;
17168            let mut out: Vec<Option<i64>> = Vec::with_capacity(arr.len());
17169            for elem in arr {
17170                match elem {
17171                    None => out.push(None),
17172                    Some(t) => {
17173                        let n: i64 = t.parse().map_err(|_| {
17174                            EngineError::Eval(EvalError::TypeMismatch {
17175                                detail: alloc::format!(
17176                                    "cannot parse {t:?} as BIGINT element for `{col_name}`"
17177                                ),
17178                            })
17179                        })?;
17180                        out.push(Some(n));
17181                    }
17182                }
17183            }
17184            Some(Value::BigIntArray(out))
17185        }
17186        // v7.10.11 — TEXT[] → Text round-trip uses PG's
17187        // external array form (`{a,b,NULL}`). Lets a SELECT
17188        // pull an array column through any Text-side codepath.
17189        (Value::TextArray(items), DataType::Text) => Some(Value::Text(encode_text_array(&items))),
17190        // v7.17.0 Phase 3.P0-68 — Text → VECTOR auto-coerce.
17191        // Matches the existing Text → TsVector arm and the
17192        // `::vector` cast: PG-canonical pgvector external form
17193        // (`'[1, 2, -3]'`) becomes a typed Vector value at the
17194        // column boundary. Dim mismatch surfaces as TypeMismatch.
17195        // For SQ8 / HALF encodings we chain through the standard
17196        // quantise helpers so the storage shape matches the
17197        // declared encoding without a second coerce pass.
17198        (Value::Text(s), DataType::Vector { dim, encoding }) => {
17199            let parsed = eval::parse_vector_text(&s).ok_or_else(|| {
17200                EngineError::Eval(EvalError::TypeMismatch {
17201                    detail: alloc::format!("cannot parse {s:?} as VECTOR for column `{col_name}`"),
17202                })
17203            })?;
17204            if parsed.len() != dim as usize {
17205                return Err(EngineError::Eval(EvalError::TypeMismatch {
17206                    detail: alloc::format!(
17207                        "VECTOR({dim}) column `{col_name}` rejects literal of length {}",
17208                        parsed.len()
17209                    ),
17210                }));
17211            }
17212            Some(match encoding {
17213                VecEncoding::F32 => Value::Vector(parsed),
17214                VecEncoding::Sq8 => Value::Sq8Vector(spg_storage::quantize::quantize(&parsed)),
17215                VecEncoding::F16 => {
17216                    Value::HalfVector(spg_storage::halfvec::HalfVector::from_f32_slice(&parsed))
17217                }
17218            })
17219        }
17220        // v7.16.1 — Text → TSVECTOR auto-coerce for the
17221        // INSERT-side wire path (mailrs round-9 A.2.a). PG
17222        // implicitly promotes the TEXT literal at INSERT into a
17223        // TSVECTOR column; SPG previously rejected with a hard
17224        // type mismatch, blocking 23,276 pg_dump rows into
17225        // `messages.search_vector`. We route through the same
17226        // `decode_tsvector_external` the `::tsvector` cast
17227        // already uses, so PG-canonical forms (`'word'`,
17228        // `'word:1A,2B'`, multi-lexeme, empty `''`) all parse.
17229        (Value::Text(s), DataType::TsVector) => {
17230            let lexs = eval::decode_tsvector_external(&s).map_err(|e| {
17231                EngineError::Eval(EvalError::TypeMismatch {
17232                    detail: alloc::format!(
17233                        "cannot parse {s:?} as TSVECTOR for column `{col_name}`: {e}"
17234                    ),
17235                })
17236            })?;
17237            Some(Value::TsVector(lexs))
17238        }
17239        (Value::Text(s), DataType::Timestamp | DataType::Timestamptz) => {
17240            let t = eval::parse_timestamp_literal(&s).ok_or_else(|| {
17241                EngineError::Eval(EvalError::TypeMismatch {
17242                    detail: alloc::format!(
17243                        "cannot parse {s:?} as TIMESTAMP for column `{col_name}`"
17244                    ),
17245                })
17246            })?;
17247            Some(Value::Timestamp(t))
17248        }
17249        // DATE ↔ TIMESTAMP convertibility (DATE → midnight,
17250        // TIMESTAMP → day truncation).
17251        (Value::Date(d), DataType::Timestamp | DataType::Timestamptz) => {
17252            Some(Value::Timestamp(i64::from(d) * 86_400_000_000))
17253        }
17254        // v7.9.21 — Value::Timestamp lands in either Timestamp
17255        // or Timestamptz columns; the on-disk layout is the
17256        // same i64 microseconds UTC.
17257        (Value::Timestamp(t), DataType::Timestamptz) => Some(Value::Timestamp(t)),
17258        (Value::Timestamp(t), DataType::Date) => {
17259            let days = t.div_euclid(86_400_000_000);
17260            i32::try_from(days).ok().map(Value::Date)
17261        }
17262        (
17263            Value::Numeric {
17264                scaled,
17265                scale: src_scale,
17266            },
17267            DataType::Numeric { precision, scale },
17268        ) => Some(numeric_rescale(
17269            scaled, src_scale, precision, scale, col_name,
17270        )?),
17271        #[allow(clippy::cast_precision_loss)]
17272        (Value::Numeric { scaled, scale }, DataType::Float) => {
17273            let mut div = 1.0_f64;
17274            for _ in 0..scale {
17275                div *= 10.0;
17276            }
17277            Some(Value::Float((scaled as f64) / div))
17278        }
17279        (Value::Numeric { scaled, scale }, DataType::Int) => {
17280            let truncated = numeric_truncate_to_integer(scaled, scale);
17281            i32::try_from(truncated).ok().map(Value::Int)
17282        }
17283        (Value::Numeric { scaled, scale }, DataType::BigInt) => {
17284            let truncated = numeric_truncate_to_integer(scaled, scale);
17285            i64::try_from(truncated).ok().map(Value::BigInt)
17286        }
17287        (Value::Numeric { scaled, scale }, DataType::SmallInt) => {
17288            let truncated = numeric_truncate_to_integer(scaled, scale);
17289            i16::try_from(truncated).ok().map(Value::SmallInt)
17290        }
17291        // VARCHAR(n) enforces an upper bound on character count.
17292        (Value::Text(s), DataType::Varchar(max)) => {
17293            if u32::try_from(s.chars().count()).unwrap_or(u32::MAX) <= max {
17294                Some(Value::Text(s))
17295            } else {
17296                return Err(EngineError::Unsupported(alloc::format!(
17297                    "value for VARCHAR({max}) column `{col_name}` exceeds length: \
17298                     {} chars",
17299                    s.chars().count()
17300                )));
17301            }
17302        }
17303        // v6.0.1: f32 → SQ8 INSERT-time quantisation. Triggered
17304        // when the column declares `VECTOR(N) USING SQ8` and
17305        // the INSERT VALUES expression yields a raw f32 vector
17306        // (the normal pgvector-shape literal). Dim mismatch
17307        // falls through the `_ => None` arm and surfaces as
17308        // `TypeMismatch` with the expected SQ8 column type —
17309        // matching the F32 path's existing error.
17310        (
17311            Value::Vector(v),
17312            DataType::Vector {
17313                dim,
17314                encoding: VecEncoding::Sq8,
17315            },
17316        ) if v.len() == dim as usize => Some(Value::Sq8Vector(spg_storage::quantize::quantize(&v))),
17317        // v6.0.3: f32 → f16 INSERT-time conversion for HALF
17318        // columns. Bit-exact at the storage layer (modulo
17319        // half-precision rounding); no rerank pass needed at
17320        // search time.
17321        (
17322            Value::Vector(v),
17323            DataType::Vector {
17324                dim,
17325                encoding: VecEncoding::F16,
17326            },
17327        ) if v.len() == dim as usize => Some(Value::HalfVector(
17328            spg_storage::halfvec::HalfVector::from_f32_slice(&v),
17329        )),
17330        // CHAR(n) right-pads with U+0020 to exactly n chars; if the input
17331        // is already longer we reject (PG truncates trailing-space-only;
17332        // staying strict for v1).
17333        (Value::Text(s), DataType::Char(size)) => {
17334            let len = u32::try_from(s.chars().count()).unwrap_or(u32::MAX);
17335            if len > size {
17336                return Err(EngineError::Unsupported(alloc::format!(
17337                    "value for CHAR({size}) column `{col_name}` exceeds length: \
17338                     {len} chars"
17339                )));
17340            }
17341            let need = (size - len) as usize;
17342            let mut padded = s;
17343            padded.reserve(need);
17344            for _ in 0..need {
17345                padded.push(' ');
17346            }
17347            Some(Value::Text(padded))
17348        }
17349        _ => None,
17350    };
17351    coerced.ok_or(EngineError::Storage(StorageError::TypeMismatch {
17352        column: col_name.into(),
17353        expected,
17354        actual,
17355        position,
17356    }))
17357}
17358
17359/// v7.12.4 — render a function arg list into the
17360/// canonical form the storage layer caches as
17361/// [`spg_storage::FunctionDef::args_repr`]. The catalogue uses
17362/// this string for both display + as a coarse signature key
17363/// for the (deferred) overload resolution v7.12.5+ adds.
17364fn render_function_args(args: &[spg_sql::ast::FunctionArg]) -> alloc::string::String {
17365    use core::fmt::Write;
17366    let mut out = alloc::string::String::from("(");
17367    for (i, a) in args.iter().enumerate() {
17368        if i > 0 {
17369            out.push_str(", ");
17370        }
17371        match a.mode {
17372            spg_sql::ast::FunctionArgMode::In => {}
17373            spg_sql::ast::FunctionArgMode::Out => out.push_str("OUT "),
17374            spg_sql::ast::FunctionArgMode::InOut => out.push_str("INOUT "),
17375        }
17376        if let Some(n) = &a.name {
17377            out.push_str(n);
17378            out.push(' ');
17379        }
17380        match &a.ty {
17381            spg_sql::ast::FunctionArgType::Typed(t) => {
17382                let _ = write!(out, "{t}");
17383            }
17384            spg_sql::ast::FunctionArgType::Raw(s) => out.push_str(s),
17385        }
17386    }
17387    out.push(')');
17388    out
17389}
17390
17391#[cfg(test)]
17392mod tests {
17393    use super::*;
17394    use alloc::vec;
17395
17396    fn unwrap_command_ok(r: &QueryResult) -> usize {
17397        match r {
17398            QueryResult::CommandOk { affected, .. } => *affected,
17399            QueryResult::Rows { .. } => panic!("expected CommandOk, got Rows"),
17400        }
17401    }
17402
17403    #[test]
17404    fn create_table_registers_schema() {
17405        let mut e = Engine::new();
17406        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT)")
17407            .unwrap();
17408        assert_eq!(e.catalog().table_count(), 1);
17409        let t = e.catalog().get("foo").unwrap();
17410        assert_eq!(t.schema().columns.len(), 2);
17411        assert_eq!(t.schema().columns[0].ty, DataType::Int);
17412        assert!(!t.schema().columns[0].nullable);
17413        assert_eq!(t.schema().columns[1].ty, DataType::Text);
17414    }
17415
17416    #[test]
17417    fn create_table_vector_default_is_f32_encoded() {
17418        let mut e = Engine::new();
17419        e.execute("CREATE TABLE t (v VECTOR(8))").unwrap();
17420        let t = e.catalog().get("t").unwrap();
17421        assert_eq!(
17422            t.schema().columns[0].ty,
17423            DataType::Vector {
17424                dim: 8,
17425                encoding: VecEncoding::F32,
17426            },
17427        );
17428    }
17429
17430    #[test]
17431    fn create_table_vector_using_sq8_succeeds() {
17432        // v6.0.1 step 3: the step-1 fence in `column_def_to_schema`
17433        // is lifted. CREATE TABLE persists an SQ8 column type in
17434        // the catalog; INSERT (next test) quantises raw f32 input.
17435        let mut e = Engine::new();
17436        e.execute("CREATE TABLE t (v VECTOR(8) USING SQ8)").unwrap();
17437        let t = e.catalog().get("t").unwrap();
17438        assert_eq!(
17439            t.schema().columns[0].ty,
17440            DataType::Vector {
17441                dim: 8,
17442                encoding: VecEncoding::Sq8,
17443            },
17444        );
17445    }
17446
17447    #[test]
17448    fn insert_into_sq8_column_quantises_f32_payload() {
17449        // v6.0.1 step 3: INSERT-time `coerce_value` rewrites a raw
17450        // `Value::Vector(Vec<f32>)` literal into the column's
17451        // quantised representation. The row that lands in the
17452        // catalog must therefore hold a `Value::Sq8Vector`, not the
17453        // original f32 buffer — that's the bit that delivers the
17454        // 4× compression target.
17455        let mut e = Engine::new();
17456        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
17457        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
17458            .unwrap();
17459        let t = e.catalog().get("t").unwrap();
17460        assert_eq!(t.rows().len(), 1);
17461        match &t.rows()[0].values[0] {
17462            Value::Sq8Vector(q) => {
17463                assert_eq!(q.bytes.len(), 4);
17464                // min/max are derived from the payload: min=0.0, max=1.0.
17465                assert!((q.min - 0.0).abs() < 1e-6);
17466                assert!((q.max - 1.0).abs() < 1e-6);
17467            }
17468            other => panic!("expected Sq8Vector cell, got {other:?}"),
17469        }
17470    }
17471
17472    #[test]
17473    fn create_table_vector_using_half_succeeds_and_insert_converts_to_f16() {
17474        // v6.0.3: CREATE TABLE accepts USING HALF; INSERT path
17475        // converts the incoming `Value::Vector(Vec<f32>)` cell
17476        // into `Value::HalfVector(HalfVector)` via the new
17477        // `coerce_value` arm. The dequantised round-trip is
17478        // bit-exact for f16-representable values, so 0.0 / 0.25
17479        // / 0.5 / 1.0 hit their grid points exactly.
17480        let mut e = Engine::new();
17481        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
17482            .unwrap();
17483        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
17484            .unwrap();
17485        let t = e.catalog().get("t").unwrap();
17486        assert_eq!(t.rows().len(), 1);
17487        match &t.rows()[0].values[0] {
17488            Value::HalfVector(h) => {
17489                assert_eq!(h.dim(), 4);
17490                let back = h.to_f32_vec();
17491                let expected = alloc::vec![0.0_f32, 0.25, 0.5, 1.0];
17492                for (g, e) in back.iter().zip(expected.iter()) {
17493                    assert!(
17494                        (g - e).abs() < 1e-6,
17495                        "{g} vs {e} should be exact on f16 grid"
17496                    );
17497                }
17498            }
17499            other => panic!("expected HalfVector cell, got {other:?}"),
17500        }
17501    }
17502
17503    #[test]
17504    fn alter_index_rebuild_in_place_succeeds() {
17505        // v6.0.4: bare REBUILD (no encoding switch) walks every
17506        // row again to rebuild the NSW graph. Verifies the engine
17507        // dispatch + storage helper plumbing without changing any
17508        // cell encoding.
17509        let mut e = Engine::new();
17510        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
17511            .unwrap();
17512        for i in 0..8_i32 {
17513            #[allow(clippy::cast_precision_loss)]
17514            let base = (i as f32) * 0.1;
17515            e.execute(&alloc::format!(
17516                "INSERT INTO t VALUES ({i}, [{base}, {b1}, {b2}])",
17517                b1 = base + 0.01,
17518                b2 = base + 0.02,
17519            ))
17520            .unwrap();
17521        }
17522        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
17523        e.execute("ALTER INDEX t_idx REBUILD").unwrap();
17524        // Schema encoding stays F32 (no encoding clause).
17525        assert_eq!(
17526            e.catalog().get("t").unwrap().schema().columns[1].ty,
17527            DataType::Vector {
17528                dim: 3,
17529                encoding: VecEncoding::F32,
17530            },
17531        );
17532    }
17533
17534    #[test]
17535    fn alter_index_rebuild_with_encoding_switches_cell_type() {
17536        // v6.0.4: REBUILD WITH (encoding = SQ8) recodes every
17537        // stored cell from F32 → SQ8 + rebuilds the graph atop the
17538        // new encoding. Post-rebuild, cells must be Sq8Vector and
17539        // the schema must report encoding = Sq8.
17540        let mut e = Engine::new();
17541        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(4) NOT NULL)")
17542            .unwrap();
17543        e.execute("INSERT INTO t VALUES (1, [0.0, 0.25, 0.5, 1.0])")
17544            .unwrap();
17545        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
17546        e.execute("ALTER INDEX t_idx REBUILD WITH (encoding = SQ8)")
17547            .unwrap();
17548        let t = e.catalog().get("t").unwrap();
17549        assert_eq!(
17550            t.schema().columns[1].ty,
17551            DataType::Vector {
17552                dim: 4,
17553                encoding: VecEncoding::Sq8,
17554            },
17555        );
17556        assert!(matches!(t.rows()[0].values[1], Value::Sq8Vector(_)));
17557    }
17558
17559    #[test]
17560    fn alter_index_rebuild_unknown_index_errors() {
17561        let mut e = Engine::new();
17562        let err = e.execute("ALTER INDEX nope REBUILD").unwrap_err();
17563        assert!(
17564            matches!(
17565                &err,
17566                EngineError::Storage(StorageError::IndexNotFound { name }) if name == "nope"
17567            ),
17568            "got: {err}"
17569        );
17570    }
17571
17572    #[test]
17573    fn alter_index_rebuild_on_btree_index_errors() {
17574        // REBUILD on a B-tree index has no semantic meaning in
17575        // v6.0.4 — rejected at the storage layer with `Unsupported`.
17576        let mut e = Engine::new();
17577        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
17578        e.execute("INSERT INTO t VALUES (1)").unwrap();
17579        e.execute("CREATE INDEX t_idx ON t (id)").unwrap();
17580        let err = e.execute("ALTER INDEX t_idx REBUILD").unwrap_err();
17581        assert!(
17582            matches!(&err, EngineError::Storage(StorageError::Unsupported(_))),
17583            "got: {err}"
17584        );
17585    }
17586
17587    #[test]
17588    fn prepared_insert_substitutes_placeholders() {
17589        // v6.1.1: prepare() parses once; execute_prepared() walks the
17590        // AST and replaces $1/$2 with the param Values BEFORE the
17591        // dispatch sees them. Same logical result as a simple-query
17592        // INSERT, but parse happens once per *statement*, not per
17593        // execution.
17594        let mut e = Engine::new();
17595        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
17596            .unwrap();
17597        let stmt = e.prepare("INSERT INTO t VALUES ($1, $2)").unwrap();
17598        for (id, name) in [(1, "alice"), (2, "bob"), (3, "carol")] {
17599            e.execute_prepared(stmt.clone(), &[Value::Int(id), Value::Text(name.into())])
17600                .unwrap();
17601        }
17602        // Read back via simple-query SELECT.
17603        let rows_result = e.execute("SELECT id, name FROM t").unwrap();
17604        let QueryResult::Rows { rows, .. } = rows_result else {
17605            panic!("expected Rows")
17606        };
17607        assert_eq!(rows.len(), 3);
17608    }
17609
17610    #[test]
17611    fn prepared_select_with_placeholder_filters_rows() {
17612        let mut e = Engine::new();
17613        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
17614            .unwrap();
17615        for i in 0..10_i32 {
17616            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
17617                .unwrap();
17618        }
17619        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
17620        let QueryResult::Rows { rows, .. } = e.execute_prepared(stmt, &[Value::Int(35)]).unwrap()
17621        else {
17622            panic!("expected Rows")
17623        };
17624        // v = 35 means i*7 = 35 → i = 5.
17625        assert_eq!(rows.len(), 1);
17626        assert_eq!(rows[0].values[0], Value::Int(5));
17627    }
17628
17629    #[test]
17630    fn prepared_too_few_params_errors() {
17631        let mut e = Engine::new();
17632        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
17633        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
17634        let err = e.execute_prepared(stmt, &[]).unwrap_err();
17635        assert!(
17636            matches!(
17637                &err,
17638                EngineError::Eval(EvalError::PlaceholderOutOfRange { n: 1, bound: 0 })
17639            ),
17640            "got: {err}"
17641        );
17642    }
17643
17644    #[test]
17645    fn bytea_cast_round_trips_text_input() {
17646        // v7.18 — `'hello'::bytea` produces the raw bytes. Closes
17647        // the mailrs D-pre #3 reverse-acceptance gap.
17648        let e = Engine::new();
17649        let r = e.execute_readonly("SELECT 'hello'::bytea").unwrap();
17650        let QueryResult::Rows { rows, .. } = r else { panic!("expected Rows") };
17651        assert_eq!(rows.len(), 1);
17652        assert_eq!(rows[0].values[0], Value::Bytes(b"hello".to_vec()));
17653    }
17654
17655    #[test]
17656    fn bytea_cast_pg_escape_hex_form() {
17657        // E'\\xdeadbeef'::bytea — E-string decodes to `\xdeadbeef`
17658        // (literal 10 chars), then ::bytea reads it as PG hex
17659        // form bytea literal → 4 bytes.
17660        let e = Engine::new();
17661        let r = e
17662            .execute_readonly(r"SELECT E'\\xdeadbeef'::bytea")
17663            .unwrap();
17664        let QueryResult::Rows { rows, .. } = r else { panic!("expected Rows") };
17665        assert_eq!(
17666            rows[0].values[0],
17667            Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef])
17668        );
17669    }
17670
17671    #[test]
17672    fn bytea_cast_chains_through_octet_length() {
17673        // octet_length('hello'::bytea) → 5. Confirms the cast
17674        // composes inside larger expressions, not just at top
17675        // level.
17676        let e = Engine::new();
17677        let r = e
17678            .execute_readonly("SELECT octet_length('hello'::bytea)")
17679            .unwrap();
17680        let QueryResult::Rows { rows, .. } = r else { panic!("expected Rows") };
17681        match &rows[0].values[0] {
17682            Value::Int(n) => assert_eq!(*n, 5),
17683            Value::BigInt(n) => assert_eq!(*n, 5),
17684            other => panic!("expected integer length, got {other:?}"),
17685        }
17686    }
17687
17688    #[test]
17689    fn readonly_prepared_on_snapshot_select_with_placeholder() {
17690        // v7.18 — sqlx Pool fan-out relies on running prepared
17691        // SELECTs against a frozen snapshot without re-entering
17692        // the writer engine. Mirrors the simple-query SELECT path
17693        // in `execute_readonly_on_snapshot` but takes a Statement
17694        // + bound params (the shape sqlx's Execute path produces).
17695        let mut e = Engine::new();
17696        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
17697            .unwrap();
17698        for i in 0..10_i32 {
17699            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
17700                .unwrap();
17701        }
17702        let snapshot = e.clone_snapshot();
17703        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
17704        let QueryResult::Rows { rows, .. } =
17705            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(35)])
17706                .unwrap()
17707        else {
17708            panic!("expected Rows")
17709        };
17710        assert_eq!(rows.len(), 1);
17711        assert_eq!(rows[0].values[0], Value::Int(5));
17712    }
17713
17714    #[test]
17715    fn readonly_prepared_on_snapshot_rejects_writes() {
17716        // DDL / DML prepared statements on the readonly path must
17717        // surface `WriteRequired` so the spg-sqlx connection layer
17718        // routes them to the writer mutex instead of the snapshot.
17719        let mut e = Engine::new();
17720        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
17721        let snapshot = e.clone_snapshot();
17722        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
17723        let err = Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(1)])
17724            .unwrap_err();
17725        assert!(
17726            matches!(&err, EngineError::WriteRequired),
17727            "got: {err}"
17728        );
17729    }
17730
17731    #[test]
17732    fn readonly_prepared_on_snapshot_frozen_view() {
17733        // The snapshot reflects engine state at clone_snapshot()
17734        // time. Writes after the snapshot are NOT visible — caller
17735        // takes a fresh snapshot (or `AsyncReadHandle::refresh()`)
17736        // to see them. This is the contract the per-statement
17737        // refresh in spg-sqlx relies on.
17738        let mut e = Engine::new();
17739        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
17740        e.execute("INSERT INTO t VALUES (1)").unwrap();
17741        let snapshot = e.clone_snapshot();
17742        e.execute("INSERT INTO t VALUES (2)").unwrap();
17743        let stmt = e.prepare("SELECT id FROM t WHERE id = $1").unwrap();
17744        let QueryResult::Rows { rows, .. } =
17745            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(2)])
17746                .unwrap()
17747        else {
17748            panic!("expected Rows")
17749        };
17750        assert!(rows.is_empty(), "id=2 was inserted after snapshot");
17751    }
17752
17753    #[test]
17754    fn describe_prepared_on_snapshot_resolves_columns() {
17755        // v7.18 — sqlx's Executor::describe path on the readonly
17756        // fan-out needs to resolve column names + types against
17757        // the snapshot's catalog (not the live engine's catalog,
17758        // which may have moved on).
17759        let mut e = Engine::new();
17760        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
17761            .unwrap();
17762        let snapshot = e.clone_snapshot();
17763        let stmt = e.prepare("SELECT id, name FROM t WHERE id = $1").unwrap();
17764        let (_params, cols) = Engine::describe_prepared_on_snapshot(&snapshot, &stmt);
17765        assert_eq!(cols.len(), 2);
17766        assert_eq!(cols[0].name, "id");
17767        assert_eq!(cols[0].ty, DataType::Int);
17768        assert_eq!(cols[1].name, "name");
17769        assert_eq!(cols[1].ty, DataType::Text);
17770    }
17771
17772    #[test]
17773    fn insert_into_half_column_dim_mismatch_errors() {
17774        let mut e = Engine::new();
17775        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
17776            .unwrap();
17777        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
17778        assert!(matches!(
17779            &err,
17780            EngineError::Storage(StorageError::TypeMismatch { .. })
17781        ));
17782    }
17783
17784    #[test]
17785    fn insert_into_sq8_column_dim_mismatch_errors() {
17786        // Dim mismatch falls through the `coerce_value` Vector→Sq8
17787        // arm's guard and surfaces as `TypeMismatch` — the same
17788        // error the F32 path produces today, so client error
17789        // handling stays uniform across encodings.
17790        let mut e = Engine::new();
17791        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
17792        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
17793        assert!(
17794            matches!(
17795                &err,
17796                EngineError::Storage(StorageError::TypeMismatch { .. })
17797            ),
17798            "got: {err}",
17799        );
17800    }
17801
17802    #[test]
17803    fn create_table_duplicate_errors() {
17804        let mut e = Engine::new();
17805        e.execute("CREATE TABLE foo (a INT)").unwrap();
17806        let err = e.execute("CREATE TABLE foo (a INT)").unwrap_err();
17807        assert!(matches!(
17808            err,
17809            EngineError::Storage(StorageError::DuplicateTable { ref name }) if name == "foo"
17810        ));
17811    }
17812
17813    #[test]
17814    fn insert_into_unknown_table_errors() {
17815        let mut e = Engine::new();
17816        let err = e.execute("INSERT INTO ghost VALUES (1)").unwrap_err();
17817        assert!(matches!(
17818            err,
17819            EngineError::Storage(StorageError::TableNotFound { ref name }) if name == "ghost"
17820        ));
17821    }
17822
17823    #[test]
17824    fn insert_happy_path_reports_one_affected() {
17825        let mut e = Engine::new();
17826        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
17827        let r = e.execute("INSERT INTO foo VALUES (42)").unwrap();
17828        assert_eq!(unwrap_command_ok(&r), 1);
17829        assert_eq!(e.catalog().get("foo").unwrap().row_count(), 1);
17830    }
17831
17832    #[test]
17833    fn insert_arity_mismatch_propagates() {
17834        let mut e = Engine::new();
17835        e.execute("CREATE TABLE foo (a INT, b TEXT)").unwrap();
17836        let err = e.execute("INSERT INTO foo VALUES (1)").unwrap_err();
17837        assert!(matches!(
17838            err,
17839            EngineError::Storage(StorageError::ArityMismatch { .. })
17840        ));
17841    }
17842
17843    #[test]
17844    fn insert_negative_integer_via_unary_minus() {
17845        let mut e = Engine::new();
17846        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
17847        e.execute("INSERT INTO foo VALUES (-7)").unwrap();
17848        let rows = e.catalog().get("foo").unwrap().rows();
17849        assert_eq!(rows[0].values[0], Value::Int(-7));
17850    }
17851
17852    #[test]
17853    fn insert_expression_evaluated_against_empty_context() {
17854        // PG-canonical: INSERT VALUES accepts an arbitrary scalar
17855        // expression. The engine evaluates against an empty row
17856        // context — column references would error, but pure
17857        // arithmetic / function calls are fine.
17858        let mut e = Engine::new();
17859        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
17860        e.execute("INSERT INTO foo VALUES (1 + 2)").unwrap();
17861        let rows = e.catalog().get("foo").unwrap().rows();
17862        assert_eq!(rows[0].values[0], Value::Int(3));
17863    }
17864
17865    #[test]
17866    fn select_star_returns_all_rows_in_insertion_order() {
17867        let mut e = Engine::new();
17868        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT NOT NULL)")
17869            .unwrap();
17870        e.execute("INSERT INTO foo VALUES (1, 'one')").unwrap();
17871        e.execute("INSERT INTO foo VALUES (2, 'two')").unwrap();
17872        e.execute("INSERT INTO foo VALUES (3, 'three')").unwrap();
17873
17874        let r = e.execute("SELECT * FROM foo").unwrap();
17875        let QueryResult::Rows { columns, rows } = r else {
17876            panic!("expected Rows")
17877        };
17878        assert_eq!(columns.len(), 2);
17879        assert_eq!(columns[0].name, "a");
17880        assert_eq!(rows.len(), 3);
17881        assert_eq!(
17882            rows[1].values,
17883            vec![Value::Int(2), Value::Text("two".into())]
17884        );
17885    }
17886
17887    #[test]
17888    fn select_star_on_empty_table_returns_zero_rows() {
17889        let mut e = Engine::new();
17890        e.execute("CREATE TABLE foo (a INT)").unwrap();
17891        let r = e.execute("SELECT * FROM foo").unwrap();
17892        match r {
17893            QueryResult::Rows { rows, .. } => assert!(rows.is_empty()),
17894            QueryResult::CommandOk { .. } => panic!("expected Rows"),
17895        }
17896    }
17897
17898    // --- v0.4: WHERE + projection ------------------------------------------
17899
17900    fn make_three_row_users(e: &mut Engine) {
17901        e.execute("CREATE TABLE users (id INT NOT NULL, name TEXT NOT NULL, score INT)")
17902            .unwrap();
17903        e.execute("INSERT INTO users VALUES (1, 'alice', 90)")
17904            .unwrap();
17905        e.execute("INSERT INTO users VALUES (2, 'bob', NULL)")
17906            .unwrap();
17907        e.execute("INSERT INTO users VALUES (3, 'cara', 70)")
17908            .unwrap();
17909    }
17910
17911    fn unwrap_rows(r: QueryResult) -> (Vec<ColumnSchema>, Vec<Row>) {
17912        match r {
17913            QueryResult::Rows { columns, rows } => (columns, rows),
17914            QueryResult::CommandOk { .. } => panic!("expected Rows"),
17915        }
17916    }
17917
17918    #[test]
17919    fn where_filter_passes_only_true_rows() {
17920        let mut e = Engine::new();
17921        make_three_row_users(&mut e);
17922        let r = e.execute("SELECT * FROM users WHERE id > 1").unwrap();
17923        let (_, rows) = unwrap_rows(r);
17924        assert_eq!(rows.len(), 2);
17925        assert_eq!(rows[0].values[0], Value::Int(2));
17926        assert_eq!(rows[1].values[0], Value::Int(3));
17927    }
17928
17929    #[test]
17930    fn where_with_null_result_filters_out_row() {
17931        let mut e = Engine::new();
17932        make_three_row_users(&mut e);
17933        // score is NULL for bob → score > 80 is NULL → row excluded
17934        let r = e.execute("SELECT * FROM users WHERE score > 80").unwrap();
17935        let (_, rows) = unwrap_rows(r);
17936        assert_eq!(rows.len(), 1);
17937        assert_eq!(rows[0].values[1], Value::Text("alice".into()));
17938    }
17939
17940    #[test]
17941    fn projection_named_columns() {
17942        let mut e = Engine::new();
17943        make_three_row_users(&mut e);
17944        let r = e.execute("SELECT name, score FROM users").unwrap();
17945        let (cols, rows) = unwrap_rows(r);
17946        assert_eq!(cols.len(), 2);
17947        assert_eq!(cols[0].name, "name");
17948        assert_eq!(cols[1].name, "score");
17949        assert_eq!(rows.len(), 3);
17950        assert_eq!(
17951            rows[0].values,
17952            vec![Value::Text("alice".into()), Value::Int(90)]
17953        );
17954    }
17955
17956    #[test]
17957    fn projection_with_column_alias() {
17958        let mut e = Engine::new();
17959        make_three_row_users(&mut e);
17960        let r = e
17961            .execute("SELECT name AS who FROM users WHERE id = 1")
17962            .unwrap();
17963        let (cols, rows) = unwrap_rows(r);
17964        assert_eq!(cols[0].name, "who");
17965        assert_eq!(rows.len(), 1);
17966        assert_eq!(rows[0].values[0], Value::Text("alice".into()));
17967    }
17968
17969    #[test]
17970    fn qualified_column_with_table_alias_resolves() {
17971        let mut e = Engine::new();
17972        make_three_row_users(&mut e);
17973        let r = e
17974            .execute("SELECT u.id, u.name FROM users AS u WHERE u.id < 3")
17975            .unwrap();
17976        let (cols, rows) = unwrap_rows(r);
17977        assert_eq!(cols.len(), 2);
17978        assert_eq!(rows.len(), 2);
17979    }
17980
17981    #[test]
17982    fn qualified_column_with_wrong_alias_errors() {
17983        let mut e = Engine::new();
17984        make_three_row_users(&mut e);
17985        let err = e.execute("SELECT x.id FROM users AS u").unwrap_err();
17986        assert!(matches!(
17987            err,
17988            EngineError::Eval(EvalError::UnknownQualifier { ref qualifier }) if qualifier == "x"
17989        ));
17990    }
17991
17992    #[test]
17993    fn select_unknown_column_errors_in_projection() {
17994        let mut e = Engine::new();
17995        make_three_row_users(&mut e);
17996        let err = e.execute("SELECT ghost FROM users").unwrap_err();
17997        assert!(matches!(
17998            err,
17999            EngineError::Eval(EvalError::ColumnNotFound { ref name }) if name == "ghost"
18000        ));
18001    }
18002
18003    #[test]
18004    fn where_unknown_column_errors() {
18005        let mut e = Engine::new();
18006        make_three_row_users(&mut e);
18007        let err = e
18008            .execute("SELECT * FROM users WHERE ghost = 1")
18009            .unwrap_err();
18010        assert!(matches!(
18011            err,
18012            EngineError::Eval(EvalError::ColumnNotFound { .. })
18013        ));
18014    }
18015
18016    #[test]
18017    fn expression_projection_evaluates_and_renders() {
18018        // Compound expressions in the SELECT list are evaluated per row;
18019        // the output column is typed TEXT, name defaults to the expression.
18020        let mut e = Engine::new();
18021        e.execute("CREATE TABLE t (a INT NOT NULL)").unwrap();
18022        e.execute("INSERT INTO t VALUES (3)").unwrap();
18023        let (_, rows) = unwrap_rows(e.execute("SELECT 1 + 2 FROM t").unwrap());
18024        assert_eq!(rows.len(), 1);
18025        // The expression evaluates to integer 3; rendered as the cell value
18026        // (storage::Value::Int(3) since arithmetic kept ints).
18027        assert_eq!(rows[0].values[0], Value::Int(3));
18028    }
18029
18030    #[test]
18031    fn select_unknown_table_errors() {
18032        let mut e = Engine::new();
18033        let err = e.execute("SELECT * FROM ghost").unwrap_err();
18034        assert!(matches!(
18035            err,
18036            EngineError::Storage(StorageError::TableNotFound { .. })
18037        ));
18038    }
18039
18040    #[test]
18041    fn invalid_sql_returns_parse_error() {
18042        // v4.4: UPDATE is now real SQL, so use a true syntactic
18043        // garbage payload for the parse-error path.
18044        let mut e = Engine::new();
18045        let err = e.execute("THIS_IS_NOT_A_KEYWORD foo bar baz").unwrap_err();
18046        assert!(matches!(err, EngineError::Parse(_)));
18047    }
18048
18049    // --- v0.8 CREATE INDEX + index seek ------------------------------------
18050
18051    #[test]
18052    fn create_index_registers_on_table() {
18053        let mut e = Engine::new();
18054        make_three_row_users(&mut e);
18055        e.execute("CREATE INDEX by_name ON users (name)").unwrap();
18056        let t = e.catalog().get("users").unwrap();
18057        assert_eq!(t.indices().len(), 1);
18058        assert_eq!(t.indices()[0].name, "by_name");
18059    }
18060
18061    #[test]
18062    fn create_index_on_unknown_table_errors() {
18063        let mut e = Engine::new();
18064        let err = e.execute("CREATE INDEX i ON ghost (a)").unwrap_err();
18065        assert!(matches!(
18066            err,
18067            EngineError::Storage(StorageError::TableNotFound { .. })
18068        ));
18069    }
18070
18071    #[test]
18072    fn create_index_on_unknown_column_errors() {
18073        let mut e = Engine::new();
18074        make_three_row_users(&mut e);
18075        let err = e.execute("CREATE INDEX i ON users (ghost)").unwrap_err();
18076        assert!(matches!(
18077            err,
18078            EngineError::Storage(StorageError::ColumnNotFound { .. })
18079        ));
18080    }
18081
18082    #[test]
18083    fn select_eq_uses_index_returns_same_rows_as_scan() {
18084        // Build two engines: one with an index, one without. Same query →
18085        // same row set (index is a planner optimisation, not a semantic
18086        // change).
18087        let mut without = Engine::new();
18088        make_three_row_users(&mut without);
18089        let mut with = Engine::new();
18090        make_three_row_users(&mut with);
18091        with.execute("CREATE INDEX by_id ON users (id)").unwrap();
18092
18093        let q = "SELECT * FROM users WHERE id = 2";
18094        let (_, no_idx_rows) = unwrap_rows(without.execute(q).unwrap());
18095        let (_, idx_rows) = unwrap_rows(with.execute(q).unwrap());
18096        assert_eq!(no_idx_rows, idx_rows);
18097        assert_eq!(idx_rows.len(), 1);
18098    }
18099
18100    #[test]
18101    fn select_eq_with_no_matching_index_value_returns_empty() {
18102        let mut e = Engine::new();
18103        make_three_row_users(&mut e);
18104        e.execute("CREATE INDEX by_id ON users (id)").unwrap();
18105        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM users WHERE id = 999").unwrap());
18106        assert_eq!(rows.len(), 0);
18107    }
18108
18109    // --- v0.9 transactions -------------------------------------------------
18110
18111    #[test]
18112    fn begin_sets_in_transaction_flag() {
18113        let mut e = Engine::new();
18114        assert!(!e.in_transaction());
18115        e.execute("BEGIN").unwrap();
18116        assert!(e.in_transaction());
18117    }
18118
18119    #[test]
18120    fn double_begin_errors() {
18121        let mut e = Engine::new();
18122        e.execute("BEGIN").unwrap();
18123        let err = e.execute("BEGIN").unwrap_err();
18124        assert_eq!(err, EngineError::TransactionAlreadyOpen);
18125    }
18126
18127    #[test]
18128    fn commit_without_begin_errors() {
18129        let mut e = Engine::new();
18130        let err = e.execute("COMMIT").unwrap_err();
18131        assert_eq!(err, EngineError::NoActiveTransaction);
18132    }
18133
18134    #[test]
18135    fn rollback_without_begin_errors() {
18136        let mut e = Engine::new();
18137        let err = e.execute("ROLLBACK").unwrap_err();
18138        assert_eq!(err, EngineError::NoActiveTransaction);
18139    }
18140
18141    #[test]
18142    fn commit_applies_shadow_to_committed_catalog() {
18143        let mut e = Engine::new();
18144        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18145        e.execute("BEGIN").unwrap();
18146        e.execute("INSERT INTO t VALUES (1)").unwrap();
18147        e.execute("INSERT INTO t VALUES (2)").unwrap();
18148        e.execute("COMMIT").unwrap();
18149        assert!(!e.in_transaction());
18150        assert_eq!(e.catalog().get("t").unwrap().row_count(), 2);
18151    }
18152
18153    #[test]
18154    fn rollback_discards_shadow() {
18155        let mut e = Engine::new();
18156        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18157        e.execute("BEGIN").unwrap();
18158        e.execute("INSERT INTO t VALUES (1)").unwrap();
18159        e.execute("INSERT INTO t VALUES (2)").unwrap();
18160        e.execute("ROLLBACK").unwrap();
18161        assert!(!e.in_transaction());
18162        assert_eq!(e.catalog().get("t").unwrap().row_count(), 0);
18163    }
18164
18165    #[test]
18166    fn select_during_tx_sees_uncommitted_writes_own_session() {
18167        // The shadow catalog is read by SELECTs while a TX is open — the
18168        // session can see its own pending writes.
18169        let mut e = Engine::new();
18170        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18171        e.execute("BEGIN").unwrap();
18172        e.execute("INSERT INTO t VALUES (42)").unwrap();
18173        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM t").unwrap());
18174        assert_eq!(rows.len(), 1);
18175        assert_eq!(rows[0].values[0], Value::Int(42));
18176    }
18177
18178    #[test]
18179    fn snapshot_with_no_users_is_bare_catalog_format() {
18180        let mut e = Engine::new();
18181        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18182        let bytes = e.snapshot();
18183        assert_eq!(
18184            &bytes[..8],
18185            b"SPGDB001",
18186            "must be the bare v3.x catalog magic"
18187        );
18188        let e2 = Engine::restore_envelope(&bytes).unwrap();
18189        assert!(e2.users().is_empty());
18190        assert_eq!(e2.catalog().table_count(), 1);
18191    }
18192
18193    #[test]
18194    fn snapshot_with_users_round_trips_both_via_envelope() {
18195        let mut e = Engine::new();
18196        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18197        e.create_user("alice", "pw1", Role::Admin, [9; 16]).unwrap();
18198        e.create_user("bob", "pw2", Role::ReadOnly, [5; 16])
18199            .unwrap();
18200        let bytes = e.snapshot();
18201        assert_eq!(&bytes[..8], b"SPGENV01", "must be the v4.1 envelope magic");
18202        let e2 = Engine::restore_envelope(&bytes).unwrap();
18203        assert_eq!(e2.users().len(), 2);
18204        assert_eq!(e2.verify_user("alice", "pw1"), Some(Role::Admin));
18205        assert_eq!(e2.verify_user("bob", "pw2"), Some(Role::ReadOnly));
18206        assert_eq!(e2.verify_user("alice", "wrong"), None);
18207        assert_eq!(e2.catalog().table_count(), 1);
18208    }
18209
18210    #[test]
18211    fn ddl_inside_tx_also_rolled_back() {
18212        let mut e = Engine::new();
18213        e.execute("BEGIN").unwrap();
18214        e.execute("CREATE TABLE t (v INT)").unwrap();
18215        // Visible inside the TX.
18216        e.execute("SELECT * FROM t").unwrap();
18217        e.execute("ROLLBACK").unwrap();
18218        // Gone after rollback.
18219        let err = e.execute("SELECT * FROM t").unwrap_err();
18220        assert!(matches!(
18221            err,
18222            EngineError::Storage(StorageError::TableNotFound { .. })
18223        ));
18224    }
18225
18226    // ── v6.1.2: CREATE / DROP PUBLICATION (engine-side) ──────
18227
18228    #[test]
18229    fn create_publication_lands_in_catalog() {
18230        let mut e = Engine::new();
18231        assert!(e.publications().is_empty());
18232        e.execute("CREATE PUBLICATION pub_a").unwrap();
18233        assert_eq!(e.publications().len(), 1);
18234        assert!(e.publications().contains("pub_a"));
18235    }
18236
18237    #[test]
18238    fn create_publication_duplicate_errors() {
18239        let mut e = Engine::new();
18240        e.execute("CREATE PUBLICATION pub_a").unwrap();
18241        let err = e.execute("CREATE PUBLICATION pub_a").unwrap_err();
18242        assert!(
18243            alloc::format!("{err:?}").contains("DuplicateName"),
18244            "got {err:?}"
18245        );
18246    }
18247
18248    #[test]
18249    fn drop_publication_silent_when_absent() {
18250        let mut e = Engine::new();
18251        // PG-compatible: DROP a publication that doesn't exist
18252        // succeeds (no-op) but reports zero affected.
18253        let r = e.execute("DROP PUBLICATION nope").unwrap();
18254        match r {
18255            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
18256            other => panic!("expected CommandOk, got {other:?}"),
18257        }
18258    }
18259
18260    #[test]
18261    fn drop_publication_present_reports_one_affected() {
18262        let mut e = Engine::new();
18263        e.execute("CREATE PUBLICATION pub_a").unwrap();
18264        let r = e.execute("DROP PUBLICATION pub_a").unwrap();
18265        match r {
18266            QueryResult::CommandOk {
18267                affected,
18268                modified_catalog,
18269            } => {
18270                assert_eq!(affected, 1);
18271                assert!(modified_catalog);
18272            }
18273            other => panic!("expected CommandOk, got {other:?}"),
18274        }
18275        assert!(e.publications().is_empty());
18276    }
18277
18278    #[test]
18279    fn publications_persist_across_snapshot_restore() {
18280        // The persist-across-restart ship-gate at the engine layer —
18281        // snapshot → restore_envelope round trip must preserve the
18282        // publication catalog. The spg-server e2e covers the
18283        // process-restart variant.
18284        let mut e = Engine::new();
18285        e.execute("CREATE PUBLICATION pub_a").unwrap();
18286        e.execute("CREATE PUBLICATION pub_b FOR ALL TABLES")
18287            .unwrap();
18288        let snap = e.snapshot();
18289        let e2 = Engine::restore_envelope(&snap).unwrap();
18290        assert_eq!(e2.publications().len(), 2);
18291        assert!(e2.publications().contains("pub_a"));
18292        assert!(e2.publications().contains("pub_b"));
18293    }
18294
18295    #[test]
18296    fn create_publication_allowed_inside_transaction() {
18297        // v6.1.4 dropped the v6.1.2 in-TX guard — PG allows
18298        // CREATE PUBLICATION inside a TX and the auto-commit
18299        // wrap path needs the same allowance.
18300        let mut e = Engine::new();
18301        e.execute("BEGIN").unwrap();
18302        e.execute("CREATE PUBLICATION pub_a").unwrap();
18303        e.execute("COMMIT").unwrap();
18304        assert!(e.publications().contains("pub_a"));
18305    }
18306
18307    // ── v6.1.3: SHOW PUBLICATIONS + FOR-list variants ───────
18308
18309    #[test]
18310    fn create_publication_for_table_list_lands_with_scope() {
18311        let mut e = Engine::new();
18312        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
18313        e.execute("CREATE TABLE t2 (id INT NOT NULL)").unwrap();
18314        e.execute("CREATE PUBLICATION pub_a FOR TABLE t1, t2")
18315            .unwrap();
18316        let scope = e.publications().get("pub_a").cloned();
18317        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = scope else {
18318            panic!("expected ForTables scope, got {scope:?}")
18319        };
18320        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
18321    }
18322
18323    #[test]
18324    fn create_publication_all_tables_except_lands_with_scope() {
18325        let mut e = Engine::new();
18326        e.execute("CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t3")
18327            .unwrap();
18328        let scope = e.publications().get("pub_a").cloned();
18329        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = scope else {
18330            panic!("expected AllTablesExcept scope, got {scope:?}")
18331        };
18332        assert_eq!(ts, alloc::vec!["t3".to_string()]);
18333    }
18334
18335    #[test]
18336    fn show_publications_empty_returns_zero_rows() {
18337        let e = Engine::new();
18338        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
18339        let QueryResult::Rows { rows, columns } = r else {
18340            panic!()
18341        };
18342        assert!(rows.is_empty());
18343        assert_eq!(columns.len(), 3);
18344        assert_eq!(columns[0].name, "name");
18345        assert_eq!(columns[1].name, "scope");
18346        assert_eq!(columns[2].name, "table_count");
18347    }
18348
18349    #[test]
18350    fn show_publications_returns_one_row_per_publication_ordered_by_name() {
18351        let mut e = Engine::new();
18352        e.execute("CREATE PUBLICATION z_pub").unwrap();
18353        e.execute("CREATE PUBLICATION a_pub FOR TABLE t1, t2")
18354            .unwrap();
18355        e.execute("CREATE PUBLICATION m_pub FOR ALL TABLES EXCEPT bad")
18356            .unwrap();
18357        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
18358        let QueryResult::Rows { rows, .. } = r else {
18359            panic!()
18360        };
18361        assert_eq!(rows.len(), 3);
18362        // Alphabetical order: a_pub, m_pub, z_pub.
18363        let names: Vec<&str> = rows
18364            .iter()
18365            .map(|r| {
18366                if let Value::Text(s) = &r.values[0] {
18367                    s.as_str()
18368                } else {
18369                    panic!()
18370                }
18371            })
18372            .collect();
18373        assert_eq!(names, alloc::vec!["a_pub", "m_pub", "z_pub"]);
18374        // Row 0 — a_pub scope summary + table_count = 2.
18375        match &rows[0].values[1] {
18376            Value::Text(s) => assert_eq!(s, "FOR TABLE t1, t2"),
18377            other => panic!("expected Text, got {other:?}"),
18378        }
18379        assert_eq!(rows[0].values[2], Value::Int(2));
18380        // Row 1 — m_pub.
18381        match &rows[1].values[1] {
18382            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES EXCEPT bad"),
18383            other => panic!("expected Text, got {other:?}"),
18384        }
18385        assert_eq!(rows[1].values[2], Value::Int(1));
18386        // Row 2 — z_pub (AllTables → NULL count).
18387        match &rows[2].values[1] {
18388            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES"),
18389            other => panic!("expected Text, got {other:?}"),
18390        }
18391        assert_eq!(rows[2].values[2], Value::Null);
18392    }
18393
18394    #[test]
18395    fn for_list_scopes_persist_across_snapshot() {
18396        // The v6.1.2 envelope-v3 round-trip exercised AllTables;
18397        // v6.1.3 needs the scope-1 / scope-2 tags to survive too.
18398        let mut e = Engine::new();
18399        e.execute("CREATE PUBLICATION p1 FOR TABLE t1, t2").unwrap();
18400        e.execute("CREATE PUBLICATION p2 FOR ALL TABLES EXCEPT bad, worse")
18401            .unwrap();
18402        let snap = e.snapshot();
18403        let e2 = Engine::restore_envelope(&snap).unwrap();
18404        assert_eq!(e2.publications().len(), 2);
18405        let p1 = e2.publications().get("p1").cloned();
18406        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = p1 else {
18407            panic!("p1 scope lost: {p1:?}")
18408        };
18409        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
18410        let p2 = e2.publications().get("p2").cloned();
18411        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = p2 else {
18412            panic!("p2 scope lost: {p2:?}")
18413        };
18414        assert_eq!(ts, alloc::vec!["bad".to_string(), "worse".to_string()]);
18415    }
18416
18417    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW + envelope v4 ─
18418
18419    #[test]
18420    fn create_subscription_lands_in_catalog_with_defaults() {
18421        let mut e = Engine::new();
18422        e.execute(
18423            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
18424        )
18425        .unwrap();
18426        let s = e.subscriptions().get("sub_a").cloned().expect("present");
18427        assert_eq!(s.conn_str, "host=127.0.0.1 port=20002");
18428        assert_eq!(s.publications, alloc::vec!["pub_a".to_string()]);
18429        assert!(s.enabled);
18430        assert_eq!(s.last_received_pos, 0);
18431    }
18432
18433    #[test]
18434    fn create_subscription_duplicate_name_errors() {
18435        let mut e = Engine::new();
18436        e.execute("CREATE SUBSCRIPTION s CONNECTION 'host=x' PUBLICATION p")
18437            .unwrap();
18438        let err = e
18439            .execute("CREATE SUBSCRIPTION s CONNECTION 'host=y' PUBLICATION p")
18440            .unwrap_err();
18441        assert!(
18442            alloc::format!("{err:?}").contains("DuplicateName"),
18443            "got {err:?}"
18444        );
18445    }
18446
18447    #[test]
18448    fn drop_subscription_silent_when_absent() {
18449        let mut e = Engine::new();
18450        let r = e.execute("DROP SUBSCRIPTION never").unwrap();
18451        match r {
18452            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
18453            other => panic!("expected CommandOk, got {other:?}"),
18454        }
18455    }
18456
18457    #[test]
18458    fn subscription_advance_updates_last_pos_monotone() {
18459        let mut e = Engine::new();
18460        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
18461            .unwrap();
18462        assert!(e.subscription_advance("s", 100));
18463        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
18464        assert!(e.subscription_advance("s", 50)); // stale → ignored
18465        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
18466        assert!(e.subscription_advance("s", 200));
18467        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 200);
18468        assert!(!e.subscription_advance("missing", 1));
18469    }
18470
18471    #[test]
18472    fn show_subscriptions_returns_rows_ordered_by_name() {
18473        let mut e = Engine::new();
18474        e.execute("CREATE SUBSCRIPTION z_sub CONNECTION 'h=x' PUBLICATION p1, p2")
18475            .unwrap();
18476        e.execute("CREATE SUBSCRIPTION a_sub CONNECTION 'h=y' PUBLICATION p3")
18477            .unwrap();
18478        let r = e.execute_readonly("SHOW SUBSCRIPTIONS").unwrap();
18479        let QueryResult::Rows { rows, columns } = r else {
18480            panic!()
18481        };
18482        assert_eq!(rows.len(), 2);
18483        assert_eq!(columns.len(), 5);
18484        assert_eq!(columns[0].name, "name");
18485        assert_eq!(columns[4].name, "last_received_pos");
18486        // Alphabetical: a_sub, z_sub.
18487        let names: Vec<&str> = rows
18488            .iter()
18489            .map(|r| {
18490                if let Value::Text(s) = &r.values[0] {
18491                    s.as_str()
18492                } else {
18493                    panic!()
18494                }
18495            })
18496            .collect();
18497        assert_eq!(names, alloc::vec!["a_sub", "z_sub"]);
18498        // Row 0: a_sub
18499        assert_eq!(rows[0].values[1], Value::Text("h=y".to_string()));
18500        assert_eq!(rows[0].values[2], Value::Text("p3".to_string()));
18501        assert_eq!(rows[0].values[3], Value::Bool(true));
18502        assert_eq!(rows[0].values[4], Value::BigInt(0));
18503        // Row 1: z_sub — publications join with ", "
18504        assert_eq!(rows[1].values[2], Value::Text("p1, p2".to_string()));
18505    }
18506
18507    #[test]
18508    fn subscriptions_persist_across_snapshot_envelope_v4() {
18509        let mut e = Engine::new();
18510        e.execute("CREATE SUBSCRIPTION s1 CONNECTION 'h=A' PUBLICATION p1, p2")
18511            .unwrap();
18512        e.execute("CREATE SUBSCRIPTION s2 CONNECTION 'h=B' PUBLICATION p3")
18513            .unwrap();
18514        e.subscription_advance("s2", 42);
18515        let snap = e.snapshot();
18516        let e2 = Engine::restore_envelope(&snap).unwrap();
18517        assert_eq!(e2.subscriptions().len(), 2);
18518        let s1 = e2.subscriptions().get("s1").unwrap();
18519        assert_eq!(s1.conn_str, "h=A");
18520        assert_eq!(
18521            s1.publications,
18522            alloc::vec!["p1".to_string(), "p2".to_string()]
18523        );
18524        assert_eq!(s1.last_received_pos, 0);
18525        let s2 = e2.subscriptions().get("s2").unwrap();
18526        assert_eq!(s2.last_received_pos, 42);
18527    }
18528
18529    #[test]
18530    fn v3_envelope_loads_with_empty_subscriptions() {
18531        // v3 snapshot (publications-only). Forge it by hand so we
18532        // verify v6.1.4 readers don't panic — they must surface
18533        // empty subscriptions and a populated publication table.
18534        let mut e = Engine::new();
18535        e.execute("CREATE PUBLICATION pub_legacy").unwrap();
18536        let catalog = e.catalog.serialize();
18537        let users = crate::users::serialize_users(&e.users);
18538        let pubs = e.publications.serialize();
18539        let mut buf = Vec::new();
18540        buf.extend_from_slice(b"SPGENV01");
18541        buf.push(3u8); // v3
18542        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
18543        buf.extend_from_slice(&catalog);
18544        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
18545        buf.extend_from_slice(&users);
18546        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
18547        buf.extend_from_slice(&pubs);
18548        let crc = spg_crypto::crc32::crc32(&buf);
18549        buf.extend_from_slice(&crc.to_le_bytes());
18550
18551        let e2 = Engine::restore_envelope(&buf).expect("v3 envelope restores under v4 reader");
18552        assert!(e2.subscriptions().is_empty());
18553        assert!(e2.publications().contains("pub_legacy"));
18554    }
18555
18556    #[test]
18557    fn create_subscription_allowed_inside_transaction() {
18558        let mut e = Engine::new();
18559        e.execute("BEGIN").unwrap();
18560        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
18561            .unwrap();
18562        e.execute("COMMIT").unwrap();
18563        assert!(e.subscriptions().contains("s"));
18564    }
18565
18566    // ── v6.2.0: ANALYZE + spg_statistic + envelope v5 ──────────
18567    #[test]
18568    fn analyze_populates_histogram_bounds() {
18569        let mut e = Engine::new();
18570        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT)")
18571            .unwrap();
18572        for i in 0..50 {
18573            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'name{i}')"))
18574                .unwrap();
18575        }
18576        e.execute("ANALYZE t").unwrap();
18577        let stats = e.statistics();
18578        let id_stats = stats.get("t", "id").unwrap();
18579        assert!(id_stats.histogram_bounds.len() >= 2);
18580        assert_eq!(id_stats.histogram_bounds.first().unwrap(), "0");
18581        assert_eq!(id_stats.histogram_bounds.last().unwrap(), "49");
18582        assert!((id_stats.null_frac - 0.0).abs() < 1e-6);
18583        assert_eq!(id_stats.n_distinct, 50);
18584    }
18585
18586    #[test]
18587    fn reanalyze_overwrites_prior_stats() {
18588        let mut e = Engine::new();
18589        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18590        for i in 0..10 {
18591            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18592                .unwrap();
18593        }
18594        e.execute("ANALYZE t").unwrap();
18595        let n1 = e.statistics().get("t", "id").unwrap().n_distinct;
18596        assert_eq!(n1, 10);
18597        for i in 10..30 {
18598            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18599                .unwrap();
18600        }
18601        e.execute("ANALYZE t").unwrap();
18602        let n2 = e.statistics().get("t", "id").unwrap().n_distinct;
18603        assert_eq!(n2, 30);
18604    }
18605
18606    #[test]
18607    fn analyze_unknown_table_errors() {
18608        let mut e = Engine::new();
18609        let err = e.execute("ANALYZE nonexistent").unwrap_err();
18610        assert!(matches!(
18611            err,
18612            EngineError::Storage(StorageError::TableNotFound { .. })
18613        ));
18614    }
18615
18616    #[test]
18617    fn bare_analyze_covers_all_user_tables() {
18618        let mut e = Engine::new();
18619        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
18620        e.execute("CREATE TABLE t2 (name TEXT NOT NULL)").unwrap();
18621        e.execute("INSERT INTO t1 VALUES (1)").unwrap();
18622        e.execute("INSERT INTO t2 VALUES ('alice')").unwrap();
18623        let r = e.execute("ANALYZE").unwrap();
18624        match r {
18625            QueryResult::CommandOk {
18626                affected,
18627                modified_catalog,
18628            } => {
18629                assert_eq!(affected, 2);
18630                assert!(modified_catalog);
18631            }
18632            other => panic!("expected CommandOk, got {other:?}"),
18633        }
18634        assert!(e.statistics().get("t1", "id").is_some());
18635        assert!(e.statistics().get("t2", "name").is_some());
18636    }
18637
18638    #[test]
18639    fn select_from_spg_statistic_returns_rows_per_column() {
18640        let mut e = Engine::new();
18641        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
18642            .unwrap();
18643        e.execute("INSERT INTO t VALUES (1, 'a')").unwrap();
18644        e.execute("INSERT INTO t VALUES (2, 'b')").unwrap();
18645        e.execute("ANALYZE t").unwrap();
18646        let r = e.execute_readonly("SELECT * FROM spg_statistic").unwrap();
18647        let QueryResult::Rows { rows, columns } = r else {
18648            panic!()
18649        };
18650        // v6.7.0 — spg_statistic gained a `cold_row_count` column.
18651        assert_eq!(columns.len(), 6);
18652        assert_eq!(columns[0].name, "table_name");
18653        assert_eq!(columns[4].name, "histogram_bounds");
18654        assert_eq!(columns[5].name, "cold_row_count");
18655        assert_eq!(rows.len(), 2, "one row per column of t");
18656        // Sorted by (table_name, column_name).
18657        match (&rows[0].values[0], &rows[0].values[1]) {
18658            (Value::Text(t), Value::Text(c)) => {
18659                assert_eq!(t, "t");
18660                // BTreeMap orders (table, column); columns "id" < "label".
18661                assert_eq!(c, "id");
18662            }
18663            _ => panic!(),
18664        }
18665    }
18666
18667    #[test]
18668    fn analyze_skips_vector_columns() {
18669        // Vector columns have their own stats shape (HNSW graph);
18670        // ANALYZE leaves them out of spg_statistic.
18671        let mut e = Engine::new();
18672        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
18673            .unwrap();
18674        e.execute("INSERT INTO t VALUES (1, [1, 2, 3])").unwrap();
18675        e.execute("ANALYZE t").unwrap();
18676        assert!(e.statistics().get("t", "id").is_some());
18677        assert!(e.statistics().get("t", "v").is_none());
18678    }
18679
18680    #[test]
18681    fn statistics_persist_across_envelope_v5_round_trip() {
18682        let mut e = Engine::new();
18683        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18684        for i in 0..20 {
18685            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18686                .unwrap();
18687        }
18688        e.execute("ANALYZE").unwrap();
18689        let snap = e.snapshot();
18690        let e2 = Engine::restore_envelope(&snap).unwrap();
18691        let s = e2.statistics().get("t", "id").unwrap();
18692        assert_eq!(s.n_distinct, 20);
18693    }
18694
18695    // ── v6.2.1 auto-analyze threshold ───────────────────────────
18696
18697    #[test]
18698    fn auto_analyze_threshold_fires_after_10pct_of_min_rows_on_small_table() {
18699        // For a table with 0 rows then 10 inserts → modified=10,
18700        // row_count=10. Threshold = 0.1 × max(10, 100) = 10. So
18701        // after the 10th INSERT the threshold is met.
18702        let mut e = Engine::new();
18703        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18704        for i in 0..9 {
18705            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18706                .unwrap();
18707        }
18708        assert!(e.tables_needing_analyze().is_empty(), "9 < threshold");
18709        e.execute("INSERT INTO t VALUES (9)").unwrap();
18710        let needs = e.tables_needing_analyze();
18711        assert_eq!(needs, alloc::vec!["t".to_string()]);
18712    }
18713
18714    #[test]
18715    fn auto_analyze_threshold_uses_10pct_of_row_count_for_large_tables() {
18716        // After ANALYZE on 1000 rows, threshold = 0.1 × row_count.
18717        // Each new INSERT bumps both modified and row_count, so to
18718        // trigger from N=1000 we need modifications ≥ 0.1 × (1000+M),
18719        // i.e. M ≥ 112. The test inserts 50 (no fire), then 150
18720        // more (200 total mods, row_count=1200, threshold=120 → fire).
18721        let mut e = Engine::new();
18722        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18723        for i in 0..1000 {
18724            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18725                .unwrap();
18726        }
18727        e.execute("ANALYZE t").unwrap();
18728        assert!(e.tables_needing_analyze().is_empty(), "fresh ANALYZE");
18729        for i in 1000..1050 {
18730            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18731                .unwrap();
18732        }
18733        assert!(
18734            e.tables_needing_analyze().is_empty(),
18735            "50 inserts < threshold of ~105"
18736        );
18737        for i in 1050..1200 {
18738            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18739                .unwrap();
18740        }
18741        assert_eq!(
18742            e.tables_needing_analyze(),
18743            alloc::vec!["t".to_string()],
18744            "200 inserts > 0.1 × 1200 threshold"
18745        );
18746    }
18747
18748    #[test]
18749    fn auto_analyze_threshold_resets_after_analyze() {
18750        let mut e = Engine::new();
18751        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18752        for i in 0..200 {
18753            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18754                .unwrap();
18755        }
18756        assert!(!e.tables_needing_analyze().is_empty());
18757        e.execute("ANALYZE").unwrap();
18758        assert!(
18759            e.tables_needing_analyze().is_empty(),
18760            "ANALYZE must reset the counter"
18761        );
18762    }
18763
18764    #[test]
18765    fn auto_analyze_threshold_tracks_updates_and_deletes() {
18766        let mut e = Engine::new();
18767        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
18768            .unwrap();
18769        for i in 0..50 {
18770            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'x')"))
18771                .unwrap();
18772        }
18773        e.execute("ANALYZE t").unwrap();
18774        // UPDATE 20 rows + DELETE 5 → modified=25. Threshold = 0.1
18775        // × max(50, 100) = 10. So 25 >= 10 → trigger.
18776        e.execute("UPDATE t SET label = 'y' WHERE id < 20").unwrap();
18777        e.execute("DELETE FROM t WHERE id >= 45").unwrap();
18778        assert_eq!(e.tables_needing_analyze(), alloc::vec!["t".to_string()]);
18779    }
18780
18781    #[test]
18782    fn v4_envelope_loads_with_empty_statistics() {
18783        // Forge a v4 envelope by hand: catalog + users + pubs +
18784        // subs trailer, no statistics. A v6.2.0 reader must accept
18785        // it and surface an empty Statistics.
18786        let mut e = Engine::new();
18787        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
18788            .unwrap();
18789        let catalog = e.catalog.serialize();
18790        let users = crate::users::serialize_users(&e.users);
18791        let pubs = e.publications.serialize();
18792        let subs = e.subscriptions.serialize();
18793        let mut buf = Vec::new();
18794        buf.extend_from_slice(b"SPGENV01");
18795        buf.push(4u8);
18796        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
18797        buf.extend_from_slice(&catalog);
18798        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
18799        buf.extend_from_slice(&users);
18800        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
18801        buf.extend_from_slice(&pubs);
18802        buf.extend_from_slice(&u32::try_from(subs.len()).unwrap().to_le_bytes());
18803        buf.extend_from_slice(&subs);
18804        let crc = spg_crypto::crc32::crc32(&buf);
18805        buf.extend_from_slice(&crc.to_le_bytes());
18806        let e2 = Engine::restore_envelope(&buf).expect("v4 envelope restores");
18807        assert!(e2.statistics().is_empty());
18808    }
18809
18810    #[test]
18811    fn v1_v2_envelope_loads_with_empty_publications() {
18812        // A snapshot taken before v6.1.2 (no publication trailer,
18813        // envelope v2) must still deserialise — and the resulting
18814        // engine must report zero publications. Use the engine's own
18815        // round-trip with no publications: that emits v3 but with an
18816        // empty pubs block. Then forge a v2 envelope by hand to lock
18817        // the back-compat path.
18818        let mut e = Engine::new();
18819        // Force users to be non-empty so the snapshot takes the
18820        // envelope path rather than the bare-catalog fallback.
18821        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
18822            .unwrap();
18823
18824        // Forge an envelope v2: same shape as v3 but no pubs trailer.
18825        let catalog = e.catalog.serialize();
18826        let users = crate::users::serialize_users(&e.users);
18827        let mut buf = Vec::new();
18828        buf.extend_from_slice(b"SPGENV01");
18829        buf.push(2u8); // v2
18830        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
18831        buf.extend_from_slice(&catalog);
18832        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
18833        buf.extend_from_slice(&users);
18834        let crc = spg_crypto::crc32::crc32(&buf);
18835        buf.extend_from_slice(&crc.to_le_bytes());
18836
18837        let e2 = Engine::restore_envelope(&buf).expect("v2 envelope restores");
18838        assert!(e2.publications().is_empty());
18839    }
18840}