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