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