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}