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::row::{decode_row, RowLayout};
6use powdb_storage::types::*;
7use std::ops::ControlFlow;
8
9use crate::executor::compiled::*;
10use crate::executor::eval::*;
11use crate::executor::row_body_base;
12use crate::executor::{Engine, MAX_SORT_ROWS};
13use powdb_storage::view::ViewDef;
14
15use super::*;
16
17impl Engine {
18    pub fn execute_plan(&mut self, plan: &PlanNode) -> Result<QueryResult, QueryError> {
19        // Refuse any plan whose evaluable expressions still carry an aggregate
20        // FunctionCall the grouped-aggregate planner could not lower. Without
21        // this, such an aggregate would reach eval_expr and silently evaluate
22        // to Empty (a wrong answer). The outermost call validates the whole
23        // tree before any row is produced.
24        validate_no_stray_aggregates(plan)?;
25        validate_json_path_types(&self.catalog, plan)?;
26        match plan {
27            PlanNode::ExprIndexScan { .. }
28            | PlanNode::ExprRangeScan { .. }
29            | PlanNode::OrderedExprIndexScan { .. } => {
30                if let Some(result) = self.execute_expression_index_plan(plan, None)? {
31                    return Ok(result);
32                }
33                let fallback = expression_index_fallback(plan)
34                    .expect("expression-index branch always has a fallback");
35                self.execute_plan(&fallback)
36            }
37            PlanNode::SeqScan { table } => {
38                // Auto-refresh dirty materialized views on read.
39                if self.view_registry.is_dirty(table) {
40                    self.refresh_view(table)?;
41                }
42                let schema = self
43                    .catalog
44                    .schema(table)
45                    .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?
46                    .clone();
47                let columns: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
48                // Cooperative cancellation: a full-table scan of a huge table
49                // must stay stoppable.
50                let mut cancel = CancelCheck::new();
51                let mut rows: Vec<Vec<Value>> = Vec::new();
52                for (_, row) in self
53                    .catalog
54                    .scan(table)
55                    .map_err(|e| QueryError::StorageError(e.to_string()))?
56                {
57                    cancel.tick()?;
58                    rows.push(row);
59                }
60                Ok(QueryResult::Rows { columns, rows })
61            }
62
63            PlanNode::Filter { input, predicate } => {
64                // Materialize any IN-subqueries in the predicate before the
65                // scan loop — the closure can't call back into the engine.
66                // Correlated subqueries are left in place for per-row eval.
67                let materialized;
68                let predicate = if contains_subquery(predicate) {
69                    materialized = self.materialize_subqueries(predicate)?;
70                    &materialized
71                } else {
72                    predicate
73                };
74
75                // Correlated subquery path: per-row materialisation.
76                if contains_subquery(predicate) {
77                    let result = self.execute_plan(input)?;
78                    return match result {
79                        QueryResult::Rows { columns, rows } => {
80                            let mut filtered = Vec::new();
81                            // Cooperative cancellation: a subquery runs per outer
82                            // row, so a large outer scan must stay stoppable.
83                            let mut cancel = CancelCheck::new();
84                            for row in rows {
85                                cancel.tick()?;
86                                let row_pred =
87                                    self.materialize_correlated_for_row(predicate, &row, &columns)?;
88                                if eval_predicate(&row_pred, &row, &columns) {
89                                    filtered.push(row);
90                                }
91                            }
92                            Ok(QueryResult::Rows {
93                                columns,
94                                rows: filtered,
95                            })
96                        }
97                        _ => Err("filter requires row input".into()),
98                    };
99                }
100
101                // Lane A fast path: Filter over an equality-driven index scan.
102                // The index narrows the candidate rids; the residual is
103                // re-checked with a partial decode, full rows only for matches.
104                if matches!(
105                    input.as_ref(),
106                    PlanNode::IndexScan { .. } | PlanNode::ExprIndexScan { .. }
107                ) {
108                    if let Some(result) = self.try_filter_index_residual_fast(input, predicate)? {
109                        return Ok(result);
110                    }
111                }
112
113                // Fast path: fuse Filter + SeqScan into a zero-copy streaming
114                // loop. Uses decode_column() to evaluate the predicate on only
115                // the columns it references, avoiding heap allocations for
116                // String/Bytes columns that aren't part of the filter.
117                // Overflow safety (P0-4/P1): v2-capable tables fall through to
118                // the decoded general Filter path below — the raw fast path
119                // rehydrates to v1 and drops/mis-reads >= 64KB spilled values.
120                if let PlanNode::SeqScan { table } = input.as_ref() {
121                    if !self.catalog.table_has_overflow(table) {
122                        // Auto-refresh dirty materialized views.
123                        if self.view_registry.is_dirty(table) {
124                            self.refresh_view(table)?;
125                        }
126                        let schema = self
127                            .catalog
128                            .schema(table)
129                            .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?
130                            .clone();
131                        let columns: Vec<String> =
132                            schema.columns.iter().map(|c| c.name.clone()).collect();
133                        let fast = FastLayout::new(&schema);
134                        let row_layout = RowLayout::new(&schema);
135                        // Mission F: pre-size to skip the first 4 Vec doublings
136                        // (4 → 8 → 16 → 32 → 64). On a 100K-row scan with 30%
137                        // selectivity that's ~4 fewer reallocations + memcpys.
138                        let mut rows: Vec<Vec<Value>> = Vec::with_capacity(64);
139
140                        // Try compiled predicate for the filter check (handles
141                        // int leaves, string-eq leaves, and And conjunctions).
142                        // Cooperative cancellation: a full-table compiled/
143                        // selective predicate scan must stay stoppable, so use
144                        // the early-terminating scan and break on cancel. The
145                        // captured error is surfaced after the scan returns.
146                        let mut cancel = CancelCheck::new();
147                        let mut cancel_err: Option<QueryError> = None;
148                        if let Some(compiled) =
149                            compile_predicate(predicate, &columns, &fast, &schema)
150                        {
151                            self.catalog
152                                .try_for_each_row_raw(table, |_rid, data| {
153                                    if let Err(e) = cancel.tick() {
154                                        cancel_err = Some(e);
155                                        return ControlFlow::Break(());
156                                    }
157                                    if compiled(data) {
158                                        rows.push(decode_row(&schema, data));
159                                    }
160                                    ControlFlow::Continue(())
161                                })
162                                .map_err(|e| QueryError::StorageError(e.to_string()))?;
163                        } else {
164                            let pred_cols = predicate_column_indices_json(predicate, &columns);
165                            self.catalog
166                                .try_for_each_row_raw(table, |_rid, data| {
167                                    if let Err(e) = cancel.tick() {
168                                        cancel_err = Some(e);
169                                        return ControlFlow::Break(());
170                                    }
171                                    let pred_row =
172                                        decode_selective(&schema, &row_layout, data, &pred_cols);
173                                    if eval_predicate(predicate, &pred_row, &columns) {
174                                        rows.push(decode_row(&schema, data));
175                                    }
176                                    ControlFlow::Continue(())
177                                })
178                                .map_err(|e| QueryError::StorageError(e.to_string()))?;
179                        }
180                        if let Some(e) = cancel_err {
181                            return Err(e);
182                        }
183
184                        return Ok(QueryResult::Rows { columns, rows });
185                    }
186                }
187
188                // General path: materialise then filter.
189                let result = self.execute_plan(input)?;
190                match result {
191                    QueryResult::Rows { columns, rows } => {
192                        let mut cancel = CancelCheck::new();
193                        let mut filtered: Vec<Vec<Value>> = Vec::new();
194                        for row in rows {
195                            cancel.tick()?;
196                            if eval_predicate(predicate, &row, &columns) {
197                                filtered.push(row);
198                            }
199                        }
200                        Ok(QueryResult::Rows {
201                            columns,
202                            rows: filtered,
203                        })
204                    }
205                    _ => Err("filter requires row input".into()),
206                }
207            }
208
209            PlanNode::Project { input, fields } => {
210                if matches!(
211                    input.as_ref(),
212                    PlanNode::ExprIndexScan { .. }
213                        | PlanNode::ExprRangeScan { .. }
214                        | PlanNode::OrderedExprIndexScan { .. }
215                ) {
216                    if let Some(result) = self.execute_expression_index_plan(input, Some(fields))? {
217                        return Ok(result);
218                    }
219                }
220                // Fast path: Project over IndexScan — decode only projected
221                // columns from raw bytes instead of full decode_row.
222                if let PlanNode::IndexScan { table, column, key } = input.as_ref() {
223                    let schema = self
224                        .catalog
225                        .schema(table)
226                        .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?
227                        .clone();
228                    let all_columns: Vec<String> =
229                        schema.columns.iter().map(|c| c.name.clone()).collect();
230                    let key_value = literal_to_value(key)?;
231                    let tbl = self
232                        .catalog
233                        .get_table(table)
234                        .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
235
236                    let proj_columns: Vec<String> = fields
237                        .iter()
238                        .map(|f| {
239                            f.alias.clone().unwrap_or_else(|| match &f.expr {
240                                Expr::Field(name) => name.clone(),
241                                _ => "?".into(),
242                            })
243                        })
244                        .collect();
245
246                    // Determine which column indices the projection needs
247                    let proj_indices: Vec<usize> = fields
248                        .iter()
249                        .filter_map(|f| {
250                            if let Expr::Field(name) = &f.expr {
251                                all_columns.iter().position(|c| c == name)
252                            } else {
253                                None
254                            }
255                        })
256                        .collect();
257
258                    // Only serve plain-field projections here; a computed
259                    // projection (e.g. `length(.v)`) must fall through to the
260                    // generic expression-evaluating path — otherwise its column
261                    // is silently dropped (proj_indices only collects Fields).
262                    let all_plain_fields = fields.iter().all(|f| matches!(f.expr, Expr::Field(_)));
263                    if tbl.has_index(column) && all_plain_fields {
264                        let rids = tbl.index_lookup_all(column, &key_value);
265                        let mut rows: Vec<Vec<Value>> = Vec::with_capacity(rids.len());
266                        let mut cancel = CancelCheck::new();
267                        for rid in rids {
268                            cancel.tick()?;
269                            // Overflow safety (P0-3/P0-4): `tbl.get` reassembles
270                            // spilled columns from their overflow chains. The old
271                            // `heap.get` + `decode_column` read raw v2 bytes and
272                            // returned Empty for a spilled column (or wrapped a
273                            // >= 64KB value).
274                            if let Some(full) = tbl.get(rid) {
275                                let row: Vec<Value> =
276                                    proj_indices.iter().map(|&ci| full[ci].clone()).collect();
277                                rows.push(row);
278                            }
279                        }
280                        return Ok(QueryResult::Rows {
281                            columns: proj_columns,
282                            rows,
283                        });
284                    }
285                }
286
287                // Fast path: Project(Limit(Sort(Filter(SeqScan)))) — bounded
288                // top-N heap. Decodes only the sort key + projected columns,
289                // keeps at most `limit` rows in a heap. Also handles the
290                // Project(Limit(Sort(SeqScan))) variant (no filter).
291                if let PlanNode::Limit {
292                    input: inner,
293                    count: limit_expr,
294                } = input.as_ref()
295                {
296                    if let PlanNode::Sort {
297                        input: sort_input,
298                        keys,
299                    } = inner.as_ref()
300                    {
301                        // Fast path only for single-key sorts
302                        if keys.len() == 1 {
303                            if let Expr::Field(sort_field) = &keys[0].expr {
304                                let descending = keys[0].descending;
305                                let limit = match limit_expr {
306                                    Expr::Literal(Literal::Int(v)) if *v >= 0 => *v as usize,
307                                    _ => usize::MAX,
308                                };
309                                let (table_opt, pred_opt): (Option<&str>, Option<&Expr>) =
310                                    match sort_input.as_ref() {
311                                        PlanNode::SeqScan { table } => (Some(table.as_str()), None),
312                                        PlanNode::Filter {
313                                            input: fi,
314                                            predicate,
315                                        } => {
316                                            if let PlanNode::SeqScan { table } = fi.as_ref() {
317                                                (Some(table.as_str()), Some(predicate))
318                                            } else {
319                                                (None, None)
320                                            }
321                                        }
322                                        _ => (None, None),
323                                    };
324                                if let Some(table) = table_opt {
325                                    if let Some(result) = self.project_filter_sort_limit_fast(
326                                        table, fields, sort_field, descending, limit, pred_opt,
327                                    )? {
328                                        return Ok(result);
329                                    }
330                                }
331                            }
332                        }
333                    }
334                    // Fast path: Project(Limit(Filter(SeqScan))) — stream,
335                    // decode only projected columns, stop at limit.
336                    if let PlanNode::Filter {
337                        input: fi,
338                        predicate,
339                    } = inner.as_ref()
340                    {
341                        if let PlanNode::SeqScan { table } = fi.as_ref() {
342                            let limit = match limit_expr {
343                                Expr::Literal(Literal::Int(v)) if *v >= 0 => *v as usize,
344                                _ => usize::MAX,
345                            };
346                            if let Some(result) = self.project_filter_limit_fast(
347                                table,
348                                fields,
349                                limit,
350                                Some(predicate),
351                            )? {
352                                return Ok(result);
353                            }
354                        }
355                    }
356                    // Fast path: Project(Limit(SeqScan)) — stream, no filter.
357                    if let PlanNode::SeqScan { table } = inner.as_ref() {
358                        let limit = match limit_expr {
359                            Expr::Literal(Literal::Int(v)) if *v >= 0 => *v as usize,
360                            _ => usize::MAX,
361                        };
362                        if let Some(result) =
363                            self.project_filter_limit_fast(table, fields, limit, None)?
364                        {
365                            return Ok(result);
366                        }
367                    }
368                }
369
370                // Mission D4: Project(Filter(SeqScan)) without Limit. Reuses
371                // `project_filter_limit_fast` with limit = usize::MAX so the
372                // hot loop decodes only projected columns and uses the
373                // compiled predicate. Previously this fell through to the
374                // generic Filter branch which materialised every column via
375                // `decode_row` then re-projected — quadratic work.
376                //
377                // multi_col_and_filter (`U filter .age > 30 and .status =
378                // "active" { .name, .age }`) was 6.18ms (0.7x SQLite) and
379                // is the load-bearing workload for this fast path.
380                if let PlanNode::Filter {
381                    input: fi,
382                    predicate,
383                } = input.as_ref()
384                {
385                    if let PlanNode::SeqScan { table } = fi.as_ref() {
386                        if let Some(result) = self.project_filter_limit_fast(
387                            table,
388                            fields,
389                            usize::MAX,
390                            Some(predicate),
391                        )? {
392                            return Ok(result);
393                        }
394                    }
395                }
396
397                // Mission D4: Project(SeqScan) without Filter or Limit.
398                // Decode only projected columns; the previous fall-through
399                // built full Vec<Value> rows then re-projected.
400                if let PlanNode::SeqScan { table } = input.as_ref() {
401                    if let Some(result) =
402                        self.project_filter_limit_fast(table, fields, usize::MAX, None)?
403                    {
404                        return Ok(result);
405                    }
406                }
407
408                let result = self.execute_plan(input)?;
409                match result {
410                    QueryResult::Rows { columns, rows } => {
411                        let proj_columns: Vec<String> = fields
412                            .iter()
413                            .map(|f| {
414                                f.alias.clone().unwrap_or_else(|| match &f.expr {
415                                    Expr::Field(name) => name.clone(),
416                                    // Mission E1.2: `{ u.name }` projects as the
417                                    // qualified column name so callers can still
418                                    // disambiguate across the join output.
419                                    Expr::QualifiedField { qualifier, field } => {
420                                        format!("{qualifier}.{field}")
421                                    }
422                                    _ => "?".into(),
423                                })
424                            })
425                            .collect();
426                        let mut cancel = CancelCheck::new();
427                        let mut proj_rows: Vec<Vec<Value>> = Vec::with_capacity(rows.len());
428                        for row in &rows {
429                            cancel.tick()?;
430                            proj_rows.push(
431                                fields
432                                    .iter()
433                                    .map(|f| eval_expr(&f.expr, row, &columns))
434                                    .collect(),
435                            );
436                        }
437                        Ok(QueryResult::Rows {
438                            columns: proj_columns,
439                            rows: proj_rows,
440                        })
441                    }
442                    _ => Err("project requires row input".into()),
443                }
444            }
445
446            PlanNode::Sort { input, keys } => {
447                let result = self.execute_plan(input)?;
448                match result {
449                    QueryResult::Rows { columns, mut rows } => {
450                        // WS2: row-count cap is a cheap secondary guard; the
451                        // byte budget is the real OOM defense for the sort
452                        // buffer (a few very large rows pass the row cap).
453                        if rows.len() > MAX_SORT_ROWS {
454                            return Err(QueryError::SortLimitExceeded);
455                        }
456                        self.charge_rows(&rows)?;
457                        let key_specs: Vec<(Option<usize>, &Expr, bool)> = keys
458                            .iter()
459                            .map(|k| {
460                                let stored_name = match &k.expr {
461                                    Expr::Field(name) => Some(name.clone()),
462                                    Expr::QualifiedField { qualifier, field } => {
463                                        Some(format!("{qualifier}.{field}"))
464                                    }
465                                    _ => None,
466                                };
467                                let index = stored_name
468                                    .as_ref()
469                                    .and_then(|name| columns.iter().position(|c| c == name));
470                                if let Some(name) = stored_name {
471                                    if index.is_none() {
472                                        return Err(QueryError::ColumnNotFound {
473                                            table: String::new(),
474                                            column: name,
475                                        });
476                                    }
477                                }
478                                Ok((index, &k.expr, k.descending))
479                            })
480                            .collect::<Result<_, QueryError>>()?;
481                        cooperative_stable_sort_by(&mut rows, self.query_memory_limit, |a, b| {
482                            for &(col_idx, expr, descending) in &key_specs {
483                                let (left_value, right_value) = match col_idx {
484                                    Some(index) => (&a[index], &b[index]),
485                                    None => {
486                                        let left = eval_expr(expr, a, &columns);
487                                        let right = eval_expr(expr, b, &columns);
488                                        let cmp = compare_order_values(&left, &right, descending);
489                                        if cmp != std::cmp::Ordering::Equal {
490                                            return cmp;
491                                        }
492                                        continue;
493                                    }
494                                };
495                                let cmp = compare_order_values(left_value, right_value, descending);
496                                if cmp != std::cmp::Ordering::Equal {
497                                    return cmp;
498                                }
499                            }
500                            std::cmp::Ordering::Equal
501                        })?;
502                        Ok(QueryResult::Rows { columns, rows })
503                    }
504                    _ => Err("sort requires row input".into()),
505                }
506            }
507
508            PlanNode::Limit { input, count } => {
509                let result = self.execute_plan(input)?;
510                let n = match count {
511                    Expr::Literal(Literal::Int(v)) => *v as usize,
512                    _ => return Err("limit must be integer literal".into()),
513                };
514                match result {
515                    QueryResult::Rows { columns, rows } => {
516                        let mut cancel = CancelCheck::new();
517                        let mut limited = Vec::with_capacity(n.min(rows.len()));
518                        for row in rows.into_iter().take(n) {
519                            cancel.tick()?;
520                            limited.push(row);
521                        }
522                        Ok(QueryResult::Rows {
523                            columns,
524                            rows: limited,
525                        })
526                    }
527                    _ => Err("limit requires row input".into()),
528                }
529            }
530
531            PlanNode::Offset { input, count } => {
532                let result = self.execute_plan(input)?;
533                let n = match count {
534                    Expr::Literal(Literal::Int(v)) => *v as usize,
535                    _ => return Err("offset must be integer literal".into()),
536                };
537                match result {
538                    QueryResult::Rows { columns, rows } => {
539                        let mut cancel = CancelCheck::new();
540                        let mut offset = Vec::with_capacity(rows.len().saturating_sub(n));
541                        for (index, row) in rows.into_iter().enumerate() {
542                            cancel.tick()?;
543                            if index >= n {
544                                offset.push(row);
545                            }
546                        }
547                        Ok(QueryResult::Rows {
548                            columns,
549                            rows: offset,
550                        })
551                    }
552                    _ => Err("offset requires row input".into()),
553                }
554            }
555
556            PlanNode::Aggregate {
557                input,
558                function,
559                argument,
560                mode: _,
561                provenance_alias,
562            } => {
563                if let Some(provenance_alias) = provenance_alias {
564                    let input = self.materialize_rows_with_provenance(input)?;
565                    self.charge_rows(&input.rows)?;
566                    return aggregate_rows_with_provenance(
567                        *function,
568                        argument.as_ref(),
569                        &input,
570                        provenance_alias,
571                        self.query_memory_limit(),
572                    );
573                }
574                // Fast path: count() over SeqScan — count rows without any decode
575                if *function == AggFunc::Count {
576                    // Overflow safety (P0-4): the raw `for_each_row_raw` count
577                    // drops any row too large to re-inline (>= 64KB) and would
578                    // undercount; v2-capable tables use the decoded generic path.
579                    if let PlanNode::SeqScan { table } = input.as_ref() {
580                        if !self.catalog.table_has_overflow(table) {
581                            // Auto-refresh a dirty materialized view before
582                            // counting it — otherwise count(View) returns stale
583                            // data after an underlying mutation (F3).
584                            if self.view_registry.is_dirty(table) {
585                                self.refresh_view(table)?;
586                            }
587                            let mut count: i64 = 0;
588                            for_each_row_raw_cancellable(&self.catalog, table, |_rid, _data| {
589                                count += 1;
590                            })?;
591                            return Ok(QueryResult::Scalar(Value::Int(count)));
592                        }
593                    }
594                    // Fast path: count() over Filter(SeqScan) — try compiled
595                    // predicate first, fall back to decode_column path.
596                    // Skip a predicate carrying a subquery: the raw-bytes
597                    // evaluators here don't materialise subqueries, so
598                    // `count(T filter .x in (...))` would silently count 0
599                    // (F1). Falling through routes it to the generic path
600                    // that resolves the subquery correctly.
601                    if let PlanNode::Filter {
602                        input: inner,
603                        predicate,
604                    } = input.as_ref()
605                    {
606                        if let PlanNode::SeqScan { table } = inner.as_ref() {
607                            if self.view_registry.is_dirty(table) {
608                                self.refresh_view(table)?;
609                            }
610                        }
611                        if let (PlanNode::SeqScan { table }, false) =
612                            (inner.as_ref(), contains_subquery(predicate))
613                        {
614                            if !self.catalog.table_has_overflow(table) {
615                                let schema = self
616                                    .catalog
617                                    .schema(table)
618                                    .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?
619                                    .clone();
620                                let columns: Vec<String> =
621                                    schema.columns.iter().map(|c| c.name.clone()).collect();
622                                let fast = FastLayout::new(&schema);
623                                let row_layout = RowLayout::new(&schema);
624
625                                // Try compiled predicate (zero-allocation hot path).
626                                // Handles int leaves, string-eq leaves, AND conjunctions.
627                                if let Some(compiled) =
628                                    compile_predicate(predicate, &columns, &fast, &schema)
629                                {
630                                    let mut count: i64 = 0;
631                                    for_each_row_raw_cancellable(
632                                        &self.catalog,
633                                        table,
634                                        |_rid, data| {
635                                            if compiled(data) {
636                                                count += 1;
637                                            }
638                                        },
639                                    )?;
640                                    return Ok(QueryResult::Scalar(Value::Int(count)));
641                                }
642
643                                // Fallback: decode predicate columns
644                                let pred_cols = predicate_column_indices_json(predicate, &columns);
645                                let mut count: i64 = 0;
646                                for_each_row_raw_cancellable(
647                                    &self.catalog,
648                                    table,
649                                    |_rid, data| {
650                                        let pred_row = decode_selective(
651                                            &schema,
652                                            &row_layout,
653                                            data,
654                                            &pred_cols,
655                                        );
656                                        if eval_predicate(predicate, &pred_row, &columns) {
657                                            count += 1;
658                                        }
659                                    },
660                                )?;
661
662                                return Ok(QueryResult::Scalar(Value::Int(count)));
663                            }
664                        }
665                    }
666                }
667
668                // Fast path: sum/avg/min/max over a single fixed-size int
669                // column with an optional compiled filter predicate. Walks
670                // raw row bytes, zero allocation per row.
671                if matches!(
672                    function,
673                    AggFunc::Sum
674                        | AggFunc::Avg
675                        | AggFunc::Min
676                        | AggFunc::Max
677                        | AggFunc::CountDistinct
678                ) {
679                    if let Some(Expr::Field(col)) = argument.as_ref() {
680                        // Shape: Aggregate(SeqScan) or Aggregate(Filter(SeqScan))
681                        let (table_opt, pred_opt): (Option<&str>, Option<&Expr>) =
682                            match input.as_ref() {
683                                PlanNode::SeqScan { table } => (Some(table.as_str()), None),
684                                PlanNode::Filter {
685                                    input: inner,
686                                    predicate,
687                                } => {
688                                    if let PlanNode::SeqScan { table } = inner.as_ref() {
689                                        (Some(table.as_str()), Some(predicate))
690                                    } else {
691                                        (None, None)
692                                    }
693                                }
694                                _ => (None, None),
695                            };
696                        if let Some(table) = table_opt {
697                            if let Some(result) =
698                                self.agg_single_col_fast(table, col, *function, pred_opt)?
699                            {
700                                return Ok(result);
701                            }
702                        }
703                    }
704                }
705
706                // Fast path: Project(Limit(Filter(SeqScan))) — stream, decode
707                // only projected columns, stop once we hit the limit.
708                // (Handled in the Project branch; this branch only fires when
709                // the aggregate is the outer node.)
710                let result = self.execute_plan(input)?;
711                match result {
712                    QueryResult::Rows { columns, rows } => {
713                        aggregate_rows(*function, argument.as_ref(), &columns, &rows)
714                    }
715                    _ => Err("aggregate requires row input".into()),
716                }
717            }
718
719            PlanNode::Insert {
720                table,
721                rows,
722                returning,
723            } => {
724                // Build + validate EVERY row before inserting any, so a bad
725                // row (unknown/missing/uncoercible field) aborts the whole
726                // statement without a partial write. The WAL fsync happens
727                // once at statement end, so N rows = N appends + 1 fsync.
728                let mut returning_columns: Vec<String> = Vec::new();
729                let all_values: Vec<Vec<Value>> = {
730                    let schema = self
731                        .catalog
732                        .schema(table)
733                        .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
734                    if *returning {
735                        returning_columns = schema.columns.iter().map(|c| c.name.clone()).collect();
736                    }
737                    let defaults = self.catalog.column_defaults(table).unwrap_or(&[]);
738                    let auto = self.catalog.auto_columns(table).unwrap_or(&[]);
739                    let mut all = Vec::with_capacity(rows.len());
740                    for assignments in rows {
741                        let mut values = vec![Value::Empty; schema.columns.len()];
742                        for a in assignments {
743                            let idx = schema.column_index(&a.field).ok_or_else(|| {
744                                QueryError::ColumnNotFound {
745                                    table: String::new(),
746                                    column: a.field.clone(),
747                                }
748                            })?;
749                            let raw = literal_to_value(&a.value)?;
750                            values[idx] = coerce_value(raw, &schema.columns[idx])?;
751                        }
752                        // Fill any column left unset by this row from its
753                        // declared default (applied before the required check,
754                        // so a default satisfies a required column).
755                        for (i, slot) in values.iter_mut().enumerate() {
756                            if slot.is_empty() {
757                                if let Some(Some(d)) = defaults.get(i) {
758                                    *slot = d.clone();
759                                }
760                            }
761                        }
762                        for col in &schema.columns {
763                            let pos = col.position as usize;
764                            // Auto columns are exempt from the required check —
765                            // they are filled from the sequence just below.
766                            let is_auto = auto.get(pos).copied().unwrap_or(false);
767                            if col.required && !is_auto && matches!(values[pos], Value::Empty) {
768                                return Err(QueryError::Execution(format!(
769                                    "column '{}' is required but no value was provided",
770                                    col.name
771                                )));
772                            }
773                        }
774                        all.push(values);
775                    }
776                    all
777                };
778                // Assign auto-increment columns now that the immutable
779                // schema/defaults/auto borrows are released. Done here (not in
780                // the build loop) so the assigned ids land in `all_values` and
781                // flow back through `returning`.
782                let mut all_values = all_values;
783                for values in all_values.iter_mut() {
784                    self.catalog.assign_auto_columns(table, values);
785                }
786                // Charge the materialized batch against the per-query memory
787                // budget before inserting — keeps multi-row insert consistent
788                // with every other full-materialization point (sort/join/group)
789                // and bounds embedded callers (the server also caps the query
790                // string at 1 MB, but embedded callers have no such limit).
791                self.charge_rows(&all_values)?;
792                let n = all_values.len() as u64;
793                for values in &all_values {
794                    self.catalog
795                        .insert(table, values)
796                        .map_err(|e| QueryError::StorageError(e.to_string()))?;
797                }
798                self.view_registry.mark_dependents_dirty(table);
799                if *returning {
800                    Ok(QueryResult::Rows {
801                        columns: returning_columns,
802                        rows: all_values,
803                    })
804                } else {
805                    Ok(QueryResult::Modified(n))
806                }
807            }
808
809            PlanNode::Upsert {
810                table,
811                key_column,
812                assignments,
813                on_conflict,
814            } => {
815                let (values, key_idx) = {
816                    let schema = self
817                        .catalog
818                        .schema(table)
819                        .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
820                    let mut values = vec![Value::Empty; schema.columns.len()];
821                    for a in assignments {
822                        let idx = schema.column_index(&a.field).ok_or_else(|| {
823                            QueryError::ColumnNotFound {
824                                table: String::new(),
825                                column: a.field.clone(),
826                            }
827                        })?;
828                        let raw = literal_to_value(&a.value)?;
829                        values[idx] = coerce_value(raw, &schema.columns[idx])?;
830                    }
831                    // Apply column defaults for the insert path, same as a plain
832                    // insert (applied before the required-column check).
833                    let defaults = self.catalog.column_defaults(table).unwrap_or(&[]);
834                    for (i, slot) in values.iter_mut().enumerate() {
835                        if slot.is_empty() {
836                            if let Some(Some(d)) = defaults.get(i) {
837                                *slot = d.clone();
838                            }
839                        }
840                    }
841                    for col in &schema.columns {
842                        if col.required && matches!(values[col.position as usize], Value::Empty) {
843                            return Err(QueryError::Execution(format!(
844                                "column '{}' is required but no value was provided",
845                                col.name
846                            )));
847                        }
848                    }
849                    let key_idx = schema
850                        .column_index(key_column)
851                        .ok_or_else(|| format!("key column '{key_column}' not found"))?;
852                    (values, key_idx)
853                };
854
855                // Upsert requires the `on` column to be unique — otherwise
856                // there is no well-defined row to overwrite and a plain
857                // insert could silently create duplicate keys.
858                if self.catalog.is_index_unique(table, key_column) != Some(true) {
859                    return Err(QueryError::Execution(format!(
860                        "upsert on .{key_column} requires a unique column (declare it with \
861                         `unique {key_column}: <type>` or `alter {table} add unique .{key_column}`)"
862                    )));
863                }
864
865                let key_value = values[key_idx].clone();
866
867                // Probe the unique index for a conflict.
868                let existing = {
869                    let tbl = self
870                        .catalog
871                        .get_table(table)
872                        .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
873                    // The key column is guaranteed unique above, so this
874                    // returns at most one matching row.
875                    let rids = tbl.index_lookup_all(key_column, &key_value);
876                    // Overflow safety (P0-3): reassemble via `tbl.get` so an
877                    // upsert conflict row with a spilled column is read in full.
878                    rids.into_iter()
879                        .next()
880                        .and_then(|rid| tbl.get(rid).map(|row| (rid, row)))
881                };
882
883                if let Some((rid, mut existing_row)) = existing {
884                    // Conflict: apply on_conflict assignments (or all non-key if empty).
885                    let update_assignments = if on_conflict.is_empty() {
886                        assignments
887                    } else {
888                        on_conflict
889                    };
890                    let changed_cols: Vec<usize> = {
891                        let schema = self
892                            .catalog
893                            .schema(table)
894                            .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
895                        let mut indices = Vec::new();
896                        for a in update_assignments {
897                            let idx = schema.column_index(&a.field).ok_or_else(|| {
898                                QueryError::ColumnNotFound {
899                                    table: String::new(),
900                                    column: a.field.clone(),
901                                }
902                            })?;
903                            if idx != key_idx {
904                                // Coerce to the target column type, same as the
905                                // UPDATE and INSERT paths — an int→float literal
906                                // here would otherwise persist as raw i64 bits
907                                // (#118 corruption on the upsert conflict path).
908                                existing_row[idx] =
909                                    coerce_value(literal_to_value(&a.value)?, &schema.columns[idx])
910                                        .map_err(QueryError::TypeError)?;
911                                indices.push(idx);
912                            }
913                        }
914                        indices
915                    };
916                    self.catalog
917                        .update_hinted(table, rid, &existing_row, Some(&changed_cols))
918                        .map_err(|e| QueryError::StorageError(e.to_string()))?;
919                    self.view_registry.mark_dependents_dirty(table);
920                    Ok(QueryResult::Modified(1))
921                } else {
922                    // No conflict: insert.
923                    self.catalog
924                        .insert(table, &values)
925                        .map_err(|e| QueryError::StorageError(e.to_string()))?;
926                    self.view_registry.mark_dependents_dirty(table);
927                    Ok(QueryResult::Modified(1))
928                }
929            }
930
931            PlanNode::Update {
932                input,
933                table,
934                assignments,
935                returning,
936            } => {
937                // Mission C Phase 3: resolve assignments against a borrowed
938                // schema, then drop the borrow before the mutation loop.
939                // Try literal-only path first; fall back to per-row expression
940                // evaluation if any assignment contains a non-literal expression
941                // (e.g., `age := .age + 1`).
942                let (col_indices, literal_vals, target_cols): (
943                    Vec<usize>,
944                    Option<Vec<Value>>,
945                    Vec<ColumnDef>,
946                ) = {
947                    let schema_ref = self
948                        .catalog
949                        .schema(table)
950                        .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
951                    let indices: Vec<usize> = assignments
952                        .iter()
953                        .map(|a| {
954                            schema_ref.column_index(&a.field).ok_or_else(|| {
955                                QueryError::ColumnNotFound {
956                                    table: String::new(),
957                                    column: a.field.clone(),
958                                }
959                            })
960                        })
961                        .collect::<Result<_, _>>()?;
962                    // The target column defs (aligned with `assignments`), owned
963                    // so the per-row expression path can coerce without holding a
964                    // catalog borrow across the mutation loop.
965                    let target_cols: Vec<ColumnDef> = indices
966                        .iter()
967                        .map(|&idx| schema_ref.columns[idx].clone())
968                        .collect();
969                    // Resolve each assignment to a literal value. If any is a
970                    // non-literal expression, fall back (None) to the per-row
971                    // expression-eval path below.
972                    let raw_vals: Result<Vec<Value>, _> = assignments
973                        .iter()
974                        .map(|a| literal_to_value(&a.value))
975                        .collect();
976                    // Coerce each literal to its target column's declared type
977                    // before it can reach the byte-patch fast path (the same
978                    // coercion the INSERT path applies). Without this, an int
979                    // assigned to a float column is written as raw i64 bits
980                    // (#118 silent corruption) and a str assigned to a
981                    // fixed-size column reaches `unreachable!` and aborts the
982                    // whole server (#117 remote DoS). A genuine type mismatch
983                    // is a hard error to the client, not an expr-path fallback.
984                    let coerced = match raw_vals {
985                        Ok(raws) => {
986                            let mut out = Vec::with_capacity(raws.len());
987                            for (raw, &idx) in raws.into_iter().zip(indices.iter()) {
988                                out.push(
989                                    coerce_value(raw, &schema_ref.columns[idx])
990                                        .map_err(QueryError::TypeError)?,
991                                );
992                            }
993                            Some(out)
994                        }
995                        Err(_) => None,
996                    };
997                    (indices, coerced, target_cols)
998                };
999                let resolved_assignments: Option<Vec<(usize, Value)>> =
1000                    literal_vals.map(|vals| col_indices.iter().copied().zip(vals).collect());
1001
1002                // Mission C Phase 2: the hint Table::update_hinted needs to
1003                // decide whether to read the old row for index diff.
1004                let changed_cols: Vec<usize> = col_indices.clone();
1005
1006                // ── RETURNING path ──────────────────────────────────────
1007                // `returning` materializes the post-update row image, so the
1008                // byte-patch / fused fast paths (which never decode a row)
1009                // can't serve it. Take the generic decode→mutate→collect
1010                // route. Opt-in only: when `returning` is false every path
1011                // below is byte-for-byte unchanged.
1012                if *returning {
1013                    let columns: Vec<String> = {
1014                        let schema_ref = self
1015                            .catalog
1016                            .schema(table)
1017                            .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
1018                        schema_ref.columns.iter().map(|c| c.name.clone()).collect()
1019                    };
1020                    let matching_rids = self.collect_rids_for_mutation(input, table)?;
1021                    let mut out_rows: Vec<Vec<Value>> = Vec::with_capacity(matching_rids.len());
1022                    // Cancellation is safe while collecting the target set, but
1023                    // once row writes start this executor has no statement-level
1024                    // savepoint. Check at the mutation boundary and then apply the
1025                    // full set without mid-loop cancellation; returning an error
1026                    // after a logged prefix would violate statement atomicity and
1027                    // is especially unsafe inside an explicit transaction.
1028                    crate::cancel::check()?;
1029                    for rid in matching_rids {
1030                        let mut row = match self.catalog.get(table, rid) {
1031                            Some(r) => r,
1032                            None => continue,
1033                        };
1034                        match &resolved_assignments {
1035                            // Literal path: apply the pre-coerced values.
1036                            Some(resolved) => {
1037                                for (idx, val) in resolved.iter() {
1038                                    row[*idx] = val.clone();
1039                                }
1040                            }
1041                            // Expression path: evaluate each RHS against the
1042                            // (progressively mutated) row, then coerce to the
1043                            // target column type before writing — same guard the
1044                            // literal path gets, matching the non-returning expr
1045                            // path exactly (#117/#118 on computed assignments).
1046                            None => {
1047                                for (i, asgn) in assignments.iter().enumerate() {
1048                                    let val = eval_expr(&asgn.value, &row, &columns);
1049                                    row[col_indices[i]] = coerce_value(val, &target_cols[i])
1050                                        .map_err(QueryError::TypeError)?;
1051                                }
1052                            }
1053                        }
1054                        self.catalog
1055                            .update_hinted(table, rid, &row, Some(&changed_cols))
1056                            .map_err(|e| QueryError::StorageError(e.to_string()))?;
1057                        out_rows.push(row);
1058                    }
1059                    self.view_registry.mark_dependents_dirty(table);
1060                    return Ok(QueryResult::Rows {
1061                        columns,
1062                        rows: out_rows,
1063                    });
1064                }
1065
1066                // ── Fused scan+update for Update(Filter(SeqScan)) ────────
1067                // Perf sprint: instead of the two-pass collect-RIDs-then-loop
1068                // pattern (which pays one ensure_hot per matched row on the
1069                // second pass), fuse the predicate evaluation and in-place
1070                // byte-level mutation into a single heap walk. Same idea as
1071                // the fused scan_delete_matching path for deletes.
1072                if let Some(ref resolved_assignments) = resolved_assignments {
1073                    if let PlanNode::Filter {
1074                        input: inner,
1075                        predicate,
1076                    } = input.as_ref()
1077                    {
1078                        if let PlanNode::SeqScan { table: t } = inner.as_ref() {
1079                            if t == table {
1080                                // The fused primitive mutates during its scan and
1081                                // cannot roll back a cancelled prefix. Honor an
1082                                // already-triggered token before entering it, then
1083                                // let the primitive finish atomically from the
1084                                // query layer's perspective.
1085                                crate::cancel::check()?;
1086                                let fused_result = self.try_fused_scan_update(
1087                                    table,
1088                                    predicate,
1089                                    resolved_assignments,
1090                                    &changed_cols,
1091                                );
1092                                if let Some(result) = fused_result {
1093                                    return result;
1094                                }
1095                            }
1096                        }
1097                    }
1098                }
1099
1100                // Collect matching RowIds in a single pass.
1101                let matching_rids = self.collect_rids_for_mutation(input, table)?;
1102                // This is the last cancellable boundary before any row is
1103                // changed. Mutation loops below deliberately do not poll.
1104                crate::cancel::check()?;
1105
1106                // ── Literal-only fast paths ─────────────────────────────
1107                if let Some(ref resolved_assignments) = resolved_assignments {
1108                    // Mission C Phase 4: in-place byte-patch fast path. If every
1109                    // assignment targets a fixed-size non-null column AND none of
1110                    // them is indexed, we can skip decode_row / Vec<Value> /
1111                    // encode_row_into entirely and patch the row's raw bytes on
1112                    // the hot page.
1113                    let fast_patch: Option<Vec<FastPatch>> = {
1114                        let tbl = self
1115                            .catalog
1116                            .get_table(table)
1117                            .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
1118                        let schema = tbl.schema();
1119                        // Overflow safety (P0): byte-patching a v2 row with v1
1120                        // offsets corrupts it. Overflow tables take the generic
1121                        // reassembling `get` + `update_hinted` path below.
1122                        let all_fixed_nonnull = !tbl.has_overflow_rows()
1123                            && resolved_assignments.iter().all(|(idx, val)| {
1124                                is_fixed_size(schema.columns[*idx].type_id) && !val.is_empty()
1125                            });
1126                        let no_indexed = !resolved_assignments
1127                            .iter()
1128                            .any(|(idx, _)| tbl.has_indexed_col(*idx));
1129
1130                        if all_fixed_nonnull && no_indexed {
1131                            let layout = RowLayout::new(schema);
1132                            let bitmap_size = layout.bitmap_size();
1133                            let patches: Vec<FastPatch> = resolved_assignments
1134                                .iter()
1135                                .map(|(idx, val)| {
1136                                    let fixed_off = layout
1137                                        .fixed_offset(*idx)
1138                                        .expect("is_fixed_size already checked");
1139                                    let field_off = 2 + bitmap_size + fixed_off;
1140                                    let bytes: FixedBytes = match val {
1141                                        Value::Int(v) => FixedBytes::I64(v.to_le_bytes()),
1142                                        Value::Float(v) => FixedBytes::F64(v.to_le_bytes()),
1143                                        Value::Bool(v) => FixedBytes::Bool(if *v { 1 } else { 0 }),
1144                                        Value::DateTime(v) => FixedBytes::I64(v.to_le_bytes()),
1145                                        Value::Uuid(v) => FixedBytes::Uuid(*v),
1146                                        _ => unreachable!("all_fixed_nonnull guard lied"),
1147                                    };
1148                                    FastPatch {
1149                                        field_off,
1150                                        bitmap_byte_off: 2 + idx / 8,
1151                                        bit_mask: 1u8 << (idx % 8),
1152                                        bytes,
1153                                    }
1154                                })
1155                                .collect();
1156                            Some(patches)
1157                        } else {
1158                            None
1159                        }
1160                    };
1161
1162                    if let Some(patches) = fast_patch {
1163                        let mut count = 0u64;
1164                        let mut fallback_rids: Vec<RowId> = Vec::new();
1165                        for rid in &matching_rids {
1166                            // Mission B2: WAL-log every patch so crash
1167                            // recovery replays the update. Same mutation
1168                            // closure as before — the wrapper just sandwiches
1169                            // it between a hot-page read and a WAL append.
1170                            //
1171                            // A false return means the byte-patch was refused
1172                            // (e.g. a v2/overflow row whose in-place layout the
1173                            // fast path cannot compute, reachable on a legacy
1174                            // heap where has_overflow_rows() under-reports). Do
1175                            // NOT drop the row: push it to `fallback_rids` and
1176                            // let the reassembling get + update_hinted path
1177                            // apply it, mirroring the var-column fast path
1178                            // below. The fast path is thus a pure optimization
1179                            // that can never silently lose an update.
1180                            let ok = self
1181                                .catalog
1182                                .update_row_bytes_logged(table, *rid, |row| {
1183                                    let base = row_body_base(row);
1184                                    for p in &patches {
1185                                        row[base + p.bitmap_byte_off] &= !p.bit_mask;
1186                                        let field_bytes = p.bytes.as_slice();
1187                                        row[base + p.field_off
1188                                            ..base + p.field_off + field_bytes.len()]
1189                                            .copy_from_slice(field_bytes);
1190                                    }
1191                                })
1192                                .map_err(|e| QueryError::StorageError(e.to_string()))?;
1193                            if ok {
1194                                count += 1;
1195                            } else {
1196                                fallback_rids.push(*rid);
1197                            }
1198                        }
1199                        for rid in fallback_rids {
1200                            let mut row = match self.catalog.get(table, rid) {
1201                                Some(r) => r,
1202                                None => continue,
1203                            };
1204                            for (idx, val) in resolved_assignments.iter() {
1205                                row[*idx] = val.clone();
1206                            }
1207                            self.catalog
1208                                .update_hinted(table, rid, &row, Some(&changed_cols))
1209                                .map_err(|e| QueryError::StorageError(e.to_string()))?;
1210                            count += 1;
1211                        }
1212                        self.view_registry.mark_dependents_dirty(table);
1213                        return Ok(QueryResult::Modified(count));
1214                    }
1215
1216                    // Mission C Phase 10: var-column in-place shrink fast path.
1217                    let var_fast: Option<(usize, Option<Vec<u8>>)> = {
1218                        let tbl = self
1219                            .catalog
1220                            .get_table(table)
1221                            .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
1222                        let schema = tbl.schema();
1223                        // Overflow safety (P0/P0-2): the in-place var shrink
1224                        // patch computes v1 offsets — never on a v2-capable
1225                        // table. Falls through to the reassembling path.
1226                        let is_single = resolved_assignments.len() == 1 && !tbl.has_overflow_rows();
1227                        let is_var_col = is_single
1228                            && !is_fixed_size(schema.columns[resolved_assignments[0].0].type_id);
1229                        let no_indexed = !resolved_assignments
1230                            .iter()
1231                            .any(|(idx, _)| tbl.has_indexed_col(*idx));
1232
1233                        if is_single && is_var_col && no_indexed {
1234                            let (idx, val) = &resolved_assignments[0];
1235                            let bytes_opt: Option<Vec<u8>> = match val {
1236                                Value::Str(s) => Some(s.as_bytes().to_vec()),
1237                                Value::Bytes(b) => Some(b.clone()),
1238                                // A json column stores its PJ1 bytes as the var
1239                                // payload (u32 length prefix + bytes, like Bytes),
1240                                // so the in-place patch writes them verbatim.
1241                                Value::Json(b) => Some(b.to_vec()),
1242                                Value::Empty => None,
1243                                _ => {
1244                                    return Err(QueryError::TypeError(format!(
1245                                        "cannot assign non-var value to var column '{}'",
1246                                        schema.columns[*idx].name
1247                                    )))
1248                                }
1249                            };
1250                            Some((*idx, bytes_opt))
1251                        } else {
1252                            None
1253                        }
1254                    };
1255
1256                    if let Some((col_idx, new_bytes_opt)) = var_fast {
1257                        let new_bytes_ref: Option<&[u8]> = new_bytes_opt.as_deref();
1258                        let mut count = 0u64;
1259                        let mut fallback_rids: Vec<RowId> = Vec::new();
1260                        for rid in &matching_rids {
1261                            // Mission B2: logged variant so crash recovery
1262                            // replays the shrink. On a false return (row
1263                            // would have to grow), the rid is pushed to
1264                            // `fallback_rids` and the slower `update_hinted`
1265                            // path — which is already WAL-logged — picks it up.
1266                            let ok = self
1267                                .catalog
1268                                .patch_var_col_logged(table, *rid, col_idx, new_bytes_ref)
1269                                .map_err(|e| QueryError::StorageError(e.to_string()))?;
1270                            if ok {
1271                                count += 1;
1272                            } else {
1273                                fallback_rids.push(*rid);
1274                            }
1275                        }
1276                        for rid in fallback_rids {
1277                            let mut row = match self.catalog.get(table, rid) {
1278                                Some(r) => r,
1279                                None => continue,
1280                            };
1281                            for (idx, val) in resolved_assignments.iter() {
1282                                row[*idx] = val.clone();
1283                            }
1284                            self.catalog
1285                                .update_hinted(table, rid, &row, Some(&changed_cols))
1286                                .map_err(|e| QueryError::StorageError(e.to_string()))?;
1287                            count += 1;
1288                        }
1289                        self.view_registry.mark_dependents_dirty(table);
1290                        return Ok(QueryResult::Modified(count));
1291                    }
1292
1293                    // Generic literal path: decode row, apply literal values.
1294                    let mut count = 0u64;
1295                    for rid in matching_rids {
1296                        let mut row = match self.catalog.get(table, rid) {
1297                            Some(r) => r,
1298                            None => continue,
1299                        };
1300                        for (idx, val) in resolved_assignments.iter() {
1301                            row[*idx] = val.clone();
1302                        }
1303                        self.catalog
1304                            .update_hinted(table, rid, &row, Some(&changed_cols))
1305                            .map_err(|e| QueryError::StorageError(e.to_string()))?;
1306                        count += 1;
1307                    }
1308                    self.view_registry.mark_dependents_dirty(table);
1309                    return Ok(QueryResult::Modified(count));
1310                } // end if let Some(resolved_assignments)
1311
1312                // ── Expression-based update path ────────────────────────
1313                // At least one assignment contains a non-literal expression
1314                // (e.g., `age := .age + 1`). Evaluate per-row.
1315                let col_names: Vec<String> = {
1316                    let schema_ref = self
1317                        .catalog
1318                        .schema(table)
1319                        .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
1320                    schema_ref.columns.iter().map(|c| c.name.clone()).collect()
1321                };
1322                let mut count = 0u64;
1323                for rid in matching_rids {
1324                    let mut row = match self.catalog.get(table, rid) {
1325                        Some(r) => r,
1326                        None => continue,
1327                    };
1328                    for (i, asgn) in assignments.iter().enumerate() {
1329                        let val = eval_expr(&asgn.value, &row, &col_names);
1330                        // Coerce to the target column type before writing, so a
1331                        // computed int→float assignment stores f64 (not raw i64
1332                        // bits, #118) and a str→fixed-col assignment returns a
1333                        // typed error instead of hitting the encoder's
1334                        // `unreachable!` and aborting the process (#117).
1335                        row[col_indices[i]] =
1336                            coerce_value(val, &target_cols[i]).map_err(QueryError::TypeError)?;
1337                    }
1338                    self.catalog
1339                        .update_hinted(table, rid, &row, Some(&changed_cols))
1340                        .map_err(|e| QueryError::StorageError(e.to_string()))?;
1341                    count += 1;
1342                }
1343                self.view_registry.mark_dependents_dirty(table);
1344                Ok(QueryResult::Modified(count))
1345            }
1346
1347            PlanNode::Delete {
1348                input,
1349                table,
1350                returning,
1351            } => {
1352                // ── RETURNING path ──────────────────────────────────────
1353                // `returning` needs the pre-delete row image, so read each
1354                // matched row before removing it. The fused single-pass
1355                // delete primitives below never decode rows, so they can't
1356                // serve this. Opt-in only: when `returning` is false the
1357                // fast paths below are byte-for-byte unchanged.
1358                if *returning {
1359                    let columns: Vec<String> = {
1360                        let schema_ref = self
1361                            .catalog
1362                            .schema(table)
1363                            .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
1364                        schema_ref.columns.iter().map(|c| c.name.clone()).collect()
1365                    };
1366                    let matching_rids = self.collect_rids_for_mutation(input, table)?;
1367                    let mut out_rows: Vec<Vec<Value>> = Vec::with_capacity(matching_rids.len());
1368                    // Cooperative cancellation of the pre-delete image read. The
1369                    // actual removal below is a single batched `delete_many`, so
1370                    // cancelling here happens before any row is deleted.
1371                    let mut cancel = CancelCheck::new();
1372                    for rid in &matching_rids {
1373                        cancel.tick()?;
1374                        if let Some(row) = self.catalog.get(table, *rid) {
1375                            out_rows.push(row);
1376                        }
1377                    }
1378                    crate::cancel::check()?;
1379                    self.catalog
1380                        .delete_many(table, &matching_rids)
1381                        .map_err(|e| QueryError::StorageError(e.to_string()))?;
1382                    self.view_registry.mark_dependents_dirty(table);
1383                    return Ok(QueryResult::Rows {
1384                        columns,
1385                        rows: out_rows,
1386                    });
1387                }
1388
1389                // Mission C Phase 3: no schema clone — collect_rids_for_mutation
1390                // looks up schema internally when it needs one, and the mutation
1391                // loop doesn't need the schema at all.
1392                //
1393                // Mission C Phase 12: route bulk deletes through
1394                // `Catalog::delete_many`, which batches the btree leaf
1395                // compaction and shares one `ensure_hot` per row between
1396                // the index-key extraction and the slot delete. On
1397                // `delete_by_filter` (100K fixture, ~20K matches) that
1398                // removes ~4ms of pure `Vec::remove` memmove from the btree
1399                // maintenance phase.
1400                //
1401                // Mission C Phase 16: for the common `delete where ...`
1402                // shape (Filter(SeqScan)) — and the rarer "delete
1403                // everything" shape (SeqScan) — skip the two-pass
1404                // `collect_rids_for_mutation` + `delete_many` flow entirely.
1405                // The fused `scan_delete_matching` primitive walks the
1406                // heap exactly once, paying one `ensure_hot` per page
1407                // instead of per-row. That closes the last major gap on
1408                // the bench's `delete_by_filter` workload.
1409                // Overflow safety (P1): a v2-capable table cannot take the fused
1410                // raw-byte delete — the compiled predicate mis-reads spilled
1411                // columns. Route it through the reassembling collect-rids path.
1412                let delete_overflow = self.catalog.table_has_overflow(table);
1413                if let PlanNode::Filter {
1414                    input: inner,
1415                    predicate,
1416                } = input.as_ref()
1417                {
1418                    if let PlanNode::SeqScan { table: t } = inner.as_ref() {
1419                        if t == table && !delete_overflow {
1420                            let schema = self
1421                                .catalog
1422                                .schema(table)
1423                                .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
1424                            let columns: Vec<String> =
1425                                schema.columns.iter().map(|c| c.name.clone()).collect();
1426                            let fast = FastLayout::new(schema);
1427                            if let Some(compiled) =
1428                                compile_predicate(predicate, &columns, &fast, schema)
1429                            {
1430                                // Mission B2: logged variant so every
1431                                // matched rid hits the WAL during the
1432                                // single-pass scan. Structure of the
1433                                // fused scan is unchanged — only the
1434                                // hook closure now also appends.
1435                                crate::cancel::check()?;
1436                                let count = self
1437                                    .catalog
1438                                    .scan_delete_matching_logged(table, |data| compiled(data))
1439                                    .map_err(|e| QueryError::StorageError(e.to_string()))?;
1440                                self.view_registry.mark_dependents_dirty(table);
1441                                return Ok(QueryResult::Modified(count));
1442                            }
1443                        }
1444                    }
1445                } else if let PlanNode::SeqScan { table: t } = input.as_ref() {
1446                    if t == table && !delete_overflow {
1447                        // `delete from T` with no predicate — every live
1448                        // row matches. One pass is still the right shape.
1449                        // Mission B2: logged variant — see above.
1450                        crate::cancel::check()?;
1451                        let count = self
1452                            .catalog
1453                            .scan_delete_matching_logged(table, |_| true)
1454                            .map_err(|e| QueryError::StorageError(e.to_string()))?;
1455                        self.view_registry.mark_dependents_dirty(table);
1456                        return Ok(QueryResult::Modified(count));
1457                    }
1458                }
1459
1460                let matching_rids = self.collect_rids_for_mutation(input, table)?;
1461                crate::cancel::check()?;
1462                let count = self
1463                    .catalog
1464                    .delete_many(table, &matching_rids)
1465                    .map_err(|e| QueryError::StorageError(e.to_string()))?;
1466                self.view_registry.mark_dependents_dirty(table);
1467                Ok(QueryResult::Modified(count))
1468            }
1469
1470            PlanNode::AliasScan { table, alias } => {
1471                // Mission E1.2: scan `table` and rename every output column
1472                // to `alias.field`. Used as a join leaf so downstream
1473                // NestedLoopJoin + Filter + Project nodes can resolve
1474                // `Expr::QualifiedField` lookups by direct column-name match.
1475                //
1476                // We don't bother with a fused zero-copy loop here yet — the
1477                // whole join path is nested-loop and correctness-first
1478                // (Phase E1.3 will introduce hash join and at that point we
1479                // can revisit whether to specialise AliasScan).
1480                let schema = self
1481                    .catalog
1482                    .schema(table)
1483                    .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?
1484                    .clone();
1485                let columns: Vec<String> = schema
1486                    .columns
1487                    .iter()
1488                    .map(|c| format!("{alias}.{}", c.name))
1489                    .collect();
1490                let mut cancel = CancelCheck::new();
1491                let mut rows: Vec<Vec<Value>> = Vec::new();
1492                for (_, row) in self
1493                    .catalog
1494                    .scan(table)
1495                    .map_err(|e| QueryError::StorageError(e.to_string()))?
1496                {
1497                    cancel.tick()?;
1498                    rows.push(row);
1499                }
1500                Ok(QueryResult::Rows { columns, rows })
1501            }
1502
1503            PlanNode::NestedLoopJoin {
1504                left,
1505                right,
1506                on,
1507                kind,
1508            } => {
1509                // Materialise both sides. The executor ships two strategies:
1510                //   1. Hash join (E1.3) — when the `on` predicate is a
1511                //      simple equi-predicate `left_col = right_col`, build a
1512                //      FxHashMap<Value, Vec<row_idx>> over the right side
1513                //      and probe with the left side. O(L + R) instead of
1514                //      O(L × R). Handles Inner and LeftOuter.
1515                //   2. Nested loop (E1.2) — fallback for Cross, non-equi
1516                //      predicates, or `on` expressions that reference
1517                //      either side with something more complex than a
1518                //      QualifiedField.
1519                let left_result = self.execute_plan(left)?;
1520                let right_result = self.execute_plan(right)?;
1521                let (left_columns, left_rows) = match left_result {
1522                    QueryResult::Rows { columns, rows } => (columns, rows),
1523                    _ => return Err("join left side must produce rows".into()),
1524                };
1525                let (right_columns, right_rows) = match right_result {
1526                    QueryResult::Rows { columns, rows } => (columns, rows),
1527                    _ => return Err("join right side must produce rows".into()),
1528                };
1529
1530                // WS2: byte-budget guard on the join build side. Charge both
1531                // materialized inputs before we build the hash table / probe;
1532                // the output is row-capped by check_join_limit below.
1533                self.charge_rows(&left_rows)?;
1534                self.charge_rows(&right_rows)?;
1535
1536                execute_materialized_join(
1537                    left_columns,
1538                    left_rows,
1539                    right_columns,
1540                    right_rows,
1541                    on.as_ref(),
1542                    *kind,
1543                    self.nested_loop_pair_limit,
1544                )
1545            }
1546
1547            PlanNode::Distinct { input } => {
1548                let result = self.execute_plan(input)?;
1549                match result {
1550                    QueryResult::Rows { columns, rows } => {
1551                        let mut seen = std::collections::HashSet::new();
1552                        let mut unique_rows = Vec::new();
1553                        let mut cancel = CancelCheck::new();
1554                        for row in rows {
1555                            cancel.tick()?;
1556                            if seen.insert(row.clone()) {
1557                                unique_rows.push(row);
1558                            }
1559                        }
1560                        Ok(QueryResult::Rows {
1561                            columns,
1562                            rows: unique_rows,
1563                        })
1564                    }
1565                    other => Ok(other),
1566                }
1567            }
1568
1569            PlanNode::GroupBy {
1570                input,
1571                keys,
1572                aggregates,
1573                having,
1574            } => {
1575                if aggregates
1576                    .iter()
1577                    .any(|aggregate| aggregate.provenance_alias.is_some())
1578                {
1579                    let input = self.materialize_rows_with_provenance(input)?;
1580                    self.charge_rows(&input.rows)?;
1581                    return exec_group_by_with_provenance(
1582                        input,
1583                        keys,
1584                        aggregates,
1585                        having,
1586                        self.query_memory_limit(),
1587                    );
1588                }
1589                let result = self.execute_plan(input)?;
1590                match result {
1591                    QueryResult::Rows { columns, rows } => {
1592                        // WS2: byte-budget guard on the GROUP BY input buffer
1593                        // (the hash table is bounded by the input it groups).
1594                        self.charge_rows(&rows)?;
1595                        exec_group_by(columns, rows, keys, aggregates, having)
1596                    }
1597                    _ => Err("group by requires row input".into()),
1598                }
1599            }
1600
1601            PlanNode::CreateTable {
1602                name,
1603                fields,
1604                if_not_exists,
1605            } => {
1606                // Idempotency: a re-declared type is a clean no-op under
1607                // `if not exists`, and otherwise a PowQL-flavored error that
1608                // names the type (not the storage layer's generic "table").
1609                if self.catalog.schema(name).is_some() {
1610                    if *if_not_exists {
1611                        return Ok(QueryResult::Executed {
1612                            message: format!("type '{name}' already exists (skipped)"),
1613                        });
1614                    }
1615                    // "cannot" prefix keeps this on the server's
1616                    // safe-to-forward allowlist (SAFE_ERROR_PREFIXES).
1617                    return Err(QueryError::Execution(format!(
1618                        "cannot create type '{name}': it already exists"
1619                    )));
1620                }
1621                let columns: Vec<ColumnDef> = fields
1622                    .iter()
1623                    .enumerate()
1624                    .map(|(i, f)| -> Result<ColumnDef, QueryError> {
1625                        Ok(ColumnDef {
1626                            name: f.name.clone(),
1627                            type_id: type_name_to_id(&f.type_name)
1628                                .map_err(QueryError::TypeError)?,
1629                            required: f.required,
1630                            position: i as u16,
1631                        })
1632                    })
1633                    .collect::<Result<Vec<_>, _>>()?;
1634                // Coerce each literal default to its column's type now, so a
1635                // type mismatch (`count: int default "x"`) is rejected at DDL
1636                // time and the stored default is ready to drop into inserts.
1637                let mut defaults: Vec<Option<Value>> = vec![None; columns.len()];
1638                let mut auto_cols: Vec<bool> = vec![false; columns.len()];
1639                for (i, f) in fields.iter().enumerate() {
1640                    if let Some(lit) = &f.default {
1641                        let raw = literal_value_from(lit);
1642                        defaults[i] = Some(coerce_value(raw, &columns[i])?);
1643                    }
1644                    if f.auto {
1645                        // Auto-increment only makes sense on an integer column,
1646                        // and combining it with a literal default is
1647                        // contradictory (both want to supply the value).
1648                        if columns[i].type_id != TypeId::Int {
1649                            return Err(QueryError::TypeError(format!(
1650                                "auto column '{}' must be of type int",
1651                                f.name
1652                            )));
1653                        }
1654                        if f.default.is_some() {
1655                            return Err(QueryError::TypeError(format!(
1656                                "auto column '{}' cannot also declare a default",
1657                                f.name
1658                            )));
1659                        }
1660                        auto_cols[i] = true;
1661                    }
1662                }
1663                let schema = Schema {
1664                    table_name: name.clone(),
1665                    columns,
1666                };
1667                self.catalog
1668                    .create_table_full(schema, defaults, auto_cols)
1669                    .map_err(|e| QueryError::StorageError(e.to_string()))?;
1670                // Declaring a field `unique` auto-creates a unique B+tree
1671                // index, which is where uniqueness is enforced on writes.
1672                for f in fields.iter().filter(|f| f.unique) {
1673                    self.catalog
1674                        .create_index_unique(name, &f.name, true)
1675                        .map_err(|e| QueryError::StorageError(e.to_string()))?;
1676                }
1677                Ok(QueryResult::Created(name.clone()))
1678            }
1679
1680            PlanNode::AlterTable { table, action } => match action {
1681                AlterAction::AddColumn {
1682                    name,
1683                    type_name,
1684                    required,
1685                } => {
1686                    let position = self
1687                        .catalog
1688                        .schema(table)
1689                        .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?
1690                        .columns
1691                        .len() as u16;
1692                    let col = ColumnDef {
1693                        name: name.clone(),
1694                        type_id: type_name_to_id(type_name).map_err(QueryError::TypeError)?,
1695                        required: *required,
1696                        position,
1697                    };
1698                    self.catalog
1699                        .alter_table_add_column(table, col)
1700                        .map_err(|e| QueryError::StorageError(e.to_string()))?;
1701                    Ok(QueryResult::Executed {
1702                        message: format!("column '{name}' added to '{table}'"),
1703                    })
1704                }
1705                AlterAction::DropColumn { name, if_exists } => {
1706                    // `if exists`: a missing column (or missing table) is a
1707                    // no-op instead of an error.
1708                    if *if_exists {
1709                        let present = self
1710                            .catalog
1711                            .schema(table)
1712                            .map(|s| s.column_index(name).is_some())
1713                            .unwrap_or(false);
1714                        if !present {
1715                            return Ok(QueryResult::Executed {
1716                                message: format!(
1717                                    "column '{name}' does not exist on '{table}' (skipped)"
1718                                ),
1719                            });
1720                        }
1721                    }
1722                    self.catalog
1723                        .alter_table_drop_column(table, name)
1724                        .map_err(|e| QueryError::StorageError(e.to_string()))?;
1725                    Ok(QueryResult::Executed {
1726                        message: format!("column '{name}' dropped from '{table}'"),
1727                    })
1728                }
1729                AlterAction::AddIndex {
1730                    target,
1731                    if_not_exists: _,
1732                } => {
1733                    let IndexTarget::Column(column) = target else {
1734                        let IndexTarget::JsonPath(path) = target else {
1735                            unreachable!("index target variants are exhaustive")
1736                        };
1737                        if let Some(existing) = resolve_expression_index(&self.catalog, table, path)
1738                        {
1739                            return Ok(QueryResult::Executed {
1740                                message: format!(
1741                                    "expression index {} on '{}' already exists (skipped)",
1742                                    existing.index_id, table
1743                                ),
1744                            });
1745                        }
1746                        crate::cancel::check()?;
1747                        let index_id = self
1748                            .catalog
1749                            .create_expression_index_metadata(
1750                                table,
1751                                1,
1752                                path.canonical_text(),
1753                                path.clone(),
1754                                false,
1755                            )
1756                            .map_err(|error| QueryError::StorageError(error.to_string()))?;
1757                        return Ok(QueryResult::Executed {
1758                            message: format!("expression index {index_id} on '{}' created", table),
1759                        });
1760                    };
1761                    // `add index` is already idempotent (no-op if the index
1762                    // exists), so `if not exists` is accepted for symmetry but
1763                    // does not change behavior.
1764                    crate::cancel::check()?;
1765                    self.catalog
1766                        .create_index(table, column)
1767                        .map_err(|e| QueryError::StorageError(e.to_string()))?;
1768                    Ok(QueryResult::Executed {
1769                        message: format!("index on '{table}.{column}' created"),
1770                    })
1771                }
1772                AlterAction::AddUnique {
1773                    target,
1774                    if_not_exists,
1775                } => {
1776                    let IndexTarget::Column(column) = target else {
1777                        let IndexTarget::JsonPath(path) = target else {
1778                            unreachable!("index target variants are exhaustive")
1779                        };
1780                        if let Some(existing) = resolve_expression_index(&self.catalog, table, path)
1781                        {
1782                            if *if_not_exists {
1783                                return Ok(QueryResult::Executed {
1784                                    message: format!(
1785                                        "expression index {} on '{}' already exists (skipped)",
1786                                        existing.index_id, table
1787                                    ),
1788                                });
1789                            }
1790                            return Err(QueryError::Execution(format!(
1791                                "cannot add unique expression index on {}: path already indexed",
1792                                table
1793                            )));
1794                        }
1795                        crate::cancel::check()?;
1796                        let index_id = self
1797                            .catalog
1798                            .create_expression_index_metadata(
1799                                table,
1800                                1,
1801                                path.canonical_text(),
1802                                path.clone(),
1803                                true,
1804                            )
1805                            .map_err(|error| QueryError::StorageError(error.to_string()))?;
1806                        return Ok(QueryResult::Executed {
1807                            message: format!(
1808                                "unique expression index {index_id} on '{}' created",
1809                                table
1810                            ),
1811                        });
1812                    };
1813                    // `if not exists`: an already-indexed column is a no-op
1814                    // rather than the (default) "already indexed" error.
1815                    if self.catalog.has_index(table, column) {
1816                        if *if_not_exists {
1817                            return Ok(QueryResult::Executed {
1818                                message: format!(
1819                                    "index on '{table}.{column}' already exists (skipped)"
1820                                ),
1821                            });
1822                        }
1823                        // Upgrading an existing non-unique index in place is
1824                        // intentionally rejected.
1825                        return Err(QueryError::Execution(format!(
1826                            "cannot add unique on {table}.{column}: column already indexed"
1827                        )));
1828                    }
1829                    // Scan existing rows for duplicate (non-null) values
1830                    // before creating the unique index.
1831                    {
1832                        let tbl = self
1833                            .catalog
1834                            .get_table(table)
1835                            .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
1836                        let col_idx = tbl.schema().column_index(column).ok_or_else(|| {
1837                            QueryError::ColumnNotFound {
1838                                table: table.to_string(),
1839                                column: column.clone(),
1840                            }
1841                        })?;
1842                        let mut seen = std::collections::HashSet::new();
1843                        let mut cancel = CancelCheck::new();
1844                        for (_, row) in tbl.scan() {
1845                            cancel.tick()?;
1846                            let v = &row[col_idx];
1847                            if v.is_empty() {
1848                                continue;
1849                            }
1850                            if !seen.insert(v.clone()) {
1851                                return Err(QueryError::Execution(format!(
1852                                    "cannot add unique on {table}.{column}: \
1853                                     duplicate value {v:?} exists"
1854                                )));
1855                            }
1856                        }
1857                    }
1858                    crate::cancel::check()?;
1859                    self.catalog
1860                        .create_index_unique(table, column, true)
1861                        .map_err(|e| QueryError::StorageError(e.to_string()))?;
1862                    Ok(QueryResult::Executed {
1863                        message: format!("unique index on '{table}.{column}' created"),
1864                    })
1865                }
1866                AlterAction::DropIndex { target, if_exists } => {
1867                    let IndexTarget::JsonPath(path) = target else {
1868                        return Err(QueryError::Execution(
1869                            "dropping stored-column indexes is not supported".to_string(),
1870                        ));
1871                    };
1872                    let Some(existing) = resolve_expression_index(&self.catalog, table, path)
1873                    else {
1874                        if *if_exists {
1875                            return Ok(QueryResult::Executed {
1876                                message: format!(
1877                                    "expression index on '{}' does not exist (skipped)",
1878                                    table
1879                                ),
1880                            });
1881                        }
1882                        return Err(QueryError::Execution(format!(
1883                            "expression index on '{}' does not exist",
1884                            table
1885                        )));
1886                    };
1887                    crate::cancel::check()?;
1888                    self.catalog
1889                        .drop_expression_index(table, existing.index_id)
1890                        .map_err(|error| QueryError::StorageError(error.to_string()))?;
1891                    Ok(QueryResult::Executed {
1892                        message: format!(
1893                            "expression index {} on '{}' dropped",
1894                            existing.index_id, table
1895                        ),
1896                    })
1897                }
1898            },
1899
1900            PlanNode::DropTable { name, if_exists } => {
1901                if *if_exists && self.catalog.schema(name).is_none() {
1902                    return Ok(QueryResult::Executed {
1903                        message: format!("type '{name}' does not exist (skipped)"),
1904                    });
1905                }
1906                self.catalog
1907                    .drop_table(name)
1908                    .map_err(|e| QueryError::StorageError(e.to_string()))?;
1909                Ok(QueryResult::Executed {
1910                    message: format!("table '{name}' dropped"),
1911                })
1912            }
1913
1914            PlanNode::ListTypes => self.introspect_list_types(),
1915
1916            PlanNode::Describe { table } => self.introspect_describe(table),
1917
1918            PlanNode::CreateView { name, query_text } => {
1919                self.create_view(name, query_text)?;
1920                Ok(QueryResult::Executed {
1921                    message: format!("materialized view '{name}' created"),
1922                })
1923            }
1924
1925            PlanNode::RefreshView { name } => {
1926                self.refresh_view(name)?;
1927                Ok(QueryResult::Executed {
1928                    message: format!("materialized view '{name}' refreshed"),
1929                })
1930            }
1931
1932            PlanNode::DropView { name, if_exists } => {
1933                if *if_exists && !self.view_registry.is_view(name) {
1934                    return Ok(QueryResult::Executed {
1935                        message: format!("view '{name}' does not exist (skipped)"),
1936                    });
1937                }
1938                self.drop_view(name)?;
1939                Ok(QueryResult::Executed {
1940                    message: format!("materialized view '{name}' dropped"),
1941                })
1942            }
1943
1944            PlanNode::Window { input, windows } => {
1945                let result = self.execute_plan(input)?;
1946                execute_window(result, windows, self.query_memory_limit)
1947            }
1948
1949            PlanNode::Union { left, right, all } => {
1950                let left_result = self.execute_plan(left)?;
1951                let right_result = self.execute_plan(right)?;
1952                let (left_cols, left_rows) = match left_result {
1953                    QueryResult::Rows { columns, rows } => (columns, rows),
1954                    _ => return Err("UNION requires query results on left side".into()),
1955                };
1956                let (_, right_rows) = match right_result {
1957                    QueryResult::Rows { columns, rows } => (columns, rows),
1958                    _ => return Err("UNION requires query results on right side".into()),
1959                };
1960                let mut combined = left_rows;
1961                let mut cancel = CancelCheck::new();
1962                if *all {
1963                    // UNION ALL — just concatenate.
1964                    for row in right_rows {
1965                        cancel.tick()?;
1966                        combined.push(row);
1967                    }
1968                } else {
1969                    // UNION — deduplicate using the same HashSet approach
1970                    // as DISTINCT. Value already implements Hash + Eq.
1971                    let mut seen = std::collections::HashSet::new();
1972                    for row in &combined {
1973                        cancel.tick()?;
1974                        seen.insert(row.clone());
1975                    }
1976                    for row in right_rows {
1977                        cancel.tick()?;
1978                        if seen.insert(row.clone()) {
1979                            combined.push(row);
1980                        }
1981                    }
1982                }
1983                Ok(QueryResult::Rows {
1984                    columns: left_cols,
1985                    rows: combined,
1986                })
1987            }
1988
1989            PlanNode::Explain { input } => {
1990                // Every execute entry point runs lower_unindexed_scans before
1991                // dispatch and lowering recurses into Explain, so `input` is
1992                // already the plan that will actually run.
1993                let text = format_plan_tree(&self.catalog, input, 0);
1994                Ok(QueryResult::Rows {
1995                    columns: vec!["plan".to_string()],
1996                    rows: text
1997                        .lines()
1998                        .map(|line| vec![Value::Str(line.to_string())])
1999                        .collect(),
2000                })
2001            }
2002
2003            PlanNode::Begin => {
2004                if self.in_transaction {
2005                    return Err(QueryError::Execution(
2006                        "already in a transaction (nested transactions not supported)".into(),
2007                    ));
2008                }
2009                self.catalog
2010                    .begin_transaction()
2011                    .map_err(|e| QueryError::StorageError(e.to_string()))?;
2012                self.in_transaction = true;
2013                Ok(QueryResult::Executed {
2014                    message: "transaction started".to_string(),
2015                })
2016            }
2017
2018            PlanNode::Commit => {
2019                if !self.in_transaction {
2020                    return Err(QueryError::Execution(
2021                        "no active transaction to commit".into(),
2022                    ));
2023                }
2024                self.catalog
2025                    .commit_transaction()
2026                    .map_err(|e| QueryError::StorageError(e.to_string()))?;
2027                self.in_transaction = false;
2028                Ok(QueryResult::Executed {
2029                    message: "transaction committed".to_string(),
2030                })
2031            }
2032
2033            PlanNode::Rollback => {
2034                if !self.in_transaction {
2035                    return Err(QueryError::Execution(
2036                        "no active transaction to roll back".into(),
2037                    ));
2038                }
2039                self.rollback_transaction_preserving_wal_archive()
2040            }
2041
2042            PlanNode::IndexScan { table, column, key } => {
2043                let key_value = literal_to_value(key)?;
2044                let tbl = self
2045                    .catalog
2046                    .get_table(table)
2047                    .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
2048                let columns: Vec<String> = tbl
2049                    .schema()
2050                    .columns
2051                    .iter()
2052                    .map(|c| c.name.clone())
2053                    .collect();
2054
2055                // Fast path: the table has a B-tree on this column.
2056                // Uses index_lookup_all to return ALL matching rows for
2057                // both unique and non-unique indexes.
2058                if tbl.has_index(column) {
2059                    let rids = tbl.index_lookup_all(column, &key_value);
2060                    let mut rows: Vec<Vec<Value>> = Vec::with_capacity(rids.len());
2061                    let mut cancel = CancelCheck::new();
2062                    for rid in rids {
2063                        cancel.tick()?;
2064                        // Overflow safety (P0-3/P0-4): `tbl.get` reassembles
2065                        // spilled columns; the old `heap.get` + `decode_row`
2066                        // returned Empty / wrapped a >= 64KB value.
2067                        if let Some(row) = tbl.get(rid) {
2068                            rows.push(row);
2069                        }
2070                    }
2071                    return Ok(QueryResult::Rows { columns, rows });
2072                }
2073
2074                // Fallback: no index on this column. The planner emits IndexScan
2075                // eagerly (it has no visibility into which columns are indexed
2076                // at plan time), so here we must behave like SeqScan+Filter on
2077                // `.col = literal`: return *all* matching rows, not just the
2078                // first one. A non-indexed column isn't necessarily unique.
2079                // We compile the eq predicate once and stream without any
2080                // per-row decode for non-matching rows.
2081                let schema = tbl.schema();
2082                let fast = FastLayout::new(schema);
2083                let synth_pred = Expr::BinaryOp(
2084                    Box::new(Expr::Field(column.clone())),
2085                    BinOp::Eq,
2086                    Box::new(key.clone()),
2087                );
2088                // Overflow safety (P0-4/P1): the raw compiled scan drops/mis-reads
2089                // spilled columns; a v2-capable table uses the decoded scan below.
2090                if !tbl.has_overflow_rows() {
2091                    if let Some(compiled) = compile_predicate(&synth_pred, &columns, &fast, schema)
2092                    {
2093                        // Mission F: skip the first 4 Vec doublings.
2094                        let mut rows: Vec<Vec<Value>> = Vec::with_capacity(64);
2095                        for_each_row_raw_cancellable(&self.catalog, table, |_rid, data| {
2096                            if compiled(data) {
2097                                rows.push(decode_row(schema, data));
2098                            }
2099                        })?;
2100                        return Ok(QueryResult::Rows { columns, rows });
2101                    }
2102                }
2103
2104                // Last resort: slow eq-check on materialised rows.
2105                let col_idx =
2106                    schema
2107                        .column_index(column)
2108                        .ok_or_else(|| QueryError::ColumnNotFound {
2109                            table: String::new(),
2110                            column: column.clone(),
2111                        })?;
2112                let mut cancel = CancelCheck::new();
2113                let mut rows: Vec<Vec<Value>> = Vec::new();
2114                for (_, row) in tbl.scan() {
2115                    cancel.tick()?;
2116                    if row[col_idx] == key_value {
2117                        rows.push(row);
2118                    }
2119                }
2120                Ok(QueryResult::Rows { columns, rows })
2121            }
2122
2123            PlanNode::RangeScan {
2124                table,
2125                column,
2126                start,
2127                end,
2128            } => {
2129                let tbl = self
2130                    .catalog
2131                    .get_table(table)
2132                    .ok_or_else(|| QueryError::TableNotFound(table.to_string()))?;
2133                let columns: Vec<String> = tbl
2134                    .schema()
2135                    .columns
2136                    .iter()
2137                    .map(|c| c.name.clone())
2138                    .collect();
2139                let schema = tbl.schema();
2140
2141                let start_val = match start {
2142                    Some((expr, _)) => Some(literal_to_value(expr)?),
2143                    None => None,
2144                };
2145                let end_val = match end {
2146                    Some((expr, _)) => Some(literal_to_value(expr)?),
2147                    None => None,
2148                };
2149                let start_inclusive = start.as_ref().map(|(_, inc)| *inc).unwrap_or(true);
2150                let end_inclusive = end.as_ref().map(|(_, inc)| *inc).unwrap_or(true);
2151
2152                // Non-unique index: walk the composite (value, rid) leaf
2153                // chain between prefix bounds, fetch each row from the heap,
2154                // and recheck. The recheck enforces exclusive bounds
2155                // (range_rids is inclusive) and defensively skips any decoded
2156                // null (nulls are never indexed, so they must not match).
2157                if tbl.is_index_unique(column) == Some(false) {
2158                    if let Some(btree) = tbl.index(column) {
2159                        if start_val.is_some() || end_val.is_some() {
2160                            let col_idx = schema.column_index(column).ok_or_else(|| {
2161                                QueryError::ColumnNotFound {
2162                                    table: String::new(),
2163                                    column: column.clone(),
2164                                }
2165                            })?;
2166                            let rids = btree.range_rids(start_val.as_ref(), end_val.as_ref());
2167                            let mut rows: Vec<Vec<Value>> = Vec::with_capacity(rids.len());
2168                            let mut cancel = CancelCheck::new();
2169                            for rid in rids {
2170                                cancel.tick()?;
2171                                // Overflow safety (P0-3): reassemble spilled cols.
2172                                if let Some(row) = tbl.get(rid) {
2173                                    if !row[col_idx].is_empty()
2174                                        && range_matches(
2175                                            &row[col_idx],
2176                                            &start_val,
2177                                            start_inclusive,
2178                                            &end_val,
2179                                            end_inclusive,
2180                                        )
2181                                    {
2182                                        rows.push(row);
2183                                    }
2184                                }
2185                            }
2186                            return Ok(QueryResult::Rows { columns, rows });
2187                        }
2188                    }
2189                }
2190
2191                // Range scans use the btree fast path for unique indexes,
2192                // walking raw column-value keys directly.
2193                if tbl.is_index_unique(column) == Some(true) {
2194                    if let Some(btree) = tbl.index(column) {
2195                        let hits: Vec<(Value, RowId)> = match (&start_val, &end_val) {
2196                            (Some(s), Some(e)) => btree.range(s, e).collect(),
2197                            (Some(s), None) => btree.range_from(s),
2198                            (None, Some(e)) => btree.range_to(e),
2199                            (None, None) => {
2200                                let mut cancel = CancelCheck::new();
2201                                let mut rows: Vec<Vec<Value>> = Vec::new();
2202                                for (_, row) in tbl.scan() {
2203                                    cancel.tick()?;
2204                                    rows.push(row);
2205                                }
2206                                return Ok(QueryResult::Rows { columns, rows });
2207                            }
2208                        };
2209                        let mut rows: Vec<Vec<Value>> = Vec::with_capacity(hits.len());
2210                        let mut cancel = CancelCheck::new();
2211                        for (key, rid) in hits {
2212                            cancel.tick()?;
2213                            if !start_inclusive {
2214                                if let Some(ref s) = start_val {
2215                                    if &key == s {
2216                                        continue;
2217                                    }
2218                                }
2219                            }
2220                            if !end_inclusive {
2221                                if let Some(ref e) = end_val {
2222                                    if &key == e {
2223                                        continue;
2224                                    }
2225                                }
2226                            }
2227                            // Overflow safety (P0-3): reassemble spilled cols.
2228                            if let Some(row) = tbl.get(rid) {
2229                                rows.push(row);
2230                            }
2231                        }
2232                        return Ok(QueryResult::Rows { columns, rows });
2233                    }
2234                }
2235
2236                // Fallback: no index — synthesize range predicate and scan.
2237                // Overflow safety (P0-4): v2-capable tables use the decoded
2238                // last-resort scan below.
2239                let fast = FastLayout::new(schema);
2240                let synth = synthesize_range_predicate(column, start, end);
2241                if !tbl.has_overflow_rows() {
2242                    if let Some(compiled) = compile_predicate(&synth, &columns, &fast, schema) {
2243                        let mut rows: Vec<Vec<Value>> = Vec::with_capacity(64);
2244                        for_each_row_raw_cancellable(&self.catalog, table, |_rid, data| {
2245                            if compiled(data) {
2246                                rows.push(decode_row(schema, data));
2247                            }
2248                        })?;
2249                        return Ok(QueryResult::Rows { columns, rows });
2250                    }
2251                }
2252
2253                let col_idx =
2254                    schema
2255                        .column_index(column)
2256                        .ok_or_else(|| QueryError::ColumnNotFound {
2257                            table: String::new(),
2258                            column: column.clone(),
2259                        })?;
2260                let mut cancel = CancelCheck::new();
2261                let mut rows: Vec<Vec<Value>> = Vec::new();
2262                for (_, row) in tbl.scan() {
2263                    cancel.tick()?;
2264                    if range_matches(
2265                        &row[col_idx],
2266                        &start_val,
2267                        start_inclusive,
2268                        &end_val,
2269                        end_inclusive,
2270                    ) {
2271                        rows.push(row);
2272                    }
2273                }
2274                Ok(QueryResult::Rows { columns, rows })
2275            }
2276        }
2277    }
2278
2279    // ─── Materialized view operations ──────────────────────────────────────
2280
2281    /// Create a materialized view: execute the source query, store results
2282    /// in a new backing table, and register the view.
2283    fn create_view(&mut self, name: &str, query_text: &str) -> Result<(), QueryError> {
2284        if self.view_registry.is_view(name) {
2285            return Err(QueryError::ViewError(format!(
2286                "materialized view '{name}' already exists"
2287            )));
2288        }
2289        // Execute the source query to get the result set.
2290        let result = self.execute_powql(query_text)?;
2291        let (columns, rows) = match result {
2292            QueryResult::Rows { columns, rows } => (columns, rows),
2293            _ => return Err("view source query must be a SELECT".into()),
2294        };
2295        // Derive a schema for the backing table from the query result columns.
2296        let schema = self.derive_view_schema(name, &columns, &rows);
2297        // Create the backing table and insert the result rows.
2298        crate::cancel::check()?;
2299        self.catalog
2300            .create_table(schema)
2301            .map_err(|e| QueryError::StorageError(e.to_string()))?;
2302        for row in &rows {
2303            self.catalog
2304                .insert(name, row)
2305                .map_err(|e| QueryError::StorageError(e.to_string()))?;
2306        }
2307        // Determine which base tables this view depends on by parsing the query.
2308        let depends_on = self.extract_view_deps(query_text);
2309        self.view_registry
2310            .register(ViewDef {
2311                name: name.to_string(),
2312                query: query_text.to_string(),
2313                depends_on,
2314                dirty: false,
2315            })
2316            .map_err(|e| QueryError::StorageError(e.to_string()))?;
2317        Ok(())
2318    }
2319
2320    /// Refresh a materialized view: re-execute its source query and replace
2321    /// the backing table's contents.
2322    fn refresh_view(&mut self, name: &str) -> Result<(), QueryError> {
2323        let def = self
2324            .view_registry
2325            .get(name)
2326            .ok_or_else(|| format!("materialized view '{name}' not found"))?;
2327        let query_text = def.query.clone();
2328        // Execute the source query.
2329        let result = self.execute_powql(&query_text)?;
2330        let (_columns, rows) = match result {
2331            QueryResult::Rows { columns, rows } => (columns, rows),
2332            _ => return Err("view source query must be a SELECT".into()),
2333        };
2334        // Clear old data and insert fresh results. Mission B2: logged
2335        // variant — view refreshes are a mutation and crash recovery
2336        // must see them.
2337        crate::cancel::check()?;
2338        self.catalog
2339            .scan_delete_matching_logged(name, |_| true)
2340            .map_err(|e| QueryError::StorageError(e.to_string()))?;
2341        for row in &rows {
2342            self.catalog
2343                .insert(name, row)
2344                .map_err(|e| QueryError::StorageError(e.to_string()))?;
2345        }
2346        self.view_registry.mark_clean(name);
2347        Ok(())
2348    }
2349
2350    /// Drop a materialized view: remove the backing table and unregister.
2351    fn drop_view(&mut self, name: &str) -> Result<(), QueryError> {
2352        if !self.view_registry.is_view(name) {
2353            return Err(QueryError::ViewError(format!(
2354                "materialized view '{name}' not found"
2355            )));
2356        }
2357        self.view_registry
2358            .unregister(name)
2359            .map_err(|e| QueryError::StorageError(e.to_string()))?;
2360        self.catalog
2361            .drop_table(name)
2362            .map_err(|e| QueryError::StorageError(e.to_string()))?;
2363        Ok(())
2364    }
2365
2366    /// Derive a storage `Schema` for a view's backing table from query
2367    /// result column names and the first row's types.
2368    fn derive_view_schema(&self, name: &str, columns: &[String], rows: &[Vec<Value>]) -> Schema {
2369        use powdb_storage::types::{ColumnDef, TypeId};
2370        let cols: Vec<ColumnDef> = columns
2371            .iter()
2372            .enumerate()
2373            .map(|(i, col_name)| {
2374                let type_id = rows
2375                    .first()
2376                    .and_then(|row| row.get(i))
2377                    .map(|v| v.type_id())
2378                    .unwrap_or(TypeId::Str);
2379                ColumnDef {
2380                    name: col_name.clone(),
2381                    type_id,
2382                    required: false,
2383                    position: i as u16,
2384                }
2385            })
2386            .collect();
2387        Schema {
2388            table_name: name.to_string(),
2389            columns: cols,
2390        }
2391    }
2392
2393    /// Extract base table dependencies from a view's source query by
2394    /// parsing it and collecting the source table name.
2395    fn extract_view_deps(&self, query_text: &str) -> Vec<String> {
2396        use crate::parser::parse;
2397        match parse(query_text) {
2398            Ok(Statement::Query(q)) => {
2399                let mut deps = vec![q.source.clone()];
2400                for j in &q.joins {
2401                    deps.push(j.source.clone());
2402                }
2403                deps
2404            }
2405            _ => Vec::new(),
2406        }
2407    }
2408}