Skip to main content

akar_planner/
planner.rs

1//! Query planner — converts bound statements into logical query plans.
2//!
3//! Builds a tree of logical operators from the bound AST:
4//! - MATCH → ScanNode / ScanRel
5//! - Multiple MATCH patterns combined via join tree (HashJoin / CrossProduct)
6//! - WHERE → Filter (applied after joins)
7//! - RETURN → Projection (topmost operator)
8
9use crate::join_order::{build_join_tree, build_wcoj_intersect, flatten_join_plan};
10use crate::logical_operator::*;
11use akar_binder::bound_statement::*;
12use akar_common::error::PlannerError;
13use akar_common::types::{Value, extract_f64_list};
14use akar_parser::ast::Expression;
15use std::collections::HashSet;
16
17/// Evaluate a constant expression (or list literal of constants) to a `Value`
18/// for planning standalone calls that take literal arguments (e.g.
19/// `vector_similarity_scan`). Non-constant expressions evaluate to `None`.
20fn eval_standalone_expr(expr: &Expression) -> Option<Value> {
21    match expr {
22        Expression::Constant(c) => match c {
23            akar_parser::ast::Constant::String(s) => Some(Value::String(s.clone())),
24            akar_parser::ast::Constant::Integer(i) => Some(Value::Int64(*i)),
25            akar_parser::ast::Constant::Float(f) => Some(Value::Double(*f)),
26            akar_parser::ast::Constant::Bool(b) => Some(Value::Bool(*b)),
27            akar_parser::ast::Constant::Null => Some(Value::Null),
28        },
29        Expression::List(items) => Some(Value::List(items.iter().filter_map(eval_standalone_expr).collect())),
30        _ => None,
31    }
32}
33
34/// Take the FTS query only when it targets `table`, so a `USING FTS INDEX`
35/// clause filters the scan of the index's base table — never a sibling scan of
36/// a different table (P108.1).
37fn take_fts_if_table(fts: &mut Option<LogicalFtsScan>, table: &str) -> Option<LogicalFtsScan> {
38    if fts.as_ref().is_some_and(|f| f.table_name == table) {
39        fts.take()
40    } else {
41        None
42    }
43}
44
45/// Recursively attach `fts` to every `ScanNode` whose table matches (skipping
46/// scans that already carry a query). Used by the WCOJ intersect path, which
47/// builds its starting scan nodes with `fts_query: None` (P108.1).
48fn attach_fts_to_matching_scans(op: &mut LogicalOperator, fts: &LogicalFtsScan) {
49    if let LogicalOperator::ScanNode(s) = op {
50        if s.table_name == fts.table_name && s.fts_query.is_none() {
51            s.fts_query = Some(fts.clone());
52        }
53        return;
54    }
55    // The indexed table may only be reachable as this hop's destination
56    // (P108.4) — the WCOJ build-side extend produces the rows to filter.
57    if let LogicalOperator::Extend(e) = op {
58        if e.dst_table_name == fts.table_name && e.fts_query.is_none() {
59            e.fts_query = Some(fts.clone());
60        }
61        return;
62    }
63    for child in op.children_mut() {
64        attach_fts_to_matching_scans(child, fts);
65    }
66}
67
68/// Whether an ORDER BY key expression is produced by the projection output.
69///
70/// A key is covered when it matches a projection item's alias or is identical
71/// to one of the projected expressions, or when the projection returns the bare
72/// node variable and the key accesses a property of that variable. Keys that are
73/// not covered (e.g. `m.access_count` in `RETURN m.id, m.label ORDER BY
74/// m.access_count`) must sort on the pre-projection columns (P53.37).
75pub fn projection_covers_sort_key(projected: &[BoundExpression], key: &Expression) -> bool {
76    for be in projected {
77        if let Some(alias) = &be.alias {
78            if sort_key_matches_name(key, alias) {
79                return true;
80            }
81        }
82        if &be.expression == key {
83            return true;
84        }
85    }
86    if let Expression::PropertyAccess(obj, _) = key {
87        if let Expression::Variable(var) = &**obj {
88            let bare = Expression::Variable(var.clone());
89            if projected.iter().any(|be| be.expression == bare) {
90                return true;
91            }
92        }
93    }
94    false
95}
96
97/// Whether a sort key refers to the column named by `name` (`x` or `node.prop`).
98fn sort_key_matches_name(key: &Expression, name: &str) -> bool {
99    match key {
100        Expression::Variable(v) => v == name,
101        Expression::PropertyAccess(obj, prop) => {
102            if let Expression::Variable(var) = &**obj {
103                format!("{var}.{prop}") == name
104            } else {
105                false
106            }
107        }
108        _ => false,
109    }
110}
111
112/// AND-combine a newly bound WHERE predicate with any previously accumulated
113/// filter (e.g. an implicit one generated by the binder from inline
114/// node-properties) — otherwise the earlier predicate would be silently
115/// dropped (P48.17 BUG-A). With no prior filter, the new predicate is kept
116/// verbatim.
117fn combine_filter_expr(prev: Option<BoundExpression>, next: BoundExpression) -> BoundExpression {
118    let Some(prev) = prev else { return next };
119    BoundExpression {
120        expression: Expression::BinaryOp(
121            akar_parser::ast::BinaryOp::And,
122            Box::new(prev.expression),
123            Box::new(next.expression),
124        ),
125        resolved_type: akar_common::types::LogicalTypeID::Bool,
126        is_constant: prev.is_constant && next.is_constant,
127        alias: None,
128    }
129}
130
131/// The query planner transforms bound statements into logical query plans.
132pub struct QueryPlanner;
133
134impl QueryPlanner {
135    pub fn new() -> Self {
136        Self
137    }
138
139    pub fn plan(&self, statement: BoundStatement) -> Result<Vec<LogicalOperator>, PlannerError> {
140        match statement {
141            BoundStatement::BoundQuery(query) => self.plan_query(query),
142            BoundStatement::BoundCopyFrom(c) => self.plan_copy_from(c),
143            BoundStatement::BoundUnion(u) => self.plan_union(u),
144            BoundStatement::BoundMerge(m) => self.plan_merge(m),
145            BoundStatement::BoundExplain(e) => self.plan_explain(e),
146            BoundStatement::BoundCreateNodeTable(t) => self.plan_create_node_table(t),
147            BoundStatement::BoundCreateRelTable(t) => self.plan_create_rel_table(t),
148            BoundStatement::BoundDropTable(t) => self.plan_drop_table(t),
149            BoundStatement::BoundAlterTable(a) => self.plan_alter_table(a),
150            BoundStatement::BoundCreateIndex(idx) => self.plan_create_index(idx),
151            BoundStatement::BoundDropIndex(idx) => self.plan_drop_index(idx),
152            BoundStatement::BoundCreateVectorIndex(idx) => self.plan_create_vector_index(idx),
153            BoundStatement::BoundCreateSequence(s) => self.plan_create_sequence(s),
154            BoundStatement::BoundDropSequence(s) => self.plan_drop_sequence(s),
155            BoundStatement::BoundCreateDml(c) => self.plan_create_dml(c),
156            BoundStatement::BoundExportDatabase(e) => self.plan_export_database(e),
157            BoundStatement::BoundImportDatabase(i) => self.plan_import_database(i),
158            BoundStatement::BoundCreateFtsIndex(c) => {
159                Ok(vec![LogicalOperator::CreateFtsIndex(LogicalCreateFtsIndex {
160                    index_name: c.index_name,
161                    table_name: c.table_name,
162                    column_name: c.column_name,
163                    tokenizer: c.tokenizer,
164                    if_not_exists: c.if_not_exists,
165                    cardinality: 1,
166                })])
167            }
168            BoundStatement::BoundStandaloneCall(c) => self.plan_standalone_call(c),
169            _ => Ok(Vec::new()),
170        }
171    }
172
173    /// Plan an EXPLAIN statement.
174    ///
175    /// Plans the inner statement first, then wraps the result in a
176    /// LogicalExplain operator that will serialize the plan tree to text.
177    fn plan_explain(&self, e: BoundExplain) -> Result<Vec<LogicalOperator>, PlannerError> {
178        let inner_plan = self.plan(*e.inner)?;
179        // Take the last operator of the inner plan as the tree root to explain
180        let inner_op = if inner_plan.is_empty() {
181            return Err("Cannot EXPLAIN an empty plan".into());
182        } else if inner_plan.len() == 1 {
183            inner_plan.into_iter().next().unwrap()
184        } else {
185            // Wrap multi-operator pipeline in a projection root
186            LogicalOperator::Projection(LogicalProjection {
187                expressions: Vec::new(),
188                children: inner_plan,
189                cardinality: 0,
190            })
191        };
192
193        Ok(vec![LogicalOperator::Explain(LogicalExplain {
194            inner: Box::new(inner_op),
195            explain_type: e.explain_type,
196            cardinality: 1,
197        })])
198    }
199
200    fn plan_copy_from(&self, c: BoundCopyFrom) -> Result<Vec<LogicalOperator>, PlannerError> {
201        Ok(vec![LogicalOperator::CopyFrom(LogicalCopyFrom {
202            table_name: c.table_name,
203            table_id: c.table_id,
204            file_path: c.file_path,
205            options: c.options,
206            cardinality: 0,
207        })])
208    }
209
210    fn plan_standalone_call(&self, c: BoundStandaloneCall) -> Result<Vec<LogicalOperator>, PlannerError> {
211        if c.function_name.eq_ignore_ascii_case("vector_similarity_scan") {
212            return self.plan_vector_similarity_scan_call(c.args);
213        }
214        Ok(vec![LogicalOperator::StandaloneCall(LogicalStandaloneCall {
215            function_name: c.function_name,
216            args: c.args,
217            cardinality: 1,
218        })])
219    }
220
221    /// Plan `CALL vector_similarity_scan(table, column, query_vector, top_k)`
222    /// into a `LogicalOperator::VectorSimilarityScan` so the scan flows through
223    /// the normal operator pipeline (mapper → `PhysicalVectorSimilarityScan`)
224    /// instead of a bespoke standalone-call handler build.
225    fn plan_vector_similarity_scan_call(&self, args: Vec<Expression>) -> Result<Vec<LogicalOperator>, PlannerError> {
226        if args.len() < 4 {
227            return Err(
228                "vector_similarity_scan requires 4 arguments: table_name, column_name, query_vector, top_k".into(),
229            );
230        }
231        let table_name = match eval_standalone_expr(&args[0]) {
232            Some(Value::String(s)) => s,
233            _ => return Err("First argument to vector_similarity_scan must be a table name string".into()),
234        };
235        let column_name = match eval_standalone_expr(&args[1]) {
236            Some(Value::String(s)) => s,
237            _ => return Err("Second argument to vector_similarity_scan must be a column name string".into()),
238        };
239        let query_vector = match eval_standalone_expr(&args[2]) {
240            Some(v) => extract_f64_list(&v).map_err(|_| {
241                format!("Third argument to vector_similarity_scan must be a list of numbers, got: {v:?}")
242            })?,
243            None => return Err("Third argument to vector_similarity_scan must be a list of numbers".into()),
244        };
245        let top_k = match eval_standalone_expr(&args[3]) {
246            Some(Value::Int64(k)) if k > 0 => k as u64,
247            _ => return Err("Fourth argument to vector_similarity_scan must be a positive integer".into()),
248        };
249        Ok(vec![LogicalOperator::VectorSimilarityScan(
250            LogicalVectorSimilarityScan {
251                table_name,
252                column_name,
253                query_vector,
254                top_k,
255                alias: None,
256                cardinality: top_k,
257            },
258        )])
259    }
260
261    // ==================== DDL Planning ====================
262
263    fn plan_create_node_table(&self, t: BoundCreateNodeTable) -> Result<Vec<LogicalOperator>, PlannerError> {
264        Ok(vec![LogicalOperator::CreateNodeTable(LogicalCreateNodeTable {
265            name: t.name,
266            columns: t.columns,
267            primary_key: t.primary_key,
268            if_not_exists: t.if_not_exists,
269            cardinality: 1,
270        })])
271    }
272
273    fn plan_create_rel_table(&self, t: BoundCreateRelTable) -> Result<Vec<LogicalOperator>, PlannerError> {
274        Ok(vec![LogicalOperator::CreateRelTable(LogicalCreateRelTable {
275            name: t.name,
276            from: t.from,
277            to: t.to,
278            columns: t.columns,
279            if_not_exists: t.if_not_exists,
280            cardinality: 1,
281        })])
282    }
283
284    fn plan_drop_table(&self, t: BoundDropTable) -> Result<Vec<LogicalOperator>, PlannerError> {
285        Ok(vec![LogicalOperator::DropTable(LogicalDropTable {
286            name: t.name,
287            cardinality: 1,
288        })])
289    }
290
291    fn plan_alter_table(&self, a: BoundAlterTable) -> Result<Vec<LogicalOperator>, PlannerError> {
292        Ok(vec![LogicalOperator::AlterTable(LogicalAlterTable {
293            table_name: a.table_name,
294            action: a.action,
295            cardinality: 1,
296        })])
297    }
298
299    fn plan_create_index(&self, idx: BoundCreateIndex) -> Result<Vec<LogicalOperator>, PlannerError> {
300        Ok(vec![LogicalOperator::CreateIndex(LogicalCreateIndex {
301            index_type: idx.index_type,
302            index_name: idx.index_name,
303            table_name: idx.table_name,
304            column_name: idx.column_name,
305            cardinality: 1,
306        })])
307    }
308
309    fn plan_drop_index(&self, idx: BoundDropIndex) -> Result<Vec<LogicalOperator>, PlannerError> {
310        Ok(vec![LogicalOperator::DropIndex(LogicalDropIndex {
311            index_name: idx.index_name,
312            table_name: idx.table_name,
313            cardinality: 1,
314        })])
315    }
316
317    fn plan_create_vector_index(&self, idx: BoundCreateVectorIndex) -> Result<Vec<LogicalOperator>, PlannerError> {
318        Ok(vec![LogicalOperator::CreateVectorIndex(LogicalCreateVectorIndex {
319            index_name: idx.index_name,
320            table_name: idx.table_name,
321            column_name: idx.column_name,
322            metric: idx.metric,
323            dimensions: idx.dimensions,
324            cardinality: 1,
325        })])
326    }
327
328    fn plan_create_sequence(&self, s: BoundCreateSequence) -> Result<Vec<LogicalOperator>, PlannerError> {
329        Ok(vec![LogicalOperator::CreateSequence(LogicalCreateSequence {
330            name: s.name,
331            if_not_exists: s.if_not_exists,
332            or_replace: s.or_replace,
333            start_with: s.start_with,
334            increment: s.increment,
335            min_value: s.min_value,
336            max_value: s.max_value,
337            cycle: s.cycle,
338            cardinality: 1,
339        })])
340    }
341
342    fn plan_drop_sequence(&self, s: BoundDropSequence) -> Result<Vec<LogicalOperator>, PlannerError> {
343        Ok(vec![LogicalOperator::DropSequence(LogicalDropSequence {
344            name: s.name,
345            if_exists: s.if_exists,
346            cardinality: 1,
347        })])
348    }
349
350    fn plan_create_dml(&self, c: BoundCreateDml) -> Result<Vec<LogicalOperator>, PlannerError> {
351        let first_node = c.patterns.iter().find_map(|p| p.node.clone());
352        let (table_name, table_id, properties) = match first_node {
353            Some(n) => (n.table_name, n.table_id, n.properties),
354            None => (String::new(), 0, Vec::new()),
355        };
356        Ok(vec![LogicalOperator::CreateDml(LogicalCreateDml {
357            table_name,
358            table_id,
359            properties,
360            cardinality: 1,
361        })])
362    }
363
364    fn plan_export_database(&self, e: BoundExportDatabase) -> Result<Vec<LogicalOperator>, PlannerError> {
365        Ok(vec![LogicalOperator::ExportDatabase(LogicalExportDatabase {
366            file_path: e.file_path,
367            file_type: e.file_type,
368            schema_only: e.schema_only,
369            options: e.options,
370            cardinality: 1,
371        })])
372    }
373
374    fn plan_import_database(&self, i: BoundImportDatabase) -> Result<Vec<LogicalOperator>, PlannerError> {
375        Ok(vec![LogicalOperator::ImportDatabase(LogicalImportDatabase {
376            file_path: i.file_path,
377            query: i.query,
378            index_query: i.index_query,
379            cardinality: 1,
380        })])
381    }
382
383    /// Plan a MERGE statement.
384    ///
385    /// Converts the bound merge into a `LogicalMerge` operator with
386    /// ON MATCH SET and ON CREATE SET as `LogicalSet` sub-operators.
387    fn plan_merge(&self, m: BoundMerge) -> Result<Vec<LogicalOperator>, PlannerError> {
388        Ok(self.build_merge_operators(&m))
389    }
390
391    /// Build the update operator(s) for a MERGE clause. Edge MERGE
392    /// (`MERGE (a)-[r:R]->(b)`, P53.20) emits a `LogicalMergeRel`; otherwise a
393    /// node `LogicalMerge` is produced.
394    fn build_merge_operators(&self, m: &BoundMerge) -> Vec<LogicalOperator> {
395        let on_match: Vec<LogicalSet> = m
396            .on_match
397            .iter()
398            .map(|item| LogicalSet {
399                table_name: item.table_name.clone(),
400                table_id: item.table_id,
401                is_node: item.is_node,
402                items: vec![SetItem {
403                    column_name: item.column_name.clone(),
404                    column_idx: item.column_idx,
405                    value: item.value.clone(),
406                }],
407                emit_count: false,
408                cardinality: 0,
409            })
410            .collect();
411
412        let on_create: Vec<LogicalSet> = m
413            .on_create
414            .iter()
415            .map(|item| LogicalSet {
416                table_name: item.table_name.clone(),
417                table_id: item.table_id,
418                is_node: item.is_node,
419                items: vec![SetItem {
420                    column_name: item.column_name.clone(),
421                    column_idx: item.column_idx,
422                    value: item.value.clone(),
423                }],
424                emit_count: false,
425                cardinality: 0,
426            })
427            .collect();
428
429        if let Some(edge) = m.patterns.iter().find_map(|p| p.edge.clone()) {
430            return vec![LogicalOperator::MergeRel(LogicalMergeRel {
431                rel_table_name: edge.table_name,
432                rel_table_id: edge.table_id,
433                edge_var: edge.variable.unwrap_or_default(),
434                src_node_var: edge.src_var,
435                dst_node_var: edge.dst_var,
436                properties: edge.properties,
437                on_match,
438                on_create,
439                cardinality: 0,
440            })];
441        }
442
443        vec![LogicalOperator::Merge(LogicalMerge {
444            table_name: m.table_name.clone(),
445            table_id: m.table_id,
446            properties: m.properties.clone(),
447            on_match,
448            on_create,
449            cardinality: 0,
450        })]
451    }
452
453    /// Plan a UNION or UNION ALL statement.
454    ///
455    /// Plans left and right sub-queries independently, then wraps each
456    /// side's pipeline (potentially multiple operators) into a synthetic
457    /// projection root so that `LogicalUnion` can store them as tree children.
458    fn plan_union(&self, u: BoundUnion) -> Result<Vec<LogicalOperator>, PlannerError> {
459        let left_plan = self.plan_query(*u.left)?;
460        let right_plan = self.plan_query(*u.right)?;
461
462        let left_op = if left_plan.len() == 1 {
463            left_plan.into_iter().next().unwrap()
464        } else {
465            // Wrap multi-operator pipeline in a projection root
466            LogicalOperator::Projection(LogicalProjection {
467                expressions: Vec::new(),
468                children: left_plan,
469                cardinality: 0,
470            })
471        };
472
473        let right_op = if right_plan.len() == 1 {
474            right_plan.into_iter().next().unwrap()
475        } else {
476            LogicalOperator::Projection(LogicalProjection {
477                expressions: Vec::new(),
478                children: right_plan,
479                cardinality: 0,
480            })
481        };
482
483        Ok(vec![LogicalOperator::Union(LogicalUnion {
484            left: Box::new(left_op),
485            right: Box::new(right_op),
486            all: u.all,
487            cardinality: 0,
488        })])
489    }
490
491    pub fn plan_query(&self, query: BoundQuery) -> Result<Vec<LogicalOperator>, PlannerError> {
492        let mut scan_ops: Vec<LogicalOperator> = Vec::new();
493        let mut filter_expr: Option<BoundExpression> = None;
494        let mut projection: Option<LogicalProjection> = None;
495        let mut distinct = false;
496        let mut delete_exprs: Vec<LogicalOperator> = Vec::new();
497        let mut extend_ops: Vec<LogicalOperator> = Vec::new();
498        // ORDER BY / LIMIT / SKIP from RETURN clause
499        let mut order_by: Option<Vec<BoundOrderByItem>> = None;
500        let mut limit: Option<u64> = None;
501        let mut skip: Option<u64> = None;
502        // Flag to skip destination node pattern consumed by RecursiveExtend or Extend
503        let mut skip_next_node = false;
504        // Node variables already bound to a scan in the current pipeline.
505        // Prevents duplicate scans for a shared variable across comma patterns
506        // (P48.1): `MATCH (a)-[:r1]->(b), (b)-[:r3]->(c)` must not scan `b` twice.
507        let mut available_vars: HashSet<String> = HashSet::new();
508        // A terminal SET (no trailing RETURN) reports "N rows updated" via
509        // column 0; with a trailing RETURN the SET must pass through the matched
510        // rows (0 rows when nothing matched) instead of a phantom count row
511        // (P53.39 / kairos P59.1).
512        let has_return = query.clauses.iter().any(|c| matches!(c, BoundClause::BoundReturn(_)));
513
514        for clause in query.clauses {
515            match clause {
516                BoundClause::BoundMatch(mut m) => {
517                    let mut fts_to_assign = m.fts_query.as_ref().map(|fq| LogicalFtsScan {
518                        index_name: fq.index_name.clone(),
519                        query_string: fq.query_string.clone(),
520                        table_name: fq.table_name.clone(),
521                        column_name: fq.column_name.clone(),
522                        cardinality: 0,
523                    });
524                    let patterns: Vec<BoundPattern> = std::mem::take(&mut m.patterns);
525
526                    // WCOJ pass: `MATCH (a)-[:r1]->(b), (a)-[:r2]->(c)` becomes a single
527                    // Intersect that probes the shared node once across all build sides.
528                    // Triangle queries additionally get closure-edge Extend+Filter ops.
529                    if let Some((mut wcoj_op, wcoj_trailing)) = build_wcoj_intersect(&patterns) {
530                        // The intersect builds its shared-node scans with no FTS query —
531                        // attach the clause to every matching scan so the document-id
532                        // filter runs at the shared leaf (P108.1).
533                        if let Some(ref fts) = fts_to_assign {
534                            attach_fts_to_matching_scans(&mut wcoj_op, fts);
535                        }
536                        scan_ops.push(wcoj_op);
537                        extend_ops.extend(wcoj_trailing);
538                    } else {
539                        let mut patterns_iter = patterns.into_iter().peekable();
540                        let _skip_next_node = false;
541                        while let Some(pattern) = patterns_iter.next() {
542                            // If the previous pattern consumed this dest node, skip the node scan
543                            let skip_current_node_scan = skip_next_node;
544                            skip_next_node = false;
545
546                            // Check if this pattern has a var-length edge → create RecursiveExtend
547                            if let Some(ref edge) = pattern.edge {
548                                let is_var_length = edge.lower_bound.is_some() || edge.upper_bound.is_some();
549                                if is_var_length {
550                                    let lb = edge.lower_bound.unwrap_or(0);
551                                    let ub = edge.upper_bound.unwrap_or(1);
552                                    let direction = match edge.direction {
553                                        akar_parser::ast::EdgeDirection::LeftToRight => {
554                                            akar_common::enums::ExtendDirection::Fwd
555                                        }
556                                        akar_parser::ast::EdgeDirection::RightToLeft => {
557                                            akar_common::enums::ExtendDirection::Bwd
558                                        }
559                                        akar_parser::ast::EdgeDirection::Both => {
560                                            akar_common::enums::ExtendDirection::Both
561                                        }
562                                    };
563
564                                    // Scan source node
565                                    let node_var = &pattern.node_variable;
566                                    let var_len_src_bound =
567                                        node_var.as_ref().is_some_and(|v| available_vars.contains(v));
568                                    if !skip_current_node_scan && !var_len_src_bound {
569                                        if let Some(label) = pattern.node_label {
570                                            let fts_for_scan = take_fts_if_table(&mut fts_to_assign, &label);
571                                            scan_ops.push(LogicalOperator::ScanNode(LogicalScanNode {
572                                                table_name: label,
573                                                table_id: pattern.node_table_id.unwrap_or(0),
574                                                alias: node_var.clone(),
575                                                columns: Vec::new(),
576                                                cardinality: 0,
577                                                fts_query: fts_for_scan,
578                                                predicate: None,
579                                            }));
580                                            if let Some(v) = node_var {
581                                                available_vars.insert(v.clone());
582                                            }
583                                        }
584                                    }
585
586                                    // Create RecursiveExtend (consumes destination node pattern)
587                                    let rel_table_ids = edge.rel_table_id.map_or(vec![], |id| vec![id]);
588                                    let rel_labels = edge.label.as_ref().map_or(vec![], |l| vec![l.clone()]);
589                                    let target_var = patterns_iter
590                                        .peek()
591                                        .and_then(|p| p.node_variable.clone())
592                                        .unwrap_or_default();
593                                    scan_ops.push(LogicalOperator::RecursiveExtend(LogicalRecursiveExtend {
594                                        source_var: node_var.clone().unwrap_or_default(),
595                                        source_table_id: pattern.node_table_id.unwrap_or(0),
596                                        edge_var: edge.variable.clone(),
597                                        target_var: target_var.clone(),
598                                        rel_table_ids,
599                                        rel_labels,
600                                        lower_bound: lb,
601                                        upper_bound: ub,
602                                        direction,
603                                        semantic: akar_common::enums::PathSemantic::Walk,
604                                        weight_property: None,
605                                        cost_output_name: None,
606                                        cardinality: 0,
607                                    }));
608                                    // The destination node is produced by the RecursiveExtend (P48.1).
609                                    if !target_var.is_empty() {
610                                        available_vars.insert(target_var);
611                                    }
612                                    // Skip the destination node pattern scan
613                                    skip_next_node = true;
614                                    continue;
615                                }
616
617                                // Regular (non-var-length) edge → create Extend
618                                // Scan the source node (clone what we need before pattern is moved)
619                                let src_node_var = pattern.node_variable.clone();
620                                let src_already_bound =
621                                    src_node_var.as_ref().is_some_and(|v| available_vars.contains(v));
622                                if !skip_current_node_scan && !src_already_bound {
623                                    if let Some(label) = &pattern.node_label {
624                                        let fts_for_scan = take_fts_if_table(&mut fts_to_assign, label);
625                                        scan_ops.push(LogicalOperator::ScanNode(LogicalScanNode {
626                                            table_name: label.clone(),
627                                            table_id: pattern.node_table_id.unwrap_or(0),
628                                            alias: src_node_var.clone(),
629                                            columns: Vec::new(),
630                                            cardinality: 0,
631                                            fts_query: fts_for_scan,
632                                            predicate: None,
633                                        }));
634                                        if let Some(v) = &src_node_var {
635                                            available_vars.insert(v.clone());
636                                        }
637                                    }
638                                }
639
640                                // Create Extend which replaces ScanRel + destination ScanNode
641                                if let Some(rel_label) = &edge.label {
642                                    let dest_pattern = patterns_iter.peek();
643                                    let dst_var =
644                                        dest_pattern.and_then(|p| p.node_variable.clone()).unwrap_or_default();
645                                    let dst_table_name =
646                                        dest_pattern.and_then(|p| p.node_label.clone()).unwrap_or_default();
647                                    let dst_table_id = dest_pattern.and_then(|p| p.node_table_id).unwrap_or(0);
648                                    // FTS routing to an Extend destination (P108.4): the indexed
649                                    // table may only be reachable as the destination of this hop —
650                                    // `(a:Author)-[:AUTHORED_BY]->(d:Document) USING FTS INDEX ...`
651                                    // has no scan of `Document`. Attach the clause so the
652                                    // document-id filter runs on the rows this hop produces; a
653                                    // pre-existing attached set is never consumed twice.
654                                    let fts_for_extend = take_fts_if_table(&mut fts_to_assign, &dst_table_name);
655
656                                    extend_ops.push(LogicalOperator::Extend(LogicalExtend {
657                                        rel_table_name: rel_label.clone(),
658                                        rel_table_id: edge.rel_table_id.unwrap_or(0),
659                                        rel_var: edge.variable.clone().unwrap_or_default(),
660                                        bound_node_var: src_node_var.unwrap_or_default(),
661                                        direction: edge.direction.clone(),
662                                        dst_node_var: dst_var.clone(),
663                                        dst_table_name,
664                                        dst_table_id,
665                                        fts_query: fts_for_extend,
666                                        cardinality: 0,
667                                    }));
668                                    // The destination node is produced by this Extend — it becomes
669                                    // available to later patterns without a fresh scan (P48.1).
670                                    if !dst_var.is_empty() {
671                                        available_vars.insert(dst_var);
672                                    }
673
674                                    // Skip the destination node pattern scan
675                                    skip_next_node = true;
676                                    continue;
677                                }
678                            }
679
680                            // Regular (non-var-length) pattern without edge: Scan node only
681                            if !skip_current_node_scan {
682                                if let Some(label) = pattern.node_label {
683                                    let var = pattern.node_variable.clone();
684                                    if !var.as_ref().is_some_and(|v| available_vars.contains(v)) {
685                                        let fts_for_scan = take_fts_if_table(&mut fts_to_assign, &label);
686                                        scan_ops.push(LogicalOperator::ScanNode(LogicalScanNode {
687                                            table_name: label,
688                                            table_id: pattern.node_table_id.unwrap_or(0),
689                                            alias: var.clone(),
690                                            columns: Vec::new(),
691                                            cardinality: 0,
692                                            fts_query: fts_for_scan,
693                                            predicate: None,
694                                        }));
695                                        if let Some(v) = var {
696                                            available_vars.insert(v);
697                                        }
698                                    }
699                                }
700                            }
701                        }
702                    } // end else (regular pattern loop)
703                }
704
705                BoundClause::BoundWhere(w) => {
706                    if !delete_exprs.is_empty() {
707                        // WHERE appearing after a WITH/UNWIND/update clause filters the
708                        // in-flight pipeline, not the scan. Push it as a Filter on top of
709                        // the clauses accumulated so far (P53.14).
710                        delete_exprs.push(LogicalOperator::Filter(LogicalFilter {
711                            expression: w.expression.expression,
712                            children: Vec::new(),
713                            cardinality: 0,
714                        }));
715                    } else {
716                        // Combine with any prior WHERE clause — see `combine_filter_expr`
717                        // (P48.17 BUG-A).
718                        filter_expr = Some(combine_filter_expr(filter_expr.take(), w.expression));
719                    }
720                }
721                BoundClause::BoundReturn(r) => {
722                    distinct = r.distinct;
723                    order_by = r.order_by;
724                    limit = r.limit;
725                    skip = r.skip;
726                    projection = Some(LogicalProjection {
727                        expressions: r.expressions,
728                        children: Vec::new(),
729                        cardinality: 0,
730                    });
731                }
732                BoundClause::BoundWith(r) => {
733                    delete_exprs.push(LogicalOperator::Projection(LogicalProjection {
734                        expressions: r.expressions,
735                        children: Vec::new(),
736                        cardinality: 0,
737                    }));
738                }
739                BoundClause::BoundOptionalMatch(om) => {
740                    // P53.25: Detect the bound-edge-probe shape — an optional
741                    // pattern that is a single edge between two node variables
742                    // already bound by the required side:
743                    //   `OPTIONAL MATCH (a)-[existing:Connected]-(b)`
744                    // where `a`/`b` come from the mandatory MATCH and carry no
745                    // label/property constraints here. Such patterns are executed
746                    // by probing the relationship adjacency per input row
747                    // (OptionalExtend) rather than scanning + outer-joining, so
748                    // the compound `a._id`/`b._id` endpoints survive.
749                    let probe_edge_shape = om.patterns.len() == 2
750                        && om.patterns[0].edge.as_ref().is_some_and(|e| {
751                            e.variable.is_some()
752                                && e.label.as_ref().is_some_and(|l| !l.is_empty())
753                                && e.properties.is_empty()
754                                && e.lower_bound.is_none()
755                                && e.upper_bound.is_none()
756                        })
757                        && om.patterns[1].edge.is_none();
758
759                    let both_endpoints_bound = probe_edge_shape
760                        && om.patterns.iter().all(|p| {
761                            p.node_label.is_none()
762                                && p.properties.is_empty()
763                                && p.node_variable.as_ref().is_some_and(|v| available_vars.contains(v))
764                        });
765
766                    // P53.40 (kairos Finding #22): anonymous labeled destination —
767                    // `(m)-[r:Connected]-(:Memory)` with `m` bound and no dst
768                    // variable downstream. The rel table's endpoint schema
769                    // guarantees the destination node type, so probing all edges
770                    // incident on the source is equivalent to the full pattern;
771                    // each match fans out one row per edge (zero → one NULL row).
772                    // The previous fallback routed this through the generic
773                    // OptionalMatch merge, which cross-producted when no column
774                    // name is shared and duplicated every left row.
775                    let anon_dst_fanout = probe_edge_shape
776                        && om.patterns[0].node_label.is_none()
777                        && om.patterns[0].properties.is_empty()
778                        && om.patterns[0]
779                            .node_variable
780                            .as_ref()
781                            .is_some_and(|v| available_vars.contains(v))
782                        && om.patterns[1].node_variable.is_none()
783                        && om.patterns[1].properties.is_empty()
784                        && om.patterns[1].node_label.is_some();
785
786                    let edge_probe = both_endpoints_bound || anon_dst_fanout;
787
788                    // Build the current required-side pipeline (left child)
789                    let mut left_pipeline: Vec<LogicalOperator> = Vec::new();
790                    if !scan_ops.is_empty() {
791                        if scan_ops.len() == 1 {
792                            left_pipeline.push(scan_ops.into_iter().next().unwrap());
793                        } else {
794                            let join_plan = build_join_tree(scan_ops, filter_expr.as_ref());
795                            let flattened = flatten_join_plan(&join_plan);
796                            left_pipeline.extend(flattened);
797                        }
798                    }
799                    if let Some(expr) = filter_expr.take() {
800                        left_pipeline.push(LogicalOperator::Filter(LogicalFilter {
801                            expression: expr.expression,
802                            children: Vec::new(),
803                            cardinality: 0,
804                        }));
805                    }
806                    if let Some(proj) = projection.take() {
807                        left_pipeline.push(LogicalOperator::Projection(proj));
808                    }
809                    if edge_probe {
810                        let edge = om.patterns[0].edge.as_ref().unwrap();
811                        let src_var = om.patterns[0].node_variable.clone().unwrap();
812                        // Anonymous destination (P53.40 fan-out): empty string
813                        // tells the physical operator to emit every incident
814                        // edge instead of probing a specific pair.
815                        let dst_var = om.patterns[1].node_variable.clone().unwrap_or_default();
816                        delete_exprs.push(LogicalOperator::OptionalExtend(LogicalOptionalExtend {
817                            children: left_pipeline,
818                            rel_table_name: edge.label.clone().unwrap(),
819                            rel_table_id: edge.rel_table_id.unwrap_or(0),
820                            rel_var: edge.variable.clone().unwrap(),
821                            src_node_var: src_var,
822                            dst_node_var: dst_var,
823                            direction: edge.direction.clone(),
824                            cardinality: 0,
825                        }));
826                    } else {
827                        let left_op = if left_pipeline.len() == 1 {
828                            left_pipeline.into_iter().next().unwrap()
829                        } else if left_pipeline.is_empty() {
830                            // Empty left side — use a dummy scan
831                            LogicalOperator::ScanNode(LogicalScanNode {
832                                table_name: String::new(),
833                                table_id: 0,
834                                alias: None,
835                                columns: Vec::new(),
836                                cardinality: 0,
837                                fts_query: None,
838                                predicate: None,
839                            })
840                        } else {
841                            LogicalOperator::Projection(LogicalProjection {
842                                expressions: Vec::new(),
843                                children: left_pipeline,
844                                cardinality: 0,
845                            })
846                        };
847
848                        // Build the optional-side pipeline (right child)
849                        let mut right_ops: Vec<LogicalOperator> = Vec::new();
850                        for pattern in &om.patterns {
851                            if let Some(label) = &pattern.node_label {
852                                right_ops.push(LogicalOperator::ScanNode(LogicalScanNode {
853                                    table_name: label.clone(),
854                                    table_id: pattern.node_table_id.unwrap_or(0),
855                                    alias: pattern.node_variable.clone(),
856                                    columns: Vec::new(),
857                                    cardinality: 0,
858                                    fts_query: None,
859                                    predicate: None,
860                                }));
861                            }
862                            if let Some(edge) = &pattern.edge
863                                && let Some(rel_label) = &edge.label
864                            {
865                                right_ops.push(LogicalOperator::ScanRel(LogicalScanRel {
866                                    table_name: rel_label.clone(),
867                                    table_id: edge.rel_table_id.unwrap_or(0),
868                                    direction: edge.direction.clone(),
869                                    cardinality: 0,
870                                }));
871                            }
872                        }
873                        // Apply inline node/edge property predicates to the optional
874                        // side, mirroring the implicit WHERE the binder generates
875                        // for MATCH. Without this, `OPTIONAL MATCH (m:T {id: 999})`
876                        // scans every T row (predicate silently dropped) and the
877                        // left-outer merge degenerates into a cross product.
878                        let mut inline_exprs: Vec<Expression> = Vec::new();
879                        for pattern in &om.patterns {
880                            if let Some(node_var) = &pattern.node_variable {
881                                for (key, val_expr) in &pattern.properties {
882                                    inline_exprs.push(Expression::BinaryOp(
883                                        akar_parser::ast::BinaryOp::Equal,
884                                        Box::new(Expression::PropertyAccess(
885                                            Box::new(Expression::Variable(node_var.clone())),
886                                            key.clone(),
887                                        )),
888                                        Box::new(val_expr.clone()),
889                                    ));
890                                }
891                            }
892                            if let Some(edge) = &pattern.edge
893                                && let Some(edge_var) = &edge.variable
894                            {
895                                for (key, val_expr) in &edge.properties {
896                                    inline_exprs.push(Expression::BinaryOp(
897                                        akar_parser::ast::BinaryOp::Equal,
898                                        Box::new(Expression::PropertyAccess(
899                                            Box::new(Expression::Variable(edge_var.clone())),
900                                            key.clone(),
901                                        )),
902                                        Box::new(val_expr.clone()),
903                                    ));
904                                }
905                            }
906                        }
907                        if !inline_exprs.is_empty() {
908                            let combined = inline_exprs.into_iter().reduce(|acc, e| {
909                                Expression::BinaryOp(akar_parser::ast::BinaryOp::And, Box::new(acc), Box::new(e))
910                            });
911                            right_ops.push(LogicalOperator::Filter(LogicalFilter {
912                                expression: combined.unwrap(),
913                                children: Vec::new(),
914                                cardinality: 0,
915                            }));
916                        }
917                        let right_op = if right_ops.len() == 1 {
918                            right_ops.into_iter().next().unwrap()
919                        } else if right_ops.is_empty() {
920                            LogicalOperator::ScanNode(LogicalScanNode {
921                                table_name: String::new(),
922                                table_id: 0,
923                                alias: None,
924                                columns: Vec::new(),
925                                cardinality: 0,
926                                fts_query: None,
927                                predicate: None,
928                            })
929                        } else {
930                            LogicalOperator::Projection(LogicalProjection {
931                                expressions: Vec::new(),
932                                children: right_ops,
933                                cardinality: 0,
934                            })
935                        };
936
937                        // Create the OptionalMatch tree node.
938                        // The left side is the entire pipeline built so far (scans + filter + projection).
939                        // The right side is the optional pattern scans.
940                        // Push to delete_exprs so it gets appended at the end of the pipeline.
941                        delete_exprs.push(LogicalOperator::OptionalMatch(LogicalOptionalMatch {
942                            left: Box::new(left_op),
943                            right: Box::new(right_op),
944                            cardinality: 0,
945                        }));
946                    }
947                    // Reset pipeline state — subsequent clauses (DELETE, SET, etc.) build fresh
948                    scan_ops = Vec::new();
949                    filter_expr = None;
950                    projection = None;
951                    distinct = false;
952                }
953                BoundClause::BoundDelete(d) => {
954                    for item in &d.items {
955                        delete_exprs.push(LogicalOperator::Delete(LogicalDelete {
956                            table_name: item.table_name.clone(),
957                            table_id: item.table_id,
958                            primary_key_column: item.primary_key_column.clone(),
959                            is_node: item.is_node,
960                            detach: d.detach,
961                            cardinality: 0,
962                        }));
963                    }
964                }
965                BoundClause::BoundUnwind(u) => {
966                    // Treat UNWIND as a scan operator: it produces the row
967                    // variable that later MATCH clauses join against. Routing it
968                    // through the scan list (instead of delete_exprs) lets the
969                    // join tree combine UNWIND rows with node scans, and keeps
970                    // implicit WHERE predicates from MATCH inline properties in
971                    // the scan filter instead of the top-level pipeline (P53.25).
972                    scan_ops.push(LogicalOperator::Unwind(LogicalUnwind {
973                        expression: u.expression.clone(),
974                        variable: u.variable.clone(),
975                        cardinality: 0,
976                    }));
977                    if !u.variable.is_empty() {
978                        available_vars.insert(u.variable.clone());
979                    }
980                }
981                BoundClause::BoundSet(s) => {
982                    // Merge every item of a single SET clause into one operator
983                    // (grouped by target table). All items then evaluate against
984                    // the same pre-update snapshot (P53.17) — chaining one
985                    // operator per item would feed items 2+ the previous item's
986                    // count chunk, losing the scan rows (`SET a=123.0, b=b+1`
987                    // left `b` at its old value).
988                    let mut groups: Vec<(String, u64, bool, Vec<SetItem>)> = Vec::new();
989                    for item in &s.items {
990                        let key = (item.table_name.clone(), item.table_id, item.is_node);
991                        match groups
992                            .iter_mut()
993                            .find(|(n, id, n2, _)| *n == key.0 && *id == key.1 && *n2 == key.2)
994                        {
995                            Some((_, _, _, items)) => items.push(SetItem {
996                                column_name: item.column_name.clone(),
997                                column_idx: item.column_idx,
998                                value: item.value.clone(),
999                            }),
1000                            None => groups.push((
1001                                key.0,
1002                                key.1,
1003                                key.2,
1004                                vec![SetItem {
1005                                    column_name: item.column_name.clone(),
1006                                    column_idx: item.column_idx,
1007                                    value: item.value.clone(),
1008                                }],
1009                            )),
1010                        }
1011                    }
1012                    for (table_name, table_id, is_node, items) in groups {
1013                        delete_exprs.push(LogicalOperator::Set(LogicalSet {
1014                            table_name,
1015                            table_id,
1016                            is_node,
1017                            items,
1018                            emit_count: !has_return,
1019                            cardinality: 0,
1020                        }));
1021                    }
1022                }
1023                BoundClause::BoundCreate(c) => {
1024                    let mut patterns_iter = c.patterns.into_iter().peekable();
1025                    while let Some(pattern) = patterns_iter.next() {
1026                        let node_var = pattern.node_variable.clone().unwrap_or_default();
1027
1028                        if c.new_variables.iter().any(|v| v.name == node_var) {
1029                            delete_exprs.push(LogicalOperator::CreateNode(LogicalCreateNode {
1030                                table_name: pattern.node_label.clone().unwrap_or_default(),
1031                                table_id: pattern.node_table_id.unwrap_or(0),
1032                                out_var_name: node_var.clone(),
1033                                properties: pattern.properties.clone(),
1034                                cardinality: 0,
1035                            }));
1036                        }
1037
1038                        if let Some(edge) = pattern.edge {
1039                            let dest_var = patterns_iter
1040                                .peek()
1041                                .and_then(|p| p.node_variable.clone())
1042                                .unwrap_or_default();
1043                            let (src_node_name, dst_node_name) = match edge.direction {
1044                                akar_parser::ast::EdgeDirection::RightToLeft => (dest_var, node_var.clone()),
1045                                _ => (node_var.clone(), dest_var),
1046                            };
1047
1048                            delete_exprs.push(LogicalOperator::CreateRel(LogicalCreateRel {
1049                                table_name: edge.label.clone().unwrap_or_default(),
1050                                table_id: edge.rel_table_id.unwrap_or(0),
1051                                src_node_name,
1052                                dst_node_name,
1053                                out_var_name: edge.variable.clone().unwrap_or_default(),
1054                                properties: edge.properties.clone(),
1055                                cardinality: 0,
1056                            }));
1057                        }
1058                    }
1059                }
1060                BoundClause::BoundForeach(f) => {
1061                    // Plan FOREACH sub-statements
1062                    let mut sub_plans = Vec::new();
1063                    for sub_stmt in &f.sub_statements {
1064                        let plan = self.plan(sub_stmt.clone())?;
1065                        sub_plans.push(plan);
1066                    }
1067                    delete_exprs.push(LogicalOperator::Foreach(LogicalForeach {
1068                        variable: f.variable.clone(),
1069                        expression: f.expression.clone(),
1070                        sub_plans,
1071                        cardinality: 0,
1072                    }));
1073                }
1074                BoundClause::BoundMerge(m) => {
1075                    delete_exprs.extend(self.build_merge_operators(&m));
1076                }
1077            }
1078        }
1079
1080        // Collect delete/set clauses (added after the main pipeline)
1081        let delete_ops: Vec<LogicalOperator> = std::mem::take(&mut delete_exprs);
1082
1083        // Build operator pipeline bottom-up
1084        let mut result: Vec<LogicalOperator> = Vec::new();
1085
1086        if scan_ops.is_empty() {
1087            // All scans live inside delete_ops children (e.g. an OPTIONAL
1088            // MATCH pipeline resets `scan_ops`). The RETURN tail must still be
1089            // emitted — this fast path previously dropped ORDER BY and LIMIT
1090            // entirely (P53.40).
1091            result.extend(delete_ops);
1092            append_return_tail(&mut result, projection, order_by, distinct, limit, skip);
1093            return Ok(result);
1094        }
1095
1096        if scan_ops.len() == 1 {
1097            // Single scan — no join needed
1098            result.push(scan_ops.into_iter().next().unwrap());
1099        } else {
1100            // Multiple scans — build join tree with greedy ordering
1101            let join_plan = build_join_tree(scan_ops, filter_expr.as_ref());
1102            let flattened = flatten_join_plan(&join_plan);
1103            result.extend(flattened);
1104        }
1105
1106        // Append extend operators (replace ScanRel in pipeline)
1107        result.extend(std::mem::take(&mut extend_ops));
1108
1109        // Apply filter on top of scans/joins/extends
1110        if let Some(expr) = filter_expr {
1111            result.push(LogicalOperator::Filter(LogicalFilter {
1112                expression: expr.expression,
1113                children: Vec::new(),
1114                cardinality: 0,
1115            }));
1116        }
1117
1118        // Append update/delete/set/create/unwind/with-projection operators.
1119        // These run before the RETURN projection so that updates are visible to
1120        // the returned expressions (P53.14): `MATCH ... SET ... RETURN ...`.
1121        result.extend(delete_ops);
1122
1123        // Project as topmost, then ORDER BY / LIMIT (shared tail, P53.40)
1124        append_return_tail(&mut result, projection, order_by, distinct, limit, skip);
1125
1126        Ok(result)
1127    }
1128}
1129
1130/// Append the RETURN projection tail shared by BOTH plan-assembly paths:
1131/// the optional below-projection sort (P53.37), the projection itself, the
1132/// DISTINCT dedup aggregate, the above-projection sort, and LIMIT/SKIP.
1133///
1134/// Extracted because the `scan_ops.is_empty()` fast path (e.g. after an
1135/// OPTIONAL MATCH resets the scan state) previously returned early with only
1136/// `[delete_ops..., Projection]` — silently dropping ORDER BY and LIMIT for
1137/// every such query (P53.40, kairos Finding #22).
1138fn append_return_tail(
1139    result: &mut Vec<LogicalOperator>,
1140    projection: Option<LogicalProjection>,
1141    mut order_by: Option<Vec<BoundOrderByItem>>,
1142    distinct: bool,
1143    limit: Option<u64>,
1144    skip: Option<u64>,
1145) {
1146    if let Some(proj) = projection {
1147        let group_by = if distinct {
1148            Some(
1149                proj.expressions
1150                    .iter()
1151                    .map(|be| be.expression.clone())
1152                    .collect::<Vec<Expression>>(),
1153            )
1154        } else {
1155            None
1156        };
1157
1158        // When ORDER BY references a column the projection does not output
1159        // (e.g. `RETURN m.id, m.label ORDER BY m.access_count`), the sort key
1160        // cannot be evaluated against the projected (pruned) chunk. Push the
1161        // sort below the projection so it runs against the full pre-projection
1162        // columns (P53.37). It stays on top when every key is covered by the
1163        // projection output (alias or identical expression), or when DISTINCT
1164        // deduplicates above (sorting before dedup would lose the order).
1165        let order_by_below_projection = match &order_by {
1166            Some(items) => {
1167                group_by.is_none()
1168                    && !items
1169                        .iter()
1170                        .all(|item| projection_covers_sort_key(&proj.expressions, &item.expression.expression))
1171            }
1172            None => false,
1173        };
1174
1175        if order_by_below_projection {
1176            if let Some(items) = order_by.take() {
1177                let sort_keys: Vec<(Expression, bool)> = items
1178                    .iter()
1179                    .map(|item| (item.expression.expression.clone(), item.ascending))
1180                    .collect();
1181                result.push(LogicalOperator::OrderBy(LogicalOrderBy {
1182                    sort_keys,
1183                    children: Vec::new(),
1184                    cardinality: 0,
1185                }));
1186            }
1187        }
1188
1189        result.push(LogicalOperator::Projection(proj));
1190        // DISTINCT is implemented as a hash aggregate with group-by keys and no aggregate functions
1191        if let Some(gb) = group_by {
1192            result.push(LogicalOperator::Aggregate(LogicalAggregate {
1193                group_by: gb,
1194                aggregates: Vec::new(),
1195                children: Vec::new(),
1196                cardinality: 0,
1197            }));
1198        }
1199    }
1200
1201    // Insert ORDER BY operator if present
1202    if let Some(items) = order_by {
1203        let sort_keys: Vec<(Expression, bool)> = items
1204            .iter()
1205            .map(|item| (item.expression.expression.clone(), item.ascending))
1206            .collect();
1207        result.push(LogicalOperator::OrderBy(LogicalOrderBy {
1208            sort_keys,
1209            children: Vec::new(),
1210            cardinality: 0,
1211        }));
1212    }
1213
1214    // Insert LIMIT/SKIP operator if present
1215    if limit.is_some() || skip.is_some() {
1216        result.push(LogicalOperator::Limit(LogicalLimit {
1217            limit: limit.unwrap_or(u64::MAX),
1218            offset: skip.unwrap_or(0),
1219            children: Vec::new(),
1220            cardinality: 0,
1221        }));
1222    }
1223}
1224
1225impl Default for QueryPlanner {
1226    fn default() -> Self {
1227        Self::new()
1228    }
1229}
1230
1231#[cfg(test)]
1232mod tests {
1233    use super::*;
1234    use akar_binder::Binder;
1235    use akar_catalog::{Catalog, CatalogColumn};
1236    use akar_common::types::LogicalTypeID;
1237    use akar_parser::parse;
1238    use std::sync::Arc;
1239
1240    fn setup_binder() -> Binder {
1241        let mut catalog = Catalog::new();
1242        catalog.create_node_table(
1243            "Person".into(),
1244            vec![
1245                CatalogColumn {
1246                    compression: akar_common::enums::CompressionType::Uncompressed,
1247                    name: "name".into(),
1248                    logical_type: LogicalTypeID::String,
1249                    is_primary_key: true,
1250                    default_value: None,
1251                },
1252                CatalogColumn {
1253                    compression: akar_common::enums::CompressionType::Uncompressed,
1254                    name: "age".into(),
1255                    logical_type: LogicalTypeID::Int64,
1256                    is_primary_key: false,
1257                    default_value: None,
1258                },
1259            ],
1260        );
1261        catalog.create_rel_table(
1262            "Knows".into(),
1263            0,
1264            0,
1265            vec![CatalogColumn {
1266                compression: akar_common::enums::CompressionType::Uncompressed,
1267                name: "since".into(),
1268                logical_type: LogicalTypeID::Int64,
1269                is_primary_key: false,
1270                default_value: None,
1271            }],
1272        );
1273        Binder::new(Arc::new(std::sync::Mutex::new(catalog)))
1274    }
1275
1276    #[test]
1277    fn test_plan_match_return() {
1278        let binder = setup_binder();
1279        let sql = "MATCH (a:Person) RETURN a.name";
1280        let stmt = parse(sql).unwrap();
1281        let bound = binder.bind(stmt).unwrap();
1282        let planner = QueryPlanner::new();
1283        let plan = planner.plan(bound).unwrap();
1284        assert!(!plan.is_empty());
1285
1286        // Should have ScanNode + Projection
1287        let scan_count = plan
1288            .iter()
1289            .filter(|op| matches!(op, LogicalOperator::ScanNode(_)))
1290            .count();
1291        let proj_count = plan
1292            .iter()
1293            .filter(|op| matches!(op, LogicalOperator::Projection(_)))
1294            .count();
1295        assert_eq!(scan_count, 1);
1296        assert_eq!(proj_count, 1);
1297    }
1298
1299    #[test]
1300    fn test_plan_optional_match_keeps_order_by_and_limit() {
1301        // P53.40 / kairos Finding #22: the `scan_ops.is_empty()` assembly fast
1302        // path (hit whenever OPTIONAL MATCH resets the scan state) previously
1303        // returned early and dropped ORDER BY and LIMIT entirely.
1304        let binder = setup_binder();
1305        let sql = "MATCH (a:Person) OPTIONAL MATCH (a)-[r:Knows]-(:Person) \
1306                   RETURN a.name AS id, COUNT(r) AS c ORDER BY id LIMIT 5";
1307        let stmt = parse(sql).unwrap();
1308        let bound = binder.bind(stmt).unwrap();
1309        let planner = QueryPlanner::new();
1310        let plan = planner.plan(bound).unwrap();
1311        assert!(
1312            plan.iter().any(|op| matches!(op, LogicalOperator::OrderBy(_))),
1313            "planner must emit OrderBy above the RETURN projection, got: {plan:?}"
1314        );
1315        assert!(
1316            plan.iter().any(|op| matches!(op, LogicalOperator::Limit(_))),
1317            "planner must emit Limit above the RETURN projection, got: {plan:?}"
1318        );
1319    }
1320
1321    #[test]
1322    fn test_plan_return_only_projection() {
1323        let binder = setup_binder();
1324        let sql = "RETURN 1";
1325        let stmt = parse(sql).unwrap();
1326        let bound = binder.bind(stmt).unwrap();
1327        let planner = QueryPlanner::new();
1328        let plan = planner.plan(bound).unwrap();
1329
1330        assert_eq!(plan.len(), 1);
1331        assert!(matches!(plan[0], LogicalOperator::Projection(_)));
1332    }
1333
1334    #[test]
1335    fn test_plan_unwind_then_projection_without_scan() {
1336        let binder = setup_binder();
1337        let sql = "UNWIND [1, 2, 3] AS x RETURN x";
1338        let stmt = parse(sql).unwrap();
1339        let bound = binder.bind(stmt).unwrap();
1340        let planner = QueryPlanner::new();
1341        let plan = planner.plan(bound).unwrap();
1342
1343        assert_eq!(plan.len(), 2);
1344        assert!(matches!(plan[0], LogicalOperator::Unwind(_)));
1345        assert!(matches!(plan[1], LogicalOperator::Projection(_)));
1346    }
1347
1348    #[test]
1349    fn test_plan_match_where_return() {
1350        let binder = setup_binder();
1351        let sql = "MATCH (a:Person) WHERE a.age > 25 RETURN a.name";
1352        let stmt = parse(sql).unwrap();
1353        let bound = binder.bind(stmt).unwrap();
1354        let planner = QueryPlanner::new();
1355        let plan = planner.plan(bound).unwrap();
1356        assert!(!plan.is_empty());
1357
1358        let scan_count = plan
1359            .iter()
1360            .filter(|op| matches!(op, LogicalOperator::ScanNode(_)))
1361            .count();
1362        let filter_count = plan
1363            .iter()
1364            .filter(|op| matches!(op, LogicalOperator::Filter(_)))
1365            .count();
1366        let proj_count = plan
1367            .iter()
1368            .filter(|op| matches!(op, LogicalOperator::Projection(_)))
1369            .count();
1370        assert_eq!(scan_count, 1);
1371        assert_eq!(filter_count, 1);
1372        assert_eq!(proj_count, 1);
1373    }
1374
1375    #[test]
1376    fn test_plan_ddl_empty() {
1377        let binder = Binder::new(Arc::new(std::sync::Mutex::new(Catalog::new())));
1378        let sql = "CREATE NODE TABLE City(name STRING, PRIMARY KEY (name))";
1379        let stmt = parse(sql).unwrap();
1380        let bound = binder.bind(stmt).unwrap();
1381        let planner = QueryPlanner::new();
1382        let plan = planner.plan(bound).unwrap();
1383        assert!(!plan.is_empty()); // DDL now produces a logical plan
1384        match &plan[0] {
1385            LogicalOperator::CreateNodeTable(ct) => {
1386                assert_eq!(ct.name, "City");
1387            }
1388            _ => panic!("Expected CreateNodeTable"),
1389        }
1390    }
1391
1392    #[test]
1393    fn test_plan_scan_node_fields() {
1394        let binder = setup_binder();
1395        let sql = "MATCH (a:Person) RETURN a";
1396        let stmt = parse(sql).unwrap();
1397        let bound = binder.bind(stmt).unwrap();
1398        let planner = QueryPlanner::new();
1399        let plan = planner.plan(bound).unwrap();
1400
1401        match &plan[0] {
1402            LogicalOperator::ScanNode(s) => {
1403                assert_eq!(s.table_name, "Person");
1404                assert_eq!(s.alias, Some("a".into()));
1405            }
1406            _ => panic!("Expected ScanNode"),
1407        }
1408    }
1409
1410    #[test]
1411    fn test_plan_rel_pattern() {
1412        let binder = setup_binder();
1413        let sql = "MATCH (a:Person)-[r:Knows]->(b:Person) RETURN a, b";
1414        let stmt = parse(sql).unwrap();
1415        let bound = binder.bind(stmt).unwrap();
1416        let planner = QueryPlanner::new();
1417        let plan = planner.plan(bound).unwrap();
1418        // Should have an Extend operator replacing the relationship scan + join
1419        assert!(plan.iter().any(|op| matches!(op, LogicalOperator::Extend(_))));
1420        // Should also have ScanNode for the source node
1421        assert!(plan.iter().any(|op| matches!(op, LogicalOperator::ScanNode(_))));
1422        // Should NOT have ScanRel (replaced by Extend)
1423        assert!(!plan.iter().any(|op| matches!(op, LogicalOperator::ScanRel(_))));
1424    }
1425
1426    #[test]
1427    fn test_plan_order() {
1428        let binder = setup_binder();
1429        let sql = "MATCH (a:Person) WHERE a.age > 25 RETURN a.name";
1430        let stmt = parse(sql).unwrap();
1431        let bound = binder.bind(stmt).unwrap();
1432        let planner = QueryPlanner::new();
1433        let plan = planner.plan(bound).unwrap();
1434
1435        // Order should be: scans → filter → projection
1436        let positions: Vec<&str> = plan
1437            .iter()
1438            .map(|op| match op {
1439                LogicalOperator::ScanNode(_) => "scan",
1440                LogicalOperator::Filter(_) => "filter",
1441                LogicalOperator::Projection(_) => "proj",
1442                _ => "other",
1443            })
1444            .collect();
1445
1446        let scan_pos = positions.iter().position(|&p| p == "scan").unwrap();
1447        let filter_pos = positions.iter().position(|&p| p == "filter").unwrap();
1448        let proj_pos = positions.iter().position(|&p| p == "proj").unwrap();
1449
1450        assert!(scan_pos < filter_pos);
1451        assert!(filter_pos < proj_pos);
1452    }
1453}