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