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 leaf => leaf,
154 }
155}
156
157fn choose_hint(
160 on: &str,
161 join_type: &JoinType,
162 left: &LogicalPlan,
163 right: &LogicalPlan,
164) -> JoinAlgoHint {
165 if matches!(join_type, JoinType::Cross) || on.is_empty() {
167 return JoinAlgoHint::NestedLoop;
168 }
169
170 let is_equi = on.contains('=') && !on.contains('<') && !on.contains('>');
172
173 if !is_equi {
174 return JoinAlgoHint::NestedLoop;
175 }
176
177 if sides_sorted_on_join_key(on, left) && sides_sorted_on_join_key(on, right) {
180 return JoinAlgoHint::MergeJoin;
181 }
182
183 JoinAlgoHint::HashJoin
184}
185
186fn sides_sorted_on_join_key(on: &str, plan: &LogicalPlan) -> bool {
190 if let LogicalPlan::Sort { order_by, .. } = plan {
191 if let Some(first) = order_by.first() {
192 let col = first.column.rsplit('.').next().unwrap_or(&first.column);
194 return on.contains(col);
195 }
196 }
197 false
198}
199
200#[cfg(test)]
203mod tests {
204 use super::*;
205 use crate::{parse_one, plan_statement, JoinType, LogicalPlan, Optimizer};
206
207 fn equi_join_plan() -> LogicalPlan {
210 LogicalPlan::Join {
211 left: Box::new(LogicalPlan::Scan {
212 table: "a".to_string(),
213 alias: None,
214 limit: None,
215 }),
216 right: Box::new(LogicalPlan::Scan {
217 table: "b".to_string(),
218 alias: None,
219 limit: None,
220 }),
221 on: "a.id = b.id".to_string(),
222 join_type: JoinType::Inner,
223 algo_hint: None,
224 }
225 }
226
227 fn cross_join_plan() -> LogicalPlan {
229 LogicalPlan::Join {
230 left: Box::new(LogicalPlan::Scan {
231 table: "a".to_string(),
232 alias: None,
233 limit: None,
234 }),
235 right: Box::new(LogicalPlan::Scan {
236 table: "b".to_string(),
237 alias: None,
238 limit: None,
239 }),
240 on: String::new(),
241 join_type: JoinType::Cross,
242 algo_hint: None,
243 }
244 }
245
246 #[test]
248 fn test_equi_join_gets_hash_hint() {
249 let plan = equi_join_plan();
250 let result = JoinAlgorithmPass.apply(plan);
251 match result {
252 LogicalPlan::Join { algo_hint, .. } => {
253 assert_eq!(algo_hint, Some(JoinAlgoHint::HashJoin));
254 }
255 other => panic!("expected Join, got {other:?}"),
256 }
257 }
258
259 #[test]
261 fn test_cross_join_gets_nested_loop() {
262 let plan = cross_join_plan();
263 let result = JoinAlgorithmPass.apply(plan);
264 match result {
265 LogicalPlan::Join { algo_hint, .. } => {
266 assert_eq!(algo_hint, Some(JoinAlgoHint::NestedLoop));
267 }
268 other => panic!("expected Join, got {other:?}"),
269 }
270 }
271
272 #[test]
274 fn test_join_algorithm_pass_name() {
275 assert_eq!(JoinAlgorithmPass.name(), "join_algorithm");
276 }
277
278 #[test]
281 fn test_optimizer_pipeline_annotates_join() {
282 let stmt = parse_one("SELECT * FROM a JOIN b ON a.id = b.id").expect("parse");
283 let plan = plan_statement(&stmt).expect("plan");
284 let result = Optimizer::new().optimize(plan);
285
286 fn find_join_hint(p: &LogicalPlan) -> Option<Option<JoinAlgoHint>> {
287 match p {
288 LogicalPlan::Join {
289 algo_hint,
290 left,
291 right,
292 ..
293 } => {
294 let _ = (left, right); Some(algo_hint.clone())
297 }
298 LogicalPlan::Filter { input, .. }
299 | LogicalPlan::Project { input, .. }
300 | LogicalPlan::Sort { input, .. }
301 | LogicalPlan::Limit { input, .. }
302 | LogicalPlan::Aggregate { input, .. } => find_join_hint(input),
303 _ => None,
304 }
305 }
306
307 let hint = find_join_hint(&result);
308 assert!(
309 matches!(hint, Some(Some(JoinAlgoHint::HashJoin))),
310 "expected HashJoin hint in optimized plan, got {hint:?}"
311 );
312 }
313}