1use crate::{JoinType, LogicalPlan};
17
18use super::OptPass;
19
20#[derive(Debug, Clone, PartialEq)]
28pub enum JoinAlgoHint {
29 HashJoin,
32 MergeJoin,
34 NestedLoop,
36}
37
38pub struct JoinAlgorithmPass;
42
43impl OptPass for JoinAlgorithmPass {
44 fn name(&self) -> &'static str {
45 "join_algorithm"
46 }
47
48 fn apply(&self, plan: LogicalPlan) -> LogicalPlan {
49 annotate_join(plan)
50 }
51}
52
53fn annotate_join(plan: LogicalPlan) -> LogicalPlan {
57 match plan {
58 LogicalPlan::Join {
59 left,
60 right,
61 on,
62 join_type,
63 algo_hint: _,
64 } => {
65 let left = Box::new(annotate_join(*left));
67 let right = Box::new(annotate_join(*right));
68
69 let hint = choose_hint(&on, &join_type, &left, &right);
70
71 LogicalPlan::Join {
72 left,
73 right,
74 on,
75 join_type,
76 algo_hint: Some(hint),
77 }
78 }
79
80 LogicalPlan::Filter { input, predicate } => LogicalPlan::Filter {
82 input: Box::new(annotate_join(*input)),
83 predicate,
84 },
85 LogicalPlan::Project { input, columns } => LogicalPlan::Project {
86 input: Box::new(annotate_join(*input)),
87 columns,
88 },
89 LogicalPlan::Aggregate {
90 input,
91 group_by,
92 aggregates,
93 } => LogicalPlan::Aggregate {
94 input: Box::new(annotate_join(*input)),
95 group_by,
96 aggregates,
97 },
98 LogicalPlan::Sort { input, order_by } => LogicalPlan::Sort {
99 input: Box::new(annotate_join(*input)),
100 order_by,
101 },
102 LogicalPlan::Limit {
103 input,
104 count,
105 offset,
106 } => LogicalPlan::Limit {
107 input: Box::new(annotate_join(*input)),
108 count,
109 offset,
110 },
111 LogicalPlan::Window { input, functions } => LogicalPlan::Window {
112 input: Box::new(annotate_join(*input)),
113 functions,
114 },
115 LogicalPlan::Cte {
116 name,
117 query,
118 recursive,
119 } => LogicalPlan::Cte {
120 name,
121 query: Box::new(annotate_join(*query)),
122 recursive,
123 },
124 LogicalPlan::SetOp {
125 op,
126 all,
127 left,
128 right,
129 } => LogicalPlan::SetOp {
130 op,
131 all,
132 left: Box::new(annotate_join(*left)),
133 right: Box::new(annotate_join(*right)),
134 },
135 LogicalPlan::Subquery { query, alias } => LogicalPlan::Subquery {
136 query: Box::new(annotate_join(*query)),
137 alias,
138 },
139 LogicalPlan::Exists { subquery, negated } => LogicalPlan::Exists {
140 subquery: Box::new(annotate_join(*subquery)),
141 negated,
142 },
143 LogicalPlan::InSubquery {
144 expr,
145 subquery,
146 negated,
147 } => LogicalPlan::InSubquery {
148 expr,
149 subquery: Box::new(annotate_join(*subquery)),
150 negated,
151 },
152 LogicalPlan::Compute { input, bindings } => LogicalPlan::Compute {
153 input: Box::new(annotate_join(*input)),
154 bindings,
155 },
156 leaf => leaf,
158 }
159}
160
161fn choose_hint(
164 on: &str,
165 join_type: &JoinType,
166 left: &LogicalPlan,
167 right: &LogicalPlan,
168) -> JoinAlgoHint {
169 if matches!(join_type, JoinType::Cross) || on.is_empty() {
171 return JoinAlgoHint::NestedLoop;
172 }
173
174 let is_equi = on.contains('=') && !on.contains('<') && !on.contains('>');
176
177 if !is_equi {
178 return JoinAlgoHint::NestedLoop;
179 }
180
181 if sides_sorted_on_join_key(on, left) && sides_sorted_on_join_key(on, right) {
184 return JoinAlgoHint::MergeJoin;
185 }
186
187 JoinAlgoHint::HashJoin
188}
189
190fn sides_sorted_on_join_key(on: &str, plan: &LogicalPlan) -> bool {
194 if let LogicalPlan::Sort { order_by, .. } = plan {
195 if let Some(first) = order_by.first() {
196 let col = first.column.rsplit('.').next().unwrap_or(&first.column);
198 return on.contains(col);
199 }
200 }
201 false
202}
203
204#[cfg(test)]
207mod tests {
208 use super::*;
209 use crate::{parse_one, plan_statement, JoinType, LogicalPlan, Optimizer};
210
211 fn equi_join_plan() -> LogicalPlan {
214 LogicalPlan::Join {
215 left: Box::new(LogicalPlan::Scan {
216 table: "a".to_string(),
217 alias: None,
218 limit: None,
219 }),
220 right: Box::new(LogicalPlan::Scan {
221 table: "b".to_string(),
222 alias: None,
223 limit: None,
224 }),
225 on: "a.id = b.id".to_string(),
226 join_type: JoinType::Inner,
227 algo_hint: None,
228 }
229 }
230
231 fn cross_join_plan() -> LogicalPlan {
233 LogicalPlan::Join {
234 left: Box::new(LogicalPlan::Scan {
235 table: "a".to_string(),
236 alias: None,
237 limit: None,
238 }),
239 right: Box::new(LogicalPlan::Scan {
240 table: "b".to_string(),
241 alias: None,
242 limit: None,
243 }),
244 on: String::new(),
245 join_type: JoinType::Cross,
246 algo_hint: None,
247 }
248 }
249
250 #[test]
252 fn test_equi_join_gets_hash_hint() {
253 let plan = equi_join_plan();
254 let result = JoinAlgorithmPass.apply(plan);
255 match result {
256 LogicalPlan::Join { algo_hint, .. } => {
257 assert_eq!(algo_hint, Some(JoinAlgoHint::HashJoin));
258 }
259 other => panic!("expected Join, got {other:?}"),
260 }
261 }
262
263 #[test]
265 fn test_cross_join_gets_nested_loop() {
266 let plan = cross_join_plan();
267 let result = JoinAlgorithmPass.apply(plan);
268 match result {
269 LogicalPlan::Join { algo_hint, .. } => {
270 assert_eq!(algo_hint, Some(JoinAlgoHint::NestedLoop));
271 }
272 other => panic!("expected Join, got {other:?}"),
273 }
274 }
275
276 #[test]
278 fn test_join_algorithm_pass_name() {
279 assert_eq!(JoinAlgorithmPass.name(), "join_algorithm");
280 }
281
282 #[test]
285 fn test_optimizer_pipeline_annotates_join() {
286 let stmt = parse_one("SELECT * FROM a JOIN b ON a.id = b.id").expect("parse");
287 let plan = plan_statement(&stmt).expect("plan");
288 let result = Optimizer::new().optimize(plan);
289
290 fn find_join_hint(p: &LogicalPlan) -> Option<Option<JoinAlgoHint>> {
291 match p {
292 LogicalPlan::Join {
293 algo_hint,
294 left,
295 right,
296 ..
297 } => {
298 let _ = (left, right); Some(algo_hint.clone())
301 }
302 LogicalPlan::Filter { input, .. }
303 | LogicalPlan::Project { input, .. }
304 | LogicalPlan::Sort { input, .. }
305 | LogicalPlan::Limit { input, .. }
306 | LogicalPlan::Aggregate { input, .. } => find_join_hint(input),
307 _ => None,
308 }
309 }
310
311 let hint = find_join_hint(&result);
312 assert!(
313 matches!(hint, Some(Some(JoinAlgoHint::HashJoin))),
314 "expected HashJoin hint in optimized plan, got {hint:?}"
315 );
316 }
317}