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