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