Skip to main content

powdb_query/
planner.rs

1use crate::ast::*;
2use crate::parser::{parse, ParseError};
3use crate::plan::*;
4use powdb_storage::stored_json_path::StoredJsonPathV1;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub(crate) enum RangeTarget {
8    Column(String),
9    JsonPath(StoredJsonPathV1),
10}
11
12/// (target, lower_bound, upper_bound) — used by range-index extraction.
13pub(crate) type RangeBound = (RangeTarget, Option<(Expr, bool)>, Option<(Expr, bool)>);
14
15/// Plan-phase error — wraps ParseError for the full lex→parse→plan chain.
16#[derive(Debug)]
17pub enum PlanError {
18    /// Error originated in the parser (or lexer, via ParseError::Lex).
19    Parse(ParseError),
20    /// The parsed query is structurally valid but cannot be planned safely.
21    Semantic(String),
22}
23
24impl PlanError {
25    /// Convenience: human-readable message for any variant.
26    pub fn message(&self) -> String {
27        self.to_string()
28    }
29}
30
31impl std::fmt::Display for PlanError {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            Self::Parse(e) => write!(f, "{e}"),
35            Self::Semantic(message) => write!(f, "{message}"),
36        }
37    }
38}
39
40impl std::error::Error for PlanError {
41    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
42        match self {
43            Self::Parse(e) => Some(e),
44            Self::Semantic(_) => None,
45        }
46    }
47}
48
49impl From<ParseError> for PlanError {
50    fn from(e: ParseError) -> Self {
51        PlanError::Parse(e)
52    }
53}
54
55pub fn plan(input: &str) -> Result<PlanNode, PlanError> {
56    let stmt = parse(input)?;
57    plan_statement(stmt)
58}
59
60pub fn plan_statement(stmt: Statement) -> Result<PlanNode, PlanError> {
61    match stmt {
62        Statement::Query(q) => plan_query(q),
63        Statement::Insert(ins) => plan_insert(ins),
64        Statement::UpdateQuery(upd) => plan_update(upd),
65        Statement::DeleteQuery(del) => plan_delete(del),
66        Statement::CreateType(ct) => plan_create_type(ct),
67        Statement::AlterTable(at) => Ok(PlanNode::AlterTable {
68            table: at.table,
69            action: at.action,
70        }),
71        Statement::DropTable(dt) => Ok(PlanNode::DropTable {
72            name: dt.table,
73            if_exists: dt.if_exists,
74        }),
75        Statement::CreateView(cv) => Ok(PlanNode::CreateView {
76            name: cv.name,
77            query_text: cv.query_text,
78        }),
79        Statement::RefreshView(rv) => Ok(PlanNode::RefreshView { name: rv.name }),
80        Statement::DropView(dv) => Ok(PlanNode::DropView {
81            name: dv.name,
82            if_exists: dv.if_exists,
83        }),
84        Statement::ListTypes => Ok(PlanNode::ListTypes),
85        Statement::Describe(table) => Ok(PlanNode::Describe { table }),
86        Statement::Union(u) => {
87            let left = plan_statement(*u.left)?;
88            let right = plan_statement(*u.right)?;
89            Ok(PlanNode::Union {
90                left: Box::new(left),
91                right: Box::new(right),
92                all: u.all,
93            })
94        }
95        Statement::Upsert(ups) => plan_upsert(ups),
96        Statement::Begin => Ok(PlanNode::Begin),
97        Statement::Commit => Ok(PlanNode::Commit),
98        Statement::Rollback => Ok(PlanNode::Rollback),
99        Statement::Explain(inner) => {
100            let inner_plan = plan_statement(*inner)?;
101            Ok(PlanNode::Explain {
102                input: Box::new(inner_plan),
103            })
104        }
105    }
106}
107
108fn plan_query(mut q: QueryExpr) -> Result<PlanNode, PlanError> {
109    // Language-lab slice: projections carrying a nested sub-query field take
110    // a dedicated path so every other query shape plans exactly as before.
111    if q.projection.as_ref().is_some_and(|proj| {
112        proj.iter()
113            .any(|pf| matches!(pf.expr, Expr::NestedQuery(_)))
114    }) {
115        return plan_nested_query(q);
116    }
117    // Mission E1.2: if the query has joins, build a left-deep nested-loop
118    // plan. Correctness first — hash-join optimization is E1.3. We also
119    // don't try to fold an IndexScan under a joined query yet (the
120    // leaf-level fast paths all match on `PlanNode::SeqScan { .. }`
121    // literally, so mixing them into a join plan would silently break).
122    if !q.joins.is_empty() {
123        return plan_joined_query(q);
124    }
125    let source_aliases = std::collections::HashSet::from([q.source.clone()]);
126    // Try to fold `filter .col = literal` into an IndexScan. The executor
127    // decides at run time whether the column actually has an index — if not,
128    // it transparently falls back to a sequential scan with the same predicate,
129    // so this rewrite is always safe.
130    //
131    // We only rewrite the *simple* eq case here: `filter .col = literal`.
132    // A conjunction like `filter .col = 1 and .other > 5` stays as
133    // SeqScan + Filter in the planner; runtime lowering
134    // (`lower_unindexed_scans`) then picks an indexed conjunct to drive the
135    // scan and re-checks the rest as a residual filter, using real catalog
136    // knowledge the pure planner does not have.
137    let ordered_expr_scan = try_extract_ordered_expr_index_scan(&q);
138    let (source, filter) = if let Some(scan) = ordered_expr_scan {
139        // The ordered expression node owns these clauses and executes them in
140        // index order. Clear them so the generic pipeline does not wrap a
141        // second Sort/Offset/Limit around the speculative node.
142        q.order = None;
143        q.limit = None;
144        q.offset = None;
145        (scan, None)
146    } else {
147        match q.filter {
148            Some(pred) => match try_extract_eq_index_key(&q.source, &pred) {
149                Some(index_scan) => (index_scan, None),
150                None => match try_extract_range_index_keys(&q.source, &pred) {
151                    Some(range_scan) => (range_scan, None),
152                    None => (
153                        PlanNode::SeqScan {
154                            table: q.source.clone(),
155                        },
156                        Some(pred),
157                    ),
158                },
159            },
160            None => (
161                PlanNode::SeqScan {
162                    table: q.source.clone(),
163                },
164                None,
165            ),
166        }
167    };
168    let mut node = source;
169
170    if let Some(pred) = filter {
171        node = PlanNode::Filter {
172            input: Box::new(node),
173            predicate: pred,
174        };
175    }
176
177    // Mission E2b: GROUP BY path — insert GroupBy + Project before
178    // order/limit/offset/distinct.
179    if let Some(group) = q.group_by {
180        let mut grouped_order = q.order;
181        let mut proj_fields: Vec<ProjectField> = q
182            .projection
183            .map(|proj| {
184                proj.into_iter()
185                    .map(|pf| ProjectField {
186                        alias: pf.alias,
187                        expr: pf.expr,
188                    })
189                    .collect()
190            })
191            .unwrap_or_default();
192        let mut having = group.having;
193        let aggregates = extract_aggregates(&mut proj_fields, &mut having, &source_aliases)?;
194        rewrite_group_order_keys(grouped_order.as_mut(), &proj_fields, &group.keys);
195        rewrite_group_key_references(&mut proj_fields, &mut having, &group.keys);
196
197        node = PlanNode::GroupBy {
198            input: Box::new(node),
199            keys: group.keys,
200            aggregates,
201            having,
202        };
203
204        if !proj_fields.is_empty() {
205            node = PlanNode::Project {
206                input: Box::new(node),
207                fields: proj_fields,
208            };
209        }
210
211        if let Some(order) = grouped_order {
212            node = PlanNode::Sort {
213                input: Box::new(node),
214                keys: order
215                    .keys
216                    .into_iter()
217                    .map(|k| SortKey {
218                        expr: k.expr,
219                        descending: k.descending,
220                    })
221                    .collect(),
222            };
223        }
224        // Offset must be applied *before* Limit: skip M rows, then take N.
225        // Plan shape is Limit(Offset(...)), so Offset is built first (inner)
226        // and Limit wraps it (outer).
227        if let Some(off) = q.offset {
228            node = PlanNode::Offset {
229                input: Box::new(node),
230                count: off,
231            };
232        }
233        if let Some(lim) = q.limit {
234            node = PlanNode::Limit {
235                input: Box::new(node),
236                count: lim,
237            };
238        }
239        if q.distinct {
240            node = PlanNode::Distinct {
241                input: Box::new(node),
242            };
243        }
244        return Ok(node);
245    }
246
247    if let Some(order) = q.order {
248        node = PlanNode::Sort {
249            input: Box::new(node),
250            keys: order
251                .keys
252                .into_iter()
253                .map(|k| SortKey {
254                    expr: k.expr,
255                    descending: k.descending,
256                })
257                .collect(),
258        };
259    }
260
261    // Offset must be applied *before* Limit: skip M rows, then take N.
262    // Plan shape is Limit(Offset(...)), so Offset is built first (inner)
263    // and Limit wraps it (outer).
264    if let Some(off) = q.offset {
265        node = PlanNode::Offset {
266            input: Box::new(node),
267            count: off,
268        };
269    }
270
271    if let Some(lim) = q.limit {
272        node = PlanNode::Limit {
273            input: Box::new(node),
274            count: lim,
275        };
276    }
277
278    if let Some(proj) = q.projection {
279        let mut fields: Vec<ProjectField> = proj
280            .into_iter()
281            .map(|pf| ProjectField {
282                alias: pf.alias,
283                expr: pf.expr,
284            })
285            .collect();
286        let windows = extract_windows(&mut fields);
287        if !windows.is_empty() {
288            node = PlanNode::Window {
289                input: Box::new(node),
290                windows,
291            };
292        }
293        node = PlanNode::Project {
294            input: Box::new(node),
295            fields,
296        };
297    }
298
299    if q.distinct {
300        node = PlanNode::Distinct {
301            input: Box::new(node),
302        };
303    }
304
305    if let Some(agg) = q.aggregation {
306        let provenance_alias = symmetric_provenance_alias(
307            agg.function,
308            agg.argument.as_ref(),
309            agg.mode,
310            &source_aliases,
311        )?;
312        node = PlanNode::Aggregate {
313            input: Box::new(node),
314            function: agg.function,
315            argument: agg.argument,
316            mode: agg.mode,
317            provenance_alias,
318        };
319    }
320
321    Ok(node)
322}
323
324/// Build a `NestedProject` plan for a query whose projection carries nested
325/// sub-query fields (language-lab slice). The parent pipeline is an
326/// `AliasScan` (so `alias.field` references resolve by column name) plus the
327/// usual filter/order/offset/limit stack; the projection itself becomes the
328/// `NestedProject` layer. Emitted speculatively like `RangeScan`: the planner
329/// stays catalog-pure and the executor resolves tables/columns at run time.
330fn plan_nested_query(q: QueryExpr) -> Result<PlanNode, PlanError> {
331    if !q.joins.is_empty() || q.group_by.is_some() || q.aggregation.is_some() || q.distinct {
332        return Err(PlanError::Semantic(
333            "nested projections require a plain aliased table scan (no joins, \
334             group, distinct, or aggregation)"
335                .into(),
336        ));
337    }
338    let parent_alias = q.alias.unwrap_or_else(|| q.source.clone());
339    let mut node = PlanNode::AliasScan {
340        table: q.source,
341        alias: parent_alias.clone(),
342    };
343    if let Some(pred) = q.filter {
344        node = PlanNode::Filter {
345            input: Box::new(node),
346            predicate: pred,
347        };
348    }
349    if let Some(order) = q.order {
350        node = PlanNode::Sort {
351            input: Box::new(node),
352            keys: order
353                .keys
354                .into_iter()
355                .map(|k| SortKey {
356                    expr: k.expr,
357                    descending: k.descending,
358                })
359                .collect(),
360        };
361    }
362    if let Some(off) = q.offset {
363        node = PlanNode::Offset {
364            input: Box::new(node),
365            count: off,
366        };
367    }
368    if let Some(lim) = q.limit {
369        node = PlanNode::Limit {
370            input: Box::new(node),
371            count: lim,
372        };
373    }
374    let fields = q
375        .projection
376        .expect("plan_nested_query is only called with a projection")
377        .into_iter()
378        .map(|pf| match pf.expr {
379            Expr::NestedQuery(nested) => {
380                let name = pf.alias.ok_or_else(|| {
381                    PlanError::Semantic("nested projection field requires a name".into())
382                })?;
383                resolve_nested_projection(name, *nested, &parent_alias)
384                    .map(|nested| NestedProjectField::Nested(Box::new(nested)))
385            }
386            expr => Ok(NestedProjectField::Plain(ProjectField {
387                alias: pf.alias,
388                expr,
389            })),
390        })
391        .collect::<Result<Vec<_>, PlanError>>()?;
392    Ok(PlanNode::NestedProject {
393        input: Box::new(node),
394        fields,
395    })
396}
397
398/// Split a parsed `NestedQuery` into the resolved `NestedProjection` form.
399/// The filter's AND chain must contain exactly one equi-correlation
400/// predicate `child.col = outer.col` (either side order, any position);
401/// the remaining conjuncts become the residual filter, rewritten to bare
402/// child columns and evaluated per child row by the executor.
403fn resolve_nested_projection(
404    name: String,
405    nested: NestedQuery,
406    parent_alias: &str,
407) -> Result<NestedProjection, PlanError> {
408    resolve_nested_projection_inner(name, nested, parent_alias, true)
409}
410
411/// `qualify_parent_key`: at the top level the parent pipeline is an
412/// `AliasScan` whose columns are `alias.field`-qualified; deeper levels
413/// correlate against the enclosing child table's bare schema columns.
414fn resolve_nested_projection_inner(
415    name: String,
416    nested: NestedQuery,
417    parent_alias: &str,
418    qualify_parent_key: bool,
419) -> Result<NestedProjection, PlanError> {
420    let mut conjuncts = Vec::new();
421    split_and_chain(nested.filter, &mut conjuncts);
422
423    // A correlation conjunct is `child.col = parent.col` (either side order).
424    let correlation_of = |expr: &Expr| -> Option<(String, String)> {
425        let Expr::BinaryOp(left, BinOp::Eq, right) = expr else {
426            return None;
427        };
428        let side = |expr: &Expr| match expr {
429            Expr::QualifiedField { qualifier, field } => Some((qualifier.clone(), field.clone())),
430            _ => None,
431        };
432        let ((lq, lf), (rq, rf)) = (side(left)?, side(right)?);
433        if lq == nested.alias && rq == parent_alias {
434            Some((lf, rf))
435        } else if rq == nested.alias && lq == parent_alias {
436            Some((rf, lf))
437        } else {
438            None
439        }
440    };
441    let mut correlation: Option<(String, String)> = None;
442    let mut residual: Option<Expr> = None;
443    for conjunct in conjuncts {
444        match correlation_of(&conjunct) {
445            Some(keys) if correlation.is_none() => correlation = Some(keys),
446            Some(_) => {
447                return Err(PlanError::Semantic(format!(
448                    "nested projection `{name}` links `{child}` to `{parent}` more than \
449                     once; exactly one correlation predicate \
450                     ({child}.<col> = {parent}.<col>) is supported",
451                    child = nested.alias,
452                    parent = parent_alias,
453                )))
454            }
455            None => {
456                let rewritten =
457                    rewrite_residual_condition(conjunct, &name, &nested.alias, parent_alias)?;
458                residual = Some(match residual {
459                    Some(existing) => {
460                        Expr::BinaryOp(Box::new(existing), BinOp::And, Box::new(rewritten))
461                    }
462                    None => rewritten,
463                });
464            }
465        }
466    }
467    let Some((child_key, parent_key)) = correlation else {
468        return Err(PlanError::Semantic(format!(
469            "nested projection `{name}` requires an equi-correlation predicate linking \
470             `{child}` to the outer query ({child}.<col> = {parent}.<col>) somewhere in \
471             its filter",
472            child = nested.alias,
473            parent = parent_alias,
474        )));
475    };
476    let order = nested
477        .order
478        .map(|clause| {
479            clause
480                .keys
481                .into_iter()
482                .map(|key| {
483                    let column = match &key.expr {
484                        Expr::Field(field) => field.clone(),
485                        Expr::QualifiedField { qualifier, field } if *qualifier == nested.alias => {
486                            field.clone()
487                        }
488                        _ => {
489                            return Err(PlanError::Semantic(format!(
490                                "nested projection `{name}` order keys must be plain \
491                                 columns of `{child}` (`{child}.<col>` or `.<col>`)",
492                                child = nested.alias,
493                            )))
494                        }
495                    };
496                    Ok((column, key.descending))
497                })
498                .collect::<Result<Vec<_>, PlanError>>()
499        })
500        .transpose()?
501        .unwrap_or_default();
502    let fields = nested
503        .fields
504        .into_iter()
505        .map(|pf| {
506            if let Expr::NestedQuery(inner) = pf.expr {
507                let inner_name = pf.alias.ok_or_else(|| {
508                    PlanError::Semantic(
509                        "nested projection field requires a name \
510                         (`<name>: <Table> as <alias> ...`)"
511                            .into(),
512                    )
513                })?;
514                // The enclosing child scan exposes bare schema columns, so
515                // deeper levels correlate on an unqualified parent key.
516                return resolve_nested_projection_inner(inner_name, *inner, &nested.alias, false)
517                    .map(|inner| NestedField::Nested(Box::new(inner)));
518            }
519            let column = match &pf.expr {
520                Expr::Field(field) => field.clone(),
521                Expr::QualifiedField { qualifier, field } if *qualifier == nested.alias => {
522                    field.clone()
523                }
524                _ => {
525                    return Err(PlanError::Semantic(format!(
526                        "nested projection `{name}` fields must be plain columns of `{}` \
527                         (`{}.<col>` or `.<col>`) or a deeper nested projection",
528                        nested.alias, nested.alias
529                    )))
530                }
531            };
532            let key = pf.alias.unwrap_or_else(|| column.clone());
533            Ok(NestedField::Scalar { key, column })
534        })
535        .collect::<Result<Vec<_>, PlanError>>()?;
536    Ok(NestedProjection {
537        name,
538        table: nested.source,
539        alias: nested.alias,
540        parent_alias: parent_alias.to_string(),
541        child_key,
542        parent_key: if qualify_parent_key {
543            format!("{parent_alias}.{parent_key}")
544        } else {
545            parent_key
546        },
547        residual,
548        order,
549        limit: nested.limit,
550        offset: nested.offset,
551        offset_before_limit: nested.offset_before_limit,
552        fields,
553    })
554}
555
556/// Flatten a left-associative AND chain into its conjuncts, in source order.
557fn split_and_chain(expr: Expr, out: &mut Vec<Expr>) {
558    match expr {
559        Expr::BinaryOp(left, BinOp::And, right) => {
560            split_and_chain(*left, out);
561            split_and_chain(*right, out);
562        }
563        other => out.push(other),
564    }
565}
566
567/// Rewrite one residual conjunct of a nested projection filter so it
568/// evaluates against the bare child schema: `child.col` becomes `col`.
569/// Rejects references to the outer alias (only the correlation predicate
570/// may cross scopes) and constructs the executor cannot evaluate per child
571/// row (subqueries, aggregates, window functions, further nesting).
572fn rewrite_residual_condition(
573    expr: Expr,
574    name: &str,
575    child_alias: &str,
576    parent_alias: &str,
577) -> Result<Expr, PlanError> {
578    let rewrite = |inner: Box<Expr>| -> Result<Box<Expr>, PlanError> {
579        Ok(Box::new(rewrite_residual_condition(
580            *inner,
581            name,
582            child_alias,
583            parent_alias,
584        )?))
585    };
586    match expr {
587        Expr::QualifiedField { qualifier, field } => {
588            if qualifier == child_alias {
589                Ok(Expr::Field(field))
590            } else if qualifier == parent_alias {
591                Err(PlanError::Semantic(format!(
592                    "nested projection `{name}` filter references outer alias \
593                     `{parent_alias}` (`{parent_alias}.{field}`) outside the correlation \
594                     predicate; move that condition to the outer query's filter"
595                )))
596            } else {
597                Err(PlanError::Semantic(format!(
598                    "nested projection `{name}` filter references unknown alias \
599                     `{qualifier}`; only columns of `{child_alias}` may be used"
600                )))
601            }
602        }
603        Expr::Field(_) | Expr::Literal(_) | Expr::Param(_) | Expr::ValueLit(_) | Expr::Null => {
604            Ok(expr)
605        }
606        Expr::BinaryOp(left, op, right) => Ok(Expr::BinaryOp(rewrite(left)?, op, rewrite(right)?)),
607        Expr::UnaryOp(op, inner) => Ok(Expr::UnaryOp(op, rewrite(inner)?)),
608        Expr::Coalesce(left, right) => Ok(Expr::Coalesce(rewrite(left)?, rewrite(right)?)),
609        Expr::Cast(inner, ty) => Ok(Expr::Cast(rewrite(inner)?, ty)),
610        Expr::ScalarFunc(func, args) => Ok(Expr::ScalarFunc(
611            func,
612            args.into_iter()
613                .map(|arg| rewrite_residual_condition(arg, name, child_alias, parent_alias))
614                .collect::<Result<Vec<_>, _>>()?,
615        )),
616        Expr::InList {
617            expr,
618            list,
619            negated,
620        } => Ok(Expr::InList {
621            expr: rewrite(expr)?,
622            list: list
623                .into_iter()
624                .map(|item| rewrite_residual_condition(item, name, child_alias, parent_alias))
625                .collect::<Result<Vec<_>, _>>()?,
626            negated,
627        }),
628        Expr::Case { whens, else_expr } => Ok(Expr::Case {
629            whens: whens
630                .into_iter()
631                .map(|(cond, result)| Ok((rewrite(cond)?, rewrite(result)?)))
632                .collect::<Result<Vec<_>, PlanError>>()?,
633            else_expr: else_expr.map(rewrite).transpose()?,
634        }),
635        Expr::JsonPath { base, segments } => Ok(Expr::JsonPath {
636            base: rewrite(base)?,
637            segments,
638        }),
639        Expr::InSubquery { .. } | Expr::ExistsSubquery { .. } => Err(PlanError::Semantic(format!(
640            "nested projection `{name}` filter cannot contain a subquery; \
641             filter the outer query or the child columns directly"
642        ))),
643        Expr::FunctionCall(..) => Err(PlanError::Semantic(format!(
644            "nested projection `{name}` filter cannot contain an aggregate function"
645        ))),
646        Expr::Window { .. } => Err(PlanError::Semantic(format!(
647            "nested projection `{name}` filter cannot contain a window function"
648        ))),
649        Expr::NestedQuery(_) => Err(PlanError::Semantic(format!(
650            "nested projection `{name}` filter cannot contain another nested projection"
651        ))),
652    }
653}
654
655/// Build a left-deep nested-loop join plan for a query with 1+ join clauses.
656///
657/// The plan shape for `T1 as a [inner|left|cross] join T2 as b on <pred> ...` is:
658///
659///   Project? (optional, from q.projection)
660///   └─ Offset? / Limit? / Sort?
661///      └─ Filter? (the top-level q.filter, using qualified columns)
662///         └─ NestedLoopJoin { kind, on }
663///            ├─ AliasScan { T1, a }
664///            └─ AliasScan { T2, b }
665///
666/// Multi-join chains extend left-deep: a third join adds a second
667/// `NestedLoopJoin` on top, with the first join's output as its `left`.
668///
669/// Aliases default to the source table name when the query didn't write
670/// `as <name>` explicitly — that way users can always write `T.field`
671/// without being forced to alias every source.
672///
673/// RightOuter is rewritten into LeftOuter with inputs swapped — the two
674/// differ only in which side survives non-matching rows, and swapping
675/// inputs lets the executor ship a single LeftOuter path.
676fn plan_joined_query(mut q: QueryExpr) -> Result<PlanNode, PlanError> {
677    let primary_alias = q.alias.clone().unwrap_or_else(|| q.source.clone());
678    let mut aliases = std::collections::HashSet::new();
679    aliases.insert(primary_alias.clone());
680    let mut node = PlanNode::AliasScan {
681        table: q.source.clone(),
682        alias: primary_alias,
683    };
684
685    for join in q.joins {
686        let right_alias = join.alias.unwrap_or_else(|| join.source.clone());
687        if !aliases.insert(right_alias.clone()) {
688            return Err(ParseError::Syntax {
689                message: format!(
690                    "duplicate source alias `{right_alias}` in join; every joined source needs a unique alias"
691                ),
692            }
693            .into());
694        }
695        let right = PlanNode::AliasScan {
696            table: join.source,
697            alias: right_alias,
698        };
699        match join.kind {
700            JoinKind::Inner | JoinKind::LeftOuter | JoinKind::Cross => {
701                node = PlanNode::NestedLoopJoin {
702                    left: Box::new(node),
703                    right: Box::new(right),
704                    on: join.on,
705                    kind: join.kind,
706                };
707            }
708            JoinKind::RightOuter => {
709                // `a RIGHT OUTER JOIN b ON <p>` ≡ `b LEFT OUTER JOIN a ON <p>`.
710                node = PlanNode::NestedLoopJoin {
711                    left: Box::new(right),
712                    right: Box::new(node),
713                    on: join.on,
714                    kind: JoinKind::LeftOuter,
715                };
716            }
717        }
718    }
719
720    if let Some(pred) = q.filter {
721        node = PlanNode::Filter {
722            input: Box::new(node),
723            predicate: pred,
724        };
725    }
726
727    if q.group_by.is_none() {
728        if let Some(order) = q.order.take() {
729            node = PlanNode::Sort {
730                input: Box::new(node),
731                keys: order
732                    .keys
733                    .into_iter()
734                    .map(|k| SortKey {
735                        expr: k.expr,
736                        descending: k.descending,
737                    })
738                    .collect(),
739            };
740        }
741    }
742
743    // Mission E2b: GROUP BY path for joined queries.
744    if let Some(group) = q.group_by {
745        let mut grouped_order = q.order;
746        let mut proj_fields: Vec<ProjectField> = q
747            .projection
748            .map(|proj| {
749                proj.into_iter()
750                    .map(|pf| ProjectField {
751                        alias: pf.alias,
752                        expr: pf.expr,
753                    })
754                    .collect()
755            })
756            .unwrap_or_default();
757        let mut having = group.having;
758        let aggregates = extract_aggregates(&mut proj_fields, &mut having, &aliases)?;
759        rewrite_group_order_keys(grouped_order.as_mut(), &proj_fields, &group.keys);
760        rewrite_group_key_references(&mut proj_fields, &mut having, &group.keys);
761
762        node = PlanNode::GroupBy {
763            input: Box::new(node),
764            keys: group.keys,
765            aggregates,
766            having,
767        };
768
769        if !proj_fields.is_empty() {
770            node = PlanNode::Project {
771                input: Box::new(node),
772                fields: proj_fields,
773            };
774        }
775        if let Some(order) = grouped_order {
776            node = PlanNode::Sort {
777                input: Box::new(node),
778                keys: order
779                    .keys
780                    .into_iter()
781                    .map(|key| SortKey {
782                        expr: key.expr,
783                        descending: key.descending,
784                    })
785                    .collect(),
786            };
787        }
788        // LIMIT/OFFSET operate on grouped result rows, never on the joined
789        // input. Applying either before GroupBy truncates source rows and can
790        // silently change aggregate values. Offset remains inside Limit so
791        // execution skips M grouped rows before taking N.
792        if let Some(off) = q.offset {
793            node = PlanNode::Offset {
794                input: Box::new(node),
795                count: off,
796            };
797        }
798        if let Some(lim) = q.limit {
799            node = PlanNode::Limit {
800                input: Box::new(node),
801                count: lim,
802            };
803        }
804        if q.distinct {
805            node = PlanNode::Distinct {
806                input: Box::new(node),
807            };
808        }
809        return Ok(node);
810    }
811
812    // Offset must be applied *before* Limit: skip M rows, then take N.
813    // Plan shape is Limit(Offset(...)), so Offset is built first (inner)
814    // and Limit wraps it (outer).
815    if let Some(off) = q.offset {
816        node = PlanNode::Offset {
817            input: Box::new(node),
818            count: off,
819        };
820    }
821
822    if let Some(lim) = q.limit {
823        node = PlanNode::Limit {
824            input: Box::new(node),
825            count: lim,
826        };
827    }
828
829    if let Some(proj) = q.projection {
830        let mut fields: Vec<ProjectField> = proj
831            .into_iter()
832            .map(|pf| ProjectField {
833                alias: pf.alias,
834                expr: pf.expr,
835            })
836            .collect();
837        let windows = extract_windows(&mut fields);
838        if !windows.is_empty() {
839            node = PlanNode::Window {
840                input: Box::new(node),
841                windows,
842            };
843        }
844        node = PlanNode::Project {
845            input: Box::new(node),
846            fields,
847        };
848    }
849
850    if q.distinct {
851        node = PlanNode::Distinct {
852            input: Box::new(node),
853        };
854    }
855
856    if let Some(agg) = q.aggregation {
857        let provenance_alias =
858            symmetric_provenance_alias(agg.function, agg.argument.as_ref(), agg.mode, &aliases)?;
859        node = PlanNode::Aggregate {
860            input: Box::new(node),
861            function: agg.function,
862            argument: agg.argument,
863            mode: agg.mode,
864            provenance_alias,
865        };
866    }
867
868    Ok(node)
869}
870
871fn plan_insert(ins: InsertExpr) -> Result<PlanNode, PlanError> {
872    Ok(PlanNode::Insert {
873        table: ins.target,
874        rows: ins.rows,
875        returning: ins.returning,
876    })
877}
878
879fn plan_update(upd: UpdateExpr) -> Result<PlanNode, PlanError> {
880    // Mirror the read-side IndexScan fold: when the update filter is a simple
881    // `.col = literal`, emit `Update(IndexScan)` so the executor's index-lookup
882    // mutation fast path fires. The executor falls back to a scan if the
883    // column happens to lack an index, so this is always safe.
884    let source = match upd.filter {
885        Some(pred) => match try_extract_eq_index_key(&upd.source, &pred) {
886            Some(index_scan) => index_scan,
887            None => match try_extract_range_index_keys(&upd.source, &pred) {
888                Some(range_scan) => range_scan,
889                None => PlanNode::Filter {
890                    input: Box::new(PlanNode::SeqScan {
891                        table: upd.source.clone(),
892                    }),
893                    predicate: pred,
894                },
895            },
896        },
897        None => PlanNode::SeqScan {
898            table: upd.source.clone(),
899        },
900    };
901    Ok(PlanNode::Update {
902        input: Box::new(source),
903        table: upd.source,
904        assignments: upd.assignments,
905        returning: upd.returning,
906    })
907}
908
909fn plan_delete(del: DeleteExpr) -> Result<PlanNode, PlanError> {
910    let source = match del.filter {
911        Some(pred) => match try_extract_eq_index_key(&del.source, &pred) {
912            Some(index_scan) => index_scan,
913            None => match try_extract_range_index_keys(&del.source, &pred) {
914                Some(range_scan) => range_scan,
915                None => PlanNode::Filter {
916                    input: Box::new(PlanNode::SeqScan {
917                        table: del.source.clone(),
918                    }),
919                    predicate: pred,
920                },
921            },
922        },
923        None => PlanNode::SeqScan {
924            table: del.source.clone(),
925        },
926    };
927    Ok(PlanNode::Delete {
928        input: Box::new(source),
929        table: del.source,
930        returning: del.returning,
931    })
932}
933
934fn plan_upsert(ups: UpsertExpr) -> Result<PlanNode, PlanError> {
935    Ok(PlanNode::Upsert {
936        table: ups.target,
937        key_column: ups.key_column,
938        assignments: ups.assignments,
939        on_conflict: ups.on_conflict,
940    })
941}
942
943fn plan_create_type(ct: CreateTypeExpr) -> Result<PlanNode, PlanError> {
944    let fields = ct
945        .fields
946        .into_iter()
947        .map(|f| crate::plan::CreateField {
948            name: f.name,
949            type_name: f.type_name,
950            required: f.required,
951            unique: f.unique,
952            default: f.default,
953            auto: f.auto,
954        })
955        .collect();
956    Ok(PlanNode::CreateTable {
957        name: ct.name,
958        fields,
959        if_not_exists: ct.if_not_exists,
960    })
961}
962
963/// If the predicate is a simple `.field = literal` (or `literal = .field`),
964/// return a corresponding IndexScan plan node. Otherwise return None so the
965/// caller can fall through to SeqScan + Filter.
966///
967/// The executor decides at run time whether the named column actually has a
968/// B-tree index — if not, IndexScan transparently falls back to a scan +
969/// equality filter on that column. That means this rewrite is always safe
970/// regardless of schema/index state; it just unlocks the fast path when an
971/// index happens to exist.
972pub(crate) fn try_extract_eq_index_key(table: &str, pred: &Expr) -> Option<PlanNode> {
973    let (lhs, op, rhs) = match pred {
974        Expr::BinaryOp(lhs, op, rhs) => (lhs.as_ref(), *op, rhs.as_ref()),
975        _ => return None,
976    };
977    if op != BinOp::Eq {
978        return None;
979    }
980    match (lhs, rhs) {
981        (path @ Expr::JsonPath { .. }, Expr::Literal(_)) => Some(PlanNode::ExprIndexScan {
982            table: table.to_string(),
983            path: stored_json_path(path)?,
984            key: rhs.clone(),
985        }),
986        (Expr::Literal(_), path @ Expr::JsonPath { .. }) => Some(PlanNode::ExprIndexScan {
987            table: table.to_string(),
988            path: stored_json_path(path)?,
989            key: lhs.clone(),
990        }),
991        (Expr::Field(name), Expr::Literal(_)) => Some(PlanNode::IndexScan {
992            table: table.to_string(),
993            column: name.clone(),
994            key: rhs.clone(),
995        }),
996        (Expr::Literal(_), Expr::Field(name)) => Some(PlanNode::IndexScan {
997            table: table.to_string(),
998            column: name.clone(),
999            key: lhs.clone(),
1000        }),
1001        _ => None,
1002    }
1003}
1004
1005fn stored_json_path(expr: &Expr) -> Option<StoredJsonPathV1> {
1006    JsonPathIdentityV1::from_expr(expr)?.bind_table_local(None)
1007}
1008
1009/// Extract a single range bound from a simple inequality predicate.
1010/// Returns `(column, lower_bound, upper_bound)` where at most one bound is set.
1011pub(crate) fn extract_single_bound(pred: &Expr) -> Option<RangeBound> {
1012    let (lhs, op, rhs) = match pred {
1013        Expr::BinaryOp(lhs, op, rhs) => (lhs.as_ref(), *op, rhs.as_ref()),
1014        _ => return None,
1015    };
1016    match op {
1017        // .col > literal  →  lower=(literal, exclusive)
1018        BinOp::Gt => match (lhs, rhs) {
1019            (Expr::Field(name), Expr::Literal(_)) => Some((
1020                RangeTarget::Column(name.clone()),
1021                Some((rhs.clone(), false)),
1022                None,
1023            )),
1024            (Expr::Literal(_), Expr::Field(name)) => {
1025                // literal > .col  →  col < literal  →  upper=(literal, exclusive)
1026                Some((
1027                    RangeTarget::Column(name.clone()),
1028                    None,
1029                    Some((lhs.clone(), false)),
1030                ))
1031            }
1032            (path @ Expr::JsonPath { .. }, Expr::Literal(_)) => Some((
1033                RangeTarget::JsonPath(stored_json_path(path)?),
1034                Some((rhs.clone(), false)),
1035                None,
1036            )),
1037            (Expr::Literal(_), path @ Expr::JsonPath { .. }) => Some((
1038                RangeTarget::JsonPath(stored_json_path(path)?),
1039                None,
1040                Some((lhs.clone(), false)),
1041            )),
1042            _ => None,
1043        },
1044        // .col >= literal  →  lower=(literal, inclusive)
1045        BinOp::Gte => match (lhs, rhs) {
1046            (Expr::Field(name), Expr::Literal(_)) => Some((
1047                RangeTarget::Column(name.clone()),
1048                Some((rhs.clone(), true)),
1049                None,
1050            )),
1051            (Expr::Literal(_), Expr::Field(name)) => Some((
1052                RangeTarget::Column(name.clone()),
1053                None,
1054                Some((lhs.clone(), true)),
1055            )),
1056            (path @ Expr::JsonPath { .. }, Expr::Literal(_)) => Some((
1057                RangeTarget::JsonPath(stored_json_path(path)?),
1058                Some((rhs.clone(), true)),
1059                None,
1060            )),
1061            (Expr::Literal(_), path @ Expr::JsonPath { .. }) => Some((
1062                RangeTarget::JsonPath(stored_json_path(path)?),
1063                None,
1064                Some((lhs.clone(), true)),
1065            )),
1066            _ => None,
1067        },
1068        // .col < literal  →  upper=(literal, exclusive)
1069        BinOp::Lt => match (lhs, rhs) {
1070            (Expr::Field(name), Expr::Literal(_)) => Some((
1071                RangeTarget::Column(name.clone()),
1072                None,
1073                Some((rhs.clone(), false)),
1074            )),
1075            (Expr::Literal(_), Expr::Field(name)) => Some((
1076                RangeTarget::Column(name.clone()),
1077                Some((lhs.clone(), false)),
1078                None,
1079            )),
1080            (path @ Expr::JsonPath { .. }, Expr::Literal(_)) => Some((
1081                RangeTarget::JsonPath(stored_json_path(path)?),
1082                None,
1083                Some((rhs.clone(), false)),
1084            )),
1085            (Expr::Literal(_), path @ Expr::JsonPath { .. }) => Some((
1086                RangeTarget::JsonPath(stored_json_path(path)?),
1087                Some((lhs.clone(), false)),
1088                None,
1089            )),
1090            _ => None,
1091        },
1092        // .col <= literal  →  upper=(literal, inclusive)
1093        BinOp::Lte => match (lhs, rhs) {
1094            (Expr::Field(name), Expr::Literal(_)) => Some((
1095                RangeTarget::Column(name.clone()),
1096                None,
1097                Some((rhs.clone(), true)),
1098            )),
1099            (Expr::Literal(_), Expr::Field(name)) => Some((
1100                RangeTarget::Column(name.clone()),
1101                Some((lhs.clone(), true)),
1102                None,
1103            )),
1104            (path @ Expr::JsonPath { .. }, Expr::Literal(_)) => Some((
1105                RangeTarget::JsonPath(stored_json_path(path)?),
1106                None,
1107                Some((rhs.clone(), true)),
1108            )),
1109            (Expr::Literal(_), path @ Expr::JsonPath { .. }) => Some((
1110                RangeTarget::JsonPath(stored_json_path(path)?),
1111                Some((lhs.clone(), true)),
1112                None,
1113            )),
1114            _ => None,
1115        },
1116        _ => None,
1117    }
1118}
1119
1120/// If the predicate is an inequality or a conjunction of two inequalities
1121/// on the same indexed column, return a RangeScan plan node.
1122/// Handles: `.col > lit`, `.col >= lit`, `.col < lit`, `.col <= lit`,
1123/// and the canonical AND-conjunction `.col >= low AND .col <= high`
1124/// (BETWEEN pattern, lower bound spelled first).
1125///
1126/// Only the lower-then-upper spelling is merged here. Two other AND shapes
1127/// deliberately fall through to `Filter(SeqScan)`:
1128///
1129/// - Same-side bounds (`.v > 1 and .v >= 9`): a merged RangeScan can only
1130///   hold one bound per side, so merging would silently drop the tighter
1131///   conjunct (v0.18.0 bug F). The full predicate must survive.
1132/// - Upper-bound-first (`.v < B and .v > A`): the merged node would hold
1133///   `start` from the *second* source literal and `end` from the *first*,
1134///   but the plan cache substitutes literals in source-text order while
1135///   `substitute_plan` visits start-then-end, so every warm hit would run
1136///   with the bounds swapped (v0.18.0 bug G).
1137///
1138/// Neither fallback costs the index: `lower_unindexed_scans` re-merges
1139/// same-target bounds from the `Filter(SeqScan)` conjuncts at runtime,
1140/// after literal substitution, with real catalog knowledge, keeping any
1141/// extra bound as a residual recheck.
1142fn try_extract_range_index_keys(table: &str, pred: &Expr) -> Option<PlanNode> {
1143    // Case 1: AND conjunction — merge only `lower AND upper`, the one shape
1144    // whose plan literal order (start, end) matches source-text order.
1145    if let Expr::BinaryOp(lhs, BinOp::And, rhs) = pred {
1146        if let (Some((col1, s1, e1)), Some((col2, s2, e2))) =
1147            (extract_single_bound(lhs), extract_single_bound(rhs))
1148        {
1149            if col1 == col2 {
1150                if let (Some(start), None, None, Some(end)) = (s1, e1, s2, e2) {
1151                    return Some(range_scan_for_target(table, col1, Some(start), Some(end)));
1152                }
1153            }
1154        }
1155    }
1156
1157    // Case 2: single inequality.
1158    if let Some((col, start, end)) = extract_single_bound(pred) {
1159        return Some(range_scan_for_target(table, col, start, end));
1160    }
1161
1162    None
1163}
1164
1165pub(crate) fn range_scan_for_target(
1166    table: &str,
1167    target: RangeTarget,
1168    start: Option<(Expr, bool)>,
1169    end: Option<(Expr, bool)>,
1170) -> PlanNode {
1171    match target {
1172        RangeTarget::Column(column) => PlanNode::RangeScan {
1173            table: table.to_string(),
1174            column,
1175            start,
1176            end,
1177        },
1178        RangeTarget::JsonPath(path) => PlanNode::ExprRangeScan {
1179            table: table.to_string(),
1180            path,
1181            start,
1182            end,
1183        },
1184    }
1185}
1186
1187/// Fold only the exact, semantics-preserving single-table shape that can stream
1188/// directly from one expression index. Anything involving filters, joins,
1189/// grouping, distinct, aggregation, windows, multiple sort keys, or non-integer
1190/// slice expressions retains the generic Sort pipeline.
1191fn try_extract_ordered_expr_index_scan(query: &QueryExpr) -> Option<PlanNode> {
1192    if query.alias.is_some()
1193        || !query.joins.is_empty()
1194        || query.filter.is_some()
1195        || query.group_by.is_some()
1196        || query.distinct
1197        || query.aggregation.is_some()
1198        || query.projection.as_ref().is_some_and(|fields| {
1199            fields
1200                .iter()
1201                .any(|field| matches!(field.expr, Expr::Window { .. }))
1202        })
1203    {
1204        return None;
1205    }
1206    let order = query.order.as_ref()?;
1207    let [key] = order.keys.as_slice() else {
1208        return None;
1209    };
1210    let path = stored_json_path(&key.expr)?;
1211    let limit = query.limit.as_ref()?;
1212    if !matches!(limit, Expr::Literal(Literal::Int(value)) if *value >= 0) {
1213        return None;
1214    }
1215    if !query
1216        .offset
1217        .as_ref()
1218        .is_none_or(|offset| matches!(offset, Expr::Literal(Literal::Int(value)) if *value >= 0))
1219    {
1220        return None;
1221    }
1222    Some(PlanNode::OrderedExprIndexScan {
1223        table: query.source.clone(),
1224        path,
1225        descending: key.descending,
1226        limit: limit.clone(),
1227        offset: query.offset.clone(),
1228    })
1229}
1230
1231/// Walk projection fields, replacing every `Expr::Window { .. }` with
1232/// `Expr::Field("__win_N")` and collecting the corresponding `WindowDef`
1233/// descriptors. Returns the list of window definitions to insert as a
1234/// `PlanNode::Window` before the `Project` node.
1235fn extract_windows(proj_fields: &mut [ProjectField]) -> Vec<WindowDef> {
1236    let mut defs = Vec::new();
1237    let mut counter = 0usize;
1238    for f in proj_fields.iter_mut() {
1239        if let Expr::Window {
1240            function,
1241            args,
1242            mode,
1243            partition_by,
1244            order_by,
1245        } = &f.expr
1246        {
1247            let output_name = format!("__win_{counter}");
1248            defs.push(WindowDef {
1249                function: *function,
1250                args: args.clone(),
1251                mode: *mode,
1252                partition_by: partition_by.clone(),
1253                order_by: order_by
1254                    .iter()
1255                    .map(|k| SortKey {
1256                        expr: k.expr.clone(),
1257                        descending: k.descending,
1258                    })
1259                    .collect(),
1260                output_name: output_name.clone(),
1261            });
1262            f.expr = Expr::Field(output_name);
1263            counter += 1;
1264        }
1265    }
1266    defs
1267}
1268
1269/// Walk projection fields and HAVING expression, replacing every
1270/// `Expr::FunctionCall(func, Field(col))` with `Expr::Field("__agg_N")`
1271/// and collecting the corresponding `GroupAgg` descriptors. Deduplicates:
1272/// if the same (func, field) pair appears in both projection and HAVING,
1273/// they share a single `GroupAgg` entry.
1274fn extract_aggregates(
1275    proj_fields: &mut [ProjectField],
1276    having: &mut Option<Expr>,
1277    source_aliases: &std::collections::HashSet<String>,
1278) -> Result<Vec<GroupAgg>, PlanError> {
1279    let mut aggs: Vec<GroupAgg> = Vec::new();
1280    let mut counter = 0usize;
1281    for f in proj_fields.iter_mut() {
1282        rewrite_agg_expr(&mut f.expr, &mut aggs, &mut counter, source_aliases)?;
1283    }
1284    if let Some(h) = having {
1285        rewrite_agg_expr(h, &mut aggs, &mut counter, source_aliases)?;
1286    }
1287    Ok(aggs)
1288}
1289
1290fn rewrite_group_key_references(
1291    fields: &mut [ProjectField],
1292    having: &mut Option<Expr>,
1293    keys: &[GroupKey],
1294) {
1295    for field in fields {
1296        rewrite_group_key_expr(&mut field.expr, keys);
1297    }
1298    if let Some(having) = having {
1299        rewrite_group_key_expr(having, keys);
1300    }
1301}
1302
1303fn rewrite_group_order_keys(
1304    order: Option<&mut OrderClause>,
1305    projection: &[ProjectField],
1306    keys: &[GroupKey],
1307) {
1308    let Some(order) = order else {
1309        return;
1310    };
1311    for order_key in &mut order.keys {
1312        let Some(group_key) = keys.iter().find(|key| key.expr == order_key.expr) else {
1313            continue;
1314        };
1315        let projected_name = projection
1316            .iter()
1317            .find(|field| field.expr == group_key.expr)
1318            .and_then(|field| field.alias.clone())
1319            .unwrap_or_else(|| group_key.output_name());
1320        order_key.expr = Expr::Field(projected_name);
1321    }
1322}
1323
1324fn rewrite_group_key_expr(expr: &mut Expr, keys: &[GroupKey]) {
1325    if let Some(key) = keys.iter().find(|key| key.expr == *expr) {
1326        *expr = Expr::Field(key.output_name());
1327        return;
1328    }
1329    match expr {
1330        // Aggregate arguments run against input rows and have already been
1331        // extracted before this pass, so a survivor must not be rebound to a
1332        // grouped output column.
1333        Expr::FunctionCall(..) => {}
1334        Expr::BinaryOp(left, _, right) | Expr::Coalesce(left, right) => {
1335            rewrite_group_key_expr(left, keys);
1336            rewrite_group_key_expr(right, keys);
1337        }
1338        Expr::UnaryOp(_, inner) | Expr::Cast(inner, _) => rewrite_group_key_expr(inner, keys),
1339        Expr::ScalarFunc(_, args) => {
1340            for arg in args {
1341                rewrite_group_key_expr(arg, keys);
1342            }
1343        }
1344        Expr::InList { expr, list, .. } => {
1345            rewrite_group_key_expr(expr, keys);
1346            for item in list {
1347                rewrite_group_key_expr(item, keys);
1348            }
1349        }
1350        Expr::Case { whens, else_expr } => {
1351            for (condition, result) in whens {
1352                rewrite_group_key_expr(condition, keys);
1353                rewrite_group_key_expr(result, keys);
1354            }
1355            if let Some(expr) = else_expr {
1356                rewrite_group_key_expr(expr, keys);
1357            }
1358        }
1359        _ => {}
1360    }
1361}
1362
1363fn rewrite_agg_expr(
1364    expr: &mut Expr,
1365    aggs: &mut Vec<GroupAgg>,
1366    counter: &mut usize,
1367    source_aliases: &std::collections::HashSet<String>,
1368) -> Result<(), PlanError> {
1369    match expr {
1370        Expr::FunctionCall(func, inner, mode) => {
1371            let output = find_or_insert_agg(aggs, *func, inner, *mode, counter, source_aliases)?;
1372            *expr = Expr::Field(output);
1373        }
1374        Expr::BinaryOp(l, _, r) => {
1375            rewrite_agg_expr(l, aggs, counter, source_aliases)?;
1376            rewrite_agg_expr(r, aggs, counter, source_aliases)?;
1377        }
1378        Expr::UnaryOp(_, inner) => rewrite_agg_expr(inner, aggs, counter, source_aliases)?,
1379        Expr::Coalesce(l, r) => {
1380            rewrite_agg_expr(l, aggs, counter, source_aliases)?;
1381            rewrite_agg_expr(r, aggs, counter, source_aliases)?;
1382        }
1383        Expr::InList { expr: e, list, .. } => {
1384            rewrite_agg_expr(e, aggs, counter, source_aliases)?;
1385            for item in list {
1386                rewrite_agg_expr(item, aggs, counter, source_aliases)?;
1387            }
1388        }
1389        Expr::InSubquery { expr: e, .. } => {
1390            rewrite_agg_expr(e, aggs, counter, source_aliases)?;
1391        }
1392        _ => {}
1393    }
1394    Ok(())
1395}
1396
1397fn find_or_insert_agg(
1398    aggs: &mut Vec<GroupAgg>,
1399    func: AggFunc,
1400    argument: &Expr,
1401    mode: AggregateMode,
1402    counter: &mut usize,
1403    source_aliases: &std::collections::HashSet<String>,
1404) -> Result<String, PlanError> {
1405    for existing in aggs.iter() {
1406        if existing.function == func && existing.argument == *argument && existing.mode == mode {
1407            return Ok(existing.output_name.clone());
1408        }
1409    }
1410    let provenance_alias = symmetric_provenance_alias(func, Some(argument), mode, source_aliases)?;
1411    let output_name = format!("__agg_{counter}");
1412    aggs.push(GroupAgg {
1413        function: func,
1414        argument: argument.clone(),
1415        mode,
1416        provenance_alias,
1417        output_name: output_name.clone(),
1418    });
1419    *counter += 1;
1420    Ok(output_name)
1421}
1422
1423fn symmetric_provenance_alias(
1424    function: AggFunc,
1425    argument: Option<&Expr>,
1426    mode: AggregateMode,
1427    source_aliases: &std::collections::HashSet<String>,
1428) -> Result<Option<String>, PlanError> {
1429    if mode == AggregateMode::Raw
1430        || source_aliases.len() < 2
1431        || !matches!(function, AggFunc::Sum | AggFunc::Avg | AggFunc::Count)
1432        || (function == AggFunc::Count
1433            && argument.is_none_or(|argument| matches!(argument, Expr::Field(name) if name == "*")))
1434    {
1435        return Ok(None);
1436    }
1437    let Some(argument) = argument else {
1438        return Err(symmetric_aggregate_error(
1439            function,
1440            "does not reference a source row",
1441        ));
1442    };
1443
1444    let mut qualified = std::collections::HashSet::new();
1445    let mut has_unqualified = false;
1446    collect_expression_sources(argument, &mut qualified, &mut has_unqualified);
1447
1448    for alias in &qualified {
1449        if !source_aliases.contains(alias) {
1450            return Err(symmetric_aggregate_error(
1451                function,
1452                &format!("references unknown source alias '{alias}'"),
1453            ));
1454        }
1455    }
1456    if has_unqualified {
1457        if source_aliases.len() != 1 {
1458            return Err(symmetric_aggregate_error(
1459                function,
1460                "contains an ambiguous unqualified field",
1461            ));
1462        }
1463        qualified.extend(source_aliases.iter().cloned());
1464    }
1465    match qualified.len() {
1466        1 => Ok(qualified.into_iter().next()),
1467        0 => Err(symmetric_aggregate_error(
1468            function,
1469            "does not reference a source row",
1470        )),
1471        _ => Err(symmetric_aggregate_error(
1472            function,
1473            "references multiple source aliases",
1474        )),
1475    }
1476}
1477
1478fn symmetric_aggregate_error(function: AggFunc, reason: &str) -> PlanError {
1479    let name = format!("{function:?}").to_lowercase();
1480    PlanError::Semantic(format!(
1481        "symmetric {name} expression {reason}; reference exactly one source alias or use {name}(raw ...)"
1482    ))
1483}
1484
1485fn collect_expression_sources(
1486    expr: &Expr,
1487    qualified: &mut std::collections::HashSet<String>,
1488    has_unqualified: &mut bool,
1489) {
1490    match expr {
1491        Expr::Field(name) if name != "*" => *has_unqualified = true,
1492        Expr::QualifiedField { qualifier, .. } => {
1493            qualified.insert(qualifier.clone());
1494        }
1495        Expr::BinaryOp(left, _, right) | Expr::Coalesce(left, right) => {
1496            collect_expression_sources(left, qualified, has_unqualified);
1497            collect_expression_sources(right, qualified, has_unqualified);
1498        }
1499        Expr::UnaryOp(_, inner) | Expr::Cast(inner, _) | Expr::JsonPath { base: inner, .. } => {
1500            collect_expression_sources(inner, qualified, has_unqualified);
1501        }
1502        Expr::ScalarFunc(_, args) => {
1503            for argument in args {
1504                collect_expression_sources(argument, qualified, has_unqualified);
1505            }
1506        }
1507        Expr::InList { expr, list, .. } => {
1508            collect_expression_sources(expr, qualified, has_unqualified);
1509            for item in list {
1510                collect_expression_sources(item, qualified, has_unqualified);
1511            }
1512        }
1513        Expr::InSubquery { expr, .. } => {
1514            collect_expression_sources(expr, qualified, has_unqualified);
1515        }
1516        Expr::Case { whens, else_expr } => {
1517            for (condition, result) in whens {
1518                collect_expression_sources(condition, qualified, has_unqualified);
1519                collect_expression_sources(result, qualified, has_unqualified);
1520            }
1521            if let Some(expr) = else_expr {
1522                collect_expression_sources(expr, qualified, has_unqualified);
1523            }
1524        }
1525        Expr::Window {
1526            args,
1527            partition_by,
1528            order_by,
1529            ..
1530        } => {
1531            for expr in args.iter().chain(partition_by) {
1532                collect_expression_sources(expr, qualified, has_unqualified);
1533            }
1534            for key in order_by {
1535                collect_expression_sources(&key.expr, qualified, has_unqualified);
1536            }
1537        }
1538        Expr::FunctionCall(_, inner, _) => {
1539            collect_expression_sources(inner, qualified, has_unqualified);
1540        }
1541        Expr::ExistsSubquery { .. }
1542        | Expr::Field(_)
1543        | Expr::Literal(_)
1544        | Expr::Param(_)
1545        | Expr::ValueLit(_)
1546        | Expr::Null
1547        | Expr::NestedQuery(_) => {}
1548    }
1549}
1550
1551#[cfg(test)]
1552mod tests {
1553    use super::*;
1554    use crate::plan::PlanNode;
1555
1556    #[test]
1557    fn test_plan_simple_scan() {
1558        let plan = plan("User").unwrap();
1559        assert!(matches!(plan, PlanNode::SeqScan { table } if table == "User"));
1560    }
1561
1562    #[test]
1563    fn test_plan_filter() {
1564        let plan = plan("User filter .age > 30").unwrap();
1565        assert!(matches!(plan, PlanNode::RangeScan { .. }));
1566    }
1567
1568    #[test]
1569    fn test_plan_filter_with_projection() {
1570        let plan = plan("User filter .age > 30 { name, email }").unwrap();
1571        assert!(matches!(plan, PlanNode::Project { .. }));
1572    }
1573
1574    #[test]
1575    fn test_plan_insert() {
1576        let plan = plan(r#"insert User { name := "Alice", age := 30 }"#).unwrap();
1577        assert!(matches!(plan, PlanNode::Insert { .. }));
1578    }
1579
1580    #[test]
1581    fn test_plan_order_limit() {
1582        let plan = plan("User order .name limit 10").unwrap();
1583        match plan {
1584            PlanNode::Limit { input, .. } => {
1585                assert!(matches!(*input, PlanNode::Sort { .. }));
1586            }
1587            _ => panic!("expected Limit(Sort(SeqScan))"),
1588        }
1589    }
1590
1591    #[test]
1592    fn test_plan_count() {
1593        let plan = plan("count(User)").unwrap();
1594        assert!(matches!(plan, PlanNode::Aggregate { .. }));
1595    }
1596
1597    #[test]
1598    fn single_source_aggregates_do_not_request_provenance() {
1599        for query in [
1600            "sum(User { .amount })",
1601            "avg(User { .amount })",
1602            "count(User { .amount })",
1603        ] {
1604            match plan(query).unwrap() {
1605                PlanNode::Aggregate {
1606                    provenance_alias, ..
1607                } => assert!(
1608                    provenance_alias.is_none(),
1609                    "unexpected provenance for {query}"
1610                ),
1611                other => panic!("expected Aggregate for {query}, got {other:?}"),
1612            }
1613        }
1614
1615        match plan("User group .dept { total: sum(.amount) }").unwrap() {
1616            PlanNode::Project { input, .. } => match *input {
1617                PlanNode::GroupBy { aggregates, .. } => {
1618                    assert!(aggregates[0].provenance_alias.is_none());
1619                }
1620                other => panic!("expected GroupBy, got {other:?}"),
1621            },
1622            other => panic!("expected Project(GroupBy), got {other:?}"),
1623        }
1624    }
1625
1626    #[test]
1627    fn join_provenance_is_limited_to_fanout_sensitive_aggregates() {
1628        let base = "Account as a join Entry as e on a.id = e.account_id group a.dept";
1629        for (function, expects_provenance) in [
1630            ("sum(a.balance)", true),
1631            ("avg(a.balance)", true),
1632            ("count(a.balance)", true),
1633            ("min(a.balance)", false),
1634            ("max(a.balance)", false),
1635            ("count(distinct a.balance)", false),
1636            ("count(*)", false),
1637        ] {
1638            let query = format!("{base} {{ value: {function} }}");
1639            match plan(&query).unwrap() {
1640                PlanNode::Project { input, .. } => match *input {
1641                    PlanNode::GroupBy { aggregates, .. } => assert_eq!(
1642                        aggregates[0].provenance_alias.as_deref(),
1643                        expects_provenance.then_some("a"),
1644                        "unexpected provenance selection for {function}"
1645                    ),
1646                    other => panic!("expected GroupBy for {function}, got {other:?}"),
1647                },
1648                other => panic!("expected Project(GroupBy) for {function}, got {other:?}"),
1649            }
1650        }
1651    }
1652
1653    #[test]
1654    fn test_plan_eq_becomes_index_scan() {
1655        // `filter .col = literal` should fold into an IndexScan — the executor
1656        // falls back to a scan if the column happens to lack an index.
1657        let plan = plan("User filter .id = 42").unwrap();
1658        match plan {
1659            PlanNode::IndexScan { table, column, key } => {
1660                assert_eq!(table, "User");
1661                assert_eq!(column, "id");
1662                assert!(matches!(key, Expr::Literal(Literal::Int(42))));
1663            }
1664            other => panic!("expected IndexScan, got {other:?}"),
1665        }
1666    }
1667
1668    #[test]
1669    fn test_plan_eq_reversed_becomes_index_scan() {
1670        // Literal-on-the-left form should fold the same way.
1671        let plan = plan(r#"User filter "NYC" = .city"#).unwrap();
1672        assert!(matches!(plan, PlanNode::IndexScan { .. }));
1673    }
1674
1675    #[test]
1676    fn json_path_equality_and_reversed_equality_are_speculative_expression_scans() {
1677        for query in ["Post filter .data->age = 21", "Post filter 21 = .data->age"] {
1678            match plan(query).unwrap() {
1679                PlanNode::ExprIndexScan { table, path, key } => {
1680                    assert_eq!(table, "Post");
1681                    assert_eq!(path.canonical_text(), "v1:.data->\"age\"");
1682                    assert!(matches!(key, Expr::Literal(Literal::Int(21))));
1683                }
1684                other => panic!("expected ExprIndexScan for `{query}`, got {other:?}"),
1685            }
1686        }
1687    }
1688
1689    #[test]
1690    fn json_path_range_and_same_path_compound_bounds_are_speculative_scans() {
1691        for query in ["Post filter .data->age > 18", "Post filter 18 < .data->age"] {
1692            match plan(query).unwrap() {
1693                PlanNode::ExprRangeScan {
1694                    path, start, end, ..
1695                } => {
1696                    assert_eq!(path.canonical_text(), "v1:.data->\"age\"");
1697                    assert!(start.is_some());
1698                    assert!(end.is_none());
1699                }
1700                other => panic!("expected ExprRangeScan for `{query}`, got {other:?}"),
1701            }
1702        }
1703
1704        match plan("Post filter .data->age >= 18 and .data->age < 65").unwrap() {
1705            PlanNode::ExprRangeScan {
1706                path, start, end, ..
1707            } => {
1708                assert_eq!(path.canonical_text(), "v1:.data->\"age\"");
1709                assert_eq!(start, Some((Expr::Literal(Literal::Int(18)), true)));
1710                assert_eq!(end, Some((Expr::Literal(Literal::Int(65)), false)));
1711            }
1712            other => panic!("expected bounded ExprRangeScan, got {other:?}"),
1713        }
1714
1715        assert!(matches!(
1716            plan("Post filter .data->age >= 18 and .data->score < 65").unwrap(),
1717            PlanNode::Filter { .. }
1718        ));
1719    }
1720
1721    #[test]
1722    fn exact_single_path_order_limit_uses_ordered_expression_scan() {
1723        match plan("Post order .data->age desc limit 10 offset 2 { .id }").unwrap() {
1724            PlanNode::Project { input, .. } => match *input {
1725                PlanNode::OrderedExprIndexScan {
1726                    table,
1727                    path,
1728                    descending,
1729                    limit,
1730                    offset,
1731                } => {
1732                    assert_eq!(table, "Post");
1733                    assert_eq!(path.canonical_text(), "v1:.data->\"age\"");
1734                    assert!(descending);
1735                    assert_eq!(limit, Expr::Literal(Literal::Int(10)));
1736                    assert_eq!(offset, Some(Expr::Literal(Literal::Int(2))));
1737                }
1738                other => panic!("expected OrderedExprIndexScan, got {other:?}"),
1739            },
1740            other => panic!("expected Project(OrderedExprIndexScan), got {other:?}"),
1741        }
1742    }
1743
1744    #[test]
1745    fn incompatible_path_order_shapes_keep_generic_sort() {
1746        for query in [
1747            "Post order .data->age",
1748            "Post order .data->age, .id limit 10",
1749            "Post filter .data->active = true order .data->age limit 10",
1750            "Post order .data->age limit .id",
1751        ] {
1752            let planned = plan(query).unwrap();
1753            assert!(
1754                !plan_contains_ordered_expr_scan(&planned),
1755                "`{query}` must remain on the generic pipeline: {planned:?}"
1756            );
1757        }
1758    }
1759
1760    fn plan_contains_ordered_expr_scan(plan: &PlanNode) -> bool {
1761        match plan {
1762            PlanNode::OrderedExprIndexScan { .. } => true,
1763            PlanNode::Filter { input, .. }
1764            | PlanNode::Project { input, .. }
1765            | PlanNode::Sort { input, .. }
1766            | PlanNode::Limit { input, .. }
1767            | PlanNode::Offset { input, .. }
1768            | PlanNode::Aggregate { input, .. }
1769            | PlanNode::Distinct { input }
1770            | PlanNode::GroupBy { input, .. }
1771            | PlanNode::Update { input, .. }
1772            | PlanNode::Delete { input, .. }
1773            | PlanNode::Window { input, .. }
1774            | PlanNode::Explain { input } => plan_contains_ordered_expr_scan(input),
1775            PlanNode::NestedLoopJoin { left, right, .. } | PlanNode::Union { left, right, .. } => {
1776                plan_contains_ordered_expr_scan(left) || plan_contains_ordered_expr_scan(right)
1777            }
1778            _ => false,
1779        }
1780    }
1781
1782    #[test]
1783    fn test_plan_non_eq_stays_filter() {
1784        // `>` now emits a RangeScan instead of SeqScan+Filter.
1785        let plan = plan("User filter .age > 30").unwrap();
1786        match plan {
1787            PlanNode::RangeScan {
1788                column, start, end, ..
1789            } => {
1790                assert_eq!(column, "age");
1791                assert!(start.is_some(), "expected lower bound");
1792                assert!(end.is_none(), "expected no upper bound");
1793                let (_, inclusive) = start.unwrap();
1794                assert!(!inclusive, "expected exclusive lower bound for >");
1795            }
1796            other => panic!("expected RangeScan, got {other:?}"),
1797        }
1798    }
1799
1800    #[test]
1801    fn test_plan_index_scan_with_projection() {
1802        // Projection on top of an IndexScan should layer correctly.
1803        let plan = plan("User filter .id = 1 { .name }").unwrap();
1804        match plan {
1805            PlanNode::Project { input, .. } => {
1806                assert!(matches!(*input, PlanNode::IndexScan { .. }));
1807            }
1808            other => panic!("expected Project(IndexScan), got {other:?}"),
1809        }
1810    }
1811
1812    #[test]
1813    fn test_plan_update_by_pk_becomes_index_scan() {
1814        // `.id = literal` update should fold to Update(IndexScan), not
1815        // Update(Filter(SeqScan)).
1816        let plan = plan("User filter .id = 42 update { age := 31 }").unwrap();
1817        match plan {
1818            PlanNode::Update { input, .. } => {
1819                assert!(
1820                    matches!(*input, PlanNode::IndexScan { .. }),
1821                    "expected Update(IndexScan), got {input:?}"
1822                );
1823            }
1824            other => panic!("expected Update, got {other:?}"),
1825        }
1826    }
1827
1828    #[test]
1829    fn test_plan_update_range_stays_range_scan() {
1830        let plan = plan("User filter .age > 30 update { age := 31 }").unwrap();
1831        match plan {
1832            PlanNode::Update { input, .. } => {
1833                assert!(
1834                    matches!(*input, PlanNode::RangeScan { .. }),
1835                    "expected Update(RangeScan), got {input:?}"
1836                );
1837            }
1838            other => panic!("expected Update, got {other:?}"),
1839        }
1840    }
1841
1842    #[test]
1843    fn test_plan_delete_by_pk_becomes_index_scan() {
1844        let plan = plan("User filter .id = 7 delete").unwrap();
1845        match plan {
1846            PlanNode::Delete { input, .. } => {
1847                assert!(matches!(*input, PlanNode::IndexScan { .. }));
1848            }
1849            other => panic!("expected Delete, got {other:?}"),
1850        }
1851    }
1852
1853    #[test]
1854    fn test_plan_inner_join_builds_nested_loop() {
1855        // Mission E1.2: a join query should plan to NestedLoopJoin with
1856        // AliasScan leaves on both sides.
1857        let plan = plan("User as u join Order as o on u.id = o.user_id").unwrap();
1858        match plan {
1859            PlanNode::NestedLoopJoin {
1860                left,
1861                right,
1862                on,
1863                kind,
1864            } => {
1865                assert_eq!(kind, JoinKind::Inner);
1866                assert!(on.is_some());
1867                assert!(matches!(*left, PlanNode::AliasScan { .. }));
1868                assert!(matches!(*right, PlanNode::AliasScan { .. }));
1869            }
1870            other => panic!("expected NestedLoopJoin, got {other:?}"),
1871        }
1872    }
1873
1874    #[test]
1875    fn duplicate_join_aliases_are_rejected_before_execution() {
1876        let err = plan("A as x join A as x on x.id = x.id").unwrap_err();
1877        assert!(
1878            err.to_string().contains("duplicate source alias `x`"),
1879            "unexpected error: {err}"
1880        );
1881    }
1882
1883    #[test]
1884    fn test_plan_right_join_rewritten_as_left_with_swapped_inputs() {
1885        let plan = plan("User as u right join Order as o on u.id = o.user_id").unwrap();
1886        match plan {
1887            PlanNode::NestedLoopJoin {
1888                left, right, kind, ..
1889            } => {
1890                assert_eq!(kind, JoinKind::LeftOuter);
1891                // Swapped: Order is now on the left, User on the right.
1892                match *left {
1893                    PlanNode::AliasScan { table, .. } => assert_eq!(table, "Order"),
1894                    other => panic!("expected AliasScan(Order), got {other:?}"),
1895                }
1896                match *right {
1897                    PlanNode::AliasScan { table, .. } => assert_eq!(table, "User"),
1898                    other => panic!("expected AliasScan(User), got {other:?}"),
1899                }
1900            }
1901            other => panic!("expected NestedLoopJoin, got {other:?}"),
1902        }
1903    }
1904
1905    #[test]
1906    fn test_plan_multi_join_is_left_deep() {
1907        // Three sources → two NestedLoopJoins, left-deep.
1908        let plan = plan(
1909            "User as u join Order as o on u.id = o.user_id \
1910             join Product as p on o.product_id = p.id",
1911        )
1912        .unwrap();
1913        match plan {
1914            PlanNode::NestedLoopJoin { left, right, .. } => {
1915                // Outer (Product) join: right is AliasScan(Product)
1916                match *right {
1917                    PlanNode::AliasScan { table, .. } => assert_eq!(table, "Product"),
1918                    other => panic!("expected AliasScan(Product), got {other:?}"),
1919                }
1920                // Outer.left is inner (Order) NestedLoopJoin
1921                assert!(matches!(*left, PlanNode::NestedLoopJoin { .. }));
1922            }
1923            other => panic!("expected NestedLoopJoin, got {other:?}"),
1924        }
1925    }
1926
1927    #[test]
1928    fn test_plan_join_with_filter_tail_wraps_filter_on_top() {
1929        let plan =
1930            plan("User as u join Order as o on u.id = o.user_id filter o.total > 100").unwrap();
1931        match plan {
1932            PlanNode::Filter { input, .. } => {
1933                assert!(matches!(*input, PlanNode::NestedLoopJoin { .. }));
1934            }
1935            other => panic!("expected Filter(NestedLoopJoin), got {other:?}"),
1936        }
1937    }
1938
1939    #[test]
1940    fn test_plan_group_by_builds_groupby_node() {
1941        let plan = plan("User group .status { .status, n: count(.name) }").unwrap();
1942        // Should be Project(GroupBy(SeqScan)).
1943        match plan {
1944            PlanNode::Project { input, fields } => {
1945                assert_eq!(fields.len(), 2);
1946                match *input {
1947                    PlanNode::GroupBy {
1948                        input: inner,
1949                        keys,
1950                        aggregates,
1951                        having,
1952                    } => {
1953                        assert!(matches!(*inner, PlanNode::SeqScan { .. }));
1954                        assert_eq!(
1955                            keys,
1956                            vec![GroupKey {
1957                                expr: Expr::Field("status".into()),
1958                                output_name: "status".into(),
1959                            }]
1960                        );
1961                        assert_eq!(aggregates.len(), 1);
1962                        assert_eq!(aggregates[0].function, AggFunc::Count);
1963                        assert_eq!(aggregates[0].argument, Expr::Field("name".into()));
1964                        assert!(having.is_none());
1965                    }
1966                    other => panic!("expected GroupBy, got {other:?}"),
1967                }
1968            }
1969            other => panic!("expected Project, got {other:?}"),
1970        }
1971    }
1972
1973    #[test]
1974    fn test_plan_joined_group_applies_order_offset_limit_after_grouping() {
1975        let plan = plan(
1976            "User as u join Order as o on u.id = o.user_id \
1977             group u.status { u.status, n: count(*) } order n desc offset 1 limit 2",
1978        )
1979        .unwrap();
1980
1981        let PlanNode::Limit { input, .. } = plan else {
1982            panic!("expected Limit at the grouped-result boundary");
1983        };
1984        let PlanNode::Offset { input, .. } = *input else {
1985            panic!("expected Offset below Limit");
1986        };
1987        let PlanNode::Sort { input, .. } = *input else {
1988            panic!("expected Sort below Offset");
1989        };
1990        let PlanNode::Project { input, .. } = *input else {
1991            panic!("expected Project below Sort");
1992        };
1993        let PlanNode::GroupBy { input, .. } = *input else {
1994            panic!("expected GroupBy below Project");
1995        };
1996        assert!(
1997            matches!(*input, PlanNode::NestedLoopJoin { .. }),
1998            "joined rows must flow into GroupBy before result limiting"
1999        );
2000    }
2001
2002    #[test]
2003    fn test_plan_group_by_having_rewrites_agg_in_having() {
2004        let plan = plan("User group .status having count(.name) > 1 { .status }").unwrap();
2005        match plan {
2006            PlanNode::Project { input, .. } => {
2007                match *input {
2008                    PlanNode::GroupBy {
2009                        having, aggregates, ..
2010                    } => {
2011                        // The planner should have extracted count(.name) into
2012                        // aggregates and rewritten the HAVING to reference __agg_0.
2013                        assert_eq!(aggregates.len(), 1);
2014                        assert_eq!(aggregates[0].output_name, "__agg_0");
2015                        let h = having.expect("having should be Some");
2016                        match h {
2017                            Expr::BinaryOp(l, BinOp::Gt, _) => {
2018                                assert!(
2019                                    matches!(*l, Expr::Field(ref name) if name == "__agg_0"),
2020                                    "expected Field(__agg_0), got {l:?}"
2021                                );
2022                            }
2023                            other => panic!("expected BinaryOp, got {other:?}"),
2024                        }
2025                    }
2026                    other => panic!("expected GroupBy, got {other:?}"),
2027                }
2028            }
2029            other => panic!("expected Project, got {other:?}"),
2030        }
2031    }
2032
2033    #[test]
2034    fn test_plan_window_inserts_window_node_before_project() {
2035        let plan = plan("User { .name, rn: row_number() over (order .age) }").unwrap();
2036        // Expected shape: Project(Window(SeqScan))
2037        match plan {
2038            PlanNode::Project { input, fields } => {
2039                assert_eq!(fields.len(), 2);
2040                // The window expr should have been replaced with Field("__win_0")
2041                assert!(
2042                    matches!(&fields[1].expr, Expr::Field(name) if name == "__win_0"),
2043                    "expected Field(__win_0), got {:?}",
2044                    fields[1].expr
2045                );
2046                match *input {
2047                    PlanNode::Window {
2048                        input: inner,
2049                        windows,
2050                    } => {
2051                        assert_eq!(windows.len(), 1);
2052                        assert_eq!(windows[0].output_name, "__win_0");
2053                        assert!(matches!(*inner, PlanNode::SeqScan { .. }));
2054                    }
2055                    other => panic!("expected Window, got {other:?}"),
2056                }
2057            }
2058            other => panic!("expected Project, got {other:?}"),
2059        }
2060    }
2061
2062    #[test]
2063    fn test_plan_multiple_windows() {
2064        let plan = plan(
2065            "User { .name, rn: row_number() over (order .age), s: sum(.salary) over (partition .dept order .salary) }"
2066        ).unwrap();
2067        match plan {
2068            PlanNode::Project { input, fields } => {
2069                assert_eq!(fields.len(), 3);
2070                assert!(matches!(&fields[1].expr, Expr::Field(name) if name == "__win_0"));
2071                assert!(matches!(&fields[2].expr, Expr::Field(name) if name == "__win_1"));
2072                match *input {
2073                    PlanNode::Window { windows, .. } => {
2074                        assert_eq!(windows.len(), 2);
2075                        assert_eq!(windows[0].output_name, "__win_0");
2076                        assert_eq!(windows[1].output_name, "__win_1");
2077                    }
2078                    other => panic!("expected Window, got {other:?}"),
2079                }
2080            }
2081            other => panic!("expected Project, got {other:?}"),
2082        }
2083    }
2084
2085    #[test]
2086    fn test_plan_no_window_without_over() {
2087        // Plain aggregate in projection should not create a Window node.
2088        let plan = plan("User group .dept { .dept, total: sum(.salary) }").unwrap();
2089        match plan {
2090            PlanNode::Project { input, .. } => {
2091                // Input should be GroupBy, not Window.
2092                assert!(
2093                    matches!(*input, PlanNode::GroupBy { .. }),
2094                    "expected GroupBy under Project, got {:?}",
2095                    input
2096                );
2097            }
2098            other => panic!("expected Project, got {other:?}"),
2099        }
2100    }
2101
2102    #[test]
2103    fn test_plan_explain_wraps_inner() {
2104        let plan = plan("explain User filter .age > 30").unwrap();
2105        match plan {
2106            PlanNode::Explain { input } => {
2107                assert!(
2108                    matches!(*input, PlanNode::RangeScan { .. }),
2109                    "expected Explain(RangeScan), got {:?}",
2110                    input
2111                );
2112            }
2113            other => panic!("expected Explain, got {other:?}"),
2114        }
2115    }
2116
2117    #[test]
2118    fn test_plan_explain_simple_scan() {
2119        let plan = plan("explain User").unwrap();
2120        match plan {
2121            PlanNode::Explain { input } => {
2122                assert!(matches!(*input, PlanNode::SeqScan { .. }));
2123            }
2124            other => panic!("expected Explain(SeqScan), got {other:?}"),
2125        }
2126    }
2127
2128    #[test]
2129    fn test_plan_explain_join() {
2130        let plan = plan("explain User as u join Order as o on u.id = o.user_id").unwrap();
2131        match plan {
2132            PlanNode::Explain { input } => {
2133                assert!(matches!(*input, PlanNode::NestedLoopJoin { .. }));
2134            }
2135            other => panic!("expected Explain(NestedLoopJoin), got {other:?}"),
2136        }
2137    }
2138}