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::result::{QueryError, QueryResult};
6use powdb_storage::catalog::Catalog;
7use powdb_storage::row::{ROW_MAGIC, ROW_PREFIX_SIZE};
8use powdb_storage::types::*;
9
10use super::compiled::*;
11use super::eval::*;
12use super::Engine;
13
14pub struct PreparedQuery {
15    plan_template: PlanNode,
16    /// Total number of `Expr::Literal` slots reachable from the plan.
17    /// Callers must supply exactly this many literals per execution.
18    pub param_count: usize,
19    /// Fast-path metadata for `PlanNode::Insert`. `Some` when:
20    ///   * the template is an Insert, and
21    ///   * every assignment RHS is `Expr::Literal(_)` (no computed exprs),
22    ///     which means param_count == assignments.len() and the caller's
23    ///     literal slice maps 1:1 to schema column indices.
24    ///
25    /// Mission C Phase 15: upgraded from a bare `Vec<usize>` to a
26    /// dedicated [`InsertFast`] struct so the execute path can skip the
27    /// second `catalog.schema(table)` HashMap lookup just to read
28    /// `n_cols`, and can dispatch through `get_table_mut` + `tbl.insert`
29    /// instead of going via the generic `catalog.insert` wrapper.
30    insert_fast: Option<InsertFast>,
31    /// Mission C Phase 14: fast-path metadata for point updates by primary
32    /// key — `T filter .pk = <lit> update { col := <lit> }` where `pk` is
33    /// an indexed column and `col` is fixed-size and not indexed. At
34    /// execute time we skip plan clone, substitute walk, schema re-lookup,
35    /// `resolved_assignments` + `FastPatch` + `matching_rids` Vec allocs,
36    /// and the whole `PlanNode::Update` arm. Just a btree lookup and a
37    /// byte patch.
38    update_pk_fast: Option<UpdatePkFast>,
39}
40
41/// Mission C Phase 15: precomputed insert fast-path metadata. Built once
42/// in [`Engine::prepare`] from a `PlanNode::Insert` template whose every
43/// assignment RHS is a raw literal. The execute path reads `n_cols` and
44/// `col_indices` directly — no catalog schema lookup needed.
45#[derive(Clone)]
46struct InsertFast {
47    /// Mission C Phase 18: cached slot index into `Catalog::tables`.
48    /// DROP/ALTER/index DDL can invalidate the slot or row contract, so every
49    /// execution compares the O(1) catalog structure generation below.
50    table_slot: usize,
51    structure_generation: u64,
52    /// Schema column index for each positional literal, in the order the
53    /// caller passes them.
54    col_indices: Vec<usize>,
55    /// Total number of schema columns — the size `insert_values_scratch`
56    /// must be resized to before filling positions via `col_indices`.
57    /// Cached here so the hot loop skips `catalog.schema(table)` entirely.
58    n_cols: usize,
59    /// Schema slots omitted by this prepared INSERT. The scratch row is shared
60    /// by all prepared inserts on an engine, so these positions must be reset
61    /// to NULL before each execution. Precomputing the complement avoids an
62    /// O(columns × assignments) membership scan on the write hot path.
63    omitted_col_indices: Vec<usize>,
64    /// Assigned column definitions in parameter order. Runtime literals still
65    /// pass through the same coercion rules as the generic INSERT executor;
66    /// preparing with an integer placeholder must not permit a later string to
67    /// be stored in an integer column.
68    assigned_columns: Vec<ColumnDef>,
69    /// Required slots are checked after coercion so a bound NULL cannot bypass
70    /// the generic INSERT required-column contract.
71    required_col_indices: Vec<usize>,
72    /// Needed to mark dependent materialized views dirty in both prepared
73    /// execution variants.
74    table_name: String,
75    /// Prepare-time schema names used only to preserve canonical required-field
76    /// errors. Structural validity is the O(1) generation check above.
77    schema_columns: Vec<ColumnDef>,
78}
79
80/// Mission C Phase 14: precomputed fast-path for `update_by_pk` shaped
81/// prepared queries. Built once in [`Engine::prepare`] and reused on every
82/// `execute_prepared` call.
83#[derive(Clone)]
84struct UpdatePkFast {
85    /// Mission C Phase 18: cached slot index into `Catalog::tables`, guarded
86    /// by the O(1) catalog structure generation on every execution.
87    table_slot: usize,
88    structure_generation: u64,
89    /// Name of the key column (the `.id = ?` side). We look this up in
90    /// the owning table's `indexed_cols` at execute time rather than
91    /// caching a raw `&BTree` — the engine owns the catalog and can't
92    /// hand out long-lived borrows anyway, and the n≤5 linear scan is
93    /// a handful of ns.
94    key_col: String,
95    /// Target column position. A later ALTER ADD INDEX on this column must
96    /// disable the raw byte-patch path so live secondary indexes and unique
97    /// constraints are maintained by the generic update executor.
98    target_col_idx: usize,
99    /// Byte offset of the target fixed column in the row encoding:
100    /// `2 + bitmap_size + layout.fixed_offsets[target_col]`.
101    field_off: usize,
102    /// Byte offset of the bitmap byte containing the target column's null
103    /// bit (`2 + target_col / 8`).
104    bitmap_byte_off: usize,
105    /// Bit mask for the target column's null bit.
106    bit_mask: u8,
107    /// Type of the target fixed column — drives the literal-to-bytes
108    /// encoding at execute time.
109    target_type: TypeId,
110    /// Index into the caller's `literals` slice that holds the filter key.
111    /// Always 0 today (filter literal is visited before the assignment
112    /// RHS), but stored explicitly so the contract is obvious.
113    key_literal_idx: usize,
114    /// Index into the caller's `literals` slice that holds the new value.
115    value_literal_idx: usize,
116}
117
118fn cached_table_matches(catalog: &Catalog, structure_generation: u64) -> bool {
119    catalog.structure_generation() == structure_generation
120}
121
122fn literal_can_take_without_error(literal: &Literal, column: &ColumnDef) -> bool {
123    matches!(
124        (literal, column.type_id),
125        (
126            Literal::Int(_),
127            TypeId::Int | TypeId::Float | TypeId::DateTime
128        ) | (Literal::Float(_), TypeId::Float | TypeId::Int)
129            | (Literal::String(_), TypeId::Str)
130            | (Literal::Bool(_), TypeId::Bool)
131    )
132}
133
134fn restore_taken_strings(fast: &InsertFast, literals: &mut [Literal], values: &mut [Value]) {
135    for (position, literal) in literals.iter_mut().enumerate() {
136        if let Literal::String(destination) = literal {
137            if let Value::Str(source) = &mut values[fast.col_indices[position]] {
138                *destination = std::mem::take(source);
139            }
140        }
141    }
142}
143
144impl Engine {
145    pub fn prepare(&mut self, query: &str) -> Result<PreparedQuery, QueryError> {
146        // The stored template is the RAW plan, exactly like the plan cache's
147        // entries: lowering is a function of catalog state, and a prepared
148        // statement outlives the DDL that changes it. Lowering therefore
149        // happens per execution, below.
150        let (plan, _) = self.plan_text_and_lower(query)?;
151        // Same walk-order restriction as the plan cache: a nested block that
152        // wrote `offset` before `limit` cannot have its slots rebound in
153        // source order.
154        if crate::plan_cache::nested_projection_defeats_cache(&plan) {
155            return Err(QueryError::Execution(
156                "cannot prepare a nested projection that writes `offset` before \
157                 `limit`; write `limit` before `offset` in the nested block"
158                    .into(),
159            ));
160        }
161        let param_count = crate::plan_cache::count_literal_slots(&plan);
162
163        // Insert fast path: if the template is Insert and every assignment
164        // RHS is a literal, resolve column indices once here and store
165        // them. execute_prepared will skip the plan-clone + substitute
166        // walk on this path.
167        //
168        // Mission C Phase 15: also cache `n_cols` and the target table
169        // name so execute_prepared doesn't need a second HashMap lookup
170        // on `self.catalog.schema(table)` just to size the scratch Vec.
171        let insert_fast = match &plan {
172            // Single-row inserts only: the byte-level fast path patches one
173            // row's worth of scratch. Multi-row `insert T {..},{..}` falls
174            // through to the generic plan path (always correct).
175            PlanNode::Insert {
176                table,
177                rows,
178                returning,
179            } if !returning
180                && rows.len() == 1
181                && rows[0].iter().all(|a| matches!(a.value, Expr::Literal(_)))
182                && param_count == rows[0].len() =>
183            {
184                let assignments = &rows[0];
185                let table_slot = self
186                    .catalog
187                    .table_slot(table)
188                    .ok_or_else(|| QueryError::TableNotFound(table.clone()))?;
189                let schema = self.catalog.table_by_slot(table_slot).schema();
190                let n_cols = schema.columns.len();
191                let indices: Result<Vec<usize>, QueryError> = assignments
192                    .iter()
193                    .map(|a| {
194                        schema
195                            .column_index(&a.field)
196                            .ok_or_else(|| QueryError::ColumnNotFound {
197                                table: table.clone(),
198                                column: a.field.clone(),
199                            })
200                    })
201                    .collect();
202                let indices = indices?;
203                let defaults = self.catalog.column_defaults(table).unwrap_or(&[]);
204                let auto = self.catalog.auto_columns(table).unwrap_or(&[]);
205                let omitted_required = schema
206                    .columns
207                    .iter()
208                    .enumerate()
209                    .any(|(index, column)| column.required && !indices.contains(&index));
210                // Defaults and auto columns require table-owned state updates.
211                // Keep those shapes on the generic executor, which applies the
212                // full schema contract. An omitted required column must also
213                // take the generic path so it returns the canonical error.
214                if defaults.iter().any(Option::is_some)
215                    || auto.iter().any(|is_auto| *is_auto)
216                    || omitted_required
217                {
218                    None
219                } else {
220                    let omitted_col_indices = (0..n_cols)
221                        .filter(|index| !indices.contains(index))
222                        .collect();
223                    let assigned_columns = indices
224                        .iter()
225                        .map(|&index| schema.columns[index].clone())
226                        .collect();
227                    let required_col_indices = schema
228                        .columns
229                        .iter()
230                        .enumerate()
231                        .filter_map(|(index, column)| column.required.then_some(index))
232                        .collect();
233                    Some(InsertFast {
234                        table_slot,
235                        structure_generation: self.catalog.structure_generation(),
236                        col_indices: indices,
237                        n_cols,
238                        omitted_col_indices,
239                        assigned_columns,
240                        required_col_indices,
241                        table_name: table.clone(),
242                        schema_columns: schema.columns.clone(),
243                    })
244                }
245            }
246            _ => None,
247        };
248
249        // Mission C Phase 14: update-by-pk fast path. Match on the shape
250        // planner::plan_update builds for `T filter .pk = ? update
251        // { col := ? }` — `Update { input: IndexScan(pk), assignments:
252        // [{col, Literal}] }` — and only if every precondition holds:
253        //   * `pk` is an indexed column (so the executor would take the
254        //     btree.lookup path at run time regardless)
255        //   * there's exactly one assignment
256        //   * the assigned column is fixed-size and *not* indexed (so we
257        //     don't have to maintain any secondary index on write)
258        //   * both literal slots are already `Expr::Literal` (no computed
259        //     expressions)
260        // If any of these fail we fall through to the standard substitute
261        // + execute path.
262        let update_pk_fast = Self::try_build_update_pk_fast(&self.catalog, &plan);
263
264        Ok(PreparedQuery {
265            plan_template: plan,
266            param_count,
267            insert_fast,
268            update_pk_fast,
269        })
270    }
271
272    /// Mission C Phase 14: inspect a planned tree and, if it matches the
273    /// `update_by_pk` fast-path shape, return the precomputed byte-patch
274    /// metadata. Returns `None` on any mismatch — the caller falls through
275    /// to the substitute-and-execute path, which is always correct.
276    fn try_build_update_pk_fast(catalog: &Catalog, plan: &PlanNode) -> Option<UpdatePkFast> {
277        // Top level must be `Update { input: IndexScan(...), ... }`.
278        let (table, input, assignments) = match plan {
279            // `returning` must materialize the post-update row image, which the
280            // byte-patch fast path can't produce — fall through to the generic
281            // executor arm.
282            PlanNode::Update {
283                table,
284                input,
285                assignments,
286                returning: false,
287            } => (table, input.as_ref(), assignments),
288            _ => return None,
289        };
290        // Exactly one assignment — the bench hot path and the only case
291        // where a single byte-patch covers the whole mutation.
292        if assignments.len() != 1 {
293            return None;
294        }
295        let assn = &assignments[0];
296        // Assignment RHS must be a raw literal, not a computed expr.
297        if !matches!(assn.value, Expr::Literal(_)) {
298            return None;
299        }
300        // Input must be an IndexScan on the same table with a literal key.
301        let (key_col, key_table) = match input {
302            PlanNode::IndexScan {
303                table: t,
304                column,
305                key: Expr::Literal(_),
306            } => (column.clone(), t.clone()),
307            _ => return None,
308        };
309        if &key_table != table {
310            return None;
311        }
312
313        // Look up schema + index state from the live catalog, caching
314        // the slot so the execute path skips the name probe.
315        let table_slot = catalog.table_slot(table)?;
316        let tbl = catalog.table_by_slot(table_slot);
317        let schema = tbl.schema();
318
319        // Key column must be a UNIQUE INT index, because the execute path
320        // probes it with `BTree::lookup_int`, which binary-searches assuming
321        // every key is a `Value::Int` and treats any other variant as `Less`.
322        //
323        // That probe is only equivalent to the lowered plan's probe under both
324        // conditions at once:
325        //
326        //   * `TypeId::Int` — a float / datetime / str / bool column stores its
327        //     keys in a different lane, so `lookup_int` addresses nothing and
328        //     the mutation silently reported `Modified(0)` while the same text
329        //     reported `Modified(1)` (or a typed error, for str and bool);
330        //   * unique — a non-unique index does not store one bare `Value::Int`
331        //     per key, so the same probe missed there too.
332        //
333        // Both are also what `plan_exec::lowering::coerce_column_index_key`
334        // decides for the text path, which is the definition this fast path has
335        // to match. Anything else falls through to the substitute-and-lower
336        // path below, which is always correct.
337        let key_col_idx = schema.column_index(&key_col)?;
338        if schema.columns[key_col_idx].type_id != TypeId::Int {
339            return None;
340        }
341        if tbl.is_index_unique(&key_col) != Some(true) {
342            return None;
343        }
344
345        // Target column must exist, be fixed-size, and NOT be indexed (so
346        // we don't have to maintain any secondary index here).
347        let target_col_idx = schema.column_index(&assn.field)?;
348        let target_type = schema.columns[target_col_idx].type_id;
349        if !is_fixed_size(target_type) {
350            return None;
351        }
352        if tbl.has_indexed_col(target_col_idx) {
353            return None;
354        }
355
356        // Precompute byte offsets from the cached row layout.
357        let layout = tbl.row_layout();
358        let fixed_off = layout.fixed_offset(target_col_idx)?;
359        let bitmap_size = layout.bitmap_size();
360        let field_off = 2 + bitmap_size + fixed_off;
361        let bitmap_byte_off = 2 + target_col_idx / 8;
362        let bit_mask = 1u8 << (target_col_idx % 8);
363
364        // Literal walk order for `Update { IndexScan(key), [{value}] }`
365        // (see `plan_cache::substitute_plan` — input first, then the
366        // assignments). The filter key is literal 0, the assignment RHS
367        // is literal 1.
368        Some(UpdatePkFast {
369            table_slot,
370            structure_generation: catalog.structure_generation(),
371            key_col,
372            target_col_idx,
373            field_off,
374            bitmap_byte_off,
375            bit_mask,
376            target_type,
377            key_literal_idx: 0,
378            value_literal_idx: 1,
379        })
380    }
381
382    /// Execute a [`PreparedQuery`] with the given literal values.
383    ///
384    /// The literals are substituted into a clone of the template plan in
385    /// the same deterministic walk order that [`crate::canonicalize`]
386    /// produces (filter predicate first, then projection, then assignment
387    /// RHS, and so on). Substitution errors here mean the caller passed
388    /// the wrong number of literals for this query shape.
389    pub fn execute_prepared(
390        &mut self,
391        prep: &PreparedQuery,
392        literals: &[Literal],
393    ) -> Result<QueryResult, QueryError> {
394        self.run_statement(|engine| engine.execute_prepared_inner(prep, literals))
395    }
396
397    fn execute_prepared_inner(
398        &mut self,
399        prep: &PreparedQuery,
400        literals: &[Literal],
401    ) -> Result<QueryResult, QueryError> {
402        self.ensure_plan_allowed(&prep.plan_template)?;
403        if !matches!(prep.plan_template, PlanNode::Rollback) {
404            crate::cancel::check()?;
405        }
406        if literals.len() != prep.param_count {
407            return Err(QueryError::Execution(format!(
408                "prepared query expects {} literal(s), got {}",
409                prep.param_count,
410                literals.len(),
411            )));
412        }
413        self.begin_plan_mutation(&prep.plan_template)?;
414
415        // Mission C Phase 14: update-by-pk fast path. Skip plan clone,
416        // substitute walk, resolved_assignments, FastPatch, Vec<RowId>,
417        // RowLayout::new — straight to btree.lookup_int + byte patch.
418        // On rare mismatches (wrong literal type, index dropped after
419        // prepare) the helper returns `Ok(None)` and we fall through to
420        // the generic substitute-and-execute path below.
421        if let Some(fast) = prep
422            .update_pk_fast
423            .as_ref()
424            .filter(|_| !self.generic_path_forced("prepared-update-pk"))
425        {
426            if let Some(result) = self.try_execute_update_pk_fast(fast, literals)? {
427                // Mark dependent views dirty for prepared update fast path.
428                // Before the commit below, so the flag is on disk before the
429                // mutation it describes is durable.
430                if let PlanNode::Update { table, .. } = &prep.plan_template {
431                    self.view_registry
432                        .mark_dependents_dirty(table)
433                        .map_err(QueryError::from_storage_io)?;
434                }
435                return Ok(result);
436            }
437        }
438
439        // Insert fast path: skip plan-clone + substitute walk + PlanNode::Insert
440        // arm's column-index resolution. Build the Row directly from the
441        // caller's literal slice using indices we resolved at prepare time.
442        // Saves ~300-500ns per insert on the bench.
443        //
444        // Mission C Phase 13: the scratch `Vec<Value>` is reused across
445        // calls — no fresh allocation per insert. We split the borrow
446        // between `self.catalog` and `self.insert_values_scratch` by
447        // moving the scratch into a local, filling it, passing to the
448        // catalog, and putting it back.
449        //
450        // Mission C Phase 15: the cached `InsertFast` carries `n_cols`
451        // and the table name, so the hot path makes exactly one catalog
452        // HashMap lookup (`get_table_mut`) and dispatches straight into
453        // `tbl.insert` — no intermediate schema lookup, no generic
454        // `Catalog::insert` wrapper.
455        if let Some(fast) = prep.insert_fast.as_ref().filter(|fast| {
456            !self.generic_path_forced("prepared-insert")
457                && cached_table_matches(&self.catalog, fast.structure_generation)
458        }) {
459            let mut values = std::mem::take(&mut self.insert_values_scratch);
460            values.resize(fast.n_cols, Value::Empty);
461            // Columns omitted by the prepared INSERT must return to NULL on
462            // every execution. Assigned string slots keep their allocation so
463            // repeated prepared inserts copy into stable buffers instead of
464            // allocating one String per field per row.
465            for &index in &fast.omitted_col_indices {
466                values[index] = Value::Empty;
467            }
468            for (pos, lit) in literals.iter().enumerate() {
469                let value = &mut values[fast.col_indices[pos]];
470                let column = &fast.assigned_columns[pos];
471                match (value, lit, column.type_id) {
472                    (Value::Str(buffer), Literal::String(text), TypeId::Str) => {
473                        buffer.clear();
474                        buffer.push_str(text);
475                    }
476                    (value, literal, _) => {
477                        let raw = literal_value_from(literal);
478                        match coerce_value(raw, column) {
479                            Ok(coerced) => *value = coerced,
480                            Err(error) => {
481                                self.insert_values_scratch = values;
482                                return Err(QueryError::Execution(error));
483                            }
484                        }
485                    }
486                }
487            }
488            for &index in &fast.required_col_indices {
489                if matches!(values[index], Value::Empty) {
490                    let column = &fast.schema_columns[index];
491                    self.insert_values_scratch = values;
492                    return Err(QueryError::Execution(format!(
493                        "column '{}' is required but no value was provided",
494                        column.name
495                    )));
496                }
497            }
498            // Mission C Phase 18: direct O(1) slot index — no
499            // catalog hash probe. Slot was resolved at prepare time.
500            // Durability fix: route through the WAL-logging `insert_by_slot`
501            // (was the raw `Table::insert`, which bypassed the WAL and lost
502            // every prepared insert on a crash).
503            let res = self
504                .catalog
505                .insert_by_slot(fast.table_slot, &values)
506                .map_err(QueryError::from_storage_io);
507            // Retain ordinary row buffers, but do not pin an overflow-sized
508            // client string in the engine forever after one prepared insert.
509            for value in &mut values {
510                if matches!(value, Value::Str(buffer) if buffer.capacity() > powdb_storage::page::MAX_ROW_DATA_SIZE)
511                {
512                    *value = Value::Empty;
513                }
514            }
515            // Keep one row's string buffers for the next prepared execution.
516            // This is bounded by the prepared row width and never escapes the
517            // engine; the catalog has already encoded/copied the values.
518            self.insert_values_scratch = values;
519            res?;
520            // Mark dependent views dirty for prepared insert fast path.
521            self.view_registry
522                .mark_dependents_dirty(&fast.table_name)
523                .map_err(QueryError::from_storage_io)?;
524            return Ok(QueryResult::Modified(1));
525        }
526
527        let mut plan = prep.plan_template.clone();
528        let mut idx = 0usize;
529        crate::plan_cache::substitute_plan(&mut plan, literals, &mut idx);
530        debug_assert_eq!(idx, literals.len());
531        // The template is raw planner output and the substituted literals are
532        // new, so this is the first and only chance to lower. Executing the
533        // template directly is what made a prepared `.price < $1` answer
534        // differently from the same query executed as text.
535        let plan = self.lower(&plan)?;
536        if self.read_only {
537            return super::to_readonly_terminal(self.execute_plan_readonly(&plan));
538        }
539        self.execute_lowered(&plan)
540    }
541
542    /// Mission C Phase 14: point-update fast path for prepared
543    /// `T filter .pk = ? update { col := ? }` queries. The caller has
544    /// already verified this is a UNIQUE INT-indexed pk with a fixed-size,
545    /// non-indexed target column; all we do here is pluck the two
546    /// literals out of the caller's slice, run one `btree.lookup_int`,
547    /// and patch 1–8 bytes of the row. No plan clone, no allocations.
548    ///
549    /// Returns:
550    ///   * `Ok(Some(result))` — fast path took the mutation.
551    ///   * `Ok(None)` — can't take the fast path this call (wrong
552    ///     literal type, index dropped since prepare, etc.). Caller
553    ///     falls through to the generic substitute-and-execute path.
554    ///   * `Err(_)` — real error (table gone, I/O, etc.).
555    #[inline]
556    fn try_execute_update_pk_fast(
557        &mut self,
558        fast: &UpdatePkFast,
559        literals: &[Literal],
560    ) -> Result<Option<QueryResult>, QueryError> {
561        if !cached_table_matches(&self.catalog, fast.structure_generation) {
562            return Ok(None);
563        }
564        let current_table = self.catalog.table_by_slot(fast.table_slot);
565        // Re-check the two properties `lookup_int` depends on rather than
566        // trusting the prepare-time decision. The structure generation above
567        // already catches index DDL, so this is defence in depth against a
568        // future mutation of the index set that forgets to bump it: losing the
569        // fast path costs speed, taking it on a non-unique or non-Int index
570        // loses writes.
571        if current_table.has_indexed_col(fast.target_col_idx)
572            || current_table.is_index_unique(&fast.key_col) != Some(true)
573        {
574            return Ok(None);
575        }
576        // 1) Extract the key literal. The fast path is only built for
577        //    int key columns; any other literal type means the caller
578        //    is violating the prepared-query contract or the schema
579        //    changed — either way, fall back.
580        let key_int = match &literals[fast.key_literal_idx] {
581            Literal::Int(v) => *v,
582            _ => return Ok(None),
583        };
584
585        // 2) Encode the new value as little-endian bytes matching the
586        //    target column's fixed encoding.
587        let bytes: FixedBytes = match (fast.target_type, &literals[fast.value_literal_idx]) {
588            (TypeId::Int, Literal::Int(v)) => FixedBytes::I64(v.to_le_bytes()),
589            (TypeId::DateTime, Literal::Int(v)) => FixedBytes::I64(v.to_le_bytes()),
590            (TypeId::Float, Literal::Float(v)) => FixedBytes::F64(v.to_le_bytes()),
591            (TypeId::Bool, Literal::Bool(v)) => FixedBytes::Bool(if *v { 1 } else { 0 }),
592            // Type mismatch — fall back to the generic path for a
593            // consistent error shape.
594            _ => return Ok(None),
595        };
596
597        // 3) Look up the table + btree, do the int lookup, patch the row
598        //    in place. Phase 18: table dispatch is a direct slot index;
599        //    the btree lookup is the linear scan over `indexed_cols`.
600        //    Single btree.lookup_int + one `with_row_bytes_mut` call.
601        //    No Vec allocations at all.
602        //
603        // Mission B2: route the in-place patch through the catalog's
604        // WAL-logged wrapper so crash recovery sees the update. The
605        // extra cost is one WAL append + fsync per query — the hot
606        // loop structure is unchanged.
607        let tbl = self.catalog.table_by_slot_mut(fast.table_slot);
608        let btree = tbl
609            .index(&fast.key_col)
610            .expect("prepared update index was revalidated above");
611        let Some(rid) = btree.lookup_int(key_int) else {
612            return Ok(Some(QueryResult::Modified(0)));
613        };
614
615        let fast_table_slot = fast.table_slot;
616        let bitmap_byte_off = fast.bitmap_byte_off;
617        let bit_mask = fast.bit_mask;
618        let field_off = fast.field_off;
619        let ok = self
620            .catalog
621            .update_row_bytes_logged_by_slot(fast_table_slot, rid, |row| {
622                let base = if row.len() >= ROW_PREFIX_SIZE && &row[0..4] == ROW_MAGIC {
623                    ROW_PREFIX_SIZE
624                } else {
625                    0
626                };
627                // Idempotent null-bit clear — safe even when the column was
628                // already non-null (the overwhelmingly common case).
629                row[base + bitmap_byte_off] &= !bit_mask;
630                let field_bytes = bytes.as_slice();
631                row[base + field_off..base + field_off + field_bytes.len()]
632                    .copy_from_slice(field_bytes);
633            })
634            .map_err(QueryError::from_storage_io)?;
635
636        Ok(Some(QueryResult::Modified(if ok { 1 } else { 0 })))
637    }
638
639    /// Mission C Phase 13: moving variant of [`Engine::execute_prepared`]
640    /// for the insert fast path. Takes `literals` by mutable reference
641    /// so that each `Literal::String` can be consumed via `mem::take`
642    /// instead of cloned into a `Value::Str`. On `insert_batch_1k` that
643    /// removes three per-row heap allocations (name, status, email),
644    /// bringing the workload over the line vs SQLite's amortized
645    /// prepare+execute loop.
646    ///
647    /// The caller's `Literal::String` entries are replaced with empty
648    /// strings on successful inserts — the `literals` slice is *not*
649    /// left in a valid-for-reuse state except for `Int`/`Float`/`Bool`
650    /// values. Non-insert templates fall through to the standard
651    /// substitute-and-execute path.
652    pub fn execute_prepared_take(
653        &mut self,
654        prep: &PreparedQuery,
655        literals: &mut [Literal],
656    ) -> Result<QueryResult, QueryError> {
657        let mut moved_insert = false;
658        let result = self.run_statement(|engine| {
659            engine.execute_prepared_take_inner(prep, literals, &mut moved_insert)
660        });
661        if moved_insert {
662            if result.is_err() {
663                // A commit failure occurs after the insert path has returned.
664                // Keep input ownership intact until the outer boundary succeeds.
665                let fast = prep.insert_fast.as_ref().expect("moving insert fast path");
666                restore_taken_strings(fast, literals, &mut self.insert_values_scratch);
667            }
668            self.insert_values_scratch.clear();
669        } else if result.is_ok() && matches!(prep.plan_template, PlanNode::Insert { .. }) {
670            for literal in literals {
671                if let Literal::String(value) = literal {
672                    value.clear();
673                }
674            }
675        }
676        result
677    }
678
679    fn execute_prepared_take_inner(
680        &mut self,
681        prep: &PreparedQuery,
682        literals: &mut [Literal],
683        moved_insert: &mut bool,
684    ) -> Result<QueryResult, QueryError> {
685        self.ensure_plan_allowed(&prep.plan_template)?;
686        if !matches!(prep.plan_template, PlanNode::Rollback) {
687            crate::cancel::check()?;
688        }
689        if literals.len() != prep.param_count {
690            return Err(QueryError::Execution(format!(
691                "prepared query expects {} literal(s), got {}",
692                prep.param_count,
693                literals.len(),
694            )));
695        }
696        self.begin_plan_mutation(&prep.plan_template)?;
697
698        if let Some(fast) = prep.insert_fast.as_ref().filter(|fast| {
699            !self.generic_path_forced("prepared-insert-take")
700                && cached_table_matches(&self.catalog, fast.structure_generation)
701        }) {
702            // Moving strings is only safe when coercion cannot fail or replace
703            // the string with another representation. Complex/coercing shapes
704            // use the borrowed path; on success we still honor this method's
705            // consume-on-success contract.
706            if !literals
707                .iter()
708                .zip(&fast.assigned_columns)
709                .all(|(literal, column)| literal_can_take_without_error(literal, column))
710            {
711                return self.execute_prepared(prep, literals);
712            }
713            let mut values = std::mem::take(&mut self.insert_values_scratch);
714            values.clear();
715            values.resize(fast.n_cols, Value::Empty);
716            for (pos, lit) in literals.iter_mut().enumerate() {
717                let raw = literal_value_take(lit);
718                match coerce_value(raw, &fast.assigned_columns[pos]) {
719                    Ok(coerced) => values[fast.col_indices[pos]] = coerced,
720                    Err(error) => {
721                        restore_taken_strings(fast, literals, &mut values);
722                        values.clear();
723                        self.insert_values_scratch = values;
724                        return Err(QueryError::Execution(error));
725                    }
726                }
727            }
728            for &index in &fast.required_col_indices {
729                if matches!(values[index], Value::Empty) {
730                    let column = &fast.schema_columns[index];
731                    let error = format!(
732                        "column '{}' is required but no value was provided",
733                        column.name
734                    );
735                    restore_taken_strings(fast, literals, &mut values);
736                    values.clear();
737                    self.insert_values_scratch = values;
738                    return Err(QueryError::Execution(error));
739                }
740            }
741            // Mission C Phase 18: direct O(1) slot index — see
742            // `execute_prepared` for rationale. This is the hot path
743            // for `insert_batch_1k`. Durability fix: WAL-logging
744            // `insert_by_slot` (was the raw `Table::insert`).
745            if let Err(error) = self.catalog.insert_by_slot(fast.table_slot, &values) {
746                restore_taken_strings(fast, literals, &mut values);
747                values.clear();
748                self.insert_values_scratch = values;
749                return Err(QueryError::from_storage_io(error));
750            }
751            if let Err(error) = self.view_registry.mark_dependents_dirty(&fast.table_name) {
752                restore_taken_strings(fast, literals, &mut values);
753                values.clear();
754                self.insert_values_scratch = values;
755                return Err(QueryError::from_storage_io(error));
756            }
757            // Keep moved strings available until the outer commit succeeds.
758            self.insert_values_scratch = values;
759            *moved_insert = true;
760            return Ok(QueryResult::Modified(1));
761        }
762
763        // Non-insert templates — fall back to the standard path. We
764        // can't usefully move the literals because `substitute_plan`
765        // still expects an immutable slice, and the non-insert hot
766        // paths are dominated by plan walks anyway.
767        self.execute_prepared(prep, literals)
768    }
769
770    /// Walk an expression tree and replace every `InSubquery` node with
771    /// an `InList` by executing the subquery and collecting its first
772    /// column as literal values. This must be called before entering
773    /// the row-by-row scan loop because the scan closure can't call back
774    /// into the engine.
775    pub(super) fn materialize_subqueries(
776        &mut self,
777        expr: &Expr,
778        outer_columns: Option<&[String]>,
779    ) -> Result<Expr, QueryError> {
780        match expr {
781            Expr::InSubquery {
782                expr: inner,
783                subquery,
784                negated,
785            } => {
786                validate_subquery_scope(subquery, &self.catalog, outer_columns)?;
787                if is_correlated_subquery(subquery, &self.catalog, outer_columns) {
788                    let inner = self.materialize_subqueries(inner, outer_columns)?;
789                    return Ok(Expr::InSubquery {
790                        expr: Box::new(inner),
791                        subquery: subquery.clone(),
792                        negated: *negated,
793                    });
794                }
795                let inner = self.materialize_subqueries(inner, outer_columns)?;
796                // Plan and execute the subquery.
797                let sub_plan = self.plan_and_lower(Statement::Query(*subquery.clone()))?;
798                let result = self.execute_lowered(&sub_plan)?;
799                let values = match result {
800                    QueryResult::Rows { rows, .. } => {
801                        let mut values = Vec::with_capacity(rows.len());
802                        let mut cancel = crate::cancel::CancelCheck::new();
803                        for mut row in rows {
804                            cancel.tick()?;
805                            if !row.is_empty() {
806                                values.push(value_to_expr(row.swap_remove(0)));
807                            }
808                        }
809                        values
810                    }
811                    _ => Vec::new(),
812                };
813                // WS2: byte-budget guard on the materialized IN-list.
814                self.charge_in_list(&values)?;
815                Ok(Expr::InList {
816                    expr: Box::new(inner),
817                    list: values,
818                    negated: *negated,
819                })
820            }
821            Expr::ExistsSubquery { subquery, negated } => {
822                validate_subquery_scope(subquery, &self.catalog, outer_columns)?;
823                if is_correlated_subquery(subquery, &self.catalog, outer_columns) {
824                    return Ok(expr.clone());
825                }
826                // Uncorrelated EXISTS: run the subquery once and collapse
827                // into a Bool literal.
828                let sub_plan = self.plan_and_lower(Statement::Query(*subquery.clone()))?;
829                let result = self.execute_lowered(&sub_plan)?;
830                let has_rows = match result {
831                    QueryResult::Rows { rows, .. } => !rows.is_empty(),
832                    _ => false,
833                };
834                let truth = if *negated { !has_rows } else { has_rows };
835                Ok(Expr::Literal(Literal::Bool(truth)))
836            }
837            Expr::BinaryOp(l, op, r) => {
838                let l = self.materialize_subqueries(l, outer_columns)?;
839                let r = self.materialize_subqueries(r, outer_columns)?;
840                Ok(Expr::BinaryOp(Box::new(l), *op, Box::new(r)))
841            }
842            Expr::UnaryOp(op, inner) => {
843                let inner = self.materialize_subqueries(inner, outer_columns)?;
844                Ok(Expr::UnaryOp(*op, Box::new(inner)))
845            }
846            Expr::Case { whens, else_expr } => {
847                let whens = whens
848                    .iter()
849                    .map(|(c, r)| {
850                        let c = self.materialize_subqueries(c, outer_columns)?;
851                        let r = self.materialize_subqueries(r, outer_columns)?;
852                        Ok((Box::new(c), Box::new(r)))
853                    })
854                    .collect::<Result<Vec<_>, QueryError>>()?;
855                let else_expr = match else_expr {
856                    Some(e) => Some(Box::new(self.materialize_subqueries(e, outer_columns)?)),
857                    None => None,
858                };
859                Ok(Expr::Case { whens, else_expr })
860            }
861            // Leaf nodes: no subqueries possible.
862            other => Ok(other.clone()),
863        }
864    }
865
866    /// Write-path per-row materialisation of correlated subqueries.
867    pub(super) fn materialize_correlated_for_row(
868        &mut self,
869        expr: &Expr,
870        outer_row: &[Value],
871        outer_columns: &[String],
872    ) -> Result<Expr, QueryError> {
873        match expr {
874            Expr::InSubquery {
875                expr: inner,
876                subquery,
877                negated,
878            } => {
879                let inner = self.materialize_correlated_for_row(inner, outer_row, outer_columns)?;
880                let mut sub = *subquery.clone();
881                if let Some(ref filter) = sub.filter {
882                    sub.filter = Some(substitute_outer_refs(
883                        filter,
884                        &sub,
885                        &self.catalog,
886                        outer_row,
887                        outer_columns,
888                    )?);
889                }
890                let sub_plan = self.plan_and_lower(Statement::Query(sub))?;
891                let result = self.execute_lowered(&sub_plan)?;
892                let values = match result {
893                    QueryResult::Rows { rows, .. } => {
894                        let mut values = Vec::with_capacity(rows.len());
895                        let mut cancel = crate::cancel::CancelCheck::new();
896                        for mut row in rows {
897                            cancel.tick()?;
898                            if !row.is_empty() {
899                                values.push(value_to_expr(row.swap_remove(0)));
900                            }
901                        }
902                        values
903                    }
904                    _ => Vec::new(),
905                };
906                Ok(Expr::InList {
907                    expr: Box::new(inner),
908                    list: values,
909                    negated: *negated,
910                })
911            }
912            Expr::ExistsSubquery { subquery, negated } => {
913                let mut sub = *subquery.clone();
914                if let Some(ref filter) = sub.filter {
915                    sub.filter = Some(substitute_outer_refs(
916                        filter,
917                        &sub,
918                        &self.catalog,
919                        outer_row,
920                        outer_columns,
921                    )?);
922                }
923                let sub_plan = self.plan_and_lower(Statement::Query(sub))?;
924                let result = self.execute_lowered(&sub_plan)?;
925                let has_rows = match result {
926                    QueryResult::Rows { rows, .. } => !rows.is_empty(),
927                    _ => false,
928                };
929                let truth = if *negated { !has_rows } else { has_rows };
930                Ok(Expr::Literal(Literal::Bool(truth)))
931            }
932            Expr::BinaryOp(l, op, r) => {
933                let l = self.materialize_correlated_for_row(l, outer_row, outer_columns)?;
934                let r = self.materialize_correlated_for_row(r, outer_row, outer_columns)?;
935                Ok(Expr::BinaryOp(Box::new(l), *op, Box::new(r)))
936            }
937            Expr::UnaryOp(op, inner) => {
938                let inner = self.materialize_correlated_for_row(inner, outer_row, outer_columns)?;
939                Ok(Expr::UnaryOp(*op, Box::new(inner)))
940            }
941            other => Ok(other.clone()),
942        }
943    }
944}