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 if literals.len() != prep.param_count {
395 return Err(QueryError::Execution(format!(
396 "prepared query expects {} literal(s), got {}",
397 prep.param_count,
398 literals.len(),
399 )));
400 }
401
402 // Mission C Phase 14: update-by-pk fast path. Skip plan clone,
403 // substitute walk, resolved_assignments, FastPatch, Vec<RowId>,
404 // RowLayout::new — straight to btree.lookup_int + byte patch.
405 // On rare mismatches (wrong literal type, index dropped after
406 // prepare) the helper returns `Ok(None)` and we fall through to
407 // the generic substitute-and-execute path below.
408 if let Some(fast) = prep
409 .update_pk_fast
410 .as_ref()
411 .filter(|_| !self.generic_path_forced("prepared-update-pk"))
412 {
413 if let Some(result) = self.try_execute_update_pk_fast(fast, literals)? {
414 // Mark dependent views dirty for prepared update fast path.
415 // Before the commit below, so the flag is on disk before the
416 // mutation it describes is durable.
417 if let PlanNode::Update { table, .. } = &prep.plan_template {
418 self.view_registry
419 .mark_dependents_dirty(table)
420 .map_err(QueryError::from_storage_io)?;
421 }
422 // Mission B (post-review): statement-boundary WAL group
423 // commit. The fast path appended an Update record but did
424 // not flush — flush it now so the executor's contract is
425 // "WAL is on disk before this returns".
426 self.catalog
427 .commit_autocommit()
428 .map_err(QueryError::from_storage_io)?;
429 return Ok(result);
430 }
431 }
432
433 // Insert fast path: skip plan-clone + substitute walk + PlanNode::Insert
434 // arm's column-index resolution. Build the Row directly from the
435 // caller's literal slice using indices we resolved at prepare time.
436 // Saves ~300-500ns per insert on the bench.
437 //
438 // Mission C Phase 13: the scratch `Vec<Value>` is reused across
439 // calls — no fresh allocation per insert. We split the borrow
440 // between `self.catalog` and `self.insert_values_scratch` by
441 // moving the scratch into a local, filling it, passing to the
442 // catalog, and putting it back.
443 //
444 // Mission C Phase 15: the cached `InsertFast` carries `n_cols`
445 // and the table name, so the hot path makes exactly one catalog
446 // HashMap lookup (`get_table_mut`) and dispatches straight into
447 // `tbl.insert` — no intermediate schema lookup, no generic
448 // `Catalog::insert` wrapper.
449 if let Some(fast) = prep.insert_fast.as_ref().filter(|fast| {
450 !self.generic_path_forced("prepared-insert")
451 && cached_table_matches(&self.catalog, fast.structure_generation)
452 }) {
453 let mut values = std::mem::take(&mut self.insert_values_scratch);
454 values.resize(fast.n_cols, Value::Empty);
455 // Columns omitted by the prepared INSERT must return to NULL on
456 // every execution. Assigned string slots keep their allocation so
457 // repeated prepared inserts copy into stable buffers instead of
458 // allocating one String per field per row.
459 for &index in &fast.omitted_col_indices {
460 values[index] = Value::Empty;
461 }
462 for (pos, lit) in literals.iter().enumerate() {
463 let value = &mut values[fast.col_indices[pos]];
464 let column = &fast.assigned_columns[pos];
465 match (value, lit, column.type_id) {
466 (Value::Str(buffer), Literal::String(text), TypeId::Str) => {
467 buffer.clear();
468 buffer.push_str(text);
469 }
470 (value, literal, _) => {
471 let raw = literal_value_from(literal);
472 match coerce_value(raw, column) {
473 Ok(coerced) => *value = coerced,
474 Err(error) => {
475 self.insert_values_scratch = values;
476 return Err(QueryError::Execution(error));
477 }
478 }
479 }
480 }
481 }
482 for &index in &fast.required_col_indices {
483 if matches!(values[index], Value::Empty) {
484 let column = &fast.schema_columns[index];
485 self.insert_values_scratch = values;
486 return Err(QueryError::Execution(format!(
487 "column '{}' is required but no value was provided",
488 column.name
489 )));
490 }
491 }
492 // Mission C Phase 18: direct O(1) slot index — no
493 // catalog hash probe. Slot was resolved at prepare time.
494 // Durability fix: route through the WAL-logging `insert_by_slot`
495 // (was the raw `Table::insert`, which bypassed the WAL and lost
496 // every prepared insert on a crash).
497 let res = self
498 .catalog
499 .insert_by_slot(fast.table_slot, &values)
500 .map_err(|e| e.to_string());
501 // Retain ordinary row buffers, but do not pin an overflow-sized
502 // client string in the engine forever after one prepared insert.
503 for value in &mut values {
504 if matches!(value, Value::Str(buffer) if buffer.capacity() > powdb_storage::page::MAX_ROW_DATA_SIZE)
505 {
506 *value = Value::Empty;
507 }
508 }
509 // Keep one row's string buffers for the next prepared execution.
510 // This is bounded by the prepared row width and never escapes the
511 // engine; the catalog has already encoded/copied the values.
512 self.insert_values_scratch = values;
513 res?;
514 // Mark dependent views dirty for prepared insert fast path.
515 self.view_registry
516 .mark_dependents_dirty(&fast.table_name)
517 .map_err(QueryError::from_storage_io)?;
518 // Mission B (post-review): statement-boundary WAL group commit.
519 self.catalog
520 .commit_autocommit()
521 .map_err(QueryError::from_storage_io)?;
522 return Ok(QueryResult::Modified(1));
523 }
524
525 let mut plan = prep.plan_template.clone();
526 let mut idx = 0usize;
527 crate::plan_cache::substitute_plan(&mut plan, literals, &mut idx);
528 debug_assert_eq!(idx, literals.len());
529 // The template is raw planner output and the substituted literals are
530 // new, so this is the first and only chance to lower. Executing the
531 // template directly is what made a prepared `.price < $1` answer
532 // differently from the same query executed as text.
533 let plan = self.lower(&plan);
534 let result = self.execute_lowered(&plan);
535 // Mission B (post-review): statement-boundary WAL group commit.
536 // No-op when nothing was buffered (read-only plans).
537 self.catalog
538 .commit_autocommit()
539 .map_err(QueryError::from_storage_io)?;
540 result
541 }
542
543 /// Mission C Phase 14: point-update fast path for prepared
544 /// `T filter .pk = ? update { col := ? }` queries. The caller has
545 /// already verified this is a UNIQUE INT-indexed pk with a fixed-size,
546 /// non-indexed target column; all we do here is pluck the two
547 /// literals out of the caller's slice, run one `btree.lookup_int`,
548 /// and patch 1–8 bytes of the row. No plan clone, no allocations.
549 ///
550 /// Returns:
551 /// * `Ok(Some(result))` — fast path took the mutation.
552 /// * `Ok(None)` — can't take the fast path this call (wrong
553 /// literal type, index dropped since prepare, etc.). Caller
554 /// falls through to the generic substitute-and-execute path.
555 /// * `Err(_)` — real error (table gone, I/O, etc.).
556 #[inline]
557 fn try_execute_update_pk_fast(
558 &mut self,
559 fast: &UpdatePkFast,
560 literals: &[Literal],
561 ) -> Result<Option<QueryResult>, QueryError> {
562 if !cached_table_matches(&self.catalog, fast.structure_generation) {
563 return Ok(None);
564 }
565 let current_table = self.catalog.table_by_slot(fast.table_slot);
566 // Re-check the two properties `lookup_int` depends on rather than
567 // trusting the prepare-time decision. The structure generation above
568 // already catches index DDL, so this is defence in depth against a
569 // future mutation of the index set that forgets to bump it: losing the
570 // fast path costs speed, taking it on a non-unique or non-Int index
571 // loses writes.
572 if current_table.has_indexed_col(fast.target_col_idx)
573 || current_table.is_index_unique(&fast.key_col) != Some(true)
574 {
575 return Ok(None);
576 }
577 // 1) Extract the key literal. The fast path is only built for
578 // int key columns; any other literal type means the caller
579 // is violating the prepared-query contract or the schema
580 // changed — either way, fall back.
581 let key_int = match &literals[fast.key_literal_idx] {
582 Literal::Int(v) => *v,
583 _ => return Ok(None),
584 };
585
586 // 2) Encode the new value as little-endian bytes matching the
587 // target column's fixed encoding.
588 let bytes: FixedBytes = match (fast.target_type, &literals[fast.value_literal_idx]) {
589 (TypeId::Int, Literal::Int(v)) => FixedBytes::I64(v.to_le_bytes()),
590 (TypeId::DateTime, Literal::Int(v)) => FixedBytes::I64(v.to_le_bytes()),
591 (TypeId::Float, Literal::Float(v)) => FixedBytes::F64(v.to_le_bytes()),
592 (TypeId::Bool, Literal::Bool(v)) => FixedBytes::Bool(if *v { 1 } else { 0 }),
593 // Type mismatch — fall back to the generic path for a
594 // consistent error shape.
595 _ => return Ok(None),
596 };
597
598 // 3) Look up the table + btree, do the int lookup, patch the row
599 // in place. Phase 18: table dispatch is a direct slot index;
600 // the btree lookup is the linear scan over `indexed_cols`.
601 // Single btree.lookup_int + one `with_row_bytes_mut` call.
602 // No Vec allocations at all.
603 //
604 // Mission B2: route the in-place patch through the catalog's
605 // WAL-logged wrapper so crash recovery sees the update. The
606 // extra cost is one WAL append + fsync per query — the hot
607 // loop structure is unchanged.
608 let tbl = self.catalog.table_by_slot_mut(fast.table_slot);
609 let btree = tbl
610 .index(&fast.key_col)
611 .expect("prepared update index was revalidated above");
612 let Some(rid) = btree.lookup_int(key_int) else {
613 return Ok(Some(QueryResult::Modified(0)));
614 };
615
616 let fast_table_slot = fast.table_slot;
617 let bitmap_byte_off = fast.bitmap_byte_off;
618 let bit_mask = fast.bit_mask;
619 let field_off = fast.field_off;
620 let ok = self
621 .catalog
622 .update_row_bytes_logged_by_slot(fast_table_slot, rid, |row| {
623 let base = if row.len() >= ROW_PREFIX_SIZE && &row[0..4] == ROW_MAGIC {
624 ROW_PREFIX_SIZE
625 } else {
626 0
627 };
628 // Idempotent null-bit clear — safe even when the column was
629 // already non-null (the overwhelmingly common case).
630 row[base + bitmap_byte_off] &= !bit_mask;
631 let field_bytes = bytes.as_slice();
632 row[base + field_off..base + field_off + field_bytes.len()]
633 .copy_from_slice(field_bytes);
634 })
635 .map_err(QueryError::from_storage_io)?;
636
637 Ok(Some(QueryResult::Modified(if ok { 1 } else { 0 })))
638 }
639
640 /// Mission C Phase 13: moving variant of [`Engine::execute_prepared`]
641 /// for the insert fast path. Takes `literals` by mutable reference
642 /// so that each `Literal::String` can be consumed via `mem::take`
643 /// instead of cloned into a `Value::Str`. On `insert_batch_1k` that
644 /// removes three per-row heap allocations (name, status, email),
645 /// bringing the workload over the line vs SQLite's amortized
646 /// prepare+execute loop.
647 ///
648 /// The caller's `Literal::String` entries are replaced with empty
649 /// strings on successful inserts — the `literals` slice is *not*
650 /// left in a valid-for-reuse state except for `Int`/`Float`/`Bool`
651 /// values. Non-insert templates fall through to the standard
652 /// substitute-and-execute path.
653 pub fn execute_prepared_take(
654 &mut self,
655 prep: &PreparedQuery,
656 literals: &mut [Literal],
657 ) -> Result<QueryResult, QueryError> {
658 if literals.len() != prep.param_count {
659 return Err(QueryError::Execution(format!(
660 "prepared query expects {} literal(s), got {}",
661 prep.param_count,
662 literals.len(),
663 )));
664 }
665
666 if let Some(fast) = prep
667 .insert_fast
668 .as_ref()
669 .filter(|fast| cached_table_matches(&self.catalog, fast.structure_generation))
670 {
671 // Moving strings is only safe when coercion cannot fail or replace
672 // the string with another representation. Complex/coercing shapes
673 // use the borrowed path; on success we still honor this method's
674 // consume-on-success contract.
675 if !literals
676 .iter()
677 .zip(&fast.assigned_columns)
678 .all(|(literal, column)| literal_can_take_without_error(literal, column))
679 {
680 let result = self.execute_prepared(prep, literals);
681 if result.is_ok() {
682 for literal in literals {
683 if let Literal::String(value) = literal {
684 value.clear();
685 }
686 }
687 }
688 return result;
689 }
690 let mut values = std::mem::take(&mut self.insert_values_scratch);
691 values.clear();
692 values.resize(fast.n_cols, Value::Empty);
693 for (pos, lit) in literals.iter_mut().enumerate() {
694 let raw = literal_value_take(lit);
695 match coerce_value(raw, &fast.assigned_columns[pos]) {
696 Ok(coerced) => values[fast.col_indices[pos]] = coerced,
697 Err(error) => {
698 restore_taken_strings(fast, literals, &mut values);
699 values.clear();
700 self.insert_values_scratch = values;
701 return Err(QueryError::Execution(error));
702 }
703 }
704 }
705 for &index in &fast.required_col_indices {
706 if matches!(values[index], Value::Empty) {
707 let column = &fast.schema_columns[index];
708 let error = format!(
709 "column '{}' is required but no value was provided",
710 column.name
711 );
712 restore_taken_strings(fast, literals, &mut values);
713 values.clear();
714 self.insert_values_scratch = values;
715 return Err(QueryError::Execution(error));
716 }
717 }
718 // Mission C Phase 18: direct O(1) slot index — see
719 // `execute_prepared` for rationale. This is the hot path
720 // for `insert_batch_1k`. Durability fix: WAL-logging
721 // `insert_by_slot` (was the raw `Table::insert`).
722 if let Err(error) = self.catalog.insert_by_slot(fast.table_slot, &values) {
723 restore_taken_strings(fast, literals, &mut values);
724 values.clear();
725 self.insert_values_scratch = values;
726 return Err(QueryError::from_storage_io(error));
727 }
728 if let Err(error) = self.view_registry.mark_dependents_dirty(&fast.table_name) {
729 restore_taken_strings(fast, literals, &mut values);
730 values.clear();
731 self.insert_values_scratch = values;
732 return Err(QueryError::from_storage_io(error));
733 }
734 // Mission B (post-review): statement-boundary WAL group commit.
735 if let Err(error) = self.catalog.commit_autocommit() {
736 restore_taken_strings(fast, literals, &mut values);
737 values.clear();
738 self.insert_values_scratch = values;
739 return Err(QueryError::from_storage_io(error));
740 }
741 values.clear();
742 self.insert_values_scratch = values;
743 return Ok(QueryResult::Modified(1));
744 }
745
746 // Non-insert templates — fall back to the standard path. We
747 // can't usefully move the literals because `substitute_plan`
748 // still expects an immutable slice, and the non-insert hot
749 // paths are dominated by plan walks anyway.
750 let result = self.execute_prepared(prep, literals);
751 if result.is_ok() && matches!(prep.plan_template, PlanNode::Insert { .. }) {
752 for literal in literals {
753 if let Literal::String(value) = literal {
754 value.clear();
755 }
756 }
757 }
758 result
759 }
760
761 /// Walk an expression tree and replace every `InSubquery` node with
762 /// an `InList` by executing the subquery and collecting its first
763 /// column as literal values. This must be called before entering
764 /// the row-by-row scan loop because the scan closure can't call back
765 /// into the engine.
766 pub(super) fn materialize_subqueries(&mut self, expr: &Expr) -> Result<Expr, QueryError> {
767 match expr {
768 Expr::InSubquery {
769 expr: inner,
770 subquery,
771 negated,
772 } => {
773 if is_correlated_subquery(subquery, &self.catalog) {
774 let inner = self.materialize_subqueries(inner)?;
775 return Ok(Expr::InSubquery {
776 expr: Box::new(inner),
777 subquery: subquery.clone(),
778 negated: *negated,
779 });
780 }
781 let inner = self.materialize_subqueries(inner)?;
782 // Plan and execute the subquery.
783 let sub_plan = self.plan_and_lower(Statement::Query(*subquery.clone()))?;
784 let result = self.execute_lowered(&sub_plan)?;
785 let values = match result {
786 QueryResult::Rows { rows, .. } => {
787 let mut values = Vec::with_capacity(rows.len());
788 let mut cancel = crate::cancel::CancelCheck::new();
789 for mut row in rows {
790 cancel.tick()?;
791 if !row.is_empty() {
792 values.push(value_to_expr(row.swap_remove(0)));
793 }
794 }
795 values
796 }
797 _ => Vec::new(),
798 };
799 // WS2: byte-budget guard on the materialized IN-list.
800 self.charge_in_list(&values)?;
801 Ok(Expr::InList {
802 expr: Box::new(inner),
803 list: values,
804 negated: *negated,
805 })
806 }
807 Expr::ExistsSubquery { subquery, negated } => {
808 if is_correlated_subquery(subquery, &self.catalog) {
809 return Ok(expr.clone());
810 }
811 // Uncorrelated EXISTS: run the subquery once and collapse
812 // into a Bool literal.
813 let sub_plan = self.plan_and_lower(Statement::Query(*subquery.clone()))?;
814 let result = self.execute_lowered(&sub_plan)?;
815 let has_rows = match result {
816 QueryResult::Rows { rows, .. } => !rows.is_empty(),
817 _ => false,
818 };
819 let truth = if *negated { !has_rows } else { has_rows };
820 Ok(Expr::Literal(Literal::Bool(truth)))
821 }
822 Expr::BinaryOp(l, op, r) => {
823 let l = self.materialize_subqueries(l)?;
824 let r = self.materialize_subqueries(r)?;
825 Ok(Expr::BinaryOp(Box::new(l), *op, Box::new(r)))
826 }
827 Expr::UnaryOp(op, inner) => {
828 let inner = self.materialize_subqueries(inner)?;
829 Ok(Expr::UnaryOp(*op, Box::new(inner)))
830 }
831 Expr::Case { whens, else_expr } => {
832 let whens = whens
833 .iter()
834 .map(|(c, r)| {
835 let c = self.materialize_subqueries(c)?;
836 let r = self.materialize_subqueries(r)?;
837 Ok((Box::new(c), Box::new(r)))
838 })
839 .collect::<Result<Vec<_>, QueryError>>()?;
840 let else_expr = match else_expr {
841 Some(e) => Some(Box::new(self.materialize_subqueries(e)?)),
842 None => None,
843 };
844 Ok(Expr::Case { whens, else_expr })
845 }
846 // Leaf nodes: no subqueries possible.
847 other => Ok(other.clone()),
848 }
849 }
850
851 /// Write-path per-row materialisation of correlated subqueries.
852 pub(super) fn materialize_correlated_for_row(
853 &mut self,
854 expr: &Expr,
855 outer_row: &[Value],
856 outer_columns: &[String],
857 ) -> Result<Expr, QueryError> {
858 match expr {
859 Expr::InSubquery {
860 expr: inner,
861 subquery,
862 negated,
863 } => {
864 let inner = self.materialize_correlated_for_row(inner, outer_row, outer_columns)?;
865 let mut sub = *subquery.clone();
866 if let Some(ref filter) = sub.filter {
867 sub.filter = Some(substitute_outer_refs(
868 filter,
869 &sub.source,
870 &self.catalog,
871 outer_row,
872 outer_columns,
873 ));
874 }
875 let sub_plan = self.plan_and_lower(Statement::Query(sub))?;
876 let result = self.execute_lowered(&sub_plan)?;
877 let values = match result {
878 QueryResult::Rows { rows, .. } => {
879 let mut values = Vec::with_capacity(rows.len());
880 let mut cancel = crate::cancel::CancelCheck::new();
881 for mut row in rows {
882 cancel.tick()?;
883 if !row.is_empty() {
884 values.push(value_to_expr(row.swap_remove(0)));
885 }
886 }
887 values
888 }
889 _ => Vec::new(),
890 };
891 Ok(Expr::InList {
892 expr: Box::new(inner),
893 list: values,
894 negated: *negated,
895 })
896 }
897 Expr::ExistsSubquery { subquery, negated } => {
898 let mut sub = *subquery.clone();
899 if let Some(ref filter) = sub.filter {
900 sub.filter = Some(substitute_outer_refs(
901 filter,
902 &sub.source,
903 &self.catalog,
904 outer_row,
905 outer_columns,
906 ));
907 }
908 let sub_plan = self.plan_and_lower(Statement::Query(sub))?;
909 let result = self.execute_lowered(&sub_plan)?;
910 let has_rows = match result {
911 QueryResult::Rows { rows, .. } => !rows.is_empty(),
912 _ => false,
913 };
914 let truth = if *negated { !has_rows } else { has_rows };
915 Ok(Expr::Literal(Literal::Bool(truth)))
916 }
917 Expr::BinaryOp(l, op, r) => {
918 let l = self.materialize_correlated_for_row(l, outer_row, outer_columns)?;
919 let r = self.materialize_correlated_for_row(r, outer_row, outer_columns)?;
920 Ok(Expr::BinaryOp(Box::new(l), *op, Box::new(r)))
921 }
922 Expr::UnaryOp(op, inner) => {
923 let inner = self.materialize_correlated_for_row(inner, outer_row, outer_columns)?;
924 Ok(Expr::UnaryOp(*op, Box::new(inner)))
925 }
926 other => Ok(other.clone()),
927 }
928 }
929}