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