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