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        // v7.19 P5 — Set-Returning-Function in projection
7391        // position (PG `SELECT unnest(arr) FROM t` shape). When a
7392        // SELECT item evaluates to a top-level unnest(arr) call,
7393        // expand it: for each input row, evaluate the array, emit
7394        // one output row per element, broadcasting non-SRF
7395        // projections from the same input row. Multi-SRF + LCM
7396        // padding stays a documented carve-out; mailrs uses
7397        // single-SRF for redirect_uris.
7398        let srf_position = projection.iter().position(|p| is_top_level_unnest(&p.expr));
7399        if let Some(srf_idx) = srf_position {
7400            let srf_arg = top_level_unnest_arg(&projection[srf_idx].expr)
7401                .expect("checked by is_top_level_unnest above");
7402            for row in &filtered {
7403                let arr_val =
7404                    eval::eval_expr(srf_arg, row, &scan_ctx).map_err(EngineError::Eval)?;
7405                let elements = array_value_to_elements(&arr_val)?;
7406                // Empty array → zero rows for this input row (PG
7407                // semantics: `SELECT unnest('{}'::int[])` returns
7408                // 0 rows, not a single NULL row).
7409                for elem in elements {
7410                    let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7411                    for (i, p) in projection.iter().enumerate() {
7412                        if i == srf_idx {
7413                            vals.push(elem.clone());
7414                        } else {
7415                            vals.push(
7416                                eval::eval_expr(&p.expr, row, &scan_ctx)
7417                                    .map_err(EngineError::Eval)?,
7418                            );
7419                        }
7420                    }
7421                    projected_rows.push(Row::new(vals));
7422                }
7423            }
7424        } else {
7425            for row in &filtered {
7426                let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7427                for p in &projection {
7428                    vals.push(eval::eval_expr(&p.expr, row, &scan_ctx).map_err(EngineError::Eval)?);
7429                }
7430                projected_rows.push(Row::new(vals));
7431            }
7432        }
7433        // ORDER BY / LIMIT — apply on the projected rows (cheap;
7434        // unnest result sets are small by design).
7435        let columns: alloc::vec::Vec<ColumnSchema> = projection
7436            .iter()
7437            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7438            .collect();
7439        // Re-evaluate ORDER BY against the source schema (pre-projection
7440        // so col refs by name still resolve through `scan_ctx`).
7441        if !stmt.order_by.is_empty() {
7442            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7443                .iter()
7444                .enumerate()
7445                .map(|(i, r)| -> Result<_, EngineError> {
7446                    let keys: Result<Vec<Value>, EngineError> = stmt
7447                        .order_by
7448                        .iter()
7449                        .map(|ob| {
7450                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7451                        })
7452                        .collect();
7453                    Ok((i, keys?))
7454                })
7455                .collect::<Result<_, _>>()?;
7456            indexed.sort_by(|a, b| {
7457                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7458                    let mut cmp = value_cmp(ka, kb);
7459                    if stmt.order_by[idx].desc {
7460                        cmp = cmp.reverse();
7461                    }
7462                    if cmp != core::cmp::Ordering::Equal {
7463                        return cmp;
7464                    }
7465                }
7466                core::cmp::Ordering::Equal
7467            });
7468            projected_rows = indexed
7469                .into_iter()
7470                .map(|(i, _)| projected_rows[i].clone())
7471                .collect();
7472        }
7473        // LIMIT / OFFSET — apply at the tail.
7474        if let Some(offset) = stmt.offset_literal() {
7475            let off = (offset as usize).min(projected_rows.len());
7476            projected_rows.drain(..off);
7477        }
7478        if let Some(limit) = stmt.limit_literal() {
7479            projected_rows.truncate(limit as usize);
7480        }
7481        Ok(QueryResult::Rows {
7482            columns,
7483            rows: projected_rows,
7484        })
7485    }
7486
7487    /// v7.17.0 Phase 3.10 — `FROM generate_series(start, stop [,
7488    /// step])` set-returning source. Mirrors `exec_select_unnest`'s
7489    /// shape: evaluate the arg list once against an empty row,
7490    /// materialise the row stream by stepping start → stop, then
7491    /// route through the standard WHERE / projection / ORDER BY /
7492    /// LIMIT pipeline. Two arg-type combos in v7.17:
7493    ///   * integer / integer [/ integer] — SmallInt, Int, BigInt
7494    ///     (widened to BigInt internally; step defaults to 1)
7495    ///   * timestamp / timestamp / interval — date-range
7496    ///     iteration (mailrs's daily-report pattern)
7497    fn exec_select_generate_series(
7498        &self,
7499        stmt: &SelectStatement,
7500        primary: &TableRef,
7501        cancel: CancelToken<'_>,
7502    ) -> Result<QueryResult, EngineError> {
7503        let args = primary
7504            .generate_series_args
7505            .as_ref()
7506            .expect("caller guards generate_series_args.is_some()");
7507        let empty_schema: alloc::vec::Vec<ColumnSchema> = alloc::vec::Vec::new();
7508        let ctx = EvalContext::new(&empty_schema, None);
7509        let dummy_row = Row::new(alloc::vec::Vec::new());
7510        let mut arg_values: alloc::vec::Vec<Value> = alloc::vec::Vec::with_capacity(args.len());
7511        for a in args {
7512            arg_values.push(eval::eval_expr(a, &dummy_row, &ctx).map_err(EngineError::Eval)?);
7513        }
7514        // Dispatch on the start value's shape. Reject mixed-shape
7515        // calls early (e.g. start = timestamp, stop = integer) so
7516        // the caller gets a clean error rather than a panic.
7517        let (elem_dtype, rows) = match arg_values.as_slice() {
7518            [Value::Timestamp(start), Value::Timestamp(stop), step] => {
7519                let interval_step = match step {
7520                    Value::Interval { .. } => step.clone(),
7521                    other => {
7522                        return Err(EngineError::Unsupported(alloc::format!(
7523                            "generate_series(timestamp, timestamp, …): \
7524                             step must be INTERVAL, got {:?}",
7525                            other.data_type()
7526                        )));
7527                    }
7528                };
7529                let rows = generate_series_timestamps(*start, *stop, interval_step, &cancel)?;
7530                (DataType::Timestamp, rows)
7531            }
7532            [start, stop, step]
7533                if value_is_integer(start) && value_is_integer(stop) && value_is_integer(step) =>
7534            {
7535                let s = value_to_i64(start);
7536                let e = value_to_i64(stop);
7537                let st = value_to_i64(step);
7538                let rows = generate_series_integers(s, e, st, &cancel)?;
7539                (DataType::BigInt, rows)
7540            }
7541            [start, stop] if value_is_integer(start) && value_is_integer(stop) => {
7542                let s = value_to_i64(start);
7543                let e = value_to_i64(stop);
7544                let rows = generate_series_integers(s, e, 1, &cancel)?;
7545                (DataType::BigInt, rows)
7546            }
7547            _ => {
7548                return Err(EngineError::Unsupported(alloc::format!(
7549                    "generate_series(): v7.17 supports integer or (timestamp, timestamp, interval) \
7550                     argument shapes; got {:?}",
7551                    arg_values
7552                        .iter()
7553                        .map(|v| v.data_type())
7554                        .collect::<alloc::vec::Vec<_>>()
7555                )));
7556            }
7557        };
7558        let alias = primary
7559            .alias
7560            .clone()
7561            .unwrap_or_else(|| "generate_series".to_string());
7562        let col_name = alias.clone();
7563        let col_schema = ColumnSchema::new(col_name, elem_dtype, true);
7564        let schema_cols = alloc::vec![col_schema.clone()];
7565        let scan_ctx = EvalContext::new(&schema_cols, Some(&alias));
7566        // WHERE.
7567        let filtered: alloc::vec::Vec<Row> = if let Some(w) = &stmt.where_ {
7568            let mut out = alloc::vec::Vec::with_capacity(rows.len());
7569            for row in rows {
7570                cancel.check()?;
7571                let v = eval::eval_expr(w, &row, &scan_ctx).map_err(EngineError::Eval)?;
7572                if matches!(v, Value::Bool(true)) {
7573                    out.push(row);
7574                }
7575            }
7576            out
7577        } else {
7578            rows
7579        };
7580        // v7.17.0 Phase 3.P0-48 — aggregate dispatch for set-
7581        // returning sources. When the SELECT projection contains
7582        // aggregate functions (COUNT/SUM/MIN/MAX/AVG/string_agg/
7583        // …) we route the filtered row stream through the same
7584        // aggregate executor the relational scan path uses, so
7585        // `SELECT COUNT(*) FROM generate_series(1, 100)` returns
7586        // a single 100 row instead of erroring at projection
7587        // time. GROUP BY / HAVING / ORDER BY over the aggregate
7588        // output all ride through `aggregate::run`.
7589        if aggregate::uses_aggregate(stmt) {
7590            let filtered_refs: alloc::vec::Vec<&Row> = filtered.iter().collect();
7591            let mut agg = aggregate::run(stmt, &filtered_refs, &schema_cols, Some(&alias))?;
7592            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7593            return Ok(QueryResult::Rows {
7594                columns: agg.columns,
7595                rows: agg.rows,
7596            });
7597        }
7598        // Projection.
7599        let projection = build_projection(&stmt.items, &schema_cols, &alias)?;
7600        let mut projected_rows: alloc::vec::Vec<Row> =
7601            alloc::vec::Vec::with_capacity(filtered.len());
7602        for row in &filtered {
7603            let mut vals = alloc::vec::Vec::with_capacity(projection.len());
7604            for p in &projection {
7605                vals.push(eval::eval_expr(&p.expr, row, &scan_ctx).map_err(EngineError::Eval)?);
7606            }
7607            projected_rows.push(Row::new(vals));
7608        }
7609        let columns: alloc::vec::Vec<ColumnSchema> = projection
7610            .iter()
7611            .map(|p| ColumnSchema::new(p.output_name.clone(), p.ty, p.nullable))
7612            .collect();
7613        // ORDER BY against the source schema.
7614        if !stmt.order_by.is_empty() {
7615            let mut indexed: alloc::vec::Vec<(usize, Vec<Value>)> = filtered
7616                .iter()
7617                .enumerate()
7618                .map(|(i, r)| -> Result<_, EngineError> {
7619                    let keys: Result<Vec<Value>, EngineError> = stmt
7620                        .order_by
7621                        .iter()
7622                        .map(|ob| {
7623                            eval::eval_expr(&ob.expr, r, &scan_ctx).map_err(EngineError::Eval)
7624                        })
7625                        .collect();
7626                    Ok((i, keys?))
7627                })
7628                .collect::<Result<_, _>>()?;
7629            indexed.sort_by(|a, b| {
7630                for (idx, (ka, kb)) in a.1.iter().zip(b.1.iter()).enumerate() {
7631                    let mut cmp = value_cmp(ka, kb);
7632                    if stmt.order_by[idx].desc {
7633                        cmp = cmp.reverse();
7634                    }
7635                    if cmp != core::cmp::Ordering::Equal {
7636                        return cmp;
7637                    }
7638                }
7639                core::cmp::Ordering::Equal
7640            });
7641            projected_rows = indexed
7642                .into_iter()
7643                .map(|(i, _)| projected_rows[i].clone())
7644                .collect();
7645        }
7646        if let Some(offset) = stmt.offset_literal() {
7647            let off = (offset as usize).min(projected_rows.len());
7648            projected_rows.drain(..off);
7649        }
7650        if let Some(limit) = stmt.limit_literal() {
7651            projected_rows.truncate(limit as usize);
7652        }
7653        Ok(QueryResult::Rows {
7654            columns,
7655            rows: projected_rows,
7656        })
7657    }
7658
7659    fn exec_bare_select_cancel(
7660        &self,
7661        stmt: &SelectStatement,
7662        cancel: CancelToken<'_>,
7663    ) -> Result<QueryResult, EngineError> {
7664        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST N ROWS WITH TIES`
7665        // is meaningless without an ORDER BY; PG raises a hard
7666        // error and SPG mirrors the surface so the same DDL/app
7667        // path behaves identically on cutover.
7668        check_with_ties_requires_order_by(stmt)?;
7669        // v7.16.2 — same meta-view dispatch as
7670        // `exec_select_cancel`, applied here too because
7671        // `subquery_replacement` enters this function directly
7672        // for Exists / ScalarSubquery / InSubquery resolution
7673        // (bypassing the top-level entry to avoid double
7674        // subquery walking). Without this dispatch the subquery
7675        // hits `__spg_info_columns` and reports TableNotFound.
7676        if !self.meta_views_materialised && select_references_meta_view(stmt) {
7677            return self.exec_select_with_meta_views(stmt, cancel);
7678        }
7679        // v4.12: window-function path. When the projection contains
7680        // any `name(args) OVER (...)` we route to the dedicated
7681        // executor — partition + sort + per-row window value before
7682        // the regular projection.
7683        if select_has_window(stmt) {
7684            return self.exec_select_with_window(stmt, cancel);
7685        }
7686        // Constant SELECT (no FROM) — evaluate each item once against an
7687        // empty dummy row. Useful for `SELECT 1`, `SELECT coalesce(...)`,
7688        // `SELECT '7'::INT`. Column references will surface as
7689        // ColumnNotFound on eval since the schema is empty.
7690        let Some(from) = &stmt.from else {
7691            let empty_schema: Vec<ColumnSchema> = Vec::new();
7692            let ctx = self.ev_ctx(&empty_schema, None);
7693            let projection = build_projection(&stmt.items, &empty_schema, "")?;
7694            let dummy_row = Row::new(Vec::new());
7695            let mut values = Vec::with_capacity(projection.len());
7696            for p in &projection {
7697                values.push(eval::eval_expr(&p.expr, &dummy_row, &ctx)?);
7698            }
7699            let columns: Vec<ColumnSchema> = projection
7700                .into_iter()
7701                .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
7702                .collect();
7703            return Ok(QueryResult::Rows {
7704                columns,
7705                rows: alloc::vec![Row::new(values)],
7706            });
7707        };
7708        // Multi-table FROM (one or more joined peers) goes through the
7709        // nested-loop join executor. Single-table FROM stays on the
7710        // existing scan + index-seek path.
7711        if !from.joins.is_empty() {
7712            return self.exec_joined_select(stmt, from);
7713        }
7714        // v7.11.7 — `FROM unnest(<expr>) [AS] <alias>`. Synthesise a
7715        // single-column table at SELECT entry by evaluating the
7716        // expression once against the empty row (UNNEST is
7717        // uncorrelated in v7.11; correlated / LATERAL unnest is a
7718        // v7.12 carve-out). Build a virtual `Table` in a heap-only
7719        // catalog, then route to the regular scan path.
7720        if from.primary.unnest_expr.is_some() {
7721            return self.exec_select_unnest(stmt, &from.primary, cancel);
7722        }
7723        // v7.17.0 Phase 3.10 — `FROM generate_series(start, stop
7724        // [, step])` set-returning source. Dispatch mirrors UNNEST:
7725        // materialise the row stream from a single eval pass, then
7726        // run the regular projection / WHERE / ORDER BY / LIMIT
7727        // pipeline over the synthetic single-column table.
7728        if from.primary.generate_series_args.is_some() {
7729            return self.exec_select_generate_series(stmt, &from.primary, cancel);
7730        }
7731        let primary = &from.primary;
7732        let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
7733            StorageError::TableNotFound {
7734                name: primary.name.clone(),
7735            }
7736        })?;
7737        let schema_cols = &table.schema().columns;
7738        // The qualifier accepted on column refs is the alias (if any) else the
7739        // bare table name.
7740        let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
7741        let ctx = self.ev_ctx(schema_cols, Some(alias));
7742
7743        // NSW kNN planner: `ORDER BY col <-> literal LIMIT k` with no
7744        // WHERE and an NSW index on `col` skips the full scan. The
7745        // walk returns rows already in ascending-distance order, so
7746        // ORDER BY / LIMIT are honoured implicitly.
7747        if let Some(nsw_rows) = try_nsw_knn(stmt, table, schema_cols, alias) {
7748            return materialise_in_order(stmt, table, schema_cols, alias, &nsw_rows);
7749        }
7750
7751        // Index seek: if WHERE is `col = literal` (or commuted) and the
7752        // referenced column has an index, dispatch each locator through
7753        // the catalog (hot tier → borrow, cold tier → page-read +
7754        // decode) and iterate just those rows. Otherwise fall back to a
7755        // full scan over the hot tier (cold-tier rows are only reached
7756        // via index seek in v5.1 — full table scans against cold-tier
7757        // data ship in v5.2 with the freezer's per-segment scan API).
7758        let indexed_rows: Option<Vec<Cow<'_, Row>>> = stmt.where_.as_ref().and_then(|w| {
7759            // BTree / col=literal seek first — covers the v7.11.3 multi-
7760            // column AND case and the leading-column equality lookup.
7761            try_index_seek(w, schema_cols, self.active_catalog(), table, alias)
7762                .or_else(|| {
7763                    // v7.12.3 — GIN-accelerated `WHERE col @@
7764                    // tsquery` when the column has a `USING gin`
7765                    // index. Returns an over-approximate candidate
7766                    // set; the WHERE re-eval loop below verifies
7767                    // the full `@@` predicate per row.
7768                    try_gin_seek(w, schema_cols, self.active_catalog(), table, alias, &ctx)
7769                })
7770                .or_else(|| {
7771                    // v7.15.0 — trigram-GIN-accelerated
7772                    // `WHERE col LIKE / ILIKE '<pat>'` when the
7773                    // column has a `gin_trgm_ops` GIN index.
7774                    // Over-approximate candidate set; the WHERE
7775                    // re-eval verifies the LIKE per row.
7776                    try_trgm_seek(w, schema_cols, table, alias)
7777                })
7778        });
7779
7780        // Aggregate path: filter rows first, then hand off to the
7781        // aggregate executor which does its own projection + ORDER BY.
7782        if aggregate::uses_aggregate(stmt) {
7783            let mut filtered: Vec<&Row> = Vec::new();
7784            // v6.2.6 — Memoize: per-query LRU cache for correlated
7785            // scalar subqueries. Fresh per row-loop entry so each
7786            // SELECT execution gets an isolated cache.
7787            let mut memo = memoize::MemoizeCache::new();
7788            if let Some(rows) = &indexed_rows {
7789                for cow in rows {
7790                    let row = cow.as_ref();
7791                    if let Some(where_expr) = &stmt.where_ {
7792                        let cond = self.eval_expr_with_correlated(
7793                            where_expr,
7794                            row,
7795                            &ctx,
7796                            cancel,
7797                            Some(&mut memo),
7798                        )?;
7799                        if !matches!(cond, Value::Bool(true)) {
7800                            continue;
7801                        }
7802                    }
7803                    filtered.push(row);
7804                }
7805            } else {
7806                for i in 0..table.row_count() {
7807                    let row = &table.rows()[i];
7808                    if let Some(where_expr) = &stmt.where_ {
7809                        let cond = self.eval_expr_with_correlated(
7810                            where_expr,
7811                            row,
7812                            &ctx,
7813                            cancel,
7814                            Some(&mut memo),
7815                        )?;
7816                        if !matches!(cond, Value::Bool(true)) {
7817                            continue;
7818                        }
7819                    }
7820                    filtered.push(row);
7821                }
7822            }
7823            let mut agg = aggregate::run(stmt, &filtered, schema_cols, Some(alias))?;
7824            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
7825            return Ok(QueryResult::Rows {
7826                columns: agg.columns,
7827                rows: agg.rows,
7828            });
7829        }
7830
7831        let projection = build_projection(&stmt.items, schema_cols, alias)?;
7832        // v7.19 P5 — single-table SELECT path for SRF
7833        // `SELECT unnest(arr) FROM t` shape. Detect a top-level
7834        // unnest in the projection list. When present, the
7835        // per-row processor emits one output row per array
7836        // element (broadcasting non-SRF projections from the
7837        // same input row). Empty / NULL arrays emit zero rows
7838        // for that input — PG semantics.
7839        let srf_position = projection.iter().position(|p| is_top_level_unnest(&p.expr));
7840
7841        // Materialise the filter pass into `(order_key, projected_row)`
7842        // tuples. The order key is `None` when there's no ORDER BY clause.
7843        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
7844        // v6.2.6 — Memoize per-row WHERE eval shares one cache.
7845        let mut memo = memoize::MemoizeCache::new();
7846        // Inline the per-row work in a closure so the indexed and full-
7847        // scan branches share the body.
7848        let mut process_row = |row: &Row, loop_idx: usize| -> Result<(), EngineError> {
7849            if loop_idx.is_multiple_of(256) {
7850                cancel.check()?;
7851            }
7852            if let Some(where_expr) = &stmt.where_ {
7853                let cond =
7854                    self.eval_expr_with_correlated(where_expr, row, &ctx, cancel, Some(&mut memo))?;
7855                if !matches!(cond, Value::Bool(true)) {
7856                    return Ok(());
7857                }
7858            }
7859            let order_keys = if stmt.order_by.is_empty() {
7860                Vec::new()
7861            } else {
7862                build_order_keys(&stmt.order_by, row, &ctx)?
7863            };
7864            if let Some(srf_idx) = srf_position {
7865                let srf_arg = top_level_unnest_arg(&projection[srf_idx].expr)
7866                    .expect("checked by is_top_level_unnest above");
7867                let arr_val = eval::eval_expr(srf_arg, row, &ctx)?;
7868                let elements = array_value_to_elements(&arr_val)?;
7869                for elem in elements {
7870                    let mut values = Vec::with_capacity(projection.len());
7871                    for (i, p) in projection.iter().enumerate() {
7872                        if i == srf_idx {
7873                            values.push(elem.clone());
7874                        } else {
7875                            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
7876                        }
7877                    }
7878                    tagged.push((order_keys.clone(), Row::new(values)));
7879                }
7880            } else {
7881                let mut values = Vec::with_capacity(projection.len());
7882                for p in &projection {
7883                    values.push(eval::eval_expr(&p.expr, row, &ctx)?);
7884                }
7885                tagged.push((order_keys, Row::new(values)));
7886            }
7887            Ok(())
7888        };
7889        if let Some(rows) = &indexed_rows {
7890            for (loop_idx, cow) in rows.iter().enumerate() {
7891                process_row(cow.as_ref(), loop_idx)?;
7892            }
7893        } else {
7894            for i in 0..table.row_count() {
7895                process_row(&table.rows()[i], i)?;
7896            }
7897        }
7898
7899        if !stmt.order_by.is_empty() {
7900            // Partial-sort fast path: when LIMIT is small relative to
7901            // the row count, select_nth_unstable + sort just the
7902            // prefix is O(n + k log k) instead of O(n log n). DISTINCT
7903            // requires the full sort because de-dup happens after.
7904            // WITH TIES likewise needs the full sort so the tie
7905            // extension can scan past `limit` to find rows that
7906            // share the last-kept row's key.
7907            let keep = if stmt.distinct || stmt.limit_with_ties {
7908                None
7909            } else {
7910                stmt.limit_literal()
7911                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
7912            };
7913            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
7914            partial_sort_tagged(&mut tagged, keep, &descs);
7915        }
7916
7917        // v7.17.0 Phase 3.P0-49 — `FETCH FIRST … WITH TIES` extends
7918        // past the truncated tail through every row that shares the
7919        // last-kept row's ORDER BY key. The tie check uses the
7920        // already-computed `(order_keys, row)` pairs so it matches
7921        // the sort comparator exactly. DISTINCT + WITH TIES falls
7922        // through to the no-ties path (PG also disallows their
7923        // combination; SPG silently drops the tie extension here so
7924        // the customer doesn't see a hard error mid-query — the
7925        // user-visible result is still correct, just narrower).
7926        let output_rows: Vec<Row> = if stmt.limit_with_ties && !stmt.distinct {
7927            apply_offset_and_limit_tagged(
7928                &mut tagged,
7929                stmt.offset_literal(),
7930                stmt.limit_literal(),
7931                true,
7932            );
7933            tagged.into_iter().map(|(_, r)| r).collect()
7934        } else {
7935            let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
7936            if stmt.distinct {
7937                output_rows = dedup_rows(output_rows);
7938            }
7939            apply_offset_and_limit(
7940                &mut output_rows,
7941                stmt.offset_literal(),
7942                stmt.limit_literal(),
7943            );
7944            output_rows
7945        };
7946
7947        let columns: Vec<ColumnSchema> = projection
7948            .into_iter()
7949            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
7950            .collect();
7951
7952        Ok(QueryResult::Rows {
7953            columns,
7954            rows: output_rows,
7955        })
7956    }
7957
7958    /// Multi-table SELECT executor (one or more JOIN peers).
7959    ///
7960    /// v1.10 builds the joined row set up-front via nested-loop joins,
7961    /// then runs WHERE + projection + ORDER BY against the combined
7962    /// rows. No index seek. Aggregates and DISTINCT still work because
7963    /// the executor delegates projection through the same shared paths.
7964    #[allow(clippy::too_many_lines)]
7965    /// v7.13.2 — mailrs round-6 S5. Resolve a TableRef into an
7966    /// owned (rows, schema) pair. Catalog tables clone their hot
7967    /// rows + schema; UNNEST table refs evaluate their array
7968    /// expression once and synthesise a single-column row set
7969    /// using the same dispatch as `exec_select_unnest`. Used by
7970    /// the joined-select path so UNNEST can appear in any FROM
7971    /// position, not just as the primary.
7972    fn materialise_table_ref(
7973        &self,
7974        tref: &TableRef,
7975    ) -> Result<(Vec<Row>, Vec<ColumnSchema>), EngineError> {
7976        if let Some(expr) = tref.unnest_expr.as_deref() {
7977            let empty_schema: Vec<ColumnSchema> = Vec::new();
7978            let ctx = EvalContext::new(&empty_schema, None);
7979            let dummy_row = Row::new(Vec::new());
7980            let (elem_dtype, rows) =
7981                match eval::eval_expr(expr, &dummy_row, &ctx).map_err(EngineError::Eval)? {
7982                    Value::Null => (DataType::Text, Vec::new()),
7983                    Value::TextArray(items) => (
7984                        DataType::Text,
7985                        items
7986                            .into_iter()
7987                            .map(|item| {
7988                                Row::new(alloc::vec![match item {
7989                                    Some(s) => Value::Text(s),
7990                                    None => Value::Null,
7991                                }])
7992                            })
7993                            .collect(),
7994                    ),
7995                    Value::IntArray(items) => (
7996                        DataType::Int,
7997                        items
7998                            .into_iter()
7999                            .map(|item| {
8000                                Row::new(alloc::vec![match item {
8001                                    Some(n) => Value::Int(n),
8002                                    None => Value::Null,
8003                                }])
8004                            })
8005                            .collect(),
8006                    ),
8007                    Value::BigIntArray(items) => (
8008                        DataType::BigInt,
8009                        items
8010                            .into_iter()
8011                            .map(|item| {
8012                                Row::new(alloc::vec![match item {
8013                                    Some(n) => Value::BigInt(n),
8014                                    None => Value::Null,
8015                                }])
8016                            })
8017                            .collect(),
8018                    ),
8019                    other => {
8020                        return Err(EngineError::Unsupported(alloc::format!(
8021                            "unnest() expects an array argument, got {:?}",
8022                            other.data_type()
8023                        )));
8024                    }
8025                };
8026            let alias = tref.alias.clone().unwrap_or_else(|| "unnest".to_string());
8027            let col_name = tref.unnest_column_aliases.first().cloned().unwrap_or(alias);
8028            return Ok((
8029                rows,
8030                alloc::vec![ColumnSchema::new(col_name, elem_dtype, true)],
8031            ));
8032        }
8033        let table =
8034            self.active_catalog()
8035                .get(&tref.name)
8036                .ok_or_else(|| StorageError::TableNotFound {
8037                    name: tref.name.clone(),
8038                })?;
8039        let rows: Vec<Row> = table.rows().iter().cloned().collect();
8040        let cols = table.schema().columns.clone();
8041        Ok((rows, cols))
8042    }
8043
8044    /// v7.17.0 Phase 3.P0-43 — materialise a `FROM` with one or more
8045    /// JOINs into `(combined_schema, filtered_rows)`. The combined
8046    /// schema uses composite `alias.col` column names so the
8047    /// qualifier-aware column resolver finds every join peer by
8048    /// exact match; the filtered rows are the join cross-product
8049    /// after the optional WHERE clause is applied.
8050    ///
8051    /// Shared by `exec_joined_select` and the JOIN branch of
8052    /// `exec_select_with_window`; both paths used to inline the
8053    /// same nested-loop logic and the window path rejected JOIN
8054    /// outright.
8055    fn build_joined_filtered_rows(
8056        &self,
8057        from: &FromClause,
8058        where_: Option<&Expr>,
8059    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
8060        let (primary_rows, primary_cols) = self.materialise_table_ref(&from.primary)?;
8061        let primary_alias = from
8062            .primary
8063            .alias
8064            .as_deref()
8065            .unwrap_or(from.primary.name.as_str())
8066            .to_string();
8067        // v7.17.0 Phase 3.P0-41 — LATERAL peers can't be
8068        // pre-materialised because their rows depend on outer
8069        // columns. For each peer, build either an eager
8070        // (rows, schema) pair or a "lateral" sentinel carrying
8071        // just the schema and the inner SELECT to re-run per
8072        // outer row.
8073        #[allow(clippy::type_complexity)]
8074        let mut joined: Vec<JoinedPeer<'_>> = Vec::new();
8075        for j in &from.joins {
8076            let a = j
8077                .table
8078                .alias
8079                .as_deref()
8080                .unwrap_or(j.table.name.as_str())
8081                .to_string();
8082            if let Some(inner_box) = &j.table.lateral_subquery {
8083                // Probe schema by running the inner SELECT against a
8084                // NULL-padded outer context. The probe gives us the
8085                // projection's column shape; rows materialise per
8086                // left-row below.
8087                let schema = self.lateral_probe_schema(inner_box)?;
8088                joined.push(JoinedPeer {
8089                    eager_rows: None,
8090                    cols: schema,
8091                    alias: a,
8092                    kind: j.kind,
8093                    on: j.on.as_ref(),
8094                    lateral: Some(inner_box.as_ref()),
8095                });
8096            } else {
8097                let (rows, cols) = self.materialise_table_ref(&j.table)?;
8098                joined.push(JoinedPeer {
8099                    eager_rows: Some(rows),
8100                    cols,
8101                    alias: a,
8102                    kind: j.kind,
8103                    on: j.on.as_ref(),
8104                    lateral: None,
8105                });
8106            }
8107        }
8108        let mut combined_schema: Vec<ColumnSchema> = Vec::new();
8109        for col in &primary_cols {
8110            combined_schema.push(ColumnSchema::new(
8111                alloc::format!("{primary_alias}.{}", col.name),
8112                col.ty,
8113                col.nullable,
8114            ));
8115        }
8116        for peer in &joined {
8117            for col in &peer.cols {
8118                combined_schema.push(ColumnSchema::new(
8119                    alloc::format!("{}.{}", peer.alias, col.name),
8120                    col.ty,
8121                    col.nullable,
8122                ));
8123            }
8124        }
8125        let ctx = EvalContext::new(&combined_schema, None);
8126        let mut working: Vec<Row> = primary_rows;
8127        // Track the per-row width consumed by the outer left side so
8128        // each lateral evaluation sees the correct schema slice.
8129        let mut consumed_cols = primary_cols.len();
8130        for peer in &joined {
8131            let right_arity = peer.cols.len();
8132            let mut next: Vec<Row> = Vec::new();
8133            for left in &working {
8134                let mut left_matched = false;
8135                let per_left_rrows: alloc::borrow::Cow<'_, [Row]> = match peer.lateral {
8136                    Some(inner) => {
8137                        // Substitute outer columns and run the inner
8138                        // SELECT against the current left row's slice
8139                        // of the combined schema.
8140                        let outer_schema = &combined_schema[..consumed_cols];
8141                        let rows = self.materialise_lateral_for_outer(inner, outer_schema, left)?;
8142                        alloc::borrow::Cow::Owned(rows)
8143                    }
8144                    None => {
8145                        let r = peer.eager_rows.as_ref().expect("non-lateral peer eager");
8146                        alloc::borrow::Cow::Borrowed(r.as_slice())
8147                    }
8148                };
8149                for right in per_left_rrows.as_ref() {
8150                    let mut combined_vals = left.values.clone();
8151                    combined_vals.extend(right.values.iter().cloned());
8152                    let combined = Row::new(combined_vals);
8153                    let keep = if let Some(on_expr) = peer.on {
8154                        let cond = eval::eval_expr(on_expr, &combined, &ctx)?;
8155                        matches!(cond, Value::Bool(true))
8156                    } else {
8157                        true
8158                    };
8159                    if keep {
8160                        next.push(combined);
8161                        left_matched = true;
8162                    }
8163                }
8164                if !left_matched && matches!(peer.kind, JoinKind::Left) {
8165                    let mut combined_vals = left.values.clone();
8166                    for _ in 0..right_arity {
8167                        combined_vals.push(Value::Null);
8168                    }
8169                    next.push(Row::new(combined_vals));
8170                }
8171            }
8172            working = next;
8173            consumed_cols += right_arity;
8174            debug_assert!(consumed_cols <= combined_schema.len());
8175        }
8176        let mut filtered: Vec<Row> = Vec::new();
8177        for row in working {
8178            if let Some(where_expr) = where_ {
8179                let cond = eval::eval_expr(where_expr, &row, &ctx)?;
8180                if !matches!(cond, Value::Bool(true)) {
8181                    continue;
8182                }
8183            }
8184            filtered.push(row);
8185        }
8186        Ok((combined_schema, filtered))
8187    }
8188
8189    /// v7.17.0 Phase 3.P0-41 — probe a LATERAL subquery's projection
8190    /// schema by running it once with a NULL-padded outer context.
8191    /// The probe never materialises real outer rows; it just executes
8192    /// the inner SELECT with `outer_alias.col` references substituted
8193    /// to NULL so the projection's type inference is exercised.
8194    fn lateral_probe_schema(
8195        &self,
8196        inner: &SelectStatement,
8197    ) -> Result<Vec<ColumnSchema>, EngineError> {
8198        // Substitute every qualified column reference whose qualifier
8199        // does NOT match an in-subquery FROM alias with NULL. The
8200        // safest probe is to walk the inner SELECT and replace any
8201        // `<qual>.<col>` whose qual isn't bound inside the subquery
8202        // with a Null literal. For the v7.17 probe we just run the
8203        // unmodified subquery and surface the columns; if it fails
8204        // (e.g. references an outer column the probe can't resolve),
8205        // we synthesise a best-effort schema from the SELECT items
8206        // by inferring a single Text-typed column per projection.
8207        match self.execute_readonly_select_for_lateral_probe(inner) {
8208            Ok(QueryResult::Rows { columns, .. }) => Ok(columns),
8209            // Best-effort fallback: each SELECT item becomes a TEXT
8210            // column. Real schemas only differ when the inner SELECT
8211            // references outer columns at projection-time; those
8212            // queries surface via the substitution path during
8213            // per-row execution and still return the right values.
8214            _ => {
8215                let mut out: Vec<ColumnSchema> = Vec::new();
8216                for (i, item) in inner.items.iter().enumerate() {
8217                    let name = match item {
8218                        SelectItem::Expr { alias: Some(a), .. } => a.clone(),
8219                        SelectItem::Expr { expr, .. } => synth_lateral_col_name(expr, i),
8220                        SelectItem::Wildcard => alloc::format!("col{i}"),
8221                    };
8222                    out.push(ColumnSchema::new(name, DataType::Text, true));
8223                }
8224                Ok(out)
8225            }
8226        }
8227    }
8228
8229    /// v7.17.0 Phase 3.P0-41 — try the inner LATERAL subquery against
8230    /// the engine in read-only mode for schema-probe purposes. Failure
8231    /// is expected when the subquery references an outer column the
8232    /// probe can't resolve; the caller falls back to a best-effort
8233    /// schema based on the SELECT items.
8234    fn execute_readonly_select_for_lateral_probe(
8235        &self,
8236        inner: &SelectStatement,
8237    ) -> Result<QueryResult, EngineError> {
8238        self.exec_bare_select_cancel(inner, CancelToken::none())
8239    }
8240
8241    /// v7.17.0 Phase 3.P0-41 — materialise a LATERAL subquery's rows
8242    /// for one outer-row context. Walks the inner SELECT, replaces
8243    /// every `<outer_alias>.<col>` reference whose alias appears in
8244    /// the outer schema with the literal value from the outer row,
8245    /// then runs the rewritten SELECT against the engine.
8246    fn materialise_lateral_for_outer(
8247        &self,
8248        inner: &SelectStatement,
8249        outer_schema: &[ColumnSchema],
8250        outer_row: &Row,
8251    ) -> Result<Vec<Row>, EngineError> {
8252        let mut substituted = inner.clone();
8253        substitute_outer_columns_multi(&mut substituted, outer_row, outer_schema);
8254        let result = self.exec_bare_select_cancel(&substituted, CancelToken::none())?;
8255        match result {
8256            QueryResult::Rows { rows, .. } => Ok(rows),
8257            _ => Err(EngineError::Unsupported(
8258                "LATERAL subquery must be a SELECT (cannot be a write statement)".into(),
8259            )),
8260        }
8261    }
8262
8263    fn exec_joined_select(
8264        &self,
8265        stmt: &SelectStatement,
8266        from: &FromClause,
8267    ) -> Result<QueryResult, EngineError> {
8268        // v7.17.0 Phase 3.P0-43 + P0-41 — delegate the join +
8269        // WHERE materialisation to the shared helper so the LATERAL
8270        // / UNNEST / regular-catalog paths route through one place.
8271        // (`build_joined_filtered_rows` carries LATERAL support as
8272        // of Phase 3.P0-41.) Downstream we still handle aggregate /
8273        // projection / ORDER BY / DISTINCT / LIMIT inline because
8274        // those depend on the SelectStatement's items list.
8275        let (combined_schema, filtered) =
8276            self.build_joined_filtered_rows(from, stmt.where_.as_ref())?;
8277        let ctx = EvalContext::new(&combined_schema, None);
8278        // Aggregate path: handle GROUP BY / aggregate calls over the
8279        // joined+filtered rows.
8280        if aggregate::uses_aggregate(stmt) {
8281            let refs: Vec<&Row> = filtered.iter().collect();
8282            let mut agg = aggregate::run(stmt, &refs, &combined_schema, None)?;
8283            apply_offset_and_limit(&mut agg.rows, stmt.offset_literal(), stmt.limit_literal());
8284            return Ok(QueryResult::Rows {
8285                columns: agg.columns,
8286                rows: agg.rows,
8287            });
8288        }
8289
8290        let projection = build_projection(&stmt.items, &combined_schema, "")?;
8291        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::new();
8292        for row in &filtered {
8293            let mut values = Vec::with_capacity(projection.len());
8294            for p in &projection {
8295                values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8296            }
8297            let order_keys = if stmt.order_by.is_empty() {
8298                Vec::new()
8299            } else {
8300                build_order_keys(&stmt.order_by, row, &ctx)?
8301            };
8302            tagged.push((order_keys, Row::new(values)));
8303        }
8304        if !stmt.order_by.is_empty() {
8305            let keep = if stmt.distinct {
8306                None
8307            } else {
8308                stmt.limit_literal()
8309                    .map(|l| l as usize + stmt.offset_literal().map_or(0, |o| o as usize))
8310            };
8311            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
8312            partial_sort_tagged(&mut tagged, keep, &descs);
8313        }
8314        let mut output_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
8315        if stmt.distinct {
8316            output_rows = dedup_rows(output_rows);
8317        }
8318        apply_offset_and_limit(
8319            &mut output_rows,
8320            stmt.offset_literal(),
8321            stmt.limit_literal(),
8322        );
8323        let columns: Vec<ColumnSchema> = projection
8324            .into_iter()
8325            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8326            .collect();
8327        Ok(QueryResult::Rows {
8328            columns,
8329            rows: output_rows,
8330        })
8331    }
8332}
8333
8334/// One row-producing projection: an expression to evaluate, the resulting
8335/// column's user-visible name, its inferred type, and nullability.
8336#[derive(Debug, Clone)]
8337struct ProjectedItem {
8338    expr: Expr,
8339    output_name: String,
8340    ty: DataType,
8341    nullable: bool,
8342}
8343
8344/// Dedupe a row set, preserving first-seen order. `Row`'s `PartialEq` is
8345/// structural (`Vec<Value>` ⇒ pairwise `Value` equality), which gives SQL
8346/// `NULL = NULL → TRUE` and `NaN = NaN → FALSE`. The first agrees with
8347/// the spec's "two NULLs are not distinct"; the second is a tolerated
8348/// quirk for v1 (no NaN literals are reachable from the SQL surface).
8349fn dedup_rows(rows: Vec<Row>) -> Vec<Row> {
8350    let mut out: Vec<Row> = Vec::with_capacity(rows.len());
8351    for r in rows {
8352        if !out.iter().any(|seen| seen == &r) {
8353            out.push(r);
8354        }
8355    }
8356    out
8357}
8358
8359/// Coerce a `Value` to an `f64` sort key for ORDER BY. Numbers map directly;
8360/// NULL sorts last (treated as `+∞`); booleans are 0.0 / 1.0; text uses lex
8361/// order via the byte values; vectors are not sortable.
8362fn value_to_order_key(v: &Value) -> Result<f64, EngineError> {
8363    match v {
8364        Value::Null => Ok(f64::INFINITY),
8365        Value::SmallInt(n) => Ok(f64::from(*n)),
8366        Value::Int(n) => Ok(f64::from(*n)),
8367        Value::Date(d) => Ok(f64::from(*d)),
8368        #[allow(clippy::cast_precision_loss)]
8369        Value::Timestamp(t) => Ok(*t as f64),
8370        // v7.17.0 Phase 3.P0-32 — PG TIME ordered by underlying
8371        // i64 microseconds (matches wall-clock ordering).
8372        #[allow(clippy::cast_precision_loss)]
8373        Value::Time(us) => Ok(*us as f64),
8374        // v7.17.0 Phase 3.P0-33 — MySQL YEAR ordered by underlying
8375        // u16 (matches calendar ordering; zero-year sentinel
8376        // sorts before 1901).
8377        Value::Year(y) => Ok(f64::from(*y)),
8378        // v7.17.0 Phase 3.P0-34 — PG TIMETZ ordered by the
8379        // UTC-equivalent microseconds (local wall - offset). Two
8380        // values for the same physical instant in different zones
8381        // sort equal — matches PG TIMETZ index behaviour.
8382        #[allow(clippy::cast_precision_loss)]
8383        Value::TimeTz { us, offset_secs } => Ok((us - i64::from(*offset_secs) * 1_000_000) as f64),
8384        // v7.17.0 Phase 3.P0-35 — PG MONEY ordered by i64 cents.
8385        #[allow(clippy::cast_precision_loss)]
8386        Value::Money(c) => Ok(*c as f64),
8387        // v7.17.0 Phase 3.P0-38 — range ordering is not supported
8388        // in v7.17.0 (needs lex-then-inclusivity tiebreak).
8389        Value::Range { .. } => Err(EngineError::Unsupported(
8390            "ORDER BY of a range value is not supported in v7.17.0".into(),
8391        )),
8392        // v7.17.0 Phase 3.P0-39 — hstore is not orderable.
8393        Value::Hstore(_) => Err(EngineError::Unsupported(
8394            "ORDER BY of a hstore value is not supported".into(),
8395        )),
8396        // v7.17.0 Phase 3.P0-40 — 2D arrays not orderable.
8397        Value::IntArray2D(_) | Value::BigIntArray2D(_) | Value::TextArray2D(_) => Err(
8398            EngineError::Unsupported("ORDER BY of a 2D array is not supported in v7.17.0".into()),
8399        ),
8400        #[allow(clippy::cast_precision_loss)]
8401        Value::Numeric { scaled, scale } => {
8402            // Scaled integer / 10^scale, computed via f64 for sort
8403            // ordering only. Precision losses here only matter for
8404            // ORDER BY tie-breaks well past 15 significant digits.
8405            // `f64::powi` lives in std; we hand-roll the loop so the
8406            // no_std engine crate doesn't need it.
8407            let mut divisor = 1.0_f64;
8408            for _ in 0..*scale {
8409                divisor *= 10.0;
8410            }
8411            Ok((*scaled as f64) / divisor)
8412        }
8413        #[allow(clippy::cast_precision_loss)]
8414        Value::BigInt(n) => Ok(*n as f64),
8415        Value::Float(x) => Ok(*x),
8416        Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
8417        Value::Text(s) => {
8418            // Lex order by codepoints — good enough for ORDER BY name.
8419            // Map first 8 bytes packed into u64 as a coarse key; ties fall to
8420            // partial_cmp Equal. v1.x can swap in a real string comparator.
8421            let mut key: u64 = 0;
8422            for &b in s.as_bytes().iter().take(8) {
8423                key = (key << 8) | u64::from(b);
8424            }
8425            #[allow(clippy::cast_precision_loss)]
8426            Ok(key as f64)
8427        }
8428        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
8429            Err(EngineError::Unsupported(
8430                "ORDER BY of a raw vector column is not meaningful — use `<->`".into(),
8431            ))
8432        }
8433        Value::Interval { .. } => Err(EngineError::Unsupported(
8434            "ORDER BY of an INTERVAL is not supported in v2.11 \
8435             (months vs micros has no single canonical ordering)"
8436                .into(),
8437        )),
8438        Value::Json(_) => Err(EngineError::Unsupported(
8439            "ORDER BY of a JSON value is not supported — cast the document to text first".into(),
8440        )),
8441        // v7.5.0 — Value is #[non_exhaustive]; future variants need
8442        // an explicit ORDER BY mapping. Surface as Unsupported until
8443        // engine support is added.
8444        _ => Err(EngineError::Unsupported(
8445            "ORDER BY of this value type is not supported".into(),
8446        )),
8447    }
8448}
8449
8450/// Try to plan a WHERE clause as an equality lookup against an existing
8451/// index. Returns the candidate row indices on success; `None` means the
8452/// caller should fall back to a full scan.
8453///
8454/// v0.8 recognises a single top-level `col = literal` (in either operand
8455/// order). AND chains and range scans land in later milestones.
8456/// Look for `ORDER BY col <dist-op> literal LIMIT k` against an
8457/// NSW-indexed vector column. Recognised distance ops: `<->` (L2),
8458/// `<#>` (inner product), `<=>` (cosine). When a WHERE clause is
8459/// present, the planner does an "over-fetch and filter" pass — it
8460/// asks the graph for `k * over_fetch` candidates, evaluates WHERE
8461/// against each, and trims back to `k`. Returns the row indices in
8462/// ascending-distance order when the plan applies.
8463fn try_nsw_knn(
8464    stmt: &SelectStatement,
8465    table: &Table,
8466    schema_cols: &[ColumnSchema],
8467    table_alias: &str,
8468) -> Option<Vec<usize>> {
8469    if stmt.distinct {
8470        return None;
8471    }
8472    let limit = usize::try_from(stmt.limit_literal()?).ok()?;
8473    if limit == 0 {
8474        return None;
8475    }
8476    // v6.4.0 — NSW kNN dispatch needs a single ORDER BY key on the
8477    // distance metric. Multi-key ORDER BY falls through to the
8478    // generic sort path.
8479    if stmt.order_by.len() != 1 {
8480        return None;
8481    }
8482    let order = &stmt.order_by[0];
8483    // NSW kNN returns rows ascending by distance — DESC inverts the
8484    // natural order, so the planner can't handle it without a sort
8485    // pass. Fall back to the generic ORDER BY path.
8486    if order.desc {
8487        return None;
8488    }
8489    let Expr::Binary { lhs, op, rhs } = &order.expr else {
8490        return None;
8491    };
8492    let metric = match op {
8493        BinOp::L2Distance => spg_storage::NswMetric::L2,
8494        BinOp::InnerProduct => spg_storage::NswMetric::InnerProduct,
8495        BinOp::CosineDistance => spg_storage::NswMetric::Cosine,
8496        _ => return None,
8497    };
8498    // Accept both `col <op> literal` and `literal <op> col`.
8499    let ((Expr::Column(col), literal) | (literal, Expr::Column(col))) =
8500        (lhs.as_ref(), rhs.as_ref())
8501    else {
8502        return None;
8503    };
8504    if let Some(q) = &col.qualifier
8505        && q != table_alias
8506    {
8507        return None;
8508    }
8509    let col_pos = schema_cols.iter().position(|s| s.name == col.name)?;
8510    let query = literal_to_vector(literal)?;
8511    let idx = spg_storage::nsw_index_on(table, col_pos)?;
8512    if let Some(where_expr) = &stmt.where_ {
8513        // Over-fetch and filter. The factor (10×) is a heuristic that
8514        // covers typical selectivity for the corpus tests; v2.x will
8515        // make it configurable.
8516        let over_fetch = limit.saturating_mul(10).max(NSW_OVER_FETCH_FLOOR);
8517        let candidates = spg_storage::nsw_query(table, &idx.name, &query, over_fetch, metric);
8518        let ctx = EvalContext::new(schema_cols, Some(table_alias));
8519        let mut kept: Vec<usize> = Vec::with_capacity(limit);
8520        for i in candidates {
8521            let row = &table.rows()[i];
8522            let cond = eval::eval_expr(where_expr, row, &ctx).ok()?;
8523            if matches!(cond, Value::Bool(true)) {
8524                kept.push(i);
8525                if kept.len() >= limit {
8526                    break;
8527                }
8528            }
8529        }
8530        Some(kept)
8531    } else {
8532        Some(spg_storage::nsw_query(
8533            table, &idx.name, &query, limit, metric,
8534        ))
8535    }
8536}
8537
8538/// Lower bound on the over-fetch pool when WHERE is present — even
8539/// for tiny `LIMIT 1` queries we keep enough candidates to absorb a
8540/// few WHERE rejections.
8541const NSW_OVER_FETCH_FLOOR: usize = 32;
8542
8543/// Pull a `Vec<f32>` out of a literal-or-cast expression. Returns
8544/// `None` for anything we can't fold at plan time.
8545fn literal_to_vector(e: &Expr) -> Option<Vec<f32>> {
8546    match e {
8547        Expr::Literal(Literal::Vector(v)) => Some(v.clone()),
8548        Expr::Cast { expr, .. } => literal_to_vector(expr),
8549        _ => None,
8550    }
8551}
8552
8553/// Materialise rows in a planner-supplied order (used by the NSW path)
8554/// without re-running ORDER BY. The projection + LIMIT slot mirror the
8555/// equivalent block in `exec_bare_select`.
8556fn materialise_in_order(
8557    stmt: &SelectStatement,
8558    table: &Table,
8559    schema_cols: &[ColumnSchema],
8560    table_alias: &str,
8561    ordered_rows: &[usize],
8562) -> Result<QueryResult, EngineError> {
8563    let ctx = EvalContext::new(schema_cols, Some(table_alias));
8564    let projection = build_projection(&stmt.items, schema_cols, table_alias)?;
8565    let mut output_rows: Vec<Row> = Vec::with_capacity(ordered_rows.len());
8566    for &i in ordered_rows {
8567        let row = &table.rows()[i];
8568        let mut values = Vec::with_capacity(projection.len());
8569        for p in &projection {
8570            values.push(eval::eval_expr(&p.expr, row, &ctx)?);
8571        }
8572        output_rows.push(Row::new(values));
8573    }
8574    apply_offset_and_limit(
8575        &mut output_rows,
8576        stmt.offset_literal(),
8577        stmt.limit_literal(),
8578    );
8579    let columns: Vec<ColumnSchema> = projection
8580        .into_iter()
8581        .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
8582        .collect();
8583    Ok(QueryResult::Rows {
8584        columns,
8585        rows: output_rows,
8586    })
8587}
8588
8589fn try_index_seek<'a>(
8590    where_expr: &Expr,
8591    schema_cols: &[ColumnSchema],
8592    catalog: &'a Catalog,
8593    table: &'a Table,
8594    table_alias: &str,
8595) -> Option<Vec<Cow<'a, Row>>> {
8596    // v7.11.3 — recurse through top-level `AND` so a PG-style
8597    // composite predicate like `WHERE id = 1 AND created_at > $1`
8598    // still hits the index on `id`. The caller re-applies the
8599    // full WHERE expression to each returned row, so dropping the
8600    // residual conjuncts here is correct — the index just narrows
8601    // the candidate set.
8602    if let Expr::Binary {
8603        lhs,
8604        op: BinOp::And,
8605        rhs,
8606    } = where_expr
8607    {
8608        // Try LHS first (typical convention: leading equality on
8609        // the indexed column comes first in user-written SQL).
8610        if let Some(rows) = try_index_seek(lhs, schema_cols, catalog, table, table_alias) {
8611            return Some(rows);
8612        }
8613        return try_index_seek(rhs, schema_cols, catalog, table, table_alias);
8614    }
8615    let Expr::Binary {
8616        lhs,
8617        op: BinOp::Eq,
8618        rhs,
8619    } = where_expr
8620    else {
8621        return None;
8622    };
8623    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
8624        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
8625    let idx = table.index_on(col_pos)?;
8626    let key = IndexKey::from_value(&value)?;
8627    let locators = idx.lookup_eq(&key);
8628    let table_name = table.schema().name.as_str();
8629    // v5.1: each locator dispatches to either the hot tier (zero-
8630    // copy borrow of `table.rows()[i]`) or a cold-tier segment
8631    // (one page read + dense row decode, ~µs scale). Cold rows are
8632    // returned as `Cow::Owned` so the caller's `&Row` iteration
8633    // doesn't see a tier distinction; pre-freezer (no cold
8634    // segments loaded) every locator is `Hot` and every entry is
8635    // `Cow::Borrowed` — identical cost to the pre-v5.1 path.
8636    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(locators.len());
8637    for loc in locators {
8638        match *loc {
8639            spg_storage::RowLocator::Hot(i) => {
8640                if let Some(row) = table.rows().get(i) {
8641                    out.push(Cow::Borrowed(row));
8642                }
8643            }
8644            spg_storage::RowLocator::Cold { segment_id, .. } => {
8645                if let Some(row) = catalog.resolve_cold_locator(table_name, segment_id, &key) {
8646                    out.push(Cow::Owned(row));
8647                }
8648            }
8649        }
8650    }
8651    Some(out)
8652}
8653
8654/// v7.12.3 — GIN-accelerated candidate seek for `WHERE col @@ <ts_query>`.
8655///
8656/// Recurses through top-level `AND` like [`try_index_seek`] so a
8657/// composite predicate `WHERE search_vector @@ q AND id > $1` still
8658/// hits the GIN index on `search_vector` — the caller re-applies the
8659/// full WHERE expression to each returned candidate, so dropping the
8660/// `id > $1` residual here stays semantically correct.
8661///
8662/// Returns `None` when:
8663///   - no leaf is a `col @@ <rhs>` shape on a GIN-indexed column;
8664///   - the RHS can't be const-evaluated to a `Value::TsQuery`
8665///     (typically because it references row columns);
8666///   - the resolved `TsQuery` uses query shapes the MVP doesn't
8667///     accelerate (`Not`, `Phrase` — those fall through to full scan).
8668///
8669/// On `Some(rows)` the caller iterates only `rows` and re-evaluates
8670/// the full `@@` predicate per row, so an over-approximate candidate
8671/// set is safe.
8672fn try_gin_seek<'a>(
8673    where_expr: &Expr,
8674    schema_cols: &[ColumnSchema],
8675    catalog: &'a Catalog,
8676    table: &'a Table,
8677    table_alias: &str,
8678    ctx: &eval::EvalContext<'_>,
8679) -> Option<Vec<Cow<'a, Row>>> {
8680    if let Expr::Binary {
8681        lhs,
8682        op: BinOp::And,
8683        rhs,
8684    } = where_expr
8685    {
8686        if let Some(rows) = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx) {
8687            return Some(rows);
8688        }
8689        return try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx);
8690    }
8691    // v7.17.0 Phase 3.P0-44 — MySQL `MATCH(col1, col2) AGAINST (...)`
8692    // desugars into `(to_tsvector(col1) @@ q) OR (to_tsvector(col2) @@ q)`
8693    // in the parser. To accelerate the multi-column case, walk OR the same
8694    // way we walk AND: only emit a candidate set if BOTH sides can seek
8695    // (otherwise the OR result is unbounded and we must fall through to
8696    // the full scan). Candidates are union'd; the caller's WHERE re-eval
8697    // verifies the full predicate per row, so duplicates / supersets stay
8698    // semantically safe.
8699    if let Expr::Binary {
8700        lhs,
8701        op: BinOp::Or,
8702        rhs,
8703    } = where_expr
8704    {
8705        let left = try_gin_seek(lhs, schema_cols, catalog, table, table_alias, ctx)?;
8706        let right = try_gin_seek(rhs, schema_cols, catalog, table, table_alias, ctx)?;
8707        let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(left.len() + right.len());
8708        out.extend(left);
8709        out.extend(right);
8710        return Some(out);
8711    }
8712    let Expr::Binary {
8713        lhs,
8714        op: BinOp::TsMatch,
8715        rhs,
8716    } = where_expr
8717    else {
8718        return None;
8719    };
8720    // Either side can be the column; pgvector idiom (`vec @@ q`)
8721    // hits the first arm, FROM-clause-derived (`plainto_tsquery($1)
8722    // q ... WHERE search_vector @@ q`) the same. CROSS JOIN derived
8723    // tables resolve `q` to a Column too.
8724    let (col_pos, query) = resolve_gin_col_query(lhs, rhs, schema_cols, table_alias, ctx)
8725        .or_else(|| resolve_gin_col_query(rhs, lhs, schema_cols, table_alias, ctx))?;
8726    // v7.17.0 Phase 3.P0-44 — MySQL `FULLTEXT KEY` builds a
8727    // `IndexKind::GinFulltext` posting list (Phase 2.2). It shares
8728    // the same `gin_lookup_word` shape as the tsvector-typed GIN,
8729    // so the MATCH-AGAINST `@@` predicate (desugared by the parser
8730    // into `to_tsvector(col) @@ plainto_tsquery('term')`) routes
8731    // through the same candidate-set seek.
8732    let idx = table
8733        .indices()
8734        .iter()
8735        .find(|i| i.column_position == col_pos && (i.is_gin() || i.is_gin_fulltext()))?;
8736    let candidates = gin_query_candidates(idx, &query)?;
8737    let _ = catalog; // cold-tier row resolution unused in MVP; see below.
8738    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(candidates.len());
8739    for loc in candidates {
8740        match loc {
8741            spg_storage::RowLocator::Hot(i) => {
8742                if let Some(row) = table.rows().get(i) {
8743                    out.push(Cow::Borrowed(row));
8744                }
8745            }
8746            // GIN cold-tier rows in the MVP: skipped, matching the
8747            // full-scan `@@` path which itself only iterates
8748            // `table.rows()` (hot tier). When v7.13+ adds cold-tier
8749            // scan-time materialisation for `@@`, the parallel
8750            // resolution lands here; until then both paths see the
8751            // same hot-only candidate set so correctness is preserved.
8752            spg_storage::RowLocator::Cold { .. } => {}
8753        }
8754    }
8755    Some(out)
8756}
8757
8758/// v7.15.0 — trigram-GIN-accelerated candidate seek for
8759/// `WHERE col LIKE '<pat>'` and `WHERE col ILIKE '<pat>'` when
8760/// the column has a `gin_trgm_ops` GIN index.
8761///
8762/// Walks top-level `AND` so multi-predicate WHEREs (`col LIKE
8763/// 'foo%' AND id > 1`) still hit the trigram index; the caller
8764/// re-evaluates the full WHERE per candidate row, so dropping
8765/// non-LIKE conjuncts here stays semantically correct.
8766///
8767/// Returns `None` when:
8768///   - no leaf is `col LIKE/ILIKE <literal>` on a trigram-GIN-
8769///     indexed column;
8770///   - the pattern's literal runs are too short to constrain
8771///     (pattern decomposes into `< 3`-char runs, e.g. `%ab%`);
8772///   - the pattern doesn't const-evaluate to a TEXT.
8773fn try_trgm_seek<'a>(
8774    where_expr: &Expr,
8775    schema_cols: &[ColumnSchema],
8776    table: &'a Table,
8777    table_alias: &str,
8778) -> Option<Vec<Cow<'a, Row>>> {
8779    if let Expr::Binary {
8780        lhs,
8781        op: BinOp::And,
8782        rhs,
8783    } = where_expr
8784    {
8785        if let Some(rows) = try_trgm_seek(lhs, schema_cols, table, table_alias) {
8786            return Some(rows);
8787        }
8788        return try_trgm_seek(rhs, schema_cols, table, table_alias);
8789    }
8790    // LIKE node is what carries the column reference + pattern.
8791    // ILIKE is the same AST node — PG's LIKE/ILIKE both lower
8792    // through `Expr::Like { expr, pattern, negated }`. The trigram
8793    // index posting-list keys are already lower-cased and
8794    // case-folded, so we only need the pattern's literal runs.
8795    let Expr::Like { expr, pattern, .. } = where_expr else {
8796        return None;
8797    };
8798    // Column side.
8799    let Expr::Column(c) = expr.as_ref() else {
8800        return None;
8801    };
8802    if let Some(q) = &c.qualifier
8803        && q != table_alias
8804    {
8805        return None;
8806    }
8807    let col_pos = schema_cols
8808        .iter()
8809        .position(|s| s.name.eq_ignore_ascii_case(&c.name))?;
8810    // Index must exist on that column AND be a trigram-GIN.
8811    let idx = table
8812        .indices()
8813        .iter()
8814        .find(|i| i.column_position == col_pos && i.is_gin_trgm())?;
8815    // Pattern side must be a literal TEXT — anything else (column
8816    // ref, function call, parameter that hasn't been bound yet)
8817    // falls through to full scan.
8818    let Expr::Literal(spg_sql::ast::Literal::String(pat)) = pattern.as_ref() else {
8819        return None;
8820    };
8821    let trigrams = spg_storage::trgm::trigrams_from_like_pattern(pat)?;
8822    // Intersect every trigram's posting list. Empty intersection
8823    // → empty candidate set (caller short-circuits its row loop).
8824    let mut iter = trigrams.iter();
8825    let first = iter.next()?;
8826    let mut acc: Vec<spg_storage::RowLocator> = {
8827        let mut v = idx.gin_trgm_lookup(first).to_vec();
8828        v.sort_by_key(locator_sort_key);
8829        v.dedup_by_key(|l| locator_sort_key(l));
8830        v
8831    };
8832    for tri in iter {
8833        let mut next: Vec<spg_storage::RowLocator> = idx.gin_trgm_lookup(tri).to_vec();
8834        next.sort_by_key(locator_sort_key);
8835        next.dedup_by_key(|l| locator_sort_key(l));
8836        // Sorted-merge intersection.
8837        let mut merged: Vec<spg_storage::RowLocator> =
8838            Vec::with_capacity(acc.len().min(next.len()));
8839        let (mut i, mut j) = (0usize, 0usize);
8840        while i < acc.len() && j < next.len() {
8841            let lk = locator_sort_key(&acc[i]);
8842            let rk = locator_sort_key(&next[j]);
8843            match lk.cmp(&rk) {
8844                core::cmp::Ordering::Less => i += 1,
8845                core::cmp::Ordering::Greater => j += 1,
8846                core::cmp::Ordering::Equal => {
8847                    merged.push(acc[i]);
8848                    i += 1;
8849                    j += 1;
8850                }
8851            }
8852        }
8853        acc = merged;
8854        if acc.is_empty() {
8855            break;
8856        }
8857    }
8858    let mut out: Vec<Cow<'a, Row>> = Vec::with_capacity(acc.len());
8859    for loc in acc {
8860        if let spg_storage::RowLocator::Hot(i) = loc
8861            && let Some(row) = table.rows().get(i)
8862        {
8863            out.push(Cow::Borrowed(row));
8864        }
8865        // Cold-tier rows: skipped in MVP (same as try_gin_seek).
8866    }
8867    Some(out)
8868}
8869
8870/// v7.12.3 — extract `(column_position, TsQueryAst)` when one side of
8871/// the binary is a column reference to a GIN-indexed tsvector column
8872/// and the other side const-evaluates to a `Value::TsQuery`. Returns
8873/// `None` if the column reference is for the wrong table alias, or if
8874/// the RHS expression depends on row data.
8875fn resolve_gin_col_query(
8876    col_side: &Expr,
8877    query_side: &Expr,
8878    schema_cols: &[ColumnSchema],
8879    table_alias: &str,
8880    ctx: &eval::EvalContext<'_>,
8881) -> Option<(usize, spg_storage::TsQueryAst)> {
8882    // v7.17.0 Phase 3.P0-44 — the MATCH AGAINST desugar wraps the
8883    // column in `to_tsvector('simple', col)`, so we peel that wrapper
8884    // before the column lookup. Direct `col @@ tsquery` paths (the
8885    // tsvector-typed v7.12 surface) skip the wrapper entirely.
8886    let column = match col_side {
8887        Expr::Column(c) => c,
8888        Expr::FunctionCall { name, args }
8889            if name.eq_ignore_ascii_case("to_tsvector") && !args.is_empty() =>
8890        {
8891            // PG `to_tsvector` accepts either `to_tsvector(col)` or
8892            // `to_tsvector(config, col)`. In both shapes the column
8893            // we care about is the final argument.
8894            if let Expr::Column(c) = args.last().unwrap() {
8895                c
8896            } else {
8897                return None;
8898            }
8899        }
8900        _ => return None,
8901    };
8902    let c = column;
8903    if let Some(q) = &c.qualifier
8904        && q != table_alias
8905    {
8906        return None;
8907    }
8908    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
8909    // Const-evaluate the query side with an empty row — fails fast
8910    // (with a `ColumnNotFound` / similar) if the expression actually
8911    // depends on row data, which is exactly the bail signal we want.
8912    let empty_row = Row::new(Vec::new());
8913    let v = eval::eval_expr(query_side, &empty_row, ctx).ok()?;
8914    let Value::TsQuery(q) = v else { return None };
8915    Some((pos, q))
8916}
8917
8918/// v7.12.3 — walk a `TsQueryAst` against an [`IndexKind::Gin`] index
8919/// to produce a candidate row-locator set. Returns `None` for query
8920/// shapes the MVP doesn't accelerate (`Not` / `Phrase` — both bail to
8921/// full scan since their semantics need either complementation across
8922/// the whole row set or positional verification beyond what the
8923/// posting list carries).
8924///
8925/// Candidate sets are over-approximate — the caller re-applies the
8926/// full `@@` predicate per row, so reporting "row was in some
8927/// posting list" without verifying positions / weights stays correct.
8928fn gin_query_candidates(
8929    idx: &spg_storage::Index,
8930    query: &spg_storage::TsQueryAst,
8931) -> Option<Vec<spg_storage::RowLocator>> {
8932    use spg_storage::TsQueryAst;
8933    match query {
8934        TsQueryAst::Term { word, .. } => {
8935            let mut v: Vec<spg_storage::RowLocator> = idx.gin_lookup_word(word).to_vec();
8936            v.sort_by_key(locator_sort_key);
8937            v.dedup_by_key(|l| locator_sort_key(l));
8938            Some(v)
8939        }
8940        TsQueryAst::And(l, r) => {
8941            let mut left = gin_query_candidates(idx, l)?;
8942            let mut right = gin_query_candidates(idx, r)?;
8943            left.sort_by_key(locator_sort_key);
8944            right.sort_by_key(locator_sort_key);
8945            // Sorted-merge intersection.
8946            let mut out: Vec<spg_storage::RowLocator> = Vec::new();
8947            let (mut i, mut j) = (0usize, 0usize);
8948            while i < left.len() && j < right.len() {
8949                let lk = locator_sort_key(&left[i]);
8950                let rk = locator_sort_key(&right[j]);
8951                match lk.cmp(&rk) {
8952                    core::cmp::Ordering::Less => i += 1,
8953                    core::cmp::Ordering::Greater => j += 1,
8954                    core::cmp::Ordering::Equal => {
8955                        out.push(left[i]);
8956                        i += 1;
8957                        j += 1;
8958                    }
8959                }
8960            }
8961            Some(out)
8962        }
8963        TsQueryAst::Or(l, r) => {
8964            let mut out = gin_query_candidates(idx, l)?;
8965            out.extend(gin_query_candidates(idx, r)?);
8966            out.sort_by_key(locator_sort_key);
8967            out.dedup_by_key(|l| locator_sort_key(l));
8968            Some(out)
8969        }
8970        // Not / Phrase bail to full scan in the MVP. Not needs
8971        // complementation against the whole row set (not represented
8972        // in the posting-list view); Phrase needs positional
8973        // verification beyond what `word → rows` carries.
8974        TsQueryAst::Not(_) | TsQueryAst::Phrase { .. } => None,
8975    }
8976}
8977
8978/// v7.12.3 — total ordering on `RowLocator` for sort/dedup purposes
8979/// inside the GIN intersection / union loops. Hot rows order by their
8980/// row index; Cold rows order after all Hot rows, then by
8981/// `(segment_id, the cold sub-key)`.
8982fn locator_sort_key(l: &spg_storage::RowLocator) -> (u8, u64, u64) {
8983    match *l {
8984        spg_storage::RowLocator::Hot(i) => (0, i as u64, 0),
8985        spg_storage::RowLocator::Cold {
8986            segment_id,
8987            page_offset,
8988        } => (1, u64::from(segment_id), u64::from(page_offset)),
8989    }
8990}
8991
8992/// v5.2.3: extract `(column_position, IndexKey)` when `where_expr`
8993/// is a simple `col = literal` predicate suitable for a `BTree` index
8994/// seek. Used by `exec_update_cancel` / `exec_delete_cancel` to
8995/// decide whether a write touches a cold-tier row (which requires
8996/// promote-on-write / shadow-on-delete) before falling through to
8997/// the hot-tier row walk.
8998///
8999/// Returns `None` for any predicate shape the planner can't push
9000/// down to an index seek — complex WHERE clauses always take the
9001/// hot-only path (cold rows are immutable to non-indexed writes
9002/// until a future scan-fanout sub-version).
9003fn try_pk_predicate(
9004    where_expr: &Expr,
9005    schema_cols: &[ColumnSchema],
9006    table_alias: &str,
9007) -> Option<(usize, IndexKey)> {
9008    let Expr::Binary {
9009        lhs,
9010        op: BinOp::Eq,
9011        rhs,
9012    } = where_expr
9013    else {
9014        return None;
9015    };
9016    let (col_pos, value) = resolve_col_literal_pair(lhs, rhs, schema_cols, table_alias)
9017        .or_else(|| resolve_col_literal_pair(rhs, lhs, schema_cols, table_alias))?;
9018    let key = IndexKey::from_value(&value)?;
9019    Some((col_pos, key))
9020}
9021
9022fn resolve_col_literal_pair(
9023    col_side: &Expr,
9024    lit_side: &Expr,
9025    schema_cols: &[ColumnSchema],
9026    table_alias: &str,
9027) -> Option<(usize, Value)> {
9028    let Expr::Column(c) = col_side else {
9029        return None;
9030    };
9031    if let Some(q) = &c.qualifier
9032        && q != table_alias
9033    {
9034        return None;
9035    }
9036    let pos = schema_cols.iter().position(|s| s.name == c.name)?;
9037    let Expr::Literal(l) = lit_side else {
9038        return None;
9039    };
9040    let v = match l {
9041        Literal::Integer(n) => {
9042            if let Ok(small) = i32::try_from(*n) {
9043                Value::Int(small)
9044            } else {
9045                Value::BigInt(*n)
9046            }
9047        }
9048        Literal::Float(x) => Value::Float(*x),
9049        Literal::String(s) => Value::Text(s.clone()),
9050        Literal::Bool(b) => Value::Bool(*b),
9051        Literal::Null => Value::Null,
9052        // Vector and Interval literals can't be used as B-tree index keys.
9053        // Tell the planner to fall back to full-scan.
9054        Literal::Vector(_) | Literal::Interval { .. } => return None,
9055    };
9056    Some((pos, v))
9057}
9058
9059/// Find the schema entry that a SELECT-list `Expr::Column` refers to.
9060/// Mirrors `resolve_column` in `eval.rs`, but returns a proper
9061/// `EngineError` so the projection-build path keeps `UnknownQualifier`
9062/// vs `ColumnNotFound` distinct.
9063fn resolve_projection_column<'a>(
9064    c: &ColumnName,
9065    schema_cols: &'a [ColumnSchema],
9066    table_alias: &str,
9067) -> Result<&'a ColumnSchema, EngineError> {
9068    if let Some(q) = &c.qualifier {
9069        let composite = alloc::format!("{q}.{name}", name = c.name);
9070        if let Some(s) = schema_cols.iter().find(|s| s.name == composite) {
9071            return Ok(s);
9072        }
9073        // Single-table case: the qualifier may equal the active alias —
9074        // then look for the bare column name.
9075        if q == table_alias
9076            && let Some(s) = schema_cols.iter().find(|s| s.name == c.name)
9077        {
9078            return Ok(s);
9079        }
9080        // For multi-table schemas the qualifier is unknown only if no
9081        // column bears the "<q>." prefix. For single-table, the alias
9082        // mismatch alone is enough.
9083        let prefix = alloc::format!("{q}.");
9084        let qualifier_known =
9085            q == table_alias || schema_cols.iter().any(|s| s.name.starts_with(&prefix));
9086        if !qualifier_known {
9087            return Err(EngineError::Eval(EvalError::UnknownQualifier {
9088                qualifier: q.clone(),
9089            }));
9090        }
9091        return Err(EngineError::Eval(EvalError::ColumnNotFound {
9092            name: c.name.clone(),
9093        }));
9094    }
9095    if let Some(s) = schema_cols.iter().find(|s| s.name == c.name) {
9096        return Ok(s);
9097    }
9098    let suffix = alloc::format!(".{name}", name = c.name);
9099    let mut matches = schema_cols.iter().filter(|s| s.name.ends_with(&suffix));
9100    let first = matches.next();
9101    let extra = matches.next();
9102    match (first, extra) {
9103        (Some(s), None) => Ok(s),
9104        (Some(_), Some(_)) => Err(EngineError::Eval(EvalError::TypeMismatch {
9105            detail: alloc::format!("ambiguous column reference: {}", c.name),
9106        })),
9107        _ => Err(EngineError::Eval(EvalError::ColumnNotFound {
9108            name: c.name.clone(),
9109        })),
9110    }
9111}
9112
9113fn build_projection(
9114    items: &[SelectItem],
9115    schema_cols: &[ColumnSchema],
9116    table_alias: &str,
9117) -> Result<Vec<ProjectedItem>, EngineError> {
9118    let mut out = Vec::new();
9119    for item in items {
9120        match item {
9121            SelectItem::Wildcard => {
9122                for col in schema_cols {
9123                    out.push(ProjectedItem {
9124                        expr: Expr::Column(ColumnName {
9125                            qualifier: None,
9126                            name: col.name.clone(),
9127                        }),
9128                        output_name: col.name.clone(),
9129                        ty: col.ty,
9130                        nullable: col.nullable,
9131                    });
9132                }
9133            }
9134            SelectItem::Expr { expr, alias } => {
9135                // Plain column ref keeps full schema info (real type +
9136                // nullability). For compound expressions try the
9137                // describe-side function-return-type table first
9138                // (e.g. `SELECT now()` → Timestamptz, `SELECT
9139                // concat(…)` → Text). Falls back to nullable Text
9140                // for shapes the describe path can't resolve.
9141                if let Expr::Column(c) = expr {
9142                    let sch = resolve_projection_column(c, schema_cols, table_alias)?;
9143                    let output_name = alias.clone().unwrap_or_else(|| c.name.clone());
9144                    out.push(ProjectedItem {
9145                        expr: expr.clone(),
9146                        output_name,
9147                        ty: sch.ty,
9148                        nullable: sch.nullable,
9149                    });
9150                } else if let Some(shape) = describe::describe_expr(expr, schema_cols) {
9151                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
9152                    out.push(ProjectedItem {
9153                        expr: expr.clone(),
9154                        output_name,
9155                        ty: shape.ty,
9156                        nullable: shape.nullable,
9157                    });
9158                } else {
9159                    let output_name = alias.clone().unwrap_or_else(|| expr.to_string());
9160                    out.push(ProjectedItem {
9161                        expr: expr.clone(),
9162                        output_name,
9163                        ty: DataType::Text,
9164                        nullable: true,
9165                    });
9166                }
9167            }
9168        }
9169    }
9170    Ok(out)
9171}
9172
9173/// Promote an integer to a NUMERIC value at the requested scale.
9174/// Rejects values that, after scaling, would overflow the column's
9175/// precision budget.
9176fn numeric_from_integer(
9177    n: i128,
9178    precision: u8,
9179    scale: u8,
9180    col_name: &str,
9181) -> Result<Value, EngineError> {
9182    let factor = pow10_i128(scale);
9183    let scaled = n.checked_mul(factor).ok_or_else(|| {
9184        EngineError::Unsupported(alloc::format!(
9185            "integer overflow scaling value for column `{col_name}` to scale {scale}"
9186        ))
9187    })?;
9188    check_precision(scaled, precision, col_name)?;
9189    Ok(Value::Numeric { scaled, scale })
9190}
9191
9192/// Float → NUMERIC. Uses round-half-away-from-zero on `x * 10^scale`,
9193/// then verifies the result fits the column's precision.
9194#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
9195fn numeric_from_float(
9196    x: f64,
9197    precision: u8,
9198    scale: u8,
9199    col_name: &str,
9200) -> Result<Value, EngineError> {
9201    if !x.is_finite() {
9202        return Err(EngineError::Unsupported(alloc::format!(
9203            "cannot store non-finite float in NUMERIC column `{col_name}`"
9204        )));
9205    }
9206    let mut factor = 1.0_f64;
9207    for _ in 0..scale {
9208        factor *= 10.0;
9209    }
9210    // Round half-away-from-zero by biasing then casting (`as i128`
9211    // truncates toward zero, so the bias + truncation gives the
9212    // desired rounding). `f64::floor` / `ceil` live in std; we don't
9213    // need them — the cast handles the truncation step.
9214    let shifted = x * factor;
9215    let biased = if shifted >= 0.0 {
9216        shifted + 0.5
9217    } else {
9218        shifted - 0.5
9219    };
9220    // Range-check before casting back to i128 — the cast itself is
9221    // saturating in Rust, which would silently truncate huge inputs.
9222    if !(-1e38..=1e38).contains(&biased) {
9223        return Err(EngineError::Unsupported(alloc::format!(
9224            "value {x} overflows NUMERIC range for column `{col_name}`"
9225        )));
9226    }
9227    let scaled = biased as i128;
9228    check_precision(scaled, precision, col_name)?;
9229    Ok(Value::Numeric { scaled, scale })
9230}
9231
9232/// v7.17.0 Phase 3.P0-67 — parse PG-canonical decimal text into
9233/// `(mantissa: i128, source_scale: u8)`. Accepts optional sign,
9234/// optional integer part, optional fractional part. Rejects
9235/// scientific notation, embedded spaces, locale-specific
9236/// thousand separators. Returns None on bad input — coerce_value
9237/// turns that into a TypeMismatch error.
9238fn parse_numeric_text(s: &str) -> Option<(i128, u8)> {
9239    let s = s.trim();
9240    if s.is_empty() {
9241        return None;
9242    }
9243    let (negative, rest) = match s.as_bytes()[0] {
9244        b'-' => (true, &s[1..]),
9245        b'+' => (false, &s[1..]),
9246        _ => (false, s),
9247    };
9248    if rest.is_empty() {
9249        return None;
9250    }
9251    // Reject scientific notation — bigdecimal collapses it before
9252    // hitting the wire, and we want a clear error if a stray `e`
9253    // sneaks in.
9254    if rest.bytes().any(|b| b == b'e' || b == b'E') {
9255        return None;
9256    }
9257    let (int_part, frac_part) = match rest.find('.') {
9258        Some(idx) => (&rest[..idx], &rest[idx + 1..]),
9259        None => (rest, ""),
9260    };
9261    if int_part.is_empty() && frac_part.is_empty() {
9262        return None;
9263    }
9264    if int_part.bytes().any(|b| !b.is_ascii_digit()) {
9265        return None;
9266    }
9267    if frac_part.bytes().any(|b| !b.is_ascii_digit()) {
9268        return None;
9269    }
9270    let scale_u32 = u32::try_from(frac_part.len()).ok()?;
9271    if scale_u32 > u32::from(u8::MAX) {
9272        return None;
9273    }
9274    let scale = scale_u32 as u8;
9275    let mut digits = alloc::string::String::with_capacity(int_part.len() + frac_part.len() + 1);
9276    if negative {
9277        digits.push('-');
9278    }
9279    digits.push_str(int_part);
9280    digits.push_str(frac_part);
9281    // Strip a leading "+0..0" so parse doesn't choke on "00" etc.
9282    let digits = if digits == "-" {
9283        return None;
9284    } else if digits.is_empty() {
9285        "0"
9286    } else {
9287        digits.as_str()
9288    };
9289    let mantissa: i128 = digits.parse().ok()?;
9290    Some((mantissa, scale))
9291}
9292
9293/// Move a Numeric value from `src_scale` to `dst_scale`. Going up
9294/// multiplies by 10; going down rounds half-away-from-zero.
9295fn numeric_rescale(
9296    scaled: i128,
9297    src_scale: u8,
9298    precision: u8,
9299    dst_scale: u8,
9300    col_name: &str,
9301) -> Result<Value, EngineError> {
9302    let new_scaled = if dst_scale >= src_scale {
9303        let bump = pow10_i128(dst_scale - src_scale);
9304        scaled.checked_mul(bump).ok_or_else(|| {
9305            EngineError::Unsupported(alloc::format!(
9306                "overflow rescaling NUMERIC for column `{col_name}`"
9307            ))
9308        })?
9309    } else {
9310        let drop = pow10_i128(src_scale - dst_scale);
9311        let half = drop / 2;
9312        if scaled >= 0 {
9313            (scaled + half) / drop
9314        } else {
9315            (scaled - half) / drop
9316        }
9317    };
9318    check_precision(new_scaled, precision, col_name)?;
9319    Ok(Value::Numeric {
9320        scaled: new_scaled,
9321        scale: dst_scale,
9322    })
9323}
9324
9325/// Drop the fractional part of a scaled integer, returning the integer
9326/// portion (toward zero). Used for NUMERIC → INT casts.
9327const fn numeric_truncate_to_integer(scaled: i128, scale: u8) -> i128 {
9328    if scale == 0 {
9329        return scaled;
9330    }
9331    let factor = pow10_i128_const(scale);
9332    scaled / factor
9333}
9334
9335/// Verify a scaled NUMERIC value fits the column's declared precision.
9336/// `precision == 0` is the "unconstrained" form (bare `NUMERIC`); we
9337/// skip the check there.
9338fn check_precision(scaled: i128, precision: u8, col_name: &str) -> Result<(), EngineError> {
9339    if precision == 0 {
9340        return Ok(());
9341    }
9342    let limit = pow10_i128(precision);
9343    if scaled.unsigned_abs() >= limit.unsigned_abs() {
9344        return Err(EngineError::Unsupported(alloc::format!(
9345            "NUMERIC value exceeds precision {precision} for column `{col_name}`"
9346        )));
9347    }
9348    Ok(())
9349}
9350
9351const fn pow10_i128_const(p: u8) -> i128 {
9352    let mut acc: i128 = 1;
9353    let mut i = 0;
9354    while i < p {
9355        acc *= 10;
9356        i += 1;
9357    }
9358    acc
9359}
9360
9361fn pow10_i128(p: u8) -> i128 {
9362    pow10_i128_const(p)
9363}
9364
9365/// Walk a parsed `Statement`, swapping any `NOW()` /
9366/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()` function calls for a
9367/// literal cast that wraps the engine's per-statement clock reading.
9368/// When `now_micros` is `None`, calls stay as-is and surface as
9369/// `unknown function` at eval time — keeps the error path explicit.
9370/// v4.10: pre-walk the WHERE / projection / etc. of a SELECT and
9371/// replace every subquery node with a materialised literal. SPG
9372/// only supports uncorrelated subqueries — the inner SELECT does
9373/// not see outer-row columns, so the result is the same for every
9374/// outer row and can be evaluated once.
9375///
9376/// Returns the rewritten statement; the caller passes this to the
9377/// regular row-loop executor which no longer sees Subquery nodes
9378/// in its tree.
9379impl Engine {
9380    /// v4.12 window executor. Implements `ROW_NUMBER` / `RANK` /
9381    /// `DENSE_RANK` and the partition-aware aggregates `SUM` /
9382    /// `AVG` / `COUNT` / `MIN` / `MAX`. The plan is:
9383    /// 1. Apply the WHERE filter.
9384    /// 2. For each unique `WindowFunction` node in the projection,
9385    ///    partition + sort, compute the per-row value.
9386    /// 3. Append the window values as synthetic columns (`__win_N`)
9387    ///    to the row schema.
9388    /// 4. Rewrite the projection to read those columns.
9389    /// 5. Hand off to the regular project / ORDER BY / LIMIT pipe.
9390    #[allow(
9391        clippy::too_many_lines,
9392        clippy::type_complexity,
9393        clippy::needless_range_loop
9394    )] // window-eval is one cohesive pipe; splitting fragments
9395    fn exec_select_with_window(
9396        &self,
9397        stmt: &SelectStatement,
9398        cancel: CancelToken<'_>,
9399    ) -> Result<QueryResult, EngineError> {
9400        let from = stmt.from.as_ref().ok_or_else(|| {
9401            EngineError::Unsupported("window functions require a FROM clause".into())
9402        })?;
9403        // v7.17.0 Phase 3.P0-43 — JOIN + window functions. Phase
9404        // 3.6 rejected this combination outright ("queued for
9405        // v5.x"); P0-43 materialises the join + WHERE through the
9406        // existing nested-loop helper and runs the window pipeline
9407        // on the joined row set with the combined `alias.col`
9408        // schema. The window expressions resolve through the
9409        // qualifier-aware column resolver same as the aggregate /
9410        // projection paths on JOIN.
9411        let (schema_cols_owned, alias_opt): (Vec<ColumnSchema>, Option<&str>);
9412        let filtered: Vec<Row>;
9413        if from.joins.is_empty() {
9414            let primary = &from.primary;
9415            let table = self.active_catalog().get(&primary.name).ok_or_else(|| {
9416                StorageError::TableNotFound {
9417                    name: primary.name.clone(),
9418                }
9419            })?;
9420            let alias = primary.alias.as_deref().unwrap_or(primary.name.as_str());
9421            schema_cols_owned = table.schema().columns.clone();
9422            alias_opt = Some(alias);
9423            // Materialise WHERE-filtered rows owned so the JOIN
9424            // and single-table paths share a single downstream
9425            // shape. The clone is cheap relative to the window
9426            // computation that follows.
9427            let ctx = self.ev_ctx(&schema_cols_owned, alias_opt);
9428            let mut owned: Vec<Row> = Vec::new();
9429            for (i, row) in table.rows().iter().enumerate() {
9430                if i.is_multiple_of(256) {
9431                    cancel.check()?;
9432                }
9433                if let Some(w) = &stmt.where_ {
9434                    let cond = eval::eval_expr(w, row, &ctx)?;
9435                    if !matches!(cond, Value::Bool(true)) {
9436                        continue;
9437                    }
9438                }
9439                owned.push(row.clone());
9440            }
9441            filtered = owned;
9442        } else {
9443            let (combined_schema, rows) =
9444                self.build_joined_filtered_rows(from, stmt.where_.as_ref())?;
9445            schema_cols_owned = combined_schema;
9446            alias_opt = None;
9447            filtered = rows;
9448        }
9449        let schema_cols = &schema_cols_owned;
9450        let ctx = self.ev_ctx(schema_cols, alias_opt);
9451        let alias = alias_opt.unwrap_or("");
9452        let n_rows = filtered.len();
9453        // Borrow refs into the owned row vec once so the downstream
9454        // `compute_window_partition` call (which takes `&[&Row]`) and
9455        // the per-row eval loops share a single backing buffer.
9456        let filtered_refs: Vec<&Row> = filtered.iter().collect();
9457
9458        // 2) Collect unique window function nodes from projection.
9459        let mut window_nodes: Vec<Expr> = Vec::new();
9460        for item in &stmt.items {
9461            if let SelectItem::Expr { expr, .. } = item {
9462                collect_window_nodes(expr, &mut window_nodes);
9463            }
9464        }
9465
9466        // 3) For each window, compute per-row value.
9467        // Index: same order as window_nodes; for row i, win_vals[w][i].
9468        let mut win_vals: Vec<Vec<Value>> = Vec::with_capacity(window_nodes.len());
9469        for wnode in &window_nodes {
9470            let Expr::WindowFunction {
9471                name,
9472                args,
9473                partition_by,
9474                order_by,
9475                frame,
9476                null_treatment,
9477            } = wnode
9478            else {
9479                unreachable!("collect_window_nodes pushes only WindowFunction");
9480            };
9481            // Compute (partition_key, order_key, original_index) for each row.
9482            let mut indexed: Vec<(Vec<Value>, Vec<(Value, bool)>, usize)> =
9483                Vec::with_capacity(n_rows);
9484            for (i, row) in filtered.iter().enumerate() {
9485                let pkey: Vec<Value> = partition_by
9486                    .iter()
9487                    .map(|p| eval::eval_expr(p, row, &ctx))
9488                    .collect::<Result<_, _>>()?;
9489                let okey: Vec<(Value, bool)> = order_by
9490                    .iter()
9491                    .map(|(e, desc)| eval::eval_expr(e, row, &ctx).map(|v| (v, *desc)))
9492                    .collect::<Result<_, _>>()?;
9493                indexed.push((pkey, okey, i));
9494            }
9495            // Sort by (partition_key, order_key). Partition key uses
9496            // a stable encoded form; order key respects ASC/DESC.
9497            indexed.sort_by(|a, b| {
9498                let p_cmp = partition_key_cmp(&a.0, &b.0);
9499                if p_cmp != core::cmp::Ordering::Equal {
9500                    return p_cmp;
9501                }
9502                order_key_cmp(&a.1, &b.1)
9503            });
9504            // Per-partition compute.
9505            let mut out_vals: Vec<Value> = alloc::vec![Value::Null; n_rows];
9506            let mut p_start = 0;
9507            while p_start < indexed.len() {
9508                let mut p_end = p_start + 1;
9509                while p_end < indexed.len()
9510                    && partition_key_cmp(&indexed[p_start].0, &indexed[p_end].0)
9511                        == core::cmp::Ordering::Equal
9512                {
9513                    p_end += 1;
9514                }
9515                // Compute the function within this partition slice.
9516                compute_window_partition(
9517                    name,
9518                    args,
9519                    !order_by.is_empty(),
9520                    frame.as_ref(),
9521                    *null_treatment,
9522                    &indexed[p_start..p_end],
9523                    &filtered_refs,
9524                    &ctx,
9525                    &mut out_vals,
9526                )?;
9527                p_start = p_end;
9528            }
9529            win_vals.push(out_vals);
9530        }
9531
9532        // 4) Build extended schema: original columns + synthetic.
9533        let mut ext_cols = schema_cols.clone();
9534        for i in 0..window_nodes.len() {
9535            ext_cols.push(ColumnSchema::new(
9536                alloc::format!("__win_{i}"),
9537                DataType::Text, // type doesn't matter for projection eval
9538                true,
9539            ));
9540        }
9541        // 5) Build extended rows: each row gets its window values appended.
9542        let mut ext_rows: Vec<Row> = Vec::with_capacity(n_rows);
9543        for i in 0..n_rows {
9544            let mut values = filtered[i].values.clone();
9545            for w in 0..window_nodes.len() {
9546                values.push(win_vals[w][i].clone());
9547            }
9548            ext_rows.push(Row::new(values));
9549        }
9550        // 6) Rewrite the projection: WindowFunction nodes → Column(__win_N).
9551        let mut rewritten_items: Vec<SelectItem> = Vec::with_capacity(stmt.items.len());
9552        for item in &stmt.items {
9553            let new_item = match item {
9554                SelectItem::Wildcard => SelectItem::Wildcard,
9555                SelectItem::Expr { expr, alias } => {
9556                    let mut e = expr.clone();
9557                    rewrite_window_to_columns(&mut e, &window_nodes);
9558                    SelectItem::Expr {
9559                        expr: e,
9560                        alias: alias.clone(),
9561                    }
9562                }
9563            };
9564            rewritten_items.push(new_item);
9565        }
9566
9567        // 7) Project into final rows. JOIN case uses None so the
9568        // qualifier check in `resolve_column` falls through to the
9569        // composite `alias.col` schema lookup; single-table case
9570        // keeps the bare alias so `bare_col` resolution still
9571        // works for the projection's per-row column references.
9572        let ext_ctx = EvalContext::new(&ext_cols, alias_opt);
9573        let projection = build_projection(&rewritten_items, &ext_cols, alias)?;
9574        let mut tagged: Vec<(Vec<f64>, Row)> = Vec::with_capacity(n_rows);
9575        for (i, row) in ext_rows.iter().enumerate() {
9576            if i.is_multiple_of(256) {
9577                cancel.check()?;
9578            }
9579            let mut values = Vec::with_capacity(projection.len());
9580            for p in &projection {
9581                values.push(eval::eval_expr(&p.expr, row, &ext_ctx)?);
9582            }
9583            let order_keys = if stmt.order_by.is_empty() {
9584                Vec::new()
9585            } else {
9586                let mut keys = Vec::with_capacity(stmt.order_by.len());
9587                for o in &stmt.order_by {
9588                    let mut e = o.expr.clone();
9589                    rewrite_window_to_columns(&mut e, &window_nodes);
9590                    let key = eval::eval_expr(&e, row, &ext_ctx)?;
9591                    keys.push(value_to_order_key(&key)?);
9592                }
9593                keys
9594            };
9595            tagged.push((order_keys, Row::new(values)));
9596        }
9597        // ORDER BY + LIMIT/OFFSET on the projected rows.
9598        if !stmt.order_by.is_empty() {
9599            let descs: Vec<bool> = stmt.order_by.iter().map(|o| o.desc).collect();
9600            sort_by_keys(&mut tagged, &descs);
9601        }
9602        let mut out_rows: Vec<Row> = tagged.into_iter().map(|(_, r)| r).collect();
9603        apply_offset_and_limit(&mut out_rows, stmt.offset_literal(), stmt.limit_literal());
9604        let final_cols: Vec<ColumnSchema> = projection
9605            .into_iter()
9606            .map(|p| ColumnSchema::new(p.output_name, p.ty, p.nullable))
9607            .collect();
9608        Ok(QueryResult::Rows {
9609            columns: final_cols,
9610            rows: out_rows,
9611        })
9612    }
9613
9614    /// v4.11: materialise each CTE into a temp table inside a
9615    /// cloned catalog, then run the body SELECT against a fresh
9616    /// engine instance that owns the enriched catalog. The clone
9617    /// is moderately expensive — only paid by CTE-bearing queries.
9618    /// Subqueries inside CTE bodies / the main body resolve as
9619    /// usual; `clock_fn` is propagated so `NOW()` lines up.
9620    /// v7.16.2 — mailrs round-10 A.3. Materialise the
9621    /// `information_schema.*` / `pg_catalog.*` virtual views
9622    /// the SELECT references, then re-execute the SELECT
9623    /// against an enriched catalog where those views are real
9624    /// tables. Same pattern as `exec_with_ctes`. The temp
9625    /// engine carries `meta_views_materialised = true` so its
9626    /// own meta-dispatch short-circuits — without that we'd
9627    /// infinite-recurse since the temp catalog's view name
9628    /// still starts with `__spg_info_` and re-triggers the
9629    /// check.
9630    fn exec_select_with_meta_views(
9631        &self,
9632        stmt: &SelectStatement,
9633        cancel: CancelToken<'_>,
9634    ) -> Result<QueryResult, EngineError> {
9635        let mut needed: alloc::collections::BTreeSet<String> = alloc::collections::BTreeSet::new();
9636        collect_meta_view_names(stmt, &mut needed);
9637        let mut catalog = self.active_catalog().clone();
9638        for view in &needed {
9639            if catalog.get(view).is_some() {
9640                continue;
9641            }
9642            match view.as_str() {
9643                "__spg_info_columns" => {
9644                    let (schema, rows) = synth_information_schema_columns(self.active_catalog());
9645                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9646                }
9647                "__spg_info_tables" => {
9648                    let (schema, rows) = synth_information_schema_tables(self.active_catalog());
9649                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9650                }
9651                "__spg_pg_class" => {
9652                    let (schema, rows) = synth_pg_class(self.active_catalog());
9653                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9654                }
9655                "__spg_pg_attribute" => {
9656                    let (schema, rows) = synth_pg_attribute(self.active_catalog());
9657                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9658                }
9659                // v7.17.0 Phase 3.P0-50 — pg_catalog.pg_type for
9660                // sqlx / SQLAlchemy / Diesel / pgAdmin lookups.
9661                "__spg_pg_type" => {
9662                    let (schema, rows) = synth_pg_type(self.active_catalog());
9663                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9664                }
9665                // v7.17.0 Phase 3.P0-51 — pg_catalog.pg_proc for
9666                // function-name introspection (ORM / pgAdmin).
9667                "__spg_pg_proc" => {
9668                    let (schema, rows) = synth_pg_proc(self.active_catalog());
9669                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9670                }
9671                // v7.17.0 Phase 3.P0-52 — pg_catalog.pg_namespace
9672                // (schema list for admin tools' tree views).
9673                "__spg_pg_namespace" => {
9674                    let (schema, rows) = synth_pg_namespace(self.active_catalog());
9675                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9676                }
9677                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_indexes view
9678                // for pgAdmin / DataGrip "indexes per table" listings.
9679                "__spg_pg_indexes" => {
9680                    let (schema, rows) = synth_pg_indexes(self.active_catalog());
9681                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9682                }
9683                // v7.17.0 Phase 3.P0-53 — pg_catalog.pg_index (raw)
9684                // for index introspection by ORM compilers.
9685                "__spg_pg_index" => {
9686                    let (schema, rows) = synth_pg_index_raw(self.active_catalog());
9687                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9688                }
9689                // v7.17.0 Phase 3.P0-54 — pg_catalog.pg_constraint
9690                // for FK / UNIQUE / PK / CHECK introspection.
9691                "__spg_pg_constraint" => {
9692                    let (schema, rows) = synth_pg_constraint(self.active_catalog());
9693                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9694                }
9695                // v7.17.0 Phase 3.P0-55 — pg_catalog.pg_database /
9696                // pg_roles / pg_user. SPG is single-database so
9697                // pg_database surfaces just `postgres`; pg_roles
9698                // / pg_user walk the engine's UserStore.
9699                "__spg_pg_database" => {
9700                    let (schema, rows) = synth_pg_database(self.active_catalog());
9701                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9702                }
9703                "__spg_pg_roles" | "__spg_pg_user" => {
9704                    let (schema, rows) = synth_pg_roles(self);
9705                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9706                }
9707                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_views. PG's
9708                // pg_views surfaces every CREATE VIEW result; SPG
9709                // ships one row per declared view from the catalog.
9710                "__spg_pg_views" => {
9711                    let (schema, rows) = synth_pg_views(self.active_catalog());
9712                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9713                }
9714                // v7.17.0 Phase 3.P0-56 — pg_catalog.pg_matviews.
9715                // SPG has no materialised view surface yet so the
9716                // table shares pg_views's schema but stays empty.
9717                "__spg_pg_matviews" => {
9718                    let (schema, _) = synth_pg_views(self.active_catalog());
9719                    materialise_meta_view(&mut catalog, view, schema, Vec::new())?;
9720                }
9721                // v7.17.0 Phase 3.P0-57 — pg_catalog.pg_settings.
9722                "__spg_pg_settings" => {
9723                    let (schema, rows) = synth_pg_settings(self);
9724                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9725                }
9726                // v7.17.0 Phase 3.P0-63 — information_schema.KEY_COLUMN_USAGE.
9727                "__spg_info_key_column_usage" => {
9728                    let (schema, rows) = synth_info_key_column_usage(self.active_catalog());
9729                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9730                }
9731                // v7.17.0 Phase 3.P0-64 — information_schema.REFERENTIAL_CONSTRAINTS.
9732                "__spg_info_referential_constraints" => {
9733                    let (schema, rows) = synth_info_referential_constraints(self.active_catalog());
9734                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9735                }
9736                // v7.17.0 Phase 3.P0-64 — information_schema.STATISTICS.
9737                "__spg_info_statistics" => {
9738                    let (schema, rows) = synth_info_statistics(self.active_catalog());
9739                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9740                }
9741                // v7.17.0 Phase 3.P0-64 — information_schema.ROUTINES.
9742                "__spg_info_routines" => {
9743                    let (schema, rows) = synth_info_routines();
9744                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9745                }
9746                // v7.17.0 Phase 3.P0-65 — mysql.user / mysql.db.
9747                "__spg_mysql_user" => {
9748                    let (schema, rows) = synth_mysql_user(self);
9749                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9750                }
9751                "__spg_mysql_db" => {
9752                    let (schema, rows) = synth_mysql_db();
9753                    materialise_meta_view(&mut catalog, view, schema, rows)?;
9754                }
9755                _ => {
9756                    return Err(EngineError::Unsupported(alloc::format!(
9757                        "meta view {view:?} is not yet materialisable; \
9758                         v7.16.2 covers information_schema.columns / .tables \
9759                         and pg_catalog.pg_class / pg_attribute; \
9760                         v7.17.0 P0-50..P0-57 add pg_type / pg_proc / pg_namespace / \
9761                         pg_indexes / pg_index / pg_constraint / pg_database / pg_roles / \
9762                         pg_user / pg_views / pg_matviews / pg_settings"
9763                    )));
9764                }
9765            }
9766        }
9767        let mut temp = Engine::restore(catalog);
9768        if let Some(c) = self.clock {
9769            temp = temp.with_clock(c);
9770        }
9771        if let Some(f) = self.salt_fn {
9772            temp = temp.with_salt_fn(f);
9773        }
9774        temp.meta_views_materialised = true;
9775        temp.exec_select_cancel(stmt, cancel)
9776    }
9777
9778    fn exec_with_ctes(
9779        &self,
9780        stmt: &SelectStatement,
9781        cancel: CancelToken<'_>,
9782    ) -> Result<QueryResult, EngineError> {
9783        cancel.check()?;
9784        let mut catalog = self.active_catalog().clone();
9785        for cte in &stmt.ctes {
9786            if catalog.get(&cte.name).is_some() {
9787                return Err(EngineError::Unsupported(alloc::format!(
9788                    "CTE name {:?} shadows an existing table; rename the CTE",
9789                    cte.name
9790                )));
9791            }
9792            let (columns, rows) = if cte.recursive {
9793                self.materialise_recursive_cte(cte, &catalog, cancel)?
9794            } else {
9795                let body_result = self.exec_select_cancel(&cte.body, cancel)?;
9796                let QueryResult::Rows { columns, rows } = body_result else {
9797                    return Err(EngineError::Unsupported(alloc::format!(
9798                        "CTE {:?} body did not return rows",
9799                        cte.name
9800                    )));
9801                };
9802                (columns, rows)
9803            };
9804            // v4.22: the projection builder labels any non-column
9805            // expression as Text — including literal SELECT 1.
9806            // Promote each column's type to whatever the rows
9807            // actually carry so the CTE storage table accepts them.
9808            let inferred = infer_column_types(&columns, &rows);
9809            let mut columns = inferred;
9810            // v4.22: apply optional `WITH name(a, b, c)` overrides.
9811            if !cte.column_overrides.is_empty() {
9812                if cte.column_overrides.len() != columns.len() {
9813                    return Err(EngineError::Unsupported(alloc::format!(
9814                        "CTE {:?} column list has {} names but body returns {} columns",
9815                        cte.name,
9816                        cte.column_overrides.len(),
9817                        columns.len()
9818                    )));
9819                }
9820                for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
9821                    col.name.clone_from(name);
9822                }
9823            }
9824            let schema = TableSchema::new(cte.name.clone(), columns);
9825            catalog.create_table(schema).map_err(EngineError::Storage)?;
9826            let table = catalog
9827                .get_mut(&cte.name)
9828                .expect("just-created CTE table must exist");
9829            for row in rows {
9830                table.insert(row).map_err(EngineError::Storage)?;
9831            }
9832        }
9833        // Strip CTEs from the body before running on the temp engine
9834        // so we don't recurse forever.
9835        let mut body = stmt.clone();
9836        body.ctes = Vec::new();
9837        let mut temp = Engine::restore(catalog);
9838        if let Some(c) = self.clock {
9839            temp = temp.with_clock(c);
9840        }
9841        if let Some(f) = self.salt_fn {
9842            temp = temp.with_salt_fn(f);
9843        }
9844        temp.exec_select_cancel(&body, cancel)
9845    }
9846
9847    /// v4.22: materialise a WITH RECURSIVE CTE. The body must be a
9848    /// UNION (or UNION ALL) of an anchor that does not reference
9849    /// the CTE name, and one or more recursive terms that do. The
9850    /// anchor runs first; each subsequent iteration runs the
9851    /// recursive term against a temp catalog where the CTE name is
9852    /// bound to the *previous* iteration's output. Iteration stops
9853    /// when the recursive term yields no rows; UNION (DISTINCT)
9854    /// deduplicates against the accumulated result, UNION ALL does
9855    /// not. A hard cap on total rows prevents runaway queries.
9856    #[allow(clippy::too_many_lines)]
9857    fn materialise_recursive_cte(
9858        &self,
9859        cte: &spg_sql::ast::Cte,
9860        base_catalog: &Catalog,
9861        cancel: CancelToken<'_>,
9862    ) -> Result<(Vec<ColumnSchema>, Vec<Row>), EngineError> {
9863        const MAX_TOTAL_ROWS: usize = 1_000_000;
9864        const MAX_ITERATIONS: usize = 100_000;
9865        cancel.check()?;
9866        if cte.body.unions.is_empty() {
9867            return Err(EngineError::Unsupported(alloc::format!(
9868                "WITH RECURSIVE {:?} body must be a UNION of an anchor and a recursive term",
9869                cte.name
9870            )));
9871        }
9872        // Anchor: the body's leading SELECT, with unions stripped.
9873        let mut anchor = cte.body.clone();
9874        let union_terms = core::mem::take(&mut anchor.unions);
9875        anchor.ctes = Vec::new();
9876        // Anchor must not reference the CTE name.
9877        if select_refers_to(&anchor, &cte.name) {
9878            return Err(EngineError::Unsupported(alloc::format!(
9879                "WITH RECURSIVE {:?}: the anchor must not reference the CTE itself",
9880                cte.name
9881            )));
9882        }
9883        let anchor_result = self.exec_select_cancel(&anchor, cancel)?;
9884        let QueryResult::Rows {
9885            columns: anchor_cols,
9886            rows: anchor_rows,
9887        } = anchor_result
9888        else {
9889            return Err(EngineError::Unsupported(alloc::format!(
9890                "WITH RECURSIVE {:?}: anchor did not return rows",
9891                cte.name
9892            )));
9893        };
9894        // The projection builder labels non-column expressions Text;
9895        // refine column types from the anchor's actual values so the
9896        // intermediate iter-catalog tables accept them.
9897        let mut columns = infer_column_types(&anchor_cols, &anchor_rows);
9898        if !cte.column_overrides.is_empty() {
9899            if cte.column_overrides.len() != columns.len() {
9900                return Err(EngineError::Unsupported(alloc::format!(
9901                    "CTE {:?} column list has {} names but anchor returns {} columns",
9902                    cte.name,
9903                    cte.column_overrides.len(),
9904                    columns.len()
9905                )));
9906            }
9907            for (col, name) in columns.iter_mut().zip(cte.column_overrides.iter()) {
9908                col.name.clone_from(name);
9909            }
9910        }
9911        let mut all_rows: Vec<Row> = anchor_rows.clone();
9912        let mut working_set: Vec<Row> = anchor_rows;
9913        let mut seen: alloc::collections::BTreeSet<Vec<u8>> = alloc::collections::BTreeSet::new();
9914        // Track at least one "all UNION ALL" flag — if every union
9915        // kind is ALL we skip the dedup step (faster + matches PG).
9916        let all_union_all = union_terms.iter().all(|(k, _)| matches!(k, UnionKind::All));
9917        if !all_union_all {
9918            for r in &all_rows {
9919                seen.insert(encode_row_key(r));
9920            }
9921        }
9922        for iter in 0..MAX_ITERATIONS {
9923            cancel.check()?;
9924            if working_set.is_empty() {
9925                break;
9926            }
9927            // Build a fresh catalog: base + CTE bound to working_set.
9928            let mut iter_catalog = base_catalog.clone();
9929            let schema = TableSchema::new(cte.name.clone(), columns.clone());
9930            iter_catalog
9931                .create_table(schema)
9932                .map_err(EngineError::Storage)?;
9933            {
9934                let table = iter_catalog.get_mut(&cte.name).expect("just-created");
9935                for row in &working_set {
9936                    table.insert(row.clone()).map_err(EngineError::Storage)?;
9937                }
9938            }
9939            let mut iter_engine = Engine::restore(iter_catalog);
9940            if let Some(c) = self.clock {
9941                iter_engine = iter_engine.with_clock(c);
9942            }
9943            if let Some(f) = self.salt_fn {
9944                iter_engine = iter_engine.with_salt_fn(f);
9945            }
9946            // Run each recursive term in sequence and collect new rows.
9947            let mut next_set: Vec<Row> = Vec::new();
9948            for (_, term) in &union_terms {
9949                let mut term = term.clone();
9950                term.ctes = Vec::new();
9951                let r = iter_engine.exec_select_cancel(&term, cancel)?;
9952                let QueryResult::Rows {
9953                    columns: rc,
9954                    rows: rs,
9955                } = r
9956                else {
9957                    return Err(EngineError::Unsupported(alloc::format!(
9958                        "WITH RECURSIVE {:?}: recursive term did not return rows",
9959                        cte.name
9960                    )));
9961                };
9962                if rc.len() != columns.len() {
9963                    return Err(EngineError::Unsupported(alloc::format!(
9964                        "WITH RECURSIVE {:?}: column count of recursive term ({}) does not match anchor ({})",
9965                        cte.name,
9966                        rc.len(),
9967                        columns.len()
9968                    )));
9969                }
9970                for row in rs {
9971                    if !all_union_all {
9972                        let key = encode_row_key(&row);
9973                        if !seen.insert(key) {
9974                            continue;
9975                        }
9976                    }
9977                    next_set.push(row);
9978                }
9979            }
9980            if next_set.is_empty() {
9981                break;
9982            }
9983            all_rows.extend(next_set.iter().cloned());
9984            working_set = next_set;
9985            if all_rows.len() > MAX_TOTAL_ROWS {
9986                return Err(EngineError::Unsupported(alloc::format!(
9987                    "WITH RECURSIVE {:?}: produced more than {MAX_TOTAL_ROWS} rows — likely runaway recursion",
9988                    cte.name
9989                )));
9990            }
9991            if iter + 1 == MAX_ITERATIONS {
9992                return Err(EngineError::Unsupported(alloc::format!(
9993                    "WITH RECURSIVE {:?}: exceeded {MAX_ITERATIONS} iterations",
9994                    cte.name
9995                )));
9996            }
9997        }
9998        Ok((columns, all_rows))
9999    }
10000
10001    fn resolve_select_subqueries(
10002        &self,
10003        stmt: &mut SelectStatement,
10004        cancel: CancelToken<'_>,
10005    ) -> Result<(), EngineError> {
10006        for item in &mut stmt.items {
10007            if let SelectItem::Expr { expr, .. } = item {
10008                self.resolve_expr_subqueries(expr, cancel)?;
10009            }
10010        }
10011        if let Some(w) = &mut stmt.where_ {
10012            self.resolve_expr_subqueries(w, cancel)?;
10013        }
10014        if let Some(gs) = &mut stmt.group_by {
10015            for g in gs {
10016                self.resolve_expr_subqueries(g, cancel)?;
10017            }
10018        }
10019        if let Some(h) = &mut stmt.having {
10020            self.resolve_expr_subqueries(h, cancel)?;
10021        }
10022        for o in &mut stmt.order_by {
10023            self.resolve_expr_subqueries(&mut o.expr, cancel)?;
10024        }
10025        for (_, peer) in &mut stmt.unions {
10026            self.resolve_select_subqueries(peer, cancel)?;
10027        }
10028        Ok(())
10029    }
10030
10031    #[allow(clippy::only_used_in_recursion)] // engine handle reads aren't really pure
10032    fn resolve_expr_subqueries(
10033        &self,
10034        e: &mut Expr,
10035        cancel: CancelToken<'_>,
10036    ) -> Result<(), EngineError> {
10037        // Replace-on-this-node cases first.
10038        if let Some(replacement) = self.subquery_replacement(e, cancel)? {
10039            *e = replacement;
10040            return Ok(());
10041        }
10042        match e {
10043            Expr::Binary { lhs, rhs, .. } => {
10044                self.resolve_expr_subqueries(lhs, cancel)?;
10045                self.resolve_expr_subqueries(rhs, cancel)?;
10046            }
10047            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10048                self.resolve_expr_subqueries(expr, cancel)?;
10049            }
10050            Expr::FunctionCall { args, .. } => {
10051                for a in args {
10052                    self.resolve_expr_subqueries(a, cancel)?;
10053                }
10054            }
10055            Expr::Like { expr, pattern, .. } => {
10056                self.resolve_expr_subqueries(expr, cancel)?;
10057                self.resolve_expr_subqueries(pattern, cancel)?;
10058            }
10059            Expr::Extract { source, .. } => self.resolve_expr_subqueries(source, cancel)?,
10060            // v4.12 window functions — recurse into args + ORDER BY
10061            // + PARTITION BY in case they carry inner subqueries.
10062            Expr::WindowFunction {
10063                args,
10064                partition_by,
10065                order_by,
10066                ..
10067            } => {
10068                for a in args {
10069                    self.resolve_expr_subqueries(a, cancel)?;
10070                }
10071                for p in partition_by {
10072                    self.resolve_expr_subqueries(p, cancel)?;
10073                }
10074                for (e, _) in order_by {
10075                    self.resolve_expr_subqueries(e, cancel)?;
10076                }
10077            }
10078            // Subquery nodes are handled in subquery_replacement
10079            // (which returned None — defensive no-op); Literal /
10080            // Column are leaves.
10081            Expr::ScalarSubquery(_)
10082            | Expr::Exists { .. }
10083            | Expr::InSubquery { .. }
10084            | Expr::Literal(_)
10085            | Expr::Placeholder(_)
10086            | Expr::Column(_) => {}
10087            // v7.10.10 — recurse children.
10088            Expr::Array(items) => {
10089                for elem in items {
10090                    self.resolve_expr_subqueries(elem, cancel)?;
10091                }
10092            }
10093            Expr::ArraySubscript { target, index } => {
10094                self.resolve_expr_subqueries(target, cancel)?;
10095                self.resolve_expr_subqueries(index, cancel)?;
10096            }
10097            Expr::AnyAll { expr, array, .. } => {
10098                self.resolve_expr_subqueries(expr, cancel)?;
10099                self.resolve_expr_subqueries(array, cancel)?;
10100            }
10101            Expr::Case {
10102                operand,
10103                branches,
10104                else_branch,
10105            } => {
10106                if let Some(o) = operand {
10107                    self.resolve_expr_subqueries(o, cancel)?;
10108                }
10109                for (w, t) in branches {
10110                    self.resolve_expr_subqueries(w, cancel)?;
10111                    self.resolve_expr_subqueries(t, cancel)?;
10112                }
10113                if let Some(e) = else_branch {
10114                    self.resolve_expr_subqueries(e, cancel)?;
10115                }
10116            }
10117        }
10118        Ok(())
10119    }
10120
10121    /// v4.23: per-row eval that handles correlated subqueries.
10122    /// Equivalent to `eval::eval_expr` when the expression has no
10123    /// subqueries; otherwise clones the expression, substitutes
10124    /// outer-row columns into each surviving subquery node, runs
10125    /// the inner SELECT, and replaces the node with the literal
10126    /// result. Only the WHERE-filter call sites use this path so
10127    /// the uncorrelated fast path is preserved everywhere else.
10128    fn eval_expr_with_correlated(
10129        &self,
10130        expr: &Expr,
10131        row: &Row,
10132        ctx: &EvalContext<'_>,
10133        cancel: CancelToken<'_>,
10134        memo: Option<&mut memoize::MemoizeCache>,
10135    ) -> Result<Value, EngineError> {
10136        if !expr_has_subquery(expr) {
10137            return eval::eval_expr(expr, row, ctx).map_err(EngineError::Eval);
10138        }
10139        let mut e = expr.clone();
10140        self.resolve_correlated_in_expr(&mut e, row, ctx, cancel, memo)?;
10141        eval::eval_expr(&e, row, ctx).map_err(EngineError::Eval)
10142    }
10143
10144    fn resolve_correlated_in_expr(
10145        &self,
10146        e: &mut Expr,
10147        row: &Row,
10148        ctx: &EvalContext<'_>,
10149        cancel: CancelToken<'_>,
10150        mut memo: Option<&mut memoize::MemoizeCache>,
10151    ) -> Result<(), EngineError> {
10152        match e {
10153            Expr::ScalarSubquery(inner) => {
10154                // v6.2.6 — Memoize: build the cache key from the
10155                // pre-substitution subquery repr + the outer row's
10156                // values. Two outer rows with identical correlated
10157                // values hit the same entry.
10158                let cache_key = memo.as_ref().map(|_| memoize::CacheKey {
10159                    subquery_repr: alloc::format!("{}", **inner),
10160                    outer_values: row.values.clone(),
10161                });
10162                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key.as_ref())
10163                    && let Some(cached) = cache.get(k)
10164                {
10165                    *e = value_to_literal_expr(cached)?;
10166                    return Ok(());
10167                }
10168                let mut s = (**inner).clone();
10169                substitute_outer_columns(&mut s, row, ctx);
10170                let r = self.exec_select_cancel(&s, cancel)?;
10171                let QueryResult::Rows { rows, .. } = r else {
10172                    return Err(EngineError::Unsupported(
10173                        "scalar subquery: inner did not return rows".into(),
10174                    ));
10175                };
10176                let value = match rows.as_slice() {
10177                    [] => Value::Null,
10178                    [r0] => r0.values.first().cloned().unwrap_or(Value::Null),
10179                    _ => {
10180                        return Err(EngineError::Unsupported(alloc::format!(
10181                            "scalar subquery returned {} rows; expected 0 or 1",
10182                            rows.len()
10183                        )));
10184                    }
10185                };
10186                if let (Some(cache), Some(k)) = (memo.as_deref_mut(), cache_key) {
10187                    cache.insert(k, value.clone());
10188                }
10189                *e = value_to_literal_expr(value)?;
10190            }
10191            Expr::Exists { subquery, negated } => {
10192                let mut s = (**subquery).clone();
10193                substitute_outer_columns(&mut s, row, ctx);
10194                let r = self.exec_select_cancel(&s, cancel)?;
10195                let exists = matches!(r, QueryResult::Rows { rows, .. } if !rows.is_empty());
10196                let bit = if *negated { !exists } else { exists };
10197                *e = Expr::Literal(Literal::Bool(bit));
10198            }
10199            Expr::InSubquery {
10200                expr: lhs,
10201                subquery,
10202                negated,
10203            } => {
10204                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
10205                let lhs_val = eval::eval_expr(lhs, row, ctx).map_err(EngineError::Eval)?;
10206                let mut s = (**subquery).clone();
10207                substitute_outer_columns(&mut s, row, ctx);
10208                let r = self.exec_select_cancel(&s, cancel)?;
10209                let QueryResult::Rows { columns, rows, .. } = r else {
10210                    return Err(EngineError::Unsupported(
10211                        "IN-subquery: inner did not return rows".into(),
10212                    ));
10213                };
10214                if columns.len() != 1 {
10215                    return Err(EngineError::Unsupported(alloc::format!(
10216                        "IN-subquery must project exactly one column; got {}",
10217                        columns.len()
10218                    )));
10219                }
10220                let mut found = false;
10221                let mut any_null = false;
10222                for r0 in rows {
10223                    let v = r0.values.into_iter().next().unwrap_or(Value::Null);
10224                    if v.is_null() {
10225                        any_null = true;
10226                        continue;
10227                    }
10228                    if value_cmp(&v, &lhs_val) == core::cmp::Ordering::Equal {
10229                        found = true;
10230                        break;
10231                    }
10232                }
10233                let bit = if found {
10234                    !*negated
10235                } else if any_null {
10236                    return Err(EngineError::Unsupported(
10237                        "IN-subquery with NULL in result and no match: NULL semantics not yet implemented".into(),
10238                    ));
10239                } else {
10240                    *negated
10241                };
10242                *e = Expr::Literal(Literal::Bool(bit));
10243            }
10244            Expr::Binary { lhs, rhs, .. } => {
10245                self.resolve_correlated_in_expr(lhs, row, ctx, cancel, memo.as_deref_mut())?;
10246                self.resolve_correlated_in_expr(rhs, row, ctx, cancel, memo.as_deref_mut())?;
10247            }
10248            Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10249                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10250            }
10251            Expr::Like { expr, pattern, .. } => {
10252                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10253                self.resolve_correlated_in_expr(pattern, row, ctx, cancel, memo.as_deref_mut())?;
10254            }
10255            Expr::FunctionCall { args, .. } => {
10256                for a in args {
10257                    self.resolve_correlated_in_expr(a, row, ctx, cancel, memo.as_deref_mut())?;
10258                }
10259            }
10260            Expr::Extract { source, .. } => {
10261                self.resolve_correlated_in_expr(source, row, ctx, cancel, memo.as_deref_mut())?;
10262            }
10263            Expr::WindowFunction { .. }
10264            | Expr::Literal(_)
10265            | Expr::Placeholder(_)
10266            | Expr::Column(_) => {}
10267            // v7.10.10 — recurse children.
10268            Expr::Array(items) => {
10269                for elem in items {
10270                    self.resolve_correlated_in_expr(elem, row, ctx, cancel, memo.as_deref_mut())?;
10271                }
10272            }
10273            Expr::ArraySubscript { target, index } => {
10274                self.resolve_correlated_in_expr(target, row, ctx, cancel, memo.as_deref_mut())?;
10275                self.resolve_correlated_in_expr(index, row, ctx, cancel, memo.as_deref_mut())?;
10276            }
10277            Expr::AnyAll { expr, array, .. } => {
10278                self.resolve_correlated_in_expr(expr, row, ctx, cancel, memo.as_deref_mut())?;
10279                self.resolve_correlated_in_expr(array, row, ctx, cancel, memo.as_deref_mut())?;
10280            }
10281            Expr::Case {
10282                operand,
10283                branches,
10284                else_branch,
10285            } => {
10286                if let Some(o) = operand {
10287                    self.resolve_correlated_in_expr(o, row, ctx, cancel, memo.as_deref_mut())?;
10288                }
10289                for (w, t) in branches {
10290                    self.resolve_correlated_in_expr(w, row, ctx, cancel, memo.as_deref_mut())?;
10291                    self.resolve_correlated_in_expr(t, row, ctx, cancel, memo.as_deref_mut())?;
10292                }
10293                if let Some(e) = else_branch {
10294                    self.resolve_correlated_in_expr(e, row, ctx, cancel, memo.as_deref_mut())?;
10295                }
10296            }
10297        }
10298        Ok(())
10299    }
10300
10301    fn subquery_replacement(
10302        &self,
10303        e: &Expr,
10304        cancel: CancelToken<'_>,
10305    ) -> Result<Option<Expr>, EngineError> {
10306        match e {
10307            Expr::ScalarSubquery(inner) => {
10308                let mut s = (**inner).clone();
10309                // Recurse into the inner SELECT first so nested
10310                // subqueries materialise bottom-up.
10311                self.resolve_select_subqueries(&mut s, cancel)?;
10312                let r = match self.exec_bare_select_cancel(&s, cancel) {
10313                    Ok(r) => r,
10314                    Err(e) if is_correlation_error(&e) => return Ok(None),
10315                    Err(e) => return Err(e),
10316                };
10317                let QueryResult::Rows { rows, .. } = r else {
10318                    return Err(EngineError::Unsupported(
10319                        "scalar subquery: inner statement did not return rows".into(),
10320                    ));
10321                };
10322                let value = match rows.as_slice() {
10323                    [] => Value::Null,
10324                    [row] => row.values.first().cloned().unwrap_or(Value::Null),
10325                    _ => {
10326                        return Err(EngineError::Unsupported(alloc::format!(
10327                            "scalar subquery returned {} rows; expected 0 or 1",
10328                            rows.len()
10329                        )));
10330                    }
10331                };
10332                Ok(Some(value_to_literal_expr(value)?))
10333            }
10334            Expr::Exists { subquery, negated } => {
10335                let mut s = (**subquery).clone();
10336                self.resolve_select_subqueries(&mut s, cancel)?;
10337                let r = match self.exec_bare_select_cancel(&s, cancel) {
10338                    Ok(r) => r,
10339                    Err(e) if is_correlation_error(&e) => return Ok(None),
10340                    Err(e) => return Err(e),
10341                };
10342                let exists = match r {
10343                    QueryResult::Rows { rows, .. } => !rows.is_empty(),
10344                    QueryResult::CommandOk { .. } => false,
10345                };
10346                let bit = if *negated { !exists } else { exists };
10347                Ok(Some(Expr::Literal(Literal::Bool(bit))))
10348            }
10349            Expr::InSubquery {
10350                expr,
10351                subquery,
10352                negated,
10353            } => {
10354                let mut s = (**subquery).clone();
10355                self.resolve_select_subqueries(&mut s, cancel)?;
10356                let r = match self.exec_bare_select_cancel(&s, cancel) {
10357                    Ok(r) => r,
10358                    Err(e) if is_correlation_error(&e) => return Ok(None),
10359                    Err(e) => return Err(e),
10360                };
10361                let QueryResult::Rows { columns, rows, .. } = r else {
10362                    return Err(EngineError::Unsupported(
10363                        "IN-subquery: inner statement did not return rows".into(),
10364                    ));
10365                };
10366                if columns.len() != 1 {
10367                    return Err(EngineError::Unsupported(alloc::format!(
10368                        "IN-subquery must project exactly one column; got {}",
10369                        columns.len()
10370                    )));
10371                }
10372                // Build the same OR-Eq chain the parse-time literal-list
10373                // path constructs, with each value lifted into a Literal.
10374                let mut acc: Option<Expr> = None;
10375                for row in rows {
10376                    let v = row.values.into_iter().next().unwrap_or(Value::Null);
10377                    let lit = value_to_literal_expr(v)?;
10378                    let cmp = Expr::Binary {
10379                        lhs: expr.clone(),
10380                        op: BinOp::Eq,
10381                        rhs: Box::new(lit),
10382                    };
10383                    acc = Some(match acc {
10384                        None => cmp,
10385                        Some(prev) => Expr::Binary {
10386                            lhs: Box::new(prev),
10387                            op: BinOp::Or,
10388                            rhs: Box::new(cmp),
10389                        },
10390                    });
10391                }
10392                let combined = acc.unwrap_or(Expr::Literal(Literal::Bool(false)));
10393                let final_expr = if *negated {
10394                    Expr::Unary {
10395                        op: UnOp::Not,
10396                        expr: Box::new(combined),
10397                    }
10398                } else {
10399                    combined
10400                };
10401                Ok(Some(final_expr))
10402            }
10403            _ => Ok(None),
10404        }
10405    }
10406}
10407
10408// ---- v4.12 window-function helpers ----
10409// The (partition-key, order-key, original-index) tuple shape used
10410// across these helpers is intrinsic to the planner. Factoring it
10411// into a typedef adds indirection without making the code clearer,
10412// so several lints are allowed inline on the affected functions
10413// rather than module-wide.
10414
10415/// v4.22: cheap structural scan for `FROM <name>` (qualified or
10416/// not) inside a SELECT — used to verify the anchor of a WITH
10417/// RECURSIVE CTE doesn't recurse into itself. Conservative: walks
10418/// FROM joins, subqueries, and unions.
10419fn select_refers_to(stmt: &SelectStatement, target: &str) -> bool {
10420    if let Some(from) = &stmt.from
10421        && from_refers_to(from, target)
10422    {
10423        return true;
10424    }
10425    for (_, peer) in &stmt.unions {
10426        if select_refers_to(peer, target) {
10427            return true;
10428        }
10429    }
10430    for item in &stmt.items {
10431        if let SelectItem::Expr { expr, .. } = item
10432            && expr_refers_to(expr, target)
10433        {
10434            return true;
10435        }
10436    }
10437    if let Some(w) = &stmt.where_
10438        && expr_refers_to(w, target)
10439    {
10440        return true;
10441    }
10442    false
10443}
10444
10445fn from_refers_to(from: &FromClause, target: &str) -> bool {
10446    if from.primary.name.eq_ignore_ascii_case(target) {
10447        return true;
10448    }
10449    from.joins
10450        .iter()
10451        .any(|j| j.table.name.eq_ignore_ascii_case(target))
10452}
10453
10454fn expr_refers_to(e: &Expr, target: &str) -> bool {
10455    match e {
10456        Expr::ScalarSubquery(s) => select_refers_to(s, target),
10457        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
10458            select_refers_to(subquery, target)
10459        }
10460        Expr::Binary { lhs, rhs, .. } => expr_refers_to(lhs, target) || expr_refers_to(rhs, target),
10461        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
10462            expr_refers_to(expr, target)
10463        }
10464        Expr::Like { expr, pattern, .. } => {
10465            expr_refers_to(expr, target) || expr_refers_to(pattern, target)
10466        }
10467        Expr::FunctionCall { args, .. } => args.iter().any(|a| expr_refers_to(a, target)),
10468        Expr::Extract { source, .. } => expr_refers_to(source, target),
10469        Expr::WindowFunction {
10470            args,
10471            partition_by,
10472            order_by,
10473            ..
10474        } => {
10475            args.iter().any(|a| expr_refers_to(a, target))
10476                || partition_by.iter().any(|p| expr_refers_to(p, target))
10477                || order_by.iter().any(|(o, _)| expr_refers_to(o, target))
10478        }
10479        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
10480        Expr::Array(items) => items.iter().any(|e| expr_refers_to(e, target)),
10481        Expr::ArraySubscript { target: t, index } => {
10482            expr_refers_to(t, target) || expr_refers_to(index, target)
10483        }
10484        Expr::AnyAll { expr, array, .. } => {
10485            expr_refers_to(expr, target) || expr_refers_to(array, target)
10486        }
10487        Expr::Case {
10488            operand,
10489            branches,
10490            else_branch,
10491        } => {
10492            operand
10493                .as_deref()
10494                .is_some_and(|o| expr_refers_to(o, target))
10495                || branches
10496                    .iter()
10497                    .any(|(w, t)| expr_refers_to(w, target) || expr_refers_to(t, target))
10498                || else_branch
10499                    .as_deref()
10500                    .is_some_and(|e| expr_refers_to(e, target))
10501        }
10502    }
10503}
10504
10505/// v4.22: pick more specific column types from observed rows when
10506/// the projection builder defaulted to Text (the v1.x behavior for
10507/// non-column expressions). Lets `WITH t(n) AS (SELECT 1 ...)`
10508/// land an Int column in the CTE storage table rather than failing
10509/// the insert with "expected TEXT, got INT".
10510/// v7.16.2 — map an SPG [`DataType`] to the PG-canonical
10511/// `information_schema.columns.data_type` text. Covers the
10512/// values mailrs's migrations probe (`'ARRAY'`, `'integer'`,
10513/// `'text'`, …). Unknown variants fall back to the SPG name
10514/// downcased — better than panicking on a future DataType.
10515fn pg_data_type_text(ty: DataType) -> alloc::string::String {
10516    let s = match ty {
10517        DataType::Int => "integer",
10518        DataType::BigInt => "bigint",
10519        DataType::SmallInt => "smallint",
10520        DataType::Float => "double precision",
10521        DataType::Bool => "boolean",
10522        DataType::Text => "text",
10523        DataType::Varchar(_) => "character varying",
10524        DataType::Date => "date",
10525        DataType::Timestamp => "timestamp without time zone",
10526        DataType::Timestamptz => "timestamp with time zone",
10527        DataType::Json => "jsonb",
10528        DataType::Bytes => "bytea",
10529        DataType::TextArray | DataType::IntArray | DataType::BigIntArray => "ARRAY",
10530        DataType::TsVector => "tsvector",
10531        DataType::TsQuery => "tsquery",
10532        DataType::Vector { .. } => "USER-DEFINED",
10533        // Non-exhaustive — fall back to "USER-DEFINED" the way
10534        // PG labels any pg_type it doesn't recognise.
10535        _ => "USER-DEFINED",
10536    };
10537    alloc::string::String::from(s)
10538}
10539
10540/// v7.16.2 — synthesise `information_schema.columns`. mailrs
10541/// queries are of shape `SELECT 1 FROM information_schema.columns
10542/// WHERE table_name = … AND column_name = … AND data_type = …` —
10543/// the v7.16.2 view returns the columns mailrs probes; broader
10544/// PG-spec parity (ordinal_position, is_nullable, character_
10545/// maximum_length, udt_name, …) lands as needed.
10546fn synth_information_schema_columns(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10547    let schema = alloc::vec![
10548        ColumnSchema::new("table_catalog", DataType::Text, false),
10549        ColumnSchema::new("table_schema", DataType::Text, false),
10550        ColumnSchema::new("table_name", DataType::Text, false),
10551        ColumnSchema::new("column_name", DataType::Text, false),
10552        ColumnSchema::new("ordinal_position", DataType::Int, false),
10553        ColumnSchema::new("is_nullable", DataType::Text, false),
10554        ColumnSchema::new("data_type", DataType::Text, false),
10555    ];
10556    let mut rows: Vec<Row> = Vec::new();
10557    for tname in cat.table_names() {
10558        let Some(t) = cat.get(&tname) else { continue };
10559        for (i, col) in t.schema().columns.iter().enumerate() {
10560            #[allow(clippy::cast_possible_wrap)]
10561            let ordinal = (i + 1) as i32;
10562            rows.push(Row::new(alloc::vec![
10563                Value::Text("spg".into()),
10564                Value::Text("public".into()),
10565                Value::Text(tname.clone()),
10566                Value::Text(col.name.clone()),
10567                Value::Int(ordinal),
10568                Value::Text(if col.nullable {
10569                    "YES".into()
10570                } else {
10571                    "NO".into()
10572                }),
10573                Value::Text(pg_data_type_text(col.ty)),
10574            ]));
10575        }
10576    }
10577    (schema, rows)
10578}
10579
10580/// v7.16.2 — synthesise `information_schema.tables`.
10581fn synth_information_schema_tables(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10582    let schema = alloc::vec![
10583        ColumnSchema::new("table_catalog", DataType::Text, false),
10584        ColumnSchema::new("table_schema", DataType::Text, false),
10585        ColumnSchema::new("table_name", DataType::Text, false),
10586        ColumnSchema::new("table_type", DataType::Text, false),
10587    ];
10588    let mut rows: Vec<Row> = Vec::new();
10589    for tname in cat.table_names() {
10590        rows.push(Row::new(alloc::vec![
10591            Value::Text("spg".into()),
10592            Value::Text("public".into()),
10593            Value::Text(tname.clone()),
10594            Value::Text("BASE TABLE".into()),
10595        ]));
10596    }
10597    (schema, rows)
10598}
10599
10600/// v7.16.2 — synthesise `pg_catalog.pg_class`. Minimum shape
10601/// for psql `\d` / ORM probes: `relname` + `relkind`. Each
10602/// user table emits one row.
10603fn synth_pg_class(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10604    let schema = alloc::vec![
10605        ColumnSchema::new("relname", DataType::Text, false),
10606        ColumnSchema::new("relkind", DataType::Text, false),
10607        ColumnSchema::new("relnamespace", DataType::BigInt, false),
10608    ];
10609    let mut rows: Vec<Row> = Vec::new();
10610    for tname in cat.table_names() {
10611        rows.push(Row::new(alloc::vec![
10612            Value::Text(tname.clone()),
10613            Value::Text("r".into()),
10614            Value::BigInt(2200), // PG's `public` namespace OID
10615        ]));
10616    }
10617    (schema, rows)
10618}
10619
10620/// v7.16.2 — synthesise `pg_catalog.pg_attribute`. Minimum
10621/// shape: `attrelid` (text — SPG has no OID), `attname`,
10622/// `attnum`, `atttypid` (text), `attnotnull`.
10623fn synth_pg_attribute(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10624    let schema = alloc::vec![
10625        ColumnSchema::new("attrelid", DataType::Text, false),
10626        ColumnSchema::new("attname", DataType::Text, false),
10627        ColumnSchema::new("attnum", DataType::Int, false),
10628        ColumnSchema::new("atttypid", DataType::Text, false),
10629        ColumnSchema::new("attnotnull", DataType::Bool, false),
10630    ];
10631    let mut rows: Vec<Row> = Vec::new();
10632    for tname in cat.table_names() {
10633        let Some(t) = cat.get(&tname) else { continue };
10634        for (i, col) in t.schema().columns.iter().enumerate() {
10635            #[allow(clippy::cast_possible_wrap)]
10636            let ordinal = (i + 1) as i32;
10637            rows.push(Row::new(alloc::vec![
10638                Value::Text(tname.clone()),
10639                Value::Text(col.name.clone()),
10640                Value::Int(ordinal),
10641                Value::Text(pg_data_type_text(col.ty)),
10642                Value::Bool(!col.nullable),
10643            ]));
10644        }
10645    }
10646    (schema, rows)
10647}
10648
10649/// v7.17.0 Phase 3.P0-50 — synthesise `pg_catalog.pg_type`. The
10650/// returned rows cover every built-in scalar / array type sqlx,
10651/// SQLAlchemy, Diesel and pgAdmin look up at compile / connect
10652/// time. PG-canonical schema columns we expose:
10653///   * oid           — type OID (the lookup key sqlx uses)
10654///   * typname       — canonical type name (`int4`, `text`, …)
10655///   * typlen        — width in bytes (-1 for var-length)
10656///   * typtype       — `b`ase / `c`omposite / `e`num / etc.
10657///   * typcategory   — PG type category single-char
10658///   * typelem       — element OID for arrays (0 otherwise)
10659///   * typarray      — array-type OID (0 if no array type)
10660///   * typnamespace  — schema OID (always `public` = 2200)
10661///
10662/// Other pg_type columns (typowner, typinput/typoutput, etc.)
10663/// land in follow-up work — sqlx encoders don't query them at
10664/// connect time.
10665fn synth_pg_type(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10666    let schema = alloc::vec![
10667        ColumnSchema::new("oid", DataType::BigInt, false),
10668        ColumnSchema::new("typname", DataType::Text, false),
10669        ColumnSchema::new("typlen", DataType::SmallInt, false),
10670        ColumnSchema::new("typtype", DataType::Text, false),
10671        ColumnSchema::new("typcategory", DataType::Text, false),
10672        ColumnSchema::new("typelem", DataType::BigInt, false),
10673        ColumnSchema::new("typarray", DataType::BigInt, false),
10674        ColumnSchema::new("typnamespace", DataType::BigInt, false),
10675    ];
10676    // (oid, name, len, type, cat, elem, array_oid). PG OID
10677    // numbers come straight from `pg_type.dat`.
10678    let scalars: &[(i64, &str, i16, &str, &str, i64, i64)] = &[
10679        // bool
10680        (16, "bool", 1, "b", "B", 0, 1000),
10681        (17, "bytea", -1, "b", "U", 0, 1001),
10682        (18, "char", 1, "b", "S", 0, 1002),
10683        (19, "name", 64, "b", "S", 0, 1003),
10684        (20, "int8", 8, "b", "N", 0, 1016),
10685        (21, "int2", 2, "b", "N", 0, 1005),
10686        (23, "int4", 4, "b", "N", 0, 1007),
10687        (24, "regproc", 4, "b", "N", 0, 1008),
10688        (25, "text", -1, "b", "S", 0, 1009),
10689        (26, "oid", 4, "b", "N", 0, 1028),
10690        (114, "json", -1, "b", "U", 0, 199),
10691        (142, "xml", -1, "b", "U", 0, 143),
10692        (700, "float4", 4, "b", "N", 0, 1021),
10693        (701, "float8", 8, "b", "N", 0, 1022),
10694        (650, "cidr", -1, "b", "I", 0, 651),
10695        (869, "inet", -1, "b", "I", 0, 1041),
10696        (829, "macaddr", 6, "b", "U", 0, 1040),
10697        (1042, "bpchar", -1, "b", "S", 0, 1014),
10698        (1043, "varchar", -1, "b", "S", 0, 1015),
10699        (1082, "date", 4, "b", "D", 0, 1182),
10700        (1083, "time", 8, "b", "D", 0, 1183),
10701        (1114, "timestamp", 8, "b", "D", 0, 1115),
10702        (1184, "timestamptz", 8, "b", "D", 0, 1185),
10703        (1186, "interval", 16, "b", "T", 0, 1187),
10704        (1266, "timetz", 12, "b", "D", 0, 1270),
10705        (1700, "numeric", -1, "b", "N", 0, 1231),
10706        (790, "money", 8, "b", "N", 0, 791),
10707        (2950, "uuid", 16, "b", "U", 0, 2951),
10708        (3802, "jsonb", -1, "b", "U", 0, 3807),
10709        (3614, "tsvector", -1, "b", "U", 0, 3643),
10710        (3615, "tsquery", -1, "b", "U", 0, 3645),
10711        // hstore + range types — typcategory 'U' (user) / 'R' (range).
10712        (3908, "tstzrange", -1, "r", "R", 0, 3909),
10713        (3910, "tsrange", -1, "r", "R", 0, 3911),
10714        (3904, "int4range", -1, "r", "R", 0, 3905),
10715        (3926, "int8range", -1, "r", "R", 0, 3927),
10716        (3906, "numrange", -1, "r", "R", 0, 3907),
10717        (3912, "daterange", -1, "r", "R", 0, 3913),
10718    ];
10719    // Array companion types share the typelem / typcategory='A'.
10720    // We emit just the array OIDs the scalars reference.
10721    let arrays: &[(i64, &str, i64)] = &[
10722        (1000, "_bool", 16),
10723        (1001, "_bytea", 17),
10724        (1002, "_char", 18),
10725        (1003, "_name", 19),
10726        (1016, "_int8", 20),
10727        (1005, "_int2", 21),
10728        (1007, "_int4", 23),
10729        (1008, "_regproc", 24),
10730        (1009, "_text", 25),
10731        (1028, "_oid", 26),
10732        (199, "_json", 114),
10733        (143, "_xml", 142),
10734        (1021, "_float4", 700),
10735        (1022, "_float8", 701),
10736        (651, "_cidr", 650),
10737        (1041, "_inet", 869),
10738        (1040, "_macaddr", 829),
10739        (1014, "_bpchar", 1042),
10740        (1015, "_varchar", 1043),
10741        (1182, "_date", 1082),
10742        (1183, "_time", 1083),
10743        (1115, "_timestamp", 1114),
10744        (1185, "_timestamptz", 1184),
10745        (1187, "_interval", 1186),
10746        (1270, "_timetz", 1266),
10747        (1231, "_numeric", 1700),
10748        (791, "_money", 790),
10749        (2951, "_uuid", 2950),
10750        (3807, "_jsonb", 3802),
10751        (3643, "_tsvector", 3614),
10752        (3645, "_tsquery", 3615),
10753    ];
10754    let mut rows: Vec<Row> = Vec::with_capacity(scalars.len() + arrays.len());
10755    for &(oid, name, len, ty, cat, elem, arr) in scalars {
10756        rows.push(Row::new(alloc::vec![
10757            Value::BigInt(oid),
10758            Value::Text(name.into()),
10759            Value::SmallInt(len),
10760            Value::Text(ty.into()),
10761            Value::Text(cat.into()),
10762            Value::BigInt(elem),
10763            Value::BigInt(arr),
10764            Value::BigInt(2200),
10765        ]));
10766    }
10767    for &(oid, name, elem) in arrays {
10768        rows.push(Row::new(alloc::vec![
10769            Value::BigInt(oid),
10770            Value::Text(name.into()),
10771            Value::SmallInt(-1),
10772            Value::Text("b".into()),
10773            Value::Text("A".into()),
10774            Value::BigInt(elem),
10775            Value::BigInt(0),
10776            Value::BigInt(2200),
10777        ]));
10778    }
10779    (schema, rows)
10780}
10781
10782/// v7.17.0 Phase 3.P0-51 — synthesise `pg_catalog.pg_proc`. ORM /
10783/// pgAdmin probes look up functions by name; SPG synthesises rows
10784/// for the built-in scalar functions / aggregates / window funcs
10785/// the engine actually dispatches. SPG has no user-defined
10786/// functions yet so the table is a stable static list.
10787///
10788/// Schema columns exposed:
10789///   * oid (BigInt) — function OID from PG's pg_proc.dat
10790///   * proname (Text) — function name (lowercase)
10791///   * pronamespace (BigInt) — 11 (`pg_catalog`)
10792///   * prokind (Text) — 'f' function, 'a' aggregate, 'w' window
10793///   * pronargs (SmallInt) — declared arg count (-1 for variadic)
10794///   * prorettype (BigInt) — return type OID (matches synth_pg_type)
10795fn synth_pg_proc(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10796    let schema = alloc::vec![
10797        ColumnSchema::new("oid", DataType::BigInt, false),
10798        ColumnSchema::new("proname", DataType::Text, false),
10799        ColumnSchema::new("pronamespace", DataType::BigInt, false),
10800        ColumnSchema::new("prokind", DataType::Text, false),
10801        ColumnSchema::new("pronargs", DataType::Int, false),
10802        ColumnSchema::new("prorettype", DataType::BigInt, false),
10803    ];
10804    // (oid, name, kind, nargs, rettype). OIDs taken from PG's
10805    // pg_proc.dat for the common subset.
10806    let funcs: &[(i64, &str, &str, i32, i64)] = &[
10807        // Scalar functions.
10808        (1318, "length", "f", 1, 23),
10809        (871, "upper", "f", 1, 25),
10810        (870, "lower", "f", 1, 25),
10811        (936, "substring", "f", 3, 25),
10812        (937, "substring", "f", 2, 25),
10813        (3055, "btrim", "f", 1, 25),
10814        (885, "btrim", "f", 2, 25),
10815        (3056, "ltrim", "f", 1, 25),
10816        (875, "ltrim", "f", 2, 25),
10817        (3057, "rtrim", "f", 1, 25),
10818        (876, "rtrim", "f", 2, 25),
10819        (1397, "abs", "f", 1, 23),
10820        (1396, "abs", "f", 1, 20),
10821        (1606, "round", "f", 1, 1700),
10822        (1707, "round", "f", 2, 1700),
10823        (2308, "ceil", "f", 1, 701),
10824        (2309, "ceiling", "f", 1, 701),
10825        (2310, "floor", "f", 1, 701),
10826        (1376, "sqrt", "f", 1, 701),
10827        (1369, "ln", "f", 1, 701),
10828        (1373, "exp", "f", 1, 701),
10829        (1368, "power", "f", 2, 701),
10830        (2228, "random", "f", 0, 701),
10831        // Date / time.
10832        (1299, "now", "f", 0, 1184),
10833        (1274, "current_timestamp", "f", 0, 1184),
10834        (1140, "current_date", "f", 0, 1082),
10835        (2050, "current_time", "f", 0, 1083),
10836        (1158, "date_trunc", "f", 2, 1184),
10837        (1171, "date_part", "f", 2, 701),
10838        (1172, "age", "f", 1, 1186),
10839        (936, "to_char", "f", 2, 25),
10840        // Session / introspection.
10841        (861, "current_database", "f", 0, 19),
10842        (745, "current_user", "f", 0, 19),
10843        (745, "session_user", "f", 0, 19),
10844        (1402, "current_schema", "f", 0, 19),
10845        // String concat / format.
10846        (3058, "concat", "f", -1, 25),
10847        (3059, "concat_ws", "f", -1, 25),
10848        (3539, "format", "f", -1, 25),
10849        // Type introspection.
10850        (2877, "pg_typeof", "f", 1, 2206),
10851        // JSON.
10852        (3198, "json_build_object", "f", -1, 114),
10853        (3199, "jsonb_build_object", "f", -1, 3802),
10854        (3271, "json_build_array", "f", -1, 114),
10855        (3272, "jsonb_build_array", "f", -1, 3802),
10856        // UUID.
10857        (3253, "gen_random_uuid", "f", 0, 2950),
10858        (3252, "uuid_generate_v4", "f", 0, 2950),
10859        // Aggregates.
10860        (2147, "count", "a", 0, 20),
10861        (2803, "count", "a", -1, 20),
10862        (2116, "max", "a", 1, 23),
10863        (2132, "min", "a", 1, 23),
10864        (2108, "sum", "a", 1, 20),
10865        (2100, "avg", "a", 1, 1700),
10866        (2517, "string_agg", "a", 2, 25),
10867        (2747, "array_agg", "a", 1, 1009),
10868        (2517, "bool_and", "a", 1, 16),
10869        (2518, "bool_or", "a", 1, 16),
10870        (2519, "every", "a", 1, 16),
10871        // Window functions.
10872        (3100, "row_number", "w", 0, 20),
10873        (3101, "rank", "w", 0, 20),
10874        (3102, "dense_rank", "w", 0, 20),
10875        (3103, "percent_rank", "w", 0, 701),
10876        (3104, "cume_dist", "w", 0, 701),
10877        (3105, "lag", "w", -1, 2283),
10878        (3106, "lead", "w", -1, 2283),
10879        (3107, "first_value", "w", 1, 2283),
10880        (3108, "last_value", "w", 1, 2283),
10881        (3109, "nth_value", "w", 2, 2283),
10882    ];
10883    let mut rows: Vec<Row> = Vec::with_capacity(funcs.len());
10884    for &(oid, name, kind, nargs, rettype) in funcs {
10885        rows.push(Row::new(alloc::vec![
10886            Value::BigInt(oid),
10887            Value::Text(name.into()),
10888            Value::BigInt(11),
10889            Value::Text(kind.into()),
10890            Value::Int(nargs),
10891            Value::BigInt(rettype),
10892        ]));
10893    }
10894    (schema, rows)
10895}
10896
10897/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.user`. MySQL admin
10898/// queries (`SELECT user, host FROM mysql.user`) probe this at
10899/// connect time to list accounts. SPG ships one row per
10900/// UserStore entry plus a synthetic `root` superuser row for
10901/// MySQL bootstrap compat.
10902fn synth_mysql_user(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
10903    let schema = alloc::vec![
10904        ColumnSchema::new("user", DataType::Text, false),
10905        ColumnSchema::new("host", DataType::Text, false),
10906        ColumnSchema::new("select_priv", DataType::Text, false),
10907    ];
10908    let mut rows: Vec<Row> = Vec::new();
10909    rows.push(Row::new(alloc::vec![
10910        Value::Text("root".into()),
10911        Value::Text("localhost".into()),
10912        Value::Text("Y".into()),
10913    ]));
10914    for (name, _) in engine.users.iter() {
10915        if name != "root" {
10916            rows.push(Row::new(alloc::vec![
10917                Value::Text(name.to_string()),
10918                Value::Text("%".into()),
10919                Value::Text("Y".into()),
10920            ]));
10921        }
10922    }
10923    (schema, rows)
10924}
10925
10926/// v7.17.0 Phase 3.P0-65 — synthesise `mysql.db`. The
10927/// per-database privileges table. SPG is single-database so the
10928/// table surfaces one row per declared user with full privileges
10929/// on the canonical `postgres` database.
10930fn synth_mysql_db() -> (Vec<ColumnSchema>, Vec<Row>) {
10931    let schema = alloc::vec![
10932        ColumnSchema::new("host", DataType::Text, false),
10933        ColumnSchema::new("db", DataType::Text, false),
10934        ColumnSchema::new("user", DataType::Text, false),
10935        ColumnSchema::new("select_priv", DataType::Text, false),
10936    ];
10937    let rows = alloc::vec![Row::new(alloc::vec![
10938        Value::Text("localhost".into()),
10939        Value::Text("postgres".into()),
10940        Value::Text("root".into()),
10941        Value::Text("Y".into()),
10942    ])];
10943    (schema, rows)
10944}
10945
10946/// v7.17.0 Phase 3.P0-63 — synthesise
10947/// `information_schema.KEY_COLUMN_USAGE`. ORM migration tools
10948/// (Alembic, Sequelize, TypeORM) walk this view to discover FK
10949/// relationships in MySQL-flavoured introspection queries.
10950///
10951/// Schema columns exposed:
10952///   * CONSTRAINT_NAME (Text)
10953///   * TABLE_NAME (Text)
10954///   * COLUMN_NAME (Text)
10955///   * ORDINAL_POSITION (Int)
10956///   * REFERENCED_TABLE_NAME (Text) — empty for non-FK rows
10957///   * REFERENCED_COLUMN_NAME (Text) — empty for non-FK rows
10958fn synth_info_key_column_usage(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
10959    let schema = alloc::vec![
10960        ColumnSchema::new("constraint_name", DataType::Text, false),
10961        ColumnSchema::new("table_name", DataType::Text, false),
10962        ColumnSchema::new("column_name", DataType::Text, false),
10963        ColumnSchema::new("ordinal_position", DataType::Int, false),
10964        ColumnSchema::new("referenced_table_name", DataType::Text, false),
10965        ColumnSchema::new("referenced_column_name", DataType::Text, false),
10966    ];
10967    let mut rows: Vec<Row> = Vec::new();
10968    for tname in cat.table_names() {
10969        let Some(t) = cat.get(&tname) else { continue };
10970        let cols = &t.schema().columns;
10971        let col_name_at = |pos: usize| -> String {
10972            cols.get(pos)
10973                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
10974        };
10975        // FKs.
10976        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
10977            let conname = fk
10978                .name
10979                .clone()
10980                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
10981            for (i, (&local, &parent)) in fk
10982                .local_columns
10983                .iter()
10984                .zip(fk.parent_columns.iter())
10985                .enumerate()
10986            {
10987                let parent_name = cat
10988                    .get(&fk.parent_table)
10989                    .and_then(|pt| pt.schema().columns.get(parent).map(|c| c.name.clone()))
10990                    .unwrap_or_else(|| alloc::format!("col{parent}"));
10991                #[allow(clippy::cast_possible_wrap)]
10992                let ordinal = (i + 1) as i32;
10993                rows.push(Row::new(alloc::vec![
10994                    Value::Text(conname.clone()),
10995                    Value::Text(tname.clone()),
10996                    Value::Text(col_name_at(local)),
10997                    Value::Int(ordinal),
10998                    Value::Text(fk.parent_table.clone()),
10999                    Value::Text(parent_name),
11000                ]));
11001            }
11002        }
11003        // PK / composite UC entries.
11004        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
11005            let conname = if uc.is_primary_key {
11006                alloc::format!("{}_pkey", tname)
11007            } else {
11008                alloc::format!("{}_uniq{ci}", tname)
11009            };
11010            for (i, &local) in uc.columns.iter().enumerate() {
11011                #[allow(clippy::cast_possible_wrap)]
11012                let ordinal = (i + 1) as i32;
11013                rows.push(Row::new(alloc::vec![
11014                    Value::Text(conname.clone()),
11015                    Value::Text(tname.clone()),
11016                    Value::Text(col_name_at(local)),
11017                    Value::Int(ordinal),
11018                    Value::Text(String::new()),
11019                    Value::Text(String::new()),
11020                ]));
11021            }
11022        }
11023    }
11024    (schema, rows)
11025}
11026
11027/// v7.17.0 Phase 3.P0-64 — synthesise
11028/// `information_schema.REFERENTIAL_CONSTRAINTS`. One row per FK.
11029fn synth_info_referential_constraints(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11030    let schema = alloc::vec![
11031        ColumnSchema::new("constraint_name", DataType::Text, false),
11032        ColumnSchema::new("table_name", DataType::Text, false),
11033        ColumnSchema::new("referenced_table_name", DataType::Text, false),
11034        ColumnSchema::new("update_rule", DataType::Text, false),
11035        ColumnSchema::new("delete_rule", DataType::Text, false),
11036    ];
11037    fn rule_name(a: spg_storage::FkAction) -> &'static str {
11038        match a {
11039            spg_storage::FkAction::Cascade => "CASCADE",
11040            spg_storage::FkAction::SetNull => "SET NULL",
11041            spg_storage::FkAction::SetDefault => "SET DEFAULT",
11042            spg_storage::FkAction::Restrict => "RESTRICT",
11043            spg_storage::FkAction::NoAction => "NO ACTION",
11044        }
11045    }
11046    let mut rows: Vec<Row> = Vec::new();
11047    for tname in cat.table_names() {
11048        let Some(t) = cat.get(&tname) else { continue };
11049        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11050            let conname = fk
11051                .name
11052                .clone()
11053                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11054            rows.push(Row::new(alloc::vec![
11055                Value::Text(conname),
11056                Value::Text(tname.clone()),
11057                Value::Text(fk.parent_table.clone()),
11058                Value::Text(rule_name(fk.on_update).into()),
11059                Value::Text(rule_name(fk.on_delete).into()),
11060            ]));
11061        }
11062    }
11063    (schema, rows)
11064}
11065
11066/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.STATISTICS`.
11067/// One row per (index × column) — admin tools walk this to
11068/// surface index-cardinality estimates.
11069fn synth_info_statistics(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11070    let schema = alloc::vec![
11071        ColumnSchema::new("table_name", DataType::Text, false),
11072        ColumnSchema::new("index_name", DataType::Text, false),
11073        ColumnSchema::new("column_name", DataType::Text, false),
11074        ColumnSchema::new("seq_in_index", DataType::Int, false),
11075        ColumnSchema::new("non_unique", DataType::Int, false),
11076        ColumnSchema::new("index_type", DataType::Text, false),
11077    ];
11078    let mut rows: Vec<Row> = Vec::new();
11079    for tname in cat.table_names() {
11080        let Some(t) = cat.get(&tname) else { continue };
11081        for idx in t.indices() {
11082            let col = t
11083                .schema()
11084                .columns
11085                .get(idx.column_position)
11086                .map_or("?".into(), |c| c.name.clone());
11087            rows.push(Row::new(alloc::vec![
11088                Value::Text(tname.clone()),
11089                Value::Text(idx.name.clone()),
11090                Value::Text(col),
11091                Value::Int(1),
11092                Value::Int(i32::from(!idx.is_unique)),
11093                Value::Text("BTREE".into()),
11094            ]));
11095        }
11096    }
11097    (schema, rows)
11098}
11099
11100/// v7.17.0 Phase 3.P0-64 — synthesise `information_schema.ROUTINES`.
11101/// SPG has no user-defined functions in v7.17 so the surface is
11102/// always empty; admin tools just need the table to exist.
11103fn synth_info_routines() -> (Vec<ColumnSchema>, Vec<Row>) {
11104    let schema = alloc::vec![
11105        ColumnSchema::new("routine_name", DataType::Text, false),
11106        ColumnSchema::new("routine_type", DataType::Text, false),
11107        ColumnSchema::new("data_type", DataType::Text, false),
11108    ];
11109    (schema, Vec::new())
11110}
11111
11112/// v7.17.0 Phase 3.P0-54 — synthesise `pg_catalog.pg_constraint`.
11113/// ORM compilers (Diesel, sea-orm) and admin tools probe this for
11114/// FK / UNIQUE / PK / CHECK definitions to surface relationship
11115/// graphs and validation rules. SPG ships one row per
11116/// uniqueness constraint + foreign key declared in the catalog.
11117///
11118/// Schema columns exposed:
11119///   * conname (Text) — constraint name (synthetic when anonymous)
11120///   * contype (Text) — `p` PK, `u` UNIQUE, `f` FK, `c` CHECK
11121///   * conrelid (Text) — owner table name
11122///   * confrelid (Text) — referenced parent table (FK only;
11123///     empty string otherwise)
11124///   * conkey (Text) — comma-separated column names
11125///   * confkey (Text) — comma-separated parent column names (FK only)
11126fn synth_pg_constraint(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11127    let schema = alloc::vec![
11128        ColumnSchema::new("conname", DataType::Text, false),
11129        ColumnSchema::new("contype", DataType::Text, false),
11130        ColumnSchema::new("conrelid", DataType::Text, false),
11131        ColumnSchema::new("confrelid", DataType::Text, false),
11132        ColumnSchema::new("conkey", DataType::Text, false),
11133        ColumnSchema::new("confkey", DataType::Text, false),
11134    ];
11135    let mut rows: Vec<Row> = Vec::new();
11136    for tname in cat.table_names() {
11137        let Some(t) = cat.get(&tname) else { continue };
11138        let cols = &t.schema().columns;
11139        let col_name_at = |pos: usize| -> String {
11140            cols.get(pos)
11141                .map_or_else(|| alloc::format!("col{pos}"), |c| c.name.clone())
11142        };
11143        // Uniqueness constraints (composite UNIQUE / PRIMARY KEY).
11144        for (ci, uc) in t.schema().uniqueness_constraints.iter().enumerate() {
11145            let kind = if uc.is_primary_key { "p" } else { "u" };
11146            let conname = if uc.is_primary_key {
11147                alloc::format!("{}_pkey", tname)
11148            } else {
11149                alloc::format!("{}_uniq{ci}", tname)
11150            };
11151            let conkey: Vec<String> = uc.columns.iter().map(|&p| col_name_at(p)).collect();
11152            rows.push(Row::new(alloc::vec![
11153                Value::Text(conname),
11154                Value::Text(kind.into()),
11155                Value::Text(tname.clone()),
11156                Value::Text(String::new()),
11157                Value::Text(conkey.join(",")),
11158                Value::Text(String::new()),
11159            ]));
11160        }
11161        // Single-column PK / UNIQUE indexes that have no
11162        // matching entry in `uniqueness_constraints` (the engine
11163        // creates only the BTree index for the bare-column case;
11164        // composite forms ride the UC path above).
11165        for idx in t.indices() {
11166            if !idx.is_unique {
11167                continue;
11168            }
11169            let is_primary = idx.name.ends_with("_pkey");
11170            let conname = idx.name.clone();
11171            let kind = if is_primary { "p" } else { "u" };
11172            let col_name = col_name_at(idx.column_position);
11173            // Skip if already emitted via the UC loop above (same
11174            // tuple shape — single-column).
11175            let already = t
11176                .schema()
11177                .uniqueness_constraints
11178                .iter()
11179                .any(|uc| uc.columns.len() == 1 && uc.columns[0] == idx.column_position);
11180            if already {
11181                continue;
11182            }
11183            rows.push(Row::new(alloc::vec![
11184                Value::Text(conname),
11185                Value::Text(kind.into()),
11186                Value::Text(tname.clone()),
11187                Value::Text(String::new()),
11188                Value::Text(col_name),
11189                Value::Text(String::new()),
11190            ]));
11191        }
11192        // Foreign keys.
11193        for (fi, fk) in t.schema().foreign_keys.iter().enumerate() {
11194            let conname = fk
11195                .name
11196                .clone()
11197                .unwrap_or_else(|| alloc::format!("{}_fk{fi}", tname));
11198            let conkey: Vec<String> = fk.local_columns.iter().map(|&p| col_name_at(p)).collect();
11199            // Parent column names: look up the parent table's
11200            // schema if it exists; otherwise emit positions.
11201            let confkey: Vec<String> = if let Some(parent) = cat.get(&fk.parent_table) {
11202                fk.parent_columns
11203                    .iter()
11204                    .map(|&p| {
11205                        parent
11206                            .schema()
11207                            .columns
11208                            .get(p)
11209                            .map_or_else(|| alloc::format!("col{p}"), |c| c.name.clone())
11210                    })
11211                    .collect()
11212            } else {
11213                fk.parent_columns
11214                    .iter()
11215                    .map(|p| alloc::format!("col{p}"))
11216                    .collect()
11217            };
11218            rows.push(Row::new(alloc::vec![
11219                Value::Text(conname),
11220                Value::Text("f".into()),
11221                Value::Text(tname.clone()),
11222                Value::Text(fk.parent_table.clone()),
11223                Value::Text(conkey.join(",")),
11224                Value::Text(confkey.join(",")),
11225            ]));
11226        }
11227    }
11228    (schema, rows)
11229}
11230
11231/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_database`.
11232/// SPG is single-database so we surface a single row keyed on the
11233/// canonical `postgres` database name (matching what every PG
11234/// admin tool's startup screen expects to find).
11235fn synth_pg_database(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11236    let schema = alloc::vec![
11237        ColumnSchema::new("oid", DataType::BigInt, false),
11238        ColumnSchema::new("datname", DataType::Text, false),
11239        ColumnSchema::new("datdba", DataType::BigInt, false),
11240        ColumnSchema::new("encoding", DataType::Int, false),
11241        ColumnSchema::new("datcollate", DataType::Text, false),
11242    ];
11243    let rows = alloc::vec![Row::new(alloc::vec![
11244        Value::BigInt(16384),
11245        Value::Text("postgres".into()),
11246        Value::BigInt(10),
11247        Value::Int(6), // UTF8
11248        Value::Text("en_US.UTF-8".into()),
11249    ])];
11250    (schema, rows)
11251}
11252
11253/// v7.17.0 Phase 3.P0-55 — synthesise `pg_catalog.pg_roles`. PG's
11254/// pg_roles is a view over pg_authid showing all roles. SPG ships
11255/// one row per declared user from the engine's UserStore so admin
11256/// tool startup screens can populate.
11257fn synth_pg_roles(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11258    let schema = alloc::vec![
11259        ColumnSchema::new("oid", DataType::BigInt, false),
11260        ColumnSchema::new("rolname", DataType::Text, false),
11261        ColumnSchema::new("rolsuper", DataType::Bool, false),
11262        ColumnSchema::new("rolinherit", DataType::Bool, false),
11263        ColumnSchema::new("rolcanlogin", DataType::Bool, false),
11264    ];
11265    let mut rows: Vec<Row> = Vec::new();
11266    let oid: i64 = 10;
11267    for (i, (name, _)) in engine.users.iter().enumerate() {
11268        rows.push(Row::new(alloc::vec![
11269            Value::BigInt(oid + (i as i64) + 1),
11270            Value::Text(name.to_string()),
11271            Value::Bool(false),
11272            Value::Bool(true),
11273            Value::Bool(true),
11274        ]));
11275    }
11276    // Always include `postgres` as the bootstrap superuser if not
11277    // already present — admin tools probe for it.
11278    if !rows
11279        .iter()
11280        .any(|r| matches!(&r.values[1], Value::Text(s) if s == "postgres"))
11281    {
11282        rows.insert(
11283            0,
11284            Row::new(alloc::vec![
11285                Value::BigInt(10),
11286                Value::Text("postgres".into()),
11287                Value::Bool(true),
11288                Value::Bool(true),
11289                Value::Bool(true),
11290            ]),
11291        );
11292    }
11293    (schema, rows)
11294}
11295
11296/// v7.17.0 Phase 3.P0-56 — synthesise `pg_catalog.pg_views`. PG's
11297/// pg_views is a view listing every catalog view; SPG ships one
11298/// row per declared view + its definition text.
11299fn synth_pg_views(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11300    let schema = alloc::vec![
11301        ColumnSchema::new("schemaname", DataType::Text, false),
11302        ColumnSchema::new("viewname", DataType::Text, false),
11303        ColumnSchema::new("definition", DataType::Text, false),
11304    ];
11305    let mut rows: Vec<Row> = Vec::new();
11306    for (name, def) in cat.views() {
11307        rows.push(Row::new(alloc::vec![
11308            Value::Text("public".into()),
11309            Value::Text(name.clone()),
11310            Value::Text(def.body.clone()),
11311        ]));
11312    }
11313    (schema, rows)
11314}
11315
11316/// v7.17.0 Phase 3.P0-57 — synthesise `pg_catalog.pg_settings`. ORM
11317/// connection-checkers (sqlx pre-flight, Diesel migrator) and admin
11318/// tools read `pg_settings` to discover server-side configuration.
11319/// SPG surfaces every session_param + a small set of canonical PG
11320/// defaults so the pre-flight queries match.
11321fn synth_pg_settings(engine: &Engine) -> (Vec<ColumnSchema>, Vec<Row>) {
11322    let schema = alloc::vec![
11323        ColumnSchema::new("name", DataType::Text, false),
11324        ColumnSchema::new("setting", DataType::Text, false),
11325        ColumnSchema::new("category", DataType::Text, false),
11326    ];
11327    let mut rows: Vec<Row> = Vec::new();
11328    // Canonical defaults every admin tool expects to find.
11329    let defaults: &[(&str, &str, &str)] = &[
11330        ("server_version", "16.0 (spg)", "Preset Options"),
11331        ("server_encoding", "UTF8", "Client Connection Defaults"),
11332        ("client_encoding", "UTF8", "Client Connection Defaults"),
11333        ("DateStyle", "ISO, MDY", "Client Connection Defaults"),
11334        ("TimeZone", "UTC", "Client Connection Defaults"),
11335        ("standard_conforming_strings", "on", "Compatibility"),
11336        ("integer_datetimes", "on", "Compatibility"),
11337        ("max_connections", "100", "Connections and Authentication"),
11338    ];
11339    for &(name, val, cat) in defaults {
11340        rows.push(Row::new(alloc::vec![
11341            Value::Text(name.into()),
11342            Value::Text(val.into()),
11343            Value::Text(cat.into()),
11344        ]));
11345    }
11346    // Session-set params override the static defaults.
11347    for (k, v) in &engine.session_params {
11348        if !defaults
11349            .iter()
11350            .any(|(n, _, _)| (*n).eq_ignore_ascii_case(k))
11351        {
11352            rows.push(Row::new(alloc::vec![
11353                Value::Text(k.clone()),
11354                Value::Text(v.clone()),
11355                Value::Text("Session".into()),
11356            ]));
11357        }
11358    }
11359    (schema, rows)
11360}
11361
11362/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_indexes`.
11363/// PG's pg_indexes is a real view on pg_index + pg_class + pg_attribute.
11364/// SPG ships it as a synthesised flat table so admin tools (pgAdmin,
11365/// DataGrip) can list indexes by tablename without joining four catalogs.
11366///
11367/// Schema columns exposed:
11368///   * schemaname (Text) — always `public`
11369///   * tablename (Text)
11370///   * indexname (Text)
11371///   * indexdef (Text) — best-effort CREATE INDEX DDL
11372fn synth_pg_indexes(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11373    let schema = alloc::vec![
11374        ColumnSchema::new("schemaname", DataType::Text, false),
11375        ColumnSchema::new("tablename", DataType::Text, false),
11376        ColumnSchema::new("indexname", DataType::Text, false),
11377        ColumnSchema::new("indexdef", DataType::Text, false),
11378    ];
11379    let mut rows: Vec<Row> = Vec::new();
11380    for tname in cat.table_names() {
11381        let Some(t) = cat.get(&tname) else { continue };
11382        for idx in t.indices() {
11383            let col_name = t
11384                .schema()
11385                .columns
11386                .get(idx.column_position)
11387                .map_or("?".into(), |c| c.name.clone());
11388            let unique_kw = if idx.is_unique { "UNIQUE " } else { "" };
11389            let indexdef = alloc::format!(
11390                "CREATE {unique_kw}INDEX {} ON public.{} ({})",
11391                idx.name,
11392                tname,
11393                col_name
11394            );
11395            rows.push(Row::new(alloc::vec![
11396                Value::Text("public".into()),
11397                Value::Text(tname.clone()),
11398                Value::Text(idx.name.clone()),
11399                Value::Text(indexdef),
11400            ]));
11401        }
11402    }
11403    (schema, rows)
11404}
11405
11406/// v7.17.0 Phase 3.P0-53 — synthesise `pg_catalog.pg_index`. The
11407/// "raw" pg_index catalog used by PG-internal tooling for index
11408/// flags and ordinal information. SPG ships the columns ORM probes
11409/// actually filter on.
11410///
11411/// Schema columns exposed:
11412///   * indexrelid (BigInt) — index OID (synthetic = position+1)
11413///   * indrelid (BigInt) — table OID (synthetic = position+1)
11414///   * indnatts (Int) — number of indexed columns
11415///   * indisunique (Bool)
11416///   * indisprimary (Bool)
11417fn synth_pg_index_raw(cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11418    let schema = alloc::vec![
11419        ColumnSchema::new("indexrelid", DataType::BigInt, false),
11420        ColumnSchema::new("indrelid", DataType::BigInt, false),
11421        ColumnSchema::new("indnatts", DataType::Int, false),
11422        ColumnSchema::new("indisunique", DataType::Bool, false),
11423        ColumnSchema::new("indisprimary", DataType::Bool, false),
11424    ];
11425    let mut rows: Vec<Row> = Vec::new();
11426    let mut idx_oid: i64 = 100_000;
11427    for (table_idx, tname) in cat.table_names().iter().enumerate() {
11428        let Some(t) = cat.get(tname) else { continue };
11429        for idx in t.indices() {
11430            idx_oid += 1;
11431            #[allow(clippy::cast_possible_wrap)]
11432            let nattrs = (1 + idx.extra_column_positions.len()) as i32;
11433            // is_primary: SPG / PG flag the primary via the
11434            // index name convention `<table>_pkey`.
11435            let is_primary = idx.name.ends_with("_pkey");
11436            rows.push(Row::new(alloc::vec![
11437                Value::BigInt(idx_oid),
11438                Value::BigInt((table_idx + 1) as i64),
11439                Value::Int(nattrs),
11440                Value::Bool(idx.is_unique),
11441                Value::Bool(is_primary),
11442            ]));
11443        }
11444    }
11445    (schema, rows)
11446}
11447
11448/// v7.17.0 Phase 3.P0-52 — synthesise `pg_catalog.pg_namespace`.
11449/// SPG is single-schema so we expose the canonical PG schemas:
11450/// `public` (user-facing), `pg_catalog` (built-in), and
11451/// `information_schema` (PG meta).
11452fn synth_pg_namespace(_cat: &Catalog) -> (Vec<ColumnSchema>, Vec<Row>) {
11453    let schema = alloc::vec![
11454        ColumnSchema::new("oid", DataType::BigInt, false),
11455        ColumnSchema::new("nspname", DataType::Text, false),
11456        ColumnSchema::new("nspowner", DataType::BigInt, false),
11457    ];
11458    let rows = alloc::vec![
11459        Row::new(alloc::vec![
11460            Value::BigInt(11),
11461            Value::Text("pg_catalog".into()),
11462            Value::BigInt(10),
11463        ]),
11464        Row::new(alloc::vec![
11465            Value::BigInt(2200),
11466            Value::Text("public".into()),
11467            Value::BigInt(10),
11468        ]),
11469        Row::new(alloc::vec![
11470            Value::BigInt(13000),
11471            Value::Text("information_schema".into()),
11472            Value::BigInt(10),
11473        ]),
11474    ];
11475    (schema, rows)
11476}
11477
11478/// v7.16.2 — drop the synthesised meta view into the enriched
11479/// catalog so the regular FROM-resolution path can see it.
11480fn materialise_meta_view(
11481    catalog: &mut Catalog,
11482    name: &str,
11483    columns: Vec<ColumnSchema>,
11484    rows: Vec<Row>,
11485) -> Result<(), EngineError> {
11486    let schema = TableSchema::new(name.to_string(), columns);
11487    catalog.create_table(schema).map_err(EngineError::Storage)?;
11488    let table = catalog
11489        .get_mut(name)
11490        .expect("just-created meta view must exist");
11491    for row in rows {
11492        table.insert(row).map_err(EngineError::Storage)?;
11493    }
11494    Ok(())
11495}
11496
11497/// v7.16.2 — true when the SELECT statement references any
11498/// `__spg_info_*` or `__spg_pg_*` synthetic table name (the
11499/// parser produces these for `information_schema.X` /
11500/// `pg_catalog.X`). Used by `exec_select_cancel` to short-
11501/// circuit into the meta-view materialisation path.
11502/// v7.17.0 Phase 1.2 — append the names of any catalog-known
11503/// views referenced by `tref` to `into`. Helper for
11504/// `Engine::expand_views_in_select`. A view that's been already
11505/// materialised as a table (e.g. via the synthetic CTE pass for
11506/// SELECT FROM v) is skipped — the table form wins so the
11507/// recursive exec_select_cancel call inside exec_with_ctes
11508/// doesn't re-expand and trigger the CTE-shadow guard.
11509fn collect_view_refs(
11510    tref: &spg_sql::ast::TableRef,
11511    cat: &spg_storage::Catalog,
11512    into: &mut Vec<String>,
11513) {
11514    if cat.views().contains_key(&tref.name)
11515        && cat.get(&tref.name).is_none()
11516        && !into.iter().any(|n| n == &tref.name)
11517    {
11518        into.push(tref.name.clone());
11519    }
11520}
11521
11522fn select_references_meta_view(stmt: &SelectStatement) -> bool {
11523    fn is_meta(name: &str) -> bool {
11524        name.starts_with("__spg_info_")
11525            || name.starts_with("__spg_pg_")
11526            || name.starts_with("__spg_mysql_")
11527    }
11528    if let Some(from) = &stmt.from {
11529        if is_meta(&from.primary.name) {
11530            return true;
11531        }
11532        for j in &from.joins {
11533            if is_meta(&j.table.name) {
11534                return true;
11535            }
11536        }
11537    }
11538    for cte in &stmt.ctes {
11539        if select_references_meta_view(&cte.body) {
11540            return true;
11541        }
11542    }
11543    false
11544}
11545
11546/// v7.16.2 — collect every meta-view name a SELECT touches.
11547/// Returns a deduplicated, sorted list. Caller materialises
11548/// each one into the enriched catalog before re-running the
11549/// SELECT. Walks JOINs, CTEs, and the primary FROM.
11550fn collect_meta_view_names(
11551    stmt: &SelectStatement,
11552    into: &mut alloc::collections::BTreeSet<String>,
11553) {
11554    fn is_meta(name: &str) -> bool {
11555        name.starts_with("__spg_info_")
11556            || name.starts_with("__spg_pg_")
11557            || name.starts_with("__spg_mysql_")
11558    }
11559    if let Some(from) = &stmt.from {
11560        if is_meta(&from.primary.name) {
11561            into.insert(from.primary.name.clone());
11562        }
11563        for j in &from.joins {
11564            if is_meta(&j.table.name) {
11565                into.insert(j.table.name.clone());
11566            }
11567        }
11568    }
11569    for cte in &stmt.ctes {
11570        collect_meta_view_names(&cte.body, into);
11571    }
11572}
11573
11574fn infer_column_types(columns: &[ColumnSchema], rows: &[Row]) -> Vec<ColumnSchema> {
11575    let mut out = columns.to_vec();
11576    for (col_idx, col) in out.iter_mut().enumerate() {
11577        if col.ty != DataType::Text {
11578            continue;
11579        }
11580        let mut inferred: Option<DataType> = None;
11581        let mut all_null = true;
11582        for row in rows {
11583            let Some(v) = row.values.get(col_idx) else {
11584                continue;
11585            };
11586            let ty = match v {
11587                Value::Null => continue,
11588                Value::SmallInt(_) => DataType::SmallInt,
11589                Value::Int(_) => DataType::Int,
11590                Value::BigInt(_) => DataType::BigInt,
11591                Value::Float(_) => DataType::Float,
11592                Value::Bool(_) => DataType::Bool,
11593                Value::Vector(_) => DataType::Vector {
11594                    dim: 0,
11595                    encoding: VecEncoding::F32,
11596                },
11597                _ => DataType::Text,
11598            };
11599            all_null = false;
11600            inferred = Some(match inferred {
11601                None => ty,
11602                Some(prev) if prev == ty => prev,
11603                Some(_) => DataType::Text,
11604            });
11605        }
11606        if let Some(t) = inferred {
11607            col.ty = t;
11608            col.nullable = true;
11609        } else if all_null {
11610            col.nullable = true;
11611        }
11612    }
11613    out
11614}
11615
11616/// v4.26: render a human-readable plan tree for `EXPLAIN <select>`.
11617/// Lines are pushed into `out`; `depth` controls indentation. We
11618/// describe the rewritten SELECT — what the executor *would* do —
11619/// using the engine handle to spot indexed lookups and table shapes.
11620#[allow(clippy::too_many_lines, clippy::format_push_string)]
11621/// v6.2.4 — Walk every line of the rendered plan tree and append
11622/// per-operator stats. Lines that name a known operator get
11623/// `(rows=N)` (`actual_rows` of the top-level operator equals the
11624/// final result row count; scans report their catalog row count
11625/// as the rows-considered metric). Other lines — Filter / Join /
11626/// GroupBy / OrderBy etc. — are marked `(—)` so the surface is
11627/// complete-by-construction; v6.2.5 fills these in via inline
11628/// executor counters.
11629/// v6.8.3 — surface "CREATE INDEX …" suggestions for every
11630/// `(table, column)` pair the query touches via WHERE / JOIN
11631/// that doesn't already have an index on the owning table.
11632/// Walks the SELECT's FROM clauses + WHERE expression tree;
11633/// returns one line per missing index. Deterministic order:
11634/// FROM-clause iteration order, then column-reference walk
11635/// order inside each WHERE. Each suggestion is a copy-pastable
11636/// DDL string.
11637fn build_index_suggestions(stmt: &SelectStatement, engine: &Engine) -> Vec<String> {
11638    use alloc::collections::BTreeSet;
11639    let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
11640    let mut out: Vec<String> = Vec::new();
11641    let cat = engine.active_catalog();
11642    // Build a (table, qualifier-or-alias) list from the FROM clause
11643    // so unqualified column refs in WHERE resolve to the correct
11644    // table.
11645    let Some(from) = &stmt.from else {
11646        return out;
11647    };
11648    let mut tables: Vec<String> = Vec::new();
11649    tables.push(from.primary.name.clone());
11650    for j in &from.joins {
11651        tables.push(j.table.name.clone());
11652    }
11653    // Collect column refs from the WHERE expression. JOIN ON
11654    // predicates also feed in.
11655    let mut col_refs: Vec<spg_sql::ast::ColumnName> = Vec::new();
11656    if let Some(w) = &stmt.where_ {
11657        collect_column_refs(w, &mut col_refs);
11658    }
11659    for j in &from.joins {
11660        if let Some(on) = &j.on {
11661            collect_column_refs(on, &mut col_refs);
11662        }
11663    }
11664    for cn in &col_refs {
11665        // Resolve owner table: explicit qualifier first, else
11666        // first table in FROM that has a column of this name.
11667        let owner: Option<String> = if let Some(q) = &cn.qualifier {
11668            tables.iter().find(|t| t == &q).cloned()
11669        } else {
11670            tables.iter().find_map(|t| {
11671                cat.get(t).and_then(|tbl| {
11672                    if tbl.schema().column_position(&cn.name).is_some() {
11673                        Some(t.clone())
11674                    } else {
11675                        None
11676                    }
11677                })
11678            })
11679        };
11680        let Some(owner) = owner else {
11681            continue;
11682        };
11683        let Some(tbl) = cat.get(&owner) else {
11684            continue;
11685        };
11686        let Some(col_pos) = tbl.schema().column_position(&cn.name) else {
11687            continue;
11688        };
11689        // Skip if any BTree index already covers this column as
11690        // its key.
11691        let already_indexed = tbl.indices().iter().any(|i| {
11692            matches!(i.kind, spg_storage::IndexKind::BTree(_))
11693                && i.column_position == col_pos
11694                && i.expression.is_none()
11695                && i.partial_predicate.is_none()
11696        });
11697        if already_indexed {
11698            continue;
11699        }
11700        if seen.insert((owner.clone(), cn.name.clone())) {
11701            out.push(alloc::format!(
11702                "SUGGEST: CREATE INDEX ix_{}_{} ON {} ({})",
11703                owner,
11704                cn.name,
11705                owner,
11706                cn.name
11707            ));
11708        }
11709    }
11710    out
11711}
11712
11713/// Walks an `Expr` and pushes every `ColumnName` it references.
11714/// Order is depth-first, left-to-right.
11715fn collect_column_refs(expr: &Expr, out: &mut Vec<spg_sql::ast::ColumnName>) {
11716    match expr {
11717        Expr::Column(cn) => out.push(cn.clone()),
11718        Expr::FunctionCall { args, .. } => {
11719            for a in args {
11720                collect_column_refs(a, out);
11721            }
11722        }
11723        Expr::Binary { lhs, rhs, .. } => {
11724            collect_column_refs(lhs, out);
11725            collect_column_refs(rhs, out);
11726        }
11727        Expr::Unary { expr: e, .. } => collect_column_refs(e, out),
11728        _ => {}
11729    }
11730}
11731
11732fn annotate_explain_lines(lines: &mut [String], total_rows: usize, engine: &Engine) {
11733    let catalog = engine.active_catalog();
11734    let cold_ids = catalog.cold_segment_ids_global();
11735    let any_cold = !cold_ids.is_empty();
11736    let cold_ids_repr = if any_cold {
11737        let mut s = alloc::string::String::from("[");
11738        for (i, id) in cold_ids.iter().enumerate() {
11739            if i > 0 {
11740                s.push(',');
11741            }
11742            s.push_str(&alloc::format!("{id}"));
11743        }
11744        s.push(']');
11745        s
11746    } else {
11747        alloc::string::String::new()
11748    };
11749    for (idx, line) in lines.iter_mut().enumerate() {
11750        let trimmed = line.trim_start();
11751        let is_top_level = idx == 0;
11752        if is_top_level {
11753            line.push_str(&alloc::format!(" (rows={total_rows})"));
11754            continue;
11755        }
11756        if let Some(rest) = trimmed.strip_prefix("From: ") {
11757            let (name, scan_kind) = match rest.split_once(" [") {
11758                Some((n, k)) => (n.trim(), k.trim_end_matches(']')),
11759                None => (rest.trim(), ""),
11760            };
11761            let bare = name.split_whitespace().next().unwrap_or(name);
11762            let hot = catalog.get(bare).map(|t| t.rows().len());
11763            // v6.2.7 — `cold_segments=[id0,id1,…]` enumerates every
11764            // cold-tier segment the scan COULD have walked. v6.2.x
11765            // can tighten to per-table by walking the table's
11766            // BTree-index cold locators.
11767            let annot = match (hot, scan_kind) {
11768                (Some(h), "full scan") => {
11769                    let mut s = alloc::format!(" (hot_rows={h}");
11770                    if any_cold {
11771                        s.push_str(&alloc::format!(
11772                            ", cold_tier=present, cold_segments={cold_ids_repr}"
11773                        ));
11774                    }
11775                    s.push(')');
11776                    s
11777                }
11778                (Some(h), "index seek") => {
11779                    let mut s = alloc::format!(" (hot_rows≤{h}");
11780                    if any_cold {
11781                        s.push_str(&alloc::format!(
11782                            ", cold_tier=present, cold_segments={cold_ids_repr}"
11783                        ));
11784                    }
11785                    s.push(')');
11786                    s
11787                }
11788                _ => " (rows=—)".to_string(),
11789            };
11790            line.push_str(&annot);
11791            continue;
11792        }
11793        // Filter / GroupBy / Having / OrderBy / Limit / Join etc.
11794        line.push_str(" (rows=—)");
11795    }
11796}
11797
11798fn explain_select(stmt: &SelectStatement, engine: &Engine, depth: usize, out: &mut Vec<String>) {
11799    let pad = "  ".repeat(depth);
11800    // 1) Top-level operator label.
11801    let top = if !stmt.ctes.is_empty() {
11802        if stmt.ctes.iter().any(|c| c.recursive) {
11803            "CTEScan (WITH RECURSIVE)"
11804        } else {
11805            "CTEScan (WITH)"
11806        }
11807    } else if !stmt.unions.is_empty() {
11808        "UnionScan"
11809    } else if select_has_window(stmt) {
11810        "WindowAgg"
11811    } else if aggregate::uses_aggregate(stmt) {
11812        "Aggregate"
11813    } else if stmt.distinct {
11814        "Distinct"
11815    } else if stmt.from.is_some() {
11816        "TableScan"
11817    } else {
11818        "Result"
11819    };
11820    out.push(alloc::format!("{pad}{top}"));
11821    let child = "  ".repeat(depth + 1);
11822    // 2) CTE bodies.
11823    for cte in &stmt.ctes {
11824        let head = if cte.recursive {
11825            alloc::format!("{child}CTE (recursive): {}", cte.name)
11826        } else {
11827            alloc::format!("{child}CTE: {}", cte.name)
11828        };
11829        out.push(head);
11830        explain_select(&cte.body, engine, depth + 2, out);
11831    }
11832    // 3) FROM details — primary table + joins, index hits.
11833    if let Some(from) = &stmt.from {
11834        let mut tag = alloc::format!("{child}From: {}", from.primary.name);
11835        if let Some(alias) = &from.primary.alias {
11836            tag.push_str(&alloc::format!(" AS {alias}"));
11837        }
11838        // Try to detect an index-seek opportunity on WHERE against
11839        // the primary table — same heuristic the executor uses.
11840        if let Some(w) = &stmt.where_
11841            && let Some(table) = engine.active_catalog().get(&from.primary.name)
11842        {
11843            let alias = from.primary.alias.as_deref().unwrap_or(&from.primary.name);
11844            let cols = &table.schema().columns;
11845            if try_index_seek(w, cols, engine.active_catalog(), table, alias).is_some() {
11846                tag.push_str(" [index seek]");
11847            } else {
11848                tag.push_str(" [full scan]");
11849            }
11850        } else {
11851            tag.push_str(" [full scan]");
11852        }
11853        out.push(tag);
11854        for j in &from.joins {
11855            let kind = match j.kind {
11856                spg_sql::ast::JoinKind::Inner => "INNER JOIN",
11857                spg_sql::ast::JoinKind::Left => "LEFT JOIN",
11858                spg_sql::ast::JoinKind::Cross => "CROSS JOIN",
11859            };
11860            let mut s = alloc::format!("{child}{kind}: {}", j.table.name);
11861            if let Some(alias) = &j.table.alias {
11862                s.push_str(&alloc::format!(" AS {alias}"));
11863            }
11864            if j.on.is_some() {
11865                s.push_str(" (ON …)");
11866            }
11867            out.push(s);
11868        }
11869    }
11870    // 4) WHERE / GROUP BY / HAVING / ORDER BY / LIMIT / OFFSET.
11871    if let Some(w) = &stmt.where_ {
11872        let mut s = alloc::format!("{child}Filter: {w}");
11873        if expr_has_subquery(w) {
11874            s.push_str(" [subquery]");
11875        }
11876        out.push(s);
11877    }
11878    if let Some(gs) = &stmt.group_by {
11879        let mut parts = Vec::new();
11880        for g in gs {
11881            parts.push(alloc::format!("{g}"));
11882        }
11883        out.push(alloc::format!("{child}GroupBy: {}", parts.join(", ")));
11884    }
11885    if let Some(h) = &stmt.having {
11886        out.push(alloc::format!("{child}Having: {h}"));
11887    }
11888    for o in &stmt.order_by {
11889        let dir = if o.desc { "DESC" } else { "ASC" };
11890        out.push(alloc::format!("{child}OrderBy: {} {dir}", o.expr));
11891    }
11892    if let Some(lim) = stmt.limit {
11893        out.push(alloc::format!("{child}Limit: {lim}"));
11894    }
11895    if let Some(off) = stmt.offset {
11896        out.push(alloc::format!("{child}Offset: {off}"));
11897    }
11898    // 5) Projection — collapse Wildcard or render N items.
11899    if stmt
11900        .items
11901        .iter()
11902        .any(|it| matches!(it, SelectItem::Wildcard))
11903    {
11904        out.push(alloc::format!("{child}Project: *"));
11905    } else {
11906        out.push(alloc::format!(
11907            "{child}Project: {} item(s)",
11908            stmt.items.len()
11909        ));
11910    }
11911    // 6) Recurse into UNION peers.
11912    for (kind, peer) in &stmt.unions {
11913        let label = match kind {
11914            UnionKind::All => "UNION ALL",
11915            UnionKind::Distinct => "UNION",
11916        };
11917        out.push(alloc::format!("{child}{label}"));
11918        explain_select(peer, engine, depth + 2, out);
11919    }
11920}
11921
11922/// v4.23: recognise the engine errors that indicate the inner
11923/// SELECT couldn't be evaluated in isolation because it references
11924/// an outer column — used by `subquery_replacement` to skip
11925/// materialisation and let row-eval handle it instead.
11926fn is_correlation_error(e: &EngineError) -> bool {
11927    matches!(
11928        e,
11929        EngineError::Eval(
11930            eval::EvalError::ColumnNotFound { .. } | eval::EvalError::UnknownQualifier { .. }
11931        )
11932    )
11933}
11934
11935/// v4.23: walk every Expr in `stmt` and replace each Column ref
11936/// that targets the outer scope (qualifier matches the outer
11937/// table alias) with a Literal carrying the outer row's value.
11938/// Conservative: only qualified refs are substituted, so the user
11939/// must write `outer_alias.col` to reference an outer column. This
11940/// matches PG's lexical scoping for correlated subqueries and
11941/// avoids accidentally rebinding inner columns of the same name.
11942/// v7.17.0 Phase 3.P0-41 — LATERAL peer descriptor. Either eagerly
11943/// materialised (every regular table / unnest / generate_series) or
11944/// lateral (subquery re-evaluated per outer row).
11945struct JoinedPeer<'a> {
11946    eager_rows: Option<Vec<Row>>,
11947    cols: Vec<ColumnSchema>,
11948    alias: String,
11949    kind: JoinKind,
11950    on: Option<&'a Expr>,
11951    lateral: Option<&'a SelectStatement>,
11952}
11953
11954/// v7.17.0 Phase 3.P0-41 — synthesise a column name for a LATERAL
11955/// projection item that has no explicit alias. PG names anonymous
11956/// projection items by the function call's name or by `column<i>`.
11957/// SPG mirrors the latter (lower-overhead than walking arbitrary
11958/// Expr shapes) so the probe-schema fallback path produces stable
11959/// names for the lateral peer's columns.
11960fn synth_lateral_col_name(expr: &Expr, idx: usize) -> String {
11961    match expr {
11962        // Bare column reference — use the column's own name.
11963        Expr::Column(c) => c.name.clone(),
11964        // Function call — use the function name (PG canonical:
11965        // `count` / `max` / `lower` …).
11966        Expr::FunctionCall { name, .. } => name.clone(),
11967        // Cast — drill into the inner expression.
11968        Expr::Cast { expr: inner, .. } => synth_lateral_col_name(inner, idx),
11969        // Everything else falls back to PG's `column<N>` placeholder.
11970        _ => alloc::format!("column{}", idx + 1),
11971    }
11972}
11973
11974/// v7.17.0 Phase 3.P0-41 — substitute every `<alias>.<col>` Expr
11975/// reference whose `<alias>.<col>` exists in the outer composite
11976/// schema with the matching value from the outer row. Walks the
11977/// entire SELECT body (items, WHERE, GROUP BY, HAVING, ORDER BY,
11978/// UNION peers) so any depth of outer reference inside the
11979/// LATERAL subquery resolves before execution.
11980fn substitute_outer_columns_multi(
11981    stmt: &mut SelectStatement,
11982    outer_row: &Row,
11983    outer_schema: &[ColumnSchema],
11984) {
11985    substitute_outer_in_select(stmt, outer_row, outer_schema);
11986}
11987
11988fn substitute_outer_in_select(
11989    stmt: &mut SelectStatement,
11990    outer_row: &Row,
11991    outer_schema: &[ColumnSchema],
11992) {
11993    for item in &mut stmt.items {
11994        if let SelectItem::Expr { expr, .. } = item {
11995            substitute_outer_in_expr(expr, outer_row, outer_schema);
11996        }
11997    }
11998    if let Some(w) = &mut stmt.where_ {
11999        substitute_outer_in_expr(w, outer_row, outer_schema);
12000    }
12001    if let Some(gs) = &mut stmt.group_by {
12002        for g in gs {
12003            substitute_outer_in_expr(g, outer_row, outer_schema);
12004        }
12005    }
12006    if let Some(h) = &mut stmt.having {
12007        substitute_outer_in_expr(h, outer_row, outer_schema);
12008    }
12009    for o in &mut stmt.order_by {
12010        substitute_outer_in_expr(&mut o.expr, outer_row, outer_schema);
12011    }
12012    for (_, peer) in &mut stmt.unions {
12013        substitute_outer_in_select(peer, outer_row, outer_schema);
12014    }
12015}
12016
12017fn substitute_outer_in_expr(e: &mut Expr, outer_row: &Row, outer_schema: &[ColumnSchema]) {
12018    if let Expr::Column(c) = e
12019        && let Some(qual) = &c.qualifier
12020    {
12021        let composite = alloc::format!("{qual}.{}", c.name);
12022        if let Some(idx) = outer_schema
12023            .iter()
12024            .position(|sc| sc.name.eq_ignore_ascii_case(&composite))
12025        {
12026            let v = outer_row.values.get(idx).cloned().unwrap_or(Value::Null);
12027            if let Ok(lit) = value_to_literal_expr(v) {
12028                *e = lit;
12029                return;
12030            }
12031        }
12032    }
12033    match e {
12034        Expr::Binary { lhs, rhs, .. } => {
12035            substitute_outer_in_expr(lhs, outer_row, outer_schema);
12036            substitute_outer_in_expr(rhs, outer_row, outer_schema);
12037        }
12038        Expr::Unary { expr: inner, .. } => {
12039            substitute_outer_in_expr(inner, outer_row, outer_schema);
12040        }
12041        Expr::FunctionCall { args, .. } => {
12042            for a in args {
12043                substitute_outer_in_expr(a, outer_row, outer_schema);
12044            }
12045        }
12046        Expr::Cast { expr: inner, .. } => {
12047            substitute_outer_in_expr(inner, outer_row, outer_schema);
12048        }
12049        Expr::Case {
12050            operand,
12051            branches,
12052            else_branch,
12053        } => {
12054            if let Some(op) = operand {
12055                substitute_outer_in_expr(op, outer_row, outer_schema);
12056            }
12057            for (cond, val) in branches {
12058                substitute_outer_in_expr(cond, outer_row, outer_schema);
12059                substitute_outer_in_expr(val, outer_row, outer_schema);
12060            }
12061            if let Some(e) = else_branch {
12062                substitute_outer_in_expr(e, outer_row, outer_schema);
12063            }
12064        }
12065        _ => {}
12066    }
12067}
12068
12069fn substitute_outer_columns(stmt: &mut SelectStatement, row: &Row, ctx: &EvalContext<'_>) {
12070    let Some(outer_alias) = ctx.table_alias else {
12071        return;
12072    };
12073    substitute_in_select(stmt, row, ctx, outer_alias);
12074}
12075
12076fn substitute_in_select(
12077    stmt: &mut SelectStatement,
12078    row: &Row,
12079    ctx: &EvalContext<'_>,
12080    outer_alias: &str,
12081) {
12082    for item in &mut stmt.items {
12083        if let SelectItem::Expr { expr, .. } = item {
12084            substitute_in_expr(expr, row, ctx, outer_alias);
12085        }
12086    }
12087    if let Some(w) = &mut stmt.where_ {
12088        substitute_in_expr(w, row, ctx, outer_alias);
12089    }
12090    if let Some(gs) = &mut stmt.group_by {
12091        for g in gs {
12092            substitute_in_expr(g, row, ctx, outer_alias);
12093        }
12094    }
12095    if let Some(h) = &mut stmt.having {
12096        substitute_in_expr(h, row, ctx, outer_alias);
12097    }
12098    for o in &mut stmt.order_by {
12099        substitute_in_expr(&mut o.expr, row, ctx, outer_alias);
12100    }
12101    for (_, peer) in &mut stmt.unions {
12102        substitute_in_select(peer, row, ctx, outer_alias);
12103    }
12104}
12105
12106fn substitute_in_expr(e: &mut Expr, row: &Row, ctx: &EvalContext<'_>, outer_alias: &str) {
12107    if let Expr::Column(c) = e
12108        && let Some(qual) = &c.qualifier
12109        && qual.eq_ignore_ascii_case(outer_alias)
12110    {
12111        // Look up the column's index in the outer schema.
12112        if let Some(idx) = ctx
12113            .columns
12114            .iter()
12115            .position(|sc| sc.name.eq_ignore_ascii_case(&c.name))
12116        {
12117            let v = row.values.get(idx).cloned().unwrap_or(Value::Null);
12118            if let Ok(lit) = value_to_literal_expr(v) {
12119                *e = lit;
12120                return;
12121            }
12122        }
12123    }
12124    match e {
12125        Expr::Binary { lhs, rhs, .. } => {
12126            substitute_in_expr(lhs, row, ctx, outer_alias);
12127            substitute_in_expr(rhs, row, ctx, outer_alias);
12128        }
12129        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12130            substitute_in_expr(expr, row, ctx, outer_alias);
12131        }
12132        Expr::Like { expr, pattern, .. } => {
12133            substitute_in_expr(expr, row, ctx, outer_alias);
12134            substitute_in_expr(pattern, row, ctx, outer_alias);
12135        }
12136        Expr::FunctionCall { args, .. } => {
12137            for a in args {
12138                substitute_in_expr(a, row, ctx, outer_alias);
12139            }
12140        }
12141        Expr::Extract { source, .. } => substitute_in_expr(source, row, ctx, outer_alias),
12142        Expr::WindowFunction {
12143            args,
12144            partition_by,
12145            order_by,
12146            ..
12147        } => {
12148            for a in args {
12149                substitute_in_expr(a, row, ctx, outer_alias);
12150            }
12151            for p in partition_by {
12152                substitute_in_expr(p, row, ctx, outer_alias);
12153            }
12154            for (o, _) in order_by {
12155                substitute_in_expr(o, row, ctx, outer_alias);
12156            }
12157        }
12158        Expr::ScalarSubquery(s) => substitute_in_select(s, row, ctx, outer_alias),
12159        Expr::Exists { subquery, .. } | Expr::InSubquery { subquery, .. } => {
12160            substitute_in_select(subquery, row, ctx, outer_alias);
12161        }
12162        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
12163        Expr::Array(items) => {
12164            for elem in items {
12165                substitute_in_expr(elem, row, ctx, outer_alias);
12166            }
12167        }
12168        Expr::ArraySubscript { target, index } => {
12169            substitute_in_expr(target, row, ctx, outer_alias);
12170            substitute_in_expr(index, row, ctx, outer_alias);
12171        }
12172        Expr::AnyAll { expr, array, .. } => {
12173            substitute_in_expr(expr, row, ctx, outer_alias);
12174            substitute_in_expr(array, row, ctx, outer_alias);
12175        }
12176        Expr::Case {
12177            operand,
12178            branches,
12179            else_branch,
12180        } => {
12181            if let Some(o) = operand {
12182                substitute_in_expr(o, row, ctx, outer_alias);
12183            }
12184            for (w, t) in branches {
12185                substitute_in_expr(w, row, ctx, outer_alias);
12186                substitute_in_expr(t, row, ctx, outer_alias);
12187            }
12188            if let Some(e) = else_branch {
12189                substitute_in_expr(e, row, ctx, outer_alias);
12190            }
12191        }
12192    }
12193}
12194
12195/// v4.22: encode a Row to a comparable byte key for UNION-DISTINCT
12196/// dedup inside the recursive iteration. Crude but deterministic
12197/// — Debug prints embed type discriminants so NULL ≠ "" ≠ 0.
12198fn encode_row_key(row: &Row) -> Vec<u8> {
12199    let mut out = Vec::new();
12200    for v in &row.values {
12201        let s = alloc::format!("{v:?}|");
12202        out.extend_from_slice(s.as_bytes());
12203    }
12204    out
12205}
12206
12207fn select_has_window(stmt: &SelectStatement) -> bool {
12208    for item in &stmt.items {
12209        if let SelectItem::Expr { expr, .. } = item
12210            && expr_has_window(expr)
12211        {
12212            return true;
12213        }
12214    }
12215    false
12216}
12217
12218fn expr_has_window(e: &Expr) -> bool {
12219    match e {
12220        Expr::WindowFunction { .. } => true,
12221        Expr::Binary { lhs, rhs, .. } => expr_has_window(lhs) || expr_has_window(rhs),
12222        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12223            expr_has_window(expr)
12224        }
12225        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_window),
12226        Expr::Like { expr, pattern, .. } => expr_has_window(expr) || expr_has_window(pattern),
12227        Expr::Extract { source, .. } => expr_has_window(source),
12228        Expr::ScalarSubquery(_)
12229        | Expr::Exists { .. }
12230        | Expr::InSubquery { .. }
12231        | Expr::Literal(_)
12232        | Expr::Placeholder(_)
12233        | Expr::Column(_) => false,
12234        Expr::Array(items) => items.iter().any(expr_has_window),
12235        Expr::ArraySubscript { target, index } => expr_has_window(target) || expr_has_window(index),
12236        Expr::AnyAll { expr, array, .. } => expr_has_window(expr) || expr_has_window(array),
12237        Expr::Case {
12238            operand,
12239            branches,
12240            else_branch,
12241        } => {
12242            operand.as_deref().is_some_and(expr_has_window)
12243                || branches
12244                    .iter()
12245                    .any(|(w, t)| expr_has_window(w) || expr_has_window(t))
12246                || else_branch.as_deref().is_some_and(expr_has_window)
12247        }
12248    }
12249}
12250
12251fn collect_window_nodes(e: &Expr, out: &mut Vec<Expr>) {
12252    if let Expr::WindowFunction { .. } = e {
12253        // Deduplicate by structural equality on the expression
12254        // (cheap because window args + partition + order are
12255        // small). Without dedup we'd recompute identical windows
12256        // once per occurrence in the projection.
12257        if !out.iter().any(|x| x == e) {
12258            out.push(e.clone());
12259        }
12260        return;
12261    }
12262    match e {
12263        // Already handled by the early-return at the top.
12264        Expr::WindowFunction { .. } => unreachable!(),
12265        Expr::Binary { lhs, rhs, .. } => {
12266            collect_window_nodes(lhs, out);
12267            collect_window_nodes(rhs, out);
12268        }
12269        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12270            collect_window_nodes(expr, out);
12271        }
12272        Expr::FunctionCall { args, .. } => {
12273            for a in args {
12274                collect_window_nodes(a, out);
12275            }
12276        }
12277        Expr::Like { expr, pattern, .. } => {
12278            collect_window_nodes(expr, out);
12279            collect_window_nodes(pattern, out);
12280        }
12281        Expr::Extract { source, .. } => collect_window_nodes(source, out),
12282        _ => {}
12283    }
12284}
12285
12286fn rewrite_window_to_columns(e: &mut Expr, window_nodes: &[Expr]) {
12287    if let Expr::WindowFunction { .. } = e
12288        && let Some(idx) = window_nodes.iter().position(|w| w == e)
12289    {
12290        *e = Expr::Column(spg_sql::ast::ColumnName {
12291            qualifier: None,
12292            name: alloc::format!("__win_{idx}"),
12293        });
12294        return;
12295    }
12296    match e {
12297        Expr::Binary { lhs, rhs, .. } => {
12298            rewrite_window_to_columns(lhs, window_nodes);
12299            rewrite_window_to_columns(rhs, window_nodes);
12300        }
12301        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
12302            rewrite_window_to_columns(expr, window_nodes);
12303        }
12304        Expr::FunctionCall { args, .. } => {
12305            for a in args {
12306                rewrite_window_to_columns(a, window_nodes);
12307            }
12308        }
12309        Expr::Like { expr, pattern, .. } => {
12310            rewrite_window_to_columns(expr, window_nodes);
12311            rewrite_window_to_columns(pattern, window_nodes);
12312        }
12313        Expr::Extract { source, .. } => rewrite_window_to_columns(source, window_nodes),
12314        _ => {}
12315    }
12316}
12317
12318/// Total order over partition-key tuples. NULL sorts as the
12319/// lowest value (matches the `<` partial order's NULL-last
12320/// behaviour with `INFINITY` flipped).
12321fn partition_key_cmp(a: &[Value], b: &[Value]) -> core::cmp::Ordering {
12322    for (x, y) in a.iter().zip(b.iter()) {
12323        let c = value_cmp(x, y);
12324        if c != core::cmp::Ordering::Equal {
12325            return c;
12326        }
12327    }
12328    a.len().cmp(&b.len())
12329}
12330
12331fn order_key_cmp(a: &[(Value, bool)], b: &[(Value, bool)]) -> core::cmp::Ordering {
12332    for ((va, desc), (vb, _)) in a.iter().zip(b.iter()) {
12333        let c = value_cmp(va, vb);
12334        let c = if *desc { c.reverse() } else { c };
12335        if c != core::cmp::Ordering::Equal {
12336            return c;
12337        }
12338    }
12339    a.len().cmp(&b.len())
12340}
12341
12342/// v7.17.0 Phase 3.10 — true when the Value is one of the
12343/// integer-shaped variants `generate_series` accepts as a start
12344/// / stop / step component. Float / NUMERIC are rejected — PG's
12345/// `generate_series(numeric, numeric)` overload is out of v7.17
12346/// scope.
12347const fn value_is_integer(v: &Value) -> bool {
12348    matches!(v, Value::SmallInt(_) | Value::Int(_) | Value::BigInt(_))
12349}
12350
12351/// v7.17.0 Phase 3.10 — widen any integer-shaped Value to i64 for
12352/// the generate_series iteration loop. Non-integer inputs panic;
12353/// caller guards via `value_is_integer`.
12354const fn value_to_i64(v: &Value) -> i64 {
12355    match v {
12356        Value::SmallInt(n) => *n as i64,
12357        Value::Int(n) => *n as i64,
12358        Value::BigInt(n) => *n,
12359        _ => panic!("value_to_i64 called on non-integer Value"),
12360    }
12361}
12362
12363/// v7.17.0 Phase 3.10 — integer-mode generate_series materialiser.
12364/// Step direction follows the sign: positive step iterates upward
12365/// (stops when current > stop); negative iterates downward; zero
12366/// errors. Caller-facing row stream is `BigInt`-typed so a single
12367/// projection schema covers SmallInt / Int / BigInt callers.
12368fn generate_series_integers(
12369    start: i64,
12370    stop: i64,
12371    step: i64,
12372    cancel: &CancelToken<'_>,
12373) -> Result<alloc::vec::Vec<Row>, EngineError> {
12374    if step == 0 {
12375        return Err(EngineError::Unsupported(
12376            "generate_series(): step argument cannot be zero".into(),
12377        ));
12378    }
12379    let mut out = alloc::vec::Vec::new();
12380    let mut cur = start;
12381    // Hard cap to keep a runaway call from eating all memory. PG
12382    // has no such cap but does honour query timeout; SPG's cancel
12383    // token will fire too — this is a defense-in-depth backstop.
12384    const MAX_ROWS: usize = 10_000_000;
12385    loop {
12386        cancel.check()?;
12387        if step > 0 && cur > stop {
12388            break;
12389        }
12390        if step < 0 && cur < stop {
12391            break;
12392        }
12393        out.push(Row::new(alloc::vec![Value::BigInt(cur)]));
12394        if out.len() > MAX_ROWS {
12395            return Err(EngineError::Unsupported(alloc::format!(
12396                "generate_series(): exceeded {MAX_ROWS} rows; \
12397                 narrow start/stop or use a larger step"
12398            )));
12399        }
12400        cur = match cur.checked_add(step) {
12401            Some(n) => n,
12402            None => break,
12403        };
12404    }
12405    Ok(out)
12406}
12407
12408/// v7.17.0 Phase 3.10 — timestamp-mode generate_series. step is a
12409/// `Value::Interval { months, micros }` per the caller's guard;
12410/// each iteration adds the interval via `apply_binary_interval`
12411/// so month-shifting handles short-month rollover (PG semantics).
12412fn generate_series_timestamps(
12413    start: i64,
12414    stop: i64,
12415    step: Value,
12416    cancel: &CancelToken<'_>,
12417) -> Result<alloc::vec::Vec<Row>, EngineError> {
12418    let (months, micros) = match &step {
12419        Value::Interval { months, micros } => (*months, *micros),
12420        _ => unreachable!("caller guards step.is_interval"),
12421    };
12422    if months == 0 && micros == 0 {
12423        return Err(EngineError::Unsupported(
12424            "generate_series(): INTERVAL step cannot be zero".into(),
12425        ));
12426    }
12427    let ascending = months > 0 || micros > 0;
12428    let mut out = alloc::vec::Vec::new();
12429    let mut cur = Value::Timestamp(start);
12430    const MAX_ROWS: usize = 10_000_000;
12431    loop {
12432        cancel.check()?;
12433        let cur_t = match cur {
12434            Value::Timestamp(t) => t,
12435            _ => unreachable!("loop invariant: cur is Timestamp"),
12436        };
12437        if ascending && cur_t > stop {
12438            break;
12439        }
12440        if !ascending && cur_t < stop {
12441            break;
12442        }
12443        out.push(Row::new(alloc::vec![Value::Timestamp(cur_t)]));
12444        if out.len() > MAX_ROWS {
12445            return Err(EngineError::Unsupported(alloc::format!(
12446                "generate_series(): exceeded {MAX_ROWS} rows; \
12447                 narrow start/stop or use a larger step"
12448            )));
12449        }
12450        let next = eval::apply_binary_interval(
12451            spg_sql::ast::BinOp::Add,
12452            &cur,
12453            &Value::Interval { months, micros },
12454        )
12455        .map_err(EngineError::Eval)?;
12456        cur = match next {
12457            Some(v) => v,
12458            None => break,
12459        };
12460    }
12461    Ok(out)
12462}
12463
12464#[allow(clippy::match_same_arms)] // explicit arms per type document the supported pairs
12465fn value_cmp(a: &Value, b: &Value) -> core::cmp::Ordering {
12466    use core::cmp::Ordering;
12467    match (a, b) {
12468        (Value::Null, Value::Null) => Ordering::Equal,
12469        (Value::Null, _) => Ordering::Less,
12470        (_, Value::Null) => Ordering::Greater,
12471        (Value::Int(x), Value::Int(y)) => x.cmp(y),
12472        (Value::BigInt(x), Value::BigInt(y)) => x.cmp(y),
12473        (Value::SmallInt(x), Value::SmallInt(y)) => x.cmp(y),
12474        (Value::Text(x), Value::Text(y)) => x.cmp(y),
12475        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
12476        (Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
12477        (Value::Date(x), Value::Date(y)) => x.cmp(y),
12478        (Value::Timestamp(x), Value::Timestamp(y)) => x.cmp(y),
12479        // Cross-type compare: fall back to the debug rendering —
12480        // same-partition is the goal, exact order is irrelevant.
12481        _ => alloc::format!("{a:?}").cmp(&alloc::format!("{b:?}")),
12482    }
12483}
12484
12485/// Compute the window function's per-row output for one partition.
12486/// `slice` has (partition key, order key, original-row-index)
12487/// tuples already sorted by order key. `filtered_rows` is the
12488/// full row list indexed by original-row-index. `out_vals` is
12489/// the destination, also indexed by original-row-index.
12490#[allow(
12491    clippy::too_many_arguments,
12492    clippy::cast_possible_truncation,
12493    clippy::cast_possible_wrap,
12494    clippy::cast_precision_loss,
12495    clippy::cast_sign_loss,
12496    clippy::doc_markdown,
12497    clippy::too_many_lines,
12498    clippy::type_complexity,
12499    clippy::match_same_arms
12500)]
12501fn compute_window_partition(
12502    name: &str,
12503    args: &[Expr],
12504    ordered: bool,
12505    frame: Option<&WindowFrame>,
12506    null_treatment: spg_sql::ast::NullTreatment,
12507    slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)],
12508    filtered_rows: &[&Row],
12509    ctx: &EvalContext<'_>,
12510    out_vals: &mut [Value],
12511) -> Result<(), EngineError> {
12512    let ignore_nulls = matches!(null_treatment, spg_sql::ast::NullTreatment::Ignore);
12513    let lower = name.to_ascii_lowercase();
12514    match lower.as_str() {
12515        "row_number" => {
12516            for (rank, (_, _, idx)) in slice.iter().enumerate() {
12517                out_vals[*idx] = Value::BigInt((rank + 1) as i64);
12518            }
12519            Ok(())
12520        }
12521        "rank" => {
12522            let mut prev_key: Option<&[(Value, bool)]> = None;
12523            let mut current_rank: i64 = 1;
12524            for (i, (_, okey, idx)) in slice.iter().enumerate() {
12525                if let Some(p) = prev_key
12526                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
12527                {
12528                    current_rank = (i + 1) as i64;
12529                }
12530                if prev_key.is_none() {
12531                    current_rank = 1;
12532                }
12533                out_vals[*idx] = Value::BigInt(current_rank);
12534                prev_key = Some(okey.as_slice());
12535            }
12536            Ok(())
12537        }
12538        "dense_rank" => {
12539            let mut prev_key: Option<&[(Value, bool)]> = None;
12540            let mut current_rank: i64 = 0;
12541            for (_, okey, idx) in slice {
12542                if prev_key.is_none_or(|p| order_key_cmp(p, okey) != core::cmp::Ordering::Equal) {
12543                    current_rank += 1;
12544                }
12545                out_vals[*idx] = Value::BigInt(current_rank);
12546                prev_key = Some(okey.as_slice());
12547            }
12548            Ok(())
12549        }
12550        "sum" | "avg" | "min" | "max" | "count" | "count_star" => {
12551            // Pre-evaluate the function arg per row in the slice
12552            // (count_star has no arg).
12553            let arg_values: Vec<Value> = if lower == "count_star" || args.is_empty() {
12554                slice.iter().map(|_| Value::Null).collect()
12555            } else {
12556                slice
12557                    .iter()
12558                    .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12559                    .collect::<Result<_, _>>()
12560                    .map_err(EngineError::Eval)?
12561            };
12562            // v4.20: pick the effective frame. Explicit frame
12563            // overrides the implicit default (running for ordered,
12564            // whole-partition for unordered).
12565            let eff = effective_frame(frame, ordered)?;
12566            #[allow(clippy::needless_range_loop)]
12567            for i in 0..slice.len() {
12568                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
12569                let mut sum: f64 = 0.0;
12570                let mut count: i64 = 0;
12571                let mut min_v: Option<f64> = None;
12572                let mut max_v: Option<f64> = None;
12573                let mut row_count: i64 = 0;
12574                if lo <= hi {
12575                    for j in lo..=hi {
12576                        let v = &arg_values[j];
12577                        match lower.as_str() {
12578                            "count_star" => row_count += 1,
12579                            "count" => {
12580                                if !v.is_null() {
12581                                    count += 1;
12582                                }
12583                            }
12584                            _ => {
12585                                if let Some(x) = value_to_f64(v) {
12586                                    sum += x;
12587                                    count += 1;
12588                                    min_v = Some(min_v.map_or(x, |m| m.min(x)));
12589                                    max_v = Some(max_v.map_or(x, |m| m.max(x)));
12590                                }
12591                            }
12592                        }
12593                    }
12594                }
12595                let value = match lower.as_str() {
12596                    "count_star" => Value::BigInt(row_count),
12597                    "count" => Value::BigInt(count),
12598                    "sum" => Value::Float(sum),
12599                    "avg" => {
12600                        if count == 0 {
12601                            Value::Null
12602                        } else {
12603                            Value::Float(sum / count as f64)
12604                        }
12605                    }
12606                    "min" => min_v.map_or(Value::Null, Value::Float),
12607                    "max" => max_v.map_or(Value::Null, Value::Float),
12608                    _ => unreachable!(),
12609                };
12610                let (_, _, idx) = &slice[i];
12611                out_vals[*idx] = value;
12612            }
12613            Ok(())
12614        }
12615        "lag" | "lead" => {
12616            // lag(expr [, offset [, default]])
12617            // lead(expr [, offset [, default]])
12618            if args.is_empty() {
12619                return Err(EngineError::Unsupported(alloc::format!(
12620                    "{lower}() requires at least one argument"
12621                )));
12622            }
12623            let offset: i64 = if args.len() >= 2 {
12624                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
12625                    .map_err(EngineError::Eval)?;
12626                match v {
12627                    Value::SmallInt(n) => i64::from(n),
12628                    Value::Int(n) => i64::from(n),
12629                    Value::BigInt(n) => n,
12630                    _ => {
12631                        return Err(EngineError::Unsupported(alloc::format!(
12632                            "{lower}() offset must be integer"
12633                        )));
12634                    }
12635                }
12636            } else {
12637                1
12638            };
12639            let default: Value = if args.len() >= 3 {
12640                eval::eval_expr(&args[2], filtered_rows[slice[0].2], ctx)
12641                    .map_err(EngineError::Eval)?
12642            } else {
12643                Value::Null
12644            };
12645            let values: Vec<Value> = slice
12646                .iter()
12647                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12648                .collect::<Result<_, _>>()
12649                .map_err(EngineError::Eval)?;
12650            let n = slice.len();
12651            for (i, (_, _, idx)) in slice.iter().enumerate() {
12652                let signed_offset = if lower == "lag" { -offset } else { offset };
12653                let v = if ignore_nulls {
12654                    // v6.4.2 — IGNORE NULLS: walk in the offset direction
12655                    // skipping NULL values; the `offset`-th non-NULL
12656                    // encountered is the result.
12657                    let step: i64 = if signed_offset >= 0 { 1 } else { -1 };
12658                    let needed: i64 = signed_offset.abs();
12659                    if needed == 0 {
12660                        values[i].clone()
12661                    } else {
12662                        let mut j: i64 = i as i64;
12663                        let mut hits: i64 = 0;
12664                        let mut found: Option<Value> = None;
12665                        loop {
12666                            j += step;
12667                            if j < 0 || j >= n as i64 {
12668                                break;
12669                            }
12670                            #[allow(clippy::cast_sign_loss)]
12671                            let v = &values[j as usize];
12672                            if !v.is_null() {
12673                                hits += 1;
12674                                if hits == needed {
12675                                    found = Some(v.clone());
12676                                    break;
12677                                }
12678                            }
12679                        }
12680                        found.unwrap_or_else(|| default.clone())
12681                    }
12682                } else {
12683                    let target_signed = i64::try_from(i).unwrap_or(i64::MAX) + signed_offset;
12684                    if target_signed < 0 || target_signed >= i64::try_from(n).unwrap_or(i64::MAX) {
12685                        default.clone()
12686                    } else {
12687                        #[allow(clippy::cast_sign_loss)]
12688                        {
12689                            values[target_signed as usize].clone()
12690                        }
12691                    }
12692                };
12693                out_vals[*idx] = v;
12694            }
12695            Ok(())
12696        }
12697        "first_value" | "last_value" | "nth_value" => {
12698            if args.is_empty() {
12699                return Err(EngineError::Unsupported(alloc::format!(
12700                    "{lower}() requires at least one argument"
12701                )));
12702            }
12703            let values: Vec<Value> = slice
12704                .iter()
12705                .map(|(_, _, idx)| eval::eval_expr(&args[0], filtered_rows[*idx], ctx))
12706                .collect::<Result<_, _>>()
12707                .map_err(EngineError::Eval)?;
12708            let nth: usize = if lower == "nth_value" {
12709                if args.len() < 2 {
12710                    return Err(EngineError::Unsupported(
12711                        "nth_value() requires (expr, n)".into(),
12712                    ));
12713                }
12714                let v = eval::eval_expr(&args[1], filtered_rows[slice[0].2], ctx)
12715                    .map_err(EngineError::Eval)?;
12716                let raw = match v {
12717                    Value::SmallInt(n) => i64::from(n),
12718                    Value::Int(n) => i64::from(n),
12719                    Value::BigInt(n) => n,
12720                    _ => {
12721                        return Err(EngineError::Unsupported(
12722                            "nth_value() n must be integer".into(),
12723                        ));
12724                    }
12725                };
12726                if raw < 1 {
12727                    return Err(EngineError::Unsupported(
12728                        "nth_value() n must be >= 1".into(),
12729                    ));
12730                }
12731                #[allow(clippy::cast_sign_loss)]
12732                {
12733                    raw as usize
12734                }
12735            } else {
12736                0
12737            };
12738            let eff = effective_frame(frame, ordered)?;
12739            for i in 0..slice.len() {
12740                let (lo, hi) = frame_bounds_for_row(&eff, i, slice);
12741                let (_, _, idx) = &slice[i];
12742                let v = if lo > hi {
12743                    Value::Null
12744                } else if ignore_nulls && matches!(lower.as_str(), "first_value" | "last_value") {
12745                    // v6.4.2 — IGNORE NULLS: skip NULL cells when
12746                    // selecting the boundary value within the frame.
12747                    if lower == "first_value" {
12748                        (lo..=hi)
12749                            .find_map(|j| {
12750                                let v = &values[j];
12751                                (!v.is_null()).then(|| v.clone())
12752                            })
12753                            .unwrap_or(Value::Null)
12754                    } else {
12755                        (lo..=hi)
12756                            .rev()
12757                            .find_map(|j| {
12758                                let v = &values[j];
12759                                (!v.is_null()).then(|| v.clone())
12760                            })
12761                            .unwrap_or(Value::Null)
12762                    }
12763                } else {
12764                    match lower.as_str() {
12765                        "first_value" => values[lo].clone(),
12766                        "last_value" => values[hi].clone(),
12767                        "nth_value" => {
12768                            let pos = lo + nth - 1;
12769                            if pos > hi {
12770                                Value::Null
12771                            } else {
12772                                values[pos].clone()
12773                            }
12774                        }
12775                        _ => unreachable!(),
12776                    }
12777                };
12778                out_vals[*idx] = v;
12779            }
12780            Ok(())
12781        }
12782        "ntile" => {
12783            if args.is_empty() {
12784                return Err(EngineError::Unsupported(
12785                    "ntile(n) requires an integer argument".into(),
12786                ));
12787            }
12788            let v = eval::eval_expr(&args[0], filtered_rows[slice[0].2], ctx)
12789                .map_err(EngineError::Eval)?;
12790            let bucket_count: i64 = match v {
12791                Value::SmallInt(n) => i64::from(n),
12792                Value::Int(n) => i64::from(n),
12793                Value::BigInt(n) => n,
12794                _ => {
12795                    return Err(EngineError::Unsupported(
12796                        "ntile() argument must be integer".into(),
12797                    ));
12798                }
12799            };
12800            if bucket_count < 1 {
12801                return Err(EngineError::Unsupported(
12802                    "ntile() argument must be >= 1".into(),
12803                ));
12804            }
12805            #[allow(clippy::cast_sign_loss)]
12806            let buckets = bucket_count as usize;
12807            let n = slice.len();
12808            // Each bucket gets `base` rows; the first `extras` buckets
12809            // get one extra. PG semantics.
12810            let base = n / buckets;
12811            let extras = n % buckets;
12812            let mut bucket: usize = 1;
12813            let mut remaining_in_bucket = if extras > 0 { base + 1 } else { base };
12814            let mut buckets_with_extra_remaining = extras;
12815            for (_, _, idx) in slice {
12816                if remaining_in_bucket == 0 {
12817                    bucket += 1;
12818                    buckets_with_extra_remaining = buckets_with_extra_remaining.saturating_sub(1);
12819                    remaining_in_bucket = if buckets_with_extra_remaining > 0 {
12820                        base + 1
12821                    } else {
12822                        base
12823                    };
12824                    // Edge: if base==0 and extras==0, all rows fit;
12825                    // shouldn't reach here, but guard anyway.
12826                    if remaining_in_bucket == 0 {
12827                        remaining_in_bucket = 1;
12828                    }
12829                }
12830                out_vals[*idx] = Value::BigInt(i64::try_from(bucket).unwrap_or(i64::MAX));
12831                remaining_in_bucket -= 1;
12832            }
12833            Ok(())
12834        }
12835        "percent_rank" => {
12836            // (rank - 1) / (n - 1) where rank is the standard RANK().
12837            // Single-row partitions get 0.
12838            let n = slice.len();
12839            let mut prev_key: Option<&[(Value, bool)]> = None;
12840            let mut current_rank: i64 = 1;
12841            for (i, (_, okey, idx)) in slice.iter().enumerate() {
12842                if let Some(p) = prev_key
12843                    && order_key_cmp(p, okey) != core::cmp::Ordering::Equal
12844                {
12845                    current_rank = i64::try_from(i + 1).unwrap_or(i64::MAX);
12846                }
12847                if prev_key.is_none() {
12848                    current_rank = 1;
12849                }
12850                #[allow(clippy::cast_precision_loss)]
12851                let pr = if n <= 1 {
12852                    0.0
12853                } else {
12854                    (current_rank - 1) as f64 / (n - 1) as f64
12855                };
12856                out_vals[*idx] = Value::Float(pr);
12857                prev_key = Some(okey.as_slice());
12858            }
12859            Ok(())
12860        }
12861        "cume_dist" => {
12862            // # rows up to and including this row's peer group / n.
12863            let n = slice.len();
12864            // First pass: find peer-group-end rank for each row.
12865            for i in 0..slice.len() {
12866                let peer_end = peer_group_end(slice, i);
12867                #[allow(clippy::cast_precision_loss)]
12868                let cd = (peer_end + 1) as f64 / n as f64;
12869                let (_, _, idx) = &slice[i];
12870                out_vals[*idx] = Value::Float(cd);
12871            }
12872            Ok(())
12873        }
12874        other => Err(EngineError::Unsupported(alloc::format!(
12875            "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)"
12876        ))),
12877    }
12878}
12879
12880/// v4.20: resolve the user-provided frame down to a normalised
12881/// `(kind, start, end)`. `None` means default — derive from
12882/// `ordered`: ordered ⇒ RANGE UNBOUNDED PRECEDING AND CURRENT ROW,
12883/// unordered ⇒ ROWS UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
12884/// Single-bound shorthand (e.g. `ROWS 5 PRECEDING`) normalises
12885/// end → CURRENT ROW per the PG spec.
12886fn effective_frame(
12887    frame: Option<&WindowFrame>,
12888    ordered: bool,
12889) -> Result<(FrameKind, FrameBound, FrameBound), EngineError> {
12890    match frame {
12891        None => {
12892            if ordered {
12893                Ok((
12894                    FrameKind::Range,
12895                    FrameBound::UnboundedPreceding,
12896                    FrameBound::CurrentRow,
12897                ))
12898            } else {
12899                Ok((
12900                    FrameKind::Rows,
12901                    FrameBound::UnboundedPreceding,
12902                    FrameBound::UnboundedFollowing,
12903                ))
12904            }
12905        }
12906        Some(fr) => {
12907            let end = fr.end.clone().unwrap_or(FrameBound::CurrentRow);
12908            // Reject start > end (a few impossible combinations).
12909            if matches!(fr.start, FrameBound::UnboundedFollowing)
12910                || matches!(end, FrameBound::UnboundedPreceding)
12911            {
12912                return Err(EngineError::Unsupported(alloc::format!(
12913                    "invalid frame: start={:?} end={:?}",
12914                    fr.start,
12915                    end
12916                )));
12917            }
12918            // RANGE OFFSET PRECEDING / FOLLOWING needs value-typed
12919            // arithmetic on the ORDER BY key (e.g. `RANGE BETWEEN
12920            // INTERVAL '1 day' PRECEDING AND CURRENT ROW`). Not
12921            // implemented in v4.20.
12922            if fr.kind == FrameKind::Range
12923                && (matches!(
12924                    fr.start,
12925                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
12926                ) || matches!(
12927                    end,
12928                    FrameBound::OffsetPreceding(_) | FrameBound::OffsetFollowing(_)
12929                ))
12930            {
12931                return Err(EngineError::Unsupported(
12932                    "RANGE with explicit offset bounds is not supported (v4.20: only UNBOUNDED / CURRENT ROW for RANGE)".into(),
12933                ));
12934            }
12935            Ok((fr.kind, fr.start.clone(), end))
12936        }
12937    }
12938}
12939
12940/// Compute `(lo, hi)` row-index bounds inside the partition slice
12941/// for the row at position `i`. Inclusive, clamped to
12942/// `[0, slice.len()-1]`. Empty result if `lo > hi`.
12943#[allow(clippy::type_complexity)]
12944fn frame_bounds_for_row(
12945    eff: &(FrameKind, FrameBound, FrameBound),
12946    i: usize,
12947    slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)],
12948) -> (usize, usize) {
12949    let (kind, start, end) = eff;
12950    let n = slice.len();
12951    let last = n.saturating_sub(1);
12952    let (mut lo, mut hi) = match kind {
12953        FrameKind::Rows => {
12954            let lo = match start {
12955                FrameBound::UnboundedPreceding => 0,
12956                FrameBound::OffsetPreceding(k) => {
12957                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
12958                    i.saturating_sub(k)
12959                }
12960                FrameBound::CurrentRow => i,
12961                FrameBound::OffsetFollowing(k) => {
12962                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
12963                    i.saturating_add(k).min(last)
12964                }
12965                FrameBound::UnboundedFollowing => last,
12966            };
12967            let hi = match end {
12968                FrameBound::UnboundedPreceding => 0,
12969                FrameBound::OffsetPreceding(k) => {
12970                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
12971                    i.saturating_sub(k)
12972                }
12973                FrameBound::CurrentRow => i,
12974                FrameBound::OffsetFollowing(k) => {
12975                    let k = usize::try_from(*k).unwrap_or(usize::MAX);
12976                    i.saturating_add(k).min(last)
12977                }
12978                FrameBound::UnboundedFollowing => last,
12979            };
12980            (lo, hi)
12981        }
12982        FrameKind::Range => {
12983            // RANGE bounds are peer-aware. With only UNBOUNDED and
12984            // CURRENT ROW supported (rejected at effective_frame for
12985            // explicit offsets), the start/end map to the
12986            // partition's full extent at the same-order-key peer
12987            // group boundary.
12988            let lo = match start {
12989                FrameBound::UnboundedPreceding => 0,
12990                FrameBound::CurrentRow => peer_group_start(slice, i),
12991                FrameBound::UnboundedFollowing => last,
12992                _ => unreachable!("offset bounds rejected for RANGE"),
12993            };
12994            let hi = match end {
12995                FrameBound::UnboundedPreceding => 0,
12996                FrameBound::CurrentRow => peer_group_end(slice, i),
12997                FrameBound::UnboundedFollowing => last,
12998                _ => unreachable!("offset bounds rejected for RANGE"),
12999            };
13000            (lo, hi)
13001        }
13002    };
13003    if hi >= n {
13004        hi = last;
13005    }
13006    if lo >= n {
13007        lo = last;
13008    }
13009    (lo, hi)
13010}
13011
13012/// Find the inclusive index of the first row with the same ORDER
13013/// BY key as `slice[i]`. Slice is already sorted by partition then
13014/// order, so peers are contiguous.
13015#[allow(clippy::type_complexity)]
13016fn peer_group_start(slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)], i: usize) -> usize {
13017    let key = &slice[i].1;
13018    let mut j = i;
13019    while j > 0 && order_key_cmp(&slice[j - 1].1, key) == core::cmp::Ordering::Equal {
13020        j -= 1;
13021    }
13022    j
13023}
13024
13025/// Find the inclusive index of the last row with the same ORDER
13026/// BY key as `slice[i]`.
13027#[allow(clippy::type_complexity)]
13028fn peer_group_end(slice: &[(Vec<Value>, Vec<(Value, bool)>, usize)], i: usize) -> usize {
13029    let key = &slice[i].1;
13030    let mut j = i;
13031    while j + 1 < slice.len() && order_key_cmp(&slice[j + 1].1, key) == core::cmp::Ordering::Equal {
13032        j += 1;
13033    }
13034    j
13035}
13036
13037fn value_to_f64(v: &Value) -> Option<f64> {
13038    match v {
13039        Value::SmallInt(n) => Some(f64::from(*n)),
13040        Value::Int(n) => Some(f64::from(*n)),
13041        #[allow(clippy::cast_precision_loss)]
13042        Value::BigInt(n) => Some(*n as f64),
13043        Value::Float(x) => Some(*x),
13044        _ => None,
13045    }
13046}
13047
13048/// Quick scan for any subquery-bearing node in a SELECT's WHERE /
13049/// projection / `order_by` — saves cloning the AST when there are
13050/// none (the common case).
13051fn expr_tree_has_subquery(stmt: &SelectStatement) -> bool {
13052    let mut any = false;
13053    for item in &stmt.items {
13054        if let SelectItem::Expr { expr, .. } = item {
13055            any = any || expr_has_subquery(expr);
13056        }
13057    }
13058    if let Some(w) = &stmt.where_ {
13059        any = any || expr_has_subquery(w);
13060    }
13061    if let Some(h) = &stmt.having {
13062        any = any || expr_has_subquery(h);
13063    }
13064    for o in &stmt.order_by {
13065        any = any || expr_has_subquery(&o.expr);
13066    }
13067    for (_, peer) in &stmt.unions {
13068        any = any || expr_tree_has_subquery(peer);
13069    }
13070    any
13071}
13072
13073fn expr_has_subquery(e: &Expr) -> bool {
13074    match e {
13075        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => true,
13076        Expr::Binary { lhs, rhs, .. } => expr_has_subquery(lhs) || expr_has_subquery(rhs),
13077        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13078            expr_has_subquery(expr)
13079        }
13080        Expr::FunctionCall { args, .. } => args.iter().any(expr_has_subquery),
13081        Expr::Like { expr, pattern, .. } => expr_has_subquery(expr) || expr_has_subquery(pattern),
13082        Expr::Extract { source, .. } => expr_has_subquery(source),
13083        Expr::WindowFunction {
13084            args,
13085            partition_by,
13086            order_by,
13087            ..
13088        } => {
13089            args.iter().any(expr_has_subquery)
13090                || partition_by.iter().any(expr_has_subquery)
13091                || order_by.iter().any(|(e, _)| expr_has_subquery(e))
13092        }
13093        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => false,
13094        Expr::Array(items) => items.iter().any(expr_has_subquery),
13095        Expr::ArraySubscript { target, index } => {
13096            expr_has_subquery(target) || expr_has_subquery(index)
13097        }
13098        Expr::AnyAll { expr, array, .. } => expr_has_subquery(expr) || expr_has_subquery(array),
13099        Expr::Case {
13100            operand,
13101            branches,
13102            else_branch,
13103        } => {
13104            operand.as_deref().is_some_and(expr_has_subquery)
13105                || branches
13106                    .iter()
13107                    .any(|(w, t)| expr_has_subquery(w) || expr_has_subquery(t))
13108                || else_branch.as_deref().is_some_and(expr_has_subquery)
13109        }
13110    }
13111}
13112
13113/// v4.10 helper: materialise a runtime `Value` back into an AST
13114/// `Expr::Literal` for the subquery-rewrite path. Supports the
13115/// types `Literal` can represent (Integer / Float / Text / Bool /
13116/// Null). Date / Timestamp / Numeric / Vector / Interval / JSON
13117/// would lose precision through Literal and aren't supported in
13118/// uncorrelated-subquery results; they error with a clear hint.
13119fn value_to_literal_expr(v: Value) -> Result<Expr, EngineError> {
13120    let lit = match v {
13121        Value::Null => Literal::Null,
13122        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13123        Value::Int(n) => Literal::Integer(i64::from(n)),
13124        Value::BigInt(n) => Literal::Integer(n),
13125        Value::Float(x) => Literal::Float(x),
13126        Value::Text(s) | Value::Json(s) => Literal::String(s),
13127        Value::Bool(b) => Literal::Bool(b),
13128        other => {
13129            return Err(EngineError::Unsupported(alloc::format!(
13130                "subquery result type {:?} not yet materialisable; cast to text or integer in the inner SELECT",
13131                other.data_type()
13132            )));
13133        }
13134    };
13135    Ok(Expr::Literal(lit))
13136}
13137
13138/// v7.13.0 — wider helper used by `INSERT … SELECT` (mailrs
13139/// round-5 G4). Covers the most common `Value` variants. Types
13140/// that need lossy textual round-trip (BYTEA, arrays, ts*)
13141/// surface as an Unsupported error so the caller can add a cast
13142/// in the inner SELECT.
13143fn value_to_literal_expr_permissive(v: Value) -> Result<Expr, EngineError> {
13144    let lit = match v {
13145        Value::Null => Literal::Null,
13146        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13147        Value::Int(n) => Literal::Integer(i64::from(n)),
13148        Value::BigInt(n) => Literal::Integer(n),
13149        Value::Float(x) => Literal::Float(x),
13150        Value::Text(s) | Value::Json(s) => Literal::String(s),
13151        Value::Bool(b) => Literal::Bool(b),
13152        Value::Vector(xs) => Literal::Vector(xs),
13153        // Date / Timestamp / Timestamptz / Numeric round-trip
13154        // through a TEXT literal that `coerce_value` re-parses
13155        // against the target column type.
13156        Value::Date(days) => {
13157            let micros = (i64::from(days)) * 86_400_000_000;
13158            Literal::String(format_timestamp_micros_as_date(micros))
13159        }
13160        Value::Timestamp(us) => Literal::String(format_timestamp_micros(us)),
13161        Value::Numeric { scaled, scale } => Literal::String(format_numeric(scaled, scale)),
13162        other => {
13163            return Err(EngineError::Unsupported(alloc::format!(
13164                "INSERT … SELECT cannot materialise value of type {:?}; \
13165                 add an explicit CAST in the inner SELECT",
13166                other.data_type()
13167            )));
13168        }
13169    };
13170    Ok(Expr::Literal(lit))
13171}
13172
13173fn format_timestamp_micros(us: i64) -> String {
13174    // Same Y/M/D split used by the wire layer; epoch-relative.
13175    let days = us.div_euclid(86_400_000_000);
13176    let intra_day = us.rem_euclid(86_400_000_000);
13177    let date = format_timestamp_micros_as_date(days * 86_400_000_000);
13178    let secs = intra_day / 1_000_000;
13179    let us_rem = intra_day % 1_000_000;
13180    let h = (secs / 3600) % 24;
13181    let m = (secs / 60) % 60;
13182    let s = secs % 60;
13183    if us_rem == 0 {
13184        alloc::format!("{date} {h:02}:{m:02}:{s:02}")
13185    } else {
13186        alloc::format!("{date} {h:02}:{m:02}:{s:02}.{us_rem:06}")
13187    }
13188}
13189
13190fn format_timestamp_micros_as_date(us: i64) -> String {
13191    // Days since 1970-01-01 → calendar Y-M-D via the proleptic
13192    // Gregorian conversion used by spg-engine's date helpers.
13193    let days = us.div_euclid(86_400_000_000);
13194    // 1970-01-01 = JDN 2440588.
13195    let jdn = days + 2_440_588;
13196    let (y, mo, d) = jdn_to_ymd(jdn);
13197    alloc::format!("{y:04}-{mo:02}-{d:02}")
13198}
13199
13200fn jdn_to_ymd(jdn: i64) -> (i64, u32, u32) {
13201    // Fliegel & Van Flandern (1968) — works for all positive JDNs.
13202    let l = jdn + 68569;
13203    let n = (4 * l) / 146_097;
13204    let l = l - (146_097 * n + 3) / 4;
13205    let i = (4000 * (l + 1)) / 1_461_001;
13206    let l = l - (1461 * i) / 4 + 31;
13207    let j = (80 * l) / 2447;
13208    let day = (l - (2447 * j) / 80) as u32;
13209    let l = j / 11;
13210    let month = (j + 2 - 12 * l) as u32;
13211    let year = 100 * (n - 49) + i + l;
13212    (year, month, day)
13213}
13214
13215fn format_numeric(scaled: i128, scale: u8) -> String {
13216    if scale == 0 {
13217        return alloc::format!("{scaled}");
13218    }
13219    let abs = scaled.unsigned_abs();
13220    let divisor = 10u128.pow(u32::from(scale));
13221    let whole = abs / divisor;
13222    let frac = abs % divisor;
13223    let sign = if scaled < 0 { "-" } else { "" };
13224    alloc::format!("{sign}{whole}.{frac:0width$}", width = usize::from(scale))
13225}
13226
13227/// v6.1.1 — walk the prepared `Statement` AST and replace every
13228/// `Expr::Placeholder(n)` with `Expr::Literal(value_to_literal(
13229/// params[n-1]))`. The dispatch downstream sees a `Statement`
13230/// indistinguishable from a simple-query parse, so the exec path
13231/// stays unchanged.
13232///
13233/// Errors fall into one shape: a `$N` references past the bound
13234/// `params.len()`. Out-of-range happens when the Bind didn't
13235/// supply enough values; pgwire surfaces this as a protocol error
13236/// to the client.
13237/// v7.15.0 — rewrite every (potentially-qualified) column
13238/// identifier matching `old` to `new` in a stored SQL source
13239/// string. Used by `ALTER TABLE … RENAME COLUMN` to patch
13240/// CHECK predicate sources, partial-index predicate sources,
13241/// and runtime DEFAULT expression sources before they get
13242/// re-parsed on the next INSERT/UPDATE.
13243///
13244/// Round-trips through the parser, so the rewritten output is
13245/// the canonical Display form (matches what the engine stores
13246/// for fresh predicates). If the source doesn't parse, surfaces
13247/// the parse error — the invariant that stored predicates are
13248/// in canonical Display form means a parse failure here is a
13249/// real bug, not a user mistake to swallow.
13250fn rewrite_column_in_source(
13251    src: &str,
13252    old: &str,
13253    new: &str,
13254) -> Result<alloc::string::String, EngineError> {
13255    let mut expr = spg_sql::parser::parse_expression(src).map_err(|e| {
13256        EngineError::Unsupported(alloc::format!(
13257            "ALTER TABLE RENAME COLUMN: stored predicate source {src:?} \
13258             failed to parse for rewrite ({e})"
13259        ))
13260    })?;
13261    rewrite_column_in_expr(&mut expr, old, new);
13262    Ok(alloc::format!("{expr}"))
13263}
13264
13265/// v7.15.0 — Expr walker that swaps `Expr::Column { name: old, .. }`
13266/// for `Expr::Column { name: new, .. }`. Qualifier is preserved
13267/// (e.g. `t.old` → `t.new`); a foreign-table qualifier still
13268/// gets rewritten because the AST has no way to tell us this
13269/// predicate is on table T versus table T2 — predicate sources
13270/// in SPG are always scoped to the owning table, so any
13271/// qualifier present is either redundant or wrong.
13272fn rewrite_column_in_expr(e: &mut Expr, old: &str, new: &str) {
13273    match e {
13274        Expr::Column(c) => {
13275            if c.name.eq_ignore_ascii_case(old) {
13276                c.name = new.to_string();
13277            }
13278        }
13279        Expr::Binary { lhs, rhs, .. } => {
13280            rewrite_column_in_expr(lhs, old, new);
13281            rewrite_column_in_expr(rhs, old, new);
13282        }
13283        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13284            rewrite_column_in_expr(expr, old, new);
13285        }
13286        Expr::FunctionCall { args, .. } => {
13287            for a in args {
13288                rewrite_column_in_expr(a, old, new);
13289            }
13290        }
13291        Expr::Like { expr, pattern, .. } => {
13292            rewrite_column_in_expr(expr, old, new);
13293            rewrite_column_in_expr(pattern, old, new);
13294        }
13295        Expr::Extract { source, .. } => rewrite_column_in_expr(source, old, new),
13296        Expr::WindowFunction {
13297            args,
13298            partition_by,
13299            order_by,
13300            ..
13301        } => {
13302            for a in args {
13303                rewrite_column_in_expr(a, old, new);
13304            }
13305            for p in partition_by {
13306                rewrite_column_in_expr(p, old, new);
13307            }
13308            for (o, _) in order_by {
13309                rewrite_column_in_expr(o, old, new);
13310            }
13311        }
13312        Expr::Array(items) => {
13313            for elem in items {
13314                rewrite_column_in_expr(elem, old, new);
13315            }
13316        }
13317        Expr::ArraySubscript { target, index } => {
13318            rewrite_column_in_expr(target, old, new);
13319            rewrite_column_in_expr(index, old, new);
13320        }
13321        Expr::AnyAll { expr, array, .. } => {
13322            rewrite_column_in_expr(expr, old, new);
13323            rewrite_column_in_expr(array, old, new);
13324        }
13325        Expr::Case {
13326            operand,
13327            branches,
13328            else_branch,
13329        } => {
13330            if let Some(o) = operand {
13331                rewrite_column_in_expr(o, old, new);
13332            }
13333            for (w, t) in branches {
13334                rewrite_column_in_expr(w, old, new);
13335                rewrite_column_in_expr(t, old, new);
13336            }
13337            if let Some(e) = else_branch {
13338                rewrite_column_in_expr(e, old, new);
13339            }
13340        }
13341        // Stored predicate sources never contain subqueries —
13342        // CHECK / partial-index / runtime_default are all scalar.
13343        // If a future feature changes that, recurse here.
13344        Expr::ScalarSubquery(_) | Expr::Exists { .. } | Expr::InSubquery { .. } => {}
13345        Expr::Literal(_) | Expr::Placeholder(_) => {}
13346    }
13347}
13348
13349/// v7.16.0 — walks a parsed statement and replaces every
13350/// `Expr::Placeholder(N)` with the corresponding `params[N-1]`
13351/// re-encoded as an `Expr::Literal`. Used internally by
13352/// `Engine::execute_prepared` AND surfaced for the spg-embedded
13353/// WAL path (which needs the bind-final AST so replay sees a
13354/// simple-query-shaped statement, not a `$1`-shaped one). Errors
13355/// when a placeholder references an index past the params slice.
13356pub fn substitute_placeholders(stmt: &mut Statement, params: &[Value]) -> Result<(), EngineError> {
13357    match stmt {
13358        Statement::Select(s) => substitute_select(s, params)?,
13359        Statement::Insert(ins) => {
13360            for row in &mut ins.rows {
13361                for e in row {
13362                    substitute_expr(e, params)?;
13363                }
13364            }
13365        }
13366        Statement::Update(u) => {
13367            for (_, e) in &mut u.assignments {
13368                substitute_expr(e, params)?;
13369            }
13370            if let Some(w) = &mut u.where_ {
13371                substitute_expr(w, params)?;
13372            }
13373        }
13374        Statement::Delete(d) => {
13375            if let Some(w) = &mut d.where_ {
13376                substitute_expr(w, params)?;
13377            }
13378        }
13379        Statement::Explain(e) => substitute_select(&mut e.inner, params)?,
13380        // Other statements (CREATE / BEGIN / SHOW / …) have no
13381        // expression slots; no walk needed.
13382        _ => {}
13383    }
13384    Ok(())
13385}
13386
13387fn substitute_select(s: &mut SelectStatement, params: &[Value]) -> Result<(), EngineError> {
13388    for item in &mut s.items {
13389        if let SelectItem::Expr { expr, .. } = item {
13390            substitute_expr(expr, params)?;
13391        }
13392    }
13393    if let Some(w) = &mut s.where_ {
13394        substitute_expr(w, params)?;
13395    }
13396    if let Some(gs) = &mut s.group_by {
13397        for g in gs {
13398            substitute_expr(g, params)?;
13399        }
13400    }
13401    if let Some(h) = &mut s.having {
13402        substitute_expr(h, params)?;
13403    }
13404    for o in &mut s.order_by {
13405        substitute_expr(&mut o.expr, params)?;
13406    }
13407    for (_, peer) in &mut s.unions {
13408        substitute_select(peer, params)?;
13409    }
13410    // v7.9.24 — LIMIT $N / OFFSET $N placeholder resolution.
13411    // mailrs H2. After this pass each LIMIT/OFFSET that was a
13412    // Placeholder is rewritten to Literal so the existing
13413    // `LimitExpr::as_literal` path consumes a concrete u32.
13414    if let Some(le) = s.limit {
13415        s.limit = Some(resolve_limit_placeholder(le, params)?);
13416    }
13417    if let Some(le) = s.offset {
13418        s.offset = Some(resolve_limit_placeholder(le, params)?);
13419    }
13420    Ok(())
13421}
13422
13423fn resolve_limit_placeholder(
13424    le: spg_sql::ast::LimitExpr,
13425    params: &[Value],
13426) -> Result<spg_sql::ast::LimitExpr, EngineError> {
13427    use spg_sql::ast::LimitExpr;
13428    match le {
13429        LimitExpr::Literal(_) => Ok(le),
13430        LimitExpr::Placeholder(n) => {
13431            let idx = usize::from(n).saturating_sub(1);
13432            let v = params.get(idx).ok_or_else(|| {
13433                EngineError::Eval(EvalError::PlaceholderOutOfRange {
13434                    n,
13435                    bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
13436                })
13437            })?;
13438            let int = match v {
13439                Value::SmallInt(x) => Some(i64::from(*x)),
13440                Value::Int(x) => Some(i64::from(*x)),
13441                Value::BigInt(x) => Some(*x),
13442                _ => None,
13443            }
13444            .ok_or_else(|| {
13445                EngineError::Unsupported(alloc::format!(
13446                    "LIMIT/OFFSET ${n} bound to non-integer {v:?}"
13447                ))
13448            })?;
13449            if int < 0 {
13450                return Err(EngineError::Unsupported(alloc::format!(
13451                    "LIMIT/OFFSET ${n} bound to negative value {int}"
13452                )));
13453            }
13454            let bounded = u32::try_from(int).map_err(|_| {
13455                EngineError::Unsupported(alloc::format!(
13456                    "LIMIT/OFFSET ${n} value {int} exceeds u32 range"
13457                ))
13458            })?;
13459            Ok(LimitExpr::Literal(bounded))
13460        }
13461    }
13462}
13463
13464fn substitute_expr(e: &mut Expr, params: &[Value]) -> Result<(), EngineError> {
13465    if let Expr::Placeholder(n) = e {
13466        let idx = usize::from(*n).saturating_sub(1);
13467        let v = params.get(idx).ok_or_else(|| {
13468            EngineError::Eval(EvalError::PlaceholderOutOfRange {
13469                n: *n,
13470                bound: u16::try_from(params.len()).unwrap_or(u16::MAX),
13471            })
13472        })?;
13473        *e = Expr::Literal(value_to_literal(v.clone()));
13474        return Ok(());
13475    }
13476    match e {
13477        Expr::Binary { lhs, rhs, .. } => {
13478            substitute_expr(lhs, params)?;
13479            substitute_expr(rhs, params)?;
13480        }
13481        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13482            substitute_expr(expr, params)?;
13483        }
13484        Expr::FunctionCall { args, .. } => {
13485            for a in args {
13486                substitute_expr(a, params)?;
13487            }
13488        }
13489        Expr::Like { expr, pattern, .. } => {
13490            substitute_expr(expr, params)?;
13491            substitute_expr(pattern, params)?;
13492        }
13493        Expr::Extract { source, .. } => substitute_expr(source, params)?,
13494        Expr::ScalarSubquery(s) => substitute_select(s, params)?,
13495        Expr::Exists { subquery, .. } => substitute_select(subquery, params)?,
13496        Expr::InSubquery { expr, subquery, .. } => {
13497            substitute_expr(expr, params)?;
13498            substitute_select(subquery, params)?;
13499        }
13500        Expr::WindowFunction {
13501            args,
13502            partition_by,
13503            order_by,
13504            ..
13505        } => {
13506            for a in args {
13507                substitute_expr(a, params)?;
13508            }
13509            for p in partition_by {
13510                substitute_expr(p, params)?;
13511            }
13512            for (e, _) in order_by {
13513                substitute_expr(e, params)?;
13514            }
13515        }
13516        Expr::Literal(_) | Expr::Column(_) => {}
13517        // Already handled above.
13518        Expr::Placeholder(_) => unreachable!("Placeholder handled at top of fn"),
13519        Expr::Array(items) => {
13520            for elem in items {
13521                substitute_expr(elem, params)?;
13522            }
13523        }
13524        Expr::ArraySubscript { target, index } => {
13525            substitute_expr(target, params)?;
13526            substitute_expr(index, params)?;
13527        }
13528        Expr::AnyAll { expr, array, .. } => {
13529            substitute_expr(expr, params)?;
13530            substitute_expr(array, params)?;
13531        }
13532        Expr::Case {
13533            operand,
13534            branches,
13535            else_branch,
13536        } => {
13537            if let Some(o) = operand {
13538                substitute_expr(o, params)?;
13539            }
13540            for (w, t) in branches {
13541                substitute_expr(w, params)?;
13542                substitute_expr(t, params)?;
13543            }
13544            if let Some(e) = else_branch {
13545                substitute_expr(e, params)?;
13546            }
13547        }
13548    }
13549    Ok(())
13550}
13551
13552/// v6.1.1 — convert a runtime `Value` into the closest matching
13553/// `Literal` for the substitute walker. Lossless for the simple
13554/// scalars (Int / Float / Text / Bool); Numeric / Date / Timestamp
13555/// / Json / Interval render as their canonical text form so the
13556/// downstream coerce_value can re-parse against the target column
13557/// type. SQ8 / HalfVector cells are NOT expected as bind params;
13558/// pgwire's Bind decodes vector params to the f32 representation
13559/// before they reach this helper.
13560/// v6.2.0 — total ordering on `Value`s used by ANALYZE to sort a
13561/// column's non-NULL sample before histogram building. Cross-type
13562/// pairs (Int vs Float, Date vs Timestamp, …) compare via the
13563/// same widening the eval-side `compare` operator uses; everything
13564/// else (the genuinely-incompatible pairs) falls back to ordering
13565/// by canonical string form so the sort is still total + stable.
13566/// Vector / SQ8 / Half / Json / Numeric / Interval values reach
13567/// here only via the string-fallback path because vector columns
13568/// are filtered out upstream.
13569fn sort_values_for_histogram(a: &Value, b: &Value) -> core::cmp::Ordering {
13570    use core::cmp::Ordering;
13571    match (a, b) {
13572        (Value::SmallInt(a), Value::SmallInt(b)) => a.cmp(b),
13573        (Value::Int(a), Value::Int(b)) => a.cmp(b),
13574        (Value::BigInt(a), Value::BigInt(b)) => a.cmp(b),
13575        (Value::SmallInt(a), Value::Int(b)) => i32::from(*a).cmp(b),
13576        (Value::Int(a), Value::SmallInt(b)) => a.cmp(&i32::from(*b)),
13577        (Value::Int(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
13578        (Value::BigInt(a), Value::Int(b)) => a.cmp(&i64::from(*b)),
13579        (Value::SmallInt(a), Value::BigInt(b)) => i64::from(*a).cmp(b),
13580        (Value::BigInt(a), Value::SmallInt(b)) => a.cmp(&i64::from(*b)),
13581        (Value::Float(a), Value::Float(b)) => a.partial_cmp(b).unwrap_or(Ordering::Equal),
13582        (Value::Text(a), Value::Text(b)) | (Value::Json(a), Value::Json(b)) => a.cmp(b),
13583        (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
13584        (Value::Date(a), Value::Date(b)) => a.cmp(b),
13585        (Value::Timestamp(a), Value::Timestamp(b)) => a.cmp(b),
13586        // Mixed numeric/float — widen to f64 and compare.
13587        (Value::SmallInt(n), Value::Float(x)) => {
13588            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
13589        }
13590        (Value::Float(x), Value::SmallInt(n)) => {
13591            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
13592        }
13593        (Value::Int(n), Value::Float(x)) => {
13594            (f64::from(*n)).partial_cmp(x).unwrap_or(Ordering::Equal)
13595        }
13596        (Value::Float(x), Value::Int(n)) => {
13597            x.partial_cmp(&f64::from(*n)).unwrap_or(Ordering::Equal)
13598        }
13599        (Value::BigInt(n), Value::Float(x)) => {
13600            #[allow(clippy::cast_precision_loss)]
13601            let nf = *n as f64;
13602            nf.partial_cmp(x).unwrap_or(Ordering::Equal)
13603        }
13604        (Value::Float(x), Value::BigInt(n)) => {
13605            #[allow(clippy::cast_precision_loss)]
13606            let nf = *n as f64;
13607            x.partial_cmp(&nf).unwrap_or(Ordering::Equal)
13608        }
13609        // Cross-type fallback: lexicographic on canonical form.
13610        // Total + stable so the sort is well-defined.
13611        _ => canonical_value_repr(a).cmp(&canonical_value_repr(b)),
13612    }
13613}
13614
13615/// v6.2.0 — render the histogram bounds list as a `[v0, v1, ...]`
13616/// string for the `spg_statistic.histogram_bounds` column. Values
13617/// containing `,` or `[` / `]` are JSON-style escaped so the
13618/// rendering round-trips through a future parser; v6.2.0 only
13619/// uses the rendered form for human consumption, so the escaping
13620/// is conservative.
13621fn render_histogram_bounds(bounds: &[alloc::string::String]) -> alloc::string::String {
13622    let mut out = alloc::string::String::with_capacity(bounds.len() * 8 + 2);
13623    out.push('[');
13624    for (i, b) in bounds.iter().enumerate() {
13625        if i > 0 {
13626            out.push_str(", ");
13627        }
13628        let needs_quote = b.contains([',', '[', ']', '"']) || b.is_empty();
13629        if needs_quote {
13630            out.push('"');
13631            for ch in b.chars() {
13632                if ch == '"' || ch == '\\' {
13633                    out.push('\\');
13634                }
13635                out.push(ch);
13636            }
13637            out.push('"');
13638        } else {
13639            out.push_str(b);
13640        }
13641    }
13642    out.push(']');
13643    out
13644}
13645
13646/// v6.2.0 — canonical textual form of a `Value` for histogram
13647/// bound storage. Strings used by ANALYZE for sort + bound output.
13648/// INT / BIGINT → decimal; FLOAT → shortest-round-trip via
13649/// `{:?}`; TEXT pass-through; BOOL → `t` / `f`; DATE / TIMESTAMP →
13650/// the same form `format_date` / `format_timestamp` produce for
13651/// SQL Display. Vector / SQ8 / Half / Json / Numeric / Interval
13652/// reach this only via a non-Vector column (vector columns are
13653/// skipped upstream); they fall back to a Debug-derived form so
13654/// stats still serialise without crashing.
13655pub(crate) fn canonical_value_repr(v: &Value) -> alloc::string::String {
13656    match v {
13657        Value::Null => "NULL".to_string(),
13658        Value::SmallInt(n) => alloc::format!("{n}"),
13659        Value::Int(n) => alloc::format!("{n}"),
13660        Value::BigInt(n) => alloc::format!("{n}"),
13661        Value::Float(x) => alloc::format!("{x:?}"),
13662        Value::Text(s) | Value::Json(s) => s.clone(),
13663        Value::Bool(b) => if *b { "t" } else { "f" }.to_string(),
13664        Value::Date(d) => eval::format_date(*d),
13665        Value::Timestamp(t) => eval::format_timestamp(*t),
13666        // v7.17.0 Phase 3.P0-32 — PG TIME canonical text form.
13667        Value::Time(us) => eval::format_time(*us),
13668        // v7.17.0 Phase 3.P0-33 — MySQL YEAR 4-digit zero-padded.
13669        Value::Year(y) => alloc::format!("{y:04}"),
13670        // v7.17.0 Phase 3.P0-34 — PG TIMETZ canonical text form.
13671        Value::TimeTz { us, offset_secs } => eval::format_timetz(*us, *offset_secs),
13672        // v7.17.0 Phase 3.P0-35 — PG MONEY canonical en_US text form.
13673        Value::Money(c) => eval::format_money(*c),
13674        // v7.17.0 Phase 3.P0-38 — PG range canonical text form.
13675        v @ Value::Range { .. } => format_range_str(v),
13676        // v7.17.0 Phase 3.P0-39 — PG hstore canonical text form.
13677        Value::Hstore(pairs) => format_hstore_str(pairs),
13678        // v7.17.0 Phase 3.P0-40 — 2D array canonical text form.
13679        Value::IntArray2D(rows) => format_int_2d_text(rows),
13680        Value::BigIntArray2D(rows) => format_bigint_2d_text(rows),
13681        Value::TextArray2D(rows) => format_text_2d_text(rows),
13682        Value::Interval { months, micros } => eval::format_interval(*months, *micros),
13683        Value::Numeric { scaled, scale } => eval::format_numeric(*scaled, *scale),
13684        Value::Vector(_) | Value::Sq8Vector(_) | Value::HalfVector(_) => {
13685            // Unreachable in practice (vector columns are filtered
13686            // out before this). Defensive fallback so a future
13687            // vector-stats path doesn't crash.
13688            alloc::format!("{v:?}")
13689        }
13690        // v7.5.0 — Value is #[non_exhaustive] for downstream
13691        // forward-compat. Future variants fall through to Debug
13692        // form here (same shape as the vector fallback above).
13693        _ => alloc::format!("{v:?}"),
13694    }
13695}
13696
13697/// v6.2.0 — true for engine-managed catalog tables that the bare
13698/// `ANALYZE` (no target) should skip. v6.2.0 has no internal
13699/// tables yet (publications / subscriptions / users / statistics
13700/// all live as engine fields, not catalog tables), so this is a
13701/// reserved future-proofing hook — every existing user table is
13702/// analysed.
13703const fn is_internal_table_name(_name: &str) -> bool {
13704    false
13705}
13706
13707fn value_to_literal(v: Value) -> Literal {
13708    match v {
13709        Value::Null => Literal::Null,
13710        Value::SmallInt(n) => Literal::Integer(i64::from(n)),
13711        Value::Int(n) => Literal::Integer(i64::from(n)),
13712        Value::BigInt(n) => Literal::Integer(n),
13713        Value::Float(x) => Literal::Float(x),
13714        Value::Text(s) | Value::Json(s) => Literal::String(s),
13715        Value::Bool(b) => Literal::Bool(b),
13716        Value::Vector(v) => Literal::Vector(v),
13717        Value::Numeric { scaled, scale } => Literal::String(eval::format_numeric(scaled, scale)),
13718        Value::Date(d) => Literal::String(eval::format_date(d)),
13719        Value::Timestamp(t) => Literal::String(eval::format_timestamp(t)),
13720        // v7.17.0 Phase 3.P0-69 — UUID round-trips via canonical
13721        // hyphenated text. Without this arm the fallback below
13722        // renders `Debug` form ("Uuid([85, …])") which the
13723        // engine's Text → Uuid coerce can't parse, breaking
13724        // prepared-bind round-trip from the spg-sqlx adapter.
13725        Value::Uuid(b) => Literal::String(spg_storage::format_uuid(&b)),
13726        // v7.16.0 — BYTEA round-trip for the spg-sqlx Bind path.
13727        // PG-canonical text rep is `\x` + lowercase hex; the
13728        // engine's coerce_value already accepts that on the
13729        // text → bytea direction.
13730        Value::Bytes(b) => Literal::String(eval::format_bytea_hex(&b)),
13731        // v7.16.0 — array round-trip for the spg-sqlx Bind
13732        // path. Render as PG external form `{a,b,c}`; the
13733        // engine's text → array coerce (just below in
13734        // coerce_value) accepts it on the matching column type.
13735        Value::TextArray(items) => Literal::String(eval::format_text_array(&items)),
13736        Value::IntArray(items) => Literal::String(eval::format_int_array(&items)),
13737        Value::BigIntArray(items) => Literal::String(eval::format_bigint_array(&items)),
13738        Value::Interval { months, micros } => Literal::Interval {
13739            months,
13740            micros,
13741            text: eval::format_interval(months, micros),
13742        },
13743        // SQ8 / halfvec cells dequantise to f32 before reaching the
13744        // substitute walker; pgwire's Bind path handles that.
13745        Value::Sq8Vector(q) => Literal::Vector(spg_storage::quantize::dequantize(&q)),
13746        Value::HalfVector(h) => Literal::Vector(h.to_f32_vec()),
13747        // v7.5.0 — Value is #[non_exhaustive]; future variants
13748        // render as Debug-form String literal until explicit
13749        // mapping is added.
13750        v => Literal::String(alloc::format!("{v:?}")),
13751    }
13752}
13753
13754fn rewrite_clock_calls(stmt: &mut Statement, now_micros: Option<i64>) {
13755    let Some(now) = now_micros else {
13756        return;
13757    };
13758    match stmt {
13759        Statement::Select(s) => rewrite_select_clock(s, now),
13760        Statement::Insert(ins) => {
13761            for row in &mut ins.rows {
13762                for e in row {
13763                    rewrite_expr_clock(e, now);
13764                }
13765            }
13766        }
13767        _ => {}
13768    }
13769}
13770
13771fn rewrite_select_clock(s: &mut SelectStatement, now: i64) {
13772    for item in &mut s.items {
13773        if let SelectItem::Expr { expr, .. } = item {
13774            rewrite_expr_clock(expr, now);
13775        }
13776    }
13777    if let Some(w) = &mut s.where_ {
13778        rewrite_expr_clock(w, now);
13779    }
13780    if let Some(gs) = &mut s.group_by {
13781        for g in gs {
13782            rewrite_expr_clock(g, now);
13783        }
13784    }
13785    if let Some(h) = &mut s.having {
13786        rewrite_expr_clock(h, now);
13787    }
13788    for o in &mut s.order_by {
13789        rewrite_expr_clock(&mut o.expr, now);
13790    }
13791    for (_, peer) in &mut s.unions {
13792        rewrite_select_clock(peer, now);
13793    }
13794}
13795
13796/// v3.0.3 hot path: every recursion lands in exactly one `match` arm.
13797/// Literal / Column-with-qualifier (the dominant cases on a typical
13798/// AST) take a single pattern dispatch and exit. The clock-rewrite
13799/// targets (zero-arg `NOW` / `CURRENT_TIMESTAMP` / `CURRENT_DATE`
13800/// functions, and bare `CURRENT_TIMESTAMP` / `CURRENT_DATE` column
13801/// refs) sit on their own arms with match guards so the fall-through
13802/// to the recursive arms is unambiguous.
13803fn rewrite_expr_clock(e: &mut Expr, now: i64) {
13804    // Fast-path test on the no-recursion shapes first. We can't fold
13805    // them into the big match below because they need to *replace* `e`
13806    // outright; the recursive arms below match on its sub-fields.
13807    if let Some(replacement) = clock_replacement_for(e, now) {
13808        *e = replacement;
13809        return;
13810    }
13811    match e {
13812        Expr::Binary { lhs, rhs, .. } => {
13813            rewrite_expr_clock(lhs, now);
13814            rewrite_expr_clock(rhs, now);
13815        }
13816        Expr::Unary { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
13817            rewrite_expr_clock(expr, now);
13818        }
13819        Expr::FunctionCall { args, .. } => {
13820            for a in args {
13821                rewrite_expr_clock(a, now);
13822            }
13823        }
13824        Expr::Like { expr, pattern, .. } => {
13825            rewrite_expr_clock(expr, now);
13826            rewrite_expr_clock(pattern, now);
13827        }
13828        Expr::Extract { source, .. } => rewrite_expr_clock(source, now),
13829        // v4.10 subquery nodes — recurse into the inner SELECT's
13830        // expression slots so e.g. SELECT NOW() in a scalar
13831        // subquery picks up the same instant as the outer query.
13832        Expr::ScalarSubquery(s) => rewrite_select_clock(s, now),
13833        Expr::Exists { subquery, .. } => rewrite_select_clock(subquery, now),
13834        Expr::InSubquery { expr, subquery, .. } => {
13835            rewrite_expr_clock(expr, now);
13836            rewrite_select_clock(subquery, now);
13837        }
13838        // v4.12 window functions — args + PARTITION BY + ORDER BY
13839        // may all reference clock literals.
13840        Expr::WindowFunction {
13841            args,
13842            partition_by,
13843            order_by,
13844            ..
13845        } => {
13846            for a in args {
13847                rewrite_expr_clock(a, now);
13848            }
13849            for p in partition_by {
13850                rewrite_expr_clock(p, now);
13851            }
13852            for (e, _) in order_by {
13853                rewrite_expr_clock(e, now);
13854            }
13855        }
13856        Expr::Literal(_) | Expr::Placeholder(_) | Expr::Column(_) => {}
13857        Expr::Array(items) => {
13858            for elem in items {
13859                rewrite_expr_clock(elem, now);
13860            }
13861        }
13862        Expr::ArraySubscript { target, index } => {
13863            rewrite_expr_clock(target, now);
13864            rewrite_expr_clock(index, now);
13865        }
13866        Expr::AnyAll { expr, array, .. } => {
13867            rewrite_expr_clock(expr, now);
13868            rewrite_expr_clock(array, now);
13869        }
13870        Expr::Case {
13871            operand,
13872            branches,
13873            else_branch,
13874        } => {
13875            if let Some(o) = operand {
13876                rewrite_expr_clock(o, now);
13877            }
13878            for (w, t) in branches {
13879                rewrite_expr_clock(w, now);
13880                rewrite_expr_clock(t, now);
13881            }
13882            if let Some(e) = else_branch {
13883                rewrite_expr_clock(e, now);
13884            }
13885        }
13886    }
13887}
13888
13889/// Returns `Some(Expr)` when `e` is one of the clock-call shapes that
13890/// must be rewritten; otherwise `None` so the caller falls through to
13891/// the recursive walk. Identifies both function-call forms (`NOW()` /
13892/// `CURRENT_TIMESTAMP()` / `CURRENT_DATE()`) and bare-identifier forms
13893/// (`CURRENT_TIMESTAMP` / `CURRENT_DATE` as unqualified column refs,
13894/// which is how PG accepts them without parens).
13895fn clock_replacement_for(e: &Expr, now: i64) -> Option<Expr> {
13896    let (kind, name) = match e {
13897        Expr::FunctionCall { name, args } if args.is_empty() => (ClockSite::Fn, name.as_str()),
13898        Expr::Column(c) if c.qualifier.is_none() => (ClockSite::BareIdent, c.name.as_str()),
13899        _ => return None,
13900    };
13901    // ASCII case-insensitive name match. Each entry decides what
13902    // synthetic literal the call expands to.
13903    //
13904    // v7.17.0 Phase 3.P0-29 — `unix_timestamp` (no args) joins this
13905    // table as MySQL's epoch-seconds equivalent of `now()`. Folded
13906    // to a BigInt literal here so apply_function never needs a
13907    // clock dependency.
13908    enum ClockShape {
13909        Timestamp,
13910        Date,
13911        UnixSeconds,
13912    }
13913    let shape = match name.len() {
13914        3 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("now") => {
13915            Some(ClockShape::Timestamp)
13916        }
13917        12 if name.eq_ignore_ascii_case("current_date") => Some(ClockShape::Date),
13918        14 if kind == ClockSite::Fn && name.eq_ignore_ascii_case("unix_timestamp") => {
13919            Some(ClockShape::UnixSeconds)
13920        }
13921        17 if name.eq_ignore_ascii_case("current_timestamp") => Some(ClockShape::Timestamp),
13922        _ => None,
13923    };
13924    let shape = shape?;
13925    let payload = match shape {
13926        ClockShape::Timestamp => now,
13927        ClockShape::Date => now.div_euclid(86_400_000_000),
13928        ClockShape::UnixSeconds => now.div_euclid(1_000_000),
13929    };
13930    let target = match shape {
13931        ClockShape::Timestamp => spg_sql::ast::CastTarget::Timestamp,
13932        ClockShape::Date => spg_sql::ast::CastTarget::Date,
13933        ClockShape::UnixSeconds => spg_sql::ast::CastTarget::BigInt,
13934    };
13935    Some(Expr::Cast {
13936        expr: alloc::boxed::Box::new(Expr::Literal(spg_sql::ast::Literal::Integer(payload))),
13937        target,
13938    })
13939}
13940
13941#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13942enum ClockSite {
13943    Fn,
13944    BareIdent,
13945}
13946
13947/// `ORDER BY <integer>` references the N-th SELECT item (1-based).
13948/// Swap the integer literal for the matching item's expression so the
13949/// executor doesn't need a special-case branch. Recurses into UNION
13950/// peers because each peer keeps its own SELECT list.
13951/// v6.4.1 — expand `GROUP BY ALL` to every non-aggregate SELECT-list
13952/// item. Mirrors DuckDB / PG 19 semantics. Wildcards (`SELECT * …`)
13953/// are NOT expanded by GROUP BY ALL (PG 19 leaves the wildcard intact
13954/// and groups by whatever explicit non-aggregates remain — none in
13955/// the wildcard-only case, which still works for non-aggregate
13956/// queries).
13957fn expand_group_by_all(s: &mut SelectStatement) {
13958    if !s.group_by_all {
13959        for (_, peer) in &mut s.unions {
13960            expand_group_by_all(peer);
13961        }
13962        return;
13963    }
13964    let mut groups: Vec<Expr> = Vec::new();
13965    for item in &s.items {
13966        if let SelectItem::Expr { expr, .. } = item
13967            && !aggregate::contains_aggregate(expr)
13968        {
13969            groups.push(expr.clone());
13970        }
13971    }
13972    s.group_by = Some(groups);
13973    s.group_by_all = false;
13974    for (_, peer) in &mut s.unions {
13975        expand_group_by_all(peer);
13976    }
13977}
13978
13979fn resolve_order_by_position(s: &mut SelectStatement) {
13980    // v6.4.0 — iterate every ORDER BY key. Position references
13981    // (`ORDER BY 2`) bind to the 1-based projection index;
13982    // identifier references that match a SELECT-list alias bind to
13983    // the projected expression (Step 4 of L3a).
13984    for order in &mut s.order_by {
13985        match &order.expr {
13986            Expr::Literal(Literal::Integer(n)) if *n >= 1 => {
13987                if let Ok(idx_one_based) = usize::try_from(*n) {
13988                    let idx = idx_one_based - 1;
13989                    if idx < s.items.len()
13990                        && let SelectItem::Expr { expr, .. } = &s.items[idx]
13991                    {
13992                        order.expr = expr.clone();
13993                    }
13994                }
13995            }
13996            Expr::Column(c) if c.qualifier.is_none() => {
13997                // Alias-in-ORDER-BY lookup.
13998                for item in &s.items {
13999                    if let SelectItem::Expr {
14000                        expr,
14001                        alias: Some(a),
14002                    } = item
14003                        && a == &c.name
14004                    {
14005                        order.expr = expr.clone();
14006                        break;
14007                    }
14008                }
14009            }
14010            _ => {}
14011        }
14012    }
14013    for (_, peer) in &mut s.unions {
14014        resolve_order_by_position(peer);
14015    }
14016}
14017
14018/// Sort `tagged` by `f64` key, reversing the comparator under DESC.
14019/// Used by the UNION ORDER BY path; per-block paths inline the same
14020/// comparator because they already hold `&OrderBy` directly.
14021/// v3.1.1: partial-sort helper. When `keep` (= offset + limit) is
14022/// strictly less than `tagged.len()`, run `select_nth_unstable_by` to
14023/// partition the prefix in O(n), then sort just that prefix in O(k
14024/// log k). Total O(n + k log k), vs O(n log n) for a full sort. The
14025/// caller decides what `keep` is; passing `None` (no LIMIT) keeps the
14026/// full-sort behaviour.
14027///
14028/// `tagged` holds `(Option<f64>, Row)` (the SELECT path) — `None` keys
14029/// sort last in ascending order, mirroring NULL-sorts-last in SQL.
14030fn partial_sort_tagged(tagged: &mut Vec<(Vec<f64>, Row)>, keep: Option<usize>, descs: &[bool]) {
14031    let cmp = |a: &(Vec<f64>, Row), b: &(Vec<f64>, Row)| cmp_multi_key(&a.0, &b.0, descs);
14032    match keep {
14033        Some(k) if k < tagged.len() && k > 0 => {
14034            let pivot = k - 1;
14035            tagged.select_nth_unstable_by(pivot, cmp);
14036            tagged[..k].sort_by(cmp);
14037            tagged.truncate(k);
14038        }
14039        _ => {
14040            tagged.sort_by(cmp);
14041        }
14042    }
14043}
14044
14045fn sort_by_keys(tagged: &mut [(Vec<f64>, Row)], descs: &[bool]) {
14046    tagged.sort_by(|a, b| cmp_multi_key(&a.0, &b.0, descs));
14047}
14048
14049/// v6.4.0 — multi-key ORDER BY comparator. Each key's per-key DESC
14050/// flag is honored independently. NULL is encoded as `f64::INFINITY`
14051/// so it sorts last in ASC and first in DESC (matches PG default).
14052fn cmp_multi_key(a: &[f64], b: &[f64], descs: &[bool]) -> core::cmp::Ordering {
14053    use core::cmp::Ordering;
14054    for (i, (ka, kb)) in a.iter().zip(b.iter()).enumerate() {
14055        let ord = ka.partial_cmp(kb).unwrap_or(Ordering::Equal);
14056        let ord = if descs.get(i).copied().unwrap_or(false) {
14057            ord.reverse()
14058        } else {
14059            ord
14060        };
14061        if ord != Ordering::Equal {
14062            return ord;
14063        }
14064    }
14065    Ordering::Equal
14066}
14067
14068/// v6.4.0 — eval every ORDER BY expression for a row and pack the
14069/// resulting keys into a `Vec<f64>`. NULL → `f64::INFINITY`.
14070fn build_order_keys(
14071    order_by: &[OrderBy],
14072    row: &Row,
14073    ctx: &EvalContext,
14074) -> Result<Vec<f64>, EngineError> {
14075    let mut keys = Vec::with_capacity(order_by.len());
14076    for o in order_by {
14077        let v = eval::eval_expr(&o.expr, row, ctx)?;
14078        keys.push(value_to_order_key(&v)?);
14079    }
14080    Ok(keys)
14081}
14082
14083/// Drop the first `offset` rows then truncate to `limit`. PG / `MySQL`
14084/// agree: OFFSET applies *after* ORDER BY but *before* LIMIT (so
14085/// `LIMIT 10 OFFSET 5` keeps rows 6..=15).
14086fn apply_offset_and_limit(rows: &mut Vec<Row>, offset: Option<u32>, limit: Option<u32>) {
14087    if let Some(off) = offset {
14088        let off = off as usize;
14089        if off >= rows.len() {
14090            rows.clear();
14091        } else {
14092            rows.drain(..off);
14093        }
14094    }
14095    if let Some(n) = limit {
14096        rows.truncate(n as usize);
14097    }
14098}
14099
14100/// v7.17.0 Phase 3.P0-49 — offset + limit applied to a tagged
14101/// `(order_keys, row)` sequence, with optional SQL:2008 `WITH
14102/// TIES` extension. When `with_ties` is set, the truncated tail
14103/// is extended through every subsequent row whose order keys
14104/// equal the last-kept row's keys (so a "top 3 by score" with
14105/// WITH TIES emits row 4 too when row 4 ties row 3 on `score`).
14106///
14107/// The order-key vector is the per-row sort key the caller already
14108/// computed via `build_order_keys`; equal-key detection therefore
14109/// matches the sort comparator exactly.
14110fn apply_offset_and_limit_tagged(
14111    tagged: &mut Vec<(Vec<f64>, Row)>,
14112    offset: Option<u32>,
14113    limit: Option<u32>,
14114    with_ties: bool,
14115) {
14116    if let Some(off) = offset {
14117        let off = off as usize;
14118        if off >= tagged.len() {
14119            tagged.clear();
14120        } else {
14121            tagged.drain(..off);
14122        }
14123    }
14124    if let Some(n) = limit {
14125        let n = n as usize;
14126        if with_ties && n > 0 && n < tagged.len() {
14127            let cutoff_key = tagged[n - 1].0.clone();
14128            let mut end = n;
14129            while end < tagged.len() && tagged[end].0 == cutoff_key {
14130                end += 1;
14131            }
14132            tagged.truncate(end);
14133        } else {
14134            tagged.truncate(n);
14135        }
14136    }
14137}
14138
14139/// v7.17.0 Phase 3.P0-49 — PG-canonical: `FETCH FIRST <n> ROWS
14140/// WITH TIES` requires an `ORDER BY`. Without one, there's no
14141/// way to identify "ties" deterministically, so PG errors at
14142/// plan time. SPG mirrors that surface so the same DDL / app
14143/// behaviour holds on cutover.
14144fn check_with_ties_requires_order_by(stmt: &SelectStatement) -> Result<(), EngineError> {
14145    if stmt.limit_with_ties && stmt.order_by.is_empty() {
14146        return Err(EngineError::Unsupported(alloc::string::String::from(
14147            "FETCH FIRST … ROWS WITH TIES requires an ORDER BY clause",
14148        )));
14149    }
14150    Ok(())
14151}
14152
14153/// v7.6.1 — resolve a parser-level `ForeignKeyConstraint` (column
14154/// names + parent table name) into the storage-layer shape (column
14155/// indices + same parent table). Validates everything the engine
14156/// needs to know about the FK at CREATE TABLE time:
14157///
14158///   - parent table exists (catalog lookup, unless self-referencing)
14159///   - parent columns exist on the parent table
14160///   - parent column list matches the local arity (defaults to the
14161///     parent's primary index column when omitted)
14162///   - parent columns are covered by a `BTree` UNIQUE-class index
14163///     (SPG's stand-in for `PRIMARY KEY`/`UNIQUE`) — required so
14164///     the v7.6.2 INSERT path can do an O(log n) parent lookup
14165///   - local columns exist on the table being created
14166fn resolve_foreign_key(
14167    local_table_name: &str,
14168    local_cols: &[ColumnSchema],
14169    fk: spg_sql::ast::ForeignKeyConstraint,
14170    catalog: &Catalog,
14171) -> Result<spg_storage::ForeignKeyConstraint, EngineError> {
14172    // Resolve local columns.
14173    let mut local_columns = Vec::with_capacity(fk.columns.len());
14174    for name in &fk.columns {
14175        let pos = local_cols
14176            .iter()
14177            .position(|c| c.name == *name)
14178            .ok_or_else(|| {
14179                EngineError::Unsupported(alloc::format!(
14180                    "FOREIGN KEY references unknown local column {name:?}"
14181                ))
14182            })?;
14183        local_columns.push(pos);
14184    }
14185    // Self-referencing FK: parent table is the one we're creating.
14186    // The parent column resolution uses the local column list since
14187    // the catalog doesn't have this table yet.
14188    let is_self_ref = fk.parent_table == local_table_name;
14189    let (parent_cols_for_lookup, parent_table_str): (&[ColumnSchema], &str) = if is_self_ref {
14190        (local_cols, local_table_name)
14191    } else {
14192        let parent_table = catalog.get(&fk.parent_table).ok_or_else(|| {
14193            EngineError::Storage(StorageError::TableNotFound {
14194                name: fk.parent_table.clone(),
14195            })
14196        })?;
14197        (
14198            parent_table.schema().columns.as_slice(),
14199            fk.parent_table.as_str(),
14200        )
14201    };
14202    // Resolve parent column names → positions. If the FK omitted the
14203    // parent column list, fall back to the parent's primary index
14204    // column (single-column only — composite default is rejected
14205    // because there's no unambiguous "PK" in SPG's index list).
14206    let parent_columns: Vec<usize> = if fk.parent_columns.is_empty() {
14207        if fk.columns.len() != 1 {
14208            return Err(EngineError::Unsupported(
14209                "composite FOREIGN KEY without explicit parent column list is not supported \
14210                 — list the parent columns explicitly"
14211                    .into(),
14212            ));
14213        }
14214        // Find a single BTree index on the parent and use its column.
14215        let pos = pick_pk_index_column(catalog, parent_table_str, is_self_ref, local_cols)
14216            .ok_or_else(|| {
14217                EngineError::Unsupported(alloc::format!(
14218                    "parent table {parent_table_str:?} has no PRIMARY-key / UNIQUE BTree index \
14219                     to default the FOREIGN KEY against"
14220                ))
14221            })?;
14222        alloc::vec![pos]
14223    } else {
14224        let mut out = Vec::with_capacity(fk.parent_columns.len());
14225        for name in &fk.parent_columns {
14226            let pos = parent_cols_for_lookup
14227                .iter()
14228                .position(|c| c.name == *name)
14229                .ok_or_else(|| {
14230                    EngineError::Unsupported(alloc::format!(
14231                        "FOREIGN KEY references unknown parent column \
14232                         {name:?} on table {parent_table_str:?}"
14233                    ))
14234                })?;
14235            out.push(pos);
14236        }
14237        out
14238    };
14239    if parent_columns.len() != local_columns.len() {
14240        return Err(EngineError::Unsupported(alloc::format!(
14241            "FOREIGN KEY arity mismatch: {} local columns vs {} parent columns",
14242            local_columns.len(),
14243            parent_columns.len()
14244        )));
14245    }
14246    // For non-self-referencing FKs, verify the parent column set is
14247    // covered by a BTree index. SPG doesn't have a `PRIMARY KEY`
14248    // declaration; the convention is "the parent column for FK
14249    // purposes must have a BTree index" — which the user creates via
14250    // `CREATE INDEX ... USING btree (col)` (the default). We accept
14251    // any single-column BTree index that covers a parent column;
14252    // composite parent column lists require an index whose `column_position`
14253    // matches the first parent column (multi-column BTree indices
14254    // are not in the v7.x roadmap).
14255    if !is_self_ref {
14256        let parent_table = catalog.get(&fk.parent_table).expect("checked above");
14257        let primary_parent_col = parent_columns[0];
14258        let has_btree = parent_table
14259            .schema()
14260            .columns
14261            .get(primary_parent_col)
14262            .is_some()
14263            && parent_table.indices().iter().any(|idx| {
14264                matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14265                    && idx.column_position == primary_parent_col
14266                    && idx.partial_predicate.is_none()
14267            });
14268        if !has_btree {
14269            return Err(EngineError::Unsupported(alloc::format!(
14270                "FOREIGN KEY parent column on {:?} is not covered by an unconditional BTree \
14271                 index — create one with `CREATE INDEX ... ON {} ({})` first",
14272                parent_table_str,
14273                parent_table_str,
14274                parent_table.schema().columns[primary_parent_col].name,
14275            )));
14276        }
14277    }
14278    let on_delete = fk_action_sql_to_storage(fk.on_delete);
14279    let on_update = fk_action_sql_to_storage(fk.on_update);
14280    Ok(spg_storage::ForeignKeyConstraint {
14281        name: fk.name,
14282        local_columns,
14283        parent_table: fk.parent_table,
14284        parent_columns,
14285        on_delete,
14286        on_update,
14287    })
14288}
14289
14290/// v7.6.1 — pick a sentinel "primary key" column from the parent
14291/// table when the FK didn't name parent columns. Picks the first
14292/// single-column unconditional BTree index — that's the closest
14293/// thing SPG has to a PRIMARY KEY today. Self-referencing FKs use
14294/// `local_cols` as the column source.
14295fn pick_pk_index_column(
14296    catalog: &Catalog,
14297    parent_name: &str,
14298    is_self_ref: bool,
14299    local_cols: &[ColumnSchema],
14300) -> Option<usize> {
14301    if is_self_ref {
14302        // Self-ref FK omitted parent columns: pick column 0 by
14303        // convention (no catalog entry yet). Engine will widen this
14304        // when v7.6.7 lands; v7.6.1 only handles the explicit form.
14305        let _ = local_cols;
14306        return Some(0);
14307    }
14308    let parent = catalog.get(parent_name)?;
14309    parent.indices().iter().find_map(|idx| {
14310        if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14311            && idx.partial_predicate.is_none()
14312            && idx.included_columns.is_empty()
14313            && idx.expression.is_none()
14314        {
14315            Some(idx.column_position)
14316        } else {
14317            None
14318        }
14319    })
14320}
14321
14322/// v7.9.8 / v7.9.10 — resolve the column positions that
14323/// identify a conflict for ON CONFLICT. Returns a Vec of
14324/// column positions (1 element for single-column form, N for
14325/// composite). When the user wrote bare `ON CONFLICT DO …`,
14326/// falls back to the table's first unconditional BTree index
14327/// (always single-column today).
14328fn resolve_on_conflict_columns(
14329    catalog: &Catalog,
14330    table_name: &str,
14331    target: &[String],
14332) -> Result<Vec<usize>, EngineError> {
14333    let table = catalog.get(table_name).ok_or_else(|| {
14334        EngineError::Storage(StorageError::TableNotFound {
14335            name: table_name.into(),
14336        })
14337    })?;
14338    if target.is_empty() {
14339        // v7.13.2 — mailrs round-6 S5 follow-up. Composite UNIQUE
14340        // constraints carry a multi-column tuple; the prior code
14341        // path picked only the leading column of the first BTree
14342        // index, which caused `ON CONFLICT DO NOTHING` to dedup
14343        // by leading column alone (3 rows with same group_id but
14344        // different permission collapsed to 1). PG semantics use
14345        // the full tuple. Prefer a UniquenessConstraint's full
14346        // column list when one exists; fall back to the leading
14347        // BTree column for legacy single-column UNIQUE.
14348        if let Some(uc) = table.schema().uniqueness_constraints.first() {
14349            return Ok(uc.columns.clone());
14350        }
14351        let pos = table
14352            .indices()
14353            .iter()
14354            .find_map(|idx| {
14355                if matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14356                    && idx.partial_predicate.is_none()
14357                    && idx.included_columns.is_empty()
14358                    && idx.expression.is_none()
14359                {
14360                    Some(idx.column_position)
14361                } else {
14362                    None
14363                }
14364            })
14365            .ok_or_else(|| {
14366                EngineError::Unsupported(alloc::format!(
14367                    "ON CONFLICT without target requires a UNIQUE BTree index on {table_name:?}"
14368                ))
14369            })?;
14370        return Ok(alloc::vec![pos]);
14371    }
14372    let mut out = Vec::with_capacity(target.len());
14373    for name in target {
14374        let pos = table
14375            .schema()
14376            .columns
14377            .iter()
14378            .position(|c| c.name == *name)
14379            .ok_or_else(|| {
14380                EngineError::Unsupported(alloc::format!(
14381                    "ON CONFLICT target column {name:?} not found on {table_name:?}"
14382                ))
14383            })?;
14384        out.push(pos);
14385    }
14386    Ok(out)
14387}
14388
14389/// v7.9.8 — check whether the BTree index on `column_pos` of
14390/// `table_name` already has a row with this key.
14391fn on_conflict_key_exists(
14392    catalog: &Catalog,
14393    table_name: &str,
14394    column_pos: usize,
14395    key: &Value,
14396) -> bool {
14397    let Some(table) = catalog.get(table_name) else {
14398        return false;
14399    };
14400    let Some(idx_key) = spg_storage::IndexKey::from_value(key) else {
14401        return false;
14402    };
14403    table.indices().iter().any(|idx| {
14404        matches!(idx.kind, spg_storage::IndexKind::BTree(_))
14405            && idx.column_position == column_pos
14406            && idx.partial_predicate.is_none()
14407            && !idx.lookup_eq(&idx_key).is_empty()
14408    })
14409}
14410
14411/// v7.9.9 / v7.9.10 — look up an existing row's position by
14412/// matching all `column_positions` against the incoming `key`
14413/// tuple. Single-column shape (one column) reduces to the
14414/// canonical PK lookup; composite shapes scan linearly until
14415/// every position matches.
14416fn lookup_row_position_by_keys(
14417    catalog: &Catalog,
14418    table_name: &str,
14419    column_positions: &[usize],
14420    key: &[&Value],
14421) -> Option<usize> {
14422    let table = catalog.get(table_name)?;
14423    table.rows().iter().position(|r| {
14424        column_positions
14425            .iter()
14426            .enumerate()
14427            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
14428    })
14429}
14430
14431/// v7.9.10 — does the table already contain a row whose
14432/// `column_positions` tuple equals `key`? Single-column shape
14433/// uses the existing BTree fast path; composite shapes fall
14434/// back to a row scan.
14435fn on_conflict_keys_exist(
14436    catalog: &Catalog,
14437    table_name: &str,
14438    column_positions: &[usize],
14439    key: &[&Value],
14440) -> bool {
14441    if column_positions.len() == 1 {
14442        return on_conflict_key_exists(catalog, table_name, column_positions[0], key[0]);
14443    }
14444    let Some(table) = catalog.get(table_name) else {
14445        return false;
14446    };
14447    table.rows().iter().any(|r| {
14448        column_positions
14449            .iter()
14450            .enumerate()
14451            .all(|(i, &pos)| r.values.get(pos) == Some(key[i]))
14452    })
14453}
14454
14455/// v7.9.9 — apply ON CONFLICT DO UPDATE SET assignments to an
14456/// existing row.
14457///
14458/// `incoming` is the rejected INSERT row (used to resolve
14459/// `EXCLUDED.col` references in the assignment exprs);
14460/// `target_pos` is the position of the existing row in the table.
14461/// Each assignment substitutes `EXCLUDED.col` with the matching
14462/// incoming value, evaluates the resulting expression against
14463/// the existing row, and writes the new value into the
14464/// corresponding column of the returned `Vec<Value>`. If
14465/// `where_` evaluates falsy, returns Ok(None) — PG behaviour:
14466/// the conflicting row is silently kept unchanged.
14467fn apply_on_conflict_assignments(
14468    catalog: &Catalog,
14469    table_name: &str,
14470    target_pos: usize,
14471    incoming: &[Value],
14472    assignments: &[(String, Expr)],
14473    where_: Option<&Expr>,
14474) -> Result<Option<Vec<Value>>, EngineError> {
14475    let table = catalog.get(table_name).ok_or_else(|| {
14476        EngineError::Storage(StorageError::TableNotFound {
14477            name: table_name.into(),
14478        })
14479    })?;
14480    let schema_cols = table.schema().columns.clone();
14481    let existing = table
14482        .rows()
14483        .get(target_pos)
14484        .ok_or_else(|| {
14485            EngineError::Unsupported(alloc::format!(
14486                "ON CONFLICT DO UPDATE: row position {target_pos} out of bounds on {table_name:?}"
14487            ))
14488        })?
14489        .clone();
14490    let ctx = eval::EvalContext::new(&schema_cols, Some(table_name));
14491    // Optional WHERE filter on the conflict row.
14492    if let Some(w) = where_ {
14493        let pred = w.clone();
14494        let pred = substitute_excluded_refs(pred, &schema_cols, incoming);
14495        let v = eval::eval_expr(&pred, &existing, &ctx)?;
14496        if !matches!(v, Value::Bool(true)) {
14497            return Ok(None);
14498        }
14499    }
14500    let mut new_values = existing.values.clone();
14501    for (col_name, expr) in assignments {
14502        let target_idx = schema_cols
14503            .iter()
14504            .position(|c| c.name == *col_name)
14505            .ok_or_else(|| {
14506                EngineError::Eval(EvalError::ColumnNotFound {
14507                    name: col_name.clone(),
14508                })
14509            })?;
14510        let sub = substitute_excluded_refs(expr.clone(), &schema_cols, incoming);
14511        let v = eval::eval_expr(&sub, &existing, &ctx)?;
14512        let coerced = coerce_value(v, schema_cols[target_idx].ty, col_name, target_idx)?;
14513        check_unsigned_range(&coerced, &schema_cols[target_idx], target_idx)?;
14514        new_values[target_idx] = coerced;
14515    }
14516    Ok(Some(new_values))
14517}
14518
14519/// v7.9.9 — walk an `Expr` tree replacing any `Column { qualifier:
14520/// "EXCLUDED", name }` reference with a `Literal` of the matching
14521/// value from the incoming-row vec. Resolution against the
14522/// child-table column list (by name).
14523fn substitute_excluded_refs(expr: Expr, schema_cols: &[ColumnSchema], incoming: &[Value]) -> Expr {
14524    use spg_sql::ast::ColumnName;
14525    match expr {
14526        Expr::Column(ColumnName { qualifier, name })
14527            if qualifier
14528                .as_deref()
14529                .is_some_and(|q| q.eq_ignore_ascii_case("excluded")) =>
14530        {
14531            let pos = schema_cols.iter().position(|c| c.name == name);
14532            match pos {
14533                Some(p) => {
14534                    let v = incoming.get(p).cloned().unwrap_or(Value::Null);
14535                    value_to_literal_expr(v)
14536                        .unwrap_or_else(|_| Expr::Literal(spg_sql::ast::Literal::Null))
14537                }
14538                None => Expr::Column(ColumnName { qualifier, name }),
14539            }
14540        }
14541        Expr::Binary { op, lhs, rhs } => Expr::Binary {
14542            op,
14543            lhs: Box::new(substitute_excluded_refs(*lhs, schema_cols, incoming)),
14544            rhs: Box::new(substitute_excluded_refs(*rhs, schema_cols, incoming)),
14545        },
14546        Expr::Unary { op, expr } => Expr::Unary {
14547            op,
14548            expr: Box::new(substitute_excluded_refs(*expr, schema_cols, incoming)),
14549        },
14550        Expr::FunctionCall { name, args } => Expr::FunctionCall {
14551            name,
14552            args: args
14553                .into_iter()
14554                .map(|a| substitute_excluded_refs(a, schema_cols, incoming))
14555                .collect(),
14556        },
14557        other => other,
14558    }
14559}
14560
14561/// v7.6.2 / v7.6.7 — INSERT-side FK enforcement. For every row
14562/// about to be inserted into `child_table`, every FK declared on
14563/// that table is checked: the row's FK columns must either be
14564/// NULL (SQL spec skip) or match an existing parent row via the
14565/// parent's BTree PK / UNIQUE index.
14566///
14567/// Returns `EngineError::Unsupported` with a `FOREIGN KEY violation`
14568/// payload on first failure.
14569///
14570/// **Self-referencing FKs (v7.6.7 widening):** when `fk.parent_table
14571/// == child_table`, the parent rows visible to this check are
14572///  (a) rows already committed to the table, plus
14573///  (b) earlier rows from the *same* `rows` batch.
14574/// This makes `INSERT INTO tree VALUES (1, NULL), (2, 1), (3, 2)`
14575/// work in a single statement — common pattern for bulk-loading
14576/// hierarchies.
14577/// v7.9.19 — enforce table-level UNIQUE / PRIMARY KEY tuple
14578/// constraints at INSERT time. For each constraint declared on
14579/// the target table, check that no existing row + no earlier row
14580/// in the same batch has the same full-column tuple. NULL in
14581/// any column lifts the row out of the check (SQL spec: NULL
14582/// ≠ NULL for uniqueness). mailrs G1 + G6.
14583fn enforce_uniqueness_inserts(
14584    catalog: &Catalog,
14585    child_table: &str,
14586    constraints: &[spg_storage::UniquenessConstraint],
14587    rows: &[Vec<Value>],
14588) -> Result<(), EngineError> {
14589    if constraints.is_empty() {
14590        return Ok(());
14591    }
14592    let table = catalog.get(child_table).ok_or_else(|| {
14593        EngineError::Storage(StorageError::TableNotFound {
14594            name: child_table.into(),
14595        })
14596    })?;
14597    let schema = table.schema();
14598    for uc in constraints {
14599        for (batch_idx, row_values) in rows.iter().enumerate() {
14600            // v7.17.0 Phase 3.P0-45 — fold each key cell by its
14601            // column's declared Collation before comparing. Phase
14602            // 2.5b wired Collation into GROUP BY / ORDER BY / `=`
14603            // but the UNIQUE-constraint enforcement still compared
14604            // Text byte-wise; a `*_ci` column would let
14605            // `('Foo')` and `('FOO')` coexist when MySQL would
14606            // reject the second. Owned Values so the fold and
14607            // the borrow live in the same scope.
14608            let key: Vec<Value> = uc
14609                .columns
14610                .iter()
14611                .map(|&i| collated_key_cell(&row_values[i], i, schema))
14612                .collect();
14613            let has_null = key.iter().any(|v| matches!(v, Value::Null));
14614            // v7.13.0 — `NULLS NOT DISTINCT` (mailrs round-5 G10,
14615            // PG 15+): two rows whose constrained columns are all
14616            // NULL collide. SQL-standard `NULLS DISTINCT` lets any
14617            // NULL skip the check.
14618            if has_null && !uc.nulls_not_distinct {
14619                continue;
14620            }
14621            // Table-side collision: scan existing rows.
14622            let collides_in_table = table.rows().iter().any(|prow| {
14623                uc.columns.iter().enumerate().all(|(i, &p)| {
14624                    prow.values
14625                        .get(p)
14626                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
14627                })
14628            });
14629            // Batch-side collision: earlier rows in the same INSERT.
14630            let collides_in_batch = rows[..batch_idx].iter().any(|earlier| {
14631                uc.columns.iter().enumerate().all(|(i, &p)| {
14632                    earlier
14633                        .get(p)
14634                        .is_some_and(|v| collated_key_cell(v, p, schema) == key[i])
14635                })
14636            });
14637            if collides_in_table || collides_in_batch {
14638                let kind = if uc.is_primary_key {
14639                    "PRIMARY KEY"
14640                } else {
14641                    "UNIQUE"
14642                };
14643                let col_names: Vec<String> = uc
14644                    .columns
14645                    .iter()
14646                    .map(|&i| table.schema().columns[i].name.clone())
14647                    .collect();
14648                return Err(EngineError::Unsupported(alloc::format!(
14649                    "{kind} violation on {child_table:?} columns {col_names:?}: \
14650                     row #{batch_idx} duplicates an existing key"
14651                )));
14652            }
14653        }
14654    }
14655    Ok(())
14656}
14657
14658/// v7.17.0 Phase 3.P0-45 — return a key cell folded by its column's
14659/// declared `Collation`. For `CaseInsensitive`, fold Text payloads to
14660/// ASCII lowercase (matches Phase 2.5's `*_ci` semantics: ASCII case-
14661/// fold only, non-ASCII bytes stay byte-wise). For `Binary` or non-Text
14662/// values, the cell passes through unchanged. The caller compares the
14663/// folded values with `==`.
14664fn collated_key_cell(
14665    v: &spg_storage::Value,
14666    column_position: usize,
14667    schema: &spg_storage::TableSchema,
14668) -> spg_storage::Value {
14669    match (v, schema.columns.get(column_position).map(|c| c.collation)) {
14670        (spg_storage::Value::Text(s), Some(spg_storage::Collation::CaseInsensitive)) => {
14671            spg_storage::Value::Text(s.to_ascii_lowercase())
14672        }
14673        _ => v.clone(),
14674    }
14675}
14676
14677/// v7.9.29 — `true` iff `v` counts as a truthy SQL value for a
14678/// WHERE-style predicate. NULL → false (three-valued logic
14679/// collapses to "skip this row" for index inclusion). Numeric
14680/// non-zero, BIGINT non-zero, TINYINT non-zero, BOOLEAN true → true.
14681/// Everything else (strings, vectors, JSON, …) is not a valid
14682/// predicate result and surfaces as `false` so a malformed
14683/// predicate degrades to "row not in index" rather than panicking.
14684fn predicate_truthy(v: &spg_storage::Value) -> bool {
14685    use spg_storage::Value as V;
14686    match v {
14687        V::Bool(b) => *b,
14688        V::Int(n) => *n != 0,
14689        V::BigInt(n) => *n != 0,
14690        V::SmallInt(n) => *n != 0,
14691        _ => false,
14692    }
14693}
14694
14695/// v7.9.29 — at CREATE UNIQUE INDEX time, scan the table's
14696/// committed rows for pre-existing duplicates. If any pair of rows
14697/// matches the predicate AND has the same index key, refuse to
14698/// create the index so the user fixes the data before retrying.
14699fn check_existing_unique_violation(
14700    idx: &spg_storage::Index,
14701    schema: &spg_storage::TableSchema,
14702    rows: &[spg_storage::Row],
14703) -> Result<(), EngineError> {
14704    let predicate_expr = match idx.partial_predicate.as_deref() {
14705        Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
14706            EngineError::Unsupported(alloc::format!(
14707                "stored partial predicate {s:?} failed to re-parse: {e:?}"
14708            ))
14709        })?),
14710        None => None,
14711    };
14712    let ctx = eval::EvalContext::new(&schema.columns, None);
14713    let key_positions = unique_key_positions(idx);
14714    let mut seen: alloc::vec::Vec<alloc::vec::Vec<spg_storage::Value>> = alloc::vec::Vec::new();
14715    for row in rows {
14716        if let Some(expr) = &predicate_expr {
14717            let v = eval::eval_expr(expr, row, &ctx).map_err(|e| {
14718                EngineError::Unsupported(alloc::format!(
14719                    "evaluating UNIQUE INDEX predicate against existing row: {e:?}"
14720                ))
14721            })?;
14722            if !predicate_truthy(&v) {
14723                continue;
14724            }
14725        }
14726        let key: alloc::vec::Vec<spg_storage::Value> = key_positions
14727            .iter()
14728            .map(|&p| {
14729                let v = row
14730                    .values
14731                    .get(p)
14732                    .cloned()
14733                    .unwrap_or(spg_storage::Value::Null);
14734                collated_key_cell(&v, p, schema)
14735            })
14736            .collect();
14737        if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
14738            continue;
14739        }
14740        if seen.iter().any(|other| *other == key) {
14741            return Err(EngineError::Unsupported(alloc::format!(
14742                "CREATE UNIQUE INDEX {:?}: existing rows already violate the constraint",
14743                idx.name
14744            )));
14745        }
14746        seen.push(key);
14747    }
14748    Ok(())
14749}
14750
14751/// v7.9.29 — full key tuple for a UNIQUE INDEX (leading +
14752/// extra positions). For single-column indexes this is just
14753/// `[column_position]`.
14754fn unique_key_positions(idx: &spg_storage::Index) -> alloc::vec::Vec<usize> {
14755    let mut out = alloc::vec::Vec::with_capacity(1 + idx.extra_column_positions.len());
14756    out.push(idx.column_position);
14757    out.extend_from_slice(&idx.extra_column_positions);
14758    out
14759}
14760
14761/// v7.9.29 — at INSERT time, walk every `is_unique` index on the
14762/// target table. For each, eval the index's optional predicate
14763/// against (a) the candidate row and (b) every committed row plus
14764/// earlier batch rows; only rows where the predicate is truthy
14765/// participate. A duplicate key among predicate-matching rows is a
14766/// uniqueness violation. NULL keys lift the row out of the check
14767/// (matching PG's "UNIQUE allows multiple NULLs" semantics).
14768fn enforce_unique_index_inserts(
14769    catalog: &Catalog,
14770    table_name: &str,
14771    rows: &[alloc::vec::Vec<spg_storage::Value>],
14772) -> Result<(), EngineError> {
14773    let table = catalog.get(table_name).ok_or_else(|| {
14774        EngineError::Storage(StorageError::TableNotFound {
14775            name: table_name.into(),
14776        })
14777    })?;
14778    let schema = table.schema();
14779    let ctx = eval::EvalContext::new(&schema.columns, None);
14780    for idx in table.indices() {
14781        if !idx.is_unique {
14782            continue;
14783        }
14784        // Re-parse the predicate once per index per batch.
14785        let predicate_expr = match idx.partial_predicate.as_deref() {
14786            Some(s) => Some(spg_sql::parser::parse_expression(s).map_err(|e| {
14787                EngineError::Unsupported(alloc::format!(
14788                    "UNIQUE INDEX {:?} predicate {s:?} failed to re-parse: {e:?}",
14789                    idx.name
14790                ))
14791            })?),
14792            None => None,
14793        };
14794        let key_positions = unique_key_positions(idx);
14795        let key_of = |values: &[spg_storage::Value]| -> alloc::vec::Vec<spg_storage::Value> {
14796            // v7.17.0 Phase 3.P0-45 — fold per-column collation
14797            // before building the comparison key so a `*_ci`
14798            // column treats `'Foo'` and `'FOO'` as equal.
14799            key_positions
14800                .iter()
14801                .map(|&p| {
14802                    let v = values.get(p).cloned().unwrap_or(spg_storage::Value::Null);
14803                    collated_key_cell(&v, p, schema)
14804                })
14805                .collect()
14806        };
14807        // Helper: does `values` participate in this index? (predicate
14808        // truthy when present.) Wraps `values` into a transient Row
14809        // because eval_expr requires &Row.
14810        let participates = |values: &[spg_storage::Value]| -> Result<bool, EngineError> {
14811            let Some(expr) = &predicate_expr else {
14812                return Ok(true);
14813            };
14814            let tmp_row = spg_storage::Row {
14815                values: values.to_vec(),
14816            };
14817            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
14818                EngineError::Unsupported(alloc::format!(
14819                    "UNIQUE INDEX {:?} predicate eval: {e:?}",
14820                    idx.name
14821                ))
14822            })?;
14823            Ok(predicate_truthy(&v))
14824        };
14825        for (batch_idx, row_values) in rows.iter().enumerate() {
14826            if !participates(row_values)? {
14827                continue;
14828            }
14829            let key = key_of(row_values);
14830            if key.iter().any(|v| matches!(v, spg_storage::Value::Null)) {
14831                continue;
14832            }
14833            // Committed-table collision.
14834            for prow in table.rows() {
14835                if !participates(&prow.values)? {
14836                    continue;
14837                }
14838                if key_of(&prow.values) == key {
14839                    return Err(EngineError::Unsupported(alloc::format!(
14840                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
14841                         row #{batch_idx} duplicates an existing key",
14842                        idx.name
14843                    )));
14844                }
14845            }
14846            // Within-batch collision: earlier rows in the same INSERT.
14847            for earlier in &rows[..batch_idx] {
14848                if !participates(earlier)? {
14849                    continue;
14850                }
14851                if key_of(earlier) == key {
14852                    return Err(EngineError::Unsupported(alloc::format!(
14853                        "UNIQUE INDEX {:?} violation on {table_name:?}: \
14854                         row #{batch_idx} duplicates an earlier row in the same batch",
14855                        idx.name
14856                    )));
14857                }
14858            }
14859        }
14860    }
14861    Ok(())
14862}
14863
14864/// v7.13.0 — `UPDATE OF cols` filter helper (mailrs round-5 G7).
14865/// Returns `true` when at least one of `filter_cols` has a
14866/// different value in `new_row` vs `old_row`. Column lookup is
14867/// case-insensitive against `schema_cols`; unknown filter columns
14868/// are treated as "not changed" (the trigger therefore won't
14869/// fire on them — surfacing a parse-time error would be too
14870/// strict for catalog reloads where the schema may have drifted).
14871fn any_column_changed(
14872    filter_cols: &[String],
14873    schema_cols: &[ColumnSchema],
14874    old_row: &Row,
14875    new_row: &Row,
14876) -> bool {
14877    for col_name in filter_cols {
14878        let Some(pos) = schema_cols
14879            .iter()
14880            .position(|c| c.name.eq_ignore_ascii_case(col_name))
14881        else {
14882            continue;
14883        };
14884        let old_v = old_row.values.get(pos);
14885        let new_v = new_row.values.get(pos);
14886        if old_v != new_v {
14887            return true;
14888        }
14889    }
14890    false
14891}
14892
14893/// v7.13.0 — evaluate every CHECK predicate on the schema against
14894/// each candidate row. Mirrors PG semantics: a `false` result
14895/// rejects the mutation; a NULL result *passes* (CHECK rejects
14896/// only on definite-false, not on unknown). mailrs round-5 G3.
14897fn enforce_check_constraints(
14898    catalog: &Catalog,
14899    table_name: &str,
14900    rows: &[alloc::vec::Vec<spg_storage::Value>],
14901) -> Result<(), EngineError> {
14902    let table = catalog.get(table_name).ok_or_else(|| {
14903        EngineError::Storage(StorageError::TableNotFound {
14904            name: table_name.into(),
14905        })
14906    })?;
14907    let schema = table.schema();
14908    // v7.17.0 Phase 1.5 — domain-level CHECKs are enforced in
14909    // parallel with table-level CHECKs. Collect both lists up
14910    // front; if neither exists we early-out.
14911    let mut domain_checks_per_col: alloc::vec::Vec<(usize, alloc::vec::Vec<Expr>)> =
14912        alloc::vec::Vec::new();
14913    for (idx, col) in schema.columns.iter().enumerate() {
14914        let Some(dname) = &col.user_domain_type else {
14915            continue;
14916        };
14917        let Some(dom) = catalog.domain_types().get(dname) else {
14918            continue;
14919        };
14920        let mut parsed_for_col: alloc::vec::Vec<Expr> =
14921            alloc::vec::Vec::with_capacity(dom.checks.len());
14922        for src in &dom.checks {
14923            let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
14924                EngineError::Unsupported(alloc::format!(
14925                    "DOMAIN {dname:?} CHECK ({src:?}) on column {:?}: re-parse failed: {e:?}",
14926                    col.name
14927                ))
14928            })?;
14929            parsed_for_col.push(expr);
14930        }
14931        if !parsed_for_col.is_empty() {
14932            domain_checks_per_col.push((idx, parsed_for_col));
14933        }
14934    }
14935    if schema.checks.is_empty() && domain_checks_per_col.is_empty() {
14936        return Ok(());
14937    }
14938    let ctx = eval::EvalContext::new(&schema.columns, None);
14939    let mut parsed: alloc::vec::Vec<(usize, Expr)> = alloc::vec::Vec::new();
14940    for (i, src) in schema.checks.iter().enumerate() {
14941        let expr = spg_sql::parser::parse_expression(src).map_err(|e| {
14942            EngineError::Unsupported(alloc::format!(
14943                "CHECK constraint #{i} on {table_name:?} ({src:?}) failed to re-parse: {e:?}"
14944            ))
14945        })?;
14946        parsed.push((i, expr));
14947    }
14948    for (batch_idx, row_values) in rows.iter().enumerate() {
14949        let tmp_row = spg_storage::Row {
14950            values: row_values.clone(),
14951        };
14952        for (i, expr) in &parsed {
14953            let v = eval::eval_expr(expr, &tmp_row, &ctx).map_err(|e| {
14954                EngineError::Unsupported(alloc::format!(
14955                    "CHECK constraint #{i} on {table_name:?} eval at row #{batch_idx}: {e:?}"
14956                ))
14957            })?;
14958            // PG: NULL passes (CHECK rejects on definite-false only).
14959            if matches!(v, spg_storage::Value::Bool(false)) {
14960                return Err(EngineError::Unsupported(alloc::format!(
14961                    "CHECK constraint violation on {table_name:?} (row #{batch_idx}): {:?}",
14962                    schema.checks[*i]
14963                )));
14964            }
14965        }
14966        // v7.17.0 Phase 1.5 — domain-level CHECKs. Each CHECK
14967        // expression references VALUE as a column-name; we
14968        // substitute the per-row cell into the eval context by
14969        // synthesising a single-column row of just that value
14970        // under a temporary `value` column schema.
14971        for (col_idx, checks) in &domain_checks_per_col {
14972            let cell = row_values
14973                .get(*col_idx)
14974                .cloned()
14975                .unwrap_or(spg_storage::Value::Null);
14976            let synth_cols = alloc::vec![spg_storage::ColumnSchema::new(
14977                "value",
14978                schema.columns[*col_idx].ty,
14979                schema.columns[*col_idx].nullable,
14980            )];
14981            let synth_ctx = eval::EvalContext::new(&synth_cols, None);
14982            let synth_row = spg_storage::Row {
14983                values: alloc::vec![cell],
14984            };
14985            for (ci, expr) in checks.iter().enumerate() {
14986                let v = eval::eval_expr(expr, &synth_row, &synth_ctx).map_err(|e| {
14987                    EngineError::Unsupported(alloc::format!(
14988                        "DOMAIN CHECK #{ci} on column {:?} eval at row #{batch_idx}: {e:?}",
14989                        schema.columns[*col_idx].name
14990                    ))
14991                })?;
14992                if matches!(v, spg_storage::Value::Bool(false)) {
14993                    return Err(EngineError::Unsupported(alloc::format!(
14994                        "DOMAIN CHECK violation on column {:?} (row #{batch_idx})",
14995                        schema.columns[*col_idx].name
14996                    )));
14997                }
14998            }
14999        }
15000    }
15001    Ok(())
15002}
15003
15004fn enforce_fk_inserts(
15005    catalog: &Catalog,
15006    child_table: &str,
15007    fks: &[spg_storage::ForeignKeyConstraint],
15008    rows: &[Vec<Value>],
15009) -> Result<(), EngineError> {
15010    for fk in fks {
15011        let parent_is_self = fk.parent_table == child_table;
15012        let parent = if parent_is_self {
15013            // Self-ref: read the current state of the same table.
15014            // The mut borrow on child has been dropped by the caller.
15015            catalog.get(child_table).ok_or_else(|| {
15016                EngineError::Storage(StorageError::TableNotFound {
15017                    name: child_table.into(),
15018                })
15019            })?
15020        } else {
15021            catalog.get(&fk.parent_table).ok_or_else(|| {
15022                EngineError::Storage(StorageError::TableNotFound {
15023                    name: fk.parent_table.clone(),
15024                })
15025            })?
15026        };
15027        for (batch_idx, row_values) in rows.iter().enumerate() {
15028            // Single-column FK fast path: try the parent's BTree
15029            // index for an O(log n) lookup. Composite FKs fall back
15030            // to a parent-row scan.
15031            if fk.local_columns.len() == 1 {
15032                let v = &row_values[fk.local_columns[0]];
15033                if matches!(v, Value::Null) {
15034                    continue;
15035                }
15036                let parent_col = fk.parent_columns[0];
15037                let key = spg_storage::IndexKey::from_value(v).ok_or_else(|| {
15038                    EngineError::Unsupported(alloc::format!(
15039                        "FOREIGN KEY column value of type {:?} is not index-eligible",
15040                        v.data_type()
15041                    ))
15042                })?;
15043                let present_committed = parent.indices().iter().any(|idx| {
15044                    matches!(idx.kind, spg_storage::IndexKind::BTree(_))
15045                        && idx.column_position == parent_col
15046                        && idx.partial_predicate.is_none()
15047                        && !idx.lookup_eq(&key).is_empty()
15048                });
15049                // v7.6.7 self-ref widening: also accept a match
15050                // against earlier rows in this same batch when the
15051                // FK points at the table being inserted into.
15052                let present_in_batch = parent_is_self
15053                    && rows[..batch_idx]
15054                        .iter()
15055                        .any(|earlier| earlier.get(parent_col) == Some(v));
15056                if !(present_committed || present_in_batch) {
15057                    return Err(EngineError::Unsupported(alloc::format!(
15058                        "FOREIGN KEY violation: no parent row in {:?} where {} = {:?}",
15059                        fk.parent_table,
15060                        parent
15061                            .schema()
15062                            .columns
15063                            .get(parent_col)
15064                            .map_or("?", |c| c.name.as_str()),
15065                        v,
15066                    )));
15067                }
15068            } else {
15069                // Composite FK: scan parent rows. v7.6.7 also
15070                // accepts a match against earlier rows in the same
15071                // batch (self-ref bulk-loading of hierarchies).
15072                if fk
15073                    .local_columns
15074                    .iter()
15075                    .all(|&i| matches!(row_values.get(i), Some(Value::Null)))
15076                {
15077                    continue;
15078                }
15079                let local: Vec<&Value> = fk.local_columns.iter().map(|&i| &row_values[i]).collect();
15080                let parent_match_committed = parent.rows().iter().any(|prow| {
15081                    fk.parent_columns
15082                        .iter()
15083                        .enumerate()
15084                        .all(|(i, &pi)| prow.values.get(pi) == Some(local[i]))
15085                });
15086                let parent_match_in_batch = parent_is_self
15087                    && rows[..batch_idx].iter().any(|earlier| {
15088                        fk.parent_columns
15089                            .iter()
15090                            .enumerate()
15091                            .all(|(i, &pi)| earlier.get(pi) == Some(local[i]))
15092                    });
15093                if !(parent_match_committed || parent_match_in_batch) {
15094                    return Err(EngineError::Unsupported(alloc::format!(
15095                        "FOREIGN KEY violation: no parent row in {:?} matching composite key",
15096                        fk.parent_table,
15097                    )));
15098                }
15099            }
15100        }
15101    }
15102    Ok(())
15103}
15104
15105/// v7.6.4 / v7.6.5 — one step of the FK action plan computed for a
15106/// DELETE on a parent. The plan is a list of these steps, stacked
15107/// across the FK graph by `plan_fk_parent_deletions`.
15108#[derive(Debug, Clone)]
15109struct FkChildStep {
15110    child_table: String,
15111    action: FkChildAction,
15112}
15113
15114#[derive(Debug, Clone)]
15115enum FkChildAction {
15116    /// CASCADE — remove these rows. Sorted, deduplicated positions.
15117    Delete { positions: Vec<usize> },
15118    /// SET NULL — for each (row, column) in the flat list, write
15119    /// NULL into that child cell. Multiple FKs on the same row may
15120    /// produce overlapping entries (deduped at plan time).
15121    SetNull {
15122        positions: Vec<usize>,
15123        columns: Vec<usize>,
15124    },
15125    /// SET DEFAULT — same shape as SetNull but writes the column's
15126    /// declared DEFAULT value (resolved at plan time). Columns
15127    /// without a DEFAULT raise an error during planning.
15128    SetDefault {
15129        positions: Vec<usize>,
15130        columns: Vec<usize>,
15131        defaults: Vec<Value>,
15132    },
15133}
15134
15135/// v7.6.3 → v7.6.5 — plan FK fallout for a DELETE on a parent table.
15136///
15137/// Walks every table in the catalog looking for FKs whose
15138/// `parent_table` is `parent_table_name`. For each such FK + each
15139/// to-be-deleted parent row:
15140///
15141///   - RESTRICT / NoAction → error, no plan returned
15142///   - CASCADE → child rows get scheduled for deletion; recursive
15143///   - SetNull → child FK column(s) scheduled to be NULL-ed.
15144///     Verified NULL-able at plan time.
15145///   - SetDefault → child FK column(s) scheduled to be reset to
15146///     their declared DEFAULT. Columns without a DEFAULT raise.
15147///
15148/// SET NULL / SET DEFAULT do NOT cascade further — the child row
15149/// stays; only one of its columns mutates.
15150fn plan_fk_parent_deletions(
15151    catalog: &Catalog,
15152    parent_table_name: &str,
15153    to_delete_positions: &[usize],
15154    to_delete_rows: &[Vec<Value>],
15155) -> Result<Vec<FkChildStep>, EngineError> {
15156    use alloc::collections::{BTreeMap, BTreeSet};
15157    if to_delete_rows.is_empty() {
15158        return Ok(Vec::new());
15159    }
15160    let mut delete_plan: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
15161    // setnull / setdefault keyed by child_table → (row_idx, col_idx) → optional default
15162    let mut setnull_plan: BTreeMap<String, BTreeSet<(usize, usize)>> = BTreeMap::new();
15163    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15164    let mut visited: BTreeSet<(String, usize)> = BTreeSet::new();
15165    for &p in to_delete_positions {
15166        visited.insert((parent_table_name.to_string(), p));
15167    }
15168    let mut work: Vec<(String, Vec<Value>)> = to_delete_rows
15169        .iter()
15170        .map(|r| (parent_table_name.to_string(), r.clone()))
15171        .collect();
15172    while let Some((cur_parent, parent_row)) = work.pop() {
15173        for child_name in catalog.table_names() {
15174            let child = catalog
15175                .get(&child_name)
15176                .expect("table_names → catalog.get round-trip is total");
15177            for fk in &child.schema().foreign_keys {
15178                if fk.parent_table != cur_parent {
15179                    continue;
15180                }
15181                let parent_key: Vec<&Value> = fk
15182                    .parent_columns
15183                    .iter()
15184                    .map(|&pi| &parent_row[pi])
15185                    .collect();
15186                if parent_key.iter().any(|v| matches!(v, Value::Null)) {
15187                    continue;
15188                }
15189                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
15190                    if child_name == cur_parent
15191                        && visited.contains(&(child_name.clone(), child_row_idx))
15192                    {
15193                        continue;
15194                    }
15195                    let matches_key = fk
15196                        .local_columns
15197                        .iter()
15198                        .enumerate()
15199                        .all(|(i, &li)| child_row.values.get(li) == Some(parent_key[i]));
15200                    if !matches_key {
15201                        continue;
15202                    }
15203                    match fk.on_delete {
15204                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
15205                            return Err(EngineError::Unsupported(alloc::format!(
15206                                "FOREIGN KEY violation: DELETE on {cur_parent:?} is \
15207                                 restricted by FK from {child_name:?}.{:?}",
15208                                fk.local_columns,
15209                            )));
15210                        }
15211                        spg_storage::FkAction::Cascade => {
15212                            if visited.insert((child_name.clone(), child_row_idx)) {
15213                                delete_plan
15214                                    .entry(child_name.clone())
15215                                    .or_default()
15216                                    .insert(child_row_idx);
15217                                work.push((child_name.clone(), child_row.values.clone()));
15218                            }
15219                        }
15220                        spg_storage::FkAction::SetNull => {
15221                            // Verify every local FK column is NULL-able.
15222                            for &li in &fk.local_columns {
15223                                let col = child.schema().columns.get(li).ok_or_else(|| {
15224                                    EngineError::Unsupported(alloc::format!(
15225                                        "FK local column {li} missing in {child_name:?}"
15226                                    ))
15227                                })?;
15228                                if !col.nullable {
15229                                    return Err(EngineError::Unsupported(alloc::format!(
15230                                        "FOREIGN KEY ON DELETE SET NULL: column \
15231                                         {child_name:?}.{:?} is NOT NULL — cannot SET NULL",
15232                                        col.name,
15233                                    )));
15234                                }
15235                            }
15236                            let entry = setnull_plan.entry(child_name.clone()).or_default();
15237                            for &li in &fk.local_columns {
15238                                entry.insert((child_row_idx, li));
15239                            }
15240                        }
15241                        spg_storage::FkAction::SetDefault => {
15242                            // Resolve the DEFAULT for every local FK col.
15243                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
15244                            for &li in &fk.local_columns {
15245                                let col = child.schema().columns.get(li).ok_or_else(|| {
15246                                    EngineError::Unsupported(alloc::format!(
15247                                        "FK local column {li} missing in {child_name:?}"
15248                                    ))
15249                                })?;
15250                                let default = col.default.clone().ok_or_else(|| {
15251                                    EngineError::Unsupported(alloc::format!(
15252                                        "FOREIGN KEY ON DELETE SET DEFAULT: column \
15253                                         {child_name:?}.{:?} has no DEFAULT declared",
15254                                        col.name,
15255                                    ))
15256                                })?;
15257                                entry.insert((child_row_idx, li), default);
15258                            }
15259                        }
15260                    }
15261                }
15262            }
15263        }
15264    }
15265    // Flatten the three plans into the ordered `FkChildStep` list.
15266    // Deletes are applied last per child (after any null/default
15267    // re-writes on the same child) so a child row that's both
15268    // re-written and then cascade-deleted only ends up deleted —
15269    // but in v7.6.5 SetNull/Cascade never overlap on the same row
15270    // (a single FK chooses exactly one action), so the order is
15271    // mostly a precaution.
15272    let mut steps: Vec<FkChildStep> = Vec::new();
15273    for (child_table, entries) in setnull_plan {
15274        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
15275        steps.push(FkChildStep {
15276            child_table,
15277            action: FkChildAction::SetNull { positions, columns },
15278        });
15279    }
15280    for (child_table, entries) in setdefault_plan {
15281        let mut positions = Vec::with_capacity(entries.len());
15282        let mut columns = Vec::with_capacity(entries.len());
15283        let mut defaults = Vec::with_capacity(entries.len());
15284        for ((p, c), v) in entries {
15285            positions.push(p);
15286            columns.push(c);
15287            defaults.push(v);
15288        }
15289        steps.push(FkChildStep {
15290            child_table,
15291            action: FkChildAction::SetDefault {
15292                positions,
15293                columns,
15294                defaults,
15295            },
15296        });
15297    }
15298    for (child_table, positions) in delete_plan {
15299        steps.push(FkChildStep {
15300            child_table,
15301            action: FkChildAction::Delete {
15302                positions: positions.into_iter().collect(),
15303            },
15304        });
15305    }
15306    Ok(steps)
15307}
15308
15309/// v7.6.6 — plan FK fallout for an UPDATE that mutates parent-side
15310/// PK/UNIQUE columns. Walks every other table whose FK references
15311/// `parent_table_name`; for each FK whose parent_columns overlap a
15312/// mutated column, decides the action by `fk.on_update`.
15313///
15314///   - RESTRICT / NoAction → error if any child references the OLD
15315///     value
15316///   - CASCADE → child FK columns get rewritten to the NEW parent
15317///     value (a SetNull-style update step with the new value)
15318///   - SetNull → child FK columns set to NULL
15319///   - SetDefault → child FK columns set to declared default
15320///
15321/// `plan_with_old` is `(row_position, old_values, new_values)` so
15322/// the planner can detect "did this row's parent key actually
15323/// change?" — only rows where at least one referenced parent
15324/// column moved trigger inbound work.
15325fn plan_fk_parent_updates(
15326    catalog: &Catalog,
15327    parent_table_name: &str,
15328    plan_with_old: &[(usize, Vec<Value>, Vec<Value>)],
15329) -> Result<Vec<FkChildStep>, EngineError> {
15330    use alloc::collections::BTreeMap;
15331    if plan_with_old.is_empty() {
15332        return Ok(Vec::new());
15333    }
15334    // For each child table we may touch, build per-child step
15335    // lists. UPDATE never deletes children — `delete_plan` stays
15336    // empty here but is kept structurally aligned with
15337    // `plan_fk_parent_deletions` for future use.
15338    let delete_plan: BTreeMap<String, alloc::collections::BTreeSet<usize>> = BTreeMap::new();
15339    let mut setnull_plan: BTreeMap<String, alloc::collections::BTreeSet<(usize, usize)>> =
15340        BTreeMap::new();
15341    let mut setdefault_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15342    // Cascade-update plan: child_table → row_idx → col_idx → new_value
15343    let mut cascade_plan: BTreeMap<String, BTreeMap<(usize, usize), Value>> = BTreeMap::new();
15344
15345    for child_name in catalog.table_names() {
15346        let child = catalog
15347            .get(&child_name)
15348            .expect("table_names → catalog.get total");
15349        for fk in &child.schema().foreign_keys {
15350            if fk.parent_table != parent_table_name {
15351                continue;
15352            }
15353            for (_pos, old_row, new_row) in plan_with_old {
15354                // Did any parent FK column change?
15355                let key_changed = fk
15356                    .parent_columns
15357                    .iter()
15358                    .any(|&pi| old_row.get(pi) != new_row.get(pi));
15359                if !key_changed {
15360                    continue;
15361                }
15362                // The OLD parent key — used to find referring children.
15363                let old_key: Vec<&Value> =
15364                    fk.parent_columns.iter().map(|&pi| &old_row[pi]).collect();
15365                if old_key.iter().any(|v| matches!(v, Value::Null)) {
15366                    // NULL parent has no children — skip.
15367                    continue;
15368                }
15369                let new_key: Vec<&Value> =
15370                    fk.parent_columns.iter().map(|&pi| &new_row[pi]).collect();
15371                for (child_row_idx, child_row) in child.rows().iter().enumerate() {
15372                    // Self-ref same-row updates: a row updating its
15373                    // own PK doesn't restrict itself.
15374                    if child_name == parent_table_name
15375                        && plan_with_old.iter().any(|(p, _, _)| *p == child_row_idx)
15376                    {
15377                        continue;
15378                    }
15379                    let matches_key = fk
15380                        .local_columns
15381                        .iter()
15382                        .enumerate()
15383                        .all(|(i, &li)| child_row.values.get(li) == Some(old_key[i]));
15384                    if !matches_key {
15385                        continue;
15386                    }
15387                    match fk.on_update {
15388                        spg_storage::FkAction::Restrict | spg_storage::FkAction::NoAction => {
15389                            return Err(EngineError::Unsupported(alloc::format!(
15390                                "FOREIGN KEY violation: UPDATE on {parent_table_name:?} PK is \
15391                                 restricted by FK from {child_name:?}.{:?}",
15392                                fk.local_columns,
15393                            )));
15394                        }
15395                        spg_storage::FkAction::Cascade => {
15396                            // Rewrite child FK columns to new key.
15397                            let entry = cascade_plan.entry(child_name.clone()).or_default();
15398                            for (i, &li) in fk.local_columns.iter().enumerate() {
15399                                entry.insert((child_row_idx, li), new_key[i].clone());
15400                            }
15401                        }
15402                        spg_storage::FkAction::SetNull => {
15403                            for &li in &fk.local_columns {
15404                                let col = child.schema().columns.get(li).ok_or_else(|| {
15405                                    EngineError::Unsupported(alloc::format!(
15406                                        "FK local column {li} missing in {child_name:?}"
15407                                    ))
15408                                })?;
15409                                if !col.nullable {
15410                                    return Err(EngineError::Unsupported(alloc::format!(
15411                                        "FOREIGN KEY ON UPDATE SET NULL: column \
15412                                         {child_name:?}.{:?} is NOT NULL",
15413                                        col.name,
15414                                    )));
15415                                }
15416                            }
15417                            let entry = setnull_plan.entry(child_name.clone()).or_default();
15418                            for &li in &fk.local_columns {
15419                                entry.insert((child_row_idx, li));
15420                            }
15421                        }
15422                        spg_storage::FkAction::SetDefault => {
15423                            let entry = setdefault_plan.entry(child_name.clone()).or_default();
15424                            for &li in &fk.local_columns {
15425                                let col = child.schema().columns.get(li).ok_or_else(|| {
15426                                    EngineError::Unsupported(alloc::format!(
15427                                        "FK local column {li} missing in {child_name:?}"
15428                                    ))
15429                                })?;
15430                                let default = col.default.clone().ok_or_else(|| {
15431                                    EngineError::Unsupported(alloc::format!(
15432                                        "FOREIGN KEY ON UPDATE SET DEFAULT: column \
15433                                         {child_name:?}.{:?} has no DEFAULT",
15434                                        col.name,
15435                                    ))
15436                                })?;
15437                                entry.insert((child_row_idx, li), default);
15438                            }
15439                        }
15440                    }
15441                }
15442            }
15443        }
15444    }
15445    // Flatten into FkChildStep list. UPDATE doesn't produce
15446    // DeleteSteps (CASCADE on UPDATE just rewrites FK values).
15447    let mut steps: Vec<FkChildStep> = Vec::new();
15448    for (child_table, entries) in cascade_plan {
15449        let mut positions = Vec::with_capacity(entries.len());
15450        let mut columns = Vec::with_capacity(entries.len());
15451        let mut defaults = Vec::with_capacity(entries.len());
15452        for ((p, c), v) in entries {
15453            positions.push(p);
15454            columns.push(c);
15455            defaults.push(v);
15456        }
15457        // We reuse `FkChildAction::SetDefault` for cascade-update:
15458        // both shapes are "write a known value into specific cells"
15459        // — `apply_per_cell_writes` doesn't care whether the value
15460        // came from a DEFAULT declaration or a new parent key.
15461        steps.push(FkChildStep {
15462            child_table,
15463            action: FkChildAction::SetDefault {
15464                positions,
15465                columns,
15466                defaults,
15467            },
15468        });
15469    }
15470    for (child_table, entries) in setnull_plan {
15471        let (positions, columns): (Vec<usize>, Vec<usize>) = entries.into_iter().unzip();
15472        steps.push(FkChildStep {
15473            child_table,
15474            action: FkChildAction::SetNull { positions, columns },
15475        });
15476    }
15477    for (child_table, entries) in setdefault_plan {
15478        let mut positions = Vec::with_capacity(entries.len());
15479        let mut columns = Vec::with_capacity(entries.len());
15480        let mut defaults = Vec::with_capacity(entries.len());
15481        for ((p, c), v) in entries {
15482            positions.push(p);
15483            columns.push(c);
15484            defaults.push(v);
15485        }
15486        steps.push(FkChildStep {
15487            child_table,
15488            action: FkChildAction::SetDefault {
15489                positions,
15490                columns,
15491                defaults,
15492            },
15493        });
15494    }
15495    let _ = delete_plan; // UPDATE never deletes children.
15496    Ok(steps)
15497}
15498
15499/// v7.6.5 — apply one FK child step to the catalog. Encapsulates
15500/// the three action variants so the DELETE executor stays a
15501/// simple loop over the planned steps.
15502fn apply_fk_child_step(catalog: &mut Catalog, step: &FkChildStep) -> Result<(), EngineError> {
15503    let child = catalog.get_mut(&step.child_table).ok_or_else(|| {
15504        EngineError::Storage(StorageError::TableNotFound {
15505            name: step.child_table.clone(),
15506        })
15507    })?;
15508    match &step.action {
15509        FkChildAction::Delete { positions } => {
15510            let _ = child.delete_rows(positions);
15511        }
15512        FkChildAction::SetNull { positions, columns } => {
15513            apply_per_cell_writes(child, positions, columns, |_| Value::Null)?;
15514        }
15515        FkChildAction::SetDefault {
15516            positions,
15517            columns,
15518            defaults,
15519        } => {
15520            apply_per_cell_writes(child, positions, columns, |i| defaults[i].clone())?;
15521        }
15522    }
15523    Ok(())
15524}
15525
15526/// v7.6.5 — write new values into selected child cells via
15527/// `Table::update_row` (the catalog's existing UPDATE entry).
15528/// Groups writes by row position so multi-column updates on the
15529/// same row only call `update_row` once. `value_for(i)` produces
15530/// the new value for the i-th (position, column) entry.
15531fn apply_per_cell_writes(
15532    child: &mut spg_storage::Table,
15533    positions: &[usize],
15534    columns: &[usize],
15535    mut value_for: impl FnMut(usize) -> Value,
15536) -> Result<(), EngineError> {
15537    use alloc::collections::BTreeMap;
15538    let mut by_row: BTreeMap<usize, Vec<(usize, Value)>> = BTreeMap::new();
15539    for i in 0..positions.len() {
15540        by_row
15541            .entry(positions[i])
15542            .or_default()
15543            .push((columns[i], value_for(i)));
15544    }
15545    for (pos, mutations) in by_row {
15546        let mut new_values = child.rows()[pos].values.clone();
15547        for (col, v) in mutations {
15548            if let Some(slot) = new_values.get_mut(col) {
15549                *slot = v;
15550            }
15551        }
15552        child
15553            .update_row(pos, new_values)
15554            .map_err(EngineError::Storage)?;
15555    }
15556    Ok(())
15557}
15558
15559fn fk_action_sql_to_storage(a: spg_sql::ast::FkAction) -> spg_storage::FkAction {
15560    match a {
15561        spg_sql::ast::FkAction::Restrict => spg_storage::FkAction::Restrict,
15562        spg_sql::ast::FkAction::Cascade => spg_storage::FkAction::Cascade,
15563        spg_sql::ast::FkAction::SetNull => spg_storage::FkAction::SetNull,
15564        spg_sql::ast::FkAction::SetDefault => spg_storage::FkAction::SetDefault,
15565        spg_sql::ast::FkAction::NoAction => spg_storage::FkAction::NoAction,
15566    }
15567}
15568
15569/// v7.9.21 — resolve a column's DEFAULT for INSERT-time
15570/// default-fill. Free fn (rather than `&self`) so callers
15571/// with an active `&mut Table` borrow can still use it.
15572/// Literal defaults take the cached path (`col.default`);
15573/// runtime defaults hit `clock_fn` at each call. mailrs G4.
15574fn resolve_column_default_free(
15575    col: &ColumnSchema,
15576    clock_fn: Option<ClockFn>,
15577) -> Result<Value, EngineError> {
15578    if let Some(rt) = &col.runtime_default {
15579        return eval_runtime_default_free(rt, col.ty, clock_fn);
15580    }
15581    Ok(col.default.clone().unwrap_or(Value::Null))
15582}
15583
15584fn eval_runtime_default_free(
15585    rt: &str,
15586    ty: DataType,
15587    clock_fn: Option<ClockFn>,
15588) -> Result<Value, EngineError> {
15589    let s = rt.trim().to_ascii_lowercase();
15590    // v7.17.0 Phase 2.1 — also strip `(N)` precision suffix
15591    // so MySQL `CURRENT_TIMESTAMP(6)` resolves the same as
15592    // bare `CURRENT_TIMESTAMP`. SPG stores TIMESTAMP at fixed
15593    // microsecond resolution; the precision modifier is
15594    // parser-only.
15595    let with_no_parens = s.trim_end_matches("()");
15596    let canonical: &str = if let Some(open_idx) = with_no_parens.find('(') {
15597        if with_no_parens.ends_with(')') {
15598            &with_no_parens[..open_idx]
15599        } else {
15600            with_no_parens
15601        }
15602    } else {
15603        with_no_parens
15604    };
15605    let now_us = match clock_fn {
15606        Some(f) => f(),
15607        None => 0,
15608    };
15609    let v = match canonical {
15610        "now" | "current_timestamp" | "localtimestamp" => Value::Timestamp(now_us),
15611        "current_date" => Value::Date((now_us / 86_400_000_000) as i32),
15612        "current_time" | "localtime" => Value::Timestamp(now_us),
15613        // v7.17.0 — UUID generators in DEFAULT clauses. Required
15614        // for the canonical Django / Rails / Hibernate `id UUID
15615        // PRIMARY KEY DEFAULT gen_random_uuid()` pattern. Each
15616        // INSERT evaluates the function fresh; the per-row UUID
15617        // is the storage value, not a cached literal.
15618        "gen_random_uuid" | "uuid_generate_v4" => Value::Uuid(eval::gen_random_uuid_bytes()),
15619        other => {
15620            return Err(EngineError::Unsupported(alloc::format!(
15621                "runtime DEFAULT expression {other:?} not supported \
15622                 (v7.17.0 whitelist: now() / current_timestamp / \
15623                 current_date / current_time / localtimestamp / \
15624                 localtime / gen_random_uuid() / \
15625                 uuid_generate_v4())"
15626            )));
15627        }
15628    };
15629    coerce_value(v, ty, "DEFAULT", 0)
15630}
15631
15632/// v7.9.21 — true when a DEFAULT expression needs INSERT-time
15633/// evaluation rather than being cacheable as a literal Value.
15634/// FunctionCall is the immediate case (`now()`,
15635/// `current_timestamp`). Literal expressions and simple sign-
15636/// flipped numerics still take the static-cache path.
15637fn is_runtime_default_expr(expr: &Expr) -> bool {
15638    match expr {
15639        Expr::FunctionCall { .. } => true,
15640        Expr::Unary { expr, .. } => is_runtime_default_expr(expr),
15641        _ => false,
15642    }
15643}
15644
15645/// v7.17.0 Phase 1.4 — INSERT/UPDATE-time enum label check. When
15646/// `col_idx` has a registered label list, the cell value must be
15647/// NULL or one of the labels (case-sensitive per PG).
15648/// v7.17.0 Phase 3.P0-37 — validate + canonicalise a MySQL inline
15649/// SET cell. For non-SET columns this is a no-op pass-through.
15650///
15651/// Semantics:
15652///   * NULL preserved.
15653///   * Empty string → `''` (zero flags).
15654///   * Otherwise split on ',', trim each token, validate every
15655///     token against the column's variant list (error on miss),
15656///     de-dup, then re-emit in DEFINITION order joined by ','.
15657fn canonicalize_set_value(
15658    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
15659    col_idx: usize,
15660    col_name: &str,
15661    value: Value,
15662) -> Result<Value, EngineError> {
15663    let Some(variants) = lookup.get(&col_idx) else {
15664        return Ok(value);
15665    };
15666    match value {
15667        Value::Null => Ok(Value::Null),
15668        Value::Text(s) => {
15669            if s.is_empty() {
15670                return Ok(Value::Text(alloc::string::String::new()));
15671            }
15672            // Collect a presence-set of variant indices to keep
15673            // definition order + handle de-dup in one pass.
15674            let mut present = alloc::vec![false; variants.len()];
15675            for raw in s.split(',') {
15676                let tok = raw.trim();
15677                if tok.is_empty() {
15678                    continue;
15679                }
15680                let idx = variants.iter().position(|v| v == tok).ok_or_else(|| {
15681                    EngineError::Unsupported(alloc::format!(
15682                        "column {col_name:?}: invalid SET token {tok:?}; \
15683                         allowed: {variants:?}"
15684                    ))
15685                })?;
15686                present[idx] = true;
15687            }
15688            // Re-emit in definition order.
15689            let mut out = alloc::string::String::new();
15690            let mut first = true;
15691            for (i, keep) in present.iter().enumerate() {
15692                if !keep {
15693                    continue;
15694                }
15695                if !first {
15696                    out.push(',');
15697                }
15698                first = false;
15699                out.push_str(&variants[i]);
15700            }
15701            Ok(Value::Text(out))
15702        }
15703        other => Err(EngineError::Unsupported(alloc::format!(
15704            "column {col_name:?}: SET-typed column expects TEXT, got {:?}",
15705            other.data_type()
15706        ))),
15707    }
15708}
15709
15710fn enforce_enum_label(
15711    lookup: &alloc::collections::BTreeMap<usize, Vec<String>>,
15712    col_idx: usize,
15713    col_name: &str,
15714    value: &Value,
15715) -> Result<(), EngineError> {
15716    if let Some(labels) = lookup.get(&col_idx) {
15717        match value {
15718            Value::Null => Ok(()),
15719            Value::Text(s) => {
15720                if labels.iter().any(|l| l == s) {
15721                    Ok(())
15722                } else {
15723                    Err(EngineError::Unsupported(alloc::format!(
15724                        "column {col_name:?}: invalid enum label {s:?}; allowed: {labels:?}"
15725                    )))
15726                }
15727            }
15728            other => Err(EngineError::Unsupported(alloc::format!(
15729                "column {col_name:?}: enum-typed column expects TEXT, got {:?}",
15730                other.data_type()
15731            ))),
15732        }
15733    } else {
15734        Ok(())
15735    }
15736}
15737
15738fn column_def_to_schema(c: ColumnDef) -> Result<ColumnSchema, EngineError> {
15739    let ty = column_type_to_data_type(c.ty);
15740    let mut schema = ColumnSchema::new(c.name.clone(), ty, c.nullable);
15741    // user_type_ref is the raw ident the parser couldn't resolve
15742    // to a built-in; classification into enum vs domain happens
15743    // at exec_create_table where we have catalog access. We
15744    // park it temporarily as user_enum_type and the engine
15745    // promotes domain bindings to user_domain_type before the
15746    // table is stored.
15747    if let Some(name) = c.user_type_ref {
15748        schema.user_enum_type = Some(name);
15749    }
15750    // v7.17.0 Phase 2.1 — render the ON UPDATE expression to
15751    // canonical text (the engine re-parses at UPDATE time).
15752    if let Some(expr) = c.on_update_runtime {
15753        schema.on_update_runtime = Some(alloc::format!("{expr}"));
15754    }
15755    // v7.17.0 Phase 2.5 — bridge the AST `Collation` enum to the
15756    // storage one. Same variants, different crates (spg-storage
15757    // owns no dep on spg-sql).
15758    schema.collation = match c.collation {
15759        spg_sql::ast::Collation::Binary => spg_storage::Collation::Binary,
15760        spg_sql::ast::Collation::CaseInsensitive => spg_storage::Collation::CaseInsensitive,
15761    };
15762    // v7.17.0 Phase 4.4 — MySQL `UNSIGNED` flag propagates to
15763    // storage so engine INSERT / UPDATE can range-check.
15764    schema.is_unsigned = c.is_unsigned;
15765    // v7.17.0 Phase 3.P0-36 — MySQL inline ENUM variant list.
15766    // INSERT validation lives in coerce_value (Text → Text path
15767    // with the column's variant list as the accept-set).
15768    schema.inline_enum_variants = c.inline_enum_variants;
15769    // v7.17.0 Phase 3.P0-37 — MySQL inline SET variant list.
15770    // INSERT canonicalisation (de-dup + sort by definition order)
15771    // lives in the exec_insert path next to the ENUM check.
15772    schema.inline_set_variants = c.inline_set_variants;
15773    if let Some(default_expr) = c.default {
15774        // v7.9.21 — distinguish literal defaults (evaluated once
15775        // at CREATE TABLE) from expression defaults (deferred to
15776        // INSERT). Function calls (`now()`, `current_timestamp`
15777        // — see v7.9.20 keyword promotion) take the runtime path.
15778        // Literals continue to cache. mailrs G4.
15779        if is_runtime_default_expr(&default_expr) {
15780            let display = alloc::format!("{default_expr}");
15781            schema = schema.with_runtime_default(display);
15782        } else {
15783            let raw = literal_expr_to_value(default_expr)?;
15784            let coerced = coerce_value(raw, ty, &c.name, 0)?;
15785            schema = schema.with_default(coerced);
15786        }
15787    }
15788    if c.auto_increment {
15789        // AUTO_INCREMENT only makes sense on integer-shaped columns.
15790        if !matches!(ty, DataType::SmallInt | DataType::Int | DataType::BigInt) {
15791            return Err(EngineError::Unsupported(alloc::format!(
15792                "AUTO_INCREMENT requires an integer column type, got {ty:?}"
15793            )));
15794        }
15795        schema = schema.with_auto_increment();
15796    }
15797    Ok(schema)
15798}
15799
15800/// v7.10.4 — decode a BYTEA literal. Accepts:
15801///   * `\xDEADBEEF` (case-insensitive hex; whitespace stripped)
15802///   * `Hello\000world` (backslash escape form; `\\` for literal backslash)
15803///   * Anything else → raw UTF-8 bytes of the input (PG accepts this too).
15804fn decode_bytea_literal(s: &str) -> Result<alloc::vec::Vec<u8>, &'static str> {
15805    let s = s.trim();
15806    if let Some(hex) = s.strip_prefix("\\x").or_else(|| s.strip_prefix("\\X")) {
15807        // Hex form. Each pair of hex digits → one byte.
15808        let cleaned: alloc::string::String = hex.chars().filter(|c| !c.is_whitespace()).collect();
15809        if cleaned.len() % 2 != 0 {
15810            return Err("odd-length hex literal");
15811        }
15812        let mut out = alloc::vec::Vec::with_capacity(cleaned.len() / 2);
15813        let cleaned_bytes = cleaned.as_bytes();
15814        for i in (0..cleaned_bytes.len()).step_by(2) {
15815            let hi = hex_nibble(cleaned_bytes[i])?;
15816            let lo = hex_nibble(cleaned_bytes[i + 1])?;
15817            out.push((hi << 4) | lo);
15818        }
15819        return Ok(out);
15820    }
15821    // Escape form or raw. Walk char-by-char; `\\` and `\NNN` octal
15822    // sequences decode; anything else is a literal byte.
15823    let bytes = s.as_bytes();
15824    let mut out = alloc::vec::Vec::with_capacity(bytes.len());
15825    let mut i = 0;
15826    while i < bytes.len() {
15827        let b = bytes[i];
15828        if b == b'\\' && i + 1 < bytes.len() {
15829            let n = bytes[i + 1];
15830            if n == b'\\' {
15831                out.push(b'\\');
15832                i += 2;
15833                continue;
15834            }
15835            if n.is_ascii_digit()
15836                && i + 3 < bytes.len()
15837                && bytes[i + 2].is_ascii_digit()
15838                && bytes[i + 3].is_ascii_digit()
15839            {
15840                let oct = |x: u8| (x - b'0') as u32;
15841                let v = oct(n) * 64 + oct(bytes[i + 2]) * 8 + oct(bytes[i + 3]);
15842                if v <= 0xFF {
15843                    out.push(v as u8);
15844                    i += 4;
15845                    continue;
15846                }
15847            }
15848        }
15849        out.push(b);
15850        i += 1;
15851    }
15852    Ok(out)
15853}
15854
15855fn hex_nibble(b: u8) -> Result<u8, &'static str> {
15856    match b {
15857        b'0'..=b'9' => Ok(b - b'0'),
15858        b'a'..=b'f' => Ok(b - b'a' + 10),
15859        b'A'..=b'F' => Ok(b - b'A' + 10),
15860        _ => Err("invalid hex digit"),
15861    }
15862}
15863
15864/// v7.10.11 — decode a PG TEXT[] external array form
15865/// (`{a,b,NULL}` with optional double-quoted elements). The
15866/// engine takes a leading/trailing `{`/`}` and splits at commas.
15867/// Quoted elements (`"hello, world"`) preserve embedded commas;
15868/// `\\` and `\"` decode to literal backslash / quote. Plain
15869/// unquoted `NULL` (case-insensitive) maps to `None`.
15870/// v7.11.13 — pick the array type for `ARRAY[lit, …]` from the
15871/// element values. Single-element-type rules:
15872///   - all NULL / all Text → TextArray
15873///   - all Int (or Int+NULL) → IntArray
15874///   - any BigInt without Text → BigIntArray (widening)
15875///   - any Text → TextArray (fallback; non-string elements
15876///     render as text)
15877fn array_literal_widen(items: alloc::vec::Vec<Value>) -> Value {
15878    let mut has_text = false;
15879    let mut has_bigint = false;
15880    let mut has_int = false;
15881    for v in &items {
15882        match v {
15883            Value::Null => {}
15884            Value::Text(_) | Value::Json(_) => has_text = true,
15885            Value::BigInt(_) => has_bigint = true,
15886            Value::Int(_) | Value::SmallInt(_) => has_int = true,
15887            _ => has_text = true,
15888        }
15889    }
15890    if has_text || (!has_bigint && !has_int) {
15891        let out: alloc::vec::Vec<Option<alloc::string::String>> = items
15892            .into_iter()
15893            .map(|v| match v {
15894                Value::Null => None,
15895                Value::Text(s) | Value::Json(s) => Some(s),
15896                other => Some(alloc::format!("{other:?}")),
15897            })
15898            .collect();
15899        return Value::TextArray(out);
15900    }
15901    if has_bigint {
15902        let out: alloc::vec::Vec<Option<i64>> = items
15903            .into_iter()
15904            .map(|v| match v {
15905                Value::Null => None,
15906                Value::Int(n) => Some(i64::from(n)),
15907                Value::SmallInt(n) => Some(i64::from(n)),
15908                Value::BigInt(n) => Some(n),
15909                _ => unreachable!("widen: unexpected non-integer in BigInt path"),
15910            })
15911            .collect();
15912        return Value::BigIntArray(out);
15913    }
15914    let out: alloc::vec::Vec<Option<i32>> = items
15915        .into_iter()
15916        .map(|v| match v {
15917            Value::Null => None,
15918            Value::Int(n) => Some(n),
15919            Value::SmallInt(n) => Some(i32::from(n)),
15920            _ => unreachable!("widen: unexpected non-i32-compatible in Int path"),
15921        })
15922        .collect();
15923    Value::IntArray(out)
15924}
15925
15926fn decode_text_array_literal(
15927    s: &str,
15928) -> Result<alloc::vec::Vec<Option<alloc::string::String>>, &'static str> {
15929    let trimmed = s.trim();
15930    let inner = trimmed
15931        .strip_prefix('{')
15932        .and_then(|x| x.strip_suffix('}'))
15933        .ok_or("TEXT[] literal must be enclosed in '{...}'")?;
15934    let mut out: alloc::vec::Vec<Option<alloc::string::String>> = alloc::vec::Vec::new();
15935    if inner.trim().is_empty() {
15936        return Ok(out);
15937    }
15938    let bytes = inner.as_bytes();
15939    let mut i = 0;
15940    while i <= bytes.len() {
15941        // Skip leading whitespace.
15942        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
15943            i += 1;
15944        }
15945        // Quoted element.
15946        if i < bytes.len() && bytes[i] == b'"' {
15947            i += 1; // open quote
15948            let mut buf = alloc::string::String::new();
15949            while i < bytes.len() && bytes[i] != b'"' {
15950                if bytes[i] == b'\\' && i + 1 < bytes.len() {
15951                    buf.push(bytes[i + 1] as char);
15952                    i += 2;
15953                } else {
15954                    buf.push(bytes[i] as char);
15955                    i += 1;
15956                }
15957            }
15958            if i >= bytes.len() {
15959                return Err("unterminated quoted element");
15960            }
15961            i += 1; // close quote
15962            out.push(Some(buf));
15963        } else {
15964            // Unquoted element — read until next comma or end.
15965            let start = i;
15966            while i < bytes.len() && bytes[i] != b',' {
15967                i += 1;
15968            }
15969            let raw = inner[start..i].trim();
15970            if raw.eq_ignore_ascii_case("NULL") {
15971                out.push(None);
15972            } else {
15973                out.push(Some(alloc::string::ToString::to_string(raw)));
15974            }
15975        }
15976        // Skip whitespace, expect comma or end.
15977        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
15978            i += 1;
15979        }
15980        if i >= bytes.len() {
15981            break;
15982        }
15983        if bytes[i] != b',' {
15984            return Err("expected ',' between TEXT[] elements");
15985        }
15986        i += 1;
15987    }
15988    Ok(out)
15989}
15990
15991/// v7.10.11 — encode a TEXT[] back into the PG external array
15992/// form. NULL elements become the literal `NULL`; elements
15993/// containing commas, quotes, backslashes, or braces are
15994/// double-quoted with `\\` / `\"` escapes.
15995fn encode_text_array(items: &[Option<alloc::string::String>]) -> alloc::string::String {
15996    let mut out = alloc::string::String::with_capacity(2 + items.len() * 8);
15997    out.push('{');
15998    for (i, item) in items.iter().enumerate() {
15999        if i > 0 {
16000            out.push(',');
16001        }
16002        match item {
16003            None => out.push_str("NULL"),
16004            Some(s) => {
16005                let needs_quote = s.is_empty()
16006                    || s.eq_ignore_ascii_case("NULL")
16007                    || s.chars()
16008                        .any(|c| matches!(c, ',' | '{' | '}' | '"' | '\\' | ' ' | '\t'));
16009                if needs_quote {
16010                    out.push('"');
16011                    for c in s.chars() {
16012                        if c == '"' || c == '\\' {
16013                            out.push('\\');
16014                        }
16015                        out.push(c);
16016                    }
16017                    out.push('"');
16018                } else {
16019                    out.push_str(s);
16020                }
16021            }
16022        }
16023    }
16024    out.push('}');
16025    out
16026}
16027
16028/// v7.10.4 — encode BYTEA bytes in PG hex output format
16029/// (`\x` prefix, lowercase hex pairs). Used by Text-side
16030/// round-trip + the wire layer's text-mode encoder.
16031fn encode_bytea_hex(b: &[u8]) -> alloc::string::String {
16032    let mut out = alloc::string::String::with_capacity(2 + 2 * b.len());
16033    out.push_str("\\x");
16034    for byte in b {
16035        let hi = byte >> 4;
16036        let lo = byte & 0x0F;
16037        out.push(hex_digit(hi));
16038        out.push(hex_digit(lo));
16039    }
16040    out
16041}
16042
16043const fn hex_digit(n: u8) -> char {
16044    match n {
16045        0..=9 => (b'0' + n) as char,
16046        10..=15 => (b'a' + n - 10) as char,
16047        _ => '?',
16048    }
16049}
16050
16051/// v7.17.0 Phase 3.P0-39 — parse a PG `hstore` text literal into
16052/// a flat key→value map. Empty string → empty map. Duplicate
16053/// keys take last-write-wins (matches PG `hstore_in`).
16054///
16055/// Accepted shapes (minimal subset):
16056///   * `'a=>1, b=>2'`            — bareword keys/values
16057///   * `'"a"=>"1", "b"=>"2"'`    — quoted keys/values
16058///   * `'a=>NULL'`               — case-insensitive NULL token
16059///     surfaces as `None` (no quotes around NULL)
16060///
16061/// Returns None on parse failure → caller surfaces as hard error.
16062fn parse_hstore_str(
16063    s: &str,
16064) -> Option<Vec<(alloc::string::String, Option<alloc::string::String>)>> {
16065    let bytes = s.as_bytes();
16066    let mut i = 0;
16067    let mut out: Vec<(alloc::string::String, Option<alloc::string::String>)> = Vec::new();
16068    let skip_ws = |bytes: &[u8], i: &mut usize| {
16069        while *i < bytes.len() && matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r') {
16070            *i += 1;
16071        }
16072    };
16073    let parse_token = |bytes: &[u8], i: &mut usize| -> Option<alloc::string::String> {
16074        if *i >= bytes.len() {
16075            return None;
16076        }
16077        if bytes[*i] == b'"' {
16078            *i += 1;
16079            let mut out = alloc::string::String::new();
16080            while *i < bytes.len() {
16081                match bytes[*i] {
16082                    b'"' => {
16083                        *i += 1;
16084                        return Some(out);
16085                    }
16086                    b'\\' if *i + 1 < bytes.len() => {
16087                        out.push(bytes[*i + 1] as char);
16088                        *i += 2;
16089                    }
16090                    c => {
16091                        out.push(c as char);
16092                        *i += 1;
16093                    }
16094                }
16095            }
16096            None
16097        } else {
16098            let start = *i;
16099            while *i < bytes.len()
16100                && !matches!(bytes[*i], b' ' | b'\t' | b'\n' | b'\r' | b',' | b'=')
16101            {
16102                *i += 1;
16103            }
16104            if *i == start {
16105                return None;
16106            }
16107            Some(alloc::str::from_utf8(&bytes[start..*i]).ok()?.to_string())
16108        }
16109    };
16110    skip_ws(bytes, &mut i);
16111    while i < bytes.len() {
16112        let key = parse_token(bytes, &mut i)?;
16113        skip_ws(bytes, &mut i);
16114        if i + 1 >= bytes.len() || bytes[i] != b'=' || bytes[i + 1] != b'>' {
16115            return None;
16116        }
16117        i += 2;
16118        skip_ws(bytes, &mut i);
16119        // Check for unquoted NULL token (case-insensitive).
16120        let val_token = if i + 4 <= bytes.len()
16121            && bytes[i..i + 4].eq_ignore_ascii_case(b"NULL")
16122            && (i + 4 == bytes.len() || matches!(bytes[i + 4], b' ' | b'\t' | b',' | b'\n' | b'\r'))
16123        {
16124            i += 4;
16125            None
16126        } else {
16127            Some(parse_token(bytes, &mut i)?)
16128        };
16129        // Replace any existing entry with the same key (last-wins).
16130        if let Some(pos) = out.iter().position(|(k, _)| k == &key) {
16131            out[pos] = (key, val_token);
16132        } else {
16133            out.push((key, val_token));
16134        }
16135        skip_ws(bytes, &mut i);
16136        if i >= bytes.len() {
16137            break;
16138        }
16139        if bytes[i] == b',' {
16140            i += 1;
16141            skip_ws(bytes, &mut i);
16142            continue;
16143        }
16144        return None;
16145    }
16146    Some(out)
16147}
16148
16149/// v7.17.0 Phase 3.P0-39 — render a hstore as canonical PG text
16150/// form `"k"=>"v"` (keys and non-NULL values always quoted;
16151/// NULL token is bare).
16152fn format_hstore_str(
16153    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
16154) -> alloc::string::String {
16155    let mut out = alloc::string::String::new();
16156    for (i, (k, v)) in pairs.iter().enumerate() {
16157        if i > 0 {
16158            out.push_str(", ");
16159        }
16160        out.push('"');
16161        out.push_str(k);
16162        out.push_str("\"=>");
16163        match v {
16164            None => out.push_str("NULL"),
16165            Some(val) => {
16166                out.push('"');
16167                out.push_str(val);
16168                out.push('"');
16169            }
16170        }
16171    }
16172    out
16173}
16174
16175/// v7.17.0 Phase 3.P0-39 — pub re-export so pgwire + sqllogictest
16176/// share the single hstore renderer.
16177pub fn format_hstore_text(
16178    pairs: &[(alloc::string::String, Option<alloc::string::String>)],
16179) -> alloc::string::String {
16180    format_hstore_str(pairs)
16181}
16182
16183// ─── v7.17.0 Phase 3.P0-40 — 2D array parse + display ─────────
16184
16185/// Split a PG external 2D-array literal `'{{a,b},{c,d}}'` into
16186/// per-row token lists. Returns Err on shape mismatch.
16187fn split_2d_literal(s: &str) -> Result<Vec<Vec<alloc::string::String>>, &'static str> {
16188    let s = s.trim();
16189    let outer = s
16190        .strip_prefix('{')
16191        .and_then(|x| x.strip_suffix('}'))
16192        .ok_or("missing outer '{...}' braces")?;
16193    let trimmed = outer.trim();
16194    if trimmed.is_empty() {
16195        return Ok(Vec::new());
16196    }
16197    let mut rows: Vec<Vec<alloc::string::String>> = Vec::new();
16198    let mut i = 0;
16199    let bytes = trimmed.as_bytes();
16200    while i < bytes.len() {
16201        while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r' | b',') {
16202            i += 1;
16203        }
16204        if i >= bytes.len() {
16205            break;
16206        }
16207        if bytes[i] != b'{' {
16208            return Err("expected '{' opening a row");
16209        }
16210        i += 1;
16211        let row_start = i;
16212        let mut depth = 1;
16213        while i < bytes.len() && depth > 0 {
16214            match bytes[i] {
16215                b'{' => depth += 1,
16216                b'}' => depth -= 1,
16217                _ => {}
16218            }
16219            if depth > 0 {
16220                i += 1;
16221            }
16222        }
16223        if depth != 0 {
16224            return Err("unbalanced '{...}' in row");
16225        }
16226        let row_text = &trimmed[row_start..i];
16227        i += 1;
16228        let cells: Vec<alloc::string::String> = if row_text.trim().is_empty() {
16229            Vec::new()
16230        } else {
16231            row_text.split(',').map(|t| t.trim().to_string()).collect()
16232        };
16233        rows.push(cells);
16234    }
16235    if let Some(first) = rows.first() {
16236        let cols = first.len();
16237        for r in &rows {
16238            if r.len() != cols {
16239                return Err("ragged 2D array (rows have different column counts)");
16240            }
16241        }
16242    }
16243    Ok(rows)
16244}
16245
16246fn parse_int_2d_literal(s: &str) -> Result<Vec<Vec<Option<i32>>>, &'static str> {
16247    let raw = split_2d_literal(s)?;
16248    raw.into_iter()
16249        .map(|row| {
16250            row.into_iter()
16251                .map(|cell| {
16252                    if cell.eq_ignore_ascii_case("NULL") {
16253                        Ok(None)
16254                    } else {
16255                        cell.parse::<i32>()
16256                            .map(Some)
16257                            .map_err(|_| "invalid int element")
16258                    }
16259                })
16260                .collect()
16261        })
16262        .collect()
16263}
16264
16265fn parse_bigint_2d_literal(s: &str) -> Result<Vec<Vec<Option<i64>>>, &'static str> {
16266    let raw = split_2d_literal(s)?;
16267    raw.into_iter()
16268        .map(|row| {
16269            row.into_iter()
16270                .map(|cell| {
16271                    if cell.eq_ignore_ascii_case("NULL") {
16272                        Ok(None)
16273                    } else {
16274                        cell.parse::<i64>()
16275                            .map(Some)
16276                            .map_err(|_| "invalid bigint element")
16277                    }
16278                })
16279                .collect()
16280        })
16281        .collect()
16282}
16283
16284fn parse_text_2d_literal(s: &str) -> Result<Vec<Vec<Option<alloc::string::String>>>, &'static str> {
16285    let raw = split_2d_literal(s)?;
16286    Ok(raw
16287        .into_iter()
16288        .map(|row| {
16289            row.into_iter()
16290                .map(|cell| {
16291                    if cell.eq_ignore_ascii_case("NULL") {
16292                        None
16293                    } else {
16294                        Some(cell.trim_matches('"').to_string())
16295                    }
16296                })
16297                .collect()
16298        })
16299        .collect())
16300}
16301
16302fn format_int_2d_text(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
16303    let mut out = alloc::string::String::from("{");
16304    for (i, row) in rows.iter().enumerate() {
16305        if i > 0 {
16306            out.push(',');
16307        }
16308        out.push('{');
16309        for (j, cell) in row.iter().enumerate() {
16310            if j > 0 {
16311                out.push(',');
16312            }
16313            match cell {
16314                None => out.push_str("NULL"),
16315                Some(n) => out.push_str(&alloc::format!("{n}")),
16316            }
16317        }
16318        out.push('}');
16319    }
16320    out.push('}');
16321    out
16322}
16323
16324fn format_bigint_2d_text(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
16325    let mut out = alloc::string::String::from("{");
16326    for (i, row) in rows.iter().enumerate() {
16327        if i > 0 {
16328            out.push(',');
16329        }
16330        out.push('{');
16331        for (j, cell) in row.iter().enumerate() {
16332            if j > 0 {
16333                out.push(',');
16334            }
16335            match cell {
16336                None => out.push_str("NULL"),
16337                Some(n) => out.push_str(&alloc::format!("{n}")),
16338            }
16339        }
16340        out.push('}');
16341    }
16342    out.push('}');
16343    out
16344}
16345
16346fn format_text_2d_text(rows: &[Vec<Option<alloc::string::String>>]) -> alloc::string::String {
16347    let mut out = alloc::string::String::from("{");
16348    for (i, row) in rows.iter().enumerate() {
16349        if i > 0 {
16350            out.push(',');
16351        }
16352        out.push('{');
16353        for (j, cell) in row.iter().enumerate() {
16354            if j > 0 {
16355                out.push(',');
16356            }
16357            match cell {
16358                None => out.push_str("NULL"),
16359                Some(s) => out.push_str(s),
16360            }
16361        }
16362        out.push('}');
16363    }
16364    out.push('}');
16365    out
16366}
16367
16368/// v7.17.0 Phase 3.P0-40 — pub re-exports so pgwire + sqllogictest
16369/// share the single 2D-array renderer.
16370pub fn format_int_2d_text_pub(rows: &[Vec<Option<i32>>]) -> alloc::string::String {
16371    format_int_2d_text(rows)
16372}
16373pub fn format_bigint_2d_text_pub(rows: &[Vec<Option<i64>>]) -> alloc::string::String {
16374    format_bigint_2d_text(rows)
16375}
16376pub fn format_text_2d_text_pub(
16377    rows: &[Vec<Option<alloc::string::String>>],
16378) -> alloc::string::String {
16379    format_text_2d_text(rows)
16380}
16381
16382/// v7.17.0 Phase 3.P0-38 — parse a PG range literal of the form
16383/// `'[lo,up)'` / `'(lo,up]'` / `'[lo,up]'` / `'(lo,up)'` /
16384/// `'empty'`. Lower / upper may be empty (unbounded). Returns
16385/// `None` on any parse failure; caller surfaces as hard error.
16386fn parse_range_str(s: &str, kind: spg_storage::RangeKind) -> Option<Value> {
16387    let s = s.trim();
16388    if s.eq_ignore_ascii_case("empty") {
16389        return Some(Value::Range {
16390            kind,
16391            lower: None,
16392            upper: None,
16393            lower_inc: false,
16394            upper_inc: false,
16395            empty: true,
16396        });
16397    }
16398    let bytes = s.as_bytes();
16399    if bytes.len() < 3 {
16400        return None;
16401    }
16402    let lower_inc = match bytes[0] {
16403        b'[' => true,
16404        b'(' => false,
16405        _ => return None,
16406    };
16407    let upper_inc = match bytes[bytes.len() - 1] {
16408        b']' => true,
16409        b')' => false,
16410        _ => return None,
16411    };
16412    let inner = &s[1..s.len() - 1];
16413    let (lo_text, up_text) = inner.split_once(',')?;
16414    let lower = if lo_text.is_empty() {
16415        None
16416    } else {
16417        Some(alloc::boxed::Box::new(parse_range_element(lo_text, kind)?))
16418    };
16419    let upper = if up_text.is_empty() {
16420        None
16421    } else {
16422        Some(alloc::boxed::Box::new(parse_range_element(up_text, kind)?))
16423    };
16424    Some(Value::Range {
16425        kind,
16426        lower,
16427        upper,
16428        lower_inc,
16429        upper_inc,
16430        empty: false,
16431    })
16432}
16433
16434/// v7.17.0 Phase 3.P0-38 — parse a single range bound text into
16435/// the matching element Value for the RangeKind.
16436fn parse_range_element(text: &str, kind: spg_storage::RangeKind) -> Option<Value> {
16437    let text = text.trim().trim_matches('"');
16438    use spg_storage::RangeKind as K;
16439    match kind {
16440        K::Int4 => text.parse::<i32>().ok().map(Value::Int),
16441        K::Int8 => text.parse::<i64>().ok().map(Value::BigInt),
16442        K::Num => {
16443            // Reuse the Numeric parse via the engine's text-coercion
16444            // path; bail to None on failure.
16445            let dot = text.find('.');
16446            let scale: u8 = dot.map_or(0, |p| (text.len() - p - 1) as u8);
16447            let digits: alloc::string::String = text
16448                .chars()
16449                .filter(|c| *c == '-' || c.is_ascii_digit())
16450                .collect();
16451            let scaled: i128 = digits.parse().ok()?;
16452            Some(Value::Numeric { scaled, scale })
16453        }
16454        K::Ts | K::TsTz => {
16455            // Reuse the existing timestamp parse path. v7.17.0
16456            // expects `'YYYY-MM-DD HH:MM:SS[.ffffff]'` in range
16457            // bounds (TZ offset on TsTz is OOS for the initial
16458            // P0-38; ship plain Timestamp shape).
16459            crate::eval::parse_timestamp_literal(text).map(Value::Timestamp)
16460        }
16461        K::Date => crate::eval::parse_date_literal(text).map(Value::Date),
16462    }
16463}
16464
16465/// v7.17.0 Phase 3.P0-38 — render a Range value as its canonical
16466/// PG text form. Re-exported via [`format_range_text`] for use
16467/// from spg-server's pgwire layer.
16468pub fn format_range_text(v: &Value) -> alloc::string::String {
16469    format_range_str(v)
16470}
16471
16472fn format_range_str(v: &Value) -> alloc::string::String {
16473    let Value::Range {
16474        lower,
16475        upper,
16476        lower_inc,
16477        upper_inc,
16478        empty,
16479        ..
16480    } = v
16481    else {
16482        return alloc::string::String::new();
16483    };
16484    if *empty {
16485        return "empty".into();
16486    }
16487    let mut out = alloc::string::String::new();
16488    out.push(if *lower_inc { '[' } else { '(' });
16489    if let Some(l) = lower {
16490        out.push_str(&format_range_element(l));
16491    }
16492    out.push(',');
16493    if let Some(u) = upper {
16494        out.push_str(&format_range_element(u));
16495    }
16496    out.push(if *upper_inc { ']' } else { ')' });
16497    out
16498}
16499
16500fn format_range_element(v: &Value) -> alloc::string::String {
16501    match v {
16502        Value::Int(n) => alloc::format!("{n}"),
16503        Value::BigInt(n) => alloc::format!("{n}"),
16504        Value::Date(d) => crate::eval::format_date(*d),
16505        Value::Timestamp(t) => crate::eval::format_timestamp(*t),
16506        Value::Numeric { scaled, scale } => crate::eval::format_numeric(*scaled, *scale),
16507        other => alloc::format!("{other:?}"),
16508    }
16509}
16510
16511/// v7.17.0 Phase 3.P0-35 — parse a PG `money` literal into i64
16512/// cents. Accepts:
16513///   * Optional leading `-` (negative)
16514///   * Optional `$` prefix
16515///   * Integer portion with optional `,` thousands separators
16516///   * Optional `.` followed by 1-2 digits (cents); 1 digit
16517///     auto-pads to 2 (`.5` → 50 cents).
16518///
16519/// Returns None on any parse failure — caller surfaces as hard
16520/// SQL error.
16521fn parse_money_str(s: &str) -> Option<i64> {
16522    let s = s.trim();
16523    let (neg, rest) = match s.strip_prefix('-') {
16524        Some(r) => (true, r.trim_start()),
16525        None => (false, s),
16526    };
16527    let rest = rest.strip_prefix('$').unwrap_or(rest).trim_start();
16528    let (int_part, frac_part) = match rest.split_once('.') {
16529        Some((i, f)) => (i, Some(f)),
16530        None => (rest, None),
16531    };
16532    if int_part.is_empty() {
16533        return None;
16534    }
16535    // Validate + strip commas from the integer portion.
16536    let mut int_digits = alloc::string::String::with_capacity(int_part.len());
16537    for b in int_part.bytes() {
16538        match b {
16539            b',' => {}
16540            b'0'..=b'9' => int_digits.push(b as char),
16541            _ => return None,
16542        }
16543    }
16544    if int_digits.is_empty() {
16545        return None;
16546    }
16547    let dollars: i64 = int_digits.parse().ok()?;
16548    let cents: i64 = match frac_part {
16549        None => 0,
16550        Some(f) => {
16551            if f.is_empty() || f.len() > 2 || !f.bytes().all(|b| b.is_ascii_digit()) {
16552                return None;
16553            }
16554            let padded = if f.len() == 1 {
16555                alloc::format!("{f}0")
16556            } else {
16557                f.to_string()
16558            };
16559            padded.parse().ok()?
16560        }
16561    };
16562    let total = dollars.checked_mul(100)?.checked_add(cents)?;
16563    Some(if neg { -total } else { total })
16564}
16565
16566/// v7.17.0 Phase 3.P0-34 — parse a PG `timetz` literal
16567/// `HH:MM:SS[.fraction]±HH[:MM]` into (us, offset_secs).
16568///
16569/// The offset suffix is MANDATORY: SPG doesn't have a session TZ
16570/// wired into eval, so a bare `HH:MM:SS` literal would be
16571/// ambiguous. Returns None for any parse failure or out-of-range
16572/// component — caller surfaces as a hard SQL error.
16573///
16574/// Offset range: ±14 hours (±50400 seconds), matching PG's
16575/// internal limit.
16576fn parse_timetz_str(s: &str) -> Option<(i64, i32)> {
16577    let s = s.trim();
16578    // Find the offset sign — scan from right since the time part
16579    // never contains '+' / '-' (after the optional fractional dot
16580    // it's all digits and ':').
16581    let bytes = s.as_bytes();
16582    let sign_pos = bytes
16583        .iter()
16584        .enumerate()
16585        .rev()
16586        .find(|&(_, &b)| b == b'+' || b == b'-')
16587        .map(|(i, _)| i)?;
16588    if sign_pos == 0 {
16589        return None; // bare sign — no time component
16590    }
16591    let time_part = &s[..sign_pos];
16592    let offset_part = &s[sign_pos..];
16593    let us = parse_time_str(time_part)?;
16594    let sign: i32 = if offset_part.starts_with('+') { 1 } else { -1 };
16595    let offset_body = &offset_part[1..];
16596    let (hh_str, mm_str) = match offset_body.split_once(':') {
16597        Some((h, m)) => (h, m),
16598        None => (offset_body, "0"),
16599    };
16600    let hh: i32 = hh_str.parse().ok()?;
16601    let mm: i32 = mm_str.parse().ok()?;
16602    if !(0..=14).contains(&hh) || !(0..=59).contains(&mm) {
16603        return None;
16604    }
16605    let total = sign * (hh * 3600 + mm * 60);
16606    if total.abs() > 50_400 {
16607        return None;
16608    }
16609    Some((us, total))
16610}
16611
16612/// v7.17.0 Phase 3.P0-33 — funnel an integer literal through MySQL
16613/// YEAR range validation: 0 sentinel or 1901..=2155. Out-of-range
16614/// surfaces as a hard SQL error (no silent truncation, mirrors PG
16615/// `time_in` / `uuid_in` discipline).
16616fn coerce_int_to_year(n: i64, col_name: &str) -> Result<Value, EngineError> {
16617    if n == 0 || (1901..=2155).contains(&n) {
16618        // u16::try_from cannot fail in this range; the cast also
16619        // covers the 0 sentinel.
16620        return Ok(Value::Year(n as u16));
16621    }
16622    Err(EngineError::Eval(EvalError::TypeMismatch {
16623        detail: alloc::format!(
16624            "year value out of range: {n} (column `{col_name}`; \
16625             MySQL accepts 0 or 1901..=2155)"
16626        ),
16627    }))
16628}
16629
16630/// v7.17.0 Phase 3.P0-32 — parse a PG `time` literal
16631/// `HH:MM:SS[.fraction]` into microseconds since 00:00:00.
16632///
16633/// Accepts:
16634///   * `HH:MM:SS`            — exact-second precision
16635///   * `HH:MM:SS.f` .. `.ffffff` — 1-6 fractional digits, right-padded
16636///     with zeros to microseconds
16637///
16638/// Range: hour 0..=23, minute 0..=59, second 0..=59. Anything else
16639/// returns None — caller surfaces as a hard SQL error (no silent
16640/// truncation, matches PG's `time_in` behaviour).
16641fn parse_time_str(s: &str) -> Option<i64> {
16642    let s = s.trim();
16643    let (hms, frac) = match s.split_once('.') {
16644        Some((h, f)) => (h, Some(f)),
16645        None => (s, None),
16646    };
16647    let mut parts = hms.split(':');
16648    let hh: u32 = parts.next()?.parse().ok()?;
16649    let mm: u32 = parts.next()?.parse().ok()?;
16650    let ss: u32 = parts.next()?.parse().ok()?;
16651    if parts.next().is_some() {
16652        return None;
16653    }
16654    if hh > 23 || mm > 59 || ss > 59 {
16655        return None;
16656    }
16657    let frac_us: i64 = match frac {
16658        None => 0,
16659        Some(f) => {
16660            if f.is_empty() || f.len() > 6 || !f.bytes().all(|b| b.is_ascii_digit()) {
16661                return None;
16662            }
16663            // Right-pad with zeros so '.5' = 500000 µsec.
16664            let mut padded = alloc::string::String::with_capacity(6);
16665            padded.push_str(f);
16666            while padded.len() < 6 {
16667                padded.push('0');
16668            }
16669            padded.parse().ok()?
16670        }
16671    };
16672    Some(
16673        i64::from(hh) * 3_600_000_000
16674            + i64::from(mm) * 60_000_000
16675            + i64::from(ss) * 1_000_000
16676            + frac_us,
16677    )
16678}
16679
16680const fn column_type_to_data_type(t: ColumnTypeName) -> DataType {
16681    match t {
16682        ColumnTypeName::SmallInt => DataType::SmallInt,
16683        ColumnTypeName::Int => DataType::Int,
16684        ColumnTypeName::BigInt => DataType::BigInt,
16685        ColumnTypeName::Float => DataType::Float,
16686        ColumnTypeName::Text => DataType::Text,
16687        ColumnTypeName::Varchar(n) => DataType::Varchar(n),
16688        ColumnTypeName::Char(n) => DataType::Char(n),
16689        ColumnTypeName::Bool => DataType::Bool,
16690        ColumnTypeName::Vector { dim, encoding } => DataType::Vector {
16691            dim,
16692            encoding: match encoding {
16693                SqlVecEncoding::F32 => VecEncoding::F32,
16694                SqlVecEncoding::Sq8 => VecEncoding::Sq8,
16695                SqlVecEncoding::F16 => VecEncoding::F16,
16696            },
16697        },
16698        ColumnTypeName::Numeric(precision, scale) => DataType::Numeric { precision, scale },
16699        ColumnTypeName::Date => DataType::Date,
16700        ColumnTypeName::Timestamp => DataType::Timestamp,
16701        ColumnTypeName::Timestamptz => DataType::Timestamptz,
16702        ColumnTypeName::Json => DataType::Json,
16703        ColumnTypeName::Jsonb => DataType::Jsonb,
16704        ColumnTypeName::Bytes => DataType::Bytes,
16705        ColumnTypeName::TextArray => DataType::TextArray,
16706        ColumnTypeName::IntArray => DataType::IntArray,
16707        ColumnTypeName::BigIntArray => DataType::BigIntArray,
16708        ColumnTypeName::TsVector => DataType::TsVector,
16709        ColumnTypeName::TsQuery => DataType::TsQuery,
16710        ColumnTypeName::Uuid => DataType::Uuid,
16711        ColumnTypeName::Time => DataType::Time,
16712        ColumnTypeName::Year => DataType::Year,
16713        ColumnTypeName::TimeTz => DataType::TimeTz,
16714        ColumnTypeName::Money => DataType::Money,
16715        ColumnTypeName::Range(k) => DataType::Range(match k {
16716            spg_sql::ast::RangeKindAst::Int4 => spg_storage::RangeKind::Int4,
16717            spg_sql::ast::RangeKindAst::Int8 => spg_storage::RangeKind::Int8,
16718            spg_sql::ast::RangeKindAst::Num => spg_storage::RangeKind::Num,
16719            spg_sql::ast::RangeKindAst::Ts => spg_storage::RangeKind::Ts,
16720            spg_sql::ast::RangeKindAst::TsTz => spg_storage::RangeKind::TsTz,
16721            spg_sql::ast::RangeKindAst::Date => spg_storage::RangeKind::Date,
16722        }),
16723        ColumnTypeName::Hstore => DataType::Hstore,
16724        ColumnTypeName::IntArray2D => DataType::IntArray2D,
16725        ColumnTypeName::BigIntArray2D => DataType::BigIntArray2D,
16726        ColumnTypeName::TextArray2D => DataType::TextArray2D,
16727    }
16728}
16729
16730/// Convert an INSERT VALUES expression to a storage Value. Supports literal
16731/// expressions, unary-minus over numeric literals, and pgvector-style
16732/// `'[..]'::vector` cast (v1.2). Anything more complex returns `Unsupported`.
16733fn literal_expr_to_value(expr: Expr) -> Result<Value, EngineError> {
16734    match expr {
16735        Expr::Literal(l) => Ok(literal_to_value(l)),
16736        Expr::Cast { expr, target } => {
16737            let inner_value = literal_expr_to_value(*expr)?;
16738            crate::eval::cast_value(inner_value, target).map_err(EngineError::Eval)
16739        }
16740        Expr::Unary {
16741            op: UnOp::Neg,
16742            expr,
16743        } => match *expr {
16744            Expr::Literal(Literal::Integer(n)) => {
16745                // Fold to i32 if it fits, else BigInt. Parser emits Integer(i64)
16746                // — overflow on negate of i64::MIN is the one edge case.
16747                let neg = n.checked_neg().ok_or_else(|| {
16748                    EngineError::Unsupported("integer literal overflow on negation".into())
16749                })?;
16750                Ok(int_value_for(neg))
16751            }
16752            Expr::Literal(Literal::Float(x)) => Ok(Value::Float(-x)),
16753            other => Err(EngineError::Unsupported(alloc::format!(
16754                "unary minus over non-literal expression: {other:?}"
16755            ))),
16756        },
16757        // v7.10.10 — `ARRAY[lit, lit, …]` constructor accepted at
16758        // INSERT-time. Each element must reduce to a Value through
16759        // `literal_expr_to_value`; NULL elements become `None`.
16760        // v7.11.13 — deduce shape from element values: all Int →
16761        // IntArray; any BigInt → BigIntArray (widening); any Text
16762        // → TextArray. Cast targets (`ARRAY[]::INT[]`) flow through
16763        // the outer Cast arm before reaching here and re-coerce.
16764        Expr::Array(items) => {
16765            let mut materialised: alloc::vec::Vec<Value> =
16766                alloc::vec::Vec::with_capacity(items.len());
16767            for elem in items {
16768                materialised.push(literal_expr_to_value(elem)?);
16769            }
16770            Ok(array_literal_widen(materialised))
16771        }
16772        // Any other Expr shape — fall back to a general evaluation
16773        // against an empty row + empty schema. This unblocks the
16774        // app-common patterns where INSERT VALUES carries a
16775        // non-correlated function call:
16776        //   INSERT INTO t VALUES (concat('U-', 42))
16777        //   INSERT INTO t VALUES (now())
16778        //   INSERT INTO t VALUES (format('%s-%s', 'a', 'b'))
16779        // Any expression that references a column or `$N`
16780        // placeholder fails cleanly inside `eval_expr` with a
16781        // descriptive error; literals + casts + ARRAY[…] continue
16782        // to take the fast paths above so the hot INSERT path is
16783        // unchanged on the common case.
16784        other => {
16785            let empty_schema: alloc::vec::Vec<spg_storage::ColumnSchema> = alloc::vec::Vec::new();
16786            let ctx = EvalContext::new(&empty_schema, None);
16787            let empty_row = spg_storage::Row::new(alloc::vec::Vec::new());
16788            crate::eval::eval_expr(&other, &empty_row, &ctx).map_err(EngineError::Eval)
16789        }
16790    }
16791}
16792
16793fn literal_to_value(l: Literal) -> Value {
16794    match l {
16795        Literal::Integer(n) => int_value_for(n),
16796        Literal::Float(x) => Value::Float(x),
16797        Literal::String(s) => Value::Text(s),
16798        Literal::Bool(b) => Value::Bool(b),
16799        Literal::Null => Value::Null,
16800        Literal::Vector(v) => Value::Vector(v),
16801        Literal::Interval { months, micros, .. } => Value::Interval { months, micros },
16802    }
16803}
16804
16805/// Pick `Int` (`i32`) when the literal fits, else `BigInt`. `INT` vs `BIGINT`
16806/// columns will still enforce the right tag downstream — this is just the
16807/// default we synthesise from an unannotated integer literal.
16808fn int_value_for(n: i64) -> Value {
16809    if let Ok(small) = i32::try_from(n) {
16810        Value::Int(small)
16811    } else {
16812        Value::BigInt(n)
16813    }
16814}
16815
16816/// Widen / narrow `v` to fit `expected`. Numerics permit safe widening
16817/// (`Int → BigInt`, `Int/BigInt → Float`) and best-effort narrowing
16818/// (`BigInt → Int` succeeds only when the value fits in `i32`). Everything
16819/// else returns `TypeMismatch` carrying the column name for caller diagnostics.
16820/// `NULL` is always permitted; the nullability check happens later in storage.
16821#[allow(clippy::too_many_lines)]
16822/// v7.17.0 Phase 4.4 — reject negative integer values on UNSIGNED
16823/// columns. Called after `coerce_value` at each INSERT / UPDATE
16824/// site that has ColumnSchema context. NULL passes through (a
16825/// nullable UNSIGNED column can legitimately hold NULL).
16826fn check_unsigned_range(
16827    v: &Value,
16828    schema: &ColumnSchema,
16829    position: usize,
16830) -> Result<(), EngineError> {
16831    if !schema.is_unsigned {
16832        return Ok(());
16833    }
16834    let n = match v {
16835        Value::SmallInt(x) => i64::from(*x),
16836        Value::Int(x) => i64::from(*x),
16837        Value::BigInt(x) => *x,
16838        _ => return Ok(()), // non-integer cells (NULL, default) skip
16839    };
16840    if n < 0 {
16841        return Err(EngineError::Unsupported(alloc::format!(
16842            "column {:?} is UNSIGNED but got negative value {n} at position {position}",
16843            schema.name
16844        )));
16845    }
16846    Ok(())
16847}
16848
16849fn coerce_value(
16850    v: Value,
16851    expected: DataType,
16852    col_name: &str,
16853    position: usize,
16854) -> Result<Value, EngineError> {
16855    if v.is_null() {
16856        return Ok(Value::Null);
16857    }
16858    let actual = v.data_type().expect("non-null");
16859    if actual == expected {
16860        return Ok(v);
16861    }
16862    let coerced = match (v, expected) {
16863        (Value::Int(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
16864        (Value::Int(n), DataType::Float) => Some(Value::Float(f64::from(n))),
16865        (Value::Int(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
16866        (Value::Int(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
16867            i128::from(n),
16868            precision,
16869            scale,
16870            col_name,
16871        )?),
16872        (Value::SmallInt(n), DataType::Int) => Some(Value::Int(i32::from(n))),
16873        (Value::SmallInt(n), DataType::BigInt) => Some(Value::BigInt(i64::from(n))),
16874        (Value::SmallInt(n), DataType::Float) => Some(Value::Float(f64::from(n))),
16875        (Value::SmallInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
16876            i128::from(n),
16877            precision,
16878            scale,
16879            col_name,
16880        )?),
16881        (Value::BigInt(n), DataType::Int) => i32::try_from(n).ok().map(Value::Int),
16882        (Value::BigInt(n), DataType::SmallInt) => i16::try_from(n).ok().map(Value::SmallInt),
16883        #[allow(clippy::cast_precision_loss)]
16884        (Value::BigInt(n), DataType::Float) => Some(Value::Float(n as f64)),
16885        (Value::BigInt(n), DataType::Numeric { precision, scale }) => Some(numeric_from_integer(
16886            i128::from(n),
16887            precision,
16888            scale,
16889            col_name,
16890        )?),
16891        (Value::Float(x), DataType::Numeric { precision, scale }) => {
16892            Some(numeric_from_float(x, precision, scale, col_name)?)
16893        }
16894        // v7.17.0 Phase 3.P0-67 — Text → NUMERIC. Parse a
16895        // canonical decimal text (`"-1234.56"` / `"42"` /
16896        // `"0.0001"`) into `(mantissa, source_scale)` and rescale
16897        // to the column's declared scale. Required for prepared
16898        // binds: `value_to_literal` flattens a Value::Numeric
16899        // into a TEXT literal because Literal carries no native
16900        // Numeric variant, so the placeholder substitution path
16901        // reaches coerce_value as Text → Numeric. Without this
16902        // arm the round-trip surfaces a TypeMismatch even though
16903        // the cell already left the engine as a valid Numeric.
16904        (Value::Text(s), DataType::Numeric { precision, scale }) => {
16905            let Some((mantissa, src_scale)) = parse_numeric_text(&s) else {
16906                return Err(EngineError::Eval(EvalError::TypeMismatch {
16907                    detail: alloc::format!("cannot parse {s:?} as NUMERIC for column `{col_name}`"),
16908                }));
16909            };
16910            Some(numeric_rescale(
16911                mantissa, src_scale, precision, scale, col_name,
16912            )?)
16913        }
16914        // Text → DATE / TIMESTAMP: parse canonical text forms.
16915        (Value::Text(s), DataType::Date) => {
16916            let d = eval::parse_date_literal(&s).ok_or_else(|| {
16917                EngineError::Eval(EvalError::TypeMismatch {
16918                    detail: alloc::format!("cannot parse {s:?} as DATE for column `{col_name}`"),
16919                })
16920            })?;
16921            Some(Value::Date(d))
16922        }
16923        // v7.14.0 — MySQL DEFAULT clauses quote integer / float
16924        // / boolean literals (`DEFAULT '0'`, `DEFAULT '1'`,
16925        // `DEFAULT '3.14'`, `DEFAULT 'true'`). Coerce the text
16926        // form to the column's numeric / bool type at DEFAULT-
16927        // installation time so the storage check sees a typed
16928        // value. Parse failures fall through to TypeMismatch.
16929        (Value::Text(s), DataType::SmallInt) => s.parse::<i16>().ok().map(Value::SmallInt),
16930        (Value::Text(s), DataType::Int) => s.parse::<i32>().ok().map(Value::Int),
16931        (Value::Text(s), DataType::BigInt) => s.parse::<i64>().ok().map(Value::BigInt),
16932        (Value::Text(s), DataType::Float) => s.parse::<f64>().ok().map(Value::Float),
16933        (Value::Text(s), DataType::Bool) => match s.to_ascii_lowercase().as_str() {
16934            "0" | "false" | "f" | "no" | "off" => Some(Value::Bool(false)),
16935            "1" | "true" | "t" | "yes" | "on" => Some(Value::Bool(true)),
16936            _ => None,
16937        },
16938        // v7.17.0 Phase 3.P0-46 — MySQL TINYINT(1) (which Phase 4.3
16939        // classifies as DataType::Bool) is the storage shape every
16940        // mysqldump-restored boolean column lands in. mysqldump emits
16941        // the values as integer `0` / `1` literals, so int → bool
16942        // coerce on INSERT is required for a 0-change cutover. MySQL's
16943        // rule is "any non-zero is truthy"; we follow that for all
16944        // signed int widths so the same coerce path serves an
16945        // explicit `BOOLEAN` column too.
16946        (Value::Int(n), DataType::Bool) => Some(Value::Bool(n != 0)),
16947        (Value::SmallInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
16948        (Value::BigInt(n), DataType::Bool) => Some(Value::Bool(n != 0)),
16949        // v4.9: Text ↔ JSON coercion. No structural validation —
16950        // any text literal is accepted; the responsibility for
16951        // valid JSON lies with the producer.
16952        (Value::Text(s), DataType::Json | DataType::Jsonb) => Some(Value::Json(s)),
16953        (Value::Json(s), DataType::Text) => Some(Value::Text(s)),
16954        // v7.13.3 — mailrs round-7 S10. SPG's storage represents
16955        // both JSON and JSONB on-disk as `Value::Json(String)` —
16956        // they share the underlying text payload. The cast
16957        // `'<text>'::jsonb` produces a Value::Json that needs to
16958        // satisfy a DataType::Jsonb column. Identity coerce in
16959        // both directions so JSON ↔ JSONB assignments work at all
16960        // INSERT / ALTER COLUMN TYPE / DEFAULT contexts.
16961        (Value::Json(s), DataType::Jsonb | DataType::Json) => Some(Value::Json(s)),
16962        // v7.10.4 — Text → BYTEA. Decode PG-style literal forms:
16963        //   - Hex:    `\x48656c6c6f`  (case-insensitive hex pairs)
16964        //   - Escape: `Hello\\000world`  (backslash + octal triples)
16965        //   - Plain:  any string → raw UTF-8 bytes (PG also accepts)
16966        // Errors surface as TypeMismatch so the operator gets a
16967        // clear "this literal isn't a bytea literal" hint.
16968        (Value::Text(s), DataType::Bytes) => {
16969            let bytes = decode_bytea_literal(&s).map_err(|e| {
16970                EngineError::Eval(EvalError::TypeMismatch {
16971                    detail: alloc::format!(
16972                        "cannot parse {s:?} as BYTEA for column `{col_name}`: {e}"
16973                    ),
16974                })
16975            })?;
16976            Some(Value::Bytes(bytes))
16977        }
16978        // v7.10.4 — BYTEA → Text round-trip uses the PG hex
16979        // output (lowercase, `\x` prefix). Important when a
16980        // SELECT pulls a bytea cell through a Text column path.
16981        (Value::Bytes(b), DataType::Text) => Some(Value::Text(encode_bytea_hex(&b))),
16982        // v7.17.0 — Text → UUID. PG accepts canonical hyphenated,
16983        // unhyphenated, uppercase, and `{...}`-braced forms; we
16984        // funnel all four through `spg_storage::parse_uuid_str`.
16985        // A malformed literal surfaces as a SQL TypeMismatch
16986        // rather than silently inserting garbage — `0-change
16987        // cutover` requires that an app inserting bad UUID text
16988        // sees the same hard error PG would raise.
16989        (Value::Text(s), DataType::Uuid) => match spg_storage::parse_uuid_str(&s) {
16990            Some(b) => Some(Value::Uuid(b)),
16991            None => {
16992                return Err(EngineError::Eval(EvalError::TypeMismatch {
16993                    detail: alloc::format!(
16994                        "invalid input syntax for type uuid: {s:?} (column `{col_name}`)"
16995                    ),
16996                }));
16997            }
16998        },
16999        // v7.17.0 — UUID → Text canonical 8-4-4-4-12 lowercase.
17000        // Surfaces when a SELECT plucks a uuid cell through a
17001        // Text column path (e.g. INSERT INTO log SELECT id::text
17002        // FROM other_table).
17003        (Value::Uuid(b), DataType::Text) => Some(Value::Text(spg_storage::format_uuid(&b))),
17004        // v7.17.0 Phase 3.P0-32 — Text → TIME. Accepts
17005        // `HH:MM:SS` and `HH:MM:SS.ffffff` (1-6 fractional digits).
17006        // Out-of-range hour/min/sec is a hard SQL error (no
17007        // silent truncation — same 0-change-cutover discipline
17008        // we apply to UUID).
17009        (Value::Text(s), DataType::Time) => match parse_time_str(&s) {
17010            Some(us) => Some(Value::Time(us)),
17011            None => {
17012                return Err(EngineError::Eval(EvalError::TypeMismatch {
17013                    detail: alloc::format!(
17014                        "invalid input syntax for type time: {s:?} (column `{col_name}`)"
17015                    ),
17016                }));
17017            }
17018        },
17019        // v7.17.0 Phase 3.P0-32 — TIME → Text canonical `HH:MM:SS[.ffffff]`.
17020        (Value::Time(us), DataType::Text) => Some(Value::Text(eval::format_time(us))),
17021        // v7.17.0 Phase 3.P0-33 — int / bigint → YEAR. Range
17022        // check enforces the MySQL canonical 1901..=2155 + 0
17023        // sentinel; out-of-range is a hard SQL error (no silent
17024        // truncation, mirrors P0-32 / P0-25 discipline).
17025        (Value::SmallInt(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
17026        (Value::Int(n), DataType::Year) => Some(coerce_int_to_year(i64::from(n), col_name)?),
17027        (Value::BigInt(n), DataType::Year) => Some(coerce_int_to_year(n, col_name)?),
17028        // Text → YEAR. Accepts the 4-digit decimal form only;
17029        // two-digit YEAR (`'99'` → 1999) was deprecated in MySQL
17030        // 5.7 and is out of scope for v7.17.0.
17031        (Value::Text(s), DataType::Year) => match s.trim().parse::<i64>() {
17032            Ok(n) => Some(coerce_int_to_year(n, col_name)?),
17033            Err(_) => {
17034                return Err(EngineError::Eval(EvalError::TypeMismatch {
17035                    detail: alloc::format!(
17036                        "invalid input syntax for type year: {s:?} (column `{col_name}`)"
17037                    ),
17038                }));
17039            }
17040        },
17041        // YEAR → Text 4-digit zero-padded.
17042        (Value::Year(y), DataType::Text) => Some(Value::Text(alloc::format!("{y:04}"))),
17043        // v7.17.0 Phase 3.P0-34 — Text → TIMETZ. Mandatory
17044        // signed offset suffix; missing offset is a hard error
17045        // (SPG has no session TZ wired into eval, unlike PG).
17046        (Value::Text(s), DataType::TimeTz) => match parse_timetz_str(&s) {
17047            Some((us, offset_secs)) => Some(Value::TimeTz { us, offset_secs }),
17048            None => {
17049                return Err(EngineError::Eval(EvalError::TypeMismatch {
17050                    detail: alloc::format!(
17051                        "invalid input syntax for type time with time zone: \
17052                         {s:?} (column `{col_name}`)"
17053                    ),
17054                }));
17055            }
17056        },
17057        // TIMETZ → Text canonical `HH:MM:SS[.ffffff]±HH[:MM]`.
17058        (Value::TimeTz { us, offset_secs }, DataType::Text) => {
17059            Some(Value::Text(eval::format_timetz(us, offset_secs)))
17060        }
17061        // v7.17.0 Phase 3.P0-35 — Text → MONEY. Accepts `$N.NN`,
17062        // `$N,NNN.NN`, optional leading `-`. Bare numeric literals
17063        // arrive via the Int/BigInt/Float/Numeric arms below.
17064        (Value::Text(s), DataType::Money) => match parse_money_str(&s) {
17065            Some(c) => Some(Value::Money(c)),
17066            None => {
17067                return Err(EngineError::Eval(EvalError::TypeMismatch {
17068                    detail: alloc::format!(
17069                        "invalid input syntax for type money: {s:?} (column `{col_name}`)"
17070                    ),
17071                }));
17072            }
17073        },
17074        // Int / BigInt / SmallInt / Float / Numeric → MONEY.
17075        // Bare numeric literal is interpreted as a major-unit
17076        // amount (matches PG: `100`::money → $100.00 = 10000 cents).
17077        (Value::SmallInt(n), DataType::Money) => {
17078            Some(Value::Money(i64::from(n).saturating_mul(100)))
17079        }
17080        (Value::Int(n), DataType::Money) => Some(Value::Money(i64::from(n).saturating_mul(100))),
17081        (Value::BigInt(n), DataType::Money) => Some(Value::Money(n.saturating_mul(100))),
17082        (Value::Float(x), DataType::Money) => {
17083            // Round half-away-from-zero to cents (no_std — no
17084            // `f64::round`, so hand-roll via biased truncation).
17085            let scaled = x * 100.0;
17086            let cents = if scaled >= 0.0 {
17087                (scaled + 0.5) as i64
17088            } else {
17089                (scaled - 0.5) as i64
17090            };
17091            Some(Value::Money(cents))
17092        }
17093        (Value::Numeric { scaled, scale }, DataType::Money) => {
17094            // Convert exact decimal to cents (scale 2). If scale > 2,
17095            // round half-away-from-zero. If scale < 2, multiply up.
17096            let cents = if scale == 2 {
17097                scaled
17098            } else if scale < 2 {
17099                let mult = 10_i128.pow(u32::from(2 - scale));
17100                scaled.saturating_mul(mult)
17101            } else {
17102                let div = 10_i128.pow(u32::from(scale - 2));
17103                let half = div / 2;
17104                let bias = if scaled >= 0 { half } else { -half };
17105                (scaled + bias) / div
17106            };
17107            Some(Value::Money(i64::try_from(cents).unwrap_or(i64::MAX)))
17108        }
17109        // MONEY → Text canonical `$N,NNN.CC`.
17110        (Value::Money(c), DataType::Text) => Some(Value::Text(eval::format_money(c))),
17111        // v7.17.0 Phase 3.P0-38 — Text → Range. Accepts canonical
17112        // PG forms: `'empty'`, `'[a,b)'`, `'(a,b]'`, `'[a,b]'`,
17113        // `'(a,b)'`, with empty lower or upper for unbounded.
17114        (Value::Text(s), DataType::Range(kind)) => match parse_range_str(&s, kind) {
17115            Some(v) => Some(v),
17116            None => {
17117                return Err(EngineError::Eval(EvalError::TypeMismatch {
17118                    detail: alloc::format!(
17119                        "invalid input syntax for range type: {s:?} (column `{col_name}`)"
17120                    ),
17121                }));
17122            }
17123        },
17124        // Range → Text canonical form (`[a,b)`, `'empty'`, etc).
17125        (v @ Value::Range { .. }, DataType::Text) => Some(Value::Text(format_range_str(&v))),
17126        // v7.17.0 Phase 3.P0-39 — Text → Hstore.
17127        (Value::Text(s), DataType::Hstore) => match parse_hstore_str(&s) {
17128            Some(pairs) => Some(Value::Hstore(pairs)),
17129            None => {
17130                return Err(EngineError::Eval(EvalError::TypeMismatch {
17131                    detail: alloc::format!(
17132                        "invalid input syntax for type hstore: {s:?} (column `{col_name}`)"
17133                    ),
17134                }));
17135            }
17136        },
17137        // Hstore → Text canonical `"k"=>"v"` form.
17138        (Value::Hstore(pairs), DataType::Text) => Some(Value::Text(format_hstore_str(&pairs))),
17139        // v7.17.0 Phase 3.P0-40 — Text → 2D arrays via PG
17140        // external `'{{a,b},{c,d}}'` literal.
17141        (Value::Text(s), DataType::IntArray2D) => match parse_int_2d_literal(&s) {
17142            Ok(m) => Some(Value::IntArray2D(m)),
17143            Err(e) => {
17144                return Err(EngineError::Eval(EvalError::TypeMismatch {
17145                    detail: alloc::format!(
17146                        "invalid input syntax for INT[][]: {s:?} (column `{col_name}`): {e}"
17147                    ),
17148                }));
17149            }
17150        },
17151        (Value::Text(s), DataType::BigIntArray2D) => match parse_bigint_2d_literal(&s) {
17152            Ok(m) => Some(Value::BigIntArray2D(m)),
17153            Err(e) => {
17154                return Err(EngineError::Eval(EvalError::TypeMismatch {
17155                    detail: alloc::format!(
17156                        "invalid input syntax for BIGINT[][]: {s:?} (column `{col_name}`): {e}"
17157                    ),
17158                }));
17159            }
17160        },
17161        (Value::Text(s), DataType::TextArray2D) => match parse_text_2d_literal(&s) {
17162            Ok(m) => Some(Value::TextArray2D(m)),
17163            Err(e) => {
17164                return Err(EngineError::Eval(EvalError::TypeMismatch {
17165                    detail: alloc::format!(
17166                        "invalid input syntax for TEXT[][]: {s:?} (column `{col_name}`): {e}"
17167                    ),
17168                }));
17169            }
17170        },
17171        // 2D arrays → Text canonical nested form.
17172        (Value::IntArray2D(rows), DataType::Text) => Some(Value::Text(format_int_2d_text(&rows))),
17173        (Value::BigIntArray2D(rows), DataType::Text) => {
17174            Some(Value::Text(format_bigint_2d_text(&rows)))
17175        }
17176        (Value::TextArray2D(rows), DataType::Text) => Some(Value::Text(format_text_2d_text(&rows))),
17177        // v7.10.11 — Text → TEXT[]. Decode PG's external array
17178        // form `'{a,b,NULL}'`. NULL element token (case-insensitive)
17179        // is the literal `NULL`; everything else is a quoted or
17180        // unquoted text element. mailrs `'{label1,label2}'::TEXT[]`.
17181        (Value::Text(s), DataType::TextArray) => {
17182            let arr = decode_text_array_literal(&s).map_err(|e| {
17183                EngineError::Eval(EvalError::TypeMismatch {
17184                    detail: alloc::format!(
17185                        "cannot parse {s:?} as TEXT[] for column `{col_name}`: {e}"
17186                    ),
17187                })
17188            })?;
17189            Some(Value::TextArray(arr))
17190        }
17191        // v7.16.0 — Text → IntArray / BigIntArray for the
17192        // spg-sqlx Bind path. Decode the PG external form
17193        // `{1,2,3}` as a TEXT array first, then parse each
17194        // element as int. Same shape as the TextArray decode
17195        // above with an element-wise narrow.
17196        (Value::Text(s), DataType::IntArray) => {
17197            let arr = decode_text_array_literal(&s).map_err(|e| {
17198                EngineError::Eval(EvalError::TypeMismatch {
17199                    detail: alloc::format!(
17200                        "cannot parse {s:?} as INT[] for column `{col_name}`: {e}"
17201                    ),
17202                })
17203            })?;
17204            let mut out: Vec<Option<i32>> = Vec::with_capacity(arr.len());
17205            for elem in arr {
17206                match elem {
17207                    None => out.push(None),
17208                    Some(t) => {
17209                        let n: i32 = t.parse().map_err(|_| {
17210                            EngineError::Eval(EvalError::TypeMismatch {
17211                                detail: alloc::format!(
17212                                    "cannot parse {t:?} as INT element for `{col_name}`"
17213                                ),
17214                            })
17215                        })?;
17216                        out.push(Some(n));
17217                    }
17218                }
17219            }
17220            Some(Value::IntArray(out))
17221        }
17222        (Value::Text(s), DataType::BigIntArray) => {
17223            let arr = decode_text_array_literal(&s).map_err(|e| {
17224                EngineError::Eval(EvalError::TypeMismatch {
17225                    detail: alloc::format!(
17226                        "cannot parse {s:?} as BIGINT[] for column `{col_name}`: {e}"
17227                    ),
17228                })
17229            })?;
17230            let mut out: Vec<Option<i64>> = Vec::with_capacity(arr.len());
17231            for elem in arr {
17232                match elem {
17233                    None => out.push(None),
17234                    Some(t) => {
17235                        let n: i64 = t.parse().map_err(|_| {
17236                            EngineError::Eval(EvalError::TypeMismatch {
17237                                detail: alloc::format!(
17238                                    "cannot parse {t:?} as BIGINT element for `{col_name}`"
17239                                ),
17240                            })
17241                        })?;
17242                        out.push(Some(n));
17243                    }
17244                }
17245            }
17246            Some(Value::BigIntArray(out))
17247        }
17248        // v7.10.11 — TEXT[] → Text round-trip uses PG's
17249        // external array form (`{a,b,NULL}`). Lets a SELECT
17250        // pull an array column through any Text-side codepath.
17251        (Value::TextArray(items), DataType::Text) => Some(Value::Text(encode_text_array(&items))),
17252        // v7.17.0 Phase 3.P0-68 — Text → VECTOR auto-coerce.
17253        // Matches the existing Text → TsVector arm and the
17254        // `::vector` cast: PG-canonical pgvector external form
17255        // (`'[1, 2, -3]'`) becomes a typed Vector value at the
17256        // column boundary. Dim mismatch surfaces as TypeMismatch.
17257        // For SQ8 / HALF encodings we chain through the standard
17258        // quantise helpers so the storage shape matches the
17259        // declared encoding without a second coerce pass.
17260        (Value::Text(s), DataType::Vector { dim, encoding }) => {
17261            let parsed = eval::parse_vector_text(&s).ok_or_else(|| {
17262                EngineError::Eval(EvalError::TypeMismatch {
17263                    detail: alloc::format!("cannot parse {s:?} as VECTOR for column `{col_name}`"),
17264                })
17265            })?;
17266            if parsed.len() != dim as usize {
17267                return Err(EngineError::Eval(EvalError::TypeMismatch {
17268                    detail: alloc::format!(
17269                        "VECTOR({dim}) column `{col_name}` rejects literal of length {}",
17270                        parsed.len()
17271                    ),
17272                }));
17273            }
17274            Some(match encoding {
17275                VecEncoding::F32 => Value::Vector(parsed),
17276                VecEncoding::Sq8 => Value::Sq8Vector(spg_storage::quantize::quantize(&parsed)),
17277                VecEncoding::F16 => {
17278                    Value::HalfVector(spg_storage::halfvec::HalfVector::from_f32_slice(&parsed))
17279                }
17280            })
17281        }
17282        // v7.16.1 — Text → TSVECTOR auto-coerce for the
17283        // INSERT-side wire path (mailrs round-9 A.2.a). PG
17284        // implicitly promotes the TEXT literal at INSERT into a
17285        // TSVECTOR column; SPG previously rejected with a hard
17286        // type mismatch, blocking 23,276 pg_dump rows into
17287        // `messages.search_vector`. We route through the same
17288        // `decode_tsvector_external` the `::tsvector` cast
17289        // already uses, so PG-canonical forms (`'word'`,
17290        // `'word:1A,2B'`, multi-lexeme, empty `''`) all parse.
17291        (Value::Text(s), DataType::TsVector) => {
17292            let lexs = eval::decode_tsvector_external(&s).map_err(|e| {
17293                EngineError::Eval(EvalError::TypeMismatch {
17294                    detail: alloc::format!(
17295                        "cannot parse {s:?} as TSVECTOR for column `{col_name}`: {e}"
17296                    ),
17297                })
17298            })?;
17299            Some(Value::TsVector(lexs))
17300        }
17301        (Value::Text(s), DataType::Timestamp | DataType::Timestamptz) => {
17302            let t = eval::parse_timestamp_literal(&s).ok_or_else(|| {
17303                EngineError::Eval(EvalError::TypeMismatch {
17304                    detail: alloc::format!(
17305                        "cannot parse {s:?} as TIMESTAMP for column `{col_name}`"
17306                    ),
17307                })
17308            })?;
17309            Some(Value::Timestamp(t))
17310        }
17311        // DATE ↔ TIMESTAMP convertibility (DATE → midnight,
17312        // TIMESTAMP → day truncation).
17313        (Value::Date(d), DataType::Timestamp | DataType::Timestamptz) => {
17314            Some(Value::Timestamp(i64::from(d) * 86_400_000_000))
17315        }
17316        // v7.9.21 — Value::Timestamp lands in either Timestamp
17317        // or Timestamptz columns; the on-disk layout is the
17318        // same i64 microseconds UTC.
17319        (Value::Timestamp(t), DataType::Timestamptz) => Some(Value::Timestamp(t)),
17320        (Value::Timestamp(t), DataType::Date) => {
17321            let days = t.div_euclid(86_400_000_000);
17322            i32::try_from(days).ok().map(Value::Date)
17323        }
17324        (
17325            Value::Numeric {
17326                scaled,
17327                scale: src_scale,
17328            },
17329            DataType::Numeric { precision, scale },
17330        ) => Some(numeric_rescale(
17331            scaled, src_scale, precision, scale, col_name,
17332        )?),
17333        #[allow(clippy::cast_precision_loss)]
17334        (Value::Numeric { scaled, scale }, DataType::Float) => {
17335            let mut div = 1.0_f64;
17336            for _ in 0..scale {
17337                div *= 10.0;
17338            }
17339            Some(Value::Float((scaled as f64) / div))
17340        }
17341        (Value::Numeric { scaled, scale }, DataType::Int) => {
17342            let truncated = numeric_truncate_to_integer(scaled, scale);
17343            i32::try_from(truncated).ok().map(Value::Int)
17344        }
17345        (Value::Numeric { scaled, scale }, DataType::BigInt) => {
17346            let truncated = numeric_truncate_to_integer(scaled, scale);
17347            i64::try_from(truncated).ok().map(Value::BigInt)
17348        }
17349        (Value::Numeric { scaled, scale }, DataType::SmallInt) => {
17350            let truncated = numeric_truncate_to_integer(scaled, scale);
17351            i16::try_from(truncated).ok().map(Value::SmallInt)
17352        }
17353        // VARCHAR(n) enforces an upper bound on character count.
17354        (Value::Text(s), DataType::Varchar(max)) => {
17355            if u32::try_from(s.chars().count()).unwrap_or(u32::MAX) <= max {
17356                Some(Value::Text(s))
17357            } else {
17358                return Err(EngineError::Unsupported(alloc::format!(
17359                    "value for VARCHAR({max}) column `{col_name}` exceeds length: \
17360                     {} chars",
17361                    s.chars().count()
17362                )));
17363            }
17364        }
17365        // v6.0.1: f32 → SQ8 INSERT-time quantisation. Triggered
17366        // when the column declares `VECTOR(N) USING SQ8` and
17367        // the INSERT VALUES expression yields a raw f32 vector
17368        // (the normal pgvector-shape literal). Dim mismatch
17369        // falls through the `_ => None` arm and surfaces as
17370        // `TypeMismatch` with the expected SQ8 column type —
17371        // matching the F32 path's existing error.
17372        (
17373            Value::Vector(v),
17374            DataType::Vector {
17375                dim,
17376                encoding: VecEncoding::Sq8,
17377            },
17378        ) if v.len() == dim as usize => Some(Value::Sq8Vector(spg_storage::quantize::quantize(&v))),
17379        // v6.0.3: f32 → f16 INSERT-time conversion for HALF
17380        // columns. Bit-exact at the storage layer (modulo
17381        // half-precision rounding); no rerank pass needed at
17382        // search time.
17383        (
17384            Value::Vector(v),
17385            DataType::Vector {
17386                dim,
17387                encoding: VecEncoding::F16,
17388            },
17389        ) if v.len() == dim as usize => Some(Value::HalfVector(
17390            spg_storage::halfvec::HalfVector::from_f32_slice(&v),
17391        )),
17392        // CHAR(n) right-pads with U+0020 to exactly n chars; if the input
17393        // is already longer we reject (PG truncates trailing-space-only;
17394        // staying strict for v1).
17395        (Value::Text(s), DataType::Char(size)) => {
17396            let len = u32::try_from(s.chars().count()).unwrap_or(u32::MAX);
17397            if len > size {
17398                return Err(EngineError::Unsupported(alloc::format!(
17399                    "value for CHAR({size}) column `{col_name}` exceeds length: \
17400                     {len} chars"
17401                )));
17402            }
17403            let need = (size - len) as usize;
17404            let mut padded = s;
17405            padded.reserve(need);
17406            for _ in 0..need {
17407                padded.push(' ');
17408            }
17409            Some(Value::Text(padded))
17410        }
17411        _ => None,
17412    };
17413    coerced.ok_or(EngineError::Storage(StorageError::TypeMismatch {
17414        column: col_name.into(),
17415        expected,
17416        actual,
17417        position,
17418    }))
17419}
17420
17421/// v7.12.4 — render a function arg list into the
17422/// canonical form the storage layer caches as
17423/// [`spg_storage::FunctionDef::args_repr`]. The catalogue uses
17424/// this string for both display + as a coarse signature key
17425/// for the (deferred) overload resolution v7.12.5+ adds.
17426fn render_function_args(args: &[spg_sql::ast::FunctionArg]) -> alloc::string::String {
17427    use core::fmt::Write;
17428    let mut out = alloc::string::String::from("(");
17429    for (i, a) in args.iter().enumerate() {
17430        if i > 0 {
17431            out.push_str(", ");
17432        }
17433        match a.mode {
17434            spg_sql::ast::FunctionArgMode::In => {}
17435            spg_sql::ast::FunctionArgMode::Out => out.push_str("OUT "),
17436            spg_sql::ast::FunctionArgMode::InOut => out.push_str("INOUT "),
17437        }
17438        if let Some(n) = &a.name {
17439            out.push_str(n);
17440            out.push(' ');
17441        }
17442        match &a.ty {
17443            spg_sql::ast::FunctionArgType::Typed(t) => {
17444                let _ = write!(out, "{t}");
17445            }
17446            spg_sql::ast::FunctionArgType::Raw(s) => out.push_str(s),
17447        }
17448    }
17449    out.push(')');
17450    out
17451}
17452
17453/// v7.19 P5 — true iff `expr` is `unnest(arg)` at the top level
17454/// (case-insensitive). Used by `exec_select_cancel`'s
17455/// projection loop to detect Set-Returning-Function rows that
17456/// need per-row expansion. Only the top-level call counts —
17457/// `coalesce(unnest(arr), 'x')` is NOT a SRF row from the
17458/// projection's perspective; it would surface as an "unknown
17459/// function" mismatch downstream, which is what we want
17460/// (multi-SRF / nested SRF is documented carve-out for v7.19).
17461fn is_top_level_unnest(expr: &spg_sql::ast::Expr) -> bool {
17462    match expr {
17463        spg_sql::ast::Expr::FunctionCall { name, args } => {
17464            name.eq_ignore_ascii_case("unnest") && args.len() == 1
17465        }
17466        _ => false,
17467    }
17468}
17469
17470/// v7.19 P5 — extract the array argument out of a top-level
17471/// `unnest(arg)` call. `None` if `expr` isn't a `unnest` call
17472/// of arity 1 (mirrors `is_top_level_unnest`).
17473fn top_level_unnest_arg(expr: &spg_sql::ast::Expr) -> Option<&spg_sql::ast::Expr> {
17474    match expr {
17475        spg_sql::ast::Expr::FunctionCall { name, args }
17476            if name.eq_ignore_ascii_case("unnest") && args.len() == 1 =>
17477        {
17478            Some(&args[0])
17479        }
17480        _ => None,
17481    }
17482}
17483
17484/// v7.19 P5 — turn an array-typed `Value` into the element list
17485/// `unnest()` projection emits. NULL → empty list (PG: `unnest(NULL)
17486/// = (no rows)`). Non-array values fall through to a type-mismatch
17487/// error.
17488fn array_value_to_elements(v: &Value) -> Result<Vec<Value>, EngineError> {
17489    match v {
17490        Value::Null => Ok(Vec::new()),
17491        Value::TextArray(items) => Ok(items
17492            .iter()
17493            .map(|opt| {
17494                opt.as_ref()
17495                    .map(|s| Value::Text(s.clone()))
17496                    .unwrap_or(Value::Null)
17497            })
17498            .collect()),
17499        Value::IntArray(items) => Ok(items
17500            .iter()
17501            .map(|opt| opt.map(Value::Int).unwrap_or(Value::Null))
17502            .collect()),
17503        Value::BigIntArray(items) => Ok(items
17504            .iter()
17505            .map(|opt| opt.map(Value::BigInt).unwrap_or(Value::Null))
17506            .collect()),
17507        other => Err(EngineError::Eval(EvalError::TypeMismatch {
17508            detail: alloc::format!(
17509                "unnest() expects an array argument, got {:?}",
17510                other.data_type()
17511            ),
17512        })),
17513    }
17514}
17515
17516#[cfg(test)]
17517mod tests {
17518    use super::*;
17519    use alloc::vec;
17520
17521    fn unwrap_command_ok(r: &QueryResult) -> usize {
17522        match r {
17523            QueryResult::CommandOk { affected, .. } => *affected,
17524            QueryResult::Rows { .. } => panic!("expected CommandOk, got Rows"),
17525        }
17526    }
17527
17528    #[test]
17529    fn create_table_registers_schema() {
17530        let mut e = Engine::new();
17531        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT)")
17532            .unwrap();
17533        assert_eq!(e.catalog().table_count(), 1);
17534        let t = e.catalog().get("foo").unwrap();
17535        assert_eq!(t.schema().columns.len(), 2);
17536        assert_eq!(t.schema().columns[0].ty, DataType::Int);
17537        assert!(!t.schema().columns[0].nullable);
17538        assert_eq!(t.schema().columns[1].ty, DataType::Text);
17539    }
17540
17541    #[test]
17542    fn create_table_vector_default_is_f32_encoded() {
17543        let mut e = Engine::new();
17544        e.execute("CREATE TABLE t (v VECTOR(8))").unwrap();
17545        let t = e.catalog().get("t").unwrap();
17546        assert_eq!(
17547            t.schema().columns[0].ty,
17548            DataType::Vector {
17549                dim: 8,
17550                encoding: VecEncoding::F32,
17551            },
17552        );
17553    }
17554
17555    #[test]
17556    fn create_table_vector_using_sq8_succeeds() {
17557        // v6.0.1 step 3: the step-1 fence in `column_def_to_schema`
17558        // is lifted. CREATE TABLE persists an SQ8 column type in
17559        // the catalog; INSERT (next test) quantises raw f32 input.
17560        let mut e = Engine::new();
17561        e.execute("CREATE TABLE t (v VECTOR(8) USING SQ8)").unwrap();
17562        let t = e.catalog().get("t").unwrap();
17563        assert_eq!(
17564            t.schema().columns[0].ty,
17565            DataType::Vector {
17566                dim: 8,
17567                encoding: VecEncoding::Sq8,
17568            },
17569        );
17570    }
17571
17572    #[test]
17573    fn insert_into_sq8_column_quantises_f32_payload() {
17574        // v6.0.1 step 3: INSERT-time `coerce_value` rewrites a raw
17575        // `Value::Vector(Vec<f32>)` literal into the column's
17576        // quantised representation. The row that lands in the
17577        // catalog must therefore hold a `Value::Sq8Vector`, not the
17578        // original f32 buffer — that's the bit that delivers the
17579        // 4× compression target.
17580        let mut e = Engine::new();
17581        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
17582        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
17583            .unwrap();
17584        let t = e.catalog().get("t").unwrap();
17585        assert_eq!(t.rows().len(), 1);
17586        match &t.rows()[0].values[0] {
17587            Value::Sq8Vector(q) => {
17588                assert_eq!(q.bytes.len(), 4);
17589                // min/max are derived from the payload: min=0.0, max=1.0.
17590                assert!((q.min - 0.0).abs() < 1e-6);
17591                assert!((q.max - 1.0).abs() < 1e-6);
17592            }
17593            other => panic!("expected Sq8Vector cell, got {other:?}"),
17594        }
17595    }
17596
17597    #[test]
17598    fn create_table_vector_using_half_succeeds_and_insert_converts_to_f16() {
17599        // v6.0.3: CREATE TABLE accepts USING HALF; INSERT path
17600        // converts the incoming `Value::Vector(Vec<f32>)` cell
17601        // into `Value::HalfVector(HalfVector)` via the new
17602        // `coerce_value` arm. The dequantised round-trip is
17603        // bit-exact for f16-representable values, so 0.0 / 0.25
17604        // / 0.5 / 1.0 hit their grid points exactly.
17605        let mut e = Engine::new();
17606        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
17607            .unwrap();
17608        e.execute("INSERT INTO t VALUES ([0.0, 0.25, 0.5, 1.0])")
17609            .unwrap();
17610        let t = e.catalog().get("t").unwrap();
17611        assert_eq!(t.rows().len(), 1);
17612        match &t.rows()[0].values[0] {
17613            Value::HalfVector(h) => {
17614                assert_eq!(h.dim(), 4);
17615                let back = h.to_f32_vec();
17616                let expected = alloc::vec![0.0_f32, 0.25, 0.5, 1.0];
17617                for (g, e) in back.iter().zip(expected.iter()) {
17618                    assert!(
17619                        (g - e).abs() < 1e-6,
17620                        "{g} vs {e} should be exact on f16 grid"
17621                    );
17622                }
17623            }
17624            other => panic!("expected HalfVector cell, got {other:?}"),
17625        }
17626    }
17627
17628    #[test]
17629    fn alter_index_rebuild_in_place_succeeds() {
17630        // v6.0.4: bare REBUILD (no encoding switch) walks every
17631        // row again to rebuild the NSW graph. Verifies the engine
17632        // dispatch + storage helper plumbing without changing any
17633        // cell encoding.
17634        let mut e = Engine::new();
17635        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
17636            .unwrap();
17637        for i in 0..8_i32 {
17638            #[allow(clippy::cast_precision_loss)]
17639            let base = (i as f32) * 0.1;
17640            e.execute(&alloc::format!(
17641                "INSERT INTO t VALUES ({i}, [{base}, {b1}, {b2}])",
17642                b1 = base + 0.01,
17643                b2 = base + 0.02,
17644            ))
17645            .unwrap();
17646        }
17647        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
17648        e.execute("ALTER INDEX t_idx REBUILD").unwrap();
17649        // Schema encoding stays F32 (no encoding clause).
17650        assert_eq!(
17651            e.catalog().get("t").unwrap().schema().columns[1].ty,
17652            DataType::Vector {
17653                dim: 3,
17654                encoding: VecEncoding::F32,
17655            },
17656        );
17657    }
17658
17659    #[test]
17660    fn alter_index_rebuild_with_encoding_switches_cell_type() {
17661        // v6.0.4: REBUILD WITH (encoding = SQ8) recodes every
17662        // stored cell from F32 → SQ8 + rebuilds the graph atop the
17663        // new encoding. Post-rebuild, cells must be Sq8Vector and
17664        // the schema must report encoding = Sq8.
17665        let mut e = Engine::new();
17666        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(4) NOT NULL)")
17667            .unwrap();
17668        e.execute("INSERT INTO t VALUES (1, [0.0, 0.25, 0.5, 1.0])")
17669            .unwrap();
17670        e.execute("CREATE INDEX t_idx ON t USING hnsw (v)").unwrap();
17671        e.execute("ALTER INDEX t_idx REBUILD WITH (encoding = SQ8)")
17672            .unwrap();
17673        let t = e.catalog().get("t").unwrap();
17674        assert_eq!(
17675            t.schema().columns[1].ty,
17676            DataType::Vector {
17677                dim: 4,
17678                encoding: VecEncoding::Sq8,
17679            },
17680        );
17681        assert!(matches!(t.rows()[0].values[1], Value::Sq8Vector(_)));
17682    }
17683
17684    #[test]
17685    fn alter_index_rebuild_unknown_index_errors() {
17686        let mut e = Engine::new();
17687        let err = e.execute("ALTER INDEX nope REBUILD").unwrap_err();
17688        assert!(
17689            matches!(
17690                &err,
17691                EngineError::Storage(StorageError::IndexNotFound { name }) if name == "nope"
17692            ),
17693            "got: {err}"
17694        );
17695    }
17696
17697    #[test]
17698    fn alter_index_rebuild_on_btree_index_errors() {
17699        // REBUILD on a B-tree index has no semantic meaning in
17700        // v6.0.4 — rejected at the storage layer with `Unsupported`.
17701        let mut e = Engine::new();
17702        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
17703        e.execute("INSERT INTO t VALUES (1)").unwrap();
17704        e.execute("CREATE INDEX t_idx ON t (id)").unwrap();
17705        let err = e.execute("ALTER INDEX t_idx REBUILD").unwrap_err();
17706        assert!(
17707            matches!(&err, EngineError::Storage(StorageError::Unsupported(_))),
17708            "got: {err}"
17709        );
17710    }
17711
17712    #[test]
17713    fn prepared_insert_substitutes_placeholders() {
17714        // v6.1.1: prepare() parses once; execute_prepared() walks the
17715        // AST and replaces $1/$2 with the param Values BEFORE the
17716        // dispatch sees them. Same logical result as a simple-query
17717        // INSERT, but parse happens once per *statement*, not per
17718        // execution.
17719        let mut e = Engine::new();
17720        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
17721            .unwrap();
17722        let stmt = e.prepare("INSERT INTO t VALUES ($1, $2)").unwrap();
17723        for (id, name) in [(1, "alice"), (2, "bob"), (3, "carol")] {
17724            e.execute_prepared(stmt.clone(), &[Value::Int(id), Value::Text(name.into())])
17725                .unwrap();
17726        }
17727        // Read back via simple-query SELECT.
17728        let rows_result = e.execute("SELECT id, name FROM t").unwrap();
17729        let QueryResult::Rows { rows, .. } = rows_result else {
17730            panic!("expected Rows")
17731        };
17732        assert_eq!(rows.len(), 3);
17733    }
17734
17735    #[test]
17736    fn prepared_select_with_placeholder_filters_rows() {
17737        let mut e = Engine::new();
17738        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
17739            .unwrap();
17740        for i in 0..10_i32 {
17741            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
17742                .unwrap();
17743        }
17744        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
17745        let QueryResult::Rows { rows, .. } = e.execute_prepared(stmt, &[Value::Int(35)]).unwrap()
17746        else {
17747            panic!("expected Rows")
17748        };
17749        // v = 35 means i*7 = 35 → i = 5.
17750        assert_eq!(rows.len(), 1);
17751        assert_eq!(rows[0].values[0], Value::Int(5));
17752    }
17753
17754    #[test]
17755    fn prepared_too_few_params_errors() {
17756        let mut e = Engine::new();
17757        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
17758        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
17759        let err = e.execute_prepared(stmt, &[]).unwrap_err();
17760        assert!(
17761            matches!(
17762                &err,
17763                EngineError::Eval(EvalError::PlaceholderOutOfRange { n: 1, bound: 0 })
17764            ),
17765            "got: {err}"
17766        );
17767    }
17768
17769    #[test]
17770    fn bytea_cast_round_trips_text_input() {
17771        // v7.18 — `'hello'::bytea` produces the raw bytes. Closes
17772        // the mailrs D-pre #3 reverse-acceptance gap.
17773        let e = Engine::new();
17774        let r = e.execute_readonly("SELECT 'hello'::bytea").unwrap();
17775        let QueryResult::Rows { rows, .. } = r else {
17776            panic!("expected Rows")
17777        };
17778        assert_eq!(rows.len(), 1);
17779        assert_eq!(rows[0].values[0], Value::Bytes(b"hello".to_vec()));
17780    }
17781
17782    #[test]
17783    fn bytea_cast_pg_escape_hex_form() {
17784        // E'\\xdeadbeef'::bytea — E-string decodes to `\xdeadbeef`
17785        // (literal 10 chars), then ::bytea reads it as PG hex
17786        // form bytea literal → 4 bytes.
17787        let e = Engine::new();
17788        let r = e.execute_readonly(r"SELECT E'\\xdeadbeef'::bytea").unwrap();
17789        let QueryResult::Rows { rows, .. } = r else {
17790            panic!("expected Rows")
17791        };
17792        assert_eq!(
17793            rows[0].values[0],
17794            Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef])
17795        );
17796    }
17797
17798    #[test]
17799    fn bytea_cast_chains_through_octet_length() {
17800        // octet_length('hello'::bytea) → 5. Confirms the cast
17801        // composes inside larger expressions, not just at top
17802        // level.
17803        let e = Engine::new();
17804        let r = e
17805            .execute_readonly("SELECT octet_length('hello'::bytea)")
17806            .unwrap();
17807        let QueryResult::Rows { rows, .. } = r else {
17808            panic!("expected Rows")
17809        };
17810        match &rows[0].values[0] {
17811            Value::Int(n) => assert_eq!(*n, 5),
17812            Value::BigInt(n) => assert_eq!(*n, 5),
17813            other => panic!("expected integer length, got {other:?}"),
17814        }
17815    }
17816
17817    #[test]
17818    fn readonly_prepared_on_snapshot_select_with_placeholder() {
17819        // v7.18 — sqlx Pool fan-out relies on running prepared
17820        // SELECTs against a frozen snapshot without re-entering
17821        // the writer engine. Mirrors the simple-query SELECT path
17822        // in `execute_readonly_on_snapshot` but takes a Statement
17823        // + bound params (the shape sqlx's Execute path produces).
17824        let mut e = Engine::new();
17825        e.execute("CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)")
17826            .unwrap();
17827        for i in 0..10_i32 {
17828            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, {})", i * 7))
17829                .unwrap();
17830        }
17831        let snapshot = e.clone_snapshot();
17832        let stmt = e.prepare("SELECT id FROM t WHERE v = $1").unwrap();
17833        let QueryResult::Rows { rows, .. } =
17834            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(35)])
17835                .unwrap()
17836        else {
17837            panic!("expected Rows")
17838        };
17839        assert_eq!(rows.len(), 1);
17840        assert_eq!(rows[0].values[0], Value::Int(5));
17841    }
17842
17843    #[test]
17844    fn readonly_prepared_on_snapshot_rejects_writes() {
17845        // DDL / DML prepared statements on the readonly path must
17846        // surface `WriteRequired` so the spg-sqlx connection layer
17847        // routes them to the writer mutex instead of the snapshot.
17848        let mut e = Engine::new();
17849        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
17850        let snapshot = e.clone_snapshot();
17851        let stmt = e.prepare("INSERT INTO t VALUES ($1)").unwrap();
17852        let err = Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(1)])
17853            .unwrap_err();
17854        assert!(matches!(&err, EngineError::WriteRequired), "got: {err}");
17855    }
17856
17857    #[test]
17858    fn readonly_prepared_on_snapshot_frozen_view() {
17859        // The snapshot reflects engine state at clone_snapshot()
17860        // time. Writes after the snapshot are NOT visible — caller
17861        // takes a fresh snapshot (or `AsyncReadHandle::refresh()`)
17862        // to see them. This is the contract the per-statement
17863        // refresh in spg-sqlx relies on.
17864        let mut e = Engine::new();
17865        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
17866        e.execute("INSERT INTO t VALUES (1)").unwrap();
17867        let snapshot = e.clone_snapshot();
17868        e.execute("INSERT INTO t VALUES (2)").unwrap();
17869        let stmt = e.prepare("SELECT id FROM t WHERE id = $1").unwrap();
17870        let QueryResult::Rows { rows, .. } =
17871            Engine::execute_readonly_prepared_on_snapshot(&snapshot, stmt, &[Value::Int(2)])
17872                .unwrap()
17873        else {
17874            panic!("expected Rows")
17875        };
17876        assert!(rows.is_empty(), "id=2 was inserted after snapshot");
17877    }
17878
17879    #[test]
17880    fn describe_prepared_on_snapshot_resolves_columns() {
17881        // v7.18 — sqlx's Executor::describe path on the readonly
17882        // fan-out needs to resolve column names + types against
17883        // the snapshot's catalog (not the live engine's catalog,
17884        // which may have moved on).
17885        let mut e = Engine::new();
17886        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
17887            .unwrap();
17888        let snapshot = e.clone_snapshot();
17889        let stmt = e.prepare("SELECT id, name FROM t WHERE id = $1").unwrap();
17890        let (_params, cols) = Engine::describe_prepared_on_snapshot(&snapshot, &stmt);
17891        assert_eq!(cols.len(), 2);
17892        assert_eq!(cols[0].name, "id");
17893        assert_eq!(cols[0].ty, DataType::Int);
17894        assert_eq!(cols[1].name, "name");
17895        assert_eq!(cols[1].ty, DataType::Text);
17896    }
17897
17898    #[test]
17899    fn insert_into_half_column_dim_mismatch_errors() {
17900        let mut e = Engine::new();
17901        e.execute("CREATE TABLE t (v VECTOR(4) USING HALF)")
17902            .unwrap();
17903        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
17904        assert!(matches!(
17905            &err,
17906            EngineError::Storage(StorageError::TypeMismatch { .. })
17907        ));
17908    }
17909
17910    #[test]
17911    fn insert_into_sq8_column_dim_mismatch_errors() {
17912        // Dim mismatch falls through the `coerce_value` Vector→Sq8
17913        // arm's guard and surfaces as `TypeMismatch` — the same
17914        // error the F32 path produces today, so client error
17915        // handling stays uniform across encodings.
17916        let mut e = Engine::new();
17917        e.execute("CREATE TABLE t (v VECTOR(4) USING SQ8)").unwrap();
17918        let err = e.execute("INSERT INTO t VALUES ([1.0, 2.0])").unwrap_err();
17919        assert!(
17920            matches!(
17921                &err,
17922                EngineError::Storage(StorageError::TypeMismatch { .. })
17923            ),
17924            "got: {err}",
17925        );
17926    }
17927
17928    #[test]
17929    fn create_table_duplicate_errors() {
17930        let mut e = Engine::new();
17931        e.execute("CREATE TABLE foo (a INT)").unwrap();
17932        let err = e.execute("CREATE TABLE foo (a INT)").unwrap_err();
17933        assert!(matches!(
17934            err,
17935            EngineError::Storage(StorageError::DuplicateTable { ref name }) if name == "foo"
17936        ));
17937    }
17938
17939    #[test]
17940    fn insert_into_unknown_table_errors() {
17941        let mut e = Engine::new();
17942        let err = e.execute("INSERT INTO ghost VALUES (1)").unwrap_err();
17943        assert!(matches!(
17944            err,
17945            EngineError::Storage(StorageError::TableNotFound { ref name }) if name == "ghost"
17946        ));
17947    }
17948
17949    #[test]
17950    fn insert_happy_path_reports_one_affected() {
17951        let mut e = Engine::new();
17952        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
17953        let r = e.execute("INSERT INTO foo VALUES (42)").unwrap();
17954        assert_eq!(unwrap_command_ok(&r), 1);
17955        assert_eq!(e.catalog().get("foo").unwrap().row_count(), 1);
17956    }
17957
17958    #[test]
17959    fn insert_arity_mismatch_propagates() {
17960        let mut e = Engine::new();
17961        e.execute("CREATE TABLE foo (a INT, b TEXT)").unwrap();
17962        let err = e.execute("INSERT INTO foo VALUES (1)").unwrap_err();
17963        assert!(matches!(
17964            err,
17965            EngineError::Storage(StorageError::ArityMismatch { .. })
17966        ));
17967    }
17968
17969    #[test]
17970    fn insert_negative_integer_via_unary_minus() {
17971        let mut e = Engine::new();
17972        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
17973        e.execute("INSERT INTO foo VALUES (-7)").unwrap();
17974        let rows = e.catalog().get("foo").unwrap().rows();
17975        assert_eq!(rows[0].values[0], Value::Int(-7));
17976    }
17977
17978    #[test]
17979    fn insert_expression_evaluated_against_empty_context() {
17980        // PG-canonical: INSERT VALUES accepts an arbitrary scalar
17981        // expression. The engine evaluates against an empty row
17982        // context — column references would error, but pure
17983        // arithmetic / function calls are fine.
17984        let mut e = Engine::new();
17985        e.execute("CREATE TABLE foo (a INT NOT NULL)").unwrap();
17986        e.execute("INSERT INTO foo VALUES (1 + 2)").unwrap();
17987        let rows = e.catalog().get("foo").unwrap().rows();
17988        assert_eq!(rows[0].values[0], Value::Int(3));
17989    }
17990
17991    #[test]
17992    fn select_star_returns_all_rows_in_insertion_order() {
17993        let mut e = Engine::new();
17994        e.execute("CREATE TABLE foo (a INT NOT NULL, b TEXT NOT NULL)")
17995            .unwrap();
17996        e.execute("INSERT INTO foo VALUES (1, 'one')").unwrap();
17997        e.execute("INSERT INTO foo VALUES (2, 'two')").unwrap();
17998        e.execute("INSERT INTO foo VALUES (3, 'three')").unwrap();
17999
18000        let r = e.execute("SELECT * FROM foo").unwrap();
18001        let QueryResult::Rows { columns, rows } = r else {
18002            panic!("expected Rows")
18003        };
18004        assert_eq!(columns.len(), 2);
18005        assert_eq!(columns[0].name, "a");
18006        assert_eq!(rows.len(), 3);
18007        assert_eq!(
18008            rows[1].values,
18009            vec![Value::Int(2), Value::Text("two".into())]
18010        );
18011    }
18012
18013    #[test]
18014    fn select_star_on_empty_table_returns_zero_rows() {
18015        let mut e = Engine::new();
18016        e.execute("CREATE TABLE foo (a INT)").unwrap();
18017        let r = e.execute("SELECT * FROM foo").unwrap();
18018        match r {
18019            QueryResult::Rows { rows, .. } => assert!(rows.is_empty()),
18020            QueryResult::CommandOk { .. } => panic!("expected Rows"),
18021        }
18022    }
18023
18024    // --- v0.4: WHERE + projection ------------------------------------------
18025
18026    fn make_three_row_users(e: &mut Engine) {
18027        e.execute("CREATE TABLE users (id INT NOT NULL, name TEXT NOT NULL, score INT)")
18028            .unwrap();
18029        e.execute("INSERT INTO users VALUES (1, 'alice', 90)")
18030            .unwrap();
18031        e.execute("INSERT INTO users VALUES (2, 'bob', NULL)")
18032            .unwrap();
18033        e.execute("INSERT INTO users VALUES (3, 'cara', 70)")
18034            .unwrap();
18035    }
18036
18037    fn unwrap_rows(r: QueryResult) -> (Vec<ColumnSchema>, Vec<Row>) {
18038        match r {
18039            QueryResult::Rows { columns, rows } => (columns, rows),
18040            QueryResult::CommandOk { .. } => panic!("expected Rows"),
18041        }
18042    }
18043
18044    #[test]
18045    fn where_filter_passes_only_true_rows() {
18046        let mut e = Engine::new();
18047        make_three_row_users(&mut e);
18048        let r = e.execute("SELECT * FROM users WHERE id > 1").unwrap();
18049        let (_, rows) = unwrap_rows(r);
18050        assert_eq!(rows.len(), 2);
18051        assert_eq!(rows[0].values[0], Value::Int(2));
18052        assert_eq!(rows[1].values[0], Value::Int(3));
18053    }
18054
18055    #[test]
18056    fn where_with_null_result_filters_out_row() {
18057        let mut e = Engine::new();
18058        make_three_row_users(&mut e);
18059        // score is NULL for bob → score > 80 is NULL → row excluded
18060        let r = e.execute("SELECT * FROM users WHERE score > 80").unwrap();
18061        let (_, rows) = unwrap_rows(r);
18062        assert_eq!(rows.len(), 1);
18063        assert_eq!(rows[0].values[1], Value::Text("alice".into()));
18064    }
18065
18066    #[test]
18067    fn projection_named_columns() {
18068        let mut e = Engine::new();
18069        make_three_row_users(&mut e);
18070        let r = e.execute("SELECT name, score FROM users").unwrap();
18071        let (cols, rows) = unwrap_rows(r);
18072        assert_eq!(cols.len(), 2);
18073        assert_eq!(cols[0].name, "name");
18074        assert_eq!(cols[1].name, "score");
18075        assert_eq!(rows.len(), 3);
18076        assert_eq!(
18077            rows[0].values,
18078            vec![Value::Text("alice".into()), Value::Int(90)]
18079        );
18080    }
18081
18082    #[test]
18083    fn projection_with_column_alias() {
18084        let mut e = Engine::new();
18085        make_three_row_users(&mut e);
18086        let r = e
18087            .execute("SELECT name AS who FROM users WHERE id = 1")
18088            .unwrap();
18089        let (cols, rows) = unwrap_rows(r);
18090        assert_eq!(cols[0].name, "who");
18091        assert_eq!(rows.len(), 1);
18092        assert_eq!(rows[0].values[0], Value::Text("alice".into()));
18093    }
18094
18095    #[test]
18096    fn qualified_column_with_table_alias_resolves() {
18097        let mut e = Engine::new();
18098        make_three_row_users(&mut e);
18099        let r = e
18100            .execute("SELECT u.id, u.name FROM users AS u WHERE u.id < 3")
18101            .unwrap();
18102        let (cols, rows) = unwrap_rows(r);
18103        assert_eq!(cols.len(), 2);
18104        assert_eq!(rows.len(), 2);
18105    }
18106
18107    #[test]
18108    fn qualified_column_with_wrong_alias_errors() {
18109        let mut e = Engine::new();
18110        make_three_row_users(&mut e);
18111        let err = e.execute("SELECT x.id FROM users AS u").unwrap_err();
18112        assert!(matches!(
18113            err,
18114            EngineError::Eval(EvalError::UnknownQualifier { ref qualifier }) if qualifier == "x"
18115        ));
18116    }
18117
18118    #[test]
18119    fn select_unknown_column_errors_in_projection() {
18120        let mut e = Engine::new();
18121        make_three_row_users(&mut e);
18122        let err = e.execute("SELECT ghost FROM users").unwrap_err();
18123        assert!(matches!(
18124            err,
18125            EngineError::Eval(EvalError::ColumnNotFound { ref name }) if name == "ghost"
18126        ));
18127    }
18128
18129    #[test]
18130    fn where_unknown_column_errors() {
18131        let mut e = Engine::new();
18132        make_three_row_users(&mut e);
18133        let err = e
18134            .execute("SELECT * FROM users WHERE ghost = 1")
18135            .unwrap_err();
18136        assert!(matches!(
18137            err,
18138            EngineError::Eval(EvalError::ColumnNotFound { .. })
18139        ));
18140    }
18141
18142    #[test]
18143    fn expression_projection_evaluates_and_renders() {
18144        // Compound expressions in the SELECT list are evaluated per row;
18145        // the output column is typed TEXT, name defaults to the expression.
18146        let mut e = Engine::new();
18147        e.execute("CREATE TABLE t (a INT NOT NULL)").unwrap();
18148        e.execute("INSERT INTO t VALUES (3)").unwrap();
18149        let (_, rows) = unwrap_rows(e.execute("SELECT 1 + 2 FROM t").unwrap());
18150        assert_eq!(rows.len(), 1);
18151        // The expression evaluates to integer 3; rendered as the cell value
18152        // (storage::Value::Int(3) since arithmetic kept ints).
18153        assert_eq!(rows[0].values[0], Value::Int(3));
18154    }
18155
18156    #[test]
18157    fn select_unknown_table_errors() {
18158        let mut e = Engine::new();
18159        let err = e.execute("SELECT * FROM ghost").unwrap_err();
18160        assert!(matches!(
18161            err,
18162            EngineError::Storage(StorageError::TableNotFound { .. })
18163        ));
18164    }
18165
18166    #[test]
18167    fn invalid_sql_returns_parse_error() {
18168        // v4.4: UPDATE is now real SQL, so use a true syntactic
18169        // garbage payload for the parse-error path.
18170        let mut e = Engine::new();
18171        let err = e.execute("THIS_IS_NOT_A_KEYWORD foo bar baz").unwrap_err();
18172        assert!(matches!(err, EngineError::Parse(_)));
18173    }
18174
18175    // --- v0.8 CREATE INDEX + index seek ------------------------------------
18176
18177    #[test]
18178    fn create_index_registers_on_table() {
18179        let mut e = Engine::new();
18180        make_three_row_users(&mut e);
18181        e.execute("CREATE INDEX by_name ON users (name)").unwrap();
18182        let t = e.catalog().get("users").unwrap();
18183        assert_eq!(t.indices().len(), 1);
18184        assert_eq!(t.indices()[0].name, "by_name");
18185    }
18186
18187    #[test]
18188    fn create_index_on_unknown_table_errors() {
18189        let mut e = Engine::new();
18190        let err = e.execute("CREATE INDEX i ON ghost (a)").unwrap_err();
18191        assert!(matches!(
18192            err,
18193            EngineError::Storage(StorageError::TableNotFound { .. })
18194        ));
18195    }
18196
18197    #[test]
18198    fn create_index_on_unknown_column_errors() {
18199        let mut e = Engine::new();
18200        make_three_row_users(&mut e);
18201        let err = e.execute("CREATE INDEX i ON users (ghost)").unwrap_err();
18202        assert!(matches!(
18203            err,
18204            EngineError::Storage(StorageError::ColumnNotFound { .. })
18205        ));
18206    }
18207
18208    #[test]
18209    fn select_eq_uses_index_returns_same_rows_as_scan() {
18210        // Build two engines: one with an index, one without. Same query →
18211        // same row set (index is a planner optimisation, not a semantic
18212        // change).
18213        let mut without = Engine::new();
18214        make_three_row_users(&mut without);
18215        let mut with = Engine::new();
18216        make_three_row_users(&mut with);
18217        with.execute("CREATE INDEX by_id ON users (id)").unwrap();
18218
18219        let q = "SELECT * FROM users WHERE id = 2";
18220        let (_, no_idx_rows) = unwrap_rows(without.execute(q).unwrap());
18221        let (_, idx_rows) = unwrap_rows(with.execute(q).unwrap());
18222        assert_eq!(no_idx_rows, idx_rows);
18223        assert_eq!(idx_rows.len(), 1);
18224    }
18225
18226    #[test]
18227    fn select_eq_with_no_matching_index_value_returns_empty() {
18228        let mut e = Engine::new();
18229        make_three_row_users(&mut e);
18230        e.execute("CREATE INDEX by_id ON users (id)").unwrap();
18231        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM users WHERE id = 999").unwrap());
18232        assert_eq!(rows.len(), 0);
18233    }
18234
18235    // --- v0.9 transactions -------------------------------------------------
18236
18237    #[test]
18238    fn begin_sets_in_transaction_flag() {
18239        let mut e = Engine::new();
18240        assert!(!e.in_transaction());
18241        e.execute("BEGIN").unwrap();
18242        assert!(e.in_transaction());
18243    }
18244
18245    #[test]
18246    fn double_begin_errors() {
18247        let mut e = Engine::new();
18248        e.execute("BEGIN").unwrap();
18249        let err = e.execute("BEGIN").unwrap_err();
18250        assert_eq!(err, EngineError::TransactionAlreadyOpen);
18251    }
18252
18253    #[test]
18254    fn commit_without_begin_errors() {
18255        let mut e = Engine::new();
18256        let err = e.execute("COMMIT").unwrap_err();
18257        assert_eq!(err, EngineError::NoActiveTransaction);
18258    }
18259
18260    #[test]
18261    fn rollback_without_begin_errors() {
18262        let mut e = Engine::new();
18263        let err = e.execute("ROLLBACK").unwrap_err();
18264        assert_eq!(err, EngineError::NoActiveTransaction);
18265    }
18266
18267    #[test]
18268    fn commit_applies_shadow_to_committed_catalog() {
18269        let mut e = Engine::new();
18270        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18271        e.execute("BEGIN").unwrap();
18272        e.execute("INSERT INTO t VALUES (1)").unwrap();
18273        e.execute("INSERT INTO t VALUES (2)").unwrap();
18274        e.execute("COMMIT").unwrap();
18275        assert!(!e.in_transaction());
18276        assert_eq!(e.catalog().get("t").unwrap().row_count(), 2);
18277    }
18278
18279    #[test]
18280    fn rollback_discards_shadow() {
18281        let mut e = Engine::new();
18282        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18283        e.execute("BEGIN").unwrap();
18284        e.execute("INSERT INTO t VALUES (1)").unwrap();
18285        e.execute("INSERT INTO t VALUES (2)").unwrap();
18286        e.execute("ROLLBACK").unwrap();
18287        assert!(!e.in_transaction());
18288        assert_eq!(e.catalog().get("t").unwrap().row_count(), 0);
18289    }
18290
18291    #[test]
18292    fn select_during_tx_sees_uncommitted_writes_own_session() {
18293        // The shadow catalog is read by SELECTs while a TX is open — the
18294        // session can see its own pending writes.
18295        let mut e = Engine::new();
18296        e.execute("CREATE TABLE t (v INT NOT NULL)").unwrap();
18297        e.execute("BEGIN").unwrap();
18298        e.execute("INSERT INTO t VALUES (42)").unwrap();
18299        let (_, rows) = unwrap_rows(e.execute("SELECT * FROM t").unwrap());
18300        assert_eq!(rows.len(), 1);
18301        assert_eq!(rows[0].values[0], Value::Int(42));
18302    }
18303
18304    #[test]
18305    fn snapshot_with_no_users_is_bare_catalog_format() {
18306        let mut e = Engine::new();
18307        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18308        let bytes = e.snapshot();
18309        assert_eq!(
18310            &bytes[..8],
18311            b"SPGDB001",
18312            "must be the bare v3.x catalog magic"
18313        );
18314        let e2 = Engine::restore_envelope(&bytes).unwrap();
18315        assert!(e2.users().is_empty());
18316        assert_eq!(e2.catalog().table_count(), 1);
18317    }
18318
18319    #[test]
18320    fn snapshot_with_users_round_trips_both_via_envelope() {
18321        let mut e = Engine::new();
18322        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18323        e.create_user("alice", "pw1", Role::Admin, [9; 16]).unwrap();
18324        e.create_user("bob", "pw2", Role::ReadOnly, [5; 16])
18325            .unwrap();
18326        let bytes = e.snapshot();
18327        assert_eq!(&bytes[..8], b"SPGENV01", "must be the v4.1 envelope magic");
18328        let e2 = Engine::restore_envelope(&bytes).unwrap();
18329        assert_eq!(e2.users().len(), 2);
18330        assert_eq!(e2.verify_user("alice", "pw1"), Some(Role::Admin));
18331        assert_eq!(e2.verify_user("bob", "pw2"), Some(Role::ReadOnly));
18332        assert_eq!(e2.verify_user("alice", "wrong"), None);
18333        assert_eq!(e2.catalog().table_count(), 1);
18334    }
18335
18336    #[test]
18337    fn ddl_inside_tx_also_rolled_back() {
18338        let mut e = Engine::new();
18339        e.execute("BEGIN").unwrap();
18340        e.execute("CREATE TABLE t (v INT)").unwrap();
18341        // Visible inside the TX.
18342        e.execute("SELECT * FROM t").unwrap();
18343        e.execute("ROLLBACK").unwrap();
18344        // Gone after rollback.
18345        let err = e.execute("SELECT * FROM t").unwrap_err();
18346        assert!(matches!(
18347            err,
18348            EngineError::Storage(StorageError::TableNotFound { .. })
18349        ));
18350    }
18351
18352    // ── v6.1.2: CREATE / DROP PUBLICATION (engine-side) ──────
18353
18354    #[test]
18355    fn create_publication_lands_in_catalog() {
18356        let mut e = Engine::new();
18357        assert!(e.publications().is_empty());
18358        e.execute("CREATE PUBLICATION pub_a").unwrap();
18359        assert_eq!(e.publications().len(), 1);
18360        assert!(e.publications().contains("pub_a"));
18361    }
18362
18363    #[test]
18364    fn create_publication_duplicate_errors() {
18365        let mut e = Engine::new();
18366        e.execute("CREATE PUBLICATION pub_a").unwrap();
18367        let err = e.execute("CREATE PUBLICATION pub_a").unwrap_err();
18368        assert!(
18369            alloc::format!("{err:?}").contains("DuplicateName"),
18370            "got {err:?}"
18371        );
18372    }
18373
18374    #[test]
18375    fn drop_publication_silent_when_absent() {
18376        let mut e = Engine::new();
18377        // PG-compatible: DROP a publication that doesn't exist
18378        // succeeds (no-op) but reports zero affected.
18379        let r = e.execute("DROP PUBLICATION nope").unwrap();
18380        match r {
18381            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
18382            other => panic!("expected CommandOk, got {other:?}"),
18383        }
18384    }
18385
18386    #[test]
18387    fn drop_publication_present_reports_one_affected() {
18388        let mut e = Engine::new();
18389        e.execute("CREATE PUBLICATION pub_a").unwrap();
18390        let r = e.execute("DROP PUBLICATION pub_a").unwrap();
18391        match r {
18392            QueryResult::CommandOk {
18393                affected,
18394                modified_catalog,
18395            } => {
18396                assert_eq!(affected, 1);
18397                assert!(modified_catalog);
18398            }
18399            other => panic!("expected CommandOk, got {other:?}"),
18400        }
18401        assert!(e.publications().is_empty());
18402    }
18403
18404    #[test]
18405    fn publications_persist_across_snapshot_restore() {
18406        // The persist-across-restart ship-gate at the engine layer —
18407        // snapshot → restore_envelope round trip must preserve the
18408        // publication catalog. The spg-server e2e covers the
18409        // process-restart variant.
18410        let mut e = Engine::new();
18411        e.execute("CREATE PUBLICATION pub_a").unwrap();
18412        e.execute("CREATE PUBLICATION pub_b FOR ALL TABLES")
18413            .unwrap();
18414        let snap = e.snapshot();
18415        let e2 = Engine::restore_envelope(&snap).unwrap();
18416        assert_eq!(e2.publications().len(), 2);
18417        assert!(e2.publications().contains("pub_a"));
18418        assert!(e2.publications().contains("pub_b"));
18419    }
18420
18421    #[test]
18422    fn create_publication_allowed_inside_transaction() {
18423        // v6.1.4 dropped the v6.1.2 in-TX guard — PG allows
18424        // CREATE PUBLICATION inside a TX and the auto-commit
18425        // wrap path needs the same allowance.
18426        let mut e = Engine::new();
18427        e.execute("BEGIN").unwrap();
18428        e.execute("CREATE PUBLICATION pub_a").unwrap();
18429        e.execute("COMMIT").unwrap();
18430        assert!(e.publications().contains("pub_a"));
18431    }
18432
18433    // ── v6.1.3: SHOW PUBLICATIONS + FOR-list variants ───────
18434
18435    #[test]
18436    fn create_publication_for_table_list_lands_with_scope() {
18437        let mut e = Engine::new();
18438        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
18439        e.execute("CREATE TABLE t2 (id INT NOT NULL)").unwrap();
18440        e.execute("CREATE PUBLICATION pub_a FOR TABLE t1, t2")
18441            .unwrap();
18442        let scope = e.publications().get("pub_a").cloned();
18443        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = scope else {
18444            panic!("expected ForTables scope, got {scope:?}")
18445        };
18446        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
18447    }
18448
18449    #[test]
18450    fn create_publication_all_tables_except_lands_with_scope() {
18451        let mut e = Engine::new();
18452        e.execute("CREATE PUBLICATION pub_a FOR ALL TABLES EXCEPT t3")
18453            .unwrap();
18454        let scope = e.publications().get("pub_a").cloned();
18455        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = scope else {
18456            panic!("expected AllTablesExcept scope, got {scope:?}")
18457        };
18458        assert_eq!(ts, alloc::vec!["t3".to_string()]);
18459    }
18460
18461    #[test]
18462    fn show_publications_empty_returns_zero_rows() {
18463        let e = Engine::new();
18464        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
18465        let QueryResult::Rows { rows, columns } = r else {
18466            panic!()
18467        };
18468        assert!(rows.is_empty());
18469        assert_eq!(columns.len(), 3);
18470        assert_eq!(columns[0].name, "name");
18471        assert_eq!(columns[1].name, "scope");
18472        assert_eq!(columns[2].name, "table_count");
18473    }
18474
18475    #[test]
18476    fn show_publications_returns_one_row_per_publication_ordered_by_name() {
18477        let mut e = Engine::new();
18478        e.execute("CREATE PUBLICATION z_pub").unwrap();
18479        e.execute("CREATE PUBLICATION a_pub FOR TABLE t1, t2")
18480            .unwrap();
18481        e.execute("CREATE PUBLICATION m_pub FOR ALL TABLES EXCEPT bad")
18482            .unwrap();
18483        let r = e.execute_readonly("SHOW PUBLICATIONS").unwrap();
18484        let QueryResult::Rows { rows, .. } = r else {
18485            panic!()
18486        };
18487        assert_eq!(rows.len(), 3);
18488        // Alphabetical order: a_pub, m_pub, z_pub.
18489        let names: Vec<&str> = rows
18490            .iter()
18491            .map(|r| {
18492                if let Value::Text(s) = &r.values[0] {
18493                    s.as_str()
18494                } else {
18495                    panic!()
18496                }
18497            })
18498            .collect();
18499        assert_eq!(names, alloc::vec!["a_pub", "m_pub", "z_pub"]);
18500        // Row 0 — a_pub scope summary + table_count = 2.
18501        match &rows[0].values[1] {
18502            Value::Text(s) => assert_eq!(s, "FOR TABLE t1, t2"),
18503            other => panic!("expected Text, got {other:?}"),
18504        }
18505        assert_eq!(rows[0].values[2], Value::Int(2));
18506        // Row 1 — m_pub.
18507        match &rows[1].values[1] {
18508            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES EXCEPT bad"),
18509            other => panic!("expected Text, got {other:?}"),
18510        }
18511        assert_eq!(rows[1].values[2], Value::Int(1));
18512        // Row 2 — z_pub (AllTables → NULL count).
18513        match &rows[2].values[1] {
18514            Value::Text(s) => assert_eq!(s, "FOR ALL TABLES"),
18515            other => panic!("expected Text, got {other:?}"),
18516        }
18517        assert_eq!(rows[2].values[2], Value::Null);
18518    }
18519
18520    #[test]
18521    fn for_list_scopes_persist_across_snapshot() {
18522        // The v6.1.2 envelope-v3 round-trip exercised AllTables;
18523        // v6.1.3 needs the scope-1 / scope-2 tags to survive too.
18524        let mut e = Engine::new();
18525        e.execute("CREATE PUBLICATION p1 FOR TABLE t1, t2").unwrap();
18526        e.execute("CREATE PUBLICATION p2 FOR ALL TABLES EXCEPT bad, worse")
18527            .unwrap();
18528        let snap = e.snapshot();
18529        let e2 = Engine::restore_envelope(&snap).unwrap();
18530        assert_eq!(e2.publications().len(), 2);
18531        let p1 = e2.publications().get("p1").cloned();
18532        let Some(spg_sql::ast::PublicationScope::ForTables(ts)) = p1 else {
18533            panic!("p1 scope lost: {p1:?}")
18534        };
18535        assert_eq!(ts, alloc::vec!["t1".to_string(), "t2".to_string()]);
18536        let p2 = e2.publications().get("p2").cloned();
18537        let Some(spg_sql::ast::PublicationScope::AllTablesExcept(ts)) = p2 else {
18538            panic!("p2 scope lost: {p2:?}")
18539        };
18540        assert_eq!(ts, alloc::vec!["bad".to_string(), "worse".to_string()]);
18541    }
18542
18543    // ── v6.1.4: CREATE / DROP SUBSCRIPTION + SHOW + envelope v4 ─
18544
18545    #[test]
18546    fn create_subscription_lands_in_catalog_with_defaults() {
18547        let mut e = Engine::new();
18548        e.execute(
18549            "CREATE SUBSCRIPTION sub_a CONNECTION 'host=127.0.0.1 port=20002' PUBLICATION pub_a",
18550        )
18551        .unwrap();
18552        let s = e.subscriptions().get("sub_a").cloned().expect("present");
18553        assert_eq!(s.conn_str, "host=127.0.0.1 port=20002");
18554        assert_eq!(s.publications, alloc::vec!["pub_a".to_string()]);
18555        assert!(s.enabled);
18556        assert_eq!(s.last_received_pos, 0);
18557    }
18558
18559    #[test]
18560    fn create_subscription_duplicate_name_errors() {
18561        let mut e = Engine::new();
18562        e.execute("CREATE SUBSCRIPTION s CONNECTION 'host=x' PUBLICATION p")
18563            .unwrap();
18564        let err = e
18565            .execute("CREATE SUBSCRIPTION s CONNECTION 'host=y' PUBLICATION p")
18566            .unwrap_err();
18567        assert!(
18568            alloc::format!("{err:?}").contains("DuplicateName"),
18569            "got {err:?}"
18570        );
18571    }
18572
18573    #[test]
18574    fn drop_subscription_silent_when_absent() {
18575        let mut e = Engine::new();
18576        let r = e.execute("DROP SUBSCRIPTION never").unwrap();
18577        match r {
18578            QueryResult::CommandOk { affected, .. } => assert_eq!(affected, 0),
18579            other => panic!("expected CommandOk, got {other:?}"),
18580        }
18581    }
18582
18583    #[test]
18584    fn subscription_advance_updates_last_pos_monotone() {
18585        let mut e = Engine::new();
18586        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
18587            .unwrap();
18588        assert!(e.subscription_advance("s", 100));
18589        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
18590        assert!(e.subscription_advance("s", 50)); // stale → ignored
18591        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 100);
18592        assert!(e.subscription_advance("s", 200));
18593        assert_eq!(e.subscriptions().get("s").unwrap().last_received_pos, 200);
18594        assert!(!e.subscription_advance("missing", 1));
18595    }
18596
18597    #[test]
18598    fn show_subscriptions_returns_rows_ordered_by_name() {
18599        let mut e = Engine::new();
18600        e.execute("CREATE SUBSCRIPTION z_sub CONNECTION 'h=x' PUBLICATION p1, p2")
18601            .unwrap();
18602        e.execute("CREATE SUBSCRIPTION a_sub CONNECTION 'h=y' PUBLICATION p3")
18603            .unwrap();
18604        let r = e.execute_readonly("SHOW SUBSCRIPTIONS").unwrap();
18605        let QueryResult::Rows { rows, columns } = r else {
18606            panic!()
18607        };
18608        assert_eq!(rows.len(), 2);
18609        assert_eq!(columns.len(), 5);
18610        assert_eq!(columns[0].name, "name");
18611        assert_eq!(columns[4].name, "last_received_pos");
18612        // Alphabetical: a_sub, z_sub.
18613        let names: Vec<&str> = rows
18614            .iter()
18615            .map(|r| {
18616                if let Value::Text(s) = &r.values[0] {
18617                    s.as_str()
18618                } else {
18619                    panic!()
18620                }
18621            })
18622            .collect();
18623        assert_eq!(names, alloc::vec!["a_sub", "z_sub"]);
18624        // Row 0: a_sub
18625        assert_eq!(rows[0].values[1], Value::Text("h=y".to_string()));
18626        assert_eq!(rows[0].values[2], Value::Text("p3".to_string()));
18627        assert_eq!(rows[0].values[3], Value::Bool(true));
18628        assert_eq!(rows[0].values[4], Value::BigInt(0));
18629        // Row 1: z_sub — publications join with ", "
18630        assert_eq!(rows[1].values[2], Value::Text("p1, p2".to_string()));
18631    }
18632
18633    #[test]
18634    fn subscriptions_persist_across_snapshot_envelope_v4() {
18635        let mut e = Engine::new();
18636        e.execute("CREATE SUBSCRIPTION s1 CONNECTION 'h=A' PUBLICATION p1, p2")
18637            .unwrap();
18638        e.execute("CREATE SUBSCRIPTION s2 CONNECTION 'h=B' PUBLICATION p3")
18639            .unwrap();
18640        e.subscription_advance("s2", 42);
18641        let snap = e.snapshot();
18642        let e2 = Engine::restore_envelope(&snap).unwrap();
18643        assert_eq!(e2.subscriptions().len(), 2);
18644        let s1 = e2.subscriptions().get("s1").unwrap();
18645        assert_eq!(s1.conn_str, "h=A");
18646        assert_eq!(
18647            s1.publications,
18648            alloc::vec!["p1".to_string(), "p2".to_string()]
18649        );
18650        assert_eq!(s1.last_received_pos, 0);
18651        let s2 = e2.subscriptions().get("s2").unwrap();
18652        assert_eq!(s2.last_received_pos, 42);
18653    }
18654
18655    #[test]
18656    fn v3_envelope_loads_with_empty_subscriptions() {
18657        // v3 snapshot (publications-only). Forge it by hand so we
18658        // verify v6.1.4 readers don't panic — they must surface
18659        // empty subscriptions and a populated publication table.
18660        let mut e = Engine::new();
18661        e.execute("CREATE PUBLICATION pub_legacy").unwrap();
18662        let catalog = e.catalog.serialize();
18663        let users = crate::users::serialize_users(&e.users);
18664        let pubs = e.publications.serialize();
18665        let mut buf = Vec::new();
18666        buf.extend_from_slice(b"SPGENV01");
18667        buf.push(3u8); // v3
18668        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
18669        buf.extend_from_slice(&catalog);
18670        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
18671        buf.extend_from_slice(&users);
18672        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
18673        buf.extend_from_slice(&pubs);
18674        let crc = spg_crypto::crc32::crc32(&buf);
18675        buf.extend_from_slice(&crc.to_le_bytes());
18676
18677        let e2 = Engine::restore_envelope(&buf).expect("v3 envelope restores under v4 reader");
18678        assert!(e2.subscriptions().is_empty());
18679        assert!(e2.publications().contains("pub_legacy"));
18680    }
18681
18682    #[test]
18683    fn create_subscription_allowed_inside_transaction() {
18684        let mut e = Engine::new();
18685        e.execute("BEGIN").unwrap();
18686        e.execute("CREATE SUBSCRIPTION s CONNECTION 'h=x' PUBLICATION p")
18687            .unwrap();
18688        e.execute("COMMIT").unwrap();
18689        assert!(e.subscriptions().contains("s"));
18690    }
18691
18692    // ── v6.2.0: ANALYZE + spg_statistic + envelope v5 ──────────
18693    #[test]
18694    fn analyze_populates_histogram_bounds() {
18695        let mut e = Engine::new();
18696        e.execute("CREATE TABLE t (id INT NOT NULL, name TEXT)")
18697            .unwrap();
18698        for i in 0..50 {
18699            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'name{i}')"))
18700                .unwrap();
18701        }
18702        e.execute("ANALYZE t").unwrap();
18703        let stats = e.statistics();
18704        let id_stats = stats.get("t", "id").unwrap();
18705        assert!(id_stats.histogram_bounds.len() >= 2);
18706        assert_eq!(id_stats.histogram_bounds.first().unwrap(), "0");
18707        assert_eq!(id_stats.histogram_bounds.last().unwrap(), "49");
18708        assert!((id_stats.null_frac - 0.0).abs() < 1e-6);
18709        assert_eq!(id_stats.n_distinct, 50);
18710    }
18711
18712    #[test]
18713    fn reanalyze_overwrites_prior_stats() {
18714        let mut e = Engine::new();
18715        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18716        for i in 0..10 {
18717            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18718                .unwrap();
18719        }
18720        e.execute("ANALYZE t").unwrap();
18721        let n1 = e.statistics().get("t", "id").unwrap().n_distinct;
18722        assert_eq!(n1, 10);
18723        for i in 10..30 {
18724            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18725                .unwrap();
18726        }
18727        e.execute("ANALYZE t").unwrap();
18728        let n2 = e.statistics().get("t", "id").unwrap().n_distinct;
18729        assert_eq!(n2, 30);
18730    }
18731
18732    #[test]
18733    fn analyze_unknown_table_errors() {
18734        let mut e = Engine::new();
18735        let err = e.execute("ANALYZE nonexistent").unwrap_err();
18736        assert!(matches!(
18737            err,
18738            EngineError::Storage(StorageError::TableNotFound { .. })
18739        ));
18740    }
18741
18742    #[test]
18743    fn bare_analyze_covers_all_user_tables() {
18744        let mut e = Engine::new();
18745        e.execute("CREATE TABLE t1 (id INT NOT NULL)").unwrap();
18746        e.execute("CREATE TABLE t2 (name TEXT NOT NULL)").unwrap();
18747        e.execute("INSERT INTO t1 VALUES (1)").unwrap();
18748        e.execute("INSERT INTO t2 VALUES ('alice')").unwrap();
18749        let r = e.execute("ANALYZE").unwrap();
18750        match r {
18751            QueryResult::CommandOk {
18752                affected,
18753                modified_catalog,
18754            } => {
18755                assert_eq!(affected, 2);
18756                assert!(modified_catalog);
18757            }
18758            other => panic!("expected CommandOk, got {other:?}"),
18759        }
18760        assert!(e.statistics().get("t1", "id").is_some());
18761        assert!(e.statistics().get("t2", "name").is_some());
18762    }
18763
18764    #[test]
18765    fn select_from_spg_statistic_returns_rows_per_column() {
18766        let mut e = Engine::new();
18767        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
18768            .unwrap();
18769        e.execute("INSERT INTO t VALUES (1, 'a')").unwrap();
18770        e.execute("INSERT INTO t VALUES (2, 'b')").unwrap();
18771        e.execute("ANALYZE t").unwrap();
18772        let r = e.execute_readonly("SELECT * FROM spg_statistic").unwrap();
18773        let QueryResult::Rows { rows, columns } = r else {
18774            panic!()
18775        };
18776        // v6.7.0 — spg_statistic gained a `cold_row_count` column.
18777        assert_eq!(columns.len(), 6);
18778        assert_eq!(columns[0].name, "table_name");
18779        assert_eq!(columns[4].name, "histogram_bounds");
18780        assert_eq!(columns[5].name, "cold_row_count");
18781        assert_eq!(rows.len(), 2, "one row per column of t");
18782        // Sorted by (table_name, column_name).
18783        match (&rows[0].values[0], &rows[0].values[1]) {
18784            (Value::Text(t), Value::Text(c)) => {
18785                assert_eq!(t, "t");
18786                // BTreeMap orders (table, column); columns "id" < "label".
18787                assert_eq!(c, "id");
18788            }
18789            _ => panic!(),
18790        }
18791    }
18792
18793    #[test]
18794    fn analyze_skips_vector_columns() {
18795        // Vector columns have their own stats shape (HNSW graph);
18796        // ANALYZE leaves them out of spg_statistic.
18797        let mut e = Engine::new();
18798        e.execute("CREATE TABLE t (id INT NOT NULL, v VECTOR(3) NOT NULL)")
18799            .unwrap();
18800        e.execute("INSERT INTO t VALUES (1, [1, 2, 3])").unwrap();
18801        e.execute("ANALYZE t").unwrap();
18802        assert!(e.statistics().get("t", "id").is_some());
18803        assert!(e.statistics().get("t", "v").is_none());
18804    }
18805
18806    #[test]
18807    fn statistics_persist_across_envelope_v5_round_trip() {
18808        let mut e = Engine::new();
18809        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18810        for i in 0..20 {
18811            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18812                .unwrap();
18813        }
18814        e.execute("ANALYZE").unwrap();
18815        let snap = e.snapshot();
18816        let e2 = Engine::restore_envelope(&snap).unwrap();
18817        let s = e2.statistics().get("t", "id").unwrap();
18818        assert_eq!(s.n_distinct, 20);
18819    }
18820
18821    // ── v6.2.1 auto-analyze threshold ───────────────────────────
18822
18823    #[test]
18824    fn auto_analyze_threshold_fires_after_10pct_of_min_rows_on_small_table() {
18825        // For a table with 0 rows then 10 inserts → modified=10,
18826        // row_count=10. Threshold = 0.1 × max(10, 100) = 10. So
18827        // after the 10th INSERT the threshold is met.
18828        let mut e = Engine::new();
18829        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18830        for i in 0..9 {
18831            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18832                .unwrap();
18833        }
18834        assert!(e.tables_needing_analyze().is_empty(), "9 < threshold");
18835        e.execute("INSERT INTO t VALUES (9)").unwrap();
18836        let needs = e.tables_needing_analyze();
18837        assert_eq!(needs, alloc::vec!["t".to_string()]);
18838    }
18839
18840    #[test]
18841    fn auto_analyze_threshold_uses_10pct_of_row_count_for_large_tables() {
18842        // After ANALYZE on 1000 rows, threshold = 0.1 × row_count.
18843        // Each new INSERT bumps both modified and row_count, so to
18844        // trigger from N=1000 we need modifications ≥ 0.1 × (1000+M),
18845        // i.e. M ≥ 112. The test inserts 50 (no fire), then 150
18846        // more (200 total mods, row_count=1200, threshold=120 → fire).
18847        let mut e = Engine::new();
18848        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18849        for i in 0..1000 {
18850            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18851                .unwrap();
18852        }
18853        e.execute("ANALYZE t").unwrap();
18854        assert!(e.tables_needing_analyze().is_empty(), "fresh ANALYZE");
18855        for i in 1000..1050 {
18856            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18857                .unwrap();
18858        }
18859        assert!(
18860            e.tables_needing_analyze().is_empty(),
18861            "50 inserts < threshold of ~105"
18862        );
18863        for i in 1050..1200 {
18864            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18865                .unwrap();
18866        }
18867        assert_eq!(
18868            e.tables_needing_analyze(),
18869            alloc::vec!["t".to_string()],
18870            "200 inserts > 0.1 × 1200 threshold"
18871        );
18872    }
18873
18874    #[test]
18875    fn auto_analyze_threshold_resets_after_analyze() {
18876        let mut e = Engine::new();
18877        e.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
18878        for i in 0..200 {
18879            e.execute(&alloc::format!("INSERT INTO t VALUES ({i})"))
18880                .unwrap();
18881        }
18882        assert!(!e.tables_needing_analyze().is_empty());
18883        e.execute("ANALYZE").unwrap();
18884        assert!(
18885            e.tables_needing_analyze().is_empty(),
18886            "ANALYZE must reset the counter"
18887        );
18888    }
18889
18890    #[test]
18891    fn auto_analyze_threshold_tracks_updates_and_deletes() {
18892        let mut e = Engine::new();
18893        e.execute("CREATE TABLE t (id INT NOT NULL, label TEXT)")
18894            .unwrap();
18895        for i in 0..50 {
18896            e.execute(&alloc::format!("INSERT INTO t VALUES ({i}, 'x')"))
18897                .unwrap();
18898        }
18899        e.execute("ANALYZE t").unwrap();
18900        // UPDATE 20 rows + DELETE 5 → modified=25. Threshold = 0.1
18901        // × max(50, 100) = 10. So 25 >= 10 → trigger.
18902        e.execute("UPDATE t SET label = 'y' WHERE id < 20").unwrap();
18903        e.execute("DELETE FROM t WHERE id >= 45").unwrap();
18904        assert_eq!(e.tables_needing_analyze(), alloc::vec!["t".to_string()]);
18905    }
18906
18907    #[test]
18908    fn v4_envelope_loads_with_empty_statistics() {
18909        // Forge a v4 envelope by hand: catalog + users + pubs +
18910        // subs trailer, no statistics. A v6.2.0 reader must accept
18911        // it and surface an empty Statistics.
18912        let mut e = Engine::new();
18913        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
18914            .unwrap();
18915        let catalog = e.catalog.serialize();
18916        let users = crate::users::serialize_users(&e.users);
18917        let pubs = e.publications.serialize();
18918        let subs = e.subscriptions.serialize();
18919        let mut buf = Vec::new();
18920        buf.extend_from_slice(b"SPGENV01");
18921        buf.push(4u8);
18922        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
18923        buf.extend_from_slice(&catalog);
18924        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
18925        buf.extend_from_slice(&users);
18926        buf.extend_from_slice(&u32::try_from(pubs.len()).unwrap().to_le_bytes());
18927        buf.extend_from_slice(&pubs);
18928        buf.extend_from_slice(&u32::try_from(subs.len()).unwrap().to_le_bytes());
18929        buf.extend_from_slice(&subs);
18930        let crc = spg_crypto::crc32::crc32(&buf);
18931        buf.extend_from_slice(&crc.to_le_bytes());
18932        let e2 = Engine::restore_envelope(&buf).expect("v4 envelope restores");
18933        assert!(e2.statistics().is_empty());
18934    }
18935
18936    #[test]
18937    fn v1_v2_envelope_loads_with_empty_publications() {
18938        // A snapshot taken before v6.1.2 (no publication trailer,
18939        // envelope v2) must still deserialise — and the resulting
18940        // engine must report zero publications. Use the engine's own
18941        // round-trip with no publications: that emits v3 but with an
18942        // empty pubs block. Then forge a v2 envelope by hand to lock
18943        // the back-compat path.
18944        let mut e = Engine::new();
18945        // Force users to be non-empty so the snapshot takes the
18946        // envelope path rather than the bare-catalog fallback.
18947        e.create_user("alice", "secret", crate::users::Role::ReadOnly, [0u8; 16])
18948            .unwrap();
18949
18950        // Forge an envelope v2: same shape as v3 but no pubs trailer.
18951        let catalog = e.catalog.serialize();
18952        let users = crate::users::serialize_users(&e.users);
18953        let mut buf = Vec::new();
18954        buf.extend_from_slice(b"SPGENV01");
18955        buf.push(2u8); // v2
18956        buf.extend_from_slice(&u32::try_from(catalog.len()).unwrap().to_le_bytes());
18957        buf.extend_from_slice(&catalog);
18958        buf.extend_from_slice(&u32::try_from(users.len()).unwrap().to_le_bytes());
18959        buf.extend_from_slice(&users);
18960        let crc = spg_crypto::crc32::crc32(&buf);
18961        buf.extend_from_slice(&crc.to_le_bytes());
18962
18963        let e2 = Engine::restore_envelope(&buf).expect("v2 envelope restores");
18964        assert!(e2.publications().is_empty());
18965    }
18966}