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