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