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