powdb_query/executor/plan_exec/dispatch.rs
1//! The `execute_plan` dispatch match and materialized view operations.
2
3use crate::cancel::CancelCheck;
4use crate::result::{QueryError, QueryResult};
5use powdb_storage::catalog::{LinkDef, LinkKind};
6use powdb_storage::row::{decode_row, RowLayout};
7use std::collections::HashMap;
8use std::ops::ControlFlow;
9
10use crate::executor::eval::*;
11use crate::executor::row_body_base;
12use crate::executor::{Engine, MAX_SORT_ROWS};
13use powdb_storage::view::ViewDef;
14
15use super::*;
16
17impl Engine {
18 /// Execute a plan on the mutable path.
19 ///
20 /// This is the one execution entry point that takes a bare [`PlanNode`],
21 /// because embedders build plans themselves: `planner::plan` is public,
22 /// `PlanNode` is public, and the `powdb` facade re-exports this method. So
23 /// it lowers first, exactly like every path inside the executor does.
24 ///
25 /// Lowering is not an optimization. The planner is pure, so it emits index
26 /// probes speculatively and leaves every literal as written; the pass is
27 /// what decides whether those probes exist and what key bytes they address.
28 /// Executing raw planner output here made a planned `.price < 3` answer
29 /// `[]` through this entry point where the same text through
30 /// [`Engine::execute_powql`] answered the rows, and `LoweredPlan` is
31 /// crate-private, so an embedder had no way to lower for itself.
32 ///
33 /// Lowering is idempotent, so a caller that already has a lowered tree pays
34 /// one pass and gets the same plan back.
35 pub fn execute_plan(&mut self, plan: &PlanNode) -> Result<QueryResult, QueryError> {
36 let lowered = self.lower(plan)?;
37 self.execute_lowered(&lowered)
38 }
39
40 /// The write-path dispatch itself. Takes a bare `&PlanNode` because it is
41 /// the recursion target: every child of a lowered plan is lowered, so a
42 /// subtree needs no second pass. Mirrors [`Engine::dispatch_readonly`], and
43 /// is private for the same reason: reaching it from outside an already
44 /// lowered tree is what [`Engine::execute_plan`] above exists to prevent.
45 pub(in crate::executor) fn dispatch_mut(
46 &mut self,
47 plan: &PlanNode,
48 ) -> Result<QueryResult, QueryError> {
49 // Refuse any plan whose evaluable expressions still carry an aggregate
50 // FunctionCall the grouped-aggregate planner could not lower. Without
51 // this, such an aggregate would reach eval_expr and silently evaluate
52 // to Empty (a wrong answer). The outermost call validates the whole
53 // tree before any row is produced; the same call also refuses unknown
54 // tables and columns, mistyped comparisons, mistyped JSON path bases
55 // and negative slice counts, against the catalog as it is right now.
56 validate_plan(&self.catalog, plan)?;
57 match plan {
58 PlanNode::ExprIndexScan { .. }
59 | PlanNode::ExprRangeScan { .. }
60 | PlanNode::OrderedExprIndexScan { .. } => {
61 if let Some(result) = self.execute_expression_index_plan(plan, None)? {
62 return Ok(result);
63 }
64 let fallback = expression_index_fallback(plan)
65 .expect("expression-index branch always has a fallback");
66 self.dispatch_mut(&fallback)
67 }
68 PlanNode::SeqScan { table } => {
69 // Auto-refresh dirty materialized views on read.
70 if self.view_registry.is_dirty(table) {
71 self.refresh_view(table)?;
72 }
73 let schema = self
74 .catalog
75 .schema(table)
76 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?
77 .clone();
78 let columns: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
79 // Cooperative cancellation: a full-table scan of a huge table
80 // must stay stoppable.
81 let mut cancel = CancelCheck::new();
82 let mut rows: Vec<Vec<Value>> = Vec::new();
83 for item in self
84 .catalog
85 .scan(table)
86 .map_err(QueryError::from_storage_io)?
87 {
88 let (_, row) = item.map_err(QueryError::from_storage_io)?;
89 cancel.tick()?;
90 rows.push(row);
91 }
92 Ok(QueryResult::Rows { columns, rows })
93 }
94
95 PlanNode::Filter { input, predicate } => {
96 // Materialize any IN-subqueries in the predicate before the
97 // scan loop — the closure can't call back into the engine.
98 // Correlated subqueries are left in place for per-row eval.
99 let materialized;
100 let predicate = if contains_subquery(predicate) {
101 materialized = self.materialize_subqueries(predicate)?;
102 &materialized
103 } else {
104 predicate
105 };
106
107 // Correlated subquery path: per-row materialisation.
108 if contains_subquery(predicate) {
109 let result = self.dispatch_mut(input)?;
110 return match result {
111 QueryResult::Rows { columns, rows } => {
112 let mut filtered = Vec::new();
113 // Cooperative cancellation: a subquery runs per outer
114 // row, so a large outer scan must stay stoppable.
115 let mut cancel = CancelCheck::new();
116 for row in rows {
117 cancel.tick()?;
118 let row_pred =
119 self.materialize_correlated_for_row(predicate, &row, &columns)?;
120 if eval_predicate(&row_pred, &row, &columns) {
121 filtered.push(row);
122 }
123 }
124 Ok(QueryResult::Rows {
125 columns,
126 rows: filtered,
127 })
128 }
129 _ => Err("filter requires row input".into()),
130 };
131 }
132
133 // Lane A fast path: Filter over an equality-driven index scan.
134 // The index narrows the candidate rids; the residual is
135 // re-checked with a partial decode, full rows only for matches.
136 if matches!(
137 input.as_ref(),
138 PlanNode::IndexScan { .. } | PlanNode::ExprIndexScan { .. }
139 ) {
140 if let Some(result) = self.try_filter_index_residual_fast(input, predicate)? {
141 return Ok(result);
142 }
143 }
144
145 // Fast path: fuse Filter + SeqScan into a zero-copy streaming
146 // loop. Uses decode_column() to evaluate the predicate on only
147 // the columns it references, avoiding heap allocations for
148 // String/Bytes columns that aren't part of the filter.
149 // Overflow safety (P0-4/P1): v2-capable tables fall through to
150 // the decoded general Filter path below — the raw fast path
151 // rehydrates to v1 and drops/mis-reads >= 64KB spilled values.
152 if let PlanNode::SeqScan { table } = input.as_ref() {
153 if !self.catalog.table_has_overflow(table)
154 && !self.generic_path_forced("filter-seqscan-raw")
155 {
156 // Auto-refresh dirty materialized views.
157 if self.view_registry.is_dirty(table) {
158 self.refresh_view(table)?;
159 }
160 let schema = self
161 .catalog
162 .schema(table)
163 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?
164 .clone();
165 let columns: Vec<String> =
166 schema.columns.iter().map(|c| c.name.clone()).collect();
167 let fast = FastLayout::new(&schema);
168 let row_layout = RowLayout::new(&schema);
169 // Mission F: pre-size to skip the first 4 Vec doublings
170 // (4 → 8 → 16 → 32 → 64). On a 100K-row scan with 30%
171 // selectivity that's ~4 fewer reallocations + memcpys.
172 let mut rows: Vec<Vec<Value>> = Vec::with_capacity(64);
173
174 // Try compiled predicate for the filter check (handles
175 // int leaves, string-eq leaves, and And conjunctions).
176 // Cooperative cancellation: a full-table compiled/
177 // selective predicate scan must stay stoppable, so use
178 // the early-terminating scan and break on cancel. The
179 // captured error is surfaced after the scan returns.
180 let mut cancel = CancelCheck::new();
181 let mut cancel_err: Option<QueryError> = None;
182 if let Some(compiled) = self.compile_predicate_unless_forced(
183 "filter-seqscan:predicate",
184 predicate,
185 &columns,
186 &fast,
187 &schema,
188 ) {
189 self.catalog
190 .try_for_each_row_raw(table, |_rid, data| {
191 if let Err(e) = cancel.tick() {
192 cancel_err = Some(e);
193 return ControlFlow::Break(());
194 }
195 if compiled(data) {
196 rows.push(decode_row(&schema, data));
197 }
198 ControlFlow::Continue(())
199 })
200 .map_err(QueryError::from_storage_io)?;
201 } else {
202 let pred_cols = predicate_column_indices_json(predicate, &columns);
203 self.catalog
204 .try_for_each_row_raw(table, |_rid, data| {
205 if let Err(e) = cancel.tick() {
206 cancel_err = Some(e);
207 return ControlFlow::Break(());
208 }
209 let pred_row =
210 decode_selective(&schema, &row_layout, data, &pred_cols);
211 if eval_predicate(predicate, &pred_row, &columns) {
212 rows.push(decode_row(&schema, data));
213 }
214 ControlFlow::Continue(())
215 })
216 .map_err(QueryError::from_storage_io)?;
217 }
218 if let Some(e) = cancel_err {
219 return Err(e);
220 }
221
222 return Ok(QueryResult::Rows { columns, rows });
223 }
224 }
225
226 // General path: materialise then filter.
227 let result = self.dispatch_mut(input)?;
228 match result {
229 QueryResult::Rows { columns, rows } => {
230 let mut cancel = CancelCheck::new();
231 let mut filtered: Vec<Vec<Value>> = Vec::new();
232 for row in rows {
233 cancel.tick()?;
234 if eval_predicate(predicate, &row, &columns) {
235 filtered.push(row);
236 }
237 }
238 Ok(QueryResult::Rows {
239 columns,
240 rows: filtered,
241 })
242 }
243 _ => Err("filter requires row input".into()),
244 }
245 }
246
247 PlanNode::Project { input, fields } => {
248 if matches!(
249 input.as_ref(),
250 PlanNode::ExprIndexScan { .. }
251 | PlanNode::ExprRangeScan { .. }
252 | PlanNode::OrderedExprIndexScan { .. }
253 ) {
254 if let Some(result) = self.execute_expression_index_plan(input, Some(fields))? {
255 return Ok(result);
256 }
257 }
258 // Fast path: Project over IndexScan — decode only projected
259 // columns from raw bytes instead of full decode_row.
260 if let PlanNode::IndexScan { table, column, key } = input.as_ref() {
261 let schema = self
262 .catalog
263 .schema(table)
264 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?
265 .clone();
266 let all_columns: Vec<String> =
267 schema.columns.iter().map(|c| c.name.clone()).collect();
268 let key_value = literal_to_value(key)?;
269 let tbl = self
270 .catalog
271 .get_table(table)
272 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
273
274 let proj_columns: Vec<String> = fields
275 .iter()
276 .map(|f| {
277 f.alias
278 .clone()
279 .unwrap_or_else(|| projection_output_name(&f.expr))
280 })
281 .collect();
282
283 // Determine which column indices the projection needs
284 let proj_indices: Vec<usize> = fields
285 .iter()
286 .filter_map(|f| {
287 if let Expr::Field(name) = &f.expr {
288 all_columns.iter().position(|c| c == name)
289 } else {
290 None
291 }
292 })
293 .collect();
294
295 // Only serve plain-field projections here; a computed
296 // projection (e.g. `length(.v)`) must fall through to the
297 // generic expression-evaluating path — otherwise its column
298 // is silently dropped (proj_indices only collects Fields).
299 let all_plain_fields = fields.iter().all(|f| matches!(f.expr, Expr::Field(_)));
300 if tbl.has_index(column)
301 && all_plain_fields
302 && !self.generic_path_forced("project-over-index-scan")
303 {
304 let rids = tbl.index_lookup_all(column, &key_value);
305 let mut rows: Vec<Vec<Value>> = Vec::with_capacity(rids.len());
306 let mut cancel = CancelCheck::new();
307 for rid in rids {
308 cancel.tick()?;
309 // Overflow safety (P0-3/P0-4): `tbl.get` reassembles
310 // spilled columns from their overflow chains. The old
311 // `heap.get` + `decode_column` read raw v2 bytes and
312 // returned Empty for a spilled column (or wrapped a
313 // >= 64KB value).
314 if let Some(full) = tbl.get(rid).map_err(QueryError::from_storage_io)? {
315 let row: Vec<Value> =
316 proj_indices.iter().map(|&ci| full[ci].clone()).collect();
317 rows.push(row);
318 }
319 }
320 return Ok(QueryResult::Rows {
321 columns: proj_columns,
322 rows,
323 });
324 }
325 }
326
327 // Fast path: Project(Limit(Sort(Filter(SeqScan)))) — bounded
328 // top-N heap. Decodes only the sort key + projected columns,
329 // keeps at most `limit` rows in a heap. Also handles the
330 // Project(Limit(Sort(SeqScan))) variant (no filter).
331 if let PlanNode::Limit {
332 input: inner,
333 count: limit_expr,
334 } = input.as_ref()
335 {
336 if let PlanNode::Sort {
337 input: sort_input,
338 keys,
339 } = inner.as_ref()
340 {
341 // Fast path only for single-key sorts, and only for a
342 // bound this path may act on, an unreadable count is
343 // the generic `Limit` arm's error to report.
344 if keys.len() == 1 {
345 if let (Expr::Field(sort_field), Some(limit)) =
346 (&keys[0].expr, literal_limit(limit_expr))
347 {
348 let descending = keys[0].descending;
349 let (table_opt, pred_opt): (Option<&str>, Option<&Expr>) =
350 match sort_input.as_ref() {
351 PlanNode::SeqScan { table } => (Some(table.as_str()), None),
352 PlanNode::Filter {
353 input: fi,
354 predicate,
355 } => {
356 if let PlanNode::SeqScan { table } = fi.as_ref() {
357 (Some(table.as_str()), Some(predicate))
358 } else {
359 (None, None)
360 }
361 }
362 _ => (None, None),
363 };
364 if let Some(table) = table_opt {
365 if let Some(result) = self.project_filter_sort_limit_fast(
366 table, fields, sort_field, descending, limit, pred_opt,
367 )? {
368 return Ok(result);
369 }
370 }
371 }
372 }
373 }
374 // Fast path: Project(Limit(Filter(SeqScan))) — stream,
375 // decode only projected columns, stop at limit.
376 if let PlanNode::Filter {
377 input: fi,
378 predicate,
379 } = inner.as_ref()
380 {
381 if let (PlanNode::SeqScan { table }, Some(limit)) =
382 (fi.as_ref(), literal_limit(limit_expr))
383 {
384 if let Some(result) = self.project_filter_limit_fast(
385 table,
386 fields,
387 limit,
388 Some(predicate),
389 )? {
390 return Ok(result);
391 }
392 }
393 }
394 // Fast path: Project(Limit(SeqScan)) — stream, no filter.
395 if let (PlanNode::SeqScan { table }, Some(limit)) =
396 (inner.as_ref(), literal_limit(limit_expr))
397 {
398 if let Some(result) =
399 self.project_filter_limit_fast(table, fields, limit, None)?
400 {
401 return Ok(result);
402 }
403 }
404 }
405
406 // Mission D4: Project(Filter(SeqScan)) without Limit. Reuses
407 // `project_filter_limit_fast` with limit = usize::MAX so the
408 // hot loop decodes only projected columns and uses the
409 // compiled predicate. Previously this fell through to the
410 // generic Filter branch which materialised every column via
411 // `decode_row` then re-projected — quadratic work.
412 //
413 // multi_col_and_filter (`U filter .age > 30 and .status =
414 // "active" { .name, .age }`) was 6.18ms (0.7x SQLite) and
415 // is the load-bearing workload for this fast path.
416 if let PlanNode::Filter {
417 input: fi,
418 predicate,
419 } = input.as_ref()
420 {
421 if let PlanNode::SeqScan { table } = fi.as_ref() {
422 if let Some(result) = self.project_filter_limit_fast(
423 table,
424 fields,
425 usize::MAX,
426 Some(predicate),
427 )? {
428 return Ok(result);
429 }
430 }
431 }
432
433 // Mission D4: Project(SeqScan) without Filter or Limit.
434 // Decode only projected columns; the previous fall-through
435 // built full Vec<Value> rows then re-projected.
436 if let PlanNode::SeqScan { table } = input.as_ref() {
437 if let Some(result) =
438 self.project_filter_limit_fast(table, fields, usize::MAX, None)?
439 {
440 return Ok(result);
441 }
442 }
443
444 let result = self.dispatch_mut(input)?;
445 match result {
446 QueryResult::Rows { columns, rows } => {
447 let proj_columns: Vec<String> = fields
448 .iter()
449 .map(|f| {
450 // Mission E1.2: `{ u.name }` projects as the
451 // qualified column name so callers can still
452 // disambiguate across the join output.
453 f.alias
454 .clone()
455 .unwrap_or_else(|| projection_output_name(&f.expr))
456 })
457 .collect();
458 let mut cancel = CancelCheck::new();
459 let mut proj_rows: Vec<Vec<Value>> = Vec::with_capacity(rows.len());
460 for row in &rows {
461 cancel.tick()?;
462 proj_rows.push(
463 fields
464 .iter()
465 .map(|f| eval_expr(&f.expr, row, &columns))
466 .collect(),
467 );
468 }
469 Ok(QueryResult::Rows {
470 columns: proj_columns,
471 rows: proj_rows,
472 })
473 }
474 _ => Err("project requires row input".into()),
475 }
476 }
477
478 PlanNode::Sort { input, keys } => {
479 let result = self.dispatch_mut(input)?;
480 match result {
481 QueryResult::Rows { columns, mut rows } => {
482 // WS2: row-count cap is a cheap secondary guard; the
483 // byte budget is the real OOM defense for the sort
484 // buffer (a few very large rows pass the row cap).
485 if rows.len() > MAX_SORT_ROWS {
486 return Err(QueryError::SortLimitExceeded);
487 }
488 self.charge_rows(&rows)?;
489 let key_specs: Vec<(Option<usize>, &Expr, bool)> = keys
490 .iter()
491 .map(|k| {
492 let stored_name = match &k.expr {
493 Expr::Field(name) => Some(name.clone()),
494 Expr::QualifiedField { qualifier, field } => {
495 Some(format!("{qualifier}.{field}"))
496 }
497 _ => None,
498 };
499 // Same resolver the projections, filters and
500 // join keys use, so `order .amount` inside a
501 // join resolves the bare name against the
502 // `alias.field` scan columns instead of
503 // reporting a column the next clause projects
504 // as missing.
505 let index = stored_name
506 .as_ref()
507 .and_then(|name| resolve_column_index(name, &columns));
508 if let Some(name) = stored_name {
509 if index.is_none() {
510 return Err(QueryError::ColumnNotFound {
511 table: String::new(),
512 column: name,
513 });
514 }
515 }
516 Ok((index, &k.expr, k.descending))
517 })
518 .collect::<Result<_, QueryError>>()?;
519 cooperative_stable_sort_by(&mut rows, self.query_memory_limit, |a, b| {
520 for &(col_idx, expr, descending) in &key_specs {
521 let (left_value, right_value) = match col_idx {
522 Some(index) => (&a[index], &b[index]),
523 None => {
524 let left = eval_expr(expr, a, &columns);
525 let right = eval_expr(expr, b, &columns);
526 let cmp = compare_order_values(&left, &right, descending);
527 if cmp != std::cmp::Ordering::Equal {
528 return cmp;
529 }
530 continue;
531 }
532 };
533 let cmp = compare_order_values(left_value, right_value, descending);
534 if cmp != std::cmp::Ordering::Equal {
535 return cmp;
536 }
537 }
538 std::cmp::Ordering::Equal
539 })?;
540 Ok(QueryResult::Rows { columns, rows })
541 }
542 _ => Err("sort requires row input".into()),
543 }
544 }
545
546 PlanNode::Limit { input, count } => {
547 let result = self.dispatch_mut(input)?;
548 let n = match count {
549 Expr::Literal(Literal::Int(v)) => *v as usize,
550 _ => return Err("limit must be integer literal".into()),
551 };
552 match result {
553 QueryResult::Rows { columns, rows } => {
554 let mut cancel = CancelCheck::new();
555 let mut limited = Vec::with_capacity(n.min(rows.len()));
556 for row in rows.into_iter().take(n) {
557 cancel.tick()?;
558 limited.push(row);
559 }
560 Ok(QueryResult::Rows {
561 columns,
562 rows: limited,
563 })
564 }
565 _ => Err("limit requires row input".into()),
566 }
567 }
568
569 PlanNode::Offset { input, count } => {
570 let result = self.dispatch_mut(input)?;
571 let n = match count {
572 Expr::Literal(Literal::Int(v)) => *v as usize,
573 _ => return Err("offset must be integer literal".into()),
574 };
575 match result {
576 QueryResult::Rows { columns, rows } => {
577 let mut cancel = CancelCheck::new();
578 let mut offset = Vec::with_capacity(rows.len().saturating_sub(n));
579 for (index, row) in rows.into_iter().enumerate() {
580 cancel.tick()?;
581 if index >= n {
582 offset.push(row);
583 }
584 }
585 Ok(QueryResult::Rows {
586 columns,
587 rows: offset,
588 })
589 }
590 _ => Err("offset requires row input".into()),
591 }
592 }
593
594 PlanNode::Aggregate {
595 input,
596 function,
597 argument,
598 mode: _,
599 provenance_alias,
600 } => {
601 if let Some(provenance_alias) = provenance_alias {
602 let input = self.materialize_rows_with_provenance(input)?;
603 self.charge_rows(&input.rows)?;
604 return aggregate_rows_with_provenance(
605 *function,
606 argument.as_ref(),
607 &input,
608 provenance_alias,
609 self.query_memory_limit(),
610 );
611 }
612 // Fast path: count() over SeqScan, counting rows without any decode.
613 // Only a count with no target column counts rows: `count(T { .v })`
614 // counts non-null `.v` and must reach the generic path below.
615 // The forced-generic check gates the whole block, including the
616 // count-over-filter path further down: one guard, so the inner
617 // `compile_predicate_unless_forced` never records a decline
618 // while the switch is on.
619 if *function == AggFunc::Count
620 && counts_every_row(argument.as_ref())
621 && !self.generic_path_forced("count-fast-block")
622 {
623 // Overflow safety (P0-4): the raw `for_each_row_raw` count
624 // drops any row too large to re-inline (>= 64KB) and would
625 // undercount; v2-capable tables use the decoded generic path.
626 if let PlanNode::SeqScan { table } = input.as_ref() {
627 if !self.catalog.table_has_overflow(table) {
628 // Auto-refresh a dirty materialized view before
629 // counting it — otherwise count(View) returns stale
630 // data after an underlying mutation (F3).
631 if self.view_registry.is_dirty(table) {
632 self.refresh_view(table)?;
633 }
634 let mut count: i64 = 0;
635 for_each_row_raw_cancellable(&self.catalog, table, |_rid, _data| {
636 count += 1;
637 })?;
638 return Ok(QueryResult::Scalar(Value::Int(count)));
639 }
640 }
641 // Fast path: count() over Filter(SeqScan) — try compiled
642 // predicate first, fall back to decode_column path.
643 // Skip a predicate carrying a subquery: the raw-bytes
644 // evaluators here don't materialise subqueries, so
645 // `count(T filter .x in (...))` would silently count 0
646 // (F1). Falling through routes it to the generic path
647 // that resolves the subquery correctly.
648 if let PlanNode::Filter {
649 input: inner,
650 predicate,
651 } = input.as_ref()
652 {
653 if let PlanNode::SeqScan { table } = inner.as_ref() {
654 if self.view_registry.is_dirty(table) {
655 self.refresh_view(table)?;
656 }
657 }
658 if let (PlanNode::SeqScan { table }, false) =
659 (inner.as_ref(), contains_subquery(predicate))
660 {
661 if !self.catalog.table_has_overflow(table) {
662 let schema = self
663 .catalog
664 .schema(table)
665 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?
666 .clone();
667 let columns: Vec<String> =
668 schema.columns.iter().map(|c| c.name.clone()).collect();
669 let fast = FastLayout::new(&schema);
670 let row_layout = RowLayout::new(&schema);
671
672 // Try compiled predicate (zero-allocation hot path).
673 // Handles int leaves, string-eq leaves, AND conjunctions.
674 if let Some(compiled) = self.compile_predicate_unless_forced(
675 "count-filter:predicate",
676 predicate,
677 &columns,
678 &fast,
679 &schema,
680 ) {
681 let mut count: i64 = 0;
682 for_each_row_raw_cancellable(
683 &self.catalog,
684 table,
685 |_rid, data| {
686 if compiled(data) {
687 count += 1;
688 }
689 },
690 )?;
691 return Ok(QueryResult::Scalar(Value::Int(count)));
692 }
693
694 // Fallback: decode predicate columns
695 let pred_cols = predicate_column_indices_json(predicate, &columns);
696 let mut count: i64 = 0;
697 for_each_row_raw_cancellable(
698 &self.catalog,
699 table,
700 |_rid, data| {
701 let pred_row = decode_selective(
702 &schema,
703 &row_layout,
704 data,
705 &pred_cols,
706 );
707 if eval_predicate(predicate, &pred_row, &columns) {
708 count += 1;
709 }
710 },
711 )?;
712
713 return Ok(QueryResult::Scalar(Value::Int(count)));
714 }
715 }
716 }
717 }
718
719 // Fast path: sum/avg/min/max over a single fixed-size int
720 // column with an optional compiled filter predicate. Walks
721 // raw row bytes, zero allocation per row.
722 if matches!(
723 function,
724 AggFunc::Sum
725 | AggFunc::Avg
726 | AggFunc::Min
727 | AggFunc::Max
728 | AggFunc::CountDistinct
729 ) {
730 if let Some(Expr::Field(col)) = argument.as_ref() {
731 // Shape: Aggregate(SeqScan) or Aggregate(Filter(SeqScan))
732 let (table_opt, pred_opt): (Option<&str>, Option<&Expr>) =
733 match input.as_ref() {
734 PlanNode::SeqScan { table } => (Some(table.as_str()), None),
735 PlanNode::Filter {
736 input: inner,
737 predicate,
738 } => {
739 if let PlanNode::SeqScan { table } = inner.as_ref() {
740 (Some(table.as_str()), Some(predicate))
741 } else {
742 (None, None)
743 }
744 }
745 _ => (None, None),
746 };
747 if let Some(table) = table_opt {
748 if let Some(result) =
749 self.agg_single_col_fast(table, col, *function, pred_opt)?
750 {
751 return Ok(result);
752 }
753 }
754 }
755 }
756
757 // Fast path: Project(Limit(Filter(SeqScan))) — stream, decode
758 // only projected columns, stop once we hit the limit.
759 // (Handled in the Project branch; this branch only fires when
760 // the aggregate is the outer node.)
761 let result = self.dispatch_mut(input)?;
762 match result {
763 QueryResult::Rows { columns, rows } => {
764 aggregate_rows(*function, argument.as_ref(), &columns, &rows)
765 }
766 _ => Err("aggregate requires row input".into()),
767 }
768 }
769
770 PlanNode::Insert {
771 table,
772 rows,
773 returning,
774 } => {
775 // Build + validate EVERY row before inserting any, so a bad
776 // row (unknown/missing/uncoercible field) aborts the whole
777 // statement without a partial write. The WAL fsync happens
778 // once at statement end, so N rows = N appends + 1 fsync.
779 let mut returning_columns: Vec<String> = Vec::new();
780 let all_values: Vec<Vec<Value>> = {
781 let schema = self
782 .catalog
783 .schema(table)
784 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
785 if *returning {
786 returning_columns = schema.columns.iter().map(|c| c.name.clone()).collect();
787 }
788 let defaults = self.catalog.column_defaults(table).unwrap_or(&[]);
789 let auto = self.catalog.auto_columns(table).unwrap_or(&[]);
790 let mut all = Vec::with_capacity(rows.len());
791 for assignments in rows {
792 let mut values = vec![Value::Empty; schema.columns.len()];
793 for a in assignments {
794 let idx = schema.column_index(&a.field).ok_or_else(|| {
795 QueryError::ColumnNotFound {
796 table: String::new(),
797 column: a.field.clone(),
798 }
799 })?;
800 let raw = literal_to_value(&a.value)?;
801 values[idx] = coerce_value(raw, &schema.columns[idx])?;
802 }
803 // Fill any column left unset by this row from its
804 // declared default (applied before the required check,
805 // so a default satisfies a required column).
806 for (i, slot) in values.iter_mut().enumerate() {
807 if slot.is_empty() {
808 if let Some(Some(d)) = defaults.get(i) {
809 *slot = d.clone();
810 }
811 }
812 }
813 for col in &schema.columns {
814 let pos = col.position as usize;
815 // Auto columns are exempt from the required check —
816 // they are filled from the sequence just below.
817 let is_auto = auto.get(pos).copied().unwrap_or(false);
818 if col.required && !is_auto && matches!(values[pos], Value::Empty) {
819 return Err(QueryError::Execution(format!(
820 "column '{}' is required but no value was provided",
821 col.name
822 )));
823 }
824 }
825 all.push(values);
826 }
827 all
828 };
829 // Assign auto-increment columns now that the immutable
830 // schema/defaults/auto borrows are released. Done here (not in
831 // the build loop) so the assigned ids land in `all_values` and
832 // flow back through `returning`.
833 let mut all_values = all_values;
834 for values in all_values.iter_mut() {
835 self.catalog
836 .assign_auto_columns(table, values)
837 .map_err(QueryError::from_storage_io)?;
838 }
839 // Charge the materialized batch against the per-query memory
840 // budget before inserting — keeps multi-row insert consistent
841 // with every other full-materialization point (sort/join/group)
842 // and bounds embedded callers (the server also caps the query
843 // string at 1 MB, but embedded callers have no such limit).
844 self.charge_rows(&all_values)?;
845 let n = all_values.len() as u64;
846 for values in &all_values {
847 self.catalog
848 .insert(table, values)
849 .map_err(QueryError::from_storage_io)?;
850 }
851 self.view_registry
852 .mark_dependents_dirty(table)
853 .map_err(QueryError::from_storage_io)?;
854 if *returning {
855 Ok(QueryResult::Rows {
856 columns: returning_columns,
857 rows: all_values,
858 })
859 } else {
860 Ok(QueryResult::Modified(n))
861 }
862 }
863
864 PlanNode::Upsert {
865 table,
866 key_column,
867 assignments,
868 on_conflict,
869 } => {
870 let (mut values, key_idx) = {
871 let schema = self
872 .catalog
873 .schema(table)
874 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
875 let mut values = vec![Value::Empty; schema.columns.len()];
876 for a in assignments {
877 let idx = schema.column_index(&a.field).ok_or_else(|| {
878 QueryError::ColumnNotFound {
879 table: String::new(),
880 column: a.field.clone(),
881 }
882 })?;
883 let raw = literal_to_value(&a.value)?;
884 values[idx] = coerce_value(raw, &schema.columns[idx])?;
885 }
886 // Apply column defaults for the insert path, same as a plain
887 // insert (applied before the required-column check).
888 let defaults = self.catalog.column_defaults(table).unwrap_or(&[]);
889 for (i, slot) in values.iter_mut().enumerate() {
890 if slot.is_empty() {
891 if let Some(Some(d)) = defaults.get(i) {
892 *slot = d.clone();
893 }
894 }
895 }
896 let auto = self.catalog.auto_columns(table).unwrap_or(&[]);
897 for col in &schema.columns {
898 let pos = col.position as usize;
899 // Auto columns are exempt from the required check, same
900 // as the Insert arm: they are filled from the sequence
901 // on the insert branch below.
902 let is_auto = auto.get(pos).copied().unwrap_or(false);
903 if col.required && !is_auto && matches!(values[pos], Value::Empty) {
904 return Err(QueryError::Execution(format!(
905 "column '{}' is required but no value was provided",
906 col.name
907 )));
908 }
909 }
910 let key_idx = schema
911 .column_index(key_column)
912 .ok_or_else(|| format!("key column '{key_column}' not found"))?;
913 (values, key_idx)
914 };
915
916 // Upsert requires the `on` column to be unique — otherwise
917 // there is no well-defined row to overwrite and a plain
918 // insert could silently create duplicate keys.
919 if self.catalog.is_index_unique(table, key_column) != Some(true) {
920 return Err(QueryError::Execution(format!(
921 "upsert on .{key_column} requires a unique column (declare it with \
922 `unique {key_column}: <type>` or `alter {table} add unique .{key_column}`)"
923 )));
924 }
925
926 let key_value = values[key_idx].clone();
927
928 // Probe the unique index for a conflict.
929 let existing = {
930 let tbl = self
931 .catalog
932 .get_table(table)
933 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
934 // The key column is guaranteed unique above, so this
935 // returns at most one matching row.
936 let rids = tbl.index_lookup_all(key_column, &key_value);
937 // Overflow safety (P0-3): reassemble via `tbl.get` so an
938 // upsert conflict row with a spilled column is read in full.
939 match rids.into_iter().next() {
940 Some(rid) => tbl
941 .get(rid)
942 .map_err(QueryError::from_storage_io)?
943 .map(|row| (rid, row)),
944 None => None,
945 }
946 };
947
948 if let Some((rid, mut existing_row)) = existing {
949 // Conflict: apply on_conflict assignments (or all non-key if empty).
950 let update_assignments = if on_conflict.is_empty() {
951 assignments
952 } else {
953 on_conflict
954 };
955 let changed_cols: Vec<usize> = {
956 let schema = self
957 .catalog
958 .schema(table)
959 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
960 let mut indices = Vec::new();
961 for a in update_assignments {
962 let idx = schema.column_index(&a.field).ok_or_else(|| {
963 QueryError::ColumnNotFound {
964 table: String::new(),
965 column: a.field.clone(),
966 }
967 })?;
968 if idx != key_idx {
969 // Coerce to the target column type, same as the
970 // UPDATE and INSERT paths — an int→float literal
971 // here would otherwise persist as raw i64 bits
972 // (#118 corruption on the upsert conflict path).
973 existing_row[idx] =
974 coerce_value(literal_to_value(&a.value)?, &schema.columns[idx])
975 .map_err(QueryError::TypeError)?;
976 indices.push(idx);
977 }
978 }
979 indices
980 };
981 self.catalog
982 .update_hinted(table, rid, &existing_row, Some(&changed_cols))
983 .map_err(QueryError::from_storage_io)?;
984 self.view_registry
985 .mark_dependents_dirty(table)
986 .map_err(QueryError::from_storage_io)?;
987 Ok(QueryResult::Modified(1))
988 } else {
989 // No conflict: insert. This branch creates a row, so it
990 // owes that row the same `auto` ids a plain `insert` would
991 // give it. Skipping this wrote Value::Empty into the column
992 // the user declared `unique auto`, and because several
993 // NULLs coexist happily in a unique index nothing rejected
994 // it: repeated upserts silently accumulated rows with a
995 // NULL primary key.
996 //
997 // After the conflict probe, not before: the probe has to
998 // look up the key the caller supplied, and assigning first
999 // would hand a freshly minted id to a lookup that then
1000 // matches nothing and inserts a duplicate. The conflict
1001 // branch above deliberately does not assign — an existing
1002 // row keeps the key it already has.
1003 self.catalog
1004 .assign_auto_columns(table, &mut values)
1005 .map_err(QueryError::from_storage_io)?;
1006 self.catalog
1007 .insert(table, &values)
1008 .map_err(QueryError::from_storage_io)?;
1009 self.view_registry
1010 .mark_dependents_dirty(table)
1011 .map_err(QueryError::from_storage_io)?;
1012 Ok(QueryResult::Modified(1))
1013 }
1014 }
1015
1016 PlanNode::Update {
1017 input,
1018 table,
1019 assignments,
1020 returning,
1021 } => {
1022 // Mission C Phase 3: resolve assignments against a borrowed
1023 // schema, then drop the borrow before the mutation loop.
1024 // Try literal-only path first; fall back to per-row expression
1025 // evaluation if any assignment contains a non-literal expression
1026 // (e.g., `age := .age + 1`).
1027 let (col_indices, literal_vals, target_cols): (
1028 Vec<usize>,
1029 Option<Vec<Value>>,
1030 Vec<ColumnDef>,
1031 ) = {
1032 let schema_ref = self
1033 .catalog
1034 .schema(table)
1035 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
1036 let indices: Vec<usize> = assignments
1037 .iter()
1038 .map(|a| {
1039 schema_ref.column_index(&a.field).ok_or_else(|| {
1040 QueryError::ColumnNotFound {
1041 table: String::new(),
1042 column: a.field.clone(),
1043 }
1044 })
1045 })
1046 .collect::<Result<_, _>>()?;
1047 // The target column defs (aligned with `assignments`), owned
1048 // so the per-row expression path can coerce without holding a
1049 // catalog borrow across the mutation loop.
1050 let target_cols: Vec<ColumnDef> = indices
1051 .iter()
1052 .map(|&idx| schema_ref.columns[idx].clone())
1053 .collect();
1054 // Resolve each assignment to a literal value. If any is a
1055 // non-literal expression, fall back (None) to the per-row
1056 // expression-eval path below.
1057 let raw_vals: Result<Vec<Value>, _> = assignments
1058 .iter()
1059 .map(|a| literal_to_value(&a.value))
1060 .collect();
1061 // Coerce each literal to its target column's declared type
1062 // before it can reach the byte-patch fast path (the same
1063 // coercion the INSERT path applies). Without this, an int
1064 // assigned to a float column is written as raw i64 bits
1065 // (#118 silent corruption) and a str assigned to a
1066 // fixed-size column reaches `unreachable!` and aborts the
1067 // whole server (#117 remote DoS). A genuine type mismatch
1068 // is a hard error to the client, not an expr-path fallback.
1069 let coerced = match raw_vals {
1070 Ok(raws) => {
1071 let mut out = Vec::with_capacity(raws.len());
1072 for (raw, &idx) in raws.into_iter().zip(indices.iter()) {
1073 out.push(
1074 coerce_value(raw, &schema_ref.columns[idx])
1075 .map_err(QueryError::TypeError)?,
1076 );
1077 }
1078 Some(out)
1079 }
1080 Err(_) => None,
1081 };
1082 (indices, coerced, target_cols)
1083 };
1084 let resolved_assignments: Option<Vec<(usize, Value)>> =
1085 literal_vals.map(|vals| col_indices.iter().copied().zip(vals).collect());
1086
1087 // Mission C Phase 2: the hint Table::update_hinted needs to
1088 // decide whether to read the old row for index diff.
1089 let changed_cols: Vec<usize> = col_indices.clone();
1090
1091 // ── RETURNING path ──────────────────────────────────────
1092 // `returning` materializes the post-update row image, so the
1093 // byte-patch / fused fast paths (which never decode a row)
1094 // can't serve it. Take the generic decode→mutate→collect
1095 // route. Opt-in only: when `returning` is false every path
1096 // below is byte-for-byte unchanged.
1097 if *returning {
1098 let columns: Vec<String> = {
1099 let schema_ref = self
1100 .catalog
1101 .schema(table)
1102 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
1103 schema_ref.columns.iter().map(|c| c.name.clone()).collect()
1104 };
1105 let matching_rids = self.collect_rids_for_mutation(input, table)?;
1106 let mut out_rows: Vec<Vec<Value>> = Vec::with_capacity(matching_rids.len());
1107 // Cancellation is safe while collecting the target set, but
1108 // once row writes start this executor has no statement-level
1109 // savepoint. Check at the mutation boundary and then apply the
1110 // full set without mid-loop cancellation; returning an error
1111 // after a logged prefix would violate statement atomicity and
1112 // is especially unsafe inside an explicit transaction.
1113 crate::cancel::check()?;
1114 // Same two-pass shape as the non-returning expression path
1115 // below: compute every new row image first, so an
1116 // arithmetic fault refuses the statement instead of leaving
1117 // the rows before it written.
1118 let mut pending_rids = Vec::with_capacity(matching_rids.len());
1119 for rid in matching_rids {
1120 let mut row = match self
1121 .catalog
1122 .get(table, rid)
1123 .map_err(QueryError::from_storage_io)?
1124 {
1125 Some(r) => r,
1126 None => continue,
1127 };
1128 match &resolved_assignments {
1129 // Literal path: apply the pre-coerced values.
1130 Some(resolved) => {
1131 for (idx, val) in resolved.iter() {
1132 row[*idx] = val.clone();
1133 }
1134 }
1135 // Expression path: evaluate each RHS against the
1136 // (progressively mutated) row, then coerce to the
1137 // target column type before writing — same guard the
1138 // literal path gets, matching the non-returning expr
1139 // path exactly (#117/#118 on computed assignments).
1140 None => {
1141 for (i, asgn) in assignments.iter().enumerate() {
1142 let val = eval_expr(&asgn.value, &row, &columns);
1143 row[col_indices[i]] = coerce_value(val, &target_cols[i])
1144 .map_err(QueryError::TypeError)?;
1145 }
1146 }
1147 }
1148 pending_rids.push(rid);
1149 out_rows.push(row);
1150 }
1151 if let Some(message) = take_arith_fault() {
1152 return Err(QueryError::Execution(message));
1153 }
1154 self.charge_rows(&out_rows)?;
1155 for (rid, row) in pending_rids.iter().zip(&out_rows) {
1156 self.catalog
1157 .update_hinted(table, *rid, row, Some(&changed_cols))
1158 .map_err(QueryError::from_storage_io)?;
1159 }
1160 self.view_registry
1161 .mark_dependents_dirty(table)
1162 .map_err(QueryError::from_storage_io)?;
1163 return Ok(QueryResult::Rows {
1164 columns,
1165 rows: out_rows,
1166 });
1167 }
1168
1169 // ── Fused scan+update for Update(Filter(SeqScan)) ────────
1170 // Perf sprint: instead of the two-pass collect-RIDs-then-loop
1171 // pattern (which pays one ensure_hot per matched row on the
1172 // second pass), fuse the predicate evaluation and in-place
1173 // byte-level mutation into a single heap walk. Same idea as
1174 // the fused scan_delete_matching path for deletes.
1175 if let Some(ref resolved_assignments) = resolved_assignments {
1176 if let PlanNode::Filter {
1177 input: inner,
1178 predicate,
1179 } = input.as_ref()
1180 {
1181 if let PlanNode::SeqScan { table: t } = inner.as_ref() {
1182 if t == table {
1183 // The fused primitive mutates during its scan and
1184 // cannot roll back a cancelled prefix. Honor an
1185 // already-triggered token before entering it, then
1186 // let the primitive finish atomically from the
1187 // query layer's perspective.
1188 crate::cancel::check()?;
1189 let fused_result = self.try_fused_scan_update(
1190 table,
1191 predicate,
1192 resolved_assignments,
1193 &changed_cols,
1194 );
1195 if let Some(result) = fused_result {
1196 return result;
1197 }
1198 }
1199 }
1200 }
1201 }
1202
1203 // Collect matching RowIds in a single pass.
1204 let matching_rids = self.collect_rids_for_mutation(input, table)?;
1205 // This is the last cancellable boundary before any row is
1206 // changed. Mutation loops below deliberately do not poll.
1207 crate::cancel::check()?;
1208
1209 // ── Literal-only fast paths ─────────────────────────────
1210 if let Some(ref resolved_assignments) = resolved_assignments {
1211 // Mission C Phase 4: in-place byte-patch fast path. If every
1212 // assignment targets a fixed-size non-null column AND none of
1213 // them is indexed, we can skip decode_row / Vec<Value> /
1214 // encode_row_into entirely and patch the row's raw bytes on
1215 // the hot page.
1216 let fast_patch: Option<Vec<FastPatch>> = if self
1217 .generic_path_forced("update-byte-patch")
1218 {
1219 None
1220 } else {
1221 let tbl = self
1222 .catalog
1223 .get_table(table)
1224 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
1225 let schema = tbl.schema();
1226 // Overflow safety (P0): byte-patching a v2 row with v1
1227 // offsets corrupts it. Overflow tables take the generic
1228 // reassembling `get` + `update_hinted` path below.
1229 let all_fixed_nonnull = !tbl.has_overflow_rows()
1230 && resolved_assignments.iter().all(|(idx, val)| {
1231 is_fixed_size(schema.columns[*idx].type_id) && !val.is_empty()
1232 });
1233 let no_indexed = !resolved_assignments
1234 .iter()
1235 .any(|(idx, _)| tbl.has_indexed_col(*idx));
1236
1237 if all_fixed_nonnull && no_indexed {
1238 let layout = RowLayout::new(schema);
1239 let bitmap_size = layout.bitmap_size();
1240 let patches: Vec<FastPatch> = resolved_assignments
1241 .iter()
1242 .map(|(idx, val)| {
1243 let fixed_off = layout
1244 .fixed_offset(*idx)
1245 .expect("is_fixed_size already checked");
1246 let field_off = 2 + bitmap_size + fixed_off;
1247 let bytes: FixedBytes = match val {
1248 Value::Int(v) => FixedBytes::I64(v.to_le_bytes()),
1249 Value::Float(v) => FixedBytes::F64(v.to_le_bytes()),
1250 Value::Bool(v) => FixedBytes::Bool(if *v { 1 } else { 0 }),
1251 Value::DateTime(v) => FixedBytes::I64(v.to_le_bytes()),
1252 Value::Uuid(v) => FixedBytes::Uuid(*v),
1253 _ => unreachable!("all_fixed_nonnull guard lied"),
1254 };
1255 FastPatch {
1256 field_off,
1257 bitmap_byte_off: 2 + idx / 8,
1258 bit_mask: 1u8 << (idx % 8),
1259 bytes,
1260 }
1261 })
1262 .collect();
1263 Some(patches)
1264 } else {
1265 None
1266 }
1267 };
1268
1269 if let Some(patches) = fast_patch {
1270 let mut count = 0u64;
1271 let mut fallback_rids: Vec<RowId> = Vec::new();
1272 for rid in &matching_rids {
1273 // Mission B2: WAL-log every patch so crash
1274 // recovery replays the update. Same mutation
1275 // closure as before — the wrapper just sandwiches
1276 // it between a hot-page read and a WAL append.
1277 //
1278 // A false return means the byte-patch was refused
1279 // (e.g. a v2/overflow row whose in-place layout the
1280 // fast path cannot compute, reachable on a legacy
1281 // heap where has_overflow_rows() under-reports). Do
1282 // NOT drop the row: push it to `fallback_rids` and
1283 // let the reassembling get + update_hinted path
1284 // apply it, mirroring the var-column fast path
1285 // below. The fast path is thus a pure optimization
1286 // that can never silently lose an update.
1287 let ok = self
1288 .catalog
1289 .update_row_bytes_logged(table, *rid, |row| {
1290 let base = row_body_base(row);
1291 for p in &patches {
1292 row[base + p.bitmap_byte_off] &= !p.bit_mask;
1293 let field_bytes = p.bytes.as_slice();
1294 row[base + p.field_off
1295 ..base + p.field_off + field_bytes.len()]
1296 .copy_from_slice(field_bytes);
1297 }
1298 })
1299 .map_err(QueryError::from_storage_io)?;
1300 if ok {
1301 count += 1;
1302 } else {
1303 fallback_rids.push(*rid);
1304 }
1305 }
1306 for rid in fallback_rids {
1307 let mut row = match self
1308 .catalog
1309 .get(table, rid)
1310 .map_err(QueryError::from_storage_io)?
1311 {
1312 Some(r) => r,
1313 None => continue,
1314 };
1315 for (idx, val) in resolved_assignments.iter() {
1316 row[*idx] = val.clone();
1317 }
1318 self.catalog
1319 .update_hinted(table, rid, &row, Some(&changed_cols))
1320 .map_err(QueryError::from_storage_io)?;
1321 count += 1;
1322 }
1323 self.view_registry
1324 .mark_dependents_dirty(table)
1325 .map_err(QueryError::from_storage_io)?;
1326 return Ok(QueryResult::Modified(count));
1327 }
1328
1329 // Mission C Phase 10: var-column in-place shrink fast path.
1330 let var_fast: Option<(usize, Option<Vec<u8>>)> = if self
1331 .generic_path_forced("update-var-shrink")
1332 {
1333 None
1334 } else {
1335 let tbl = self
1336 .catalog
1337 .get_table(table)
1338 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
1339 let schema = tbl.schema();
1340 // Overflow safety (P0/P0-2): the in-place var shrink
1341 // patch computes v1 offsets — never on a v2-capable
1342 // table. Falls through to the reassembling path.
1343 let is_single = resolved_assignments.len() == 1 && !tbl.has_overflow_rows();
1344 let is_var_col = is_single
1345 && !is_fixed_size(schema.columns[resolved_assignments[0].0].type_id);
1346 let no_indexed = !resolved_assignments
1347 .iter()
1348 .any(|(idx, _)| tbl.has_indexed_col(*idx));
1349
1350 if is_single && is_var_col && no_indexed {
1351 let (idx, val) = &resolved_assignments[0];
1352 let bytes_opt: Option<Vec<u8>> = match val {
1353 Value::Str(s) => Some(s.as_bytes().to_vec()),
1354 Value::Bytes(b) => Some(b.clone()),
1355 // A json column stores its PJ1 bytes as the var
1356 // payload (u32 length prefix + bytes, like Bytes),
1357 // so the in-place patch writes them verbatim.
1358 Value::Json(b) => Some(b.to_vec()),
1359 Value::Empty => None,
1360 _ => {
1361 return Err(QueryError::TypeError(format!(
1362 "cannot assign non-var value to var column '{}'",
1363 schema.columns[*idx].name
1364 )))
1365 }
1366 };
1367 Some((*idx, bytes_opt))
1368 } else {
1369 None
1370 }
1371 };
1372
1373 if let Some((col_idx, new_bytes_opt)) = var_fast {
1374 let new_bytes_ref: Option<&[u8]> = new_bytes_opt.as_deref();
1375 let mut count = 0u64;
1376 let mut fallback_rids: Vec<RowId> = Vec::new();
1377 for rid in &matching_rids {
1378 // Mission B2: logged variant so crash recovery
1379 // replays the shrink. On a false return (row
1380 // would have to grow), the rid is pushed to
1381 // `fallback_rids` and the slower `update_hinted`
1382 // path — which is already WAL-logged — picks it up.
1383 let ok = self
1384 .catalog
1385 .patch_var_col_logged(table, *rid, col_idx, new_bytes_ref)
1386 .map_err(QueryError::from_storage_io)?;
1387 if ok {
1388 count += 1;
1389 } else {
1390 fallback_rids.push(*rid);
1391 }
1392 }
1393 for rid in fallback_rids {
1394 let mut row = match self
1395 .catalog
1396 .get(table, rid)
1397 .map_err(QueryError::from_storage_io)?
1398 {
1399 Some(r) => r,
1400 None => continue,
1401 };
1402 for (idx, val) in resolved_assignments.iter() {
1403 row[*idx] = val.clone();
1404 }
1405 self.catalog
1406 .update_hinted(table, rid, &row, Some(&changed_cols))
1407 .map_err(QueryError::from_storage_io)?;
1408 count += 1;
1409 }
1410 self.view_registry
1411 .mark_dependents_dirty(table)
1412 .map_err(QueryError::from_storage_io)?;
1413 return Ok(QueryResult::Modified(count));
1414 }
1415
1416 // Generic literal path: decode row, apply literal values.
1417 let mut count = 0u64;
1418 for rid in matching_rids {
1419 let mut row = match self
1420 .catalog
1421 .get(table, rid)
1422 .map_err(QueryError::from_storage_io)?
1423 {
1424 Some(r) => r,
1425 None => continue,
1426 };
1427 for (idx, val) in resolved_assignments.iter() {
1428 row[*idx] = val.clone();
1429 }
1430 self.catalog
1431 .update_hinted(table, rid, &row, Some(&changed_cols))
1432 .map_err(QueryError::from_storage_io)?;
1433 count += 1;
1434 }
1435 self.view_registry
1436 .mark_dependents_dirty(table)
1437 .map_err(QueryError::from_storage_io)?;
1438 return Ok(QueryResult::Modified(count));
1439 } // end if let Some(resolved_assignments)
1440
1441 // ── Expression-based update path ────────────────────────
1442 // At least one assignment contains a non-literal expression
1443 // (e.g., `age := .age + 1`). Evaluate per-row.
1444 let col_names: Vec<String> = {
1445 let schema_ref = self
1446 .catalog
1447 .schema(table)
1448 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
1449 schema_ref.columns.iter().map(|c| c.name.clone()).collect()
1450 };
1451 // Every target row's new image is computed BEFORE any of them
1452 // is written. The expression evaluator reports an overflow or a
1453 // per-row zero divisor through `arith_fault` rather than through
1454 // its return value, and writing as we go would leave the rows
1455 // before the faulting one holding the `Value::Empty` it produced
1456 // instead. Two passes cost the same materialization the
1457 // `returning` path above already pays.
1458 let (mut pending_rids, mut pending_rows) = (
1459 Vec::with_capacity(matching_rids.len()),
1460 Vec::with_capacity(matching_rids.len()),
1461 );
1462 for rid in matching_rids {
1463 let mut row = match self
1464 .catalog
1465 .get(table, rid)
1466 .map_err(QueryError::from_storage_io)?
1467 {
1468 Some(r) => r,
1469 None => continue,
1470 };
1471 for (i, asgn) in assignments.iter().enumerate() {
1472 let val = eval_expr(&asgn.value, &row, &col_names);
1473 // Coerce to the target column type before writing, so a
1474 // computed int→float assignment stores f64 (not raw i64
1475 // bits, #118) and a str→fixed-col assignment returns a
1476 // typed error instead of hitting the encoder's
1477 // `unreachable!` and aborting the process (#117).
1478 row[col_indices[i]] =
1479 coerce_value(val, &target_cols[i]).map_err(QueryError::TypeError)?;
1480 }
1481 pending_rids.push(rid);
1482 pending_rows.push(row);
1483 }
1484 if let Some(message) = take_arith_fault() {
1485 return Err(QueryError::Execution(message));
1486 }
1487 self.charge_rows(&pending_rows)?;
1488 let mut count = 0u64;
1489 for (rid, row) in pending_rids.into_iter().zip(pending_rows) {
1490 self.catalog
1491 .update_hinted(table, rid, &row, Some(&changed_cols))
1492 .map_err(QueryError::from_storage_io)?;
1493 count += 1;
1494 }
1495 self.view_registry
1496 .mark_dependents_dirty(table)
1497 .map_err(QueryError::from_storage_io)?;
1498 Ok(QueryResult::Modified(count))
1499 }
1500
1501 PlanNode::Delete {
1502 input,
1503 table,
1504 returning,
1505 } => {
1506 // ── RETURNING path ──────────────────────────────────────
1507 // `returning` needs the pre-delete row image, so read each
1508 // matched row before removing it. The fused single-pass
1509 // delete primitives below never decode rows, so they can't
1510 // serve this. Opt-in only: when `returning` is false the
1511 // fast paths below are byte-for-byte unchanged.
1512 if *returning {
1513 let columns: Vec<String> = {
1514 let schema_ref = self
1515 .catalog
1516 .schema(table)
1517 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
1518 schema_ref.columns.iter().map(|c| c.name.clone()).collect()
1519 };
1520 let matching_rids = self.collect_rids_for_mutation(input, table)?;
1521 let mut out_rows: Vec<Vec<Value>> = Vec::with_capacity(matching_rids.len());
1522 // Cooperative cancellation of the pre-delete image read. The
1523 // actual removal below is a single batched `delete_many`, so
1524 // cancelling here happens before any row is deleted.
1525 let mut cancel = CancelCheck::new();
1526 for rid in &matching_rids {
1527 cancel.tick()?;
1528 if let Some(row) = self
1529 .catalog
1530 .get(table, *rid)
1531 .map_err(QueryError::from_storage_io)?
1532 {
1533 out_rows.push(row);
1534 }
1535 }
1536 crate::cancel::check()?;
1537 self.catalog
1538 .delete_many(table, &matching_rids)
1539 .map_err(QueryError::from_storage_io)?;
1540 self.view_registry
1541 .mark_dependents_dirty(table)
1542 .map_err(QueryError::from_storage_io)?;
1543 return Ok(QueryResult::Rows {
1544 columns,
1545 rows: out_rows,
1546 });
1547 }
1548
1549 // Mission C Phase 3: no schema clone — collect_rids_for_mutation
1550 // looks up schema internally when it needs one, and the mutation
1551 // loop doesn't need the schema at all.
1552 //
1553 // Mission C Phase 12: route bulk deletes through
1554 // `Catalog::delete_many`, which batches the btree leaf
1555 // compaction and shares one `ensure_hot` per row between
1556 // the index-key extraction and the slot delete. On
1557 // `delete_by_filter` (100K fixture, ~20K matches) that
1558 // removes ~4ms of pure `Vec::remove` memmove from the btree
1559 // maintenance phase.
1560 //
1561 // Mission C Phase 16: for the common `delete where ...`
1562 // shape (Filter(SeqScan)) — and the rarer "delete
1563 // everything" shape (SeqScan) — skip the two-pass
1564 // `collect_rids_for_mutation` + `delete_many` flow entirely.
1565 // The fused `scan_delete_matching` primitive walks the
1566 // heap exactly once, paying one `ensure_hot` per page
1567 // instead of per-row. That closes the last major gap on
1568 // the bench's `delete_by_filter` workload.
1569 // Overflow safety (P1): a v2-capable table cannot take the fused
1570 // raw-byte delete — the compiled predicate mis-reads spilled
1571 // columns. Route it through the reassembling collect-rids path.
1572 let skip_fused_delete = self.catalog.table_has_overflow(table)
1573 || self.generic_path_forced("delete-fused");
1574 if let PlanNode::Filter {
1575 input: inner,
1576 predicate,
1577 } = input.as_ref()
1578 {
1579 if let PlanNode::SeqScan { table: t } = inner.as_ref() {
1580 if t == table && !skip_fused_delete {
1581 let schema = self
1582 .catalog
1583 .schema(table)
1584 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
1585 let columns: Vec<String> =
1586 schema.columns.iter().map(|c| c.name.clone()).collect();
1587 let fast = FastLayout::new(schema);
1588 if let Some(compiled) = self.compile_predicate_unless_forced(
1589 "delete-fused:predicate",
1590 predicate,
1591 &columns,
1592 &fast,
1593 schema,
1594 ) {
1595 // Mission B2: logged variant so every
1596 // matched rid hits the WAL during the
1597 // single-pass scan. Structure of the
1598 // fused scan is unchanged — only the
1599 // hook closure now also appends.
1600 crate::cancel::check()?;
1601 let count = self
1602 .catalog
1603 .scan_delete_matching_logged(table, |data| compiled(data))
1604 .map_err(QueryError::from_storage_io)?;
1605 self.view_registry
1606 .mark_dependents_dirty(table)
1607 .map_err(QueryError::from_storage_io)?;
1608 return Ok(QueryResult::Modified(count));
1609 }
1610 }
1611 }
1612 } else if let PlanNode::SeqScan { table: t } = input.as_ref() {
1613 if t == table && !skip_fused_delete {
1614 // `delete from T` with no predicate — every live
1615 // row matches. One pass is still the right shape.
1616 // Mission B2: logged variant — see above.
1617 crate::cancel::check()?;
1618 let count = self
1619 .catalog
1620 .scan_delete_matching_logged(table, |_| true)
1621 .map_err(QueryError::from_storage_io)?;
1622 self.view_registry
1623 .mark_dependents_dirty(table)
1624 .map_err(QueryError::from_storage_io)?;
1625 return Ok(QueryResult::Modified(count));
1626 }
1627 }
1628
1629 let matching_rids = self.collect_rids_for_mutation(input, table)?;
1630 crate::cancel::check()?;
1631 let count = self
1632 .catalog
1633 .delete_many(table, &matching_rids)
1634 .map_err(QueryError::from_storage_io)?;
1635 self.view_registry
1636 .mark_dependents_dirty(table)
1637 .map_err(QueryError::from_storage_io)?;
1638 Ok(QueryResult::Modified(count))
1639 }
1640
1641 PlanNode::NestedProject { input, fields } => {
1642 // Resolve link traversals against the persistent catalog before
1643 // anything else, so child tables are concrete for the
1644 // dirty-view refresh and the assembly below.
1645 let resolved;
1646 let fields: &[NestedProjectField] = if nested_fields_have_via_link(fields) {
1647 let outer = scan_source_table(input).ok_or_else(|| {
1648 QueryError::Execution(
1649 "link traversal requires a plain aliased table scan as its parent"
1650 .into(),
1651 )
1652 })?;
1653 resolved = self.resolve_nested_via_links(fields, outer)?;
1654 &resolved
1655 } else {
1656 fields
1657 };
1658 // Auto-refresh dirty materialized views among the child
1659 // tables (at every nesting level) before the read-only
1660 // assembly runs.
1661 let mut child_tables = Vec::new();
1662 for field in fields {
1663 if let NestedProjectField::Nested(nested) = field {
1664 nested.visit_tables(&mut |table| child_tables.push(table.to_string()));
1665 }
1666 }
1667 for table in child_tables {
1668 if self.view_registry.is_dirty(&table) {
1669 self.refresh_view(&table)?;
1670 }
1671 }
1672 let parent = self.dispatch_mut(input)?;
1673 self.execute_nested_project(parent, fields)
1674 }
1675
1676 PlanNode::AliasScan { table, alias } => {
1677 // Mission E1.2: scan `table` and rename every output column
1678 // to `alias.field`. Used as a join leaf so downstream
1679 // NestedLoopJoin + Filter + Project nodes can resolve
1680 // `Expr::QualifiedField` lookups by direct column-name match.
1681 //
1682 // We don't bother with a fused zero-copy loop here yet — the
1683 // whole join path is nested-loop and correctness-first
1684 // (Phase E1.3 will introduce hash join and at that point we
1685 // can revisit whether to specialise AliasScan).
1686 let schema = self
1687 .catalog
1688 .schema(table)
1689 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?
1690 .clone();
1691 let columns: Vec<String> = schema
1692 .columns
1693 .iter()
1694 .map(|c| format!("{alias}.{}", c.name))
1695 .collect();
1696 let mut cancel = CancelCheck::new();
1697 let mut rows: Vec<Vec<Value>> = Vec::new();
1698 for item in self
1699 .catalog
1700 .scan(table)
1701 .map_err(QueryError::from_storage_io)?
1702 {
1703 let (_, row) = item.map_err(QueryError::from_storage_io)?;
1704 cancel.tick()?;
1705 rows.push(row);
1706 }
1707 Ok(QueryResult::Rows { columns, rows })
1708 }
1709
1710 PlanNode::NestedLoopJoin {
1711 left,
1712 right,
1713 on,
1714 kind,
1715 } => {
1716 // Materialise both sides. The executor ships two strategies:
1717 // 1. Hash join (E1.3) — when the `on` predicate is a
1718 // simple equi-predicate `left_col = right_col`, build a
1719 // FxHashMap<Value, Vec<row_idx>> over the right side
1720 // and probe with the left side. O(L + R) instead of
1721 // O(L × R). Handles Inner and LeftOuter.
1722 // 2. Nested loop (E1.2) — fallback for Cross, non-equi
1723 // predicates, or `on` expressions that reference
1724 // either side with something more complex than a
1725 // QualifiedField.
1726 let left_result = self.dispatch_mut(left)?;
1727 let right_result = self.dispatch_mut(right)?;
1728 let (left_columns, left_rows) = match left_result {
1729 QueryResult::Rows { columns, rows } => (columns, rows),
1730 _ => return Err("join left side must produce rows".into()),
1731 };
1732 let (right_columns, right_rows) = match right_result {
1733 QueryResult::Rows { columns, rows } => (columns, rows),
1734 _ => return Err("join right side must produce rows".into()),
1735 };
1736
1737 // WS2: byte-budget guard on the join build side. Charge both
1738 // materialized inputs before we build the hash table / probe;
1739 // the output is row-capped by check_join_limit below.
1740 self.charge_rows(&left_rows)?;
1741 self.charge_rows(&right_rows)?;
1742
1743 execute_materialized_join(
1744 left_columns,
1745 left_rows,
1746 right_columns,
1747 right_rows,
1748 on.as_ref(),
1749 *kind,
1750 self.nested_loop_pair_limit,
1751 )
1752 }
1753
1754 PlanNode::Distinct { input } => {
1755 let result = self.dispatch_mut(input)?;
1756 match result {
1757 QueryResult::Rows { columns, rows } => {
1758 let mut seen = std::collections::HashSet::new();
1759 let mut unique_rows = Vec::new();
1760 let mut cancel = CancelCheck::new();
1761 for row in rows {
1762 cancel.tick()?;
1763 if seen.insert(row.clone()) {
1764 unique_rows.push(row);
1765 }
1766 }
1767 Ok(QueryResult::Rows {
1768 columns,
1769 rows: unique_rows,
1770 })
1771 }
1772 other => Ok(other),
1773 }
1774 }
1775
1776 PlanNode::GroupBy {
1777 input,
1778 keys,
1779 aggregates,
1780 having,
1781 } => {
1782 if aggregates
1783 .iter()
1784 .any(|aggregate| aggregate.provenance_alias.is_some())
1785 {
1786 let input = self.materialize_rows_with_provenance(input)?;
1787 self.charge_rows(&input.rows)?;
1788 return exec_group_by_with_provenance(
1789 input,
1790 keys,
1791 aggregates,
1792 having,
1793 self.query_memory_limit(),
1794 );
1795 }
1796 let result = self.dispatch_mut(input)?;
1797 match result {
1798 QueryResult::Rows { columns, rows } => {
1799 // WS2: byte-budget guard on the GROUP BY input buffer
1800 // (the hash table is bounded by the input it groups).
1801 self.charge_rows(&rows)?;
1802 exec_group_by(columns, rows, keys, aggregates, having)
1803 }
1804 _ => Err("group by requires row input".into()),
1805 }
1806 }
1807
1808 PlanNode::CreateTable {
1809 name,
1810 fields,
1811 if_not_exists,
1812 } => {
1813 // Idempotency: a re-declared type is a clean no-op under
1814 // `if not exists`, and otherwise a PowQL-flavored error that
1815 // names the type (not the storage layer's generic "table").
1816 if self.catalog.schema(name).is_some() {
1817 if *if_not_exists {
1818 return Ok(QueryResult::Executed {
1819 message: format!("type '{name}' already exists (skipped)"),
1820 });
1821 }
1822 // "cannot" prefix keeps this on the server's
1823 // safe-to-forward allowlist (SAFE_ERROR_PREFIXES).
1824 return Err(QueryError::Execution(format!(
1825 "cannot create type '{name}': it already exists"
1826 )));
1827 }
1828 let columns: Vec<ColumnDef> = fields
1829 .iter()
1830 .enumerate()
1831 .map(|(i, f)| -> Result<ColumnDef, QueryError> {
1832 Ok(ColumnDef {
1833 name: f.name.clone(),
1834 type_id: type_name_to_id(&f.type_name)
1835 .map_err(QueryError::TypeError)?,
1836 required: f.required,
1837 position: i as u16,
1838 })
1839 })
1840 .collect::<Result<Vec<_>, _>>()?;
1841 // Coerce each literal default to its column's type now, so a
1842 // type mismatch (`count: int default "x"`) is rejected at DDL
1843 // time and the stored default is ready to drop into inserts.
1844 let mut defaults: Vec<Option<Value>> = vec![None; columns.len()];
1845 let mut auto_cols: Vec<bool> = vec![false; columns.len()];
1846 for (i, f) in fields.iter().enumerate() {
1847 if let Some(lit) = &f.default {
1848 let raw = literal_value_from(lit);
1849 defaults[i] = Some(coerce_value(raw, &columns[i])?);
1850 }
1851 if f.auto {
1852 // Auto-increment only makes sense on an integer column,
1853 // and combining it with a literal default is
1854 // contradictory (both want to supply the value).
1855 if columns[i].type_id != TypeId::Int {
1856 return Err(QueryError::TypeError(format!(
1857 "auto column '{}' must be of type int",
1858 f.name
1859 )));
1860 }
1861 if f.default.is_some() {
1862 return Err(QueryError::TypeError(format!(
1863 "auto column '{}' cannot also declare a default",
1864 f.name
1865 )));
1866 }
1867 auto_cols[i] = true;
1868 }
1869 }
1870 let schema = Schema {
1871 table_name: name.clone(),
1872 columns,
1873 };
1874 refuse_schema_with_no_room_for_a_row(&schema)?;
1875 self.catalog
1876 .create_table_full(schema, defaults, auto_cols)
1877 .map_err(QueryError::from_storage_io)?;
1878 // Declaring a field `unique` auto-creates a unique B+tree
1879 // index, which is where uniqueness is enforced on writes.
1880 for f in fields.iter().filter(|f| f.unique) {
1881 self.catalog
1882 .create_index_unique(name, &f.name, true)
1883 .map_err(QueryError::from_storage_io)?;
1884 }
1885 Ok(QueryResult::Created(name.clone()))
1886 }
1887
1888 PlanNode::CreateLink {
1889 owner,
1890 name,
1891 target,
1892 local_key,
1893 target_key,
1894 } => {
1895 self.create_link_from_parts(owner, name, target, local_key, target_key)?;
1896 Ok(QueryResult::Executed {
1897 message: format!("link '{name}' added to '{owner}'"),
1898 })
1899 }
1900
1901 PlanNode::DropLink {
1902 owner,
1903 name,
1904 if_exists,
1905 } => {
1906 if *if_exists && self.catalog.link(owner, name).is_none() {
1907 return Ok(QueryResult::Executed {
1908 message: format!("link '{name}' on '{owner}' does not exist (skipped)"),
1909 });
1910 }
1911 self.catalog
1912 .drop_link(owner, name)
1913 .map_err(QueryError::from_storage_io)?;
1914 // A cached plan may traverse the link that just went, and a
1915 // materialized view built over it can no longer be recomputed.
1916 if let Ok(mut cache) = self.plan_cache.lock() {
1917 cache.clear();
1918 }
1919 Ok(QueryResult::Executed {
1920 message: format!("link '{name}' dropped from '{owner}'"),
1921 })
1922 }
1923
1924 PlanNode::AlterTable { table, action } => match action {
1925 AlterAction::AddColumn {
1926 name,
1927 type_name,
1928 required,
1929 } => {
1930 let existing = self
1931 .catalog
1932 .schema(table)
1933 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
1934 let position = existing.columns.len() as u16;
1935 let col = ColumnDef {
1936 name: name.clone(),
1937 type_id: type_name_to_id(type_name).map_err(QueryError::TypeError)?,
1938 required: *required,
1939 position,
1940 };
1941 let mut widened = existing.clone();
1942 widened.columns.push(col.clone());
1943 refuse_schema_with_no_room_for_a_row(&widened)?;
1944 self.catalog
1945 .alter_table_add_column(table, col)
1946 .map_err(QueryError::from_storage_io)?;
1947 Ok(QueryResult::Executed {
1948 message: format!("column '{name}' added to '{table}'"),
1949 })
1950 }
1951 AlterAction::DropColumn { name, if_exists } => {
1952 // `if exists`: a missing column (or missing table) is a
1953 // no-op instead of an error.
1954 if *if_exists {
1955 let present = self
1956 .catalog
1957 .schema(table)
1958 .map(|s| s.column_index(name).is_some())
1959 .unwrap_or(false);
1960 if !present {
1961 return Ok(QueryResult::Executed {
1962 message: format!(
1963 "column '{name}' does not exist on '{table}' (skipped)"
1964 ),
1965 });
1966 }
1967 }
1968 self.catalog
1969 .alter_table_drop_column(table, name)
1970 .map_err(QueryError::from_storage_io)?;
1971 Ok(QueryResult::Executed {
1972 message: format!("column '{name}' dropped from '{table}'"),
1973 })
1974 }
1975 AlterAction::AddIndex {
1976 target,
1977 if_not_exists: _,
1978 } => {
1979 let IndexTarget::Column(column) = target else {
1980 let IndexTarget::JsonPath(path) = target else {
1981 unreachable!("index target variants are exhaustive")
1982 };
1983 if let Some(existing) = resolve_expression_index(&self.catalog, table, path)
1984 {
1985 return Ok(QueryResult::Executed {
1986 message: format!(
1987 "expression index {} on '{}' already exists (skipped)",
1988 existing.index_id, table
1989 ),
1990 });
1991 }
1992 crate::cancel::check()?;
1993 let index_id = self
1994 .catalog
1995 .create_expression_index_metadata(
1996 table,
1997 1,
1998 path.canonical_text(),
1999 path.clone(),
2000 false,
2001 )
2002 .map_err(QueryError::from_storage_io)?;
2003 return Ok(QueryResult::Executed {
2004 message: format!("expression index {index_id} on '{}' created", table),
2005 });
2006 };
2007 // `add index` is already idempotent (no-op if the index
2008 // exists), so `if not exists` is accepted for symmetry but
2009 // does not change behavior.
2010 crate::cancel::check()?;
2011 self.catalog
2012 .create_index(table, column)
2013 .map_err(QueryError::from_storage_io)?;
2014 Ok(QueryResult::Executed {
2015 message: format!("index on '{table}.{column}' created"),
2016 })
2017 }
2018 AlterAction::AddUnique {
2019 target,
2020 if_not_exists,
2021 } => {
2022 let IndexTarget::Column(column) = target else {
2023 let IndexTarget::JsonPath(path) = target else {
2024 unreachable!("index target variants are exhaustive")
2025 };
2026 if let Some(existing) = resolve_expression_index(&self.catalog, table, path)
2027 {
2028 if *if_not_exists {
2029 return Ok(QueryResult::Executed {
2030 message: format!(
2031 "expression index {} on '{}' already exists (skipped)",
2032 existing.index_id, table
2033 ),
2034 });
2035 }
2036 return Err(QueryError::Execution(format!(
2037 "cannot add unique expression index on {}: path already indexed",
2038 table
2039 )));
2040 }
2041 crate::cancel::check()?;
2042 let index_id = self
2043 .catalog
2044 .create_expression_index_metadata(
2045 table,
2046 1,
2047 path.canonical_text(),
2048 path.clone(),
2049 true,
2050 )
2051 .map_err(QueryError::from_storage_io)?;
2052 return Ok(QueryResult::Executed {
2053 message: format!(
2054 "unique expression index {index_id} on '{}' created",
2055 table
2056 ),
2057 });
2058 };
2059 // `if not exists`: an already-indexed column is a no-op
2060 // rather than the (default) "already indexed" error.
2061 if self.catalog.has_index(table, column) {
2062 if *if_not_exists {
2063 return Ok(QueryResult::Executed {
2064 message: format!(
2065 "index on '{table}.{column}' already exists (skipped)"
2066 ),
2067 });
2068 }
2069 // Upgrading an existing non-unique index in place is
2070 // intentionally rejected.
2071 return Err(QueryError::Execution(format!(
2072 "cannot add unique on {table}.{column}: column already indexed"
2073 )));
2074 }
2075 // Scan existing rows for duplicate (non-null) values
2076 // before creating the unique index.
2077 {
2078 let tbl = self
2079 .catalog
2080 .get_table(table)
2081 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
2082 let col_idx = tbl.schema().column_index(column).ok_or_else(|| {
2083 QueryError::ColumnNotFound {
2084 table: table.to_string(),
2085 column: column.clone(),
2086 }
2087 })?;
2088 let mut seen = std::collections::HashSet::new();
2089 let mut cancel = CancelCheck::new();
2090 for item in tbl.scan() {
2091 let (_, row) = item.map_err(QueryError::from_storage_io)?;
2092 cancel.tick()?;
2093 let v = &row[col_idx];
2094 if v.is_empty() {
2095 continue;
2096 }
2097 if !seen.insert(v.clone()) {
2098 return Err(QueryError::Execution(format!(
2099 "cannot add unique on {table}.{column}: \
2100 duplicate value {v:?} exists"
2101 )));
2102 }
2103 }
2104 }
2105 crate::cancel::check()?;
2106 self.catalog
2107 .create_index_unique(table, column, true)
2108 .map_err(QueryError::from_storage_io)?;
2109 Ok(QueryResult::Executed {
2110 message: format!("unique index on '{table}.{column}' created"),
2111 })
2112 }
2113 AlterAction::DropIndex { target, if_exists } => {
2114 let IndexTarget::JsonPath(path) = target else {
2115 return Err(QueryError::Execution(
2116 "dropping stored-column indexes is not supported".to_string(),
2117 ));
2118 };
2119 let Some(existing) = resolve_expression_index(&self.catalog, table, path)
2120 else {
2121 if *if_exists {
2122 return Ok(QueryResult::Executed {
2123 message: format!(
2124 "expression index on '{}' does not exist (skipped)",
2125 table
2126 ),
2127 });
2128 }
2129 return Err(QueryError::Execution(format!(
2130 "expression index on '{}' does not exist",
2131 table
2132 )));
2133 };
2134 crate::cancel::check()?;
2135 self.catalog
2136 .drop_expression_index(table, existing.index_id)
2137 .map_err(QueryError::from_storage_io)?;
2138 Ok(QueryResult::Executed {
2139 message: format!(
2140 "expression index {} on '{}' dropped",
2141 existing.index_id, table
2142 ),
2143 })
2144 }
2145 AlterAction::AddLink {
2146 name,
2147 target,
2148 local_key,
2149 target_key,
2150 } => {
2151 self.create_link_from_parts(table, name, target, local_key, target_key)?;
2152 Ok(QueryResult::Executed {
2153 message: format!("link '{name}' added to '{table}'"),
2154 })
2155 }
2156 },
2157
2158 PlanNode::DropTable { name, if_exists } => {
2159 // A materialized view is a registry entry plus a backing table
2160 // that happens to share its name. `drop` reaches only the
2161 // table, so it left the definition behind in `views.bin`: the
2162 // view stayed registered, stayed listed, was still marked dirty
2163 // by writes to its source, and every read of it reported a
2164 // missing table, across restarts. One DDL drops a view whole,
2165 // and this refusal is what points at it.
2166 if self.view_registry.is_view(name) {
2167 return Err(QueryError::Execution(format!(
2168 "'{name}' is a materialized view; use 'drop view {name}'"
2169 )));
2170 }
2171 if *if_exists && self.catalog.schema(name).is_none() {
2172 return Ok(QueryResult::Executed {
2173 message: format!("type '{name}' does not exist (skipped)"),
2174 });
2175 }
2176 self.catalog
2177 .drop_table(name)
2178 .map_err(QueryError::from_storage_io)?;
2179 // Dropping a table invalidates every view built over it just
2180 // as surely as mutating one does, and more permanently: the
2181 // rows a materialized view holds are now the only copy of data
2182 // whose source is gone. Without this, reading such a view kept
2183 // answering from that orphaned copy while `refresh` on the same
2184 // view already failed with "table not found" — the read and the
2185 // refresh disagreed about whether the view was still valid.
2186 // Marking dependents dirty makes the read take the refresh
2187 // path, so both now report the missing source instead of one
2188 // silently serving it.
2189 let views_affected = self
2190 .view_registry
2191 .mark_dependents_dirty(name)
2192 .map(|()| self.view_registry.dependents_of(name))
2193 .map_err(QueryError::from_storage_io)?;
2194 let message = if views_affected.is_empty() {
2195 format!("table '{name}' dropped")
2196 } else {
2197 let (subject, verb) = describe_view_list(&views_affected);
2198 format!(
2199 "table '{name}' dropped; {subject} {verb} no source and \
2200 will fail until dropped or recreated"
2201 )
2202 };
2203 Ok(QueryResult::Executed { message })
2204 }
2205
2206 PlanNode::ListTypes => self.introspect_list_types(),
2207
2208 PlanNode::Describe { table } => self.introspect_describe(table),
2209
2210 PlanNode::ListLinks => self.introspect_list_links(),
2211
2212 PlanNode::CreateView { name, query_text } => {
2213 self.create_view(name, query_text)?;
2214 Ok(QueryResult::Executed {
2215 message: format!("materialized view '{name}' created"),
2216 })
2217 }
2218
2219 PlanNode::RefreshView { name } => {
2220 self.refresh_view(name)?;
2221 Ok(QueryResult::Executed {
2222 message: format!("materialized view '{name}' refreshed"),
2223 })
2224 }
2225
2226 PlanNode::DropView { name, if_exists } => {
2227 if *if_exists && !self.view_registry.is_view(name) {
2228 return Ok(QueryResult::Executed {
2229 message: format!("view '{name}' does not exist (skipped)"),
2230 });
2231 }
2232 self.drop_view(name)?;
2233 Ok(QueryResult::Executed {
2234 message: format!("materialized view '{name}' dropped"),
2235 })
2236 }
2237
2238 PlanNode::Window { input, windows } => {
2239 let result = self.dispatch_mut(input)?;
2240 execute_window(result, windows, self.query_memory_limit)
2241 }
2242
2243 PlanNode::Union { left, right, all } => {
2244 let left_result = self.dispatch_mut(left)?;
2245 let right_result = self.dispatch_mut(right)?;
2246 let (left_cols, left_rows) = match left_result {
2247 QueryResult::Rows { columns, rows } => (columns, rows),
2248 _ => return Err("UNION requires query results on left side".into()),
2249 };
2250 let (_, right_rows) = match right_result {
2251 QueryResult::Rows { columns, rows } => (columns, rows),
2252 _ => return Err("UNION requires query results on right side".into()),
2253 };
2254 Ok(QueryResult::Rows {
2255 columns: left_cols,
2256 rows: union_rows(left_rows, right_rows, *all)?,
2257 })
2258 }
2259
2260 PlanNode::Explain { input } => {
2261 // Every execute entry point runs lower_unindexed_scans before
2262 // dispatch and lowering recurses into Explain, so `input` is
2263 // already the plan that will actually run.
2264 let text = format_plan_tree(&self.catalog, input, 0);
2265 Ok(QueryResult::Rows {
2266 columns: vec!["plan".to_string()],
2267 rows: text
2268 .lines()
2269 .map(|line| vec![Value::Str(line.to_string())])
2270 .collect(),
2271 })
2272 }
2273
2274 PlanNode::Begin => {
2275 if self.in_transaction {
2276 return Err(QueryError::Execution(
2277 "already in a transaction (nested transactions not supported)".into(),
2278 ));
2279 }
2280 self.catalog
2281 .begin_transaction()
2282 .map_err(QueryError::from_storage_io)?;
2283 self.in_transaction = true;
2284 Ok(QueryResult::Executed {
2285 message: "transaction started".to_string(),
2286 })
2287 }
2288
2289 PlanNode::Commit => {
2290 if !self.in_transaction {
2291 return Err(QueryError::Execution(
2292 "no active transaction to commit".into(),
2293 ));
2294 }
2295 self.catalog
2296 .commit_transaction()
2297 .map_err(QueryError::from_storage_io)?;
2298 self.in_transaction = false;
2299 Ok(QueryResult::Executed {
2300 message: "transaction committed".to_string(),
2301 })
2302 }
2303
2304 PlanNode::Rollback => {
2305 if !self.in_transaction {
2306 return Err(QueryError::Execution(
2307 "no active transaction to roll back".into(),
2308 ));
2309 }
2310 self.rollback_transaction_preserving_wal_archive()
2311 }
2312
2313 PlanNode::IndexScan { table, column, key } => {
2314 let key_value = literal_to_value(key)?;
2315 let tbl = self
2316 .catalog
2317 .get_table(table)
2318 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
2319 let columns: Vec<String> = tbl
2320 .schema()
2321 .columns
2322 .iter()
2323 .map(|c| c.name.clone())
2324 .collect();
2325
2326 // Fast path: the table has a B-tree on this column.
2327 // Uses index_lookup_all to return ALL matching rows for
2328 // both unique and non-unique indexes.
2329 if tbl.has_index(column) {
2330 let rids = tbl.index_lookup_all(column, &key_value);
2331 let mut rows: Vec<Vec<Value>> = Vec::with_capacity(rids.len());
2332 let mut cancel = CancelCheck::new();
2333 for rid in rids {
2334 cancel.tick()?;
2335 // Overflow safety (P0-3/P0-4): `tbl.get` reassembles
2336 // spilled columns; the old `heap.get` + `decode_row`
2337 // returned Empty / wrapped a >= 64KB value.
2338 if let Some(row) = tbl.get(rid).map_err(QueryError::from_storage_io)? {
2339 rows.push(row);
2340 }
2341 }
2342 return Ok(QueryResult::Rows { columns, rows });
2343 }
2344
2345 // Fallback: no index on this column. The planner emits IndexScan
2346 // eagerly (it has no visibility into which columns are indexed
2347 // at plan time), so here we must behave like SeqScan+Filter on
2348 // `.col = literal`: return *all* matching rows, not just the
2349 // first one. A non-indexed column isn't necessarily unique.
2350 // We compile the eq predicate once and stream without any
2351 // per-row decode for non-matching rows.
2352 let schema = tbl.schema();
2353 let fast = FastLayout::new(schema);
2354 let synth_pred = Expr::BinaryOp(
2355 Box::new(Expr::Field(column.clone())),
2356 BinOp::Eq,
2357 Box::new(key.clone()),
2358 );
2359 // Overflow safety (P0-4/P1): the raw compiled scan drops/mis-reads
2360 // spilled columns; a v2-capable table uses the decoded scan below.
2361 if !tbl.has_overflow_rows() {
2362 if let Some(compiled) = self.compile_predicate_unless_forced(
2363 "index-scan-scan-fallback:predicate",
2364 &synth_pred,
2365 &columns,
2366 &fast,
2367 schema,
2368 ) {
2369 // Mission F: skip the first 4 Vec doublings.
2370 let mut rows: Vec<Vec<Value>> = Vec::with_capacity(64);
2371 for_each_row_raw_cancellable(&self.catalog, table, |_rid, data| {
2372 if compiled(data) {
2373 rows.push(decode_row(schema, data));
2374 }
2375 })?;
2376 return Ok(QueryResult::Rows { columns, rows });
2377 }
2378 }
2379
2380 // Last resort: slow eq-check on materialised rows.
2381 let col_idx =
2382 schema
2383 .column_index(column)
2384 .ok_or_else(|| QueryError::ColumnNotFound {
2385 table: String::new(),
2386 column: column.clone(),
2387 })?;
2388 let mut cancel = CancelCheck::new();
2389 let mut rows: Vec<Vec<Value>> = Vec::new();
2390 for item in tbl.scan() {
2391 let (_, row) = item.map_err(QueryError::from_storage_io)?;
2392 cancel.tick()?;
2393 if row[col_idx] == key_value {
2394 rows.push(row);
2395 }
2396 }
2397 Ok(QueryResult::Rows { columns, rows })
2398 }
2399
2400 PlanNode::RangeScan {
2401 table,
2402 column,
2403 start,
2404 end,
2405 } => {
2406 let tbl = self
2407 .catalog
2408 .get_table(table)
2409 .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
2410 let columns: Vec<String> = tbl
2411 .schema()
2412 .columns
2413 .iter()
2414 .map(|c| c.name.clone())
2415 .collect();
2416 let schema = tbl.schema();
2417
2418 let start_val = match start {
2419 Some((expr, _)) => Some(literal_to_value(expr)?),
2420 None => None,
2421 };
2422 let end_val = match end {
2423 Some((expr, _)) => Some(literal_to_value(expr)?),
2424 None => None,
2425 };
2426 let start_inclusive = start.as_ref().map(|(_, inc)| *inc).unwrap_or(true);
2427 let end_inclusive = end.as_ref().map(|(_, inc)| *inc).unwrap_or(true);
2428
2429 // Non-unique index: walk the composite (value, rid) leaf
2430 // chain between prefix bounds, fetch each row from the heap,
2431 // and recheck. The recheck enforces exclusive bounds
2432 // (range_rids is inclusive) and defensively skips any decoded
2433 // null (nulls are never indexed, so they must not match).
2434 if tbl.is_index_unique(column) == Some(false) {
2435 if let Some(btree) = tbl.index(column) {
2436 if start_val.is_some() || end_val.is_some() {
2437 let col_idx = schema.column_index(column).ok_or_else(|| {
2438 QueryError::ColumnNotFound {
2439 table: String::new(),
2440 column: column.clone(),
2441 }
2442 })?;
2443 let rids = btree.range_rids(start_val.as_ref(), end_val.as_ref());
2444 let mut rows: Vec<Vec<Value>> = Vec::with_capacity(rids.len());
2445 let mut cancel = CancelCheck::new();
2446 for rid in rids {
2447 cancel.tick()?;
2448 // Overflow safety (P0-3): reassemble spilled cols.
2449 if let Some(row) =
2450 tbl.get(rid).map_err(QueryError::from_storage_io)?
2451 {
2452 if !row[col_idx].is_empty()
2453 && range_matches(
2454 &row[col_idx],
2455 &start_val,
2456 start_inclusive,
2457 &end_val,
2458 end_inclusive,
2459 )
2460 {
2461 rows.push(row);
2462 }
2463 }
2464 }
2465 return Ok(QueryResult::Rows { columns, rows });
2466 }
2467 }
2468 }
2469
2470 // Range scans use the btree fast path for unique indexes,
2471 // walking raw column-value keys directly.
2472 if tbl.is_index_unique(column) == Some(true) {
2473 if let Some(btree) = tbl.index(column) {
2474 let hits: Vec<(Value, RowId)> = match (&start_val, &end_val) {
2475 (Some(s), Some(e)) => btree.range(s, e).collect(),
2476 (Some(s), None) => btree.range_from(s),
2477 (None, Some(e)) => btree.range_to(e),
2478 (None, None) => {
2479 let mut cancel = CancelCheck::new();
2480 let mut rows: Vec<Vec<Value>> = Vec::new();
2481 for item in tbl.scan() {
2482 let (_, row) = item.map_err(QueryError::from_storage_io)?;
2483 cancel.tick()?;
2484 rows.push(row);
2485 }
2486 return Ok(QueryResult::Rows { columns, rows });
2487 }
2488 };
2489 let mut rows: Vec<Vec<Value>> = Vec::with_capacity(hits.len());
2490 let mut cancel = CancelCheck::new();
2491 for (key, rid) in hits {
2492 cancel.tick()?;
2493 if !start_inclusive {
2494 if let Some(ref s) = start_val {
2495 if &key == s {
2496 continue;
2497 }
2498 }
2499 }
2500 if !end_inclusive {
2501 if let Some(ref e) = end_val {
2502 if &key == e {
2503 continue;
2504 }
2505 }
2506 }
2507 // Overflow safety (P0-3): reassemble spilled cols.
2508 if let Some(row) = tbl.get(rid).map_err(QueryError::from_storage_io)? {
2509 rows.push(row);
2510 }
2511 }
2512 return Ok(QueryResult::Rows { columns, rows });
2513 }
2514 }
2515
2516 // Fallback: no index — synthesize range predicate and scan.
2517 // Overflow safety (P0-4): v2-capable tables use the decoded
2518 // last-resort scan below.
2519 let fast = FastLayout::new(schema);
2520 let synth = synthesize_range_predicate(column, start, end);
2521 if !tbl.has_overflow_rows() {
2522 if let Some(compiled) = self.compile_predicate_unless_forced(
2523 "range-scan-scan-fallback:predicate",
2524 &synth,
2525 &columns,
2526 &fast,
2527 schema,
2528 ) {
2529 let mut rows: Vec<Vec<Value>> = Vec::with_capacity(64);
2530 for_each_row_raw_cancellable(&self.catalog, table, |_rid, data| {
2531 if compiled(data) {
2532 rows.push(decode_row(schema, data));
2533 }
2534 })?;
2535 return Ok(QueryResult::Rows { columns, rows });
2536 }
2537 }
2538
2539 let col_idx =
2540 schema
2541 .column_index(column)
2542 .ok_or_else(|| QueryError::ColumnNotFound {
2543 table: String::new(),
2544 column: column.clone(),
2545 })?;
2546 let mut cancel = CancelCheck::new();
2547 let mut rows: Vec<Vec<Value>> = Vec::new();
2548 for item in tbl.scan() {
2549 let (_, row) = item.map_err(QueryError::from_storage_io)?;
2550 cancel.tick()?;
2551 if range_matches(
2552 &row[col_idx],
2553 &start_val,
2554 start_inclusive,
2555 &end_val,
2556 end_inclusive,
2557 ) {
2558 rows.push(row);
2559 }
2560 }
2561 Ok(QueryResult::Rows { columns, rows })
2562 }
2563 }
2564 }
2565
2566 // ─── Materialized view operations ──────────────────────────────────────
2567 //
2568 // See [`parse_stored_view_source`] below for why a stored source is parsed
2569 // before anything is computed from it.
2570
2571 /// Create a materialized view: execute the source query, store results
2572 /// in a new backing table, and register the view.
2573 fn create_view(&mut self, name: &str, query_text: &str) -> Result<(), QueryError> {
2574 if self.view_registry.is_view(name) {
2575 return Err(QueryError::ViewError(format!(
2576 "materialized view '{name}' already exists"
2577 )));
2578 }
2579 // Execute the source query to get the result set.
2580 let result = self.execute_powql(query_text)?;
2581 let (columns, rows) = match result {
2582 QueryResult::Rows { columns, rows } => (columns, rows),
2583 _ => return Err("view source query must be a SELECT".into()),
2584 };
2585 // Derive a schema for the backing table from the query result columns.
2586 let schema = self.derive_view_schema(name, &columns, &rows, query_text)?;
2587 // Create the backing table and insert the result rows.
2588 crate::cancel::check()?;
2589 self.catalog
2590 .create_table(schema)
2591 .map_err(QueryError::from_storage_io)?;
2592 for row in &rows {
2593 self.catalog
2594 .insert(name, row)
2595 .map_err(QueryError::from_storage_io)?;
2596 }
2597 // Determine which base tables this view depends on by parsing the query.
2598 let depends_on = self.extract_view_deps(name, query_text)?;
2599 // Same ordering rule as `refresh_view`: registering the view CLEAN is a
2600 // durable claim that the rows just inserted are its current contents,
2601 // and `register` fsyncs `views.bin` while those rows are still only in
2602 // the WAL buffer. A crash in between would leave a registered, clean,
2603 // EMPTY view answering queries with zero rows and no error.
2604 self.commit_statement()?;
2605 self.view_registry
2606 .register(ViewDef {
2607 name: name.to_string(),
2608 query: query_text.to_string(),
2609 depends_on,
2610 dirty: false,
2611 })
2612 .map_err(QueryError::from_storage_io)?;
2613 Ok(())
2614 }
2615
2616 /// Refresh a materialized view: re-execute its source query and replace
2617 /// the backing table's contents.
2618 pub(in crate::executor) fn refresh_view(&mut self, name: &str) -> Result<(), QueryError> {
2619 let def = self
2620 .view_registry
2621 .get(name)
2622 .ok_or_else(|| format!("materialized view '{name}' not found"))?;
2623 let query_text = def.query.clone();
2624 // The stored source has to be readable before anything is recomputed
2625 // from it. Re-executing it blind is what made a view with an
2626 // unparseable source silently keep serving its old rows.
2627 parse_stored_view_source(name, &query_text)?;
2628 // Execute the source query.
2629 let result = self.execute_powql(&query_text)?;
2630 let (columns, rows) = match result {
2631 QueryResult::Rows { columns, rows } => (columns, rows),
2632 _ => return Err("view source query must be a SELECT".into()),
2633 };
2634 // The backing table's schema was frozen at create time, and the
2635 // encoder trusts it unconditionally. A projection is typed per row,
2636 // so fresh rows can legitimately come back with a different type
2637 // (the base table changed, a `??` arm flipped). That has to be a
2638 // typed error HERE, before the old contents are destroyed, not an
2639 // abort or bit-reinterpreted garbage inside the insert loop below.
2640 //
2641 // The one case where it is not an error is a backing table that holds
2642 // no rows: nothing is stored for those types to describe, so retyping
2643 // it destroys nothing. That is what lets a view materialized over an
2644 // empty source recover on its first real refresh instead of refusing
2645 // every read from then on.
2646 let mismatch = {
2647 let schema = self.catalog.schema(name).ok_or_else(|| {
2648 QueryError::ViewError(format!("materialized view '{name}' has no backing table"))
2649 })?;
2650 let mut mismatch = None;
2651 'rows: for row in &rows {
2652 if row.len() != schema.columns.len() {
2653 mismatch = Some(QueryError::ViewError(format!(
2654 "refresh of materialized view '{name}' produced rows with {} \
2655 columns but the view stores {}; drop and recreate the view",
2656 row.len(),
2657 schema.columns.len()
2658 )));
2659 break 'rows;
2660 }
2661 for (val, col) in row.iter().zip(&schema.columns) {
2662 let t = val.type_id();
2663 if t != powdb_storage::types::TypeId::Empty && t != col.type_id {
2664 mismatch = Some(QueryError::ViewError(format!(
2665 "refresh of materialized view '{name}' produced a {t:?} \
2666 in column '{}' but the view stores {:?}; drop and \
2667 recreate the view to change its column types",
2668 col.name, col.type_id
2669 )));
2670 break 'rows;
2671 }
2672 }
2673 }
2674 mismatch
2675 };
2676 let rebuildable = self.backing_table_is_rebuildable(name);
2677 let retype = match mismatch {
2678 None => false,
2679 Some(mismatch) => {
2680 let backing_is_empty = {
2681 let mut scan = self
2682 .catalog
2683 .scan(name)
2684 .map_err(QueryError::from_storage_io)?;
2685 scan.next().is_none()
2686 };
2687 if !backing_is_empty || !rebuildable {
2688 return Err(mismatch);
2689 }
2690 true
2691 }
2692 };
2693 // Clear old data and insert fresh results. Mission B2: logged
2694 // variant — view refreshes are a mutation and crash recovery
2695 // must see them.
2696 //
2697 // Deleting every row leaves its bytes in place: a slotted page marks
2698 // the slot and never moves `free_start` back, so each refresh appended
2699 // a whole new copy of the view beside the old one and the backing heap
2700 // grew without bound (113 MB from a 1 MB base table in half an hour).
2701 // Rebuilding the table frees the file instead, and is also less WAL
2702 // than one delete record per row. It needs plain DDL, so a backing
2703 // table with indexes, or a refresh inside an explicit transaction,
2704 // keeps the delete path.
2705 crate::cancel::check()?;
2706 if rebuildable {
2707 let schema = if retype {
2708 self.derive_view_schema(name, &columns, &rows, &query_text)?
2709 } else {
2710 self.catalog.schema(name).cloned().ok_or_else(|| {
2711 QueryError::ViewError(format!(
2712 "materialized view '{name}' has no backing table"
2713 ))
2714 })?
2715 };
2716 self.catalog
2717 .drop_table(name)
2718 .map_err(QueryError::from_storage_io)?;
2719 self.catalog
2720 .create_table(schema)
2721 .map_err(QueryError::from_storage_io)?;
2722 } else {
2723 self.catalog
2724 .scan_delete_matching_logged(name, |_| true)
2725 .map_err(QueryError::from_storage_io)?;
2726 }
2727 for row in &rows {
2728 self.catalog
2729 .insert(name, row)
2730 .map_err(QueryError::from_storage_io)?;
2731 }
2732 // The clean flag is durable, so it must not get to disk ahead of the
2733 // rows it vouches for: `mark_clean` fsyncs `views.bin`, and the writes
2734 // just above are still only in the WAL buffer at this point. A crash
2735 // between the two would leave a CLEAN flag over the pre-refresh rows,
2736 // which is the wrong-answer direction of the same staleness bug. So
2737 // commit first, then record. Inside an explicit transaction there is
2738 // nothing to commit yet, so the flag is cleared in memory only and the
2739 // on-disk flag stays dirty: at worst one redundant refresh after the
2740 // next open, never a stale answer.
2741 if self.in_transaction {
2742 self.view_registry.mark_clean_in_memory(name);
2743 } else {
2744 self.catalog
2745 .commit_autocommit()
2746 .map_err(QueryError::from_storage_io)?;
2747 self.view_registry
2748 .mark_clean(name)
2749 .map_err(QueryError::from_storage_io)?;
2750 }
2751 Ok(())
2752 }
2753
2754 /// Whether a view's backing table can be dropped and recreated to reclaim
2755 /// its previous materialization.
2756 ///
2757 /// Rebuilding is plain DDL, so it is out on three counts: inside an
2758 /// explicit transaction the catalog refuses DDL outright, a secondary index
2759 /// on the backing table would be silently lost, and a declared link that
2760 /// names the table pins it in place. Each of those keeps the delete path,
2761 /// which is correct but does not reclaim.
2762 fn backing_table_is_rebuildable(&self, name: &str) -> bool {
2763 if self.in_transaction {
2764 return false;
2765 }
2766 if self
2767 .catalog
2768 .links()
2769 .any(|link| link.owner_type == name || link.target_type == name)
2770 {
2771 return false;
2772 }
2773 self.catalog
2774 .get_table(name)
2775 .is_some_and(|table| table.indexes_is_empty())
2776 }
2777
2778 /// Drop a materialized view: remove the backing table and unregister.
2779 fn drop_view(&mut self, name: &str) -> Result<(), QueryError> {
2780 if !self.view_registry.is_view(name) {
2781 return Err(QueryError::ViewError(format!(
2782 "materialized view '{name}' not found"
2783 )));
2784 }
2785 self.view_registry
2786 .unregister(name)
2787 .map_err(QueryError::from_storage_io)?;
2788 self.catalog
2789 .drop_table(name)
2790 .map_err(QueryError::from_storage_io)?;
2791 Ok(())
2792 }
2793
2794 /// Derive a storage `Schema` for a view's backing table from query
2795 /// result column names and the types of ALL rows.
2796 ///
2797 /// A projection is typed per row (`.tags ?? 0` is json where `tags` is
2798 /// set and int where it is not), while the backing table's encoder
2799 /// trusts the schema unconditionally: a value whose class contradicts
2800 /// its column either aborts (variable column, fixed value) or is bit-
2801 /// reinterpreted on decode (int bits read as a float). So the type must
2802 /// be unified over every row, with null never constraining it, and a
2803 /// column that genuinely mixes types is a typed error here, before any
2804 /// backing table exists.
2805 fn derive_view_schema(
2806 &self,
2807 name: &str,
2808 columns: &[String],
2809 rows: &[Vec<Value>],
2810 query_text: &str,
2811 ) -> Result<Schema, QueryError> {
2812 use powdb_storage::types::{ColumnDef, TypeId};
2813 let mut types: Vec<Option<TypeId>> = vec![None; columns.len()];
2814 for row in rows {
2815 for (i, val) in row.iter().enumerate().take(columns.len()) {
2816 let t = val.type_id();
2817 if t == TypeId::Empty {
2818 continue;
2819 }
2820 match types[i] {
2821 None => types[i] = Some(t),
2822 Some(prev) if prev == t => {}
2823 Some(prev) => {
2824 return Err(QueryError::ViewError(format!(
2825 "materialized view '{name}' column '{}' mixes value types \
2826 across rows ({prev:?} and {t:?}); make the projection \
2827 produce one type per column",
2828 columns[i]
2829 )));
2830 }
2831 }
2832 }
2833 }
2834 // A column the rows could not type (all null, or no rows at all) falls
2835 // back to the source schema. Typing it `str` unconditionally is what
2836 // froze a view materialized over zero rows into a table of strings that
2837 // every later refresh then refused to write. Only a column with nothing
2838 // to inherit from either -- a computed expression over no rows -- keeps
2839 // `str`, and the refresh path retypes that one on its first real row.
2840 let inherited = self.static_view_column_types(query_text);
2841 let cols: Vec<ColumnDef> = columns
2842 .iter()
2843 .enumerate()
2844 .map(|(i, col_name)| ColumnDef {
2845 name: col_name.clone(),
2846 type_id: types[i]
2847 .or_else(|| inherited.get(col_name).copied())
2848 .unwrap_or(TypeId::Str),
2849 required: false,
2850 position: i as u16,
2851 })
2852 .collect();
2853 Ok(Schema {
2854 table_name: name.to_string(),
2855 columns: cols,
2856 })
2857 }
2858
2859 /// The output columns a view's source query types statically, taken from
2860 /// the source schema rather than from the rows that came back.
2861 ///
2862 /// Only pass-through references are typed here: a projection field that is
2863 /// a bare or qualified scan column, or, for a plain single-table view with
2864 /// no projection, every column of the source. Anything computed has no
2865 /// declared type to inherit and is left to the rows.
2866 fn static_view_column_types(&self, query_text: &str) -> HashMap<String, TypeId> {
2867 let mut inherited = HashMap::new();
2868 let Ok(Statement::Query(q)) = crate::parser::parse(query_text) else {
2869 return inherited;
2870 };
2871 // Bare names that more than one source exposes resolve by suffix match
2872 // at runtime and have no single declared type, so they are dropped.
2873 let mut bare: HashMap<String, Option<TypeId>> = HashMap::new();
2874 let mut qualified: HashMap<String, TypeId> = HashMap::new();
2875 let mut sources: Vec<(String, String)> = vec![(
2876 q.alias.clone().unwrap_or_else(|| q.source.clone()),
2877 q.source.clone(),
2878 )];
2879 for join in &q.joins {
2880 sources.push((
2881 join.alias.clone().unwrap_or_else(|| join.source.clone()),
2882 join.source.clone(),
2883 ));
2884 }
2885 for (alias, table) in &sources {
2886 let Some(schema) = self.catalog.schema(table) else {
2887 return inherited;
2888 };
2889 for column in &schema.columns {
2890 qualified.insert(format!("{alias}.{}", column.name), column.type_id);
2891 match bare.get(&column.name) {
2892 Some(Some(previous)) if *previous == column.type_id => {}
2893 Some(_) => {
2894 bare.insert(column.name.clone(), None);
2895 }
2896 None => {
2897 bare.insert(column.name.clone(), Some(column.type_id));
2898 }
2899 }
2900 }
2901 }
2902 match &q.projection {
2903 Some(fields) => {
2904 for field in fields {
2905 let (output, type_id) = match &field.expr {
2906 Expr::Field(name) => (
2907 field.alias.clone().unwrap_or_else(|| name.clone()),
2908 bare.get(name).copied().flatten(),
2909 ),
2910 Expr::QualifiedField {
2911 qualifier,
2912 field: column,
2913 } => (
2914 field
2915 .alias
2916 .clone()
2917 .unwrap_or_else(|| format!("{qualifier}.{column}")),
2918 qualified.get(&format!("{qualifier}.{column}")).copied(),
2919 ),
2920 _ => continue,
2921 };
2922 if let Some(type_id) = type_id {
2923 inherited.insert(output, type_id);
2924 }
2925 }
2926 }
2927 // With no projection the row IS the source row, but only for a
2928 // plain single-table read: a join renames every column, and
2929 // grouping or aggregation produces computed ones.
2930 None => {
2931 if q.joins.is_empty()
2932 && q.alias.is_none()
2933 && q.aggregation.is_none()
2934 && q.group_by.is_none()
2935 {
2936 for (name, type_id) in bare {
2937 if let Some(type_id) = type_id {
2938 inherited.insert(name, type_id);
2939 }
2940 }
2941 }
2942 }
2943 }
2944 inherited
2945 }
2946
2947 /// Extract base table dependencies from a view's source query by
2948 /// parsing it and collecting the source table names.
2949 ///
2950 /// A parse failure is an error rather than "no dependencies". An empty
2951 /// dependency list means nothing ever marks the view dirty, so it is never
2952 /// refreshed and every read serves whatever the backing table happens to
2953 /// hold, permanently and without any error: the exact silent-wrong-answer
2954 /// shape the rest of the engine refuses.
2955 fn extract_view_deps(&self, name: &str, query_text: &str) -> Result<Vec<String>, QueryError> {
2956 let statement = parse_stored_view_source(name, query_text)?;
2957 let mut deps = Vec::new();
2958 self.collect_statement_deps(&statement, &mut deps);
2959 Ok(deps)
2960 }
2961
2962 fn collect_statement_deps(&self, statement: &Statement, deps: &mut Vec<String>) {
2963 match statement {
2964 Statement::Query(q) => self.collect_query_deps(q, &AliasScope::default(), deps),
2965 // Both halves of a union are read by the view, so both have to be
2966 // able to dirty it. Without this arm a `union` view was registered
2967 // with no dependencies at all and never refreshed.
2968 Statement::Union(u) => {
2969 self.collect_statement_deps(&u.left, deps);
2970 self.collect_statement_deps(&u.right, deps);
2971 }
2972 _ => {}
2973 }
2974 }
2975
2976 /// Every table one query level reads: its own source, its joins, the
2977 /// sources of any subquery in its clauses, and the child and link-target
2978 /// tables its projection reaches.
2979 fn collect_query_deps(&self, q: &QueryExpr, outer: &AliasScope, deps: &mut Vec<String>) {
2980 push_dep(deps, &q.source);
2981 let mut scope = outer.clone();
2982 scope.bind(q.alias.as_deref().unwrap_or(&q.source), &q.source);
2983 for join in &q.joins {
2984 push_dep(deps, &join.source);
2985 scope.bind(join.alias.as_deref().unwrap_or(&join.source), &join.source);
2986 if let Some(on) = &join.on {
2987 self.collect_expr_deps(on, &scope, deps);
2988 }
2989 }
2990 if let Some(filter) = &q.filter {
2991 self.collect_expr_deps(filter, &scope, deps);
2992 }
2993 for field in q.projection.iter().flatten() {
2994 self.collect_expr_deps(&field.expr, &scope, deps);
2995 }
2996 if let Some(order) = &q.order {
2997 for key in &order.keys {
2998 self.collect_expr_deps(&key.expr, &scope, deps);
2999 }
3000 }
3001 if let Some(group_by) = &q.group_by {
3002 for key in &group_by.keys {
3003 self.collect_expr_deps(&key.expr, &scope, deps);
3004 }
3005 if let Some(having) = &group_by.having {
3006 self.collect_expr_deps(having, &scope, deps);
3007 }
3008 }
3009 if let Some(aggregation) = &q.aggregation {
3010 if let Some(argument) = &aggregation.argument {
3011 self.collect_expr_deps(argument, &scope, deps);
3012 }
3013 }
3014 }
3015
3016 /// Every table an expression reads. Nested blocks, link paths and
3017 /// subqueries all name tables that no scan node of this level mentions;
3018 /// missing them is what left a view over the flagship PowQL shapes
3019 /// permanently stale.
3020 fn collect_expr_deps(&self, expr: &Expr, scope: &AliasScope, deps: &mut Vec<String>) {
3021 match expr {
3022 Expr::NestedQuery(nested) => {
3023 let table = match &nested.via_link {
3024 Some(via) => match self.link_target(scope, &via.outer_alias, &via.link_name) {
3025 Some(target) => target,
3026 // An unresolvable link is reported when the view is
3027 // first executed; there is no table to depend on.
3028 None => return,
3029 },
3030 None => nested.source.clone(),
3031 };
3032 push_dep(deps, &table);
3033 let mut inner = scope.clone();
3034 inner.bind(&nested.alias, &table);
3035 self.collect_expr_deps(&nested.filter, &inner, deps);
3036 if let Some(order) = &nested.order {
3037 for key in &order.keys {
3038 self.collect_expr_deps(&key.expr, &inner, deps);
3039 }
3040 }
3041 for field in &nested.fields {
3042 self.collect_expr_deps(&field.expr, &inner, deps);
3043 }
3044 }
3045 Expr::LinkPath {
3046 outer_alias, links, ..
3047 } => {
3048 let Some(mut owner) = scope.table_of(outer_alias) else {
3049 return;
3050 };
3051 for link in links {
3052 let Some(target) = self
3053 .catalog
3054 .link(&owner, link)
3055 .map(|l| l.target_type.clone())
3056 else {
3057 return;
3058 };
3059 push_dep(deps, &target);
3060 owner = target;
3061 }
3062 }
3063 Expr::InSubquery { expr, subquery, .. } => {
3064 self.collect_expr_deps(expr, scope, deps);
3065 self.collect_query_deps(subquery, scope, deps);
3066 }
3067 Expr::ExistsSubquery { subquery, .. } => self.collect_query_deps(subquery, scope, deps),
3068 Expr::BinaryOp(l, _, r) | Expr::Coalesce(l, r) => {
3069 self.collect_expr_deps(l, scope, deps);
3070 self.collect_expr_deps(r, scope, deps);
3071 }
3072 Expr::UnaryOp(_, inner)
3073 | Expr::FunctionCall(_, inner, _)
3074 | Expr::Cast(inner, _)
3075 | Expr::JsonPath { base: inner, .. } => self.collect_expr_deps(inner, scope, deps),
3076 Expr::ScalarFunc(_, args) => {
3077 for arg in args {
3078 self.collect_expr_deps(arg, scope, deps);
3079 }
3080 }
3081 Expr::InList { expr, list, .. } => {
3082 self.collect_expr_deps(expr, scope, deps);
3083 for item in list {
3084 self.collect_expr_deps(item, scope, deps);
3085 }
3086 }
3087 Expr::Case { whens, else_expr } => {
3088 for (when, then) in whens {
3089 self.collect_expr_deps(when, scope, deps);
3090 self.collect_expr_deps(then, scope, deps);
3091 }
3092 if let Some(else_expr) = else_expr {
3093 self.collect_expr_deps(else_expr, scope, deps);
3094 }
3095 }
3096 Expr::Window {
3097 args,
3098 partition_by,
3099 order_by,
3100 ..
3101 } => {
3102 for arg in args.iter().chain(partition_by) {
3103 self.collect_expr_deps(arg, scope, deps);
3104 }
3105 for key in order_by {
3106 self.collect_expr_deps(&key.expr, scope, deps);
3107 }
3108 }
3109 _ => {}
3110 }
3111 }
3112
3113 /// The table a declared link points at, given the alias its owner is bound
3114 /// to in this scope.
3115 fn link_target(&self, scope: &AliasScope, outer_alias: &str, link: &str) -> Option<String> {
3116 let owner = scope.table_of(outer_alias)?;
3117 self.catalog
3118 .link(&owner, link)
3119 .map(|link| link.target_type.clone())
3120 }
3121
3122 /// Route a parsed link declaration to the persistent catalog's
3123 /// `create_link`, which validates the tables/columns and derives the
3124 /// cardinality from the target key's uniqueness. The `on <local> =
3125 /// <target>` clause means "the owner's `local_key` equals the target's
3126 /// `target_key`". A caller-supplied `kind` is ignored by the catalog, so
3127 /// we pass a placeholder.
3128 fn create_link_from_parts(
3129 &mut self,
3130 owner: &str,
3131 name: &str,
3132 target: &str,
3133 local_key: &str,
3134 target_key: &str,
3135 ) -> Result<(), QueryError> {
3136 self.catalog
3137 .create_link(LinkDef {
3138 owner_type: owner.to_string(),
3139 name: name.to_string(),
3140 target_type: target.to_string(),
3141 local_key: local_key.to_string(),
3142 target_key: target_key.to_string(),
3143 // Placeholder: the catalog derives the real cardinality.
3144 kind: LinkKind::ToMany,
3145 })
3146 .map_err(QueryError::from_storage_io)
3147 }
3148
3149 /// Resolve every unresolved link traversal among these nested fields
3150 /// against the persistent catalog, returning fields whose nested
3151 /// projections carry a concrete child table and correlation columns and
3152 /// whose scalar link paths carry a resolved hop chain. Runs at execution
3153 /// time (the pure planner cannot see the catalog), in the same spirit as
3154 /// `RangeScan` late lowering. `outer_table` is the declaring type of the
3155 /// parent scan.
3156 pub(crate) fn resolve_nested_via_links(
3157 &self,
3158 fields: &[NestedProjectField],
3159 outer_table: &str,
3160 ) -> Result<Vec<NestedProjectField>, QueryError> {
3161 fields
3162 .iter()
3163 .map(|field| match field {
3164 NestedProjectField::Nested(nested) => Ok(NestedProjectField::Nested(Box::new(
3165 self.resolve_via_link(nested, outer_table, true)?,
3166 ))),
3167 NestedProjectField::Plain(_) => Ok(field.clone()),
3168 NestedProjectField::Link(link) => Ok(NestedProjectField::Link(Box::new(
3169 self.resolve_scalar_link_field(link, outer_table)?,
3170 ))),
3171 })
3172 .collect()
3173 }
3174
3175 /// Resolve one nested projection level (and its deeper levels): if it is a
3176 /// block link traversal, look the link up under `(outer_table, link_name)`
3177 /// and fill in the child table and correlation columns so execution
3178 /// proceeds exactly as for the explicit correlated spelling. A block
3179 /// traversal is only valid through a `ToMany` link; a `ToOne` link is a
3180 /// kind-mismatch error. Cardinality is derived from the catalog at
3181 /// execution time, so it tracks index DDL that ran after the link was
3182 /// declared. `qualify_parent` mirrors the planner: the
3183 /// top level correlates against an `AliasScan`'s `alias.col` columns,
3184 /// deeper levels against the enclosing child's bare schema columns.
3185 fn resolve_via_link(
3186 &self,
3187 nested: &NestedProjection,
3188 outer_table: &str,
3189 qualify_parent: bool,
3190 ) -> Result<NestedProjection, QueryError> {
3191 let mut out = nested.clone();
3192 if let Some(via) = &nested.via_link {
3193 let link = self.catalog.link(outer_table, &via.link_name).cloned();
3194 let link = link.ok_or_else(|| {
3195 QueryError::Execution(format!(
3196 "unknown link `{}` on type `{}`; declare it with \
3197 `link {}.{} -> <Target> on <local> = <target>`",
3198 via.link_name, outer_table, outer_table, via.link_name
3199 ))
3200 })?;
3201 // GATE B1. Cardinality is derived from index uniqueness here and
3202 // nowhere else. `LinkDef::kind` is an advisory byte that is never
3203 // refreshed (see `Catalog::derive_link_kind`); reading it would
3204 // make `alter <Target> add unique .<key>` after the link silently
3205 // keep this hop to-many forever.
3206 let kind = self
3207 .catalog
3208 .derive_link_kind(&link.target_type, &link.target_key);
3209 if kind != LinkKind::ToMany {
3210 return Err(QueryError::Execution(format!(
3211 "link `{}` on type `{}` is a to-one link (its target key \
3212 `{}.{}` is unique, so a hop matches at most one row); \
3213 traverse it as a path (`{}.{}.<column>`), not a block",
3214 via.link_name,
3215 outer_table,
3216 link.target_type,
3217 link.target_key,
3218 nested.parent_alias,
3219 via.link_name
3220 )));
3221 }
3222 // owner.local_key = target.target_key: the child (target) side of
3223 // the correlation is `target_key`, the parent (owner) side is
3224 // `local_key`.
3225 out.table = link.target_type.clone();
3226 out.child_key = link.target_key.clone();
3227 out.parent_key = if qualify_parent {
3228 format!("{}.{}", nested.parent_alias, link.local_key)
3229 } else {
3230 link.local_key.clone()
3231 };
3232 out.via_link = None;
3233 }
3234 // Deeper levels correlate against THIS child table (now concrete) on a
3235 // bare parent key.
3236 out.fields = nested
3237 .fields
3238 .iter()
3239 .map(|field| match field {
3240 NestedField::Nested(inner) => Ok(NestedField::Nested(Box::new(
3241 self.resolve_via_link(inner, &out.table, false)?,
3242 ))),
3243 NestedField::Scalar { .. } => Ok(field.clone()),
3244 })
3245 .collect::<Result<Vec<_>, QueryError>>()?;
3246 Ok(out)
3247 }
3248
3249 /// Resolve a scalar link path (`o.user.company.name`) against the
3250 /// persistent catalog: each path segment must name a declared `ToOne`
3251 /// link on the type reached so far. Produces one [`ScalarLinkHop`] per
3252 /// segment; the first hop's FK column is qualified with the outer alias to
3253 /// match the parent `AliasScan`'s column names. A `ToMany` link in the
3254 /// chain (a non-unique target key) is a kind-mismatch error, never a silent
3255 /// fan-out. Each hop's cardinality is derived from the catalog at execution
3256 /// time, so a target key made unique after the link was declared is a
3257 /// to-one hop from that moment on, with no re-declaration.
3258 fn resolve_scalar_link_field(
3259 &self,
3260 field: &ScalarLinkField,
3261 outer_table: &str,
3262 ) -> Result<ScalarLinkField, QueryError> {
3263 let mut out = field.clone();
3264 if out.resolved.is_some() {
3265 return Ok(out);
3266 }
3267 let mut chain: Vec<LinkDef> = Vec::with_capacity(field.links.len());
3268 let mut current = outer_table.to_string();
3269 for link_name in &field.links {
3270 let link = self.catalog.link(¤t, link_name).cloned();
3271 let link = link.ok_or_else(|| {
3272 QueryError::Execution(format!(
3273 "unknown link `{link_name}` on type `{current}`; declare it with \
3274 `link {current}.{link_name} -> <Target> on <local> = <target>`"
3275 ))
3276 })?;
3277 // GATE B2, per hop: every link in the chain is checked against the
3278 // catalog as it stands now, not as it stood when the link was
3279 // declared. Same rule as B1: never read `LinkDef::kind`.
3280 let kind = self
3281 .catalog
3282 .derive_link_kind(&link.target_type, &link.target_key);
3283 if kind != LinkKind::ToOne {
3284 // Lead with the remedy that keeps the query as written. The
3285 // block form is the alternative, not the default: it turns a
3286 // foreign-key lookup into a one-element array the caller has to
3287 // unwrap forever. Only offer `add unique` when it would
3288 // actually be accepted: a target key that already carries a
3289 // plain index cannot be upgraded in place, and pointing at a
3290 // statement that errors is how the old message misled.
3291 //
3292 // Each branch supplies a whole sentence rather than a fragment
3293 // spliced into a shared frame: the plain-index case has no
3294 // imperative to give, and forcing it into "To read one value
3295 // per row, <fragment>" produced a sentence that did not parse.
3296 let remedy = if self
3297 .catalog
3298 .is_index_unique(&link.target_type, &link.target_key)
3299 == Some(false)
3300 {
3301 format!(
3302 "There is no way to read one value per row here: `{}.{}` \
3303 already carries a non-unique index and an index cannot \
3304 be upgraded in place, so this link stays to-many.",
3305 link.target_type, link.target_key
3306 )
3307 } else {
3308 format!(
3309 "To read one value per row, make the target key unique \
3310 with `alter {} add unique .{}`.",
3311 link.target_type, link.target_key
3312 )
3313 };
3314 return Err(QueryError::Execution(format!(
3315 "link `{link_name}` on type `{current}` is a to-many link: \
3316 its target key `{}.{}` is not unique, so a hop can match \
3317 many rows. {remedy} To read every match, traverse it with a \
3318 block (`{link_name}: {}.{link_name} {{ ... }}`)",
3319 link.target_type, link.target_key, field.outer_alias
3320 )));
3321 }
3322 current = link.target_type.clone();
3323 chain.push(link);
3324 }
3325 // owner.local_key = target.target_key: the FK on the many side is
3326 // `local_key`, the key on the one side is `target_key`. The parser only
3327 // builds a link path with at least one hop, but this runs on any plan
3328 // an executor is handed, and an empty chain must be a typed error and
3329 // never a slice-index panic (panic = abort makes that a remote DoS).
3330 let Some(first) = chain.first() else {
3331 return Err(QueryError::Execution(format!(
3332 "scalar link path for column `{}` names no link to traverse; \
3333 write it as `<alias>.<link>.<column>`",
3334 field.column
3335 )));
3336 };
3337 let first_fk = format!("{}.{}", field.outer_alias, first.local_key);
3338 let hops = chain
3339 .iter()
3340 .enumerate()
3341 .map(|(i, link)| ScalarLinkHop {
3342 table: link.target_type.clone(),
3343 key_col: link.target_key.clone(),
3344 out_col: match chain.get(i + 1) {
3345 Some(next) => next.local_key.clone(),
3346 None => field.column.clone(),
3347 },
3348 })
3349 .collect();
3350 out.resolved = Some(ScalarLinkResolved { first_fk, hops });
3351 Ok(out)
3352 }
3353
3354 /// Build one lookup map per hop of a resolved scalar link path: key column
3355 /// value -> out column value over the hop's target table. A duplicate key
3356 /// value is an error, not a silent pick: a scalar hop through a non-unique
3357 /// key is the to-one assumption failing (a `ToOne` link whose unique index
3358 /// was later dropped), which in SQL would silently fan the join out.
3359 /// `fk_keys` is the set of distinct non-NULL FK values the outer scan
3360 /// actually selects. A to-one hop's target key is unique, so a selective
3361 /// outer query only needs a point probe per key it references instead of a
3362 /// full target-table scan. Each hop restricts to the keys the previous
3363 /// hop's map can actually reach, so a selective outer query stays selective
3364 /// through a multi-hop path.
3365 fn build_scalar_link_maps(
3366 &self,
3367 link: &ScalarLinkField,
3368 resolved: &ScalarLinkResolved,
3369 fk_keys: &rustc_hash::FxHashSet<Value>,
3370 ) -> Result<Vec<rustc_hash::FxHashMap<Value, Value>>, QueryError> {
3371 use rustc_hash::{FxHashMap, FxHashSet};
3372 let mut cancel = CancelCheck::new();
3373 let mut maps = Vec::with_capacity(resolved.hops.len());
3374 // Keys the executor will look up at this hop: the outer FK values for
3375 // the first hop, then the non-NULL outputs the previous map produced
3376 // for those keys. The executor only ever consults `map.get(v)` for
3377 // `v` in this set, so a map restricted to it is byte-identical for
3378 // every lookup that actually happens.
3379 let mut needed_keys: FxHashSet<Value> = fk_keys.clone();
3380 for hop in &resolved.hops {
3381 let schema = self
3382 .catalog
3383 .schema(&hop.table)
3384 .ok_or_else(|| QueryError::TableNotFound(hop.table.clone()))?
3385 .clone();
3386 let column_index = |name: &str| {
3387 schema
3388 .columns
3389 .iter()
3390 .position(|c| c.name == name)
3391 .ok_or_else(|| QueryError::ColumnNotFound {
3392 table: hop.table.clone(),
3393 column: name.to_string(),
3394 })
3395 };
3396 let key_idx = column_index(&hop.key_col)?;
3397 let out_idx = column_index(&hop.out_col)?;
3398 // Probe only when the key column has a UNIQUE index: uniqueness
3399 // makes the full-scan duplicate check moot (at most one row per
3400 // key), so a per-key point probe is byte-identical to the scan.
3401 // A non-unique or absent index falls through to the full scan,
3402 // which still raises the hard duplicate-key error even for keys no
3403 // parent references (the "to-one link whose unique index was
3404 // dropped" corruption case).
3405 let use_probes = self.catalog.is_index_unique(&hop.table, &hop.key_col) == Some(true)
3406 && self.child_index_probe_pays_off(&hop.table, &hop.key_col, needed_keys.len());
3407 let mut map: FxHashMap<Value, Value> = FxHashMap::default();
3408 if use_probes {
3409 let tbl = self
3410 .catalog
3411 .get_table(&hop.table)
3412 .ok_or_else(|| QueryError::TableNotFound(hop.table.clone()))?;
3413 // Strict-type gate mirrors the scan-built map: its keys are all
3414 // the column's own type, and Value equality is typed, so a
3415 // cross-type FK never matches under either strategy.
3416 let col_type = schema.columns[key_idx].type_id;
3417 let mut narrowed: Vec<Vec<Value>> = Vec::with_capacity(needed_keys.len());
3418 for key in &needed_keys {
3419 cancel.tick()?;
3420 if key.type_id() != col_type {
3421 continue;
3422 }
3423 if let Some((_, row)) = tbl
3424 .index_lookup(&hop.key_col, key)
3425 .map_err(QueryError::from_storage_io)?
3426 {
3427 // A NULL key never matches any FK value.
3428 if row[key_idx] == Value::Empty {
3429 continue;
3430 }
3431 narrowed.push(vec![row[key_idx].clone(), row[out_idx].clone()]);
3432 }
3433 }
3434 self.charge_rows(&narrowed)?;
3435 for mut pair in narrowed {
3436 cancel.tick()?;
3437 let value = pair.pop().expect("two columns per narrowed row");
3438 let key = pair.pop().expect("two columns per narrowed row");
3439 map.insert(key, value);
3440 }
3441 } else {
3442 // Materialize the two needed columns and charge them against
3443 // the query budget like a join build side.
3444 let mut narrowed: Vec<Vec<Value>> = Vec::new();
3445 for item in self
3446 .catalog
3447 .scan(&hop.table)
3448 .map_err(QueryError::from_storage_io)?
3449 {
3450 let (_, row) = item.map_err(QueryError::from_storage_io)?;
3451 cancel.tick()?;
3452 // A NULL key never matches any FK value.
3453 if row[key_idx] == Value::Empty {
3454 continue;
3455 }
3456 narrowed.push(vec![row[key_idx].clone(), row[out_idx].clone()]);
3457 }
3458 self.charge_rows(&narrowed)?;
3459 map.reserve(narrowed.len());
3460 for mut pair in narrowed {
3461 cancel.tick()?;
3462 let value = pair.pop().expect("two columns per narrowed row");
3463 let key = pair.pop().expect("two columns per narrowed row");
3464 if map.insert(key.clone(), value).is_some() {
3465 return Err(QueryError::Execution(format!(
3466 "scalar link `{}`: key column `{}.{}` is not unique \
3467 (duplicate value {key:?}); a scalar link requires a \
3468 unique target key",
3469 link.name, hop.table, hop.key_col
3470 )));
3471 }
3472 }
3473 }
3474 // The next hop only needs arrays for the non-NULL values this hop
3475 // produces for the keys we care about; anything else the executor
3476 // will never consult.
3477 needed_keys = needed_keys
3478 .iter()
3479 .filter_map(|k| map.get(k))
3480 .filter(|v| **v != Value::Empty)
3481 .cloned()
3482 .collect();
3483 maps.push(map);
3484 }
3485 Ok(maps)
3486 }
3487
3488 /// Execute the projection layer of a `NestedProject`: plain fields
3489 /// evaluate against the parent rows like `Project`; each nested field is
3490 /// assembled bottom-up by [`Engine::assemble_nested_arrays`], one hash
3491 /// build pass per child table keyed by its correlation column. Shared by
3492 /// the mutable and read-only dispatches (assembly only reads).
3493 pub(crate) fn execute_nested_project(
3494 &self,
3495 parent: QueryResult,
3496 fields: &[NestedProjectField],
3497 ) -> Result<QueryResult, QueryError> {
3498 use rustc_hash::FxHashMap;
3499 let QueryResult::Rows {
3500 columns: parent_columns,
3501 rows: parent_rows,
3502 } = parent
3503 else {
3504 return Err("nested projection requires row input".into());
3505 };
3506 // Per non-plain field: the parent-side key column index and the
3507 // assembled build side (JSON array map for a nested block, one
3508 // key -> value map per hop for a scalar link path).
3509 enum FieldBuild {
3510 Nested(usize, FxHashMap<Value, String>),
3511 Link(usize, Vec<FxHashMap<Value, Value>>),
3512 }
3513 let mut builds: Vec<FieldBuild> = Vec::new();
3514 for field in fields {
3515 match field {
3516 NestedProjectField::Plain(_) => {}
3517 NestedProjectField::Nested(nested) => {
3518 let parent_idx = parent_columns
3519 .iter()
3520 .position(|c| c == &nested.parent_key)
3521 .ok_or_else(|| {
3522 QueryError::Execution(format!(
3523 "nested projection `{}` outer column `{}` not found",
3524 nested.name, nested.parent_key
3525 ))
3526 })?;
3527 // Distinct non-NULL correlation values actually present on
3528 // the parent side. Assembly only ever needs child rows for
3529 // these keys, which is what lets a selective parent avoid
3530 // paying for the whole child table.
3531 let parent_keys = distinct_non_null(&parent_rows, parent_idx);
3532 builds.push(FieldBuild::Nested(
3533 parent_idx,
3534 self.assemble_nested_arrays(nested, &parent_keys)?,
3535 ));
3536 }
3537 NestedProjectField::Link(link) => {
3538 let resolved = link.resolved.as_ref().ok_or_else(|| {
3539 QueryError::Execution(format!(
3540 "scalar link path `{}` was not resolved before execution",
3541 link.name
3542 ))
3543 })?;
3544 let parent_idx = parent_columns
3545 .iter()
3546 .position(|c| c == &resolved.first_fk)
3547 .ok_or_else(|| {
3548 QueryError::Execution(format!(
3549 "scalar link `{}` FK column `{}` not found on the outer scan",
3550 link.name, resolved.first_fk
3551 ))
3552 })?;
3553 // Distinct non-NULL FK values the outer scan actually
3554 // selects: a to-one hop's target key is unique, so a
3555 // selective outer query only needs point probes for these
3556 // keys instead of scanning the whole target table.
3557 let fk_keys = distinct_non_null(&parent_rows, parent_idx);
3558 builds.push(FieldBuild::Link(
3559 parent_idx,
3560 self.build_scalar_link_maps(link, resolved, &fk_keys)?,
3561 ));
3562 }
3563 }
3564 }
3565
3566 let columns: Vec<String> = fields
3567 .iter()
3568 .map(|field| match field {
3569 NestedProjectField::Plain(f) => f
3570 .alias
3571 .clone()
3572 .unwrap_or_else(|| projection_output_name(&f.expr)),
3573 NestedProjectField::Nested(nested) => nested.name.clone(),
3574 NestedProjectField::Link(link) => link.name.clone(),
3575 })
3576 .collect();
3577 let mut cancel = CancelCheck::new();
3578 let mut rows: Vec<Vec<Value>> = Vec::with_capacity(parent_rows.len());
3579 for parent_row in &parent_rows {
3580 cancel.tick()?;
3581 let mut out = Vec::with_capacity(fields.len());
3582 let mut build_iter = builds.iter();
3583 for field in fields {
3584 match field {
3585 NestedProjectField::Plain(f) => {
3586 out.push(eval_expr(&f.expr, parent_row, &parent_columns));
3587 }
3588 NestedProjectField::Nested(nested) => {
3589 let Some(FieldBuild::Nested(parent_idx, build)) = build_iter.next() else {
3590 unreachable!("one build side per non-plain field, in order");
3591 };
3592 let array = build
3593 .get(&parent_row[*parent_idx])
3594 .map(String::as_str)
3595 .unwrap_or("[]");
3596 // Round-tripping through the text parser yields
3597 // canonical PJ1 (sorted object keys) for free.
3598 let doc = powdb_storage::pj1::parse_json_text(array).map_err(|e| {
3599 QueryError::Execution(format!(
3600 "nested projection `{}` produced invalid JSON: {e}",
3601 nested.name
3602 ))
3603 })?;
3604 out.push(Value::Json(doc.into()));
3605 }
3606 NestedProjectField::Link(_) => {
3607 let Some(FieldBuild::Link(parent_idx, maps)) = build_iter.next() else {
3608 unreachable!("one build side per non-plain field, in order");
3609 };
3610 // Walk the hop maps: a NULL or dangling FK at any hop
3611 // yields an empty value (LEFT JOIN semantics); the
3612 // parent row is never dropped.
3613 let mut value = parent_row[*parent_idx].clone();
3614 for map in maps {
3615 if value == Value::Empty {
3616 break;
3617 }
3618 value = map.get(&value).cloned().unwrap_or(Value::Empty);
3619 }
3620 out.push(value);
3621 }
3622 }
3623 }
3624 rows.push(out);
3625 }
3626 Ok(QueryResult::Rows { columns, rows })
3627 }
3628
3629 /// Assemble one nested projection level bottom-up: gather this level's
3630 /// child rows (full scan, or per-parent-key index probes when the parent
3631 /// side is selective and the correlation column is indexed), apply the
3632 /// residual filter, recursively assemble deeper levels restricted to the
3633 /// correlation values actually gathered, group rows by correlation
3634 /// value, order and truncate each parent's bucket, and serialize each
3635 /// bucket to JSON array text. Recursion depth is bounded by the parser's
3636 /// nesting guard.
3637 ///
3638 /// `parent_keys` is the set of distinct non-NULL correlation values the
3639 /// enclosing level will look up: the assembled map never needs any other
3640 /// key, so a small set with an index on `child_key` skips the child
3641 /// table scan entirely.
3642 fn assemble_nested_arrays(
3643 &self,
3644 nested: &NestedProjection,
3645 parent_keys: &rustc_hash::FxHashSet<Value>,
3646 ) -> Result<rustc_hash::FxHashMap<Value, String>, QueryError> {
3647 use rustc_hash::FxHashMap;
3648 let schema = self
3649 .catalog
3650 .schema(&nested.table)
3651 .ok_or_else(|| QueryError::TableNotFound(nested.table.clone()))?
3652 .clone();
3653 let column_index = |name: &str| {
3654 schema
3655 .columns
3656 .iter()
3657 .position(|c| c.name == name)
3658 .ok_or_else(|| QueryError::ColumnNotFound {
3659 table: nested.table.clone(),
3660 column: name.to_string(),
3661 })
3662 };
3663 let key_idx = column_index(&nested.child_key)?;
3664 // One value source per output field: a scalar column, or the
3665 // correlation column of a deeper level (whose arrays are assembled
3666 // after this level's rows are gathered, so the recursion can be
3667 // restricted to the keys those rows actually reference).
3668 let mut field_specs: Vec<(&str, usize, Option<&NestedProjection>)> =
3669 Vec::with_capacity(nested.fields.len());
3670 for field in &nested.fields {
3671 match field {
3672 NestedField::Scalar { key, column } => {
3673 field_specs.push((key.as_str(), column_index(column)?, None));
3674 }
3675 NestedField::Nested(inner) => {
3676 field_specs.push((
3677 inner.name.as_str(),
3678 column_index(&inner.parent_key)?,
3679 Some(inner),
3680 ));
3681 }
3682 }
3683 }
3684 let order_idxs = nested
3685 .order
3686 .iter()
3687 .map(|(column, descending)| Ok((column_index(column)?, *descending)))
3688 .collect::<Result<Vec<_>, QueryError>>()?;
3689 let bound = |expr: &Option<Expr>, what: &str| -> Result<Option<usize>, QueryError> {
3690 match expr {
3691 None => Ok(None),
3692 Some(Expr::Literal(Literal::Int(v))) if *v >= 0 => Ok(Some(*v as usize)),
3693 Some(_) => Err(QueryError::Execution(format!(
3694 "nested projection `{}` {what} must be a non-negative integer literal",
3695 nested.name
3696 ))),
3697 }
3698 };
3699 let limit = bound(&nested.limit, "limit")?;
3700 let offset = bound(&nested.offset, "offset")?;
3701 // No parent will consult the map: skip the data work, but only
3702 // after the validation above, and still validate deeper levels so
3703 // schema errors do not appear and disappear with the data.
3704 if parent_keys.is_empty() {
3705 for (_, _, inner) in &field_specs {
3706 if let Some(inner) = inner {
3707 self.assemble_nested_arrays(inner, parent_keys)?;
3708 }
3709 }
3710 return Ok(FxHashMap::default());
3711 }
3712 // Residual conditions reference bare child columns (rewritten by
3713 // the planner), so they evaluate against the full schema row.
3714 let schema_cols: Vec<String> = if nested.residual.is_some() {
3715 schema.columns.iter().map(|c| c.name.clone()).collect()
3716 } else {
3717 Vec::new()
3718 };
3719 // Materialize only the needed child columns (key first), charge
3720 // them against the query budget like a join build side, then fold
3721 // into per-parent buckets.
3722 //
3723 // Row gathering has two strategies:
3724 // 1. Index probes: when the parent side is selective and
3725 // `child_key` is indexed, probe the btree once per parent key
3726 // and fetch only matching rows. Probe results come back in rid
3727 // order per key, which is exactly the heap scan order the
3728 // unordered-array contract promises.
3729 // 2. Full scan: the fleet-shaped default. When the parent key set
3730 // is small in absolute terms, non-matching correlation values
3731 // are skipped before narrowing so unrelated buckets are never
3732 // materialized or serialized.
3733 let use_index_probes =
3734 self.child_index_probe_pays_off(&nested.table, &nested.child_key, parent_keys.len());
3735 // Membership pre-filter for the scan strategy: cheap insurance for
3736 // selective parents without an index, skipped for large parent sets
3737 // (fleet shape) where nearly every child row matches anyway.
3738 const SCAN_KEY_FILTER_MAX_KEYS: usize = 1024;
3739 let scan_key_filter = !use_index_probes && parent_keys.len() <= SCAN_KEY_FILTER_MAX_KEYS;
3740 let mut cancel = CancelCheck::new();
3741 let mut child_rows: Vec<Vec<Value>> = Vec::new();
3742 let narrow_into =
3743 |row: &[Value], child_rows: &mut Vec<Vec<Value>>| -> Result<(), QueryError> {
3744 // A NULL correlation value never matches any parent.
3745 if row[key_idx] == Value::Empty {
3746 return Ok(());
3747 }
3748 if scan_key_filter && !parent_keys.contains(&row[key_idx]) {
3749 return Ok(());
3750 }
3751 if let Some(residual) = &nested.residual {
3752 if !eval_predicate(residual, row, &schema_cols) {
3753 return Ok(());
3754 }
3755 }
3756 let mut narrowed = Vec::with_capacity(1 + field_specs.len() + order_idxs.len());
3757 narrowed.push(row[key_idx].clone());
3758 for (_, idx, _) in &field_specs {
3759 narrowed.push(row[*idx].clone());
3760 }
3761 for (idx, _) in &order_idxs {
3762 narrowed.push(row[*idx].clone());
3763 }
3764 child_rows.push(narrowed);
3765 Ok(())
3766 };
3767 if use_index_probes {
3768 let tbl = self
3769 .catalog
3770 .get_table(&nested.table)
3771 .ok_or_else(|| QueryError::TableNotFound(nested.table.clone()))?;
3772 // Strict-type gate: the hash build this path replaces uses
3773 // strictly-typed Value equality (Int(4) never equals Float(4.0)),
3774 // but the btree's Ord is cross-type numeric. Only probe with
3775 // keys of the column's own type; any other key can never match
3776 // and correctly falls through to the [] default.
3777 let col_type = schema.columns[key_idx].type_id;
3778 for key in parent_keys {
3779 cancel.tick()?;
3780 if key.type_id() != col_type {
3781 continue;
3782 }
3783 for rid in tbl.index_lookup_all(&nested.child_key, key) {
3784 cancel.tick()?;
3785 // `tbl.get` reassembles spilled/overflow columns and
3786 // tolerates a stale rid (None) like the IndexScan path.
3787 if let Some(row) = tbl.get(rid).map_err(QueryError::from_storage_io)? {
3788 narrow_into(&row, &mut child_rows)?;
3789 }
3790 }
3791 }
3792 } else {
3793 for item in self
3794 .catalog
3795 .scan(&nested.table)
3796 .map_err(QueryError::from_storage_io)?
3797 {
3798 let (_, row) = item.map_err(QueryError::from_storage_io)?;
3799 cancel.tick()?;
3800 narrow_into(&row, &mut child_rows)?;
3801 }
3802 }
3803 self.charge_rows(&child_rows)?;
3804 // Deeper levels only need arrays for correlation values that
3805 // actually appear in the gathered rows; collecting them here is what
3806 // lets a selective parent stay selective all the way down.
3807 enum FieldSource {
3808 Column,
3809 Arrays(FxHashMap<Value, String>),
3810 }
3811 let mut sources: Vec<(&str, FieldSource)> = Vec::with_capacity(field_specs.len());
3812 for (i, (name, _, inner)) in field_specs.iter().enumerate() {
3813 match inner {
3814 None => sources.push((name, FieldSource::Column)),
3815 Some(inner) => {
3816 let mut inner_keys: rustc_hash::FxHashSet<Value> =
3817 rustc_hash::FxHashSet::default();
3818 for child in &child_rows {
3819 let value = &child[1 + i];
3820 if *value != Value::Empty {
3821 inner_keys.insert(value.clone());
3822 }
3823 }
3824 sources.push((
3825 name,
3826 FieldSource::Arrays(self.assemble_nested_arrays(inner, &inner_keys)?),
3827 ));
3828 }
3829 }
3830 }
3831 // Bucket entries keep their per-parent sort key values (the
3832 // narrowed tail) until ordering and truncation are applied.
3833 let mut buckets: FxHashMap<Value, Vec<(Vec<Value>, String)>> =
3834 FxHashMap::with_capacity_and_hasher(child_rows.len(), Default::default());
3835 let sort_tail = 1 + sources.len();
3836 for mut child in child_rows {
3837 cancel.tick()?;
3838 let sort_values = child.split_off(sort_tail);
3839 let mut object = String::from("{");
3840 for (i, ((name, source), value)) in sources.iter().zip(&child[1..]).enumerate() {
3841 if i > 0 {
3842 object.push(',');
3843 }
3844 push_json_string(&mut object, name);
3845 object.push(':');
3846 match source {
3847 FieldSource::Column => push_json_value(&mut object, value),
3848 FieldSource::Arrays(arrays) => {
3849 object.push_str(arrays.get(value).map(String::as_str).unwrap_or("[]"));
3850 }
3851 }
3852 }
3853 object.push('}');
3854 let key = child.swap_remove(0);
3855 buckets.entry(key).or_default().push((sort_values, object));
3856 }
3857 let mut build: FxHashMap<Value, String> =
3858 FxHashMap::with_capacity_and_hasher(buckets.len(), Default::default());
3859 for (key, mut bucket) in buckets {
3860 cancel.tick()?;
3861 if !order_idxs.is_empty() {
3862 // Stable sort: ties keep child scan order.
3863 bucket.sort_by(|(a, _), (b, _)| {
3864 for (pos, (_, descending)) in order_idxs.iter().enumerate() {
3865 let cmp = compare_order_values(&a[pos], &b[pos], *descending);
3866 if cmp != std::cmp::Ordering::Equal {
3867 return cmp;
3868 }
3869 }
3870 std::cmp::Ordering::Equal
3871 });
3872 }
3873 let kept = bucket
3874 .iter()
3875 .skip(offset.unwrap_or(0))
3876 .take(limit.unwrap_or(usize::MAX));
3877 let mut array =
3878 String::with_capacity(2 + kept.clone().map(|(_, o)| o.len() + 1).sum::<usize>());
3879 array.push('[');
3880 for (i, (_, object)) in kept.enumerate() {
3881 if i > 0 {
3882 array.push(',');
3883 }
3884 array.push_str(object);
3885 }
3886 array.push(']');
3887 build.insert(key, array);
3888 }
3889 Ok(build)
3890 }
3891
3892 /// Whether per-parent-key index probes beat a full child-table scan for
3893 /// one nested projection level. Mirrors the range chooser's use of live
3894 /// `catalog.index_stats`: estimate the fetched row count as
3895 /// `parent keys * average bucket size` and require it to undercut the
3896 /// scan by 4x, pricing in the btree probe plus the random-access
3897 /// `tbl.get` per rid versus the sequential mmap scan. A fleet-shaped
3898 /// read (every parent selected) estimates at ~total entries and stays
3899 /// on the scan; a selective parent estimates tiny and probes.
3900 fn child_index_probe_pays_off(&self, table: &str, column: &str, n_keys: usize) -> bool {
3901 if !self.catalog.has_index(table, column) {
3902 return false;
3903 }
3904 let Some(stats) = self.catalog.index_stats(table, column) else {
3905 return false;
3906 };
3907 if stats.distinct_keys == 0 {
3908 // Empty index: every probe is a no-op and the scan has nothing
3909 // indexable either (Empty keys never correlate).
3910 return true;
3911 }
3912 let avg_bucket = stats.total_entries.div_ceil(stats.distinct_keys);
3913 let estimated_fetch = (n_keys as u64).saturating_mul(avg_bucket);
3914 estimated_fetch.saturating_mul(4) <= stats.total_entries
3915 }
3916}
3917
3918/// True when any nested field (at any depth) is an unresolved link traversal
3919/// (a block `via_link` or an unresolved scalar link path) and therefore needs
3920/// catalog resolution before assembly.
3921/// Render a view-name list for the `drop` message, with the verb that agrees
3922/// with it: ("view 'V'", "has") for one, ("views 'A', 'B'", "have") for several.
3923/// Callers only reach this with a non-empty list.
3924fn describe_view_list(names: &[String]) -> (String, &'static str) {
3925 let quoted: Vec<String> = names.iter().map(|n| format!("'{n}'")).collect();
3926 if quoted.len() == 1 {
3927 (format!("view {}", quoted[0]), "has")
3928 } else {
3929 (format!("views {}", quoted.join(", ")), "have")
3930 }
3931}
3932
3933pub(crate) fn nested_fields_have_via_link(fields: &[NestedProjectField]) -> bool {
3934 fn nested_has(nested: &NestedProjection) -> bool {
3935 nested.via_link.is_some()
3936 || nested.fields.iter().any(|field| match field {
3937 NestedField::Nested(inner) => nested_has(inner),
3938 NestedField::Scalar { .. } => false,
3939 })
3940 }
3941 fields.iter().any(|field| match field {
3942 NestedProjectField::Nested(nested) => nested_has(nested),
3943 NestedProjectField::Plain(_) => false,
3944 NestedProjectField::Link(link) => link.resolved.is_none(),
3945 })
3946}
3947
3948/// The base table a read plan scans, following the single-input pipeline down
3949/// to its `AliasScan`/`SeqScan` leaf. Used to name the declaring type when
3950/// resolving a top-level link traversal.
3951pub(crate) fn scan_source_table(plan: &PlanNode) -> Option<&str> {
3952 match plan {
3953 PlanNode::AliasScan { table, .. } | PlanNode::SeqScan { table } => Some(table),
3954 PlanNode::Filter { input, .. }
3955 | PlanNode::Sort { input, .. }
3956 | PlanNode::Limit { input, .. }
3957 | PlanNode::Offset { input, .. } => scan_source_table(input),
3958 _ => None,
3959 }
3960}
3961
3962/// Distinct non-NULL values at column `idx` across `rows`. This is the set of
3963/// correlation / FK keys a nested block or scalar link will ever look up, so
3964/// threading it into the build side lets a selective parent skip child rows no
3965/// parent references.
3966fn distinct_non_null(rows: &[Vec<Value>], idx: usize) -> rustc_hash::FxHashSet<Value> {
3967 let mut keys: rustc_hash::FxHashSet<Value> = rustc_hash::FxHashSet::default();
3968 for row in rows {
3969 let key = &row[idx];
3970 if *key != Value::Empty {
3971 keys.insert(key.clone());
3972 }
3973 }
3974 keys
3975}
3976
3977/// Append `s` to `out` as a JSON string literal with the required escapes.
3978fn push_json_string(out: &mut String, s: &str) {
3979 use std::fmt::Write;
3980 out.push('"');
3981 for ch in s.chars() {
3982 match ch {
3983 '"' => out.push_str("\\\""),
3984 '\\' => out.push_str("\\\\"),
3985 '\n' => out.push_str("\\n"),
3986 '\r' => out.push_str("\\r"),
3987 '\t' => out.push_str("\\t"),
3988 c if c <= '\u{1f}' => {
3989 let _ = write!(out, "\\u{:04x}", c as u32);
3990 }
3991 c => out.push(c),
3992 }
3993 }
3994 out.push('"');
3995}
3996
3997/// Append a child column value to `out` as a JSON value. Scalars map
3998/// naturally (int/float -> number, str -> string, bool -> bool, empty ->
3999/// null); JSON columns embed as sub-documents; the remaining types
4000/// (datetime, uuid, bytes) fall back to their wire text as a JSON string
4001/// (slice scope).
4002fn push_json_value(out: &mut String, value: &Value) {
4003 use std::fmt::Write;
4004 match value {
4005 Value::Empty => out.push_str("null"),
4006 Value::Int(v) => {
4007 let _ = write!(out, "{v}");
4008 }
4009 Value::Float(v) if v.is_finite() => {
4010 // Rust's shortest Display renders 3.0 as "3", which the
4011 // canonicalizing PJ1 re-parse would store as an int. Use the
4012 // shared renderer that guarantees a fractional/exponent marker.
4013 out.push_str(&powdb_storage::pj1::render_float(*v));
4014 }
4015 // NaN/infinity have no JSON representation.
4016 Value::Float(_) => out.push_str("null"),
4017 Value::Bool(v) => out.push_str(if *v { "true" } else { "false" }),
4018 Value::Str(s) => push_json_string(out, s),
4019 Value::Json(doc) => {
4020 out.push_str(&powdb_storage::pj1::pj1_to_text(doc).unwrap_or_else(|_| "null".into()))
4021 }
4022 other => push_json_string(out, &other.to_wire_string()),
4023 }
4024}
4025
4026/// Parse a materialized view's STORED source text, or fail with a typed error
4027/// naming the view.
4028///
4029/// A view's source text outlives the process and outlives the release that
4030/// wrote it. Releases up to 0.21.0 reconstructed that text in a way that could
4031/// lose string escapes and backtick-quoted identifiers, so a database written
4032/// by one of them can hold a source that no longer parses at all. Both places
4033/// that read one back treated a parse failure as "nothing to do":
4034/// `extract_view_deps` returned no dependencies, so the view was never marked
4035/// dirty and therefore never refreshed, and every read of it then served
4036/// whatever rows the backing table happened to hold, forever, with no error
4037/// anywhere. A read returned `[]` where the view's own query returned `[1]`.
4038///
4039/// Fixing the reconstruction is not retroactive: nothing rewrites a source that
4040/// is already on disk. So the read side refuses instead, which turns a silent
4041/// wrong answer into an error the operator can act on.
4042///
4043/// The relex round-trip check that guards `materialize` is deliberately NOT
4044/// applied here. A refresh executes the stored text directly rather than
4045/// re-rendering it, so a source that parses but is not a fixed point of the
4046/// current reconstruction still computes exactly what it says; rejecting it
4047/// would fail live views over a difference with no runtime consequence.
4048pub(super) fn parse_stored_view_source(name: &str, source: &str) -> Result<Statement, QueryError> {
4049 crate::parser::parse(source).map_err(|err| {
4050 QueryError::ViewError(format!(
4051 "materialized view '{name}' has a stored source query that no longer parses \
4052 ({err}). It was written by an older release whose source-text reconstruction \
4053 was lossy, so the view cannot be refreshed and its rows cannot be trusted. \
4054 Re-create it: `drop view {name}`, then `materialize {name} as \
4055 <the original query>`."
4056 ))
4057 })
4058}
4059
4060/// Query aliases bound to their tables while walking a view's source, so a
4061/// link traversal can be resolved against its owner's declared type.
4062#[derive(Clone, Default)]
4063struct AliasScope {
4064 bindings: Vec<(String, String)>,
4065}
4066
4067impl AliasScope {
4068 fn bind(&mut self, alias: &str, table: &str) {
4069 self.bindings.push((alias.to_string(), table.to_string()));
4070 }
4071
4072 /// The table an alias names. Later bindings shadow earlier ones, which is
4073 /// what makes a nested block's own alias win over an outer one.
4074 fn table_of(&self, alias: &str) -> Option<String> {
4075 self.bindings
4076 .iter()
4077 .rev()
4078 .find(|(bound, _)| bound == alias)
4079 .map(|(_, table)| table.clone())
4080 }
4081}
4082
4083/// Record a dependency once. The list is small and order is what the view
4084/// registry persists, so this keeps insertion order rather than sorting.
4085fn push_dep(deps: &mut Vec<String>, table: &str) {
4086 if !deps.iter().any(|dep| dep == table) {
4087 deps.push(table.to_string());
4088 }
4089}
4090
4091/// Refuse a schema no row of which can ever be stored.
4092///
4093/// A row lives in one page and cannot be split across pages: overflow pages
4094/// hold an individual large VALUE, not a row's own header. Once the null
4095/// bitmap, the fixed-column region and the variable-offset table exceed the
4096/// page's row budget on their own, every `insert` fails, including one that
4097/// sets nothing at all, so the type could be declared and could never hold a
4098/// row. Measured by encoding the emptiest row the schema allows rather than by
4099/// re-deriving the layout arithmetic here, so it stays true if the row format
4100/// changes.
4101fn refuse_schema_with_no_room_for_a_row(schema: &Schema) -> Result<(), QueryError> {
4102 let empty_row = vec![Value::Empty; schema.columns.len()];
4103 let size = powdb_storage::row::try_encode_row(schema, &empty_row)
4104 .map_err(QueryError::from_storage_io)?
4105 .len();
4106 if size <= powdb_storage::page::MAX_ROW_DATA_SIZE {
4107 return Ok(());
4108 }
4109 Err(QueryError::Execution(format!(
4110 "cannot store any row of '{}': its {} columns need {} bytes even with every value \
4111 missing, and a row must fit {} bytes; declare fewer columns",
4112 schema.table_name,
4113 schema.columns.len(),
4114 size,
4115 powdb_storage::page::MAX_ROW_DATA_SIZE
4116 )))
4117}