powdb_query/executor/prepared.rs
1//! PreparedQuery struct and related Engine methods.
2
3use crate::ast::*;
4use crate::plan::*;
5use crate::planner;
6use crate::result::{QueryError, QueryResult};
7use powdb_storage::catalog::Catalog;
8use powdb_storage::row::{ROW_MAGIC, ROW_PREFIX_SIZE};
9use powdb_storage::types::*;
10
11use super::compiled::*;
12use super::eval::*;
13use super::Engine;
14
15pub struct PreparedQuery {
16 plan_template: PlanNode,
17 /// Total number of `Expr::Literal` slots reachable from the plan.
18 /// Callers must supply exactly this many literals per execution.
19 pub param_count: usize,
20 /// Fast-path metadata for `PlanNode::Insert`. `Some` when:
21 /// * the template is an Insert, and
22 /// * every assignment RHS is `Expr::Literal(_)` (no computed exprs),
23 /// which means param_count == assignments.len() and the caller's
24 /// literal slice maps 1:1 to schema column indices.
25 ///
26 /// Mission C Phase 15: upgraded from a bare `Vec<usize>` to a
27 /// dedicated [`InsertFast`] struct so the execute path can skip the
28 /// second `catalog.schema(table)` HashMap lookup just to read
29 /// `n_cols`, and can dispatch through `get_table_mut` + `tbl.insert`
30 /// instead of going via the generic `catalog.insert` wrapper.
31 insert_fast: Option<InsertFast>,
32 /// Mission C Phase 14: fast-path metadata for point updates by primary
33 /// key — `T filter .pk = <lit> update { col := <lit> }` where `pk` is
34 /// an indexed column and `col` is fixed-size and not indexed. At
35 /// execute time we skip plan clone, substitute walk, schema re-lookup,
36 /// `resolved_assignments` + `FastPatch` + `matching_rids` Vec allocs,
37 /// and the whole `PlanNode::Update` arm. Just a btree lookup and a
38 /// byte patch.
39 update_pk_fast: Option<UpdatePkFast>,
40}
41
42/// Mission C Phase 15: precomputed insert fast-path metadata. Built once
43/// in [`Engine::prepare`] from a `PlanNode::Insert` template whose every
44/// assignment RHS is a raw literal. The execute path reads `n_cols` and
45/// `col_indices` directly — no catalog schema lookup needed.
46#[derive(Clone)]
47struct InsertFast {
48 /// Mission C Phase 18: cached slot index into `Catalog::tables`.
49 /// Resolved once at `prepare` time and stable for the lifetime of
50 /// the catalog (PowDB has no DROP TABLE). Lets the hot path dispatch
51 /// through `catalog.table_by_slot_mut(slot)` — a pure Vec index,
52 /// no hash, no bucket walk, no string compare.
53 table_slot: usize,
54 /// Schema column index for each positional literal, in the order the
55 /// caller passes them.
56 col_indices: Vec<usize>,
57 /// Total number of schema columns — the size `insert_values_scratch`
58 /// must be resized to before filling positions via `col_indices`.
59 /// Cached here so the hot loop skips `catalog.schema(table)` entirely.
60 n_cols: usize,
61}
62
63/// Mission C Phase 14: precomputed fast-path for `update_by_pk` shaped
64/// prepared queries. Built once in [`Engine::prepare`] and reused on every
65/// `execute_prepared` call.
66#[derive(Clone)]
67struct UpdatePkFast {
68 /// Mission C Phase 18: cached slot index into `Catalog::tables`.
69 /// Resolved once at `prepare` time and stable for the lifetime of
70 /// the catalog. At a 52ns total budget the swap from FxHashMap
71 /// probe to a Vec index is measurable.
72 table_slot: usize,
73 /// Name of the key column (the `.id = ?` side). We look this up in
74 /// the owning table's `indexed_cols` at execute time rather than
75 /// caching a raw `&BTree` — the engine owns the catalog and can't
76 /// hand out long-lived borrows anyway, and the n≤5 linear scan is
77 /// a handful of ns.
78 key_col: String,
79 /// Byte offset of the target fixed column in the row encoding:
80 /// `2 + bitmap_size + layout.fixed_offsets[target_col]`.
81 field_off: usize,
82 /// Byte offset of the bitmap byte containing the target column's null
83 /// bit (`2 + target_col / 8`).
84 bitmap_byte_off: usize,
85 /// Bit mask for the target column's null bit.
86 bit_mask: u8,
87 /// Type of the target fixed column — drives the literal-to-bytes
88 /// encoding at execute time.
89 target_type: TypeId,
90 /// Index into the caller's `literals` slice that holds the filter key.
91 /// Always 0 today (filter literal is visited before the assignment
92 /// RHS), but stored explicitly so the contract is obvious.
93 key_literal_idx: usize,
94 /// Index into the caller's `literals` slice that holds the new value.
95 value_literal_idx: usize,
96}
97
98impl Engine {
99 pub fn prepare(&mut self, query: &str) -> Result<PreparedQuery, QueryError> {
100 let plan = planner::plan(query).map_err(|e| QueryError::Parse(e.to_string()))?;
101 let param_count = crate::plan_cache::count_literal_slots(&plan);
102
103 // Insert fast path: if the template is Insert and every assignment
104 // RHS is a literal, resolve column indices once here and store
105 // them. execute_prepared will skip the plan-clone + substitute
106 // walk on this path.
107 //
108 // Mission C Phase 15: also cache `n_cols` and the target table
109 // name so execute_prepared doesn't need a second HashMap lookup
110 // on `self.catalog.schema(table)` just to size the scratch Vec.
111 let insert_fast = match &plan {
112 // Single-row inserts only: the byte-level fast path patches one
113 // row's worth of scratch. Multi-row `insert T {..},{..}` falls
114 // through to the generic plan path (always correct).
115 PlanNode::Insert {
116 table,
117 rows,
118 returning,
119 } if !returning
120 && rows.len() == 1
121 && rows[0].iter().all(|a| matches!(a.value, Expr::Literal(_)))
122 && param_count == rows[0].len() =>
123 {
124 let assignments = &rows[0];
125 let table_slot = self
126 .catalog
127 .table_slot(table)
128 .ok_or_else(|| QueryError::TableNotFound(table.clone()))?;
129 let schema = &self.catalog.table_by_slot(table_slot).schema;
130 let n_cols = schema.columns.len();
131 let indices: Result<Vec<usize>, QueryError> = assignments
132 .iter()
133 .map(|a| {
134 schema
135 .column_index(&a.field)
136 .ok_or_else(|| QueryError::ColumnNotFound {
137 table: table.clone(),
138 column: a.field.clone(),
139 })
140 })
141 .collect();
142 let indices = indices?;
143 // The fast path writes each literal verbatim with no
144 // `coerce_value`, so a plain string into a uuid/bytes/json
145 // column would store a raw `Value::Str` — silent typed
146 // corruption (and, for json, invalid PJ1). Fall back to the
147 // generic (coercing, validating) path for those columns.
148 if indices.iter().any(|&i| {
149 matches!(
150 schema.columns[i].type_id,
151 TypeId::Uuid | TypeId::Bytes | TypeId::Json
152 )
153 }) {
154 None
155 } else {
156 Some(InsertFast {
157 table_slot,
158 col_indices: indices,
159 n_cols,
160 })
161 }
162 }
163 _ => None,
164 };
165
166 // Mission C Phase 14: update-by-pk fast path. Match on the shape
167 // planner::plan_update builds for `T filter .pk = ? update
168 // { col := ? }` — `Update { input: IndexScan(pk), assignments:
169 // [{col, Literal}] }` — and only if every precondition holds:
170 // * `pk` is an indexed column (so the executor would take the
171 // btree.lookup path at run time regardless)
172 // * there's exactly one assignment
173 // * the assigned column is fixed-size and *not* indexed (so we
174 // don't have to maintain any secondary index on write)
175 // * both literal slots are already `Expr::Literal` (no computed
176 // expressions)
177 // If any of these fail we fall through to the standard substitute
178 // + execute path.
179 let update_pk_fast = Self::try_build_update_pk_fast(&self.catalog, &plan);
180
181 Ok(PreparedQuery {
182 plan_template: plan,
183 param_count,
184 insert_fast,
185 update_pk_fast,
186 })
187 }
188
189 /// Mission C Phase 14: inspect a planned tree and, if it matches the
190 /// `update_by_pk` fast-path shape, return the precomputed byte-patch
191 /// metadata. Returns `None` on any mismatch — the caller falls through
192 /// to the substitute-and-execute path, which is always correct.
193 fn try_build_update_pk_fast(catalog: &Catalog, plan: &PlanNode) -> Option<UpdatePkFast> {
194 // Top level must be `Update { input: IndexScan(...), ... }`.
195 let (table, input, assignments) = match plan {
196 // `returning` must materialize the post-update row image, which the
197 // byte-patch fast path can't produce — fall through to the generic
198 // executor arm.
199 PlanNode::Update {
200 table,
201 input,
202 assignments,
203 returning: false,
204 } => (table, input.as_ref(), assignments),
205 _ => return None,
206 };
207 // Exactly one assignment — the bench hot path and the only case
208 // where a single byte-patch covers the whole mutation.
209 if assignments.len() != 1 {
210 return None;
211 }
212 let assn = &assignments[0];
213 // Assignment RHS must be a raw literal, not a computed expr.
214 if !matches!(assn.value, Expr::Literal(_)) {
215 return None;
216 }
217 // Input must be an IndexScan on the same table with a literal key.
218 let (key_col, key_table) = match input {
219 PlanNode::IndexScan {
220 table: t,
221 column,
222 key: Expr::Literal(_),
223 } => (column.clone(), t.clone()),
224 _ => return None,
225 };
226 if &key_table != table {
227 return None;
228 }
229
230 // Look up schema + index state from the live catalog, caching
231 // the slot so the execute path skips the name probe.
232 let table_slot = catalog.table_slot(table)?;
233 let tbl = catalog.table_by_slot(table_slot);
234 let schema = &tbl.schema;
235
236 // Key column must have an index (the btree.lookup path is what
237 // makes the fast path worth building).
238 if !tbl.has_index(&key_col) {
239 return None;
240 }
241
242 // Target column must exist, be fixed-size, and NOT be indexed (so
243 // we don't have to maintain any secondary index here).
244 let target_col_idx = schema.column_index(&assn.field)?;
245 let target_type = schema.columns[target_col_idx].type_id;
246 if !is_fixed_size(target_type) {
247 return None;
248 }
249 if tbl.has_indexed_col(target_col_idx) {
250 return None;
251 }
252
253 // Precompute byte offsets from the cached row layout.
254 let layout = tbl.row_layout();
255 let fixed_off = layout.fixed_offset(target_col_idx)?;
256 let bitmap_size = layout.bitmap_size();
257 let field_off = 2 + bitmap_size + fixed_off;
258 let bitmap_byte_off = 2 + target_col_idx / 8;
259 let bit_mask = 1u8 << (target_col_idx % 8);
260
261 // Literal walk order for `Update { IndexScan(key), [{value}] }`
262 // (see `plan_cache::substitute_plan` — input first, then the
263 // assignments). The filter key is literal 0, the assignment RHS
264 // is literal 1.
265 Some(UpdatePkFast {
266 table_slot,
267 key_col,
268 field_off,
269 bitmap_byte_off,
270 bit_mask,
271 target_type,
272 key_literal_idx: 0,
273 value_literal_idx: 1,
274 })
275 }
276
277 /// Execute a [`PreparedQuery`] with the given literal values.
278 ///
279 /// The literals are substituted into a clone of the template plan in
280 /// the same deterministic walk order that [`crate::canonicalize`]
281 /// produces (filter predicate first, then projection, then assignment
282 /// RHS, and so on). Substitution errors here mean the caller passed
283 /// the wrong number of literals for this query shape.
284 pub fn execute_prepared(
285 &mut self,
286 prep: &PreparedQuery,
287 literals: &[Literal],
288 ) -> Result<QueryResult, QueryError> {
289 if literals.len() != prep.param_count {
290 return Err(QueryError::Execution(format!(
291 "prepared query expects {} literal(s), got {}",
292 prep.param_count,
293 literals.len(),
294 )));
295 }
296
297 // Mission C Phase 14: update-by-pk fast path. Skip plan clone,
298 // substitute walk, resolved_assignments, FastPatch, Vec<RowId>,
299 // RowLayout::new — straight to btree.lookup_int + byte patch.
300 // On rare mismatches (wrong literal type, index dropped after
301 // prepare) the helper returns `Ok(None)` and we fall through to
302 // the generic substitute-and-execute path below.
303 if let Some(fast) = &prep.update_pk_fast {
304 if let Some(result) = self.try_execute_update_pk_fast(fast, literals)? {
305 // Mark dependent views dirty for prepared update fast path.
306 if let PlanNode::Update { table, .. } = &prep.plan_template {
307 self.view_registry.mark_dependents_dirty(table);
308 }
309 // Mission B (post-review): statement-boundary WAL group
310 // commit. The fast path appended an Update record but did
311 // not flush — flush it now so the executor's contract is
312 // "WAL is on disk before this returns".
313 self.catalog
314 .commit_autocommit()
315 .map_err(|e| QueryError::StorageError(e.to_string()))?;
316 return Ok(result);
317 }
318 }
319
320 // Insert fast path: skip plan-clone + substitute walk + PlanNode::Insert
321 // arm's column-index resolution. Build the Row directly from the
322 // caller's literal slice using indices we resolved at prepare time.
323 // Saves ~300-500ns per insert on the bench.
324 //
325 // Mission C Phase 13: the scratch `Vec<Value>` is reused across
326 // calls — no fresh allocation per insert. We split the borrow
327 // between `self.catalog` and `self.insert_values_scratch` by
328 // moving the scratch into a local, filling it, passing to the
329 // catalog, and putting it back.
330 //
331 // Mission C Phase 15: the cached `InsertFast` carries `n_cols`
332 // and the table name, so the hot path makes exactly one catalog
333 // HashMap lookup (`get_table_mut`) and dispatches straight into
334 // `tbl.insert` — no intermediate schema lookup, no generic
335 // `Catalog::insert` wrapper.
336 if let Some(fast) = &prep.insert_fast {
337 let mut values = std::mem::take(&mut self.insert_values_scratch);
338 values.clear();
339 values.resize(fast.n_cols, Value::Empty);
340 for (pos, lit) in literals.iter().enumerate() {
341 values[fast.col_indices[pos]] = literal_value_from(lit);
342 }
343 // Mission C Phase 18: direct O(1) slot index — no
344 // catalog hash probe. Slot was resolved at prepare time.
345 // Durability fix: route through the WAL-logging `insert_by_slot`
346 // (was the raw `Table::insert`, which bypassed the WAL and lost
347 // every prepared insert on a crash).
348 let res = self
349 .catalog
350 .insert_by_slot(fast.table_slot, &values)
351 .map_err(|e| e.to_string());
352 // Clear strings before returning the scratch — don't keep
353 // dangling allocations from the previous row alive across
354 // calls. `clear()` drops the Value::Str entries.
355 values.clear();
356 self.insert_values_scratch = values;
357 res?;
358 // Mark dependent views dirty for prepared insert fast path.
359 if let PlanNode::Insert { table, .. } = &prep.plan_template {
360 self.view_registry.mark_dependents_dirty(table);
361 }
362 // Mission B (post-review): statement-boundary WAL group commit.
363 self.catalog
364 .commit_autocommit()
365 .map_err(|e| QueryError::StorageError(e.to_string()))?;
366 return Ok(QueryResult::Modified(1));
367 }
368
369 let mut plan = prep.plan_template.clone();
370 let mut idx = 0usize;
371 crate::plan_cache::substitute_plan(&mut plan, literals, &mut idx);
372 debug_assert_eq!(idx, literals.len());
373 let result = self.execute_plan(&plan);
374 // Mission B (post-review): statement-boundary WAL group commit.
375 // No-op when nothing was buffered (read-only plans).
376 self.catalog
377 .commit_autocommit()
378 .map_err(|e| QueryError::StorageError(e.to_string()))?;
379 result
380 }
381
382 /// Mission C Phase 14: point-update fast path for prepared
383 /// `T filter .pk = ? update { col := ? }` queries. The caller has
384 /// already verified this is an int-indexed pk with a fixed-size,
385 /// non-indexed target column; all we do here is pluck the two
386 /// literals out of the caller's slice, run one `btree.lookup_int`,
387 /// and patch 1–8 bytes of the row. No plan clone, no allocations.
388 ///
389 /// Returns:
390 /// * `Ok(Some(result))` — fast path took the mutation.
391 /// * `Ok(None)` — can't take the fast path this call (wrong
392 /// literal type, index dropped since prepare, etc.). Caller
393 /// falls through to the generic substitute-and-execute path.
394 /// * `Err(_)` — real error (table gone, I/O, etc.).
395 #[inline]
396 fn try_execute_update_pk_fast(
397 &mut self,
398 fast: &UpdatePkFast,
399 literals: &[Literal],
400 ) -> Result<Option<QueryResult>, QueryError> {
401 // 1) Extract the key literal. The fast path is only built for
402 // int key columns; any other literal type means the caller
403 // is violating the prepared-query contract or the schema
404 // changed — either way, fall back.
405 let key_int = match &literals[fast.key_literal_idx] {
406 Literal::Int(v) => *v,
407 _ => return Ok(None),
408 };
409
410 // 2) Encode the new value as little-endian bytes matching the
411 // target column's fixed encoding.
412 let bytes: FixedBytes = match (fast.target_type, &literals[fast.value_literal_idx]) {
413 (TypeId::Int, Literal::Int(v)) => FixedBytes::I64(v.to_le_bytes()),
414 (TypeId::DateTime, Literal::Int(v)) => FixedBytes::I64(v.to_le_bytes()),
415 (TypeId::Float, Literal::Float(v)) => FixedBytes::F64(v.to_le_bytes()),
416 (TypeId::Bool, Literal::Bool(v)) => FixedBytes::Bool(if *v { 1 } else { 0 }),
417 // Type mismatch — fall back to the generic path for a
418 // consistent error shape.
419 _ => return Ok(None),
420 };
421
422 // 3) Look up the table + btree, do the int lookup, patch the row
423 // in place. Phase 18: table dispatch is a direct slot index;
424 // the btree lookup is the linear scan over `indexed_cols`.
425 // Single btree.lookup_int + one `with_row_bytes_mut` call.
426 // No Vec allocations at all.
427 //
428 // Mission B2: route the in-place patch through the catalog's
429 // WAL-logged wrapper so crash recovery sees the update. The
430 // extra cost is one WAL append + fsync per query — the hot
431 // loop structure is unchanged.
432 let tbl = self.catalog.table_by_slot_mut(fast.table_slot);
433 let Some(btree) = tbl.index(&fast.key_col) else {
434 // Index dropped since prepare — bail to the generic path.
435 return Ok(None);
436 };
437 let Some(rid) = btree.lookup_int(key_int) else {
438 return Ok(Some(QueryResult::Modified(0)));
439 };
440
441 let fast_table_slot = fast.table_slot;
442 let bitmap_byte_off = fast.bitmap_byte_off;
443 let bit_mask = fast.bit_mask;
444 let field_off = fast.field_off;
445 let ok = self
446 .catalog
447 .update_row_bytes_logged_by_slot(fast_table_slot, rid, |row| {
448 let base = if row.len() >= ROW_PREFIX_SIZE && &row[0..4] == ROW_MAGIC {
449 ROW_PREFIX_SIZE
450 } else {
451 0
452 };
453 // Idempotent null-bit clear — safe even when the column was
454 // already non-null (the overwhelmingly common case).
455 row[base + bitmap_byte_off] &= !bit_mask;
456 let field_bytes = bytes.as_slice();
457 row[base + field_off..base + field_off + field_bytes.len()]
458 .copy_from_slice(field_bytes);
459 })
460 .map_err(|e| QueryError::StorageError(e.to_string()))?;
461
462 Ok(Some(QueryResult::Modified(if ok { 1 } else { 0 })))
463 }
464
465 /// Mission C Phase 13: moving variant of [`Engine::execute_prepared`]
466 /// for the insert fast path. Takes `literals` by mutable reference
467 /// so that each `Literal::String` can be consumed via `mem::take`
468 /// instead of cloned into a `Value::Str`. On `insert_batch_1k` that
469 /// removes three per-row heap allocations (name, status, email),
470 /// bringing the workload over the line vs SQLite's amortized
471 /// prepare+execute loop.
472 ///
473 /// The caller's `Literal::String` entries are replaced with empty
474 /// strings on successful inserts — the `literals` slice is *not*
475 /// left in a valid-for-reuse state except for `Int`/`Float`/`Bool`
476 /// values. Non-insert templates fall through to the standard
477 /// substitute-and-execute path.
478 pub fn execute_prepared_take(
479 &mut self,
480 prep: &PreparedQuery,
481 literals: &mut [Literal],
482 ) -> Result<QueryResult, QueryError> {
483 if literals.len() != prep.param_count {
484 return Err(QueryError::Execution(format!(
485 "prepared query expects {} literal(s), got {}",
486 prep.param_count,
487 literals.len(),
488 )));
489 }
490
491 if let Some(fast) = &prep.insert_fast {
492 let mut values = std::mem::take(&mut self.insert_values_scratch);
493 values.clear();
494 values.resize(fast.n_cols, Value::Empty);
495 for (pos, lit) in literals.iter_mut().enumerate() {
496 values[fast.col_indices[pos]] = literal_value_take(lit);
497 }
498 // Mission C Phase 18: direct O(1) slot index — see
499 // `execute_prepared` for rationale. This is the hot path
500 // for `insert_batch_1k`. Durability fix: WAL-logging
501 // `insert_by_slot` (was the raw `Table::insert`).
502 let res = self
503 .catalog
504 .insert_by_slot(fast.table_slot, &values)
505 .map_err(|e| e.to_string());
506 values.clear();
507 self.insert_values_scratch = values;
508 res?;
509 // Mission B (post-review): statement-boundary WAL group commit.
510 self.catalog
511 .commit_autocommit()
512 .map_err(|e| QueryError::StorageError(e.to_string()))?;
513 return Ok(QueryResult::Modified(1));
514 }
515
516 // Non-insert templates — fall back to the standard path. We
517 // can't usefully move the literals because `substitute_plan`
518 // still expects an immutable slice, and the non-insert hot
519 // paths are dominated by plan walks anyway.
520 self.execute_prepared(prep, literals)
521 }
522
523 /// Walk an expression tree and replace every `InSubquery` node with
524 /// an `InList` by executing the subquery and collecting its first
525 /// column as literal values. This must be called before entering
526 /// the row-by-row scan loop because the scan closure can't call back
527 /// into the engine.
528 pub(super) fn materialize_subqueries(&mut self, expr: &Expr) -> Result<Expr, QueryError> {
529 match expr {
530 Expr::InSubquery {
531 expr: inner,
532 subquery,
533 negated,
534 } => {
535 if is_correlated_subquery(subquery, &self.catalog) {
536 let inner = self.materialize_subqueries(inner)?;
537 return Ok(Expr::InSubquery {
538 expr: Box::new(inner),
539 subquery: subquery.clone(),
540 negated: *negated,
541 });
542 }
543 let inner = self.materialize_subqueries(inner)?;
544 // Plan and execute the subquery.
545 let sub_plan = crate::planner::plan_statement(Statement::Query(*subquery.clone()))
546 .map_err(|e| QueryError::StorageError(e.to_string()))?;
547 let result = self.execute_plan(&sub_plan)?;
548 let values = match result {
549 QueryResult::Rows { rows, .. } => rows
550 .into_iter()
551 .filter_map(|mut row| {
552 if row.is_empty() {
553 None
554 } else {
555 Some(value_to_expr(row.swap_remove(0)))
556 }
557 })
558 .collect(),
559 _ => Vec::new(),
560 };
561 // WS2: byte-budget guard on the materialized IN-list.
562 self.charge_in_list(&values)?;
563 Ok(Expr::InList {
564 expr: Box::new(inner),
565 list: values,
566 negated: *negated,
567 })
568 }
569 Expr::ExistsSubquery { subquery, negated } => {
570 if is_correlated_subquery(subquery, &self.catalog) {
571 return Ok(expr.clone());
572 }
573 // Uncorrelated EXISTS: run the subquery once and collapse
574 // into a Bool literal.
575 let sub_plan = crate::planner::plan_statement(Statement::Query(*subquery.clone()))
576 .map_err(|e| QueryError::StorageError(e.to_string()))?;
577 let result = self.execute_plan(&sub_plan)?;
578 let has_rows = match result {
579 QueryResult::Rows { rows, .. } => !rows.is_empty(),
580 _ => false,
581 };
582 let truth = if *negated { !has_rows } else { has_rows };
583 Ok(Expr::Literal(Literal::Bool(truth)))
584 }
585 Expr::BinaryOp(l, op, r) => {
586 let l = self.materialize_subqueries(l)?;
587 let r = self.materialize_subqueries(r)?;
588 Ok(Expr::BinaryOp(Box::new(l), *op, Box::new(r)))
589 }
590 Expr::UnaryOp(op, inner) => {
591 let inner = self.materialize_subqueries(inner)?;
592 Ok(Expr::UnaryOp(*op, Box::new(inner)))
593 }
594 Expr::Case { whens, else_expr } => {
595 let whens = whens
596 .iter()
597 .map(|(c, r)| {
598 let c = self.materialize_subqueries(c)?;
599 let r = self.materialize_subqueries(r)?;
600 Ok((Box::new(c), Box::new(r)))
601 })
602 .collect::<Result<Vec<_>, QueryError>>()?;
603 let else_expr = match else_expr {
604 Some(e) => Some(Box::new(self.materialize_subqueries(e)?)),
605 None => None,
606 };
607 Ok(Expr::Case { whens, else_expr })
608 }
609 // Leaf nodes: no subqueries possible.
610 other => Ok(other.clone()),
611 }
612 }
613
614 /// Write-path per-row materialisation of correlated subqueries.
615 pub(super) fn materialize_correlated_for_row(
616 &mut self,
617 expr: &Expr,
618 outer_row: &[Value],
619 outer_columns: &[String],
620 ) -> Result<Expr, QueryError> {
621 match expr {
622 Expr::InSubquery {
623 expr: inner,
624 subquery,
625 negated,
626 } => {
627 let inner = self.materialize_correlated_for_row(inner, outer_row, outer_columns)?;
628 let mut sub = *subquery.clone();
629 if let Some(ref filter) = sub.filter {
630 sub.filter = Some(substitute_outer_refs(
631 filter,
632 &sub.source,
633 &self.catalog,
634 outer_row,
635 outer_columns,
636 ));
637 }
638 let sub_plan = crate::planner::plan_statement(Statement::Query(sub))
639 .map_err(|e| QueryError::StorageError(e.to_string()))?;
640 let result = self.execute_plan(&sub_plan)?;
641 let values = match result {
642 QueryResult::Rows { rows, .. } => rows
643 .into_iter()
644 .filter_map(|mut row| {
645 if row.is_empty() {
646 None
647 } else {
648 Some(value_to_expr(row.swap_remove(0)))
649 }
650 })
651 .collect(),
652 _ => Vec::new(),
653 };
654 Ok(Expr::InList {
655 expr: Box::new(inner),
656 list: values,
657 negated: *negated,
658 })
659 }
660 Expr::ExistsSubquery { subquery, negated } => {
661 let mut sub = *subquery.clone();
662 if let Some(ref filter) = sub.filter {
663 sub.filter = Some(substitute_outer_refs(
664 filter,
665 &sub.source,
666 &self.catalog,
667 outer_row,
668 outer_columns,
669 ));
670 }
671 let sub_plan = crate::planner::plan_statement(Statement::Query(sub))
672 .map_err(|e| QueryError::StorageError(e.to_string()))?;
673 let result = self.execute_plan(&sub_plan)?;
674 let has_rows = match result {
675 QueryResult::Rows { rows, .. } => !rows.is_empty(),
676 _ => false,
677 };
678 let truth = if *negated { !has_rows } else { has_rows };
679 Ok(Expr::Literal(Literal::Bool(truth)))
680 }
681 Expr::BinaryOp(l, op, r) => {
682 let l = self.materialize_correlated_for_row(l, outer_row, outer_columns)?;
683 let r = self.materialize_correlated_for_row(r, outer_row, outer_columns)?;
684 Ok(Expr::BinaryOp(Box::new(l), *op, Box::new(r)))
685 }
686 Expr::UnaryOp(op, inner) => {
687 let inner = self.materialize_correlated_for_row(inner, outer_row, outer_columns)?;
688 Ok(Expr::UnaryOp(*op, Box::new(inner)))
689 }
690 other => Ok(other.clone()),
691 }
692 }
693}