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