1#[derive(Debug, Clone, PartialEq)]
11pub struct JoinPattern {
12 pub estimated_size: usize,
14 pub bound_vars: Vec<String>,
16 pub free_vars: Vec<String>,
18 pub label: String,
20}
21
22impl JoinPattern {
23 pub fn new(
25 label: impl Into<String>,
26 estimated_size: usize,
27 bound_vars: Vec<String>,
28 free_vars: Vec<String>,
29 ) -> Self {
30 Self {
31 estimated_size,
32 bound_vars,
33 free_vars,
34 label: label.into(),
35 }
36 }
37}
38
39#[derive(Debug, Clone, PartialEq)]
41pub struct JoinEdge {
42 pub left: usize,
44 pub right: usize,
46 pub shared_vars: Vec<String>,
48 pub join_selectivity: f64,
50}
51
52#[derive(Debug, Clone, PartialEq)]
54pub struct JoinPlan {
55 pub order: Vec<usize>,
57 pub estimated_cost: f64,
59}
60
61#[derive(Debug, Clone)]
65pub struct JoinOptimizer {
66 pub max_patterns: usize,
69}
70
71impl JoinOptimizer {
72 pub fn new(max_patterns: usize) -> Self {
76 Self { max_patterns }
77 }
78
79 pub fn optimize(&self, patterns: Vec<JoinPattern>) -> JoinPlan {
83 if patterns.is_empty() {
84 return JoinPlan {
85 order: vec![],
86 estimated_cost: 0.0,
87 };
88 }
89 if patterns.len() <= self.max_patterns {
90 self.dynamic_programming(&patterns)
91 } else {
92 Self::greedy(&patterns)
93 }
94 }
95
96 pub fn greedy(patterns: &[JoinPattern]) -> JoinPlan {
103 if patterns.is_empty() {
104 return JoinPlan {
105 order: vec![],
106 estimated_cost: 0.0,
107 };
108 }
109
110 let n = patterns.len();
111 let mut remaining: Vec<usize> = (0..n).collect();
112 let mut order: Vec<usize> = Vec::with_capacity(n);
113 let mut total_cost = 0.0_f64;
114
115 let (seed_pos, _) = remaining
117 .iter()
118 .enumerate()
119 .min_by_key(|&(_, &idx)| patterns[idx].estimated_size)
120 .unwrap_or((0, &0));
121 let seed_idx = remaining.remove(seed_pos);
122 order.push(seed_idx);
123 let mut accumulated = patterns[seed_idx].clone();
124 total_cost += accumulated.estimated_size as f64;
125
126 while !remaining.is_empty() {
127 let mut best_pos = 0_usize;
128 let mut best_cost = f64::MAX;
129
130 for (pos, &candidate) in remaining.iter().enumerate() {
131 let incremental = Self::cost_join(&accumulated, &patterns[candidate]);
132 if incremental < best_cost {
133 best_cost = incremental;
134 best_pos = pos;
135 }
136 }
137
138 let chosen = remaining.remove(best_pos);
139 accumulated = Self::merge_patterns(&accumulated, &patterns[chosen]);
140 total_cost += best_cost;
141 order.push(chosen);
142 }
143
144 JoinPlan {
145 order,
146 estimated_cost: total_cost,
147 }
148 }
149
150 pub fn dynamic_programming(&self, patterns: &[JoinPattern]) -> JoinPlan {
156 let n = patterns.len();
157 if n == 0 {
158 return JoinPlan {
159 order: vec![],
160 estimated_cost: 0.0,
161 };
162 }
163 if n == 1 {
164 return JoinPlan {
165 order: vec![0],
166 estimated_cost: patterns[0].estimated_size as f64,
167 };
168 }
169 if n > 20 {
171 return Self::greedy(patterns);
172 }
173
174 let num_subsets = 1usize << n;
175 let mut dp = vec![(f64::MAX, usize::MAX); num_subsets];
177 let mut acc: Vec<Option<JoinPattern>> = vec![None; num_subsets];
179
180 #[allow(clippy::needless_range_loop)]
184 for i in 0..n {
185 let mask = 1usize << i;
186 dp[mask] = (patterns[i].estimated_size as f64, i);
187 acc[mask] = Some(patterns[i].clone());
188 }
189
190 for mask in 1..num_subsets {
191 if dp[mask].0 == f64::MAX && mask.count_ones() > 1 {
192 continue;
193 }
194 #[allow(clippy::needless_range_loop)]
197 for i in 0..n {
198 if mask & (1 << i) != 0 {
199 continue; }
201 let new_mask = mask | (1 << i);
202 if let Some(ref current) = acc[mask] {
203 let incremental = Self::cost_join(current, &patterns[i]);
204 let new_cost = dp[mask].0 + incremental;
205 if new_cost < dp[new_mask].0 {
206 dp[new_mask] = (new_cost, i);
207 acc[new_mask] = Some(Self::merge_patterns(current, &patterns[i]));
208 }
209 }
210 }
211 }
212
213 let full_mask = num_subsets - 1;
215 let total_cost = dp[full_mask].0;
216 let order = Self::reconstruct_order(&dp, n, full_mask);
217
218 JoinPlan {
219 order,
220 estimated_cost: total_cost,
221 }
222 }
223
224 pub fn cost_join(left: &JoinPattern, right: &JoinPattern) -> f64 {
232 let shared = Self::shared_variables(left, right);
233 let base = (left.estimated_size as f64) * (right.estimated_size as f64).max(1.0);
234 let selectivity = if shared.is_empty() {
235 1.0
236 } else {
237 let factor = 0.1_f64.powi(shared.len() as i32);
239 factor.max(1e-6)
240 };
241 base * selectivity
242 }
243
244 pub fn build_join_graph(patterns: &[JoinPattern]) -> Vec<JoinEdge> {
249 let mut edges = Vec::new();
250 let n = patterns.len();
251 #[allow(clippy::needless_range_loop)]
254 for i in 0..n {
255 for j in (i + 1)..n {
256 let shared = Self::shared_variables(&patterns[i], &patterns[j]);
257 if !shared.is_empty() {
258 let left_size = patterns[i].estimated_size.max(1) as f64;
259 let right_size = patterns[j].estimated_size.max(1) as f64;
260 let cross = left_size * right_size;
261 let join_selectivity = (1.0 / cross * shared.len() as f64).min(1.0);
262 edges.push(JoinEdge {
263 left: i,
264 right: j,
265 shared_vars: shared,
266 join_selectivity,
267 });
268 }
269 }
270 }
271 edges
272 }
273
274 pub fn shared_variables(a: &JoinPattern, b: &JoinPattern) -> Vec<String> {
278 let mut shared: Vec<String> = a
279 .free_vars
280 .iter()
281 .chain(a.bound_vars.iter())
282 .filter(|v| b.free_vars.contains(v) || b.bound_vars.contains(v))
283 .cloned()
284 .collect();
285 shared.sort();
286 shared.dedup();
287 shared
288 }
289
290 pub fn merge_patterns(left: &JoinPattern, right: &JoinPattern) -> JoinPattern {
292 let shared = Self::shared_variables(left, right);
293 let merged_size = if shared.is_empty() {
294 left.estimated_size.saturating_mul(right.estimated_size)
295 } else {
296 let cross = (left.estimated_size as f64) * (right.estimated_size as f64);
299 let selectivity = 0.1_f64.powi(shared.len() as i32).max(1e-6);
300 (cross * selectivity).round().max(1.0) as usize
301 };
302
303 let mut bound: Vec<String> = left
304 .bound_vars
305 .iter()
306 .chain(left.free_vars.iter())
307 .chain(right.bound_vars.iter())
308 .cloned()
309 .collect();
310 bound.sort();
311 bound.dedup();
312
313 let free: Vec<String> = right
314 .free_vars
315 .iter()
316 .filter(|v| !bound.contains(v))
317 .cloned()
318 .collect();
319
320 JoinPattern {
321 estimated_size: merged_size,
322 bound_vars: bound,
323 free_vars: free,
324 label: format!("({} ⋈ {})", left.label, right.label),
325 }
326 }
327
328 fn reconstruct_order(dp: &[(f64, usize)], n: usize, mut mask: usize) -> Vec<usize> {
331 let mut order = Vec::with_capacity(n);
332 while mask != 0 {
333 let (_, last) = dp[mask];
334 if last == usize::MAX {
335 break;
336 }
337 order.push(last);
338 mask ^= 1 << last;
339 }
340 order.reverse();
341 order
342 }
343}
344
345#[cfg(test)]
348mod tests {
349 use super::*;
350
351 fn pat(label: &str, size: usize, bound: &[&str], free: &[&str]) -> JoinPattern {
352 JoinPattern::new(
353 label,
354 size,
355 bound.iter().map(|s| s.to_string()).collect(),
356 free.iter().map(|s| s.to_string()).collect(),
357 )
358 }
359
360 #[test]
363 fn test_pattern_new() {
364 let p = pat("p1", 100, &["x"], &["y"]);
365 assert_eq!(p.label, "p1");
366 assert_eq!(p.estimated_size, 100);
367 assert_eq!(p.bound_vars, vec!["x"]);
368 assert_eq!(p.free_vars, vec!["y"]);
369 }
370
371 #[test]
372 fn test_pattern_no_vars() {
373 let p = pat("empty", 1, &[], &[]);
374 assert!(p.bound_vars.is_empty());
375 assert!(p.free_vars.is_empty());
376 }
377
378 #[test]
381 fn test_shared_vars_empty() {
382 let a = pat("a", 10, &[], &["x"]);
383 let b = pat("b", 10, &[], &["y"]);
384 assert!(JoinOptimizer::shared_variables(&a, &b).is_empty());
385 }
386
387 #[test]
388 fn test_shared_vars_one() {
389 let a = pat("a", 10, &[], &["x", "y"]);
390 let b = pat("b", 10, &[], &["y", "z"]);
391 assert_eq!(JoinOptimizer::shared_variables(&a, &b), vec!["y"]);
392 }
393
394 #[test]
395 fn test_shared_vars_multiple() {
396 let a = pat("a", 10, &["x"], &["y", "z"]);
397 let b = pat("b", 10, &["x", "y"], &["w"]);
398 let shared = JoinOptimizer::shared_variables(&a, &b);
399 assert!(shared.contains(&"x".to_string()));
400 assert!(shared.contains(&"y".to_string()));
401 }
402
403 #[test]
404 fn test_shared_vars_between_bound_and_free() {
405 let a = pat("a", 5, &["s"], &["p"]);
406 let b = pat("b", 5, &[], &["s"]);
407 let shared = JoinOptimizer::shared_variables(&a, &b);
408 assert!(shared.contains(&"s".to_string()));
409 }
410
411 #[test]
414 fn test_cost_join_no_shared() {
415 let a = pat("a", 100, &[], &["x"]);
416 let b = pat("b", 200, &[], &["y"]);
417 let cost = JoinOptimizer::cost_join(&a, &b);
418 assert!(cost > 0.0);
420 }
421
422 #[test]
423 fn test_cost_join_with_shared_lower() {
424 let a = pat("a", 100, &[], &["x"]);
425 let b_no_share = pat("b", 200, &[], &["y"]);
426 let b_shared = pat("b", 200, &[], &["x", "y"]);
427 let cost_no = JoinOptimizer::cost_join(&a, &b_no_share);
428 let cost_shared = JoinOptimizer::cost_join(&a, &b_shared);
429 assert!(cost_shared < cost_no);
430 }
431
432 #[test]
433 fn test_cost_join_symmetry_not_required() {
434 let a = pat("a", 50, &[], &["x"]);
436 let b = pat("b", 150, &[], &["x"]);
437 let c1 = JoinOptimizer::cost_join(&a, &b);
438 let c2 = JoinOptimizer::cost_join(&b, &a);
439 assert!(c1 > 0.0);
440 assert!(c2 > 0.0);
441 }
442
443 #[test]
444 fn test_cost_join_zero_size() {
445 let a = pat("a", 0, &[], &["x"]);
446 let b = pat("b", 100, &[], &["y"]);
447 let cost = JoinOptimizer::cost_join(&a, &b);
448 assert!(cost >= 0.0);
449 }
450
451 #[test]
454 fn test_merge_label() {
455 let a = pat("A", 10, &[], &["x"]);
456 let b = pat("B", 20, &[], &["x", "y"]);
457 let merged = JoinOptimizer::merge_patterns(&a, &b);
458 assert!(merged.label.contains('A'));
459 assert!(merged.label.contains('B'));
460 }
461
462 #[test]
463 fn test_merge_size_with_shared() {
464 let a = pat("a", 100, &[], &["x"]);
465 let b = pat("b", 100, &[], &["x", "y"]);
466 let merged = JoinOptimizer::merge_patterns(&a, &b);
467 assert!(merged.estimated_size < 100 * 100);
469 }
470
471 #[test]
472 fn test_merge_size_no_shared() {
473 let a = pat("a", 10, &[], &["x"]);
474 let b = pat("b", 10, &[], &["y"]);
475 let merged = JoinOptimizer::merge_patterns(&a, &b);
476 assert_eq!(merged.estimated_size, 100);
477 }
478
479 #[test]
480 fn test_merge_bound_vars_accumulate() {
481 let a = pat("a", 1, &[], &["x"]);
482 let b = pat("b", 1, &[], &["x", "y"]);
483 let merged = JoinOptimizer::merge_patterns(&a, &b);
484 assert!(merged.bound_vars.contains(&"x".to_string()));
485 }
486
487 #[test]
490 fn test_join_graph_no_shared() {
491 let patterns = vec![pat("a", 10, &[], &["x"]), pat("b", 10, &[], &["y"])];
492 let edges = JoinOptimizer::build_join_graph(&patterns);
493 assert!(edges.is_empty());
494 }
495
496 #[test]
497 fn test_join_graph_one_edge() {
498 let patterns = vec![pat("a", 10, &[], &["x"]), pat("b", 10, &[], &["x", "y"])];
499 let edges = JoinOptimizer::build_join_graph(&patterns);
500 assert_eq!(edges.len(), 1);
501 assert_eq!(edges[0].left, 0);
502 assert_eq!(edges[0].right, 1);
503 assert!(edges[0].shared_vars.contains(&"x".to_string()));
504 }
505
506 #[test]
507 fn test_join_graph_selectivity_range() {
508 let patterns = vec![pat("a", 100, &[], &["x"]), pat("b", 100, &[], &["x", "y"])];
509 let edges = JoinOptimizer::build_join_graph(&patterns);
510 assert!(!edges.is_empty());
511 let sel = edges[0].join_selectivity;
512 assert!((0.0..=1.0).contains(&sel));
513 }
514
515 #[test]
516 fn test_join_graph_multiple_edges() {
517 let patterns = vec![
518 pat("a", 10, &[], &["x", "y"]),
519 pat("b", 10, &[], &["y", "z"]),
520 pat("c", 10, &[], &["x", "z"]),
521 ];
522 let edges = JoinOptimizer::build_join_graph(&patterns);
523 assert_eq!(edges.len(), 3);
524 }
525
526 #[test]
529 fn test_greedy_empty() {
530 let plan = JoinOptimizer::greedy(&[]);
531 assert!(plan.order.is_empty());
532 assert_eq!(plan.estimated_cost, 0.0);
533 }
534
535 #[test]
536 fn test_greedy_single() {
537 let patterns = vec![pat("a", 42, &[], &["x"])];
538 let plan = JoinOptimizer::greedy(&patterns);
539 assert_eq!(plan.order, vec![0]);
540 assert!(plan.estimated_cost > 0.0);
541 }
542
543 #[test]
544 fn test_greedy_two_patterns_order() {
545 let patterns = vec![pat("big", 1000, &[], &["x"]), pat("small", 5, &[], &["y"])];
547 let plan = JoinOptimizer::greedy(&patterns);
548 assert_eq!(plan.order[0], 1); }
550
551 #[test]
552 fn test_greedy_all_patterns_included() {
553 let patterns = vec![
554 pat("a", 10, &[], &["x"]),
555 pat("b", 20, &[], &["y"]),
556 pat("c", 5, &[], &["z"]),
557 ];
558 let plan = JoinOptimizer::greedy(&patterns);
559 assert_eq!(plan.order.len(), 3);
560 let mut sorted = plan.order.clone();
561 sorted.sort();
562 assert_eq!(sorted, vec![0, 1, 2]);
563 }
564
565 #[test]
566 fn test_greedy_prefers_shared_vars() {
567 let patterns = vec![
569 pat("seed", 10, &[], &["x"]),
570 pat("linked", 100, &[], &["x", "y"]),
571 pat("unrelated", 50, &[], &["z"]),
572 ];
573 let plan = JoinOptimizer::greedy(&patterns);
574 assert_eq!(plan.order.len(), 3);
575 assert!(plan.estimated_cost > 0.0);
576 }
577
578 #[test]
581 fn test_dp_empty() {
582 let opt = JoinOptimizer::new(12);
583 let plan = opt.dynamic_programming(&[]);
584 assert!(plan.order.is_empty());
585 }
586
587 #[test]
588 fn test_dp_single() {
589 let opt = JoinOptimizer::new(12);
590 let patterns = vec![pat("a", 10, &[], &["x"])];
591 let plan = opt.dynamic_programming(&patterns);
592 assert_eq!(plan.order, vec![0]);
593 }
594
595 #[test]
596 fn test_dp_two_patterns() {
597 let opt = JoinOptimizer::new(12);
598 let patterns = vec![pat("big", 500, &[], &["x"]), pat("small", 5, &[], &["y"])];
599 let plan = opt.dynamic_programming(&patterns);
600 assert_eq!(plan.order.len(), 2);
601 }
602
603 #[test]
604 fn test_dp_covers_all() {
605 let opt = JoinOptimizer::new(12);
606 let patterns = vec![
607 pat("a", 10, &[], &["x", "y"]),
608 pat("b", 20, &[], &["y", "z"]),
609 pat("c", 5, &[], &["z", "w"]),
610 ];
611 let plan = opt.dynamic_programming(&patterns);
612 assert_eq!(plan.order.len(), 3);
613 let mut sorted = plan.order.clone();
614 sorted.sort();
615 assert_eq!(sorted, vec![0, 1, 2]);
616 }
617
618 #[test]
619 fn test_dp_cost_not_negative() {
620 let opt = JoinOptimizer::new(12);
621 let patterns = vec![
622 pat("a", 100, &[], &["x"]),
623 pat("b", 200, &[], &["x", "y"]),
624 pat("c", 50, &[], &["y", "z"]),
625 ];
626 let plan = opt.dynamic_programming(&patterns);
627 assert!(plan.estimated_cost > 0.0);
628 }
629
630 #[test]
633 fn test_optimize_small_uses_dp() {
634 let opt = JoinOptimizer::new(12);
635 let patterns = vec![pat("a", 10, &[], &["x"]), pat("b", 20, &[], &["y"])];
636 let plan = opt.optimize(patterns);
637 assert_eq!(plan.order.len(), 2);
638 }
639
640 #[test]
641 fn test_optimize_large_uses_greedy() {
642 let opt = JoinOptimizer::new(2); let patterns: Vec<_> = (0..5)
644 .map(|i| pat(&format!("p{i}"), 10 + i, &[], &[&format!("x{i}")]))
645 .collect();
646 let plan = opt.optimize(patterns);
647 assert_eq!(plan.order.len(), 5);
648 }
649
650 #[test]
651 fn test_optimize_empty() {
652 let opt = JoinOptimizer::new(12);
653 let plan = opt.optimize(vec![]);
654 assert!(plan.order.is_empty());
655 assert_eq!(plan.estimated_cost, 0.0);
656 }
657
658 #[test]
659 fn test_optimize_single() {
660 let opt = JoinOptimizer::new(12);
661 let plan = opt.optimize(vec![pat("solo", 7, &[], &["x"])]);
662 assert_eq!(plan.order, vec![0]);
663 }
664
665 #[test]
668 fn test_join_edge_fields() {
669 let edge = JoinEdge {
670 left: 0,
671 right: 1,
672 shared_vars: vec!["x".to_string()],
673 join_selectivity: 0.5,
674 };
675 assert_eq!(edge.left, 0);
676 assert_eq!(edge.right, 1);
677 assert_eq!(edge.shared_vars, vec!["x"]);
678 assert!((edge.join_selectivity - 0.5).abs() < 1e-9);
679 }
680
681 #[test]
684 fn test_join_plan_fields() {
685 let plan = JoinPlan {
686 order: vec![2, 0, 1],
687 estimated_cost: 123.45,
688 };
689 assert_eq!(plan.order, vec![2, 0, 1]);
690 assert!((plan.estimated_cost - 123.45).abs() < 1e-9);
691 }
692
693 #[test]
696 fn test_star_shaped_query() {
697 let patterns = vec![
699 pat("?s rdf:type :Person", 1000, &[], &["s"]),
700 pat("?s :name ?name", 5000, &["s"], &["name"]),
701 pat("?s :age ?age", 5000, &["s"], &["age"]),
702 pat("?s :email ?email", 4000, &["s"], &["email"]),
703 ];
704 let opt = JoinOptimizer::new(12);
705 let plan = opt.optimize(patterns);
706 assert_eq!(plan.order.len(), 4);
707 assert!(plan.estimated_cost > 0.0);
708 }
709
710 #[test]
711 fn test_chain_query() {
712 let patterns = vec![
713 pat("?a :knows ?b", 200, &[], &["a", "b"]),
714 pat("?b :knows ?c", 200, &["b"], &["c"]),
715 pat("?c :knows ?d", 200, &["c"], &["d"]),
716 ];
717 let opt = JoinOptimizer::new(12);
718 let plan = opt.optimize(patterns);
719 assert_eq!(plan.order.len(), 3);
720 }
721
722 #[test]
723 fn test_dp_better_or_equal_than_greedy_for_small() {
724 let patterns = vec![
726 pat("a", 1000, &[], &["x"]),
727 pat("b", 100, &[], &["x", "y"]),
728 pat("c", 10, &[], &["y"]),
729 ];
730 let opt = JoinOptimizer::new(12);
731 let dp_plan = opt.dynamic_programming(&patterns);
732 let greedy_plan = JoinOptimizer::greedy(&patterns);
733 assert!(dp_plan.estimated_cost <= greedy_plan.estimated_cost + 1.0);
735 }
736
737 #[test]
738 fn test_large_pattern_set_greedy() {
739 let patterns: Vec<_> = (0..15)
740 .map(|i| {
741 pat(
742 &format!("p{i}"),
743 100 + i * 7,
744 &[],
745 &[&format!("v{i}"), &format!("v{}", i + 1)],
746 )
747 })
748 .collect();
749 let opt = JoinOptimizer::new(12);
750 let plan = opt.optimize(patterns);
751 assert_eq!(plan.order.len(), 15);
752 }
753
754 #[test]
755 fn test_merge_deduplicates_vars() {
756 let a = pat("a", 10, &["x"], &["x", "y"]);
757 let b = pat("b", 10, &["x", "y"], &["y", "z"]);
758 let merged = JoinOptimizer::merge_patterns(&a, &b);
759 let x_count = merged.bound_vars.iter().filter(|v| *v == "x").count();
761 let y_count = merged.bound_vars.iter().filter(|v| *v == "y").count();
762 assert_eq!(x_count, 1);
763 assert_eq!(y_count, 1);
764 }
765
766 #[test]
767 fn test_join_graph_empty_patterns() {
768 let edges = JoinOptimizer::build_join_graph(&[]);
769 assert!(edges.is_empty());
770 }
771
772 #[test]
773 fn test_join_graph_single_pattern() {
774 let patterns = vec![pat("a", 10, &[], &["x"])];
775 let edges = JoinOptimizer::build_join_graph(&patterns);
776 assert!(edges.is_empty());
777 }
778
779 #[test]
780 fn test_cost_join_large_size() {
781 let a = pat("a", 1_000_000, &[], &["x"]);
782 let b = pat("b", 1_000_000, &[], &["y"]);
783 let cost = JoinOptimizer::cost_join(&a, &b);
784 assert!(cost > 0.0);
785 }
786
787 #[test]
788 fn test_optimize_four_patterns() {
789 let patterns = vec![
790 pat("a", 10, &[], &["x"]),
791 pat("b", 50, &[], &["x", "y"]),
792 pat("c", 100, &[], &["y", "z"]),
793 pat("d", 20, &[], &["z", "w"]),
794 ];
795 let opt = JoinOptimizer::new(12);
796 let plan = opt.optimize(patterns);
797 assert_eq!(plan.order.len(), 4);
798 assert!(plan.estimated_cost > 0.0);
799 }
800
801 #[test]
802 fn test_optimizer_new() {
803 let opt = JoinOptimizer::new(8);
804 assert_eq!(opt.max_patterns, 8);
805 }
806
807 #[test]
808 fn test_dp_twelve_patterns() {
809 let opt = JoinOptimizer::new(12);
810 let patterns: Vec<_> = (0..12)
811 .map(|i| {
812 pat(
813 &format!("p{i}"),
814 10 + i,
815 &[],
816 &[&format!("v{i}"), &format!("v{}", i + 1)],
817 )
818 })
819 .collect();
820 let plan = opt.dynamic_programming(&patterns);
821 assert_eq!(plan.order.len(), 12);
822 assert!(plan.estimated_cost > 0.0);
823 }
824
825 #[test]
826 fn test_plan_order_is_permutation() {
827 let patterns: Vec<_> = (0..6)
828 .map(|i| pat(&format!("p{i}"), 10 + i * 3, &[], &[&format!("v{i}")]))
829 .collect();
830 let opt = JoinOptimizer::new(12);
831 let plan = opt.optimize(patterns);
832 let mut sorted = plan.order.clone();
833 sorted.sort();
834 assert_eq!(sorted, vec![0, 1, 2, 3, 4, 5]);
835 }
836}