1use std::collections::HashMap;
8use std::sync::Arc;
9
10use crate::cost::{CostEstimate, CostModel};
11use crate::optimizer::expr_util::{
12 equi_key, join_conjuncts, parse_predicate, render, split_conjuncts,
13};
14use crate::plan::{JoinType, LogicalPlan};
15
16use super::OptPass;
17
18pub struct JoinReorder {
25 pub model: Arc<CostModel>,
27 pub dp_threshold: usize,
29}
30
31impl JoinReorder {
32 pub fn new(model: Arc<CostModel>) -> Self {
34 Self {
35 model,
36 dp_threshold: 12,
37 }
38 }
39
40 pub fn with_threshold(model: Arc<CostModel>, dp_threshold: usize) -> Self {
42 Self {
43 model,
44 dp_threshold,
45 }
46 }
47}
48
49impl OptPass for JoinReorder {
50 fn name(&self) -> &'static str {
51 "JoinReorder"
52 }
53
54 fn apply(&self, plan: LogicalPlan) -> LogicalPlan {
55 walk(plan, &self.model, self.dp_threshold)
56 }
57}
58
59struct Atom {
63 plan: LogicalPlan,
64 cost: CostEstimate,
65 table: Option<String>,
67}
68
69struct Edge {
71 left_idx: usize,
72 right_idx: usize,
73 predicates: Vec<String>,
74 selectivity: f64,
75}
76
77fn walk(plan: LogicalPlan, model: &Arc<CostModel>, dp_threshold: usize) -> LogicalPlan {
82 match plan {
83 LogicalPlan::Join {
84 left,
85 right,
86 on,
87 join_type,
88 algo_hint,
89 } => {
90 let left_opt = walk(*left, model, dp_threshold);
91 let right_opt = walk(*right, model, dp_threshold);
92
93 let rebuilt = LogicalPlan::Join {
94 left: Box::new(left_opt),
95 right: Box::new(right_opt),
96 on,
97 join_type: join_type.clone(),
98 algo_hint,
99 };
100
101 if matches!(join_type, JoinType::Inner | JoinType::Cross) {
102 attempt_reorder(rebuilt, model, dp_threshold)
103 } else {
104 rebuilt
105 }
106 }
107
108 LogicalPlan::Filter { input, predicate } => LogicalPlan::Filter {
109 input: Box::new(walk(*input, model, dp_threshold)),
110 predicate,
111 },
112 LogicalPlan::Project { input, columns } => LogicalPlan::Project {
113 input: Box::new(walk(*input, model, dp_threshold)),
114 columns,
115 },
116 LogicalPlan::Aggregate {
117 input,
118 group_by,
119 aggregates,
120 } => LogicalPlan::Aggregate {
121 input: Box::new(walk(*input, model, dp_threshold)),
122 group_by,
123 aggregates,
124 },
125 LogicalPlan::Sort { input, order_by } => LogicalPlan::Sort {
126 input: Box::new(walk(*input, model, dp_threshold)),
127 order_by,
128 },
129 LogicalPlan::Limit {
130 input,
131 count,
132 offset,
133 } => LogicalPlan::Limit {
134 input: Box::new(walk(*input, model, dp_threshold)),
135 count,
136 offset,
137 },
138 LogicalPlan::Window { input, functions } => LogicalPlan::Window {
139 input: Box::new(walk(*input, model, dp_threshold)),
140 functions,
141 },
142 LogicalPlan::Cte {
143 name,
144 query,
145 recursive,
146 } => LogicalPlan::Cte {
147 name,
148 query: Box::new(walk(*query, model, dp_threshold)),
149 recursive,
150 },
151 LogicalPlan::SetOp {
152 op,
153 all,
154 left,
155 right,
156 } => LogicalPlan::SetOp {
157 op,
158 all,
159 left: Box::new(walk(*left, model, dp_threshold)),
160 right: Box::new(walk(*right, model, dp_threshold)),
161 },
162 LogicalPlan::Subquery { query, alias } => LogicalPlan::Subquery {
163 query: Box::new(walk(*query, model, dp_threshold)),
164 alias,
165 },
166 LogicalPlan::Exists { subquery, negated } => LogicalPlan::Exists {
167 subquery: Box::new(walk(*subquery, model, dp_threshold)),
168 negated,
169 },
170 LogicalPlan::InSubquery {
171 expr,
172 subquery,
173 negated,
174 } => LogicalPlan::InSubquery {
175 expr,
176 subquery: Box::new(walk(*subquery, model, dp_threshold)),
177 negated,
178 },
179 LogicalPlan::Compute { input, bindings } => LogicalPlan::Compute {
180 input: Box::new(walk(*input, model, dp_threshold)),
181 bindings,
182 },
183 leaf => leaf,
184 }
185}
186
187fn attempt_reorder(plan: LogicalPlan, model: &Arc<CostModel>, dp_threshold: usize) -> LogicalPlan {
192 let mut atoms: Vec<Atom> = Vec::new();
193 let mut all_preds: Vec<String> = Vec::new();
194
195 flatten_join_nest(plan, model, &mut atoms, &mut all_preds);
196
197 let n = atoms.len();
198 if n <= 1 {
199 return atoms
200 .into_iter()
201 .next()
202 .map(|a| a.plan)
203 .unwrap_or(LogicalPlan::Empty);
204 }
205
206 let edges = build_edges(&atoms, &all_preds);
207
208 if n <= dp_threshold {
209 dpccp(model, atoms, edges, &all_preds)
210 } else {
211 goo(model, atoms, edges, &all_preds)
212 }
213}
214
215fn flatten_join_nest(
218 plan: LogicalPlan,
219 model: &Arc<CostModel>,
220 atoms: &mut Vec<Atom>,
221 preds: &mut Vec<String>,
222) {
223 match plan {
224 LogicalPlan::Join {
225 left,
226 right,
227 on,
228 join_type: JoinType::Inner | JoinType::Cross,
229 ..
230 } => {
231 collect_pred_strings(&on, preds);
232 flatten_join_nest(*left, model, atoms, preds);
233 flatten_join_nest(*right, model, atoms, preds);
234 }
235 other => {
236 let table = table_name_of(&other);
237 let cost = model.estimate(&other);
238 atoms.push(Atom {
239 plan: other,
240 cost,
241 table,
242 });
243 }
244 }
245}
246
247fn table_name_of(plan: &LogicalPlan) -> Option<String> {
248 if let LogicalPlan::Scan { table, .. } = plan {
249 Some(table.to_lowercase())
250 } else {
251 None
252 }
253}
254
255fn collect_pred_strings(on: &str, preds: &mut Vec<String>) {
256 if on.is_empty() {
257 return;
258 }
259 if let Some(expr) = parse_predicate(on) {
260 for c in split_conjuncts(expr) {
261 preds.push(render(&c));
262 }
263 } else {
264 preds.push(on.to_string());
265 }
266}
267
268fn build_edges(atoms: &[Atom], all_preds: &[String]) -> Vec<Edge> {
271 let mut edges: Vec<Edge> = Vec::new();
272
273 for pred in all_preds {
274 let Some(expr) = parse_predicate(pred) else {
275 continue;
276 };
277 let Some((l_ref, r_ref)) = equi_key(&expr) else {
278 continue;
279 };
280
281 let l_idx = find_atom_idx(atoms, &l_ref.qualifier);
282 let r_idx = find_atom_idx(atoms, &r_ref.qualifier);
283
284 let (l_idx, r_idx) = match (l_idx, r_idx) {
285 (Some(l), Some(r)) if l != r => (l.min(r), l.max(r)),
286 _ => continue,
287 };
288
289 let selectivity = 0.1_f64;
290
291 if let Some(existing) = edges
292 .iter_mut()
293 .find(|e| e.left_idx == l_idx && e.right_idx == r_idx)
294 {
295 existing.predicates.push(pred.clone());
296 existing.selectivity *= selectivity;
297 } else {
298 edges.push(Edge {
299 left_idx: l_idx,
300 right_idx: r_idx,
301 predicates: vec![pred.clone()],
302 selectivity,
303 });
304 }
305 }
306
307 edges
308}
309
310fn find_atom_idx(atoms: &[Atom], qualifier: &Option<String>) -> Option<usize> {
311 let q = qualifier.as_deref()?.to_lowercase();
312 atoms
313 .iter()
314 .enumerate()
315 .find(|(_, a)| a.table.as_deref() == Some(q.as_str()))
316 .map(|(i, _)| i)
317}
318
319fn preds_crossing(edges: &[Edge], l_mask: u32, r_mask: u32) -> Vec<String> {
322 edges
323 .iter()
324 .filter(|e| {
325 let lb = 1u32 << e.left_idx;
326 let rb = 1u32 << e.right_idx;
327 (l_mask & lb != 0 && r_mask & rb != 0) || (l_mask & rb != 0 && r_mask & lb != 0)
328 })
329 .flat_map(|e| e.predicates.iter().cloned())
330 .collect()
331}
332
333fn selectivity_crossing(edges: &[Edge], l_mask: u32, r_mask: u32) -> f64 {
334 let mut sel = 1.0_f64;
335 let mut found = false;
336 for e in edges {
337 let lb = 1u32 << e.left_idx;
338 let rb = 1u32 << e.right_idx;
339 if (l_mask & lb != 0 && r_mask & rb != 0) || (l_mask & rb != 0 && r_mask & lb != 0) {
340 sel *= e.selectivity;
341 found = true;
342 }
343 }
344 if found {
345 sel
346 } else {
347 1.0
348 }
349}
350
351fn is_connected(edges: &[Edge], mask: u32) -> bool {
354 if mask == 0 || mask & (mask - 1) == 0 {
355 return true;
356 }
357 let start = mask.trailing_zeros() as usize;
358 let mut visited: u32 = 1 << start;
359 let mut queue: Vec<usize> = vec![start];
360
361 while let Some(cur) = queue.pop() {
362 for e in edges {
363 let lb = 1u32 << e.left_idx;
364 let rb = 1u32 << e.right_idx;
365 if (lb & mask == 0) || (rb & mask == 0) {
366 continue;
367 }
368 let other = if e.left_idx == cur {
369 e.right_idx
370 } else if e.right_idx == cur {
371 e.left_idx
372 } else {
373 continue;
374 };
375 let ob = 1u32 << other;
376 if visited & ob == 0 {
377 visited |= ob;
378 queue.push(other);
379 }
380 }
381 }
382 visited == mask
383}
384
385fn has_edge_crossing(edges: &[Edge], l_mask: u32, r_mask: u32) -> bool {
386 edges.iter().any(|e| {
387 let lb = 1u32 << e.left_idx;
388 let rb = 1u32 << e.right_idx;
389 (l_mask & lb != 0 && r_mask & rb != 0) || (l_mask & rb != 0 && r_mask & lb != 0)
390 })
391}
392
393fn make_join(left: LogicalPlan, right: LogicalPlan, crossing_preds: Vec<String>) -> LogicalPlan {
396 let on = if crossing_preds.is_empty() {
397 String::new()
398 } else if crossing_preds.len() == 1 {
399 crossing_preds.into_iter().next().unwrap_or_default()
400 } else {
401 let exprs: Vec<_> = crossing_preds
402 .iter()
403 .filter_map(|p| parse_predicate(p))
404 .collect();
405 if let Some(combined) = join_conjuncts(exprs) {
406 render(&combined)
407 } else {
408 crossing_preds.join(" AND ")
409 }
410 };
411 let join_type = if on.is_empty() {
412 JoinType::Cross
413 } else {
414 JoinType::Inner
415 };
416 LogicalPlan::Join {
417 left: Box::new(left),
418 right: Box::new(right),
419 on,
420 join_type,
421 algo_hint: None,
422 }
423}
424
425fn dpccp(
428 model: &Arc<CostModel>,
429 atoms: Vec<Atom>,
430 edges: Vec<Edge>,
431 all_preds: &[String],
432) -> LogicalPlan {
433 let n = atoms.len();
434 let full_mask: u32 = (1u32 << n) - 1;
435
436 let mut best_plan: HashMap<u32, LogicalPlan> = HashMap::new();
437 let mut best_cost: HashMap<u32, CostEstimate> = HashMap::new();
438
439 for (i, atom) in atoms.iter().enumerate() {
440 let mask = 1u32 << i;
441 best_plan.insert(mask, atom.plan.clone());
442 best_cost.insert(mask, atom.cost.clone());
443 }
444
445 let total: u32 = 1u32 << n;
446 for s in 1..total {
447 if s.count_ones() < 2 {
448 continue;
449 }
450 let no_edges = edges.is_empty();
453 if !no_edges && !is_connected(&edges, s) {
454 continue;
455 }
456
457 let mut sub = (s - 1) & s;
458 while sub > 0 {
459 let complement = s ^ sub;
460 if complement != 0 && sub < complement {
461 let l_mask = sub;
462 let r_mask = complement;
463
464 let l_ok = no_edges || is_connected(&edges, l_mask);
465 let r_ok = no_edges || is_connected(&edges, r_mask);
466 let edge_ok =
467 no_edges || has_edge_crossing(&edges, l_mask, r_mask) || s == full_mask;
468
469 if l_ok && r_ok && edge_ok {
470 let lp = best_plan.get(&l_mask);
471 let lc = best_cost.get(&l_mask);
472 let rp = best_plan.get(&r_mask);
473 let rc = best_cost.get(&r_mask);
474
475 if let (Some(lp), Some(lc), Some(rp), Some(rc)) = (lp, lc, rp, rc) {
476 let sel = selectivity_crossing(&edges, l_mask, r_mask);
477 let new_cost = model.estimate_join_from(lc, rc, sel);
478
479 let is_better = best_cost
480 .get(&s)
481 .map(|c| new_cost.total_cost < c.total_cost)
482 .unwrap_or(true);
483
484 if is_better {
485 let crossing = preds_crossing(&edges, l_mask, r_mask);
486 let _ = all_preds; let new_plan = make_join(lp.clone(), rp.clone(), crossing);
488 best_plan.insert(s, new_plan);
489 best_cost.insert(s, new_cost);
490 }
491 }
492 }
493 }
494 if sub == 0 {
495 break;
496 }
497 sub = (sub - 1) & s;
498 }
499 }
500
501 best_plan.remove(&full_mask).unwrap_or_else(|| {
502 assemble_linear(
503 atoms.into_iter().map(|a| (a.plan, a.cost)).collect(),
504 &edges,
505 )
506 })
507}
508
509struct GooAtom {
512 plan: LogicalPlan,
513 cost: CostEstimate,
514 mask: u32,
515}
516
517fn goo(
518 model: &Arc<CostModel>,
519 atoms: Vec<Atom>,
520 edges: Vec<Edge>,
521 _all_preds: &[String],
522) -> LogicalPlan {
523 let mut remaining: Vec<GooAtom> = atoms
524 .into_iter()
525 .enumerate()
526 .map(|(i, a)| GooAtom {
527 plan: a.plan,
528 cost: a.cost,
529 mask: 1u32 << i,
530 })
531 .collect();
532
533 while remaining.len() > 1 {
534 let mut best_i = 0usize;
535 let mut best_j = 1usize;
536 let mut best_rows = f64::MAX;
537
538 for i in 0..remaining.len() {
539 for j in (i + 1)..remaining.len() {
540 let sel = selectivity_crossing(&edges, remaining[i].mask, remaining[j].mask);
541 let est = model.estimate_join_from(&remaining[i].cost, &remaining[j].cost, sel);
542 if est.rows < best_rows {
543 best_rows = est.rows;
544 best_i = i;
545 best_j = j;
546 }
547 }
548 }
549
550 let j_entry = remaining.remove(best_j);
552 let i_entry = remaining.remove(best_i);
553
554 let combined_mask = i_entry.mask | j_entry.mask;
555 let sel = selectivity_crossing(&edges, i_entry.mask, j_entry.mask);
556 let crossing = preds_crossing(&edges, i_entry.mask, j_entry.mask);
557 let new_cost = model.estimate_join_from(&i_entry.cost, &j_entry.cost, sel);
558 let new_plan = make_join(i_entry.plan, j_entry.plan, crossing);
559
560 remaining.push(GooAtom {
561 plan: new_plan,
562 cost: new_cost,
563 mask: combined_mask,
564 });
565 }
566
567 remaining
568 .into_iter()
569 .next()
570 .map(|a| a.plan)
571 .unwrap_or(LogicalPlan::Empty)
572}
573
574fn assemble_linear(mut plans: Vec<(LogicalPlan, CostEstimate)>, edges: &[Edge]) -> LogicalPlan {
577 if plans.is_empty() {
578 return LogicalPlan::Empty;
579 }
580
581 while plans.len() > 1 {
583 let len = plans.len();
584 let (rp, _rc) = plans.remove(len - 1);
585 let (lp, _lc) = plans.remove(len - 2);
586 let _ = edges;
589 let new_plan = make_join(lp, rp, vec![]);
590 plans.push((new_plan, CostEstimate::zero()));
591 }
592
593 plans
594 .into_iter()
595 .next()
596 .map(|(p, _)| p)
597 .unwrap_or(LogicalPlan::Empty)
598}
599
600#[cfg(test)]
603mod tests {
604 use super::*;
605 use crate::cost::{CostModel, TableStats};
606
607 fn scan(table: &str) -> LogicalPlan {
608 LogicalPlan::Scan {
609 table: table.to_string(),
610 alias: None,
611 limit: None,
612 }
613 }
614
615 fn inner_join(l: LogicalPlan, r: LogicalPlan, on: &str) -> LogicalPlan {
616 LogicalPlan::Join {
617 left: Box::new(l),
618 right: Box::new(r),
619 on: on.to_string(),
620 join_type: JoinType::Inner,
621 algo_hint: None,
622 }
623 }
624
625 fn left_join(l: LogicalPlan, r: LogicalPlan, on: &str) -> LogicalPlan {
626 LogicalPlan::Join {
627 left: Box::new(l),
628 right: Box::new(r),
629 on: on.to_string(),
630 join_type: JoinType::Left,
631 algo_hint: None,
632 }
633 }
634
635 fn model_3table() -> Arc<CostModel> {
636 Arc::new(
637 CostModel::new()
638 .with_table_stats(
639 "big",
640 TableStats {
641 row_count: 1_000_000,
642 avg_row_size_bytes: 100,
643 ..Default::default()
644 },
645 )
646 .with_table_stats(
647 "small1",
648 TableStats {
649 row_count: 10,
650 avg_row_size_bytes: 50,
651 ..Default::default()
652 },
653 )
654 .with_table_stats(
655 "small2",
656 TableStats {
657 row_count: 10,
658 avg_row_size_bytes: 50,
659 ..Default::default()
660 },
661 ),
662 )
663 }
664
665 #[test]
668 fn test_3table_small_tables_join_first() {
669 let model = model_3table();
670 let original = inner_join(
671 inner_join(scan("big"), scan("small1"), "big.id = small1.big_id"),
672 scan("small2"),
673 "small1.id = small2.s1_id",
674 );
675
676 let pass = JoinReorder::new(Arc::clone(&model));
677 let reordered = pass.apply(original.clone());
678
679 let orig_cost = model.estimate(&original).total_cost;
680 let reord_cost = model.estimate(&reordered).total_cost;
681 assert!(
682 reord_cost <= orig_cost + 1e-6,
683 "reordered cost ({reord_cost}) should not exceed original ({orig_cost})"
684 );
685 }
686
687 #[test]
689 fn test_outer_join_nest_untouched() {
690 let model = Arc::new(CostModel::new());
691 let plan = left_join(scan("a"), scan("b"), "a.id = b.id");
692
693 let pass = JoinReorder::new(Arc::clone(&model));
694 let result = pass.apply(plan);
695
696 match &result {
697 LogicalPlan::Join { join_type, .. } => {
698 assert_eq!(
699 *join_type,
700 JoinType::Left,
701 "outer join must not be reordered"
702 );
703 }
704 other => panic!("expected Join, got {other:?}"),
705 }
706 }
707
708 #[test]
710 fn test_cross_join_only_still_connects_atoms() {
711 let model = Arc::new(CostModel::new());
712 let plan = LogicalPlan::Join {
713 left: Box::new(LogicalPlan::Join {
714 left: Box::new(scan("a")),
715 right: Box::new(scan("b")),
716 on: String::new(),
717 join_type: JoinType::Cross,
718 algo_hint: None,
719 }),
720 right: Box::new(scan("c")),
721 on: String::new(),
722 join_type: JoinType::Cross,
723 algo_hint: None,
724 };
725
726 let pass = JoinReorder::new(Arc::clone(&model));
727 let result = pass.apply(plan);
728
729 let text = crate::explain::explain(&result);
730 assert!(text.contains("Scan [a"), "a should be present: {text}");
731 assert!(text.contains("Scan [b"), "b should be present: {text}");
732 assert!(text.contains("Scan [c"), "c should be present: {text}");
733 }
734
735 #[test]
737 fn test_goo_fallback_with_4_atoms() {
738 let model = Arc::new(
739 CostModel::new()
740 .with_table_stats(
741 "a",
742 TableStats {
743 row_count: 1_000,
744 ..Default::default()
745 },
746 )
747 .with_table_stats(
748 "b",
749 TableStats {
750 row_count: 10,
751 ..Default::default()
752 },
753 )
754 .with_table_stats(
755 "c",
756 TableStats {
757 row_count: 10,
758 ..Default::default()
759 },
760 )
761 .with_table_stats(
762 "d",
763 TableStats {
764 row_count: 5,
765 ..Default::default()
766 },
767 ),
768 );
769 let plan = inner_join(
770 inner_join(
771 inner_join(scan("a"), scan("b"), "a.id = b.a_id"),
772 scan("c"),
773 "b.id = c.b_id",
774 ),
775 scan("d"),
776 "c.id = d.c_id",
777 );
778
779 let pass = JoinReorder::with_threshold(Arc::clone(&model), 3);
780 let result = pass.apply(plan);
781
782 let text = crate::explain::explain(&result);
783 assert!(text.contains("Scan [a"), "a missing: {text}");
784 assert!(text.contains("Scan [b"), "b missing: {text}");
785 assert!(text.contains("Scan [c"), "c missing: {text}");
786 assert!(text.contains("Scan [d"), "d missing: {text}");
787 }
788
789 #[test]
791 fn test_proptest_reorder_cost_le_original() {
792 use proptest::prelude::*;
793 use proptest::test_runner::TestRunner;
794
795 let mut runner = TestRunner::default();
796 runner
797 .run(
798 &(1u64..100_000u64, 1u64..100_000u64, 1u64..100_000u64),
799 |(r_a, r_b, r_c)| {
800 let model = Arc::new(
801 CostModel::new()
802 .with_table_stats(
803 "a",
804 TableStats {
805 row_count: r_a,
806 ..Default::default()
807 },
808 )
809 .with_table_stats(
810 "b",
811 TableStats {
812 row_count: r_b,
813 ..Default::default()
814 },
815 )
816 .with_table_stats(
817 "c",
818 TableStats {
819 row_count: r_c,
820 ..Default::default()
821 },
822 ),
823 );
824 let original = inner_join(
825 inner_join(scan("a"), scan("b"), "a.id = b.a_id"),
826 scan("c"),
827 "b.id = c.b_id",
828 );
829 let pass = JoinReorder::new(Arc::clone(&model));
830 let reordered = pass.apply(original.clone());
831 let orig_cost = model.estimate(&original).total_cost;
832 let reord_cost = model.estimate(&reordered).total_cost;
833 prop_assert!(
834 reord_cost <= orig_cost + 1e-6,
835 "reord={reord_cost} orig={orig_cost}"
836 );
837 Ok(())
838 },
839 )
840 .expect("proptest failed");
841 }
842}