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