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