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