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