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