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