Skip to main content

oxisql_parse/optimizer/
join_algo.rs

1//! Pass 5: Join algorithm selection.
2//!
3//! Annotates each [`LogicalPlan::Join`] node with a recommended physical
4//! algorithm hint:
5//!
6//! - [`JoinAlgoHint::HashJoin`]  — for equi-joins (predicate contains `=`
7//!   without `<` or `>`).
8//! - [`JoinAlgoHint::MergeJoin`] — for equi-joins where *both* sides are
9//!   already [`LogicalPlan::Sort`] nodes on the same key as the join column.
10//! - [`JoinAlgoHint::NestedLoop`] — fallback for cross-joins or non-equi
11//!   predicates.
12//!
13//! The hint is stored in the `algo_hint` field of each `Join` variant and can
14//! be consumed by downstream physical planning stages.
15
16use crate::{JoinType, LogicalPlan};
17
18use super::OptPass;
19
20// ── Types ─────────────────────────────────────────────────────────────────────
21
22/// Physical join algorithm hint assigned by [`JoinAlgorithmPass`].
23///
24/// The hint is stored in [`LogicalPlan::Join::algo_hint`] after the optimizer
25/// pass runs.  Downstream physical planners can inspect it to select the best
26/// join implementation.
27#[derive(Debug, Clone, PartialEq)]
28pub enum JoinAlgoHint {
29    /// Hash-join: build a hash table on one side and probe with the other.
30    /// Suitable for equi-joins.
31    HashJoin,
32    /// Merge-join: requires both sides already sorted on the join key.
33    MergeJoin,
34    /// Nested-loop join: the fallback for cross-joins and non-equi predicates.
35    NestedLoop,
36}
37
38// ── Public struct ────────────────────────────────────────────────────────────
39
40/// Optimizer pass that annotates each `Join` node with an algorithm hint.
41pub 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
53// ── Implementation ────────────────────────────────────────────────────────────
54
55/// Recursively walk `plan`; for each `Join` node set its `algo_hint`.
56fn 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            // Recurse into children first.
66            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        // Recurse into all other plan variants that have children.
81        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 nodes — nothing to recurse into.
153        leaf => leaf,
154    }
155}
156
157/// Determine the best algorithm hint for a join given its ON predicate and
158/// the physical shape of each input.
159fn choose_hint(
160    on: &str,
161    join_type: &JoinType,
162    left: &LogicalPlan,
163    right: &LogicalPlan,
164) -> JoinAlgoHint {
165    // Cross joins have no predicate → always nested-loop.
166    if matches!(join_type, JoinType::Cross) || on.is_empty() {
167        return JoinAlgoHint::NestedLoop;
168    }
169
170    // Equi-join heuristic: contains '=' but no inequality operators.
171    let is_equi = on.contains('=') && !on.contains('<') && !on.contains('>');
172
173    if !is_equi {
174        return JoinAlgoHint::NestedLoop;
175    }
176
177    // Try to promote to MergeJoin when both sides are already Sort nodes on
178    // a column that appears in the join predicate.
179    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
186/// Returns `true` when `plan` is a `Sort` whose first key column appears in
187/// the join predicate string — a simple heuristic that the data is sorted on
188/// the join key.
189fn 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            // Extract the bare column name (strip table-qualifier if present).
193            let col = first.column.rsplit('.').next().unwrap_or(&first.column);
194            return on.contains(col);
195        }
196    }
197    false
198}
199
200// ── Tests ─────────────────────────────────────────────────────────────────────
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::{parse_one, plan_statement, JoinType, LogicalPlan, Optimizer};
206
207    /// Build a simple equi-join plan directly (bypasses the SQL parser so the
208    /// test is deterministic about which plan tree is built).
209    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    /// Build a cross-join plan (no ON predicate).
228    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    /// An equi-join plan produced from SQL gets `HashJoin` hint.
247    #[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    /// A cross join (no ON clause) gets `NestedLoop` hint.
260    #[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    /// The pass name is exactly "join_algorithm".
273    #[test]
274    fn test_join_algorithm_pass_name() {
275        assert_eq!(JoinAlgorithmPass.name(), "join_algorithm");
276    }
277
278    /// The full optimizer pipeline (including JoinAlgorithmPass) sets a hint
279    /// on a join produced by `plan_statement`.
280    #[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                    // Return as soon as we find a Join.
295                    let _ = (left, right); // used only for structure check
296                    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}