Skip to main content

powdb_query/executor/
mod.rs

1//! PowDB query executor.
2
3// Submodules that don't use macros defined in this file.
4mod compiled;
5mod eval;
6pub mod mem_budget;
7
8use crate::ast::*;
9use crate::canonicalize::canonicalize;
10use crate::plan::*;
11use crate::plan_cache::PlanCache;
12use crate::planner;
13use crate::result::{QueryError, QueryResult};
14use powdb_storage::catalog::Catalog;
15use powdb_storage::row::{decode_column, decode_row, RowLayout, ROW_MAGIC, ROW_PREFIX_SIZE};
16use powdb_storage::types::*;
17use powdb_storage::view::ViewRegistry;
18pub use powdb_storage::wal::{WalDurabilityTicket, WalSyncMode};
19
20use std::io;
21use std::path::Path;
22use std::sync::{Arc, Mutex};
23use std::time::Instant;
24use tracing::{error, info, Level};
25
26use self::compiled::*;
27use self::eval::*;
28
29/// Legacy sentinel string constant — kept for backward compatibility with
30/// any external code matching on the string representation. New code should
31/// match on `QueryError::ReadonlyNeedsWrite` directly.
32pub const READONLY_NEEDS_WRITE: &str = "__POWDB_READONLY_NEEDS_WRITE__";
33
34/// Return the byte offset where the row body starts.
35///
36/// v0.5 rows begin with the `PROW` magic/version prefix. Legacy rows start
37/// directly with the row body. Raw executor fast paths must add this base
38/// before reading body-relative bitmap/data offsets.
39#[inline]
40pub(crate) fn row_body_base(row: &[u8]) -> usize {
41    if row.len() >= ROW_PREFIX_SIZE && &row[0..4] == ROW_MAGIC {
42        ROW_PREFIX_SIZE
43    } else {
44        0
45    }
46}
47
48/// Query frontend dialect. PowQL remains the default/native dialect; SQL is
49/// an explicit frontend that lowers to the same AST before planning.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum QueryDialect {
52    PowQL,
53    Sql,
54}
55
56/// Plan cache capacity. Bench workloads fill ~15 slots; real apps will sit
57/// comfortably in 256. Lookup is O(1), collisions clear the cache (see
58/// `plan_cache::PlanCache::insert`).
59const PLAN_CACHE_CAPACITY: usize = 256;
60type WalArchiveHook =
61    Arc<dyn Fn(&Path, &[powdb_storage::wal::WalRecord]) -> io::Result<()> + Send + Sync>;
62
63/// Maximum number of rows a join may produce before the executor aborts.
64/// Prevents Cartesian-product blowups (e.g. `T cross join T` on 10K rows
65/// would produce 100M rows in memory without this cap).
66pub(super) const MAX_JOIN_ROWS: usize = 1_000_000;
67
68/// Maximum number of rows that may be materialized for sorting.
69/// Queries that exceed this should add a LIMIT clause to narrow the input
70/// before sorting.
71pub(super) const MAX_SORT_ROWS: usize = 10_000_000;
72
73#[inline]
74pub(super) fn check_join_limit(row_count: usize) -> Result<(), QueryError> {
75    if row_count > MAX_JOIN_ROWS {
76        return Err(QueryError::JoinLimitExceeded);
77    }
78    Ok(())
79}
80
81// ─── Mission D11 Phase 1: scalar hot-loop helpers ─────────────────────────
82//
83// These macros expand into the scan body of `agg_single_col_fast` and sit
84// inside the `for_each_row_raw` closure. They exist to:
85//
86//   1. Split the loop on presence of a predicate *outside* the hot body,
87//      so the no-predicate path (agg_sum/agg_min/agg_max bench workloads)
88//      never pays the `Option<CompiledPredicate>` branch per row.
89//   2. Drop two bounds checks per row by reading the null bitmap byte
90//      and the 8-byte value via raw pointer casts.
91//
92// SAFETY (shared across every call site below):
93//
94//   - `$bmp_byte` is `col_idx / 8` where `col_idx < n_cols`, and the row body
95//     encoding stores `bitmap_size = n_cols.div_ceil(8)` bytes of bitmap
96//     starting at body offset 2. So `bmp_off = row_body_base(row) + 2 +
97//     $bmp_byte < row_len`, and `get_unchecked(bmp_off)` is inside the
98//     row slice.
99//   - `$off = 2 + bitmap_size + fixed_offsets[col_idx]` is body-relative for a fixed-size
100//     column. Every fixed-size column contributes `fixed_size(type_id)`
101//     bytes to the fixed region, so the row always has
102//     `[data_off .. data_off + 8]` available for any i64/f64 column, where
103//     `data_off = row_body_base(row) + $off` — enforced by the row encoder
104//     (`storage/src/row.rs`) and the schema invariant that a row with a
105//     given schema has enough body bytes for `2 + bitmap_size + fixed_region_size`.
106//   - Both macros are only invoked from `agg_single_col_fast`, which
107//     early-returns if the column isn't Int/Float (8-byte fixed) and
108//     early-returns if `fast.fixed_offsets[col_idx]` is `None`.
109macro_rules! agg_int_loop {
110    (
111        $self:expr, $table:expr, $pred:expr,
112        $bmp_byte:expr, $bmp_bit:expr, $off:expr,
113        |$v:ident : i64| $body:block
114    ) => {{
115        let bmp_byte = $bmp_byte;
116        let bmp_bit = $bmp_bit;
117        let off = $off;
118        if let Some(pred) = &$pred {
119            $self
120                .catalog
121                .for_each_row_raw($table, |_rid, data| {
122                    if !pred(data) {
123                        return;
124                    }
125                    let base = row_body_base(data);
126                    let bmp_off = base + 2 + bmp_byte;
127                    let data_off = base + off;
128                    // Bounds guard: skip corrupt/truncated rows that are too
129                    // short to contain the bitmap byte or the 8-byte value.
130                    if bmp_off >= data.len() || data_off + 8 > data.len() {
131                        return;
132                    }
133                    // SAFETY: `bmp_off < data.len()` is checked above.
134                    // The bitmap byte lives at body offset 2..2+bitmap_size in
135                    // the row encoding, and bmp_byte = col_idx / 8 < bitmap_size.
136                    // Corrupt rows are rejected by the bounds guard.
137                    let bmp = unsafe { *data.get_unchecked(bmp_off) };
138                    if (bmp >> bmp_bit) & 1 == 1 {
139                        return;
140                    }
141                    // SAFETY: `data_off + 8 <= data.len()` is checked above.
142                    // `data_off = base + 2 + bitmap_size + fixed_offsets[col_idx]`
143                    // points to an 8-byte i64 in the fixed-size region of the row.
144                    // The pointer cast is valid because we read exactly 8
145                    // bytes via from_le_bytes. Corrupt rows are rejected by
146                    // the bounds guard.
147                    let $v: i64 = unsafe {
148                        i64::from_le_bytes(*(data.as_ptr().add(data_off) as *const [u8; 8]))
149                    };
150                    $body
151                })
152                .map_err(|e| QueryError::StorageError(e.to_string()))?;
153        } else {
154            $self
155                .catalog
156                .for_each_row_raw($table, |_rid, data| {
157                    let base = row_body_base(data);
158                    let bmp_off = base + 2 + bmp_byte;
159                    let data_off = base + off;
160                    // Bounds guard: skip corrupt/truncated rows.
161                    if bmp_off >= data.len() || data_off + 8 > data.len() {
162                        return;
163                    }
164                    // SAFETY: `bmp_off < data.len()` is checked above.
165                    // See the predicate branch for the full invariant.
166                    let bmp = unsafe { *data.get_unchecked(bmp_off) };
167                    if (bmp >> bmp_bit) & 1 == 1 {
168                        return;
169                    }
170                    // SAFETY: `data_off + 8 <= data.len()` is checked above.
171                    // See the predicate branch for the full invariant.
172                    let $v: i64 = unsafe {
173                        i64::from_le_bytes(*(data.as_ptr().add(data_off) as *const [u8; 8]))
174                    };
175                    $body
176                })
177                .map_err(|e| QueryError::StorageError(e.to_string()))?;
178        }
179    }};
180}
181
182macro_rules! agg_float_loop {
183    (
184        $self:expr, $table:expr, $pred:expr,
185        $bmp_byte:expr, $bmp_bit:expr, $off:expr,
186        |$v:ident : f64| $body:block
187    ) => {{
188        let bmp_byte = $bmp_byte;
189        let bmp_bit = $bmp_bit;
190        let off = $off;
191        if let Some(pred) = &$pred {
192            $self
193                .catalog
194                .for_each_row_raw($table, |_rid, data| {
195                    if !pred(data) {
196                        return;
197                    }
198                    let base = row_body_base(data);
199                    let bmp_off = base + 2 + bmp_byte;
200                    let data_off = base + off;
201                    // Bounds guard: skip corrupt/truncated rows that are too
202                    // short to contain the bitmap byte or the 8-byte value.
203                    if bmp_off >= data.len() || data_off + 8 > data.len() {
204                        return;
205                    }
206                    // SAFETY: `bmp_off < data.len()` is checked above.
207                    // The bitmap byte lives at body offset 2..2+bitmap_size in
208                    // the row encoding, and bmp_byte = col_idx / 8 < bitmap_size.
209                    // Corrupt rows are rejected by the bounds guard.
210                    let bmp = unsafe { *data.get_unchecked(bmp_off) };
211                    if (bmp >> bmp_bit) & 1 == 1 {
212                        return;
213                    }
214                    // SAFETY: `data_off + 8 <= data.len()` is checked above.
215                    // `data_off = base + 2 + bitmap_size + fixed_offsets[col_idx]`
216                    // points to an 8-byte f64 in the fixed-size region of the row.
217                    // The pointer cast is valid because we read exactly 8
218                    // bytes via from_le_bytes. Corrupt rows are rejected by
219                    // the bounds guard.
220                    let $v: f64 = unsafe {
221                        f64::from_le_bytes(*(data.as_ptr().add(data_off) as *const [u8; 8]))
222                    };
223                    $body
224                })
225                .map_err(|e| QueryError::StorageError(e.to_string()))?;
226        } else {
227            $self
228                .catalog
229                .for_each_row_raw($table, |_rid, data| {
230                    let base = row_body_base(data);
231                    let bmp_off = base + 2 + bmp_byte;
232                    let data_off = base + off;
233                    // Bounds guard: skip corrupt/truncated rows.
234                    if bmp_off >= data.len() || data_off + 8 > data.len() {
235                        return;
236                    }
237                    // SAFETY: `bmp_off < data.len()` is checked above.
238                    // See the predicate branch for the full invariant.
239                    let bmp = unsafe { *data.get_unchecked(bmp_off) };
240                    if (bmp >> bmp_bit) & 1 == 1 {
241                        return;
242                    }
243                    // SAFETY: `data_off + 8 <= data.len()` is checked above.
244                    // See the predicate branch for the full invariant.
245                    let $v: f64 = unsafe {
246                        f64::from_le_bytes(*(data.as_ptr().add(data_off) as *const [u8; 8]))
247                    };
248                    $body
249                })
250                .map_err(|e| QueryError::StorageError(e.to_string()))?;
251        }
252    }};
253}
254
255// Submodules that use the macros above — must be declared after macro_rules!.
256mod plan_exec;
257mod prepared;
258
259#[cfg(test)]
260mod tests;
261
262// Re-exports for the public API
263pub use self::prepared::PreparedQuery;
264
265use self::plan_exec::{
266    compute_group_aggregate, execute_window, format_plan_tree, hash_join, lower_unindexed_scans,
267    range_matches, synthesize_range_predicate, try_extract_equi_join_keys,
268};
269
270/// Mission infra-1: classify a parsed statement as read-only vs. mutating.
271/// Used by [`Engine::execute_powql_readonly`] and by the server handler
272/// to decide between the RwLock reader and writer sides. `Union` recurses
273/// because each side can independently be read/write (though in practice
274/// both sides are reads — the parser only builds Union from query shapes).
275pub fn is_read_only_statement(stmt: &Statement) -> bool {
276    match stmt {
277        Statement::Query(_) => true,
278        Statement::ListTypes | Statement::Describe(_) => true,
279        Statement::Union(u) => is_read_only_statement(&u.left) && is_read_only_statement(&u.right),
280        Statement::Insert(_)
281        | Statement::Upsert(_)
282        | Statement::UpdateQuery(_)
283        | Statement::DeleteQuery(_)
284        | Statement::CreateType(_)
285        | Statement::AlterTable(_)
286        | Statement::DropTable(_)
287        | Statement::CreateView(_)
288        | Statement::RefreshView(_)
289        | Statement::DropView(_) => false,
290        Statement::Begin | Statement::Commit | Statement::Rollback => false,
291        Statement::Explain(inner) => is_read_only_statement(inner),
292    }
293}
294
295pub struct Engine {
296    catalog: Catalog,
297    /// Exclusive PID-based lock on the data directory, held for the engine's
298    /// lifetime so two separate processes can't open the same dir and corrupt
299    /// the heap/WAL. Released on clean drop; a `mem::forget` crash leaves a
300    /// stale lock the next open takes over. Leading `_`: it does its work
301    /// through `Drop`, never read directly.
302    _dir_lock: powdb_storage::dir_lock::DirLock,
303    /// Mission D9 — cached parsed+planned query trees keyed by canonical
304    /// hash. Saves the ~3μs parse+plan cost on repeat queries that differ
305    /// only in literal values.
306    ///
307    /// Mission infra-1: wrapped in `Mutex` so the read path can be driven
308    /// by `&self`. The critical section is extremely short — a single
309    /// hashmap lookup + plan clone on a hit, or a single insert on a miss.
310    /// A full `RwLock` would be over-engineered here; the contention window
311    /// is smaller than the read-path scan work it gates.
312    plan_cache: Mutex<PlanCache>,
313    /// Mission C Phase 13: reusable `Vec<Value>` scratch buffer for the
314    /// prepared-insert fast path. `execute_prepared` used to allocate a
315    /// fresh `vec![Value::Empty; n_cols]` on every insert; recycling this
316    /// buffer shaves one heap alloc per row on `insert_batch_1k`.
317    insert_values_scratch: Vec<Value>,
318    /// Materialized view registry: tracks view definitions, dependencies,
319    /// and dirty state. Views are backed by regular catalog tables; this
320    /// registry adds the lifecycle metadata.
321    view_registry: ViewRegistry,
322    in_transaction: bool,
323    /// WS2 — per-query memory budget ceiling (bytes). The running total lives
324    /// in a thread-local (see [`mem_budget`]) and is reset at every top-level
325    /// query entry, so sort/join/GROUP BY/IN-list materialization can be capped
326    /// without OOM-killing the process. This field holds only the *limit* (a
327    /// plain `usize`, so `Engine` stays `Sync` for the concurrent read path).
328    /// Default [`mem_budget::DEFAULT_QUERY_MEMORY_LIMIT`] (256 MB); overridable
329    /// via `Engine::with_memory_limit` (server reads `POWDB_QUERY_MEMORY_LIMIT`).
330    query_memory_limit: usize,
331    wal_archive_hook: Option<WalArchiveHook>,
332}
333
334impl Engine {
335    /// Open or create a PowDB engine rooted at `data_dir`.
336    ///
337    /// If the directory already contains a catalog, it is reopened.
338    /// Otherwise a fresh empty database is created.
339    ///
340    /// # Examples
341    ///
342    /// ```
343    /// use powdb_query::executor::Engine;
344    ///
345    /// let dir = tempfile::tempdir().unwrap();
346    /// let engine = Engine::new(dir.path()).unwrap();
347    /// // Engine is ready — the directory now contains a catalog.
348    /// ```
349    pub fn new(data_dir: &Path) -> io::Result<Self> {
350        Self::new_inner(data_dir, None)
351    }
352
353    /// Open or create an engine that archives WAL records before any recovery,
354    /// rollback, or drop checkpoint truncates them. This keeps the query crate
355    /// independent of replication metadata while giving sync-aware callers one
356    /// lifecycle boundary for retained-history preservation.
357    pub fn new_with_wal_archive<F>(data_dir: &Path, archive: F) -> io::Result<Self>
358    where
359        F: Fn(&Path, &[powdb_storage::wal::WalRecord]) -> io::Result<()> + Send + Sync + 'static,
360    {
361        Self::new_inner(data_dir, Some(Arc::new(archive)))
362    }
363
364    fn new_inner(data_dir: &Path, wal_archive_hook: Option<WalArchiveHook>) -> io::Result<Self> {
365        powdb_storage::create_data_dir_secure(data_dir)?;
366        // Refuse to open a directory another live process already holds, before
367        // touching any on-disk state (concurrent writers corrupt the heap/WAL).
368        let dir_lock = powdb_storage::dir_lock::DirLock::acquire(data_dir)?;
369        // Try to reopen an existing database first; only create a fresh
370        // catalog when there isn't one already on disk.
371        let catalog_result = match &wal_archive_hook {
372            Some(hook) => {
373                let hook = Arc::clone(hook);
374                Catalog::open_with_wal_archive(data_dir, move |dir, records| hook(dir, records))
375            }
376            None => Catalog::open(data_dir),
377        };
378        let catalog = match catalog_result {
379            Ok(c) => {
380                info!(data_dir = %data_dir.display(), "engine reopened existing database");
381                c
382            }
383            Err(e) if e.kind() == io::ErrorKind::NotFound => {
384                info!(data_dir = %data_dir.display(), "engine initialized fresh database");
385                Catalog::create(data_dir)?
386            }
387            Err(e) => return Err(e),
388        };
389        let view_registry =
390            ViewRegistry::open(data_dir).unwrap_or_else(|_| ViewRegistry::new(data_dir));
391        Ok(Engine {
392            catalog,
393            _dir_lock: dir_lock,
394            plan_cache: Mutex::new(PlanCache::new(PLAN_CACHE_CAPACITY)),
395            insert_values_scratch: Vec::new(),
396            view_registry,
397            in_transaction: false,
398            query_memory_limit: mem_budget::DEFAULT_QUERY_MEMORY_LIMIT,
399            wal_archive_hook,
400        })
401    }
402
403    /// Open or create an engine with an explicit per-query memory limit
404    /// (bytes). Used by the server to apply `POWDB_QUERY_MEMORY_LIMIT`, and by
405    /// tests that need a tiny limit to exercise the budget guard.
406    pub fn with_memory_limit(data_dir: &Path, limit_bytes: usize) -> io::Result<Self> {
407        let mut engine = Engine::new(data_dir)?;
408        engine.set_query_memory_limit(limit_bytes);
409        Ok(engine)
410    }
411
412    /// Open or create an archive-aware engine with an explicit per-query memory
413    /// limit.
414    pub fn with_memory_limit_and_wal_archive<F>(
415        data_dir: &Path,
416        limit_bytes: usize,
417        archive: F,
418    ) -> io::Result<Self>
419    where
420        F: Fn(&Path, &[powdb_storage::wal::WalRecord]) -> io::Result<()> + Send + Sync + 'static,
421    {
422        let mut engine = Engine::new_with_wal_archive(data_dir, archive)?;
423        engine.set_query_memory_limit(limit_bytes);
424        Ok(engine)
425    }
426
427    /// Current per-query memory limit in bytes.
428    pub fn query_memory_limit(&self) -> usize {
429        self.query_memory_limit
430    }
431
432    /// Override the per-query memory limit in bytes (builder-style).
433    pub fn set_query_memory_limit(&mut self, limit_bytes: usize) {
434        self.query_memory_limit = limit_bytes;
435    }
436
437    /// Set the WAL durability mode (see [`WalSyncMode`]). `Full` (the default)
438    /// fsyncs every commit; `Normal` moves the fsync to a background flusher
439    /// with a bounded crash-loss window; `Off` is bench-only (no durability).
440    /// Wired from the server's `POWDB_SYNC_MODE` / `--sync-mode` config.
441    pub fn set_wal_sync_mode(&mut self, mode: WalSyncMode) {
442        self.catalog.set_wal_sync_mode(mode);
443    }
444
445    /// Run `f` with commit durability deferred — the WAL group-commit entry
446    /// point for callers that serialize writers behind an exclusive lock.
447    ///
448    /// Inside `f`, Full-mode commit points register the WAL generation they
449    /// need durable instead of fsyncing inline. The returned ticket (if any)
450    /// must be waited on before the statement's result is acknowledged; the
451    /// caller should release its exclusive engine lock first, so other
452    /// committers can append while the fsync runs. That overlap is what lets
453    /// one fsync cover many commits. A lone committer's wait performs the
454    /// fsync immediately — group commit never introduces a delay.
455    ///
456    /// `Normal`/`Off` sync modes return no ticket; their durability
457    /// contracts are unchanged. If `f` panics the engine must not be reused
458    /// (the deferral flag may still be set); lock poisoning enforces this
459    /// for callers that share the engine behind a lock.
460    pub fn run_with_deferred_durability<T>(
461        &mut self,
462        f: impl FnOnce(&mut Engine) -> T,
463    ) -> (T, Option<WalDurabilityTicket>) {
464        self.catalog.set_wal_sync_deferred(true);
465        let out = f(self);
466        self.catalog.set_wal_sync_deferred(false);
467        let ticket = self.catalog.take_wal_durability_ticket();
468        (out, ticket)
469    }
470
471    /// Number of fsyncs issued against the WAL (test/metrics hook).
472    pub fn wal_fsync_count(&self) -> u64 {
473        self.catalog.wal_fsync_count()
474    }
475
476    /// Roll back the active explicit transaction while archiving any committed
477    /// pre-transaction WAL records that recovery must replay and truncate.
478    /// This is the sync-aware counterpart to the ordinary `rollback` statement;
479    /// callers provide the archive hook so the query crate stays independent of
480    /// replication metadata.
481    pub fn rollback_transaction_with_wal_archive<F>(
482        &mut self,
483        archive: F,
484    ) -> Result<QueryResult, QueryError>
485    where
486        F: FnMut(&Path, &[powdb_storage::wal::WalRecord]) -> io::Result<()>,
487    {
488        if !self.in_transaction {
489            return Err(QueryError::Execution(
490                "no active transaction to roll back".into(),
491            ));
492        }
493        self.catalog
494            .rollback_to_last_sync_with_wal_archive(archive)
495            .map_err(|e| QueryError::StorageError(e.to_string()))?;
496        self.finish_rollback_after_catalog_restore()
497    }
498
499    pub fn rollback_transaction_preserving_wal_archive(
500        &mut self,
501    ) -> Result<QueryResult, QueryError> {
502        let Some(hook) = self.wal_archive_hook.clone() else {
503            if !self.in_transaction {
504                return Err(QueryError::Execution(
505                    "no active transaction to roll back".into(),
506                ));
507            }
508            self.catalog
509                .rollback_to_last_sync()
510                .map_err(|e| QueryError::StorageError(e.to_string()))?;
511            return self.finish_rollback_after_catalog_restore();
512        };
513        self.rollback_transaction_with_wal_archive(move |dir, records| hook(dir, records))
514    }
515
516    fn finish_rollback_after_catalog_restore(&mut self) -> Result<QueryResult, QueryError> {
517        self.in_transaction = false;
518        if let Ok(mut cache) = self.plan_cache.lock() {
519            cache.clear();
520        }
521        self.view_registry = ViewRegistry::open(self.catalog.data_dir())
522            .unwrap_or_else(|_| ViewRegistry::new(self.catalog.data_dir()));
523        Ok(QueryResult::Executed {
524            message: "transaction rolled back".to_string(),
525        })
526    }
527
528    /// Enter a budgeted-statement frame for the current query. The returned
529    /// guard must be held for the duration of the statement; on its drop the
530    /// reentrancy depth is decremented. Only the *outermost* statement entry
531    /// zeroes this thread's running total, so a nested `execute_powql` (the
532    /// source query of a `create_view`/`refresh_view`) does NOT discard the
533    /// outer frame's accounting. The accumulator is thread-local, so this never
534    /// touches another concurrent query's total.
535    #[must_use = "the budget guard must outlive the statement body"]
536    pub(super) fn enter_memory_budget(&self) -> mem_budget::EnterGuard {
537        mem_budget::enter()
538    }
539
540    /// Charge the estimated footprint of a freshly materialized batch of rows
541    /// against the current per-query budget. Returns
542    /// [`QueryError::MemoryLimitExceeded`] cleanly if the batch would push the
543    /// query over its limit. Used at every full-materialization point (sort
544    /// buffer, join build side, GROUP BY hash table, IN-list).
545    pub(super) fn charge_rows(&self, rows: &[Vec<Value>]) -> Result<(), QueryError> {
546        let mut total = 0usize;
547        for row in rows {
548            total = total.saturating_add(mem_budget::estimate_row_size(row));
549        }
550        mem_budget::charge(total, self.query_memory_limit)
551    }
552
553    /// Charge a materialized IN-list (the literal expressions pulled out of an
554    /// uncorrelated `IN (subquery)`) against the current per-query budget.
555    /// Each item is conservatively sized at the `Expr` slot plus, for string
556    /// literals, the owned heap bytes.
557    pub(super) fn charge_in_list(&self, list: &[crate::ast::Expr]) -> Result<(), QueryError> {
558        let base = std::mem::size_of::<crate::ast::Expr>();
559        let mut total = std::mem::size_of::<Vec<crate::ast::Expr>>();
560        for item in list {
561            total = total.saturating_add(base);
562            if let crate::ast::Expr::Literal(crate::ast::Literal::String(s)) = item {
563                total = total.saturating_add(s.capacity());
564            }
565        }
566        mem_budget::charge(total, self.query_memory_limit)
567    }
568
569    /// Dispatch to the requested query frontend.
570    pub fn execute_with_dialect(
571        &mut self,
572        dialect: QueryDialect,
573        input: &str,
574    ) -> Result<QueryResult, QueryError> {
575        match dialect {
576            QueryDialect::PowQL => self.execute_powql(input),
577            QueryDialect::Sql => self.execute_sql(input),
578        }
579    }
580
581    /// Read-only variant of [`Engine::execute_with_dialect`].
582    pub fn execute_readonly_with_dialect(
583        &self,
584        dialect: QueryDialect,
585        input: &str,
586    ) -> Result<QueryResult, QueryError> {
587        match dialect {
588            QueryDialect::PowQL => self.execute_powql_readonly(input),
589            QueryDialect::Sql => self.execute_sql_readonly(input),
590        }
591    }
592
593    /// Parse + plan + execute a PowQL query.
594    ///
595    /// # Examples
596    ///
597    /// ```
598    /// use powdb_query::executor::Engine;
599    /// use powdb_query::result::QueryResult;
600    ///
601    /// let dir = tempfile::tempdir().unwrap();
602    /// let mut engine = Engine::new(dir.path()).unwrap();
603    ///
604    /// // Create a table and insert a row.
605    /// engine.execute_powql("type User { required name: str, age: int }").unwrap();
606    /// engine.execute_powql(r#"insert User { name := "Alice", age := 30 }"#).unwrap();
607    ///
608    /// // Query rows back.
609    /// let result = engine.execute_powql("User").unwrap();
610    /// assert_eq!(result.row_count(), 1);
611    /// ```
612    ///
613    /// Mission D6 — tracing collapse: the previous implementation ran 4
614    /// `Instant::now()` + 3 `elapsed().as_micros()` calls + formatted an
615    /// `info!` span on every query, even when tracing was disabled. On a
616    /// sub-microsecond `point_lookup_indexed` call that overhead was
617    /// 100-200ns — 20%+ of the whole query. We now measure time only when
618    /// INFO is actually enabled via `tracing::enabled!`, and we moved the
619    /// noisy `debug!(?plan)` line behind the same gate so the Debug
620    /// formatter can't run unconditionally either.
621    ///
622    /// Mission D9 — plan cache: on the hot path we canonicalise the query
623    /// text (lex + FNV-1a hash with literal values stripped), check the
624    /// cache, and on a hit substitute the new literals into a clone of the
625    /// cached plan. This skips re-lexing, re-parsing, and re-planning —
626    /// around 3μs per call on bench workloads. On a miss we plan as before
627    /// and insert the plan under its canonical hash.
628    pub fn execute_powql(&mut self, input: &str) -> Result<QueryResult, QueryError> {
629        // WS2: each *outermost* statement starts with the full memory
630        // allowance. The guard holds the reentrancy depth so a nested
631        // `execute_powql` (e.g. a view's source query) does not reset the
632        // outer frame's accounting mid-statement.
633        let _budget = self.enter_memory_budget();
634        // Hot path: tracing disabled. Zero syscalls, zero formatting.
635        if !tracing::enabled!(Level::INFO) {
636            // D9: try the plan cache first. Canonicalisation lexes the
637            // query once; on a hit we skip the parser and planner entirely.
638            if let Ok((hash, literals)) = canonicalize(input) {
639                let cached = self
640                    .plan_cache
641                    .lock()
642                    .map_err(|e| QueryError::Execution(format!("plan cache lock poisoned: {e}")))?
643                    .get_with_substitution(hash, &literals);
644                if let Some(plan) = cached {
645                    let plan = lower_unindexed_scans(&self.catalog, &plan);
646                    let result = self.execute_plan(&plan);
647                    // Mission B (post-review): statement-boundary WAL
648                    // group commit. Catalog::wal_log now only appends;
649                    // the fsync happens here exactly once per statement.
650                    // `sync_wal` is a no-op when nothing was buffered
651                    // (pure reads pay zero fsync).
652                    if !self.in_transaction {
653                        self.catalog
654                            .commit_autocommit()
655                            .map_err(|e| QueryError::StorageError(e.to_string()))?;
656                    }
657                    return result;
658                }
659                // Miss — plan, insert, execute.
660                return match planner::plan(input) {
661                    Ok(plan) => {
662                        self.plan_cache
663                            .lock()
664                            .map_err(|e| {
665                                QueryError::Execution(format!("plan cache lock poisoned: {e}"))
666                            })?
667                            .insert(hash, plan.clone(), literals.len());
668                        let plan = lower_unindexed_scans(&self.catalog, &plan);
669                        let result = self.execute_plan(&plan);
670                        if !self.in_transaction {
671                            self.catalog
672                                .commit_autocommit()
673                                .map_err(|e| QueryError::StorageError(e.to_string()))?;
674                        }
675                        result
676                    }
677                    Err(e) => Err(QueryError::Parse(e.to_string())),
678                };
679            }
680            // Lex error — fall through to the planner so the caller gets a
681            // consistent error shape.
682            return match planner::plan(input) {
683                Ok(plan) => {
684                    let plan = lower_unindexed_scans(&self.catalog, &plan);
685                    let result = self.execute_plan(&plan);
686                    if !self.in_transaction {
687                        self.catalog
688                            .commit_autocommit()
689                            .map_err(|e| QueryError::StorageError(e.to_string()))?;
690                    }
691                    result
692                }
693                Err(e) => Err(QueryError::Parse(e.to_string())),
694            };
695        }
696
697        // Instrumented path — only taken under explicit tracing subscribers.
698        let total_start = Instant::now();
699        let plan_start = Instant::now();
700        let plan = planner::plan(input).map_err(|e| {
701            let msg = e.to_string();
702            error!(query = %input, error = %msg, "query plan failed");
703            QueryError::Parse(msg)
704        })?;
705        let plan_us = plan_start.elapsed().as_micros();
706
707        let exec_start = Instant::now();
708        let plan = lower_unindexed_scans(&self.catalog, &plan);
709        let result = self.execute_plan(&plan);
710        if !self.in_transaction {
711            self.catalog
712                .commit_autocommit()
713                .map_err(|e| QueryError::StorageError(e.to_string()))?;
714        }
715        let exec_us = exec_start.elapsed().as_micros();
716
717        let total_us = total_start.elapsed().as_micros();
718        match &result {
719            Ok(r) => {
720                info!(
721                    query = %input,
722                    plan_us = plan_us,
723                    exec_us = exec_us,
724                    total_us = total_us,
725                    rows = r.row_count(),
726                    "query ok"
727                );
728            }
729            Err(e) => {
730                error!(
731                    query = %input,
732                    plan_us = plan_us,
733                    exec_us = exec_us,
734                    error = %e,
735                    "query failed"
736                );
737            }
738        }
739        result
740    }
741
742    /// Parse + plan + execute a SQL query through the SQL frontend.
743    ///
744    /// SQL is lowered to the existing PowDB AST and to canonical PowQL text.
745    /// The canonical PowQL text is used as the plan-cache key, so equivalent
746    /// SQL and PowQL spellings share cached plans.
747    pub fn execute_sql(&mut self, input: &str) -> Result<QueryResult, QueryError> {
748        let _budget = self.enter_memory_budget();
749        let parsed = crate::sql::parse_sql_with_canonical(input)
750            .map_err(|e| QueryError::Parse(e.to_string()))?;
751
752        if !tracing::enabled!(Level::INFO) {
753            if let Ok((hash, literals)) = canonicalize(&parsed.canonical_powql) {
754                let cached = self
755                    .plan_cache
756                    .lock()
757                    .map_err(|e| QueryError::Execution(format!("plan cache lock poisoned: {e}")))?
758                    .get_with_substitution(hash, &literals);
759                if let Some(plan) = cached {
760                    let plan = lower_unindexed_scans(&self.catalog, &plan);
761                    let result = self.execute_plan(&plan);
762                    if !self.in_transaction {
763                        self.catalog
764                            .commit_autocommit()
765                            .map_err(|e| QueryError::StorageError(e.to_string()))?;
766                    }
767                    return result;
768                }
769
770                let plan = crate::planner::plan_statement(parsed.statement)
771                    .map_err(|e| QueryError::Parse(e.to_string()))?;
772                self.plan_cache
773                    .lock()
774                    .map_err(|e| QueryError::Execution(format!("plan cache lock poisoned: {e}")))?
775                    .insert(hash, plan.clone(), literals.len());
776                let plan = lower_unindexed_scans(&self.catalog, &plan);
777                let result = self.execute_plan(&plan);
778                if !self.in_transaction {
779                    self.catalog
780                        .commit_autocommit()
781                        .map_err(|e| QueryError::StorageError(e.to_string()))?;
782                }
783                return result;
784            }
785        }
786
787        let plan = crate::planner::plan_statement(parsed.statement)
788            .map_err(|e| QueryError::Parse(e.to_string()))?;
789        let plan = lower_unindexed_scans(&self.catalog, &plan);
790        let result = self.execute_plan(&plan);
791        if !self.in_transaction {
792            self.catalog
793                .commit_autocommit()
794                .map_err(|e| QueryError::StorageError(e.to_string()))?;
795        }
796        result
797    }
798
799    /// Read-only variant of [`Engine::execute_sql`].
800    pub fn execute_sql_readonly(&self, input: &str) -> Result<QueryResult, QueryError> {
801        let _budget = self.enter_memory_budget();
802        let parsed = crate::sql::parse_sql_with_canonical(input)
803            .map_err(|e| QueryError::Parse(e.to_string()))?;
804        if !is_read_only_statement(&parsed.statement) {
805            return Err(QueryError::ReadonlyNeedsWrite);
806        }
807
808        if let Ok((hash, literals)) = canonicalize(&parsed.canonical_powql) {
809            let cached = self
810                .plan_cache
811                .lock()
812                .map_err(|e| QueryError::Execution(format!("plan cache lock poisoned: {e}")))?
813                .get_with_substitution(hash, &literals);
814            if let Some(plan) = cached {
815                let plan = lower_unindexed_scans(&self.catalog, &plan);
816                return self.execute_plan_readonly(&plan);
817            }
818            let plan = crate::planner::plan_statement(parsed.statement)
819                .map_err(|e| QueryError::Parse(e.to_string()))?;
820            self.plan_cache
821                .lock()
822                .map_err(|e| QueryError::Execution(format!("plan cache lock poisoned: {e}")))?
823                .insert(hash, plan.clone(), literals.len());
824            let plan = lower_unindexed_scans(&self.catalog, &plan);
825            return self.execute_plan_readonly(&plan);
826        }
827
828        let plan = crate::planner::plan_statement(parsed.statement)
829            .map_err(|e| QueryError::Parse(e.to_string()))?;
830        let plan = lower_unindexed_scans(&self.catalog, &plan);
831        self.execute_plan_readonly(&plan)
832    }
833
834    /// Execute PowQL with `$N` placeholders bound to positional `params`.
835    ///
836    /// Task 4: parameters are substituted as literal *tokens* before
837    /// parsing (see [`crate::parser::parse_with_params`]), so untrusted
838    /// input can never change the query's shape. This path deliberately
839    /// **bypasses the plan cache** — template caching is a follow-up — and
840    /// otherwise mirrors the non-cached tail of [`Engine::execute_powql`].
841    pub fn execute_powql_with_params(
842        &mut self,
843        input: &str,
844        params: &[crate::ast::ParamValue],
845    ) -> Result<QueryResult, QueryError> {
846        let _budget = self.enter_memory_budget();
847        let stmt = crate::parser::parse_with_params(input, params)
848            .map_err(|e| QueryError::Parse(e.to_string()))?;
849        let plan =
850            crate::planner::plan_statement(stmt).map_err(|e| QueryError::Parse(e.to_string()))?;
851        let plan = lower_unindexed_scans(&self.catalog, &plan);
852        let result = self.execute_plan(&plan);
853        if !self.in_transaction {
854            self.catalog
855                .commit_autocommit()
856                .map_err(|e| QueryError::StorageError(e.to_string()))?;
857        }
858        result
859    }
860
861    /// Read-only variant of [`Engine::execute_powql_with_params`].
862    ///
863    /// Mirrors [`Engine::execute_powql_readonly`]: parses with bound
864    /// params, rejects any write statement with
865    /// [`QueryError::ReadonlyNeedsWrite`] so the caller can escalate to the
866    /// write lock, then executes under a shared borrow. No plan-cache
867    /// interaction.
868    pub fn execute_powql_readonly_with_params(
869        &self,
870        input: &str,
871        params: &[crate::ast::ParamValue],
872    ) -> Result<QueryResult, QueryError> {
873        let _budget = self.enter_memory_budget();
874        let stmt = crate::parser::parse_with_params(input, params)
875            .map_err(|e| QueryError::Parse(e.to_string()))?;
876        if !is_read_only_statement(&stmt) {
877            return Err(QueryError::ReadonlyNeedsWrite);
878        }
879        let plan =
880            crate::planner::plan_statement(stmt).map_err(|e| QueryError::Parse(e.to_string()))?;
881        let plan = lower_unindexed_scans(&self.catalog, &plan);
882        self.execute_plan_readonly(&plan)
883    }
884
885    /// Plan cache stats — useful for benches and debugging.
886    pub fn plan_cache_stats(&self) -> (u64, u64, usize) {
887        let cache = self.plan_cache.lock().unwrap_or_else(|e| e.into_inner());
888        (cache.hits, cache.misses, cache.len())
889    }
890
891    /// Mission infra-1: read-only entry point.
892    ///
893    /// Parses + plans + executes a PowQL query using only a shared borrow
894    /// on the engine. Rejects any statement that would mutate state
895    /// (Insert/Update/Delete/CreateTable/AlterTable/DropTable/CreateView/
896    /// RefreshView/DropView) by returning [`READONLY_NEEDS_WRITE`] so the
897    /// caller can escalate to the write lock.
898    ///
899    /// Also returns [`READONLY_NEEDS_WRITE`] if a materialized view in the
900    /// query is dirty — refreshing one requires `&mut self`, so the caller
901    /// must retake the write lock for the first refresh.
902    ///
903    /// This method is the concurrent-read fast path behind
904    /// `Arc<RwLock<Engine>>`: multiple threads can call it simultaneously
905    /// under a shared `.read()` lock and each will scan independently.
906    pub fn execute_powql_readonly(&self, input: &str) -> Result<QueryResult, QueryError> {
907        // WS2: each *outermost* statement starts with the full memory
908        // allowance. The guard holds the reentrancy depth so a nested
909        // `execute_powql*` does not reset the outer frame's accounting.
910        let _budget = self.enter_memory_budget();
911        // Parse the statement first so we can classify read vs. write
912        // without touching the catalog. This is the same lex+parse cost
913        // the hot path would pay anyway.
914        let stmt = crate::parser::parse(input).map_err(|e| QueryError::Parse(e.to_string()))?;
915        if !is_read_only_statement(&stmt) {
916            return Err(QueryError::ReadonlyNeedsWrite);
917        }
918
919        // Try the plan cache first — identical hash scheme to
920        // `execute_powql` so both paths share cache state. The mutex
921        // section is just a hashmap lookup + plan clone.
922        if let Ok((hash, literals)) = canonicalize(input) {
923            let cached = self
924                .plan_cache
925                .lock()
926                .map_err(|e| QueryError::Execution(format!("plan cache lock poisoned: {e}")))?
927                .get_with_substitution(hash, &literals);
928            if let Some(plan) = cached {
929                let plan = lower_unindexed_scans(&self.catalog, &plan);
930                return self.execute_plan_readonly(&plan);
931            }
932            // Miss: plan + insert + execute. The planner is pure, so this
933            // is safe from `&self`.
934            let plan = crate::planner::plan_statement(stmt)
935                .map_err(|e| QueryError::Parse(e.to_string()))?;
936            self.plan_cache
937                .lock()
938                .map_err(|e| QueryError::Execution(format!("plan cache lock poisoned: {e}")))?
939                .insert(hash, plan.clone(), literals.len());
940            let plan = lower_unindexed_scans(&self.catalog, &plan);
941            return self.execute_plan_readonly(&plan);
942        }
943        // Lex error — fall through to the planner for a consistent error
944        // shape (though `parse` above would usually have caught it).
945        let plan =
946            crate::planner::plan_statement(stmt).map_err(|e| QueryError::Parse(e.to_string()))?;
947        let plan = lower_unindexed_scans(&self.catalog, &plan);
948        self.execute_plan_readonly(&plan)
949    }
950
951    /// Read-only version of [`Engine::execute_plan`]. Dispatches the
952    /// read-path plan variants by calling `&self` helpers and errors with
953    /// [`READONLY_NEEDS_WRITE`] on any write variant. This is the
954    /// recursion target for composite read plans under the RwLock reader.
955    ///
956    /// The dispatch mirrors `execute_plan` for the read branches but does
957    /// not carry any of the fast-paths that need `&mut self` (e.g. plan-
958    /// cache mutation on inner subqueries is handled via the shared mutex
959    /// in [`Engine::execute_powql_readonly`]; in-flight subquery
960    /// materialisation uses [`Engine::materialize_subqueries_readonly`]).
961    fn execute_plan_readonly(&self, plan: &PlanNode) -> Result<QueryResult, QueryError> {
962        match plan {
963            PlanNode::SeqScan { table } => {
964                // Dirty view means we'd need to refresh it — can't do that
965                // under `&self`. Escalate to the write path.
966                if self.view_registry.is_dirty(table) {
967                    return Err(QueryError::ReadonlyNeedsWrite);
968                }
969                let schema = self
970                    .catalog
971                    .schema(table)
972                    .ok_or_else(|| QueryError::TableNotFound(table.clone()))?
973                    .clone();
974                let columns: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
975                let rows: Vec<Vec<Value>> = self
976                    .catalog
977                    .scan(table)
978                    .map_err(|e| e.to_string())?
979                    .map(|(_, row)| row)
980                    .collect();
981                Ok(QueryResult::Rows { columns, rows })
982            }
983
984            PlanNode::AliasScan { table, alias } => {
985                let schema = self
986                    .catalog
987                    .schema(table)
988                    .ok_or_else(|| QueryError::TableNotFound(table.clone()))?
989                    .clone();
990                let columns: Vec<String> = schema
991                    .columns
992                    .iter()
993                    .map(|c| format!("{alias}.{}", c.name))
994                    .collect();
995                let rows: Vec<Vec<Value>> = self
996                    .catalog
997                    .scan(table)
998                    .map_err(|e| e.to_string())?
999                    .map(|(_, row)| row)
1000                    .collect();
1001                Ok(QueryResult::Rows { columns, rows })
1002            }
1003
1004            PlanNode::IndexScan { table, column, key } => {
1005                let schema = self
1006                    .catalog
1007                    .schema(table)
1008                    .ok_or_else(|| QueryError::TableNotFound(table.clone()))?
1009                    .clone();
1010                let columns: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
1011                let key_value = literal_to_value(key)?;
1012                let tbl = self
1013                    .catalog
1014                    .get_table(table)
1015                    .ok_or_else(|| QueryError::TableNotFound(table.clone()))?;
1016
1017                if tbl.has_index(column) {
1018                    // Use index_lookup_all to handle both unique and
1019                    // non-unique indexes — returns all matching RowIds.
1020                    let rids = tbl.index_lookup_all(column, &key_value);
1021                    let mut rows: Vec<Vec<Value>> = Vec::with_capacity(rids.len());
1022                    for rid in rids {
1023                        if let Some(data) = tbl.heap.get(rid) {
1024                            rows.push(decode_row(&tbl.schema, &data));
1025                        }
1026                    }
1027                    return Ok(QueryResult::Rows { columns, rows });
1028                }
1029
1030                // No index: synthetic eq predicate + compiled scan.
1031                let fast = FastLayout::new(&schema);
1032                let synth_pred = Expr::BinaryOp(
1033                    Box::new(Expr::Field(column.clone())),
1034                    BinOp::Eq,
1035                    Box::new(key.clone()),
1036                );
1037                if let Some(compiled) = compile_predicate(&synth_pred, &columns, &fast, &schema) {
1038                    let mut rows: Vec<Vec<Value>> = Vec::with_capacity(64);
1039                    self.catalog
1040                        .for_each_row_raw(table, |_rid, data| {
1041                            if compiled(data) {
1042                                rows.push(decode_row(&schema, data));
1043                            }
1044                        })
1045                        .map_err(|e| QueryError::StorageError(e.to_string()))?;
1046                    return Ok(QueryResult::Rows { columns, rows });
1047                }
1048
1049                // Last resort: slow eq-check.
1050                let col_idx =
1051                    schema
1052                        .column_index(column)
1053                        .ok_or_else(|| QueryError::ColumnNotFound {
1054                            table: String::new(),
1055                            column: column.clone(),
1056                        })?;
1057                let rows: Vec<Vec<Value>> = tbl
1058                    .scan()
1059                    .filter_map(|(_, row)| {
1060                        if row[col_idx] == key_value {
1061                            Some(row)
1062                        } else {
1063                            None
1064                        }
1065                    })
1066                    .collect();
1067                Ok(QueryResult::Rows { columns, rows })
1068            }
1069
1070            PlanNode::RangeScan {
1071                table,
1072                column,
1073                start,
1074                end,
1075            } => {
1076                let tbl = self
1077                    .catalog
1078                    .get_table(table)
1079                    .ok_or_else(|| QueryError::TableNotFound(table.clone()))?;
1080                let columns: Vec<String> =
1081                    tbl.schema.columns.iter().map(|c| c.name.clone()).collect();
1082                let schema = tbl.schema.clone();
1083
1084                let start_val = match start {
1085                    Some((expr, _)) => Some(literal_to_value(expr)?),
1086                    None => None,
1087                };
1088                let end_val = match end {
1089                    Some((expr, _)) => Some(literal_to_value(expr)?),
1090                    None => None,
1091                };
1092                let start_inclusive = start.as_ref().map(|(_, inc)| *inc).unwrap_or(true);
1093                let end_inclusive = end.as_ref().map(|(_, inc)| *inc).unwrap_or(true);
1094
1095                // Range scans only use the btree fast path for unique indexes.
1096                // Non-unique indexes store composite keys that don't compare
1097                // directly against raw column values.
1098                if tbl.is_index_unique(column) == Some(true) {
1099                    if let Some(btree) = tbl.index(column) {
1100                        let hits: Vec<(Value, RowId)> = match (&start_val, &end_val) {
1101                            (Some(s), Some(e)) => btree.range(s, e).collect(),
1102                            (Some(s), None) => btree.range_from(s),
1103                            (None, Some(e)) => btree.range_to(e),
1104                            (None, None) => {
1105                                // Unbounded both sides — equivalent to seq scan.
1106                                let rows: Vec<Vec<Value>> =
1107                                    tbl.scan().map(|(_, row)| row).collect();
1108                                return Ok(QueryResult::Rows { columns, rows });
1109                            }
1110                        };
1111                        let mut rows: Vec<Vec<Value>> = Vec::with_capacity(hits.len());
1112                        for (key, rid) in hits {
1113                            // Filter for exclusive bounds.
1114                            if !start_inclusive {
1115                                if let Some(ref s) = start_val {
1116                                    if &key == s {
1117                                        continue;
1118                                    }
1119                                }
1120                            }
1121                            if !end_inclusive {
1122                                if let Some(ref e) = end_val {
1123                                    if &key == e {
1124                                        continue;
1125                                    }
1126                                }
1127                            }
1128                            if let Some(data) = tbl.heap.get(rid) {
1129                                rows.push(decode_row(&schema, &data));
1130                            }
1131                        }
1132                        return Ok(QueryResult::Rows { columns, rows });
1133                    }
1134                }
1135
1136                // Fallback: no index — synthesize the range predicate and scan.
1137                let fast = FastLayout::new(&schema);
1138                let synth = synthesize_range_predicate(column, start, end);
1139                if let Some(compiled) = compile_predicate(&synth, &columns, &fast, &schema) {
1140                    let mut rows: Vec<Vec<Value>> = Vec::with_capacity(64);
1141                    self.catalog
1142                        .for_each_row_raw(table, |_rid, data| {
1143                            if compiled(data) {
1144                                rows.push(decode_row(&schema, data));
1145                            }
1146                        })
1147                        .map_err(|e| QueryError::StorageError(e.to_string()))?;
1148                    return Ok(QueryResult::Rows { columns, rows });
1149                }
1150
1151                // Last resort: decoded row eval.
1152                let col_idx =
1153                    schema
1154                        .column_index(column)
1155                        .ok_or_else(|| QueryError::ColumnNotFound {
1156                            table: String::new(),
1157                            column: column.clone(),
1158                        })?;
1159                let rows: Vec<Vec<Value>> = tbl
1160                    .scan()
1161                    .filter(|(_, row)| {
1162                        range_matches(
1163                            &row[col_idx],
1164                            &start_val,
1165                            start_inclusive,
1166                            &end_val,
1167                            end_inclusive,
1168                        )
1169                    })
1170                    .map(|(_, row)| row)
1171                    .collect();
1172                Ok(QueryResult::Rows { columns, rows })
1173            }
1174
1175            PlanNode::Filter { input, predicate } => {
1176                // Materialise subqueries using the `&self` variant.
1177                // Uncorrelated subqueries are replaced with InList/Bool;
1178                // correlated ones are left as InSubquery/ExistsSubquery
1179                // for per-row materialisation below.
1180                let materialized;
1181                let predicate = if contains_subquery(predicate) {
1182                    materialized = self.materialize_subqueries_readonly(predicate)?;
1183                    &materialized
1184                } else {
1185                    predicate
1186                };
1187
1188                // Correlated subquery path: per-row materialisation.
1189                if contains_subquery(predicate) {
1190                    let result = self.execute_plan_readonly(input)?;
1191                    return match result {
1192                        QueryResult::Rows { columns, rows } => {
1193                            let mut filtered = Vec::new();
1194                            for row in rows {
1195                                let row_pred = self.materialize_correlated_for_row_readonly(
1196                                    predicate, &row, &columns,
1197                                )?;
1198                                if eval_predicate(&row_pred, &row, &columns) {
1199                                    filtered.push(row);
1200                                }
1201                            }
1202                            Ok(QueryResult::Rows {
1203                                columns,
1204                                rows: filtered,
1205                            })
1206                        }
1207                        _ => Err("filter requires row input".into()),
1208                    };
1209                }
1210
1211                // Fused Filter+SeqScan fast path.
1212                if let PlanNode::SeqScan { table } = input.as_ref() {
1213                    if self.view_registry.is_dirty(table) {
1214                        return Err(QueryError::ReadonlyNeedsWrite);
1215                    }
1216                    let schema = self
1217                        .catalog
1218                        .schema(table)
1219                        .ok_or_else(|| QueryError::TableNotFound(table.clone()))?
1220                        .clone();
1221                    let columns: Vec<String> =
1222                        schema.columns.iter().map(|c| c.name.clone()).collect();
1223                    let fast = FastLayout::new(&schema);
1224                    let row_layout = RowLayout::new(&schema);
1225                    let mut rows: Vec<Vec<Value>> = Vec::with_capacity(64);
1226
1227                    if let Some(compiled) = compile_predicate(predicate, &columns, &fast, &schema) {
1228                        self.catalog
1229                            .for_each_row_raw(table, |_rid, data| {
1230                                if compiled(data) {
1231                                    rows.push(decode_row(&schema, data));
1232                                }
1233                            })
1234                            .map_err(|e| QueryError::StorageError(e.to_string()))?;
1235                    } else {
1236                        let pred_cols = predicate_column_indices(predicate, &columns);
1237                        self.catalog
1238                            .for_each_row_raw(table, |_rid, data| {
1239                                let pred_row =
1240                                    decode_selective(&schema, &row_layout, data, &pred_cols);
1241                                if eval_predicate(predicate, &pred_row, &columns) {
1242                                    rows.push(decode_row(&schema, data));
1243                                }
1244                            })
1245                            .map_err(|e| QueryError::StorageError(e.to_string()))?;
1246                    }
1247
1248                    return Ok(QueryResult::Rows { columns, rows });
1249                }
1250
1251                // General path.
1252                let result = self.execute_plan_readonly(input)?;
1253                match result {
1254                    QueryResult::Rows { columns, rows } => {
1255                        let filtered: Vec<Vec<Value>> = rows
1256                            .into_iter()
1257                            .filter(|row| eval_predicate(predicate, row, &columns))
1258                            .collect();
1259                        Ok(QueryResult::Rows {
1260                            columns,
1261                            rows: filtered,
1262                        })
1263                    }
1264                    _ => Err("filter requires row input".into()),
1265                }
1266            }
1267
1268            PlanNode::Project { input, fields } => {
1269                // Fast path: Project over IndexScan. Avoids full-row decode
1270                // by calling decode_column only for projected fields.
1271                if let PlanNode::IndexScan { table, column, key } = input.as_ref() {
1272                    let key_value = literal_to_value(key)?;
1273                    let tbl = self
1274                        .catalog
1275                        .get_table(table)
1276                        .ok_or_else(|| QueryError::TableNotFound(table.clone()))?;
1277                    let schema = &tbl.schema;
1278                    let layout = tbl.row_layout();
1279
1280                    let proj_columns: Vec<String> = fields
1281                        .iter()
1282                        .map(|f| {
1283                            f.alias.clone().unwrap_or_else(|| match &f.expr {
1284                                Expr::Field(name) => name.clone(),
1285                                _ => "?".into(),
1286                            })
1287                        })
1288                        .collect();
1289
1290                    let proj_indices: Vec<usize> = fields
1291                        .iter()
1292                        .filter_map(|f| {
1293                            if let Expr::Field(name) = &f.expr {
1294                                schema.column_index(name)
1295                            } else {
1296                                None
1297                            }
1298                        })
1299                        .collect();
1300
1301                    if tbl.has_index(column) {
1302                        let rids = tbl.index_lookup_all(column, &key_value);
1303                        let mut rows: Vec<Vec<Value>> = Vec::with_capacity(rids.len());
1304                        for rid in rids {
1305                            if let Some(data) = tbl.heap.get(rid) {
1306                                let row: Vec<Value> = proj_indices
1307                                    .iter()
1308                                    .map(|&ci| decode_column(schema, layout, &data, ci))
1309                                    .collect();
1310                                rows.push(row);
1311                            }
1312                        }
1313                        return Ok(QueryResult::Rows {
1314                            columns: proj_columns,
1315                            rows,
1316                        });
1317                    }
1318                }
1319
1320                // Fast paths over Limit(Sort(...)) / Limit(Filter(...)) / Limit(SeqScan).
1321                if let PlanNode::Limit {
1322                    input: inner,
1323                    count: limit_expr,
1324                } = input.as_ref()
1325                {
1326                    if let PlanNode::Sort {
1327                        input: sort_input,
1328                        keys,
1329                    } = inner.as_ref()
1330                    {
1331                        if keys.len() == 1 {
1332                            let sort_field = &keys[0].field;
1333                            let descending = keys[0].descending;
1334                            let limit = match limit_expr {
1335                                Expr::Literal(Literal::Int(v)) if *v >= 0 => *v as usize,
1336                                _ => usize::MAX,
1337                            };
1338                            let (table_opt, pred_opt): (Option<&str>, Option<&Expr>) =
1339                                match sort_input.as_ref() {
1340                                    PlanNode::SeqScan { table } => (Some(table.as_str()), None),
1341                                    PlanNode::Filter {
1342                                        input: fi,
1343                                        predicate,
1344                                    } => {
1345                                        if let PlanNode::SeqScan { table } = fi.as_ref() {
1346                                            (Some(table.as_str()), Some(predicate))
1347                                        } else {
1348                                            (None, None)
1349                                        }
1350                                    }
1351                                    _ => (None, None),
1352                                };
1353                            if let Some(table) = table_opt {
1354                                if let Some(result) = self.project_filter_sort_limit_fast(
1355                                    table, fields, sort_field, descending, limit, pred_opt,
1356                                )? {
1357                                    return Ok(result);
1358                                }
1359                            }
1360                        }
1361                    }
1362                    if let PlanNode::Filter {
1363                        input: fi,
1364                        predicate,
1365                    } = inner.as_ref()
1366                    {
1367                        if let PlanNode::SeqScan { table } = fi.as_ref() {
1368                            let limit = match limit_expr {
1369                                Expr::Literal(Literal::Int(v)) if *v >= 0 => *v as usize,
1370                                _ => usize::MAX,
1371                            };
1372                            if let Some(result) = self.project_filter_limit_fast(
1373                                table,
1374                                fields,
1375                                limit,
1376                                Some(predicate),
1377                            )? {
1378                                return Ok(result);
1379                            }
1380                        }
1381                    }
1382                    if let PlanNode::SeqScan { table } = inner.as_ref() {
1383                        let limit = match limit_expr {
1384                            Expr::Literal(Literal::Int(v)) if *v >= 0 => *v as usize,
1385                            _ => usize::MAX,
1386                        };
1387                        if let Some(result) =
1388                            self.project_filter_limit_fast(table, fields, limit, None)?
1389                        {
1390                            return Ok(result);
1391                        }
1392                    }
1393                }
1394
1395                // Project(Filter(SeqScan)) without Limit.
1396                if let PlanNode::Filter {
1397                    input: fi,
1398                    predicate,
1399                } = input.as_ref()
1400                {
1401                    if let PlanNode::SeqScan { table } = fi.as_ref() {
1402                        if let Some(result) = self.project_filter_limit_fast(
1403                            table,
1404                            fields,
1405                            usize::MAX,
1406                            Some(predicate),
1407                        )? {
1408                            return Ok(result);
1409                        }
1410                    }
1411                }
1412
1413                // Project(SeqScan) without Filter or Limit.
1414                if let PlanNode::SeqScan { table } = input.as_ref() {
1415                    if let Some(result) =
1416                        self.project_filter_limit_fast(table, fields, usize::MAX, None)?
1417                    {
1418                        return Ok(result);
1419                    }
1420                }
1421
1422                // Generic path.
1423                let result = self.execute_plan_readonly(input)?;
1424                match result {
1425                    QueryResult::Rows { columns, rows } => {
1426                        let proj_columns: Vec<String> = fields
1427                            .iter()
1428                            .map(|f| {
1429                                f.alias.clone().unwrap_or_else(|| match &f.expr {
1430                                    Expr::Field(name) => name.clone(),
1431                                    Expr::QualifiedField { qualifier, field } => {
1432                                        format!("{qualifier}.{field}")
1433                                    }
1434                                    _ => "?".into(),
1435                                })
1436                            })
1437                            .collect();
1438                        let proj_rows: Vec<Vec<Value>> = rows
1439                            .iter()
1440                            .map(|row| {
1441                                fields
1442                                    .iter()
1443                                    .map(|f| eval_expr(&f.expr, row, &columns))
1444                                    .collect()
1445                            })
1446                            .collect();
1447                        Ok(QueryResult::Rows {
1448                            columns: proj_columns,
1449                            rows: proj_rows,
1450                        })
1451                    }
1452                    _ => Err("project requires row input".into()),
1453                }
1454            }
1455
1456            PlanNode::Sort { input, keys } => {
1457                let result = self.execute_plan_readonly(input)?;
1458                match result {
1459                    QueryResult::Rows { columns, mut rows } => {
1460                        if rows.len() > MAX_SORT_ROWS {
1461                            return Err(QueryError::SortLimitExceeded);
1462                        }
1463                        // WS2: byte-budget guard on the sort buffer.
1464                        self.charge_rows(&rows)?;
1465                        let key_indices: Vec<(usize, bool)> = keys
1466                            .iter()
1467                            .map(|k| {
1468                                columns
1469                                    .iter()
1470                                    .position(|c| c == &k.field)
1471                                    .map(|idx| (idx, k.descending))
1472                                    .ok_or_else(|| QueryError::ColumnNotFound {
1473                                        table: String::new(),
1474                                        column: k.field.clone(),
1475                                    })
1476                            })
1477                            .collect::<Result<_, QueryError>>()?;
1478                        rows.sort_by(|a, b| {
1479                            for &(col_idx, descending) in &key_indices {
1480                                let cmp = a[col_idx].cmp(&b[col_idx]);
1481                                let cmp = if descending { cmp.reverse() } else { cmp };
1482                                if cmp != std::cmp::Ordering::Equal {
1483                                    return cmp;
1484                                }
1485                            }
1486                            std::cmp::Ordering::Equal
1487                        });
1488                        Ok(QueryResult::Rows { columns, rows })
1489                    }
1490                    _ => Err("sort requires row input".into()),
1491                }
1492            }
1493
1494            PlanNode::Limit { input, count } => {
1495                let result = self.execute_plan_readonly(input)?;
1496                let n = match count {
1497                    Expr::Literal(Literal::Int(v)) => *v as usize,
1498                    _ => return Err("limit must be integer literal".into()),
1499                };
1500                match result {
1501                    QueryResult::Rows { columns, rows } => Ok(QueryResult::Rows {
1502                        columns,
1503                        rows: rows.into_iter().take(n).collect(),
1504                    }),
1505                    _ => Err("limit requires row input".into()),
1506                }
1507            }
1508
1509            PlanNode::Offset { input, count } => {
1510                let result = self.execute_plan_readonly(input)?;
1511                let n = match count {
1512                    Expr::Literal(Literal::Int(v)) => *v as usize,
1513                    _ => return Err("offset must be integer literal".into()),
1514                };
1515                match result {
1516                    QueryResult::Rows { columns, rows } => Ok(QueryResult::Rows {
1517                        columns,
1518                        rows: rows.into_iter().skip(n).collect(),
1519                    }),
1520                    _ => Err("offset requires row input".into()),
1521                }
1522            }
1523
1524            PlanNode::Aggregate {
1525                input,
1526                function,
1527                field,
1528            } => {
1529                // Fast path: count() over SeqScan.
1530                if *function == AggFunc::Count {
1531                    if let PlanNode::SeqScan { table } = input.as_ref() {
1532                        // A dirty materialized view must be refreshed before
1533                        // it can be counted, which needs `&mut self`. Escalate
1534                        // to the write path (F3: count(View) returned stale).
1535                        if self.view_registry.is_dirty(table) {
1536                            return Err(QueryError::ReadonlyNeedsWrite);
1537                        }
1538                        let mut count: i64 = 0;
1539                        self.catalog
1540                            .for_each_row_raw(table, |_rid, _data| {
1541                                count += 1;
1542                            })
1543                            .map_err(|e| QueryError::StorageError(e.to_string()))?;
1544                        return Ok(QueryResult::Scalar(Value::Int(count)));
1545                    }
1546                    if let PlanNode::Filter {
1547                        input: inner,
1548                        predicate,
1549                    } = input.as_ref()
1550                    {
1551                        // Only take the fast path for a plain Filter(SeqScan)
1552                        // with no subquery in the predicate. A subquery
1553                        // predicate (`count(T filter .x in (...))`) must be
1554                        // resolved first; the fast path evaluates the raw
1555                        // predicate with no subquery materialisation, which
1556                        // silently yields 0 (F1). Falling through routes it to
1557                        // the generic path that runs the subquery correctly.
1558                        if let PlanNode::SeqScan { table } = inner.as_ref() {
1559                            if self.view_registry.is_dirty(table) {
1560                                // F3: count(View filter ...) over a dirty view.
1561                                return Err(QueryError::ReadonlyNeedsWrite);
1562                            }
1563                        }
1564                        if let (PlanNode::SeqScan { table }, false) =
1565                            (inner.as_ref(), contains_subquery(predicate))
1566                        {
1567                            let schema = self
1568                                .catalog
1569                                .schema(table)
1570                                .ok_or_else(|| QueryError::TableNotFound(table.clone()))?
1571                                .clone();
1572                            let columns: Vec<String> =
1573                                schema.columns.iter().map(|c| c.name.clone()).collect();
1574                            let fast = FastLayout::new(&schema);
1575                            let row_layout = RowLayout::new(&schema);
1576
1577                            if let Some(compiled) =
1578                                compile_predicate(predicate, &columns, &fast, &schema)
1579                            {
1580                                let mut count: i64 = 0;
1581                                self.catalog
1582                                    .for_each_row_raw(table, |_rid, data| {
1583                                        if compiled(data) {
1584                                            count += 1;
1585                                        }
1586                                    })
1587                                    .map_err(|e| QueryError::StorageError(e.to_string()))?;
1588                                return Ok(QueryResult::Scalar(Value::Int(count)));
1589                            }
1590
1591                            let pred_cols = predicate_column_indices(predicate, &columns);
1592                            let mut count: i64 = 0;
1593                            self.catalog
1594                                .for_each_row_raw(table, |_rid, data| {
1595                                    let pred_row =
1596                                        decode_selective(&schema, &row_layout, data, &pred_cols);
1597                                    if eval_predicate(predicate, &pred_row, &columns) {
1598                                        count += 1;
1599                                    }
1600                                })
1601                                .map_err(|e| QueryError::StorageError(e.to_string()))?;
1602                            return Ok(QueryResult::Scalar(Value::Int(count)));
1603                        }
1604                    }
1605                }
1606
1607                // Fast path: sum/avg/min/max over single fixed-size numeric.
1608                if matches!(
1609                    function,
1610                    AggFunc::Sum
1611                        | AggFunc::Avg
1612                        | AggFunc::Min
1613                        | AggFunc::Max
1614                        | AggFunc::CountDistinct
1615                ) {
1616                    if let Some(col) = field.as_ref() {
1617                        let (table_opt, pred_opt): (Option<&str>, Option<&Expr>) =
1618                            match input.as_ref() {
1619                                PlanNode::SeqScan { table } => (Some(table.as_str()), None),
1620                                PlanNode::Filter {
1621                                    input: inner,
1622                                    predicate,
1623                                } => {
1624                                    if let PlanNode::SeqScan { table } = inner.as_ref() {
1625                                        (Some(table.as_str()), Some(predicate))
1626                                    } else {
1627                                        (None, None)
1628                                    }
1629                                }
1630                                _ => (None, None),
1631                            };
1632                        if let Some(table) = table_opt {
1633                            if let Some(result) =
1634                                self.agg_single_col_fast(table, col, *function, pred_opt)?
1635                            {
1636                                return Ok(result);
1637                            }
1638                        }
1639                    }
1640                }
1641
1642                // Generic path.
1643                let result = self.execute_plan_readonly(input)?;
1644                match result {
1645                    QueryResult::Rows { columns, rows } => match function {
1646                        AggFunc::Count => Ok(QueryResult::Scalar(Value::Int(rows.len() as i64))),
1647                        AggFunc::CountDistinct => {
1648                            let col = field.as_ref().ok_or("count distinct requires field")?;
1649                            let idx = columns
1650                                .iter()
1651                                .position(|c| c == col)
1652                                .ok_or("col not found")?;
1653                            let mut seen = std::collections::HashSet::new();
1654                            for row in &rows {
1655                                let v = &row[idx];
1656                                if !v.is_empty() {
1657                                    seen.insert(v.clone());
1658                                }
1659                            }
1660                            Ok(QueryResult::Scalar(Value::Int(seen.len() as i64)))
1661                        }
1662                        AggFunc::Avg => {
1663                            let col = field.as_ref().ok_or("avg requires field")?;
1664                            let idx = columns
1665                                .iter()
1666                                .position(|c| c == col)
1667                                .ok_or("col not found")?;
1668                            let mut count: u64 = 0;
1669                            let sum: f64 = rows
1670                                .iter()
1671                                .filter_map(|r| match &r[idx] {
1672                                    Value::Int(v) => Some(*v as f64),
1673                                    Value::Float(v) => Some(*v),
1674                                    _ => None,
1675                                })
1676                                .inspect(|_| count += 1)
1677                                .sum();
1678                            if count == 0 {
1679                                Ok(QueryResult::Scalar(Value::Empty))
1680                            } else {
1681                                Ok(QueryResult::Scalar(Value::Float(sum / count as f64)))
1682                            }
1683                        }
1684                        AggFunc::Sum => {
1685                            let col = field.as_ref().ok_or("sum requires field")?;
1686                            let idx = columns
1687                                .iter()
1688                                .position(|c| c == col)
1689                                .ok_or("col not found")?;
1690                            let mut int_sum: i64 = 0;
1691                            let mut float_sum: f64 = 0.0;
1692                            let mut saw_float = false;
1693                            for r in &rows {
1694                                match &r[idx] {
1695                                    Value::Int(v) => int_sum += *v,
1696                                    Value::Float(v) => {
1697                                        float_sum += *v;
1698                                        saw_float = true;
1699                                    }
1700                                    _ => {}
1701                                }
1702                            }
1703                            let result = if saw_float {
1704                                Value::Float(float_sum + int_sum as f64)
1705                            } else {
1706                                Value::Int(int_sum)
1707                            };
1708                            Ok(QueryResult::Scalar(result))
1709                        }
1710                        AggFunc::Min | AggFunc::Max => {
1711                            let col = field.as_ref().ok_or("min/max requires field")?;
1712                            let idx = columns
1713                                .iter()
1714                                .position(|c| c == col)
1715                                .ok_or("col not found")?;
1716                            let vals: Vec<&Value> = rows.iter().map(|r| &r[idx]).collect();
1717                            let result = if *function == AggFunc::Min {
1718                                vals.into_iter().min().cloned()
1719                            } else {
1720                                vals.into_iter().max().cloned()
1721                            };
1722                            Ok(QueryResult::Scalar(result.unwrap_or(Value::Empty)))
1723                        }
1724                    },
1725                    _ => Err("aggregate requires row input".into()),
1726                }
1727            }
1728
1729            PlanNode::Distinct { input } => {
1730                let result = self.execute_plan_readonly(input)?;
1731                match result {
1732                    QueryResult::Rows { columns, rows } => {
1733                        let mut seen = std::collections::HashSet::new();
1734                        let mut unique_rows = Vec::new();
1735                        for row in rows {
1736                            if seen.insert(row.clone()) {
1737                                unique_rows.push(row);
1738                            }
1739                        }
1740                        Ok(QueryResult::Rows {
1741                            columns,
1742                            rows: unique_rows,
1743                        })
1744                    }
1745                    other => Ok(other),
1746                }
1747            }
1748
1749            PlanNode::GroupBy {
1750                input,
1751                keys,
1752                aggregates,
1753                having,
1754            } => {
1755                let result = self.execute_plan_readonly(input)?;
1756                match result {
1757                    QueryResult::Rows { columns, rows } => {
1758                        // WS2: byte-budget guard on the GROUP BY input buffer
1759                        // (the hash table is bounded by the input it groups).
1760                        self.charge_rows(&rows)?;
1761                        let key_indices: Vec<usize> = keys
1762                            .iter()
1763                            .map(|k| {
1764                                columns.iter().position(|c| c == k).ok_or_else(|| {
1765                                    QueryError::ColumnNotFound {
1766                                        table: String::new(),
1767                                        column: k.clone(),
1768                                    }
1769                                })
1770                            })
1771                            .collect::<Result<Vec<_>, _>>()?;
1772
1773                        let agg_field_indices: Vec<usize> = aggregates
1774                            .iter()
1775                            .map(|a| {
1776                                if a.field == "*" {
1777                                    Ok(usize::MAX)
1778                                } else {
1779                                    columns.iter().position(|c| c == &a.field).ok_or_else(|| {
1780                                        QueryError::ColumnNotFound {
1781                                            table: String::new(),
1782                                            column: a.field.clone(),
1783                                        }
1784                                    })
1785                                }
1786                            })
1787                            .collect::<Result<Vec<_>, _>>()?;
1788
1789                        let mut group_map: rustc_hash::FxHashMap<Vec<Value>, usize> =
1790                            rustc_hash::FxHashMap::default();
1791                        let mut groups: Vec<(Vec<Value>, Vec<usize>)> = Vec::new();
1792                        for (ri, row) in rows.iter().enumerate() {
1793                            let key: Vec<Value> =
1794                                key_indices.iter().map(|&i| row[i].clone()).collect();
1795                            match group_map.get(&key) {
1796                                Some(&idx) => groups[idx].1.push(ri),
1797                                None => {
1798                                    let idx = groups.len();
1799                                    group_map.insert(key.clone(), idx);
1800                                    groups.push((key, vec![ri]));
1801                                }
1802                            }
1803                        }
1804
1805                        let mut out_columns: Vec<String> = keys.clone();
1806                        for agg in aggregates.iter() {
1807                            out_columns.push(agg.output_name.clone());
1808                        }
1809
1810                        let mut out_rows: Vec<Vec<Value>> = Vec::with_capacity(groups.len());
1811                        for (key_vals, row_indices) in &groups {
1812                            let mut row = key_vals.clone();
1813                            for (ai, agg) in aggregates.iter().enumerate() {
1814                                let col_idx = agg_field_indices[ai];
1815                                let val = compute_group_aggregate(
1816                                    agg.function,
1817                                    &rows,
1818                                    row_indices,
1819                                    col_idx,
1820                                );
1821                                row.push(val);
1822                            }
1823                            out_rows.push(row);
1824                        }
1825
1826                        if let Some(having_expr) = having {
1827                            out_rows.retain(|row| eval_predicate(having_expr, row, &out_columns));
1828                        }
1829
1830                        Ok(QueryResult::Rows {
1831                            columns: out_columns,
1832                            rows: out_rows,
1833                        })
1834                    }
1835                    _ => Err("group by requires row input".into()),
1836                }
1837            }
1838
1839            PlanNode::NestedLoopJoin {
1840                left,
1841                right,
1842                on,
1843                kind,
1844            } => {
1845                let left_result = self.execute_plan_readonly(left)?;
1846                let right_result = self.execute_plan_readonly(right)?;
1847                let (left_columns, left_rows) = match left_result {
1848                    QueryResult::Rows { columns, rows } => (columns, rows),
1849                    _ => return Err("join left side must produce rows".into()),
1850                };
1851                let (right_columns, right_rows) = match right_result {
1852                    QueryResult::Rows { columns, rows } => (columns, rows),
1853                    _ => return Err("join right side must produce rows".into()),
1854                };
1855
1856                // WS2: byte-budget guard on the join build side.
1857                self.charge_rows(&left_rows)?;
1858                self.charge_rows(&right_rows)?;
1859
1860                if !matches!(kind, JoinKind::Cross) {
1861                    if let Some(pred) = on {
1862                        if let Some((l_idx, r_idx)) =
1863                            try_extract_equi_join_keys(pred, &left_columns, &right_columns)
1864                        {
1865                            let result = hash_join(
1866                                left_columns,
1867                                left_rows,
1868                                right_columns,
1869                                right_rows,
1870                                l_idx,
1871                                r_idx,
1872                                *kind,
1873                            );
1874                            if let QueryResult::Rows { ref rows, .. } = result {
1875                                check_join_limit(rows.len())?;
1876                            }
1877                            return Ok(result);
1878                        }
1879                    }
1880                }
1881
1882                let n_left = left_columns.len();
1883                let n_right = right_columns.len();
1884                let mut columns = Vec::with_capacity(n_left + n_right);
1885                columns.extend(left_columns);
1886                columns.extend(right_columns);
1887
1888                let mut rows: Vec<Vec<Value>> = Vec::with_capacity(left_rows.len());
1889                let mut combined: Vec<Value> = Vec::with_capacity(n_left + n_right);
1890
1891                for left_row in &left_rows {
1892                    let mut matched = false;
1893                    for right_row in &right_rows {
1894                        combined.clear();
1895                        combined.extend_from_slice(left_row);
1896                        combined.extend_from_slice(right_row);
1897                        let keep = match kind {
1898                            JoinKind::Cross => true,
1899                            JoinKind::Inner | JoinKind::LeftOuter => match on {
1900                                Some(pred) => eval_predicate(pred, &combined, &columns),
1901                                None => true,
1902                            },
1903                            JoinKind::RightOuter => {
1904                                unreachable!("planner rewrites RightOuter to LeftOuter")
1905                            }
1906                        };
1907                        if keep {
1908                            rows.push(combined.clone());
1909                            check_join_limit(rows.len())?;
1910                            matched = true;
1911                        }
1912                    }
1913                    if !matched && matches!(kind, JoinKind::LeftOuter) {
1914                        let mut row = Vec::with_capacity(n_left + n_right);
1915                        row.extend_from_slice(left_row);
1916                        row.resize(n_left + n_right, Value::Empty);
1917                        rows.push(row);
1918                        check_join_limit(rows.len())?;
1919                    }
1920                }
1921
1922                Ok(QueryResult::Rows { columns, rows })
1923            }
1924
1925            PlanNode::Window { input, windows } => {
1926                let result = self.execute_plan_readonly(input)?;
1927                execute_window(result, windows)
1928            }
1929
1930            PlanNode::Union { left, right, all } => {
1931                let left_result = self.execute_plan_readonly(left)?;
1932                let right_result = self.execute_plan_readonly(right)?;
1933                let (left_cols, left_rows) = match left_result {
1934                    QueryResult::Rows { columns, rows } => (columns, rows),
1935                    _ => return Err("UNION requires query results on left side".into()),
1936                };
1937                let (_, right_rows) = match right_result {
1938                    QueryResult::Rows { columns, rows } => (columns, rows),
1939                    _ => return Err("UNION requires query results on right side".into()),
1940                };
1941                let mut combined = left_rows;
1942                if *all {
1943                    combined.extend(right_rows);
1944                } else {
1945                    let mut seen = std::collections::HashSet::new();
1946                    for row in &combined {
1947                        seen.insert(row.clone());
1948                    }
1949                    for row in right_rows {
1950                        if seen.insert(row.clone()) {
1951                            combined.push(row);
1952                        }
1953                    }
1954                }
1955                Ok(QueryResult::Rows {
1956                    columns: left_cols,
1957                    rows: combined,
1958                })
1959            }
1960
1961            PlanNode::Explain { input } => {
1962                let text = format_plan_tree(input, 0);
1963                Ok(QueryResult::Rows {
1964                    columns: vec!["plan".to_string()],
1965                    rows: text
1966                        .lines()
1967                        .map(|line| vec![Value::Str(line.to_string())])
1968                        .collect(),
1969                })
1970            }
1971
1972            PlanNode::ListTypes => self.introspect_list_types(),
1973
1974            PlanNode::Describe { table } => self.introspect_describe(table),
1975
1976            // All write variants — caller must escalate to the write lock.
1977            PlanNode::Insert { .. }
1978            | PlanNode::Update { .. }
1979            | PlanNode::Delete { .. }
1980            | PlanNode::Upsert { .. }
1981            | PlanNode::CreateTable { .. }
1982            | PlanNode::AlterTable { .. }
1983            | PlanNode::DropTable { .. }
1984            | PlanNode::CreateView { .. }
1985            | PlanNode::RefreshView { .. }
1986            | PlanNode::DropView { .. }
1987            | PlanNode::Begin
1988            | PlanNode::Commit
1989            | PlanNode::Rollback => Err(QueryError::ReadonlyNeedsWrite),
1990        }
1991    }
1992
1993    /// `&self` variant of [`Engine::materialize_subqueries`]. Used by the
1994    /// read path so `Filter` predicates with `InSubquery`/`ExistsSubquery`
1995    /// children can evaluate their inner queries without taking the write
1996    /// lock. Inner queries that would themselves need a write (e.g. dirty
1997    /// view) escalate via [`READONLY_NEEDS_WRITE`] just like the top-level
1998    /// read path does.
1999    fn materialize_subqueries_readonly(&self, expr: &Expr) -> Result<Expr, QueryError> {
2000        match expr {
2001            Expr::InSubquery {
2002                expr: inner,
2003                subquery,
2004                negated,
2005            } => {
2006                if is_correlated_subquery(subquery, &self.catalog) {
2007                    // Pass through — will be materialized per-row in the
2008                    // Filter handler's correlated subquery path.
2009                    let inner = self.materialize_subqueries_readonly(inner)?;
2010                    return Ok(Expr::InSubquery {
2011                        expr: Box::new(inner),
2012                        subquery: subquery.clone(),
2013                        negated: *negated,
2014                    });
2015                }
2016                let inner = self.materialize_subqueries_readonly(inner)?;
2017                let sub_plan = crate::planner::plan_statement(Statement::Query(*subquery.clone()))
2018                    .map_err(|e| QueryError::StorageError(e.to_string()))?;
2019                let result = self.execute_plan_readonly(&sub_plan)?;
2020                let values = match result {
2021                    QueryResult::Rows { rows, .. } => rows
2022                        .into_iter()
2023                        .filter_map(|mut row| {
2024                            if row.is_empty() {
2025                                None
2026                            } else {
2027                                Some(value_to_expr(row.swap_remove(0)))
2028                            }
2029                        })
2030                        .collect(),
2031                    _ => Vec::new(),
2032                };
2033                // WS2: byte-budget guard on the materialized IN-list.
2034                self.charge_in_list(&values)?;
2035                Ok(Expr::InList {
2036                    expr: Box::new(inner),
2037                    list: values,
2038                    negated: *negated,
2039                })
2040            }
2041            Expr::ExistsSubquery { subquery, negated } => {
2042                if is_correlated_subquery(subquery, &self.catalog) {
2043                    return Ok(expr.clone());
2044                }
2045                let sub_plan = crate::planner::plan_statement(Statement::Query(*subquery.clone()))
2046                    .map_err(|e| QueryError::StorageError(e.to_string()))?;
2047                let result = self.execute_plan_readonly(&sub_plan)?;
2048                let has_rows = match result {
2049                    QueryResult::Rows { rows, .. } => !rows.is_empty(),
2050                    _ => false,
2051                };
2052                let truth = if *negated { !has_rows } else { has_rows };
2053                Ok(Expr::Literal(Literal::Bool(truth)))
2054            }
2055            Expr::BinaryOp(l, op, r) => {
2056                let l = self.materialize_subqueries_readonly(l)?;
2057                let r = self.materialize_subqueries_readonly(r)?;
2058                Ok(Expr::BinaryOp(Box::new(l), *op, Box::new(r)))
2059            }
2060            Expr::UnaryOp(op, inner) => {
2061                let inner = self.materialize_subqueries_readonly(inner)?;
2062                Ok(Expr::UnaryOp(*op, Box::new(inner)))
2063            }
2064            Expr::Case { whens, else_expr } => {
2065                let whens = whens
2066                    .iter()
2067                    .map(|(c, r)| {
2068                        let c = self.materialize_subqueries_readonly(c)?;
2069                        let r = self.materialize_subqueries_readonly(r)?;
2070                        Ok((Box::new(c), Box::new(r)))
2071                    })
2072                    .collect::<Result<Vec<_>, QueryError>>()?;
2073                let else_expr = match else_expr {
2074                    Some(e) => Some(Box::new(self.materialize_subqueries_readonly(e)?)),
2075                    None => None,
2076                };
2077                Ok(Expr::Case { whens, else_expr })
2078            }
2079            other => Ok(other.clone()),
2080        }
2081    }
2082
2083    /// Per-row materialisation of correlated subqueries. For each row in the
2084    /// outer query, substitute outer column references in the subquery's
2085    /// filter with the current row's literal values, execute the modified
2086    /// subquery, and return the result as an InList or Bool literal.
2087    fn materialize_correlated_for_row_readonly(
2088        &self,
2089        expr: &Expr,
2090        outer_row: &[Value],
2091        outer_columns: &[String],
2092    ) -> Result<Expr, QueryError> {
2093        match expr {
2094            Expr::InSubquery {
2095                expr: inner,
2096                subquery,
2097                negated,
2098            } => {
2099                let inner =
2100                    self.materialize_correlated_for_row_readonly(inner, outer_row, outer_columns)?;
2101                let mut sub = *subquery.clone();
2102                if let Some(ref filter) = sub.filter {
2103                    sub.filter = Some(substitute_outer_refs(
2104                        filter,
2105                        &sub.source,
2106                        &self.catalog,
2107                        outer_row,
2108                        outer_columns,
2109                    ));
2110                }
2111                let sub_plan = crate::planner::plan_statement(Statement::Query(sub))
2112                    .map_err(|e| QueryError::StorageError(e.to_string()))?;
2113                let result = self.execute_plan_readonly(&sub_plan)?;
2114                let values = match result {
2115                    QueryResult::Rows { rows, .. } => rows
2116                        .into_iter()
2117                        .filter_map(|mut row| {
2118                            if row.is_empty() {
2119                                None
2120                            } else {
2121                                Some(value_to_expr(row.swap_remove(0)))
2122                            }
2123                        })
2124                        .collect(),
2125                    _ => Vec::new(),
2126                };
2127                // WS2: byte-budget guard on the per-row materialized IN-list.
2128                self.charge_in_list(&values)?;
2129                Ok(Expr::InList {
2130                    expr: Box::new(inner),
2131                    list: values,
2132                    negated: *negated,
2133                })
2134            }
2135            Expr::ExistsSubquery { subquery, negated } => {
2136                let mut sub = *subquery.clone();
2137                if let Some(ref filter) = sub.filter {
2138                    sub.filter = Some(substitute_outer_refs(
2139                        filter,
2140                        &sub.source,
2141                        &self.catalog,
2142                        outer_row,
2143                        outer_columns,
2144                    ));
2145                }
2146                let sub_plan = crate::planner::plan_statement(Statement::Query(sub))
2147                    .map_err(|e| QueryError::StorageError(e.to_string()))?;
2148                let result = self.execute_plan_readonly(&sub_plan)?;
2149                let has_rows = match result {
2150                    QueryResult::Rows { rows, .. } => !rows.is_empty(),
2151                    _ => false,
2152                };
2153                let truth = if *negated { !has_rows } else { has_rows };
2154                Ok(Expr::Literal(Literal::Bool(truth)))
2155            }
2156            Expr::BinaryOp(l, op, r) => {
2157                let l =
2158                    self.materialize_correlated_for_row_readonly(l, outer_row, outer_columns)?;
2159                let r =
2160                    self.materialize_correlated_for_row_readonly(r, outer_row, outer_columns)?;
2161                Ok(Expr::BinaryOp(Box::new(l), *op, Box::new(r)))
2162            }
2163            Expr::UnaryOp(op, inner) => {
2164                let inner =
2165                    self.materialize_correlated_for_row_readonly(inner, outer_row, outer_columns)?;
2166                Ok(Expr::UnaryOp(*op, Box::new(inner)))
2167            }
2168            other => Ok(other.clone()),
2169        }
2170    }
2171
2172    pub fn catalog(&self) -> &Catalog {
2173        &self.catalog
2174    }
2175
2176    pub fn catalog_mut(&mut self) -> &mut Catalog {
2177        &mut self.catalog
2178    }
2179}
2180
2181impl Drop for Engine {
2182    fn drop(&mut self) {
2183        let Some(hook) = self.wal_archive_hook.clone() else {
2184            return;
2185        };
2186        if let Err(err) = self
2187            .catalog
2188            .checkpoint_with_wal_archive(move |dir, records| hook(dir, records))
2189        {
2190            error!(error = %err, "sync-aware engine checkpoint on drop failed");
2191        }
2192    }
2193}