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