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        LogicalPlan::Compute { input, bindings } => LogicalPlan::Compute {
153            input: Box::new(annotate_join(*input)),
154            bindings,
155        },
156        // Leaf nodes — nothing to recurse into.
157        leaf => leaf,
158    }
159}
160
161/// Determine the best algorithm hint for a join given its ON predicate and
162/// the physical shape of each input.
163fn choose_hint(
164    on: &str,
165    join_type: &JoinType,
166    left: &LogicalPlan,
167    right: &LogicalPlan,
168) -> JoinAlgoHint {
169    // Cross joins have no predicate → always nested-loop.
170    if matches!(join_type, JoinType::Cross) || on.is_empty() {
171        return JoinAlgoHint::NestedLoop;
172    }
173
174    // Equi-join heuristic: contains '=' but no inequality operators.
175    let is_equi = on.contains('=') && !on.contains('<') && !on.contains('>');
176
177    if !is_equi {
178        return JoinAlgoHint::NestedLoop;
179    }
180
181    // Try to promote to MergeJoin when both sides are already Sort nodes on
182    // a column that appears in the join predicate.
183    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
190/// Returns `true` when `plan` is a `Sort` whose first key column appears in
191/// the join predicate string — a simple heuristic that the data is sorted on
192/// the join key.
193fn 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            // Extract the bare column name (strip table-qualifier if present).
197            let col = first.column.rsplit('.').next().unwrap_or(&first.column);
198            return on.contains(col);
199        }
200    }
201    false
202}
203
204// ── Tests ─────────────────────────────────────────────────────────────────────
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::{parse_one, plan_statement, JoinType, LogicalPlan, Optimizer};
210
211    /// Build a simple equi-join plan directly (bypasses the SQL parser so the
212    /// test is deterministic about which plan tree is built).
213    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    /// Build a cross-join plan (no ON predicate).
232    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    /// An equi-join plan produced from SQL gets `HashJoin` hint.
251    #[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    /// A cross join (no ON clause) gets `NestedLoop` hint.
264    #[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    /// The pass name is exactly "join_algorithm".
277    #[test]
278    fn test_join_algorithm_pass_name() {
279        assert_eq!(JoinAlgorithmPass.name(), "join_algorithm");
280    }
281
282    /// The full optimizer pipeline (including JoinAlgorithmPass) sets a hint
283    /// on a join produced by `plan_statement`.
284    #[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                    // Return as soon as we find a Join.
299                    let _ = (left, right); // used only for structure check
300                    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}