Skip to main content

akar_planner/
join_order.rs

1//! Join order enumeration — builds optimal join trees from query patterns.
2//!
3//! Uses a simple greedy heuristic: join the smallest tables first.
4
5use crate::logical_operator::*;
6use akar_binder::bound_statement::{BoundExpression, BoundPattern};
7use akar_parser::ast::{BinaryOp, EdgeDirection, Expression};
8use std::collections::HashSet;
9
10/// A join plan tree representing how to combine scan operators.
11#[derive(Debug, Clone)]
12#[allow(clippy::large_enum_variant)]
13pub enum JoinPlan {
14    /// A single leaf operator (ScanNode or ScanRel).
15    Leaf(LogicalOperator),
16    /// Hash join of two sub-plans with join keys.
17    HashJoin {
18        keys: Vec<Expression>,
19        left: Box<JoinPlan>,
20        right: Box<JoinPlan>,
21    },
22    /// Cross product of two sub-plans.
23    CrossProduct { left: Box<JoinPlan>, right: Box<JoinPlan> },
24}
25
26/// Build a join tree from a list of scan operators and an optional filter expression.
27///
28/// Uses a greedy heuristic:
29/// 1. Start with the first scan as the base
30/// 2. For each remaining scan, find any join conditions from the filter
31/// 3. If join conditions exist → HashJoin, otherwise → CrossProduct
32///
33/// P48.3: before joining, single-variable WHERE conjuncts (e.g. `b.id >= 0`)
34/// are pushed into the predicate of the scan whose alias matches that variable.
35/// This lets `PhysicalScan` prune rows before the cross product is materialized.
36pub fn build_join_tree(scans: Vec<LogicalOperator>, filter_expr: Option<&BoundExpression>) -> JoinPlan {
37    if scans.is_empty() {
38        return JoinPlan::Leaf(LogicalOperator::ScanNode(LogicalScanNode {
39            table_name: "empty".into(),
40            table_id: 0,
41            alias: None,
42            columns: Vec::new(),
43            cardinality: 0,
44            fts_query: None,
45            predicate: None,
46        }));
47    }
48
49    let mut scans = scans;
50    if scans.len() == 1 {
51        // P48.3: also push single-variable predicates for the single-scan case
52        // so the scan can prune rows before any Extend/Projection downstream.
53        if let Some(filter) = filter_expr {
54            push_single_var_predicates(&mut scans, &filter.expression);
55        }
56        return JoinPlan::Leaf(scans.into_iter().next().unwrap());
57    }
58
59    if let Some(filter) = filter_expr {
60        push_single_var_predicates(&mut scans, &filter.expression);
61    }
62
63    // Extract table aliases from scans for join condition matching
64
65    // Extract potential join conditions from the filter
66    let join_conditions = filter_expr.map_or(Vec::new(), |f| extract_join_conditions(&f.expression));
67
68    // Greedy join ordering: start with the first scan, then join each subsequent one
69    let mut scans_iter = scans.into_iter();
70    let first = scans_iter.next().unwrap();
71    let mut result = JoinPlan::Leaf(first);
72
73    for scan in scans_iter {
74        let alias = get_scan_alias(&scan);
75
76        // Try to find a join condition matching this scan's alias
77        let matching_conditions: Vec<Expression> = join_conditions
78            .iter()
79            .filter(|(left_alias, right_alias, _expr)| left_alias == &alias || right_alias == &alias)
80            .map(|(_, _, expr)| expr.clone())
81            .collect();
82
83        if matching_conditions.is_empty() {
84            // No join condition found — use cross product
85            result = JoinPlan::CrossProduct {
86                left: Box::new(result),
87                right: Box::new(JoinPlan::Leaf(scan)),
88            };
89        } else {
90            // Use the first matching join condition
91            result = JoinPlan::HashJoin {
92                keys: matching_conditions,
93                left: Box::new(result),
94                right: Box::new(JoinPlan::Leaf(scan)),
95            };
96        }
97    }
98
99    result
100}
101
102/// Split an expression into a list of top-level AND conjuncts.
103fn split_and_conjuncts(expr: &Expression) -> Vec<Expression> {
104    match expr {
105        Expression::BinaryOp(BinaryOp::And, left, right) => {
106            let mut out = split_and_conjuncts(left);
107            out.extend(split_and_conjuncts(right));
108            out
109        }
110        other => vec![other.clone()],
111    }
112}
113
114/// Collect the set of variable names referenced by an expression.
115fn collect_variables(expr: &Expression, out: &mut HashSet<String>) {
116    match expr {
117        Expression::Variable(v) => {
118            out.insert(v.clone());
119        }
120        Expression::PropertyAccess(base, _) => collect_variables(base, out),
121        Expression::FunctionCall(_, args) => {
122            for a in args {
123                collect_variables(a, out);
124            }
125        }
126        Expression::BinaryOp(_, left, right) => {
127            collect_variables(left, out);
128            collect_variables(right, out);
129        }
130        Expression::UnaryOp(_, inner) => collect_variables(inner, out),
131        Expression::List(items) => {
132            for item in items {
133                collect_variables(item, out);
134            }
135        }
136        Expression::Map(items) => {
137            for (_, e) in items {
138                collect_variables(e, out);
139            }
140        }
141        Expression::Case(c) => {
142            if let Some(s) = &c.subject {
143                collect_variables(s, out);
144            }
145            for alt in &c.alternatives {
146                collect_variables(&alt.when, out);
147                collect_variables(&alt.then, out);
148            }
149            if let Some(e) = &c.else_expr {
150                collect_variables(e, out);
151            }
152        }
153        Expression::ListPredicate { list, predicate, .. } => {
154            collect_variables(list, out);
155            collect_variables(predicate, out);
156        }
157        Expression::Lambda { body, .. } => collect_variables(body, out),
158        _ => {}
159    }
160}
161
162/// Push single-variable WHERE conjuncts into the matching scan's predicate.
163///
164/// A conjunct (e.g. `b.id >= 0`) that references exactly one variable is folded
165/// into the `ScanNode` whose `alias` matches that variable, allowing the scan to
166/// prune rows before the join. The conjunct is AND-combined with any existing
167/// scan predicate. Conjuncts referencing multiple variables (join conditions)
168/// or variables with no backing scan are left untouched for the top-level Filter.
169fn push_single_var_predicates(scans: &mut [LogicalOperator], filter_expr: &Expression) {
170    let conjuncts = split_and_conjuncts(filter_expr);
171
172    for scan in scans.iter_mut() {
173        let LogicalOperator::ScanNode(node) = scan else {
174            continue;
175        };
176        let Some(alias) = node.alias.clone() else { continue };
177
178        let mut pushable: Vec<Expression> = Vec::new();
179        for c in &conjuncts {
180            let mut vars = HashSet::new();
181            collect_variables(c, &mut vars);
182            if vars.len() == 1 && vars.contains(&alias) {
183                pushable.push(c.clone());
184            }
185        }
186        if pushable.is_empty() {
187            continue;
188        }
189
190        let mut combined = pushable.remove(0);
191        for c in pushable {
192            combined = Expression::BinaryOp(BinaryOp::And, Box::new(combined), Box::new(c));
193        }
194        node.predicate = Some(match node.predicate.take() {
195            Some(existing) => Expression::BinaryOp(BinaryOp::And, Box::new(existing), Box::new(combined)),
196            None => combined,
197        });
198    }
199}
200
201/// Detect a WCOJ star (`MATCH (a)-[:r1]->(b), (a)-[:r2]->(c), ...`) and build an
202/// `Intersect` operator whose build sides enumerate each edge pattern from the
203/// shared node.
204///
205/// Ports the `planWCOJoin` semantics: the shared node is probed once and its
206/// key value is intersected across N build hash tables (one per pattern), instead
207/// of cross-joining duplicated scans of the shared node.
208///
209/// Triangle/cycle patterns (`MATCH (a)-[:r1]->(b), (a)-[:r2]->(c), (b)-[:r3]->(c)`)
210/// additionally produce closure-edge `Extend` + `Filter` operators (returned as
211/// the trailing ops) that verify the edges connecting the star's leaves.
212///
213/// Returns `None` when the patterns do not form a clean star (chains, var-length
214/// edges, backward edges, or any leftover patterns) — callers fall back to the
215/// regular join ordering.
216pub fn build_wcoj_intersect(patterns: &[BoundPattern]) -> Option<(LogicalOperator, Vec<LogicalOperator>)> {
217    let rel_indices: Vec<usize> = patterns
218        .iter()
219        .enumerate()
220        .filter(|(_, p)| p.edge.is_some())
221        .map(|(i, _)| i)
222        .collect();
223    if rel_indices.len() < 2 {
224        return None;
225    }
226
227    // Categorize each rel pattern: source var, and whether it is a simple LTR
228    // edge with a valid destination pattern.
229    let mut src_of: Vec<Option<String>> = Vec::with_capacity(rel_indices.len());
230    let mut star_dst_vars: std::collections::HashSet<String> = std::collections::HashSet::new();
231    for &i in &rel_indices {
232        let pattern = &patterns[i];
233        let edge = pattern.edge.as_ref()?;
234        let is_simple_edge = edge.lower_bound.is_none() && edge.upper_bound.is_none();
235        let is_fwd = matches!(edge.direction, EdgeDirection::LeftToRight);
236        if !is_simple_edge || !is_fwd {
237            return None;
238        }
239        let dst = patterns.get(i + 1)?;
240        if dst.node_variable.as_ref()? == pattern.node_variable.as_ref()? {
241            // Self-loop — not a supported edge.
242            return None;
243        }
244        src_of.push(pattern.node_variable.clone());
245    }
246
247    // Find the shared source variable with the most star edges (≥ 2).
248    let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
249    for s in src_of.iter().flatten() {
250        *counts.entry(s).or_insert(0) += 1;
251    }
252    let shared_var = counts
253        .iter()
254        .filter(|(_, c)| **c >= 2)
255        .max_by_key(|(_, c)| **c)?
256        .0
257        .to_string();
258
259    // Split rel patterns into star (shared source) vs closure (source is a star leaf).
260    let mut star_idxs: Vec<usize> = Vec::new();
261    let mut closure_idxs: Vec<usize> = Vec::new();
262    for (k, &i) in rel_indices.iter().enumerate() {
263        if src_of[k].as_deref() == Some(shared_var.as_str()) {
264            star_idxs.push(i);
265        } else {
266            closure_idxs.push(i);
267        }
268    }
269
270    let mut consumed: Vec<usize> = Vec::with_capacity(patterns.len());
271    let mut build_sides: Vec<Vec<LogicalOperator>> = Vec::with_capacity(star_idxs.len());
272    let mut shared_label: Option<&str> = None;
273    let mut shared_table_id = 0u64;
274
275    for &i in &star_idxs {
276        let pattern = &patterns[i];
277        let edge = pattern.edge.as_ref()?;
278        let node_var = pattern.node_variable.as_ref()?;
279        let node_label = pattern.node_label.as_ref()?;
280        let node_table_id = pattern.node_table_id?;
281        let rel_label = edge.label.as_ref()?;
282        let rel_table_id = edge.rel_table_id?;
283
284        if shared_label.is_none() {
285            shared_label = Some(node_label);
286            shared_table_id = node_table_id;
287        } else if shared_label != Some(node_label) || shared_table_id != node_table_id {
288            return None;
289        }
290
291        let dst = patterns.get(i + 1)?;
292        let dst_var = dst.node_variable.as_ref()?.clone();
293        let dst_label = dst.node_label.as_ref()?.clone();
294        let dst_table_id = dst.node_table_id?;
295        star_dst_vars.insert(dst_var.clone());
296        consumed.push(i);
297        consumed.push(i + 1);
298
299        let pipeline: Vec<LogicalOperator> = vec![
300            LogicalOperator::ScanNode(LogicalScanNode {
301                table_name: node_label.clone(),
302                table_id: node_table_id,
303                alias: Some(node_var.clone()),
304                columns: Vec::new(),
305                cardinality: 0,
306                fts_query: None,
307                predicate: None,
308            }),
309            LogicalOperator::Extend(LogicalExtend {
310                rel_table_name: rel_label.clone(),
311                rel_table_id,
312                rel_var: edge.variable.clone().unwrap_or_default(),
313                bound_node_var: node_var.clone(),
314                direction: edge.direction.clone(),
315                dst_node_var: dst_var,
316                dst_table_name: dst_label,
317                dst_table_id,
318                cardinality: 0,
319            }),
320        ];
321        build_sides.push(pipeline);
322    }
323
324    // Closure edges: each must connect two distinct star leaves and be the only
325    // remaining rel patterns (no leftover chains).
326    let mut trailing: Vec<LogicalOperator> = Vec::new();
327    for &i in &closure_idxs {
328        let pattern = &patterns[i];
329        let edge = pattern.edge.as_ref()?;
330        let src_var = pattern.node_variable.as_ref()?;
331        let dst = patterns.get(i + 1)?;
332        let dst_var = dst.node_variable.as_ref()?;
333        if !star_dst_vars.contains(src_var) || !star_dst_vars.contains(dst_var) {
334            return None;
335        }
336        consumed.push(i);
337        consumed.push(i + 1);
338
339        let rel_label = edge.label.as_ref()?;
340        let rel_table_id = edge.rel_table_id?;
341        let dst_label = dst.node_label.as_ref()?;
342        let dst_table_id = dst.node_table_id?;
343
344        // Rename the closure destination so the filter can compare it against
345        // the star's copy of the same variable.
346        let closure_var = format!("__wcoj_closure_{rel_label}_{dst_var}");
347        trailing.push(LogicalOperator::Extend(LogicalExtend {
348            rel_table_name: rel_label.clone(),
349            rel_table_id,
350            rel_var: edge.variable.clone().unwrap_or_default(),
351            bound_node_var: src_var.clone(),
352            direction: edge.direction.clone(),
353            dst_node_var: closure_var.clone(),
354            dst_table_name: dst_label.clone(),
355            dst_table_id,
356            cardinality: 0,
357        }));
358        trailing.push(LogicalOperator::Filter(LogicalFilter {
359            expression: Expression::BinaryOp(
360                BinaryOp::Equal,
361                Box::new(Expression::PropertyAccess(
362                    Box::new(Expression::Variable(closure_var)),
363                    "id".into(),
364                )),
365                Box::new(Expression::PropertyAccess(
366                    Box::new(Expression::Variable(dst_var.clone())),
367                    "id".into(),
368                )),
369            ),
370            children: Vec::new(),
371            cardinality: 0,
372        }));
373    }
374
375    // Only enumerate when the whole MATCH is the star + its closures — no
376    // leftover patterns for the regular loop to process.
377    consumed.sort_unstable();
378    consumed.dedup();
379    if consumed.len() != patterns.len() {
380        return None;
381    }
382
383    let shared_label = shared_label?;
384
385    // Probe side: scan the shared node once.
386    let probe = LogicalOperator::ScanNode(LogicalScanNode {
387        table_name: shared_label.to_string(),
388        table_id: shared_table_id,
389        alias: Some(shared_var.clone()),
390        columns: Vec::new(),
391        cardinality: 0,
392        fts_query: None,
393        predicate: None,
394    });
395
396    let wrap_side = |pipeline: Vec<LogicalOperator>| {
397        LogicalOperator::Projection(LogicalProjection {
398            expressions: Vec::new(),
399            children: pipeline,
400            cardinality: 0,
401        })
402    };
403
404    // Build side: union of per-pattern pipelines (ScanNode(shared) → Extend).
405    let mut sides = build_sides.into_iter();
406    let mut left = wrap_side(sides.next()?);
407    for side in sides {
408        left = LogicalOperator::Union(LogicalUnion {
409            left: Box::new(left),
410            right: Box::new(wrap_side(side)),
411            all: true,
412            cardinality: 0,
413        });
414    }
415
416    let key_exprs: Vec<Expression> = star_idxs
417        .iter()
418        .map(|_| Expression::Variable(shared_var.clone()))
419        .collect();
420
421    let root = LogicalOperator::Intersect(LogicalIntersect {
422        num_build_sides: star_idxs.len() as u32,
423        build_key_exprs: key_exprs,
424        left: Box::new(left),
425        right: Box::new(probe),
426        cardinality: 0,
427    });
428
429    Some((root, trailing))
430}
431
432/// Extract table alias from a logical operator.
433fn get_scan_alias(op: &LogicalOperator) -> Option<String> {
434    match op {
435        LogicalOperator::ScanNode(s) => s.alias.clone(),
436        LogicalOperator::ScanRel(s) => {
437            // Rel scans don't have aliases; use table_name
438            Some(s.table_name.clone())
439        }
440        _ => None,
441    }
442}
443
444/// Extract potential join conditions from a filter expression.
445///
446/// Looks for equality comparisons between variables (e.g., `a.id = b.id`).
447/// Returns tuples of (left_alias, right_alias, condition_expression).
448fn extract_join_conditions(expr: &Expression) -> Vec<(Option<String>, Option<String>, Expression)> {
449    let mut conditions = Vec::new();
450    collect_equality_conditions(expr, &mut conditions);
451    conditions
452}
453
454/// Recursively collect equality conditions that reference different variables.
455fn collect_equality_conditions(expr: &Expression, conditions: &mut Vec<(Option<String>, Option<String>, Expression)>) {
456    match expr {
457        Expression::BinaryOp(BinaryOp::Equal, left, right) => {
458            let left_var = extract_variable_alias(left);
459            let right_var = extract_variable_alias(right);
460            if let (Some(lv), Some(rv)) = (&left_var, &right_var)
461                && lv != rv
462            {
463                // This is a potential join condition between two different variables
464                conditions.push((left_var, right_var, expr.clone()));
465            }
466            // Fall through to check children
467        }
468        Expression::BinaryOp(BinaryOp::And, left, right) => {
469            collect_equality_conditions(left, conditions);
470            collect_equality_conditions(right, conditions);
471        }
472        _ => {}
473    }
474}
475
476/// Extract the variable alias from an expression.
477/// e.g., `a.id` → `"a"`, `b` → `"b"`
478fn extract_variable_alias(expr: &Expression) -> Option<String> {
479    match expr {
480        Expression::Variable(name) => Some(name.clone()),
481        Expression::PropertyAccess(obj, _) => extract_variable_alias(obj),
482        _ => None,
483    }
484}
485
486/// Convert a JoinPlan tree to a flat Vec<LogicalOperator> for the processor.
487///
488/// The flattening order ensures scans appear before joins, and
489/// joins appear before filters/projections.
490pub fn flatten_join_plan(plan: &JoinPlan) -> Vec<LogicalOperator> {
491    let mut ops = Vec::new();
492    flatten_plan(plan, &mut ops);
493    ops
494}
495
496fn flatten_plan(plan: &JoinPlan, ops: &mut Vec<LogicalOperator>) {
497    match plan {
498        JoinPlan::Leaf(op) => {
499            ops.push(op.clone());
500        }
501        JoinPlan::HashJoin { keys, left, right } => {
502            let mut left_ops = Vec::new();
503            flatten_plan(left, &mut left_ops);
504            let mut right_ops = Vec::new();
505            flatten_plan(right, &mut right_ops);
506
507            ops.push(LogicalOperator::HashJoin(LogicalHashJoin {
508                join_keys: keys.clone(),
509                build_side: Box::new(LogicalOperator::Projection(
510                    crate::logical_operator::LogicalProjection {
511                        expressions: Vec::new(),
512                        children: left_ops,
513                        cardinality: 0,
514                    },
515                )),
516                probe_side: Box::new(LogicalOperator::Projection(
517                    crate::logical_operator::LogicalProjection {
518                        expressions: Vec::new(),
519                        children: right_ops,
520                        cardinality: 0,
521                    },
522                )),
523                cardinality: 0,
524                push_down_eligible: false,
525            }));
526        }
527        JoinPlan::CrossProduct { left, right } => {
528            let mut left_ops = Vec::new();
529            flatten_plan(left, &mut left_ops);
530            let mut right_ops = Vec::new();
531            flatten_plan(right, &mut right_ops);
532
533            ops.push(LogicalOperator::CrossProduct(LogicalCrossProduct {
534                left: Box::new(LogicalOperator::Projection(
535                    crate::logical_operator::LogicalProjection {
536                        expressions: Vec::new(),
537                        children: left_ops,
538                        cardinality: 0,
539                    },
540                )),
541                right: Box::new(LogicalOperator::Projection(
542                    crate::logical_operator::LogicalProjection {
543                        expressions: Vec::new(),
544                        children: right_ops,
545                        cardinality: 0,
546                    },
547                )),
548                cardinality: 0,
549            }));
550        }
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use akar_binder::bound_statement::BoundEdgePattern;
558
559    #[test]
560    fn test_single_scan_leaf() {
561        let scan = LogicalOperator::ScanNode(LogicalScanNode {
562            table_name: "Person".into(),
563            table_id: 0,
564            alias: Some("a".into()),
565            columns: Vec::new(),
566            cardinality: 0,
567            fts_query: None,
568            predicate: None,
569        });
570        let plan = build_join_tree(vec![scan], None);
571        match plan {
572            JoinPlan::Leaf(_) => {}
573            _ => panic!("Expected Leaf"),
574        }
575    }
576
577    #[test]
578    fn test_two_scans_cross_product() {
579        let scan1 = LogicalOperator::ScanNode(LogicalScanNode {
580            table_name: "Person".into(),
581            table_id: 0,
582            alias: Some("a".into()),
583            columns: Vec::new(),
584            cardinality: 0,
585            fts_query: None,
586            predicate: None,
587        });
588        let scan2 = LogicalOperator::ScanNode(LogicalScanNode {
589            table_name: "City".into(),
590            table_id: 1,
591            alias: Some("c".into()),
592            columns: Vec::new(),
593            cardinality: 0,
594            fts_query: None,
595            predicate: None,
596        });
597        let plan = build_join_tree(vec![scan1, scan2], None);
598        match plan {
599            JoinPlan::CrossProduct { .. } => {}
600            _ => panic!("Expected CrossProduct"),
601        }
602    }
603
604    #[test]
605    fn test_join_condition_extraction() {
606        use akar_parser::ast::Expression;
607        // a.id = b.id
608        let expr = Expression::BinaryOp(
609            BinaryOp::Equal,
610            Box::new(Expression::PropertyAccess(
611                Box::new(Expression::Variable("a".into())),
612                "id".into(),
613            )),
614            Box::new(Expression::PropertyAccess(
615                Box::new(Expression::Variable("b".into())),
616                "id".into(),
617            )),
618        );
619        let conditions = extract_join_conditions(&expr);
620        assert_eq!(conditions.len(), 1);
621        assert_eq!(conditions[0].0, Some("a".into()));
622        assert_eq!(conditions[0].1, Some("b".into()));
623    }
624
625    #[test]
626    fn test_no_join_condition() {
627        use akar_parser::ast::Constant;
628        let expr = Expression::BinaryOp(
629            BinaryOp::GreaterThan,
630            Box::new(Expression::PropertyAccess(
631                Box::new(Expression::Variable("a".into())),
632                "age".into(),
633            )),
634            Box::new(Expression::Constant(Constant::Integer(25))),
635        );
636        let conditions = extract_join_conditions(&expr);
637        assert!(conditions.is_empty());
638    }
639
640    #[test]
641    fn test_and_condition_extraction() {
642        use akar_parser::ast::Expression;
643        // a.id = b.id AND a.age > 25
644        let expr = Expression::BinaryOp(
645            BinaryOp::And,
646            Box::new(Expression::BinaryOp(
647                BinaryOp::Equal,
648                Box::new(Expression::PropertyAccess(
649                    Box::new(Expression::Variable("a".into())),
650                    "id".into(),
651                )),
652                Box::new(Expression::PropertyAccess(
653                    Box::new(Expression::Variable("b".into())),
654                    "id".into(),
655                )),
656            )),
657            Box::new(Expression::BinaryOp(
658                BinaryOp::GreaterThan,
659                Box::new(Expression::PropertyAccess(
660                    Box::new(Expression::Variable("a".into())),
661                    "age".into(),
662                )),
663                Box::new(Expression::Constant(akar_parser::ast::Constant::Integer(25))),
664            )),
665        );
666        let conditions = extract_join_conditions(&expr);
667        assert_eq!(conditions.len(), 1, "Should find 1 join condition");
668        // The age > 25 is NOT a join condition
669    }
670
671    #[test]
672    fn test_extract_variable_alias() {
673        let expr = Expression::PropertyAccess(Box::new(Expression::Variable("p".into())), "name".into());
674        assert_eq!(extract_variable_alias(&expr), Some("p".into()));
675
676        let expr = Expression::Variable("x".into());
677        assert_eq!(extract_variable_alias(&expr), Some("x".into()));
678    }
679
680    #[test]
681    fn test_flatten_join_plan() {
682        let scan = LogicalOperator::ScanNode(LogicalScanNode {
683            predicate: None,
684            table_name: "T".into(),
685            table_id: 0,
686            alias: None,
687            columns: Vec::new(),
688            cardinality: 0,
689            fts_query: None,
690        });
691        let plan = JoinPlan::Leaf(scan.clone());
692        let flat = flatten_join_plan(&plan);
693        assert_eq!(flat.len(), 1);
694    }
695
696    #[test]
697    fn test_single_var_predicate_pushdown() {
698        use akar_parser::ast::Constant;
699        // MATCH (a:Person), (b:Person) WHERE b.id >= 0 AND b.id <= 100
700        // The conjuncts referencing only `b` should be folded into scan b's
701        // predicate; scan a's predicate stays empty. The `a.id = b.id` join
702        // condition references two variables so it must NOT be pushed.
703        let scan_a = LogicalOperator::ScanNode(LogicalScanNode {
704            table_name: "Person".into(),
705            table_id: 0,
706            alias: Some("a".into()),
707            columns: Vec::new(),
708            cardinality: 0,
709            fts_query: None,
710            predicate: None,
711        });
712        let scan_b = LogicalOperator::ScanNode(LogicalScanNode {
713            table_name: "Person".into(),
714            table_id: 0,
715            alias: Some("b".into()),
716            columns: Vec::new(),
717            cardinality: 0,
718            fts_query: None,
719            predicate: None,
720        });
721        let filter = Expression::BinaryOp(
722            BinaryOp::And,
723            Box::new(Expression::BinaryOp(
724                BinaryOp::And,
725                Box::new(Expression::BinaryOp(
726                    BinaryOp::GreaterThanOrEqual,
727                    Box::new(Expression::PropertyAccess(
728                        Box::new(Expression::Variable("b".into())),
729                        "id".into(),
730                    )),
731                    Box::new(Expression::Constant(Constant::Integer(0))),
732                )),
733                Box::new(Expression::BinaryOp(
734                    BinaryOp::LessThanOrEqual,
735                    Box::new(Expression::PropertyAccess(
736                        Box::new(Expression::Variable("b".into())),
737                        "id".into(),
738                    )),
739                    Box::new(Expression::Constant(Constant::Integer(100))),
740                )),
741            )),
742            Box::new(Expression::BinaryOp(
743                BinaryOp::Equal,
744                Box::new(Expression::PropertyAccess(
745                    Box::new(Expression::Variable("a".into())),
746                    "id".into(),
747                )),
748                Box::new(Expression::PropertyAccess(
749                    Box::new(Expression::Variable("b".into())),
750                    "id".into(),
751                )),
752            )),
753        );
754
755        let mut scans = vec![scan_a, scan_b];
756        push_single_var_predicates(&mut scans, &filter);
757
758        match &scans[0] {
759            LogicalOperator::ScanNode(s) => assert!(
760                s.predicate.is_none(),
761                "scan a must not receive b-only conjuncts, got: {:?}",
762                s.predicate
763            ),
764            _ => panic!("expected ScanNode"),
765        }
766        match &scans[1] {
767            LogicalOperator::ScanNode(s) => {
768                let pred = s.predicate.as_ref().expect("scan b should have a predicate");
769                // b.id >= 0 AND b.id <= 100 — two conjuncts AND-combined.
770                match pred {
771                    Expression::BinaryOp(BinaryOp::And, _, _) => {}
772                    other => panic!("expected AND-combined predicate, got: {other:?}"),
773                }
774            }
775            _ => panic!("expected ScanNode"),
776        }
777    }
778
779    #[test]
780    fn test_flatten_cross_product() {
781        let scan1 = LogicalOperator::ScanNode(LogicalScanNode {
782            predicate: None,
783            table_name: "A".into(),
784            table_id: 0,
785            alias: None,
786            columns: Vec::new(),
787            cardinality: 0,
788            fts_query: None,
789        });
790        let scan2 = LogicalOperator::ScanNode(LogicalScanNode {
791            predicate: None,
792            table_name: "B".into(),
793            table_id: 1,
794            alias: None,
795            columns: Vec::new(),
796            cardinality: 0,
797            fts_query: None,
798        });
799        let plan = JoinPlan::CrossProduct {
800            left: Box::new(JoinPlan::Leaf(scan1)),
801            right: Box::new(JoinPlan::Leaf(scan2)),
802        };
803        let flat = flatten_join_plan(&plan);
804        assert_eq!(flat.len(), 1); // 1 cross product root
805        assert!(matches!(flat[0], LogicalOperator::CrossProduct(_)));
806    }
807
808    fn mk_pattern(var: &str, label: &str, tid: u64, rel: Option<(&str, u64)>) -> BoundPattern {
809        BoundPattern {
810            node_variable: Some(var.into()),
811            node_label: Some(label.into()),
812            node_table_id: Some(tid),
813            properties: Vec::new(),
814            edge: rel.map(|(l, id)| BoundEdgePattern {
815                variable: None,
816                label: Some(l.into()),
817                rel_table_id: Some(id),
818                direction: EdgeDirection::LeftToRight,
819                properties: Vec::new(),
820                lower_bound: None,
821                upper_bound: None,
822            }),
823        }
824    }
825
826    #[test]
827    fn test_wcoj_star_detection() {
828        // (a)-[:r1]->(b), (a)-[:r2]->(c)
829        let patterns = vec![
830            mk_pattern("a", "N", 1, Some(("r1", 10))),
831            mk_pattern("b", "N", 1, None),
832            mk_pattern("a", "N", 1, Some(("r2", 11))),
833            mk_pattern("c", "N", 1, None),
834        ];
835        let (root, trailing) = build_wcoj_intersect(&patterns).expect("expected WCOJ intersect");
836        assert!(matches!(&root, LogicalOperator::Intersect(i) if i.num_build_sides == 2));
837        assert_eq!(root.cardinality(), 0);
838        assert!(trailing.is_empty(), "no closure edges for a fan-out");
839    }
840
841    #[test]
842    fn test_wcoj_triangle_detection() {
843        // (a)-[:r1]->(b), (a)-[:r2]->(c), (b)-[:r3]->(c)
844        let patterns = vec![
845            mk_pattern("a", "N", 1, Some(("r1", 10))),
846            mk_pattern("b", "N", 1, None),
847            mk_pattern("a", "N", 1, Some(("r2", 11))),
848            mk_pattern("c", "N", 1, None),
849            mk_pattern("b", "N", 1, Some(("r3", 12))),
850            mk_pattern("c", "N", 1, None),
851        ];
852        let (root, trailing) = build_wcoj_intersect(&patterns).expect("expected triangle WCOJ");
853        assert!(matches!(&root, LogicalOperator::Intersect(i) if i.num_build_sides == 2));
854        assert_eq!(trailing.len(), 2, "closure Extend + Filter expected");
855    }
856
857    #[test]
858    fn test_wcoj_chain_falls_back() {
859        // (a)-[:r1]->(b), (b)-[:r2]->(c) — a chain, not a star
860        let patterns = vec![
861            mk_pattern("a", "N", 1, Some(("r1", 10))),
862            mk_pattern("b", "N", 1, None),
863            mk_pattern("b", "N", 1, Some(("r2", 11))),
864            mk_pattern("c", "N", 1, None),
865        ];
866        assert!(build_wcoj_intersect(&patterns).is_none());
867    }
868
869    #[test]
870    fn test_wcoj_single_edge_falls_back() {
871        // A single edge is not a WCOJ star.
872        let patterns = vec![mk_pattern("a", "N", 1, Some(("r1", 10))), mk_pattern("b", "N", 1, None)];
873        assert!(build_wcoj_intersect(&patterns).is_none());
874    }
875}