1use std::collections::HashMap;
16
17use crate::{JoinType, LogicalPlan, NodeOp, PlanNode};
18
19use super::OptimizerRule;
20
21pub struct JoinReorderRule;
30
31impl OptimizerRule for JoinReorderRule {
32 fn name(&self) -> &str {
33 "join-reorder"
34 }
35
36 fn apply(&self, plan: &LogicalPlan) -> Option<LogicalPlan> {
37 let nodes = plan.nodes();
38
39 let row_estimates: HashMap<&str, u64> = nodes
41 .iter()
42 .filter_map(|n| n.estimated_rows().map(|r| (n.id(), r)))
43 .collect();
44
45 let mut changed = false;
46 let mut new_nodes: Vec<PlanNode> = Vec::with_capacity(nodes.len());
47
48 for node in nodes {
49 let Some(NodeOp::Join { join_type }) = node.op() else {
50 new_nodes.push(node.clone());
51 continue;
52 };
53
54 if !matches!(join_type, JoinType::Inner | JoinType::Cross) {
56 new_nodes.push(node.clone());
57 continue;
58 }
59
60 let inputs = node.inputs();
61 if inputs.len() != 2 {
62 new_nodes.push(node.clone());
63 continue;
64 }
65
66 let left_rows = inputs
67 .first()
68 .and_then(|id| row_estimates.get(id.as_str()).copied())
69 .unwrap_or(u64::MAX);
70 let right_rows = inputs
71 .get(1)
72 .and_then(|id| row_estimates.get(id.as_str()).copied())
73 .unwrap_or(u64::MAX);
74
75 if right_rows < left_rows
81 && let (Some(left), Some(right)) = (inputs.first(), inputs.get(1))
82 {
83 let swapped = vec![right.clone(), left.clone()];
84 new_nodes.push(node.clone().with_inputs(swapped));
85 changed = true;
86 } else {
87 new_nodes.push(node.clone());
88 }
89 }
90
91 if !changed {
92 return None;
93 }
94
95 let mut new_plan = LogicalPlan::new(plan.name(), plan.kind());
96 for n in new_nodes {
97 new_plan = new_plan.with_node(n);
98 }
99 Some(new_plan)
100 }
101}
102
103#[cfg(test)]
106mod tests {
107 use super::*;
108 use crate::{ExecutionKind, JoinType, LogicalPlan, NodeOp, PlanNode};
109
110 fn scan(id: &str, rows: u64) -> PlanNode {
111 PlanNode::new(id, format!("scan {id}"), ExecutionKind::Batch)
112 .with_op(NodeOp::Scan {
113 table: id.to_string(),
114 filters: vec![],
115 })
116 .with_estimated_rows(Some(rows))
117 }
118
119 fn scan_no_estimate(id: &str) -> PlanNode {
120 PlanNode::new(id, format!("scan {id}"), ExecutionKind::Batch).with_op(NodeOp::Scan {
121 table: id.to_string(),
122 filters: vec![],
123 })
124 }
125
126 fn inner_join(id: &str, left: &str, right: &str) -> PlanNode {
127 PlanNode::new(id, "join", ExecutionKind::Batch)
128 .with_inputs([left, right])
129 .with_op(NodeOp::Join {
130 join_type: JoinType::Inner,
131 })
132 }
133
134 fn join_with_type(id: &str, left: &str, right: &str, jt: JoinType) -> PlanNode {
135 PlanNode::new(id, "join", ExecutionKind::Batch)
136 .with_inputs([left, right])
137 .with_op(NodeOp::Join { join_type: jt })
138 }
139
140 #[test]
143 fn join_reorder_rule_name() {
144 assert_eq!(JoinReorderRule.name(), "join-reorder");
145 }
146
147 #[test]
150 fn join_reorder_noop_when_already_ordered_correctly() {
151 let plan = LogicalPlan::new("q", ExecutionKind::Batch)
153 .with_node(scan("small", 10))
154 .with_node(scan("large", 1000))
155 .with_node(inner_join("j", "small", "large"));
156
157 let result = JoinReorderRule.apply(&plan);
158 assert!(result.is_none(), "already ordered correctly → no change");
159 }
160
161 #[test]
162 fn join_reorder_noop_when_no_estimates() {
163 let plan = LogicalPlan::new("q", ExecutionKind::Batch)
164 .with_node(scan_no_estimate("a"))
165 .with_node(scan_no_estimate("b"))
166 .with_node(inner_join("j", "a", "b"));
167
168 let result = JoinReorderRule.apply(&plan);
169 assert!(result.is_none(), "no estimates → no change");
170 }
171
172 #[test]
173 fn join_reorder_noop_equal_estimates() {
174 let plan = LogicalPlan::new("q", ExecutionKind::Batch)
175 .with_node(scan("a", 500))
176 .with_node(scan("b", 500))
177 .with_node(inner_join("j", "a", "b"));
178
179 let result = JoinReorderRule.apply(&plan);
180 assert!(result.is_none(), "equal estimates → no change");
181 }
182
183 #[test]
186 fn join_reorder_swaps_when_right_is_smaller() {
187 let plan = LogicalPlan::new("q", ExecutionKind::Batch)
189 .with_node(scan("large", 1000))
190 .with_node(scan("small", 10))
191 .with_node(inner_join("j", "large", "small"));
192
193 let result = JoinReorderRule.apply(&plan).expect("should swap");
194 let join_node = result.nodes().iter().find(|n| n.id() == "j").unwrap();
195 assert_eq!(
196 join_node.inputs()[0],
197 "small",
198 "small table must be on left"
199 );
200 assert_eq!(
201 join_node.inputs()[1],
202 "large",
203 "large table must be on right"
204 );
205 }
206
207 #[test]
208 fn join_reorder_cross_join_also_swapped() {
209 let plan = LogicalPlan::new("q", ExecutionKind::Batch)
210 .with_node(scan("big", 5000))
211 .with_node(scan("tiny", 5))
212 .with_node(join_with_type("j", "big", "tiny", JoinType::Cross));
213
214 let result = JoinReorderRule
215 .apply(&plan)
216 .expect("cross join should swap");
217 let join_node = result.nodes().iter().find(|n| n.id() == "j").unwrap();
218 assert_eq!(join_node.inputs()[0], "tiny");
219 assert_eq!(join_node.inputs()[1], "big");
220 }
221
222 #[test]
225 fn join_reorder_left_join_not_swapped() {
226 let plan = LogicalPlan::new("q", ExecutionKind::Batch)
227 .with_node(scan("big", 5000))
228 .with_node(scan("small", 5))
229 .with_node(join_with_type("j", "big", "small", JoinType::Left));
230
231 let result = JoinReorderRule.apply(&plan);
232 assert!(result.is_none(), "left join must not be reordered");
233 }
234
235 #[test]
236 fn join_reorder_right_join_not_swapped() {
237 let plan = LogicalPlan::new("q", ExecutionKind::Batch)
238 .with_node(scan("big", 5000))
239 .with_node(scan("small", 5))
240 .with_node(join_with_type("j", "big", "small", JoinType::Right));
241
242 let result = JoinReorderRule.apply(&plan);
243 assert!(result.is_none(), "right join must not be reordered");
244 }
245
246 #[test]
247 fn join_reorder_semi_join_not_swapped() {
248 let plan = LogicalPlan::new("q", ExecutionKind::Batch)
249 .with_node(scan("big", 5000))
250 .with_node(scan("small", 5))
251 .with_node(join_with_type("j", "big", "small", JoinType::Semi));
252
253 let result = JoinReorderRule.apply(&plan);
254 assert!(result.is_none(), "semi join must not be reordered");
255 }
256
257 #[test]
258 fn join_reorder_anti_join_not_swapped() {
259 let plan = LogicalPlan::new("q", ExecutionKind::Batch)
260 .with_node(scan("big", 5000))
261 .with_node(scan("small", 5))
262 .with_node(join_with_type("j", "big", "small", JoinType::Anti));
263
264 let result = JoinReorderRule.apply(&plan);
265 assert!(result.is_none(), "anti join must not be reordered");
266 }
267
268 #[test]
269 fn join_reorder_full_join_not_swapped() {
270 let plan = LogicalPlan::new("q", ExecutionKind::Batch)
271 .with_node(scan("big", 5000))
272 .with_node(scan("small", 5))
273 .with_node(join_with_type("j", "big", "small", JoinType::Full));
274
275 let result = JoinReorderRule.apply(&plan);
276 assert!(result.is_none(), "full join must not be reordered");
277 }
278
279 #[test]
282 fn join_reorder_reorders_multiple_joins_in_one_pass() {
283 let plan = LogicalPlan::new("q", ExecutionKind::Batch)
290 .with_node(scan("a", 100))
291 .with_node(scan("b", 10_000))
292 .with_node(inner_join("j1", "a", "b"))
293 .with_node(scan("c", 50))
294 .with_node(inner_join("j2", "j1", "c"));
295
296 let result = JoinReorderRule.apply(&plan).expect("should swap j2");
300 let j2 = result.nodes().iter().find(|n| n.id() == "j2").unwrap();
301 assert_eq!(j2.inputs()[0], "c");
303 assert_eq!(j2.inputs()[1], "j1");
304 }
305
306 #[test]
307 fn join_reorder_result_plan_is_valid() {
308 let plan = LogicalPlan::new("q", ExecutionKind::Batch)
309 .with_node(scan("big", 5000))
310 .with_node(scan("small", 5))
311 .with_node(inner_join("j", "big", "small"));
312
313 let result = JoinReorderRule.apply(&plan).expect("should swap");
314 result.validate().expect("reordered plan must be valid");
315 }
316
317 #[test]
318 fn join_reorder_only_right_estimate_treated_as_smaller() {
319 let plan = LogicalPlan::new("q", ExecutionKind::Batch)
322 .with_node(scan_no_estimate("a"))
323 .with_node(scan("b", 100))
324 .with_node(inner_join("j", "a", "b"));
325
326 let result = JoinReorderRule.apply(&plan).expect("should swap");
327 let j = result.nodes().iter().find(|n| n.id() == "j").unwrap();
328 assert_eq!(j.inputs()[0], "b");
329 assert_eq!(j.inputs()[1], "a");
330 }
331}