Skip to main content

alopex_dataframe/lazy/
optimizer.rs

1use std::collections::HashSet;
2
3use crate::expr::{Expr as E, Operator};
4use crate::lazy::{LogicalPlan, ProjectionKind};
5use crate::Expr;
6
7/// Optimizer that rewrites `LogicalPlan` (e.g. predicate/projection pushdown).
8pub struct Optimizer;
9
10impl Optimizer {
11    /// Optimize a `LogicalPlan` and return the rewritten plan.
12    pub fn optimize(plan: &LogicalPlan) -> LogicalPlan {
13        let plan = predicate_pushdown(plan.clone());
14        projection_pushdown(plan)
15    }
16}
17
18fn predicate_pushdown(plan: LogicalPlan) -> LogicalPlan {
19    match plan {
20        LogicalPlan::Filter { input, predicate } => {
21            let input = predicate_pushdown(*input);
22
23            match input {
24                LogicalPlan::Filter {
25                    input: inner,
26                    predicate: inner_predicate,
27                } => {
28                    let combined = and_expr(inner_predicate, predicate);
29                    predicate_pushdown(LogicalPlan::Filter {
30                        input: inner,
31                        predicate: combined,
32                    })
33                }
34                LogicalPlan::Projection { input, exprs, kind } => {
35                    if can_push_filter_through_projection(&predicate, &exprs, &kind) {
36                        predicate_pushdown(LogicalPlan::Projection {
37                            input: Box::new(LogicalPlan::Filter { input, predicate }),
38                            exprs,
39                            kind,
40                        })
41                    } else {
42                        LogicalPlan::Filter {
43                            input: Box::new(LogicalPlan::Projection { input, exprs, kind }),
44                            predicate,
45                        }
46                    }
47                }
48                LogicalPlan::CsvScan {
49                    path,
50                    predicate: existing,
51                    projection,
52                } => LogicalPlan::CsvScan {
53                    path,
54                    predicate: Some(match existing {
55                        Some(existing) => and_expr(existing, predicate),
56                        None => predicate,
57                    }),
58                    projection,
59                },
60                LogicalPlan::ParquetScan {
61                    path,
62                    predicate: existing,
63                    projection,
64                } => LogicalPlan::ParquetScan {
65                    path,
66                    predicate: Some(match existing {
67                        Some(existing) => and_expr(existing, predicate),
68                        None => predicate,
69                    }),
70                    projection,
71                },
72                other => LogicalPlan::Filter {
73                    input: Box::new(other),
74                    predicate,
75                },
76            }
77        }
78        LogicalPlan::Projection { input, exprs, kind } => LogicalPlan::Projection {
79            input: Box::new(predicate_pushdown(*input)),
80            exprs,
81            kind,
82        },
83        LogicalPlan::Aggregate {
84            input,
85            group_by,
86            aggs,
87        } => LogicalPlan::Aggregate {
88            input: Box::new(predicate_pushdown(*input)),
89            group_by,
90            aggs,
91        },
92        LogicalPlan::Join {
93            left,
94            right,
95            keys,
96            how,
97        } => LogicalPlan::Join {
98            left: Box::new(predicate_pushdown(*left)),
99            right: Box::new(predicate_pushdown(*right)),
100            keys,
101            how,
102        },
103        LogicalPlan::Sort { input, options } => LogicalPlan::Sort {
104            input: Box::new(predicate_pushdown(*input)),
105            options,
106        },
107        LogicalPlan::Slice {
108            input,
109            offset,
110            len,
111            from_end,
112        } => LogicalPlan::Slice {
113            input: Box::new(predicate_pushdown(*input)),
114            offset,
115            len,
116            from_end,
117        },
118        LogicalPlan::Unique { input, subset } => LogicalPlan::Unique {
119            input: Box::new(predicate_pushdown(*input)),
120            subset,
121        },
122        LogicalPlan::FillNull { input, fill } => LogicalPlan::FillNull {
123            input: Box::new(predicate_pushdown(*input)),
124            fill,
125        },
126        LogicalPlan::DropNulls { input, subset } => LogicalPlan::DropNulls {
127            input: Box::new(predicate_pushdown(*input)),
128            subset,
129        },
130        LogicalPlan::NullCount { input } => LogicalPlan::NullCount {
131            input: Box::new(predicate_pushdown(*input)),
132        },
133        LogicalPlan::Explode { input, column } => LogicalPlan::Explode {
134            input: Box::new(predicate_pushdown(*input)),
135            column,
136        },
137        LogicalPlan::Implode { input } => LogicalPlan::Implode {
138            input: Box::new(predicate_pushdown(*input)),
139        },
140        other => other,
141    }
142}
143
144fn and_expr(left: Expr, right: Expr) -> Expr {
145    let mut conjuncts = Vec::new();
146    conjuncts.extend(flatten_and(left));
147    conjuncts.extend(flatten_and(right));
148    build_and(conjuncts)
149}
150
151fn flatten_and(expr: Expr) -> Vec<Expr> {
152    match expr {
153        E::BinaryOp {
154            left,
155            op: Operator::And,
156            right,
157        } => {
158            let mut out = flatten_and(*left);
159            out.extend(flatten_and(*right));
160            out
161        }
162        other => vec![other],
163    }
164}
165
166fn build_and(mut conjuncts: Vec<Expr>) -> Expr {
167    let first = conjuncts
168        .pop()
169        .expect("build_and must be called with non-empty conjuncts");
170    conjuncts
171        .into_iter()
172        .rev()
173        .fold(first, |acc, expr| E::BinaryOp {
174            left: Box::new(expr),
175            op: Operator::And,
176            right: Box::new(acc),
177        })
178}
179
180fn can_push_filter_through_projection(
181    predicate: &Expr,
182    exprs: &[Expr],
183    kind: &ProjectionKind,
184) -> bool {
185    let referenced = referenced_columns(predicate);
186
187    match kind {
188        ProjectionKind::Select => match projection_select_output_columns(exprs) {
189            OutputColumns::Some(cols) => referenced.is_subset(&cols),
190            OutputColumns::All | OutputColumns::Unknown => false,
191        },
192        ProjectionKind::WithColumns => {
193            let assigned = projection_assigned_columns(exprs);
194            !referenced.iter().any(|c| assigned.contains(c))
195        }
196    }
197}
198
199fn referenced_columns(expr: &Expr) -> HashSet<String> {
200    let mut out = HashSet::new();
201    collect_referenced_columns(expr, &mut out);
202    out
203}
204
205fn collect_referenced_columns(expr: &Expr, out: &mut HashSet<String>) {
206    match expr {
207        E::Column(name) => {
208            out.insert(name.clone());
209        }
210        E::Alias { expr, .. } => collect_referenced_columns(expr, out),
211        E::UnaryOp { expr, .. } => collect_referenced_columns(expr, out),
212        E::BinaryOp { left, right, .. } => {
213            collect_referenced_columns(left, out);
214            collect_referenced_columns(right, out);
215        }
216        E::Agg { expr, .. } => collect_referenced_columns(expr, out),
217        E::Function { input, .. } => collect_referenced_columns(input, out),
218        E::Literal(_) | E::Wildcard => {}
219    }
220}
221
222enum OutputColumns {
223    All,
224    Some(HashSet<String>),
225    Unknown,
226}
227
228fn projection_select_output_columns(exprs: &[Expr]) -> OutputColumns {
229    let mut cols = HashSet::new();
230    for expr in exprs {
231        match expr {
232            E::Wildcard => return OutputColumns::All,
233            E::Alias { name, .. } => {
234                cols.insert(name.clone());
235            }
236            E::Column(name) => {
237                cols.insert(name.clone());
238            }
239            _ => return OutputColumns::Unknown,
240        }
241    }
242    OutputColumns::Some(cols)
243}
244
245fn projection_assigned_columns(exprs: &[Expr]) -> HashSet<String> {
246    let mut cols = HashSet::new();
247    for expr in exprs {
248        match expr {
249            E::Alias { name, .. } => {
250                cols.insert(name.clone());
251            }
252            E::Column(name) => {
253                cols.insert(name.clone());
254            }
255            _ => {}
256        }
257    }
258    cols
259}
260
261fn projection_pushdown(plan: LogicalPlan) -> LogicalPlan {
262    projection_pushdown_inner(plan, RequiredColumns::All).0
263}
264
265#[derive(Debug, Clone)]
266enum RequiredColumns {
267    All,
268    Some(HashSet<String>),
269}
270
271impl RequiredColumns {
272    fn union(self, other: Self) -> Self {
273        match (self, other) {
274            (RequiredColumns::All, _) | (_, RequiredColumns::All) => RequiredColumns::All,
275            (RequiredColumns::Some(mut a), RequiredColumns::Some(b)) => {
276                a.extend(b);
277                RequiredColumns::Some(a)
278            }
279        }
280    }
281}
282
283fn projection_pushdown_inner(
284    plan: LogicalPlan,
285    required: RequiredColumns,
286) -> (LogicalPlan, RequiredColumns) {
287    match plan {
288        LogicalPlan::Projection { input, exprs, kind } => match kind {
289            ProjectionKind::Select => {
290                let input_required = required_columns_for_select(&exprs);
291                let (new_input, _) = projection_pushdown_inner(*input, input_required);
292                (
293                    LogicalPlan::Projection {
294                        input: Box::new(new_input),
295                        exprs,
296                        kind: ProjectionKind::Select,
297                    },
298                    required,
299                )
300            }
301            ProjectionKind::WithColumns => {
302                // Conservative: keep required columns and any inputs to compute overwritten/new columns
303                let mut needed = HashSet::new();
304                if let RequiredColumns::Some(ref req) = required {
305                    needed.extend(req.clone());
306                }
307                for expr in &exprs {
308                    match expr {
309                        E::Alias { expr, .. } => needed.extend(referenced_columns(expr)),
310                        E::Column(_) => {}
311                        _ => {}
312                    }
313                }
314                let (new_input, _) =
315                    projection_pushdown_inner(*input, RequiredColumns::Some(needed));
316                (
317                    LogicalPlan::Projection {
318                        input: Box::new(new_input),
319                        exprs,
320                        kind: ProjectionKind::WithColumns,
321                    },
322                    required,
323                )
324            }
325        },
326        LogicalPlan::Filter { input, predicate } => {
327            let input_required = required
328                .clone()
329                .union(RequiredColumns::Some(referenced_columns(&predicate)));
330            let (new_input, _) = projection_pushdown_inner(*input, input_required);
331            (
332                LogicalPlan::Filter {
333                    input: Box::new(new_input),
334                    predicate,
335                },
336                required,
337            )
338        }
339        LogicalPlan::Aggregate {
340            input,
341            group_by,
342            aggs,
343        } => {
344            let mut needed = HashSet::new();
345            for e in group_by.iter().chain(aggs.iter()) {
346                needed.extend(referenced_columns(e));
347            }
348            let (new_input, _) = projection_pushdown_inner(*input, RequiredColumns::Some(needed));
349            (
350                LogicalPlan::Aggregate {
351                    input: Box::new(new_input),
352                    group_by,
353                    aggs,
354                },
355                required,
356            )
357        }
358        LogicalPlan::Join {
359            left,
360            right,
361            keys,
362            how,
363        } => {
364            let (new_left, _) = projection_pushdown_inner(*left, RequiredColumns::All);
365            let (new_right, _) = projection_pushdown_inner(*right, RequiredColumns::All);
366            (
367                LogicalPlan::Join {
368                    left: Box::new(new_left),
369                    right: Box::new(new_right),
370                    keys,
371                    how,
372                },
373                required,
374            )
375        }
376        LogicalPlan::Sort { input, options } => {
377            let (new_input, _) = projection_pushdown_inner(*input, required.clone());
378            (
379                LogicalPlan::Sort {
380                    input: Box::new(new_input),
381                    options,
382                },
383                required,
384            )
385        }
386        LogicalPlan::Slice {
387            input,
388            offset,
389            len,
390            from_end,
391        } => {
392            let (new_input, _) = projection_pushdown_inner(*input, required.clone());
393            (
394                LogicalPlan::Slice {
395                    input: Box::new(new_input),
396                    offset,
397                    len,
398                    from_end,
399                },
400                required,
401            )
402        }
403        LogicalPlan::Unique { input, subset } => {
404            let (new_input, _) = projection_pushdown_inner(*input, required.clone());
405            (
406                LogicalPlan::Unique {
407                    input: Box::new(new_input),
408                    subset,
409                },
410                required,
411            )
412        }
413        LogicalPlan::FillNull { input, fill } => {
414            let (new_input, _) = projection_pushdown_inner(*input, required.clone());
415            (
416                LogicalPlan::FillNull {
417                    input: Box::new(new_input),
418                    fill,
419                },
420                required,
421            )
422        }
423        LogicalPlan::DropNulls { input, subset } => {
424            let (new_input, _) = projection_pushdown_inner(*input, required.clone());
425            (
426                LogicalPlan::DropNulls {
427                    input: Box::new(new_input),
428                    subset,
429                },
430                required,
431            )
432        }
433        LogicalPlan::NullCount { input } => {
434            let (new_input, _) = projection_pushdown_inner(*input, RequiredColumns::All);
435            (
436                LogicalPlan::NullCount {
437                    input: Box::new(new_input),
438                },
439                RequiredColumns::All,
440            )
441        }
442        LogicalPlan::Explode { input, column } => {
443            let (new_input, _) = projection_pushdown_inner(*input, RequiredColumns::All);
444            (
445                LogicalPlan::Explode {
446                    input: Box::new(new_input),
447                    column,
448                },
449                RequiredColumns::All,
450            )
451        }
452        LogicalPlan::Implode { input } => {
453            let (new_input, _) = projection_pushdown_inner(*input, RequiredColumns::All);
454            (
455                LogicalPlan::Implode {
456                    input: Box::new(new_input),
457                },
458                RequiredColumns::All,
459            )
460        }
461        LogicalPlan::CsvScan {
462            path,
463            predicate,
464            projection,
465        } => {
466            let mut needed = match required {
467                RequiredColumns::All => None,
468                RequiredColumns::Some(s) => Some(s),
469            };
470            if let Some(pred) = &predicate {
471                let cols = referenced_columns(pred);
472                needed = Some(match needed {
473                    Some(mut s) => {
474                        s.extend(cols);
475                        s
476                    }
477                    None => cols,
478                });
479            }
480            (
481                LogicalPlan::CsvScan {
482                    path,
483                    predicate,
484                    projection: merge_projection(projection, needed),
485                },
486                RequiredColumns::All,
487            )
488        }
489        LogicalPlan::ParquetScan {
490            path,
491            predicate,
492            projection,
493        } => {
494            let mut needed = match required {
495                RequiredColumns::All => None,
496                RequiredColumns::Some(s) => Some(s),
497            };
498            if let Some(pred) = &predicate {
499                let cols = referenced_columns(pred);
500                needed = Some(match needed {
501                    Some(mut s) => {
502                        s.extend(cols);
503                        s
504                    }
505                    None => cols,
506                });
507            }
508            (
509                LogicalPlan::ParquetScan {
510                    path,
511                    predicate,
512                    projection: merge_projection(projection, needed),
513                },
514                RequiredColumns::All,
515            )
516        }
517        other => (other, RequiredColumns::All),
518    }
519}
520
521fn required_columns_for_select(exprs: &[Expr]) -> RequiredColumns {
522    let mut needed = HashSet::new();
523    for expr in exprs {
524        match expr {
525            E::Wildcard => return RequiredColumns::All,
526            other => needed.extend(referenced_columns(other)),
527        }
528    }
529    RequiredColumns::Some(needed)
530}
531
532fn merge_projection(
533    existing: Option<Vec<String>>,
534    needed: Option<HashSet<String>>,
535) -> Option<Vec<String>> {
536    let Some(needed) = needed else {
537        return existing;
538    };
539
540    let mut out = Vec::new();
541    let mut seen = HashSet::new();
542
543    if let Some(existing) = existing {
544        for c in existing {
545            if seen.insert(c.clone()) {
546                out.push(c);
547            }
548        }
549    }
550
551    for c in needed {
552        if seen.insert(c.clone()) {
553            out.push(c);
554        }
555    }
556
557    Some(out)
558}
559
560#[cfg(test)]
561mod tests {
562    use super::Optimizer;
563    use crate::expr::{col, lit};
564    use crate::lazy::LogicalPlan;
565    use crate::lazy::ProjectionKind;
566
567    #[test]
568    fn predicate_pushdown_moves_filter_into_scan() {
569        let plan = LogicalPlan::Filter {
570            input: Box::new(LogicalPlan::CsvScan {
571                path: "data.csv".into(),
572                predicate: None,
573                projection: None,
574            }),
575            predicate: col("a").gt(lit(1_i64)),
576        };
577
578        let optimized = Optimizer::optimize(&plan);
579        match optimized {
580            LogicalPlan::CsvScan { predicate, .. } => assert!(predicate.is_some()),
581            other => panic!("expected CsvScan, got {other:?}"),
582        }
583    }
584
585    #[test]
586    fn predicate_pushdown_combines_multiple_filters_with_and() {
587        let plan = LogicalPlan::Filter {
588            input: Box::new(LogicalPlan::Filter {
589                input: Box::new(LogicalPlan::CsvScan {
590                    path: "data.csv".into(),
591                    predicate: None,
592                    projection: None,
593                }),
594                predicate: col("a").gt(lit(1_i64)),
595            }),
596            predicate: col("b").lt(lit(10_i64)),
597        };
598
599        let optimized = Optimizer::optimize(&plan);
600        match optimized {
601            LogicalPlan::CsvScan {
602                predicate: Some(p), ..
603            } => {
604                let s = format!("{p:?}");
605                assert!(s.contains("And"));
606            }
607            other => panic!("expected CsvScan with predicate, got {other:?}"),
608        }
609    }
610
611    #[test]
612    fn predicate_pushdown_does_not_cross_select_when_column_not_selected() {
613        let plan = LogicalPlan::Filter {
614            input: Box::new(LogicalPlan::Projection {
615                input: Box::new(LogicalPlan::CsvScan {
616                    path: "data.csv".into(),
617                    predicate: None,
618                    projection: None,
619                }),
620                exprs: vec![col("a")],
621                kind: ProjectionKind::Select,
622            }),
623            predicate: col("b").gt(lit(1_i64)),
624        };
625
626        let optimized = Optimizer::optimize(&plan);
627        assert!(matches!(optimized, LogicalPlan::Filter { .. }));
628    }
629
630    #[test]
631    fn projection_pushdown_sets_scan_projection() {
632        let plan = LogicalPlan::Projection {
633            input: Box::new(LogicalPlan::CsvScan {
634                path: "data.csv".into(),
635                predicate: None,
636                projection: None,
637            }),
638            exprs: vec![col("a"), col("b")],
639            kind: ProjectionKind::Select,
640        };
641
642        let optimized = Optimizer::optimize(&plan);
643        let s = optimized.display();
644        assert!(s.contains("projection"));
645    }
646}