1use crate::logical_operator::*;
6use akar_binder::bound_statement::{BoundExpression, BoundPattern};
7use akar_parser::ast::{BinaryOp, EdgeDirection, Expression};
8use std::collections::HashSet;
9
10#[derive(Debug, Clone)]
12#[allow(clippy::large_enum_variant)]
13pub enum JoinPlan {
14 Leaf(LogicalOperator),
16 HashJoin {
18 keys: Vec<Expression>,
19 left: Box<JoinPlan>,
20 right: Box<JoinPlan>,
21 },
22 CrossProduct { left: Box<JoinPlan>, right: Box<JoinPlan> },
24}
25
26pub 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 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 let join_conditions = filter_expr.map_or(Vec::new(), |f| extract_join_conditions(&f.expression));
67
68 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 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 result = JoinPlan::CrossProduct {
86 left: Box::new(result),
87 right: Box::new(JoinPlan::Leaf(scan)),
88 };
89 } else {
90 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
102fn 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
114fn 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
162fn 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
201pub 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 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 return None;
243 }
244 src_of.push(pattern.node_variable.clone());
245 }
246
247 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 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 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 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 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 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 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
432fn get_scan_alias(op: &LogicalOperator) -> Option<String> {
434 match op {
435 LogicalOperator::ScanNode(s) => s.alias.clone(),
436 LogicalOperator::ScanRel(s) => {
437 Some(s.table_name.clone())
439 }
440 _ => None,
441 }
442}
443
444fn 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
454fn 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 conditions.push((left_var, right_var, expr.clone()));
465 }
466 }
468 Expression::BinaryOp(BinaryOp::And, left, right) => {
469 collect_equality_conditions(left, conditions);
470 collect_equality_conditions(right, conditions);
471 }
472 _ => {}
473 }
474}
475
476fn 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
486pub 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 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 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 }
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 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 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); 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 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 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 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 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}