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