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