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