oxisql_parse/optimizer/
mod.rs1mod 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
27pub trait OptPass: Send + Sync {
31 fn name(&self) -> &'static str;
33
34 fn apply(&self, plan: LogicalPlan) -> LogicalPlan;
36}
37
38pub struct Optimizer {
45 passes: Vec<Box<dyn OptPass>>,
46}
47
48impl Optimizer {
49 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 pub fn with_passes(passes: Vec<Box<dyn OptPass>>) -> Self {
67 Self { passes }
68 }
69
70 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
82pub fn optimize(plan: LogicalPlan) -> LogicalPlan {
89 Optimizer::new().optimize(plan)
90}
91
92#[cfg(test)]
95mod tests {
96 use super::*;
97 use crate::{JoinType, LogicalPlan};
98
99 fn scan(table: &str) -> LogicalPlan {
101 LogicalPlan::Scan {
102 table: table.to_string(),
103 alias: None,
104 limit: None,
105 }
106 }
107
108 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 fn filter(input: LogicalPlan, pred: &str) -> LogicalPlan {
119 LogicalPlan::Filter {
120 input: Box::new(input),
121 predicate: pred.to_string(),
122 }
123 }
124
125 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 #[test]
139 fn test_predicate_pushdown_moves_filter_below_join() {
140 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 #[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 #[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 #[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 #[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 #[test]
217 fn test_optimizer_pipeline_composes() {
218 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}