Skip to main content

oxisql_parse/optimizer/
mod.rs

1//! Rule-based SQL query optimizer for [`LogicalPlan`].
2//!
3//! The optimizer applies a configurable sequence of rewrite passes to a
4//! [`LogicalPlan`] tree.  Each pass implements [`OptPass`] and is applied in
5//! order.  The default pipeline (built by [`Optimizer::new`]) includes:
6//!
7//! 1. [`PredicatePushdown`]  — push filters toward leaf scans
8//! 2. [`ProjectionPruning`]  — trim projections to the required column set
9//! 3. [`ConstantFolding`]    — evaluate literal expressions at compile time
10//! 4. [`LimitPushThrough`]   — push `LIMIT N` into single-table scans
11//! 5. [`JoinAlgorithmPass`]  — annotate `Join` nodes with an algorithm hint
12
13mod folding;
14pub mod join_algo;
15mod limit;
16mod pruning;
17mod pushdown;
18
19pub use folding::ConstantFolding;
20pub use join_algo::{JoinAlgoHint, JoinAlgorithmPass};
21pub use limit::LimitPushThrough;
22pub use pruning::ProjectionPruning;
23pub use pushdown::PredicatePushdown;
24
25use crate::LogicalPlan;
26
27// ── Trait ────────────────────────────────────────────────────────────────────
28
29/// A single optimizer rewrite rule.
30pub trait OptPass: Send + Sync {
31    /// Short name identifying this pass (for tracing / debugging).
32    fn name(&self) -> &'static str;
33
34    /// Apply the rewrite to `plan`, returning the (possibly modified) plan.
35    fn apply(&self, plan: LogicalPlan) -> LogicalPlan;
36}
37
38// ── Optimizer ────────────────────────────────────────────────────────────────
39
40/// Multi-pass rule-based optimizer.
41///
42/// Passes are applied left-to-right in the order they were registered.  The
43/// default pipeline is constructed by [`Optimizer::new`].
44pub struct Optimizer {
45    passes: Vec<Box<dyn OptPass>>,
46}
47
48impl Optimizer {
49    /// Build the default five-pass optimizer pipeline.
50    ///
51    /// The `ProjectionPruning` pass is created with an empty `required` set,
52    /// which means it operates as a no-op unless a caller explicitly builds an
53    /// optimizer with a non-empty requirement list via [`Optimizer::with_passes`].
54    pub fn new() -> Self {
55        let passes: Vec<Box<dyn OptPass>> = vec![
56            Box::new(PredicatePushdown),
57            Box::new(ProjectionPruning { required: vec![] }),
58            Box::new(ConstantFolding),
59            Box::new(LimitPushThrough),
60            Box::new(JoinAlgorithmPass),
61        ];
62        Self { passes }
63    }
64
65    /// Build an optimizer with a custom ordered list of passes.
66    pub fn with_passes(passes: Vec<Box<dyn OptPass>>) -> Self {
67        Self { passes }
68    }
69
70    /// Run all registered passes over `plan` in registration order.
71    pub fn optimize(&self, plan: LogicalPlan) -> LogicalPlan {
72        self.passes.iter().fold(plan, |p, pass| pass.apply(p))
73    }
74}
75
76impl Default for Optimizer {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82// ── Public convenience wrapper ────────────────────────────────────────────────
83
84/// Run all five default optimizer passes over `plan`.
85///
86/// This is the top-level convenience wrapper that creates a default
87/// [`Optimizer`] and applies it to the given plan.
88pub fn optimize(plan: LogicalPlan) -> LogicalPlan {
89    Optimizer::new().optimize(plan)
90}
91
92// ── Tests ─────────────────────────────────────────────────────────────────────
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::{JoinType, LogicalPlan};
98
99    // Helper: build an unbounded Scan node.
100    fn scan(table: &str) -> LogicalPlan {
101        LogicalPlan::Scan {
102            table: table.to_string(),
103            alias: None,
104            limit: None,
105        }
106    }
107
108    // Helper: build a named-alias Scan.
109    fn scan_alias(table: &str, alias: &str) -> LogicalPlan {
110        LogicalPlan::Scan {
111            table: table.to_string(),
112            alias: Some(alias.to_string()),
113            limit: None,
114        }
115    }
116
117    // Helper: build a Filter.
118    fn filter(input: LogicalPlan, pred: &str) -> LogicalPlan {
119        LogicalPlan::Filter {
120            input: Box::new(input),
121            predicate: pred.to_string(),
122        }
123    }
124
125    // Helper: build an inner Join.
126    fn join(left: LogicalPlan, right: LogicalPlan, on: &str) -> LogicalPlan {
127        LogicalPlan::Join {
128            left: Box::new(left),
129            right: Box::new(right),
130            on: on.to_string(),
131            join_type: JoinType::Inner,
132            algo_hint: None,
133        }
134    }
135
136    /// 1. A predicate referencing only the left side of an INNER join is pushed
137    ///    below the join onto the left scan.
138    #[test]
139    fn test_predicate_pushdown_moves_filter_below_join() {
140        // Filter(Join(Scan(a AS a), Scan(b AS b)), "a.x > 1")
141        let plan = filter(
142            join(scan_alias("a", "a"), scan_alias("b", "b"), "a.id = b.id"),
143            "a.x > 1",
144        );
145        let result = PredicatePushdown.apply(plan);
146
147        match result {
148            LogicalPlan::Join { left, .. } => match *left {
149                LogicalPlan::Filter { predicate, .. } => {
150                    assert_eq!(predicate, "a.x > 1");
151                }
152                other => panic!("expected Filter on left side, got {other:?}"),
153            },
154            other => panic!("expected Join at root after pushdown, got {other:?}"),
155        }
156    }
157
158    /// 2. Projection pruning reduces the column list to only required columns.
159    #[test]
160    fn test_projection_pruning_reduces_columns() {
161        let plan = LogicalPlan::Project {
162            input: Box::new(scan("t")),
163            columns: vec!["col1".to_string(), "col2".to_string(), "col3".to_string()],
164        };
165        let pass = ProjectionPruning {
166            required: vec!["col1".to_string()],
167        };
168        let result = pass.apply(plan);
169        match result {
170            LogicalPlan::Project { columns, .. } => {
171                assert_eq!(columns, vec!["col1".to_string()]);
172            }
173            other => panic!("expected Project, got {other:?}"),
174        }
175    }
176
177    /// 3. Constant-folding a `1 = 1` predicate removes the Filter entirely.
178    #[test]
179    fn test_constant_folding_true_removes_filter() {
180        let plan = filter(scan("t"), "1 = 1");
181        let result = ConstantFolding.apply(plan);
182        match result {
183            LogicalPlan::Scan { table, .. } => assert_eq!(table, "t"),
184            other => panic!("expected Scan after folding 1=1, got {other:?}"),
185        }
186    }
187
188    /// 4. Constant-folding a `1 = 2` predicate replaces the subtree with Empty.
189    #[test]
190    fn test_constant_folding_false_yields_empty() {
191        let plan = filter(scan("t"), "1 = 2");
192        let result = ConstantFolding.apply(plan);
193        assert!(
194            matches!(result, LogicalPlan::Empty),
195            "expected Empty after folding 1=2, got {result:?}"
196        );
197    }
198
199    /// 5. LimitPushThrough pushes `Limit(10)` into the Scan's `limit` field,
200    ///    removing the Limit wrapper node.
201    #[test]
202    fn test_limit_push_through_scan() {
203        let plan = LogicalPlan::Limit {
204            input: Box::new(scan("t")),
205            count: Some(10),
206            offset: None,
207        };
208        let result = LimitPushThrough.apply(plan);
209        match result {
210            LogicalPlan::Scan { limit, .. } => assert_eq!(limit, Some(10)),
211            other => panic!("expected Scan with limit after push-through, got {other:?}"),
212        }
213    }
214
215    /// 6. The full four-pass pipeline composes without errors.
216    #[test]
217    fn test_optimizer_pipeline_composes() {
218        // Build: Limit(Filter(Join(Scan(a AS a), Scan(b AS b)), "a.id > 0"), 5)
219        let base = filter(
220            join(scan_alias("a", "a"), scan_alias("b", "b"), "a.id = b.id"),
221            "a.id > 0",
222        );
223        let limited = LogicalPlan::Limit {
224            input: Box::new(base),
225            count: Some(5),
226            offset: None,
227        };
228        let result = Optimizer::new().optimize(limited);
229        let text = crate::explain(&result);
230        assert!(!text.is_empty(), "explain should produce non-empty output");
231        assert!(
232            text.contains("Join") || text.contains("Scan"),
233            "expected join or scan in plan: {text}"
234        );
235    }
236}