Skip to main content

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