Skip to main content

krishiv_plan/optimizer/
join_reorder.rs

1//! Join-reordering logical optimizer rule.
2//!
3//! Reorders binary join inputs so that the smaller table (by `estimated_rows`)
4//! appears on the LEFT.  For left-deep join trees this minimises the size of
5//! intermediate results at each join step, reducing memory pressure and
6//! improving cache locality.
7//!
8//! The rule is a no-op for join types where input order affects semantics
9//! (`Left`, `Right`, `Full`, `Semi`, `Anti`).  Only `Inner` and `Cross` joins
10//! are commutative and can be safely reordered.
11//!
12//! **Important**: the rule operates on `estimated_rows` annotations that must
13//! already be present on the plan nodes.
14
15use std::collections::HashMap;
16
17use crate::{JoinType, LogicalPlan, NodeOp, PlanNode};
18
19use super::OptimizerRule;
20
21/// Logical optimizer rule that puts the smaller table on the left of commutative joins.
22///
23/// For each `Join { Inner }` or `Join { Cross }` node whose two inputs both
24/// carry `estimated_rows`, the rule checks whether swapping the inputs would
25/// place a smaller table on the left.  When it would, the inputs are swapped.
26///
27/// All other join types are left unchanged because input order is semantically
28/// meaningful for outer, semi, and anti joins.
29pub 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        // Build a map from node ID → estimated_rows for O(1) lookup.
40        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            // Only commutative joins can be reordered without changing semantics.
55            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            // Swap when the right input is strictly smaller than the left.
76            // After the swap the smaller table is on the left, which is the
77            // outer/driving side for nested-loop and sort-merge joins, and
78            // keeps left-deep join trees in ascending size order so that each
79            // successive join operates on the smallest available intermediate.
80            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// ── Tests ─────────────────────────────────────────────────────────────────────
104
105#[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    // ── Rule name ─────────────────────────────────────────────────────────────
141
142    #[test]
143    fn join_reorder_rule_name() {
144        assert_eq!(JoinReorderRule.name(), "join-reorder");
145    }
146
147    // ── No-op cases ───────────────────────────────────────────────────────────
148
149    #[test]
150    fn join_reorder_noop_when_already_ordered_correctly() {
151        // small(10) on left, large(1000) on right → already correct; no swap
152        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    // ── Swap cases ────────────────────────────────────────────────────────────
184
185    #[test]
186    fn join_reorder_swaps_when_right_is_smaller() {
187        // large(1000) on left, small(10) on right → swap so small is on left
188        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    // ── Non-commutative joins must not be reordered ───────────────────────────
223
224    #[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    // ── Multi-join plan ───────────────────────────────────────────────────────
280
281    #[test]
282    fn join_reorder_reorders_multiple_joins_in_one_pass() {
283        // Three tables: a=100, b=10000, c=50
284        // joins: j1 = (a JOIN b), j2 = (j1 JOIN c)
285        // After rule:
286        //   j1: a(100) < b(10000) → already correct (no swap in j1)
287        //   j2: j1 has an estimated_rows from join estimate; c=50
288        //       If j1.estimated_rows > 50 then c goes to left
289        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        // j1 has no estimated_rows set → no swap in j2 based on j1
297        // c(50) on right, j1(unknown) on left → right has a known estimate (50)
298        // left has unknown estimate → left_rows = u64::MAX → right < left → swap
299        let result = JoinReorderRule.apply(&plan).expect("should swap j2");
300        let j2 = result.nodes().iter().find(|n| n.id() == "j2").unwrap();
301        // c (right, rows=50) should have moved to left since j1 has no estimate (→ u64::MAX)
302        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        // left has no estimate (→ u64::MAX), right has estimate 100
320        // u64::MAX vs 100 → right is much smaller → swap
321        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}