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