Skip to main content

powdb_query/executor/plan_exec/
dispatch.rs

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