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