Skip to main content

powdb_query/
planner.rs

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