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