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