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::Concat { inputs, schema } => {
84            let mut flattened = Vec::new();
85            for input in inputs {
86                match predicate_pushdown(input) {
87                    LogicalPlan::Concat {
88                        inputs: nested,
89                        schema: nested_schema,
90                    } if nested_schema == schema => flattened.extend(nested),
91                    input => flattened.push(input),
92                }
93            }
94            LogicalPlan::Concat {
95                inputs: flattened,
96                schema,
97            }
98        }
99        LogicalPlan::Aggregate {
100            input,
101            group_by,
102            aggs,
103        } => LogicalPlan::Aggregate {
104            input: Box::new(predicate_pushdown(*input)),
105            group_by,
106            aggs,
107        },
108        LogicalPlan::Join {
109            left,
110            right,
111            keys,
112            how,
113        } => LogicalPlan::Join {
114            left: Box::new(predicate_pushdown(*left)),
115            right: Box::new(predicate_pushdown(*right)),
116            keys,
117            how,
118        },
119        LogicalPlan::Sort { input, options } => LogicalPlan::Sort {
120            input: Box::new(predicate_pushdown(*input)),
121            options,
122        },
123        LogicalPlan::Slice {
124            input,
125            offset,
126            len,
127            from_end,
128        } => LogicalPlan::Slice {
129            input: Box::new(predicate_pushdown(*input)),
130            offset,
131            len,
132            from_end,
133        },
134        LogicalPlan::Unique { input, subset } => LogicalPlan::Unique {
135            input: Box::new(predicate_pushdown(*input)),
136            subset,
137        },
138        LogicalPlan::FillNull { input, fill } => LogicalPlan::FillNull {
139            input: Box::new(predicate_pushdown(*input)),
140            fill,
141        },
142        LogicalPlan::DropNulls { input, subset } => LogicalPlan::DropNulls {
143            input: Box::new(predicate_pushdown(*input)),
144            subset,
145        },
146        LogicalPlan::NullCount { input } => LogicalPlan::NullCount {
147            input: Box::new(predicate_pushdown(*input)),
148        },
149        LogicalPlan::Explode { input, column } => LogicalPlan::Explode {
150            input: Box::new(predicate_pushdown(*input)),
151            column,
152        },
153        LogicalPlan::Implode { input } => LogicalPlan::Implode {
154            input: Box::new(predicate_pushdown(*input)),
155        },
156        other => other,
157    }
158}
159
160fn and_expr(left: Expr, right: Expr) -> Expr {
161    let mut conjuncts = Vec::new();
162    conjuncts.extend(flatten_and(left));
163    conjuncts.extend(flatten_and(right));
164    build_and(conjuncts)
165}
166
167fn flatten_and(expr: Expr) -> Vec<Expr> {
168    match expr {
169        E::BinaryOp {
170            left,
171            op: Operator::And,
172            right,
173        } => {
174            let mut out = flatten_and(*left);
175            out.extend(flatten_and(*right));
176            out
177        }
178        other => vec![other],
179    }
180}
181
182fn build_and(mut conjuncts: Vec<Expr>) -> Expr {
183    let first = conjuncts
184        .pop()
185        .expect("build_and must be called with non-empty conjuncts");
186    conjuncts
187        .into_iter()
188        .rev()
189        .fold(first, |acc, expr| E::BinaryOp {
190            left: Box::new(expr),
191            op: Operator::And,
192            right: Box::new(acc),
193        })
194}
195
196fn can_push_filter_through_projection(
197    predicate: &Expr,
198    exprs: &[Expr],
199    kind: &ProjectionKind,
200) -> bool {
201    let referenced = referenced_columns(predicate);
202
203    match kind {
204        ProjectionKind::Select => match projection_select_output_columns(exprs) {
205            OutputColumns::Some(cols) => referenced.is_subset(&cols),
206            OutputColumns::All | OutputColumns::Unknown => false,
207        },
208        ProjectionKind::WithColumns => {
209            let assigned = projection_assigned_columns(exprs);
210            !referenced.iter().any(|c| assigned.contains(c))
211        }
212    }
213}
214
215fn referenced_columns(expr: &Expr) -> HashSet<String> {
216    let mut out = HashSet::new();
217    collect_referenced_columns(expr, &mut out);
218    out
219}
220
221fn collect_referenced_columns(expr: &Expr, out: &mut HashSet<String>) {
222    match expr {
223        E::Column(name) => {
224            out.insert(name.clone());
225        }
226        E::Alias { expr, .. } => collect_referenced_columns(expr, out),
227        E::UnaryOp { expr, .. } => collect_referenced_columns(expr, out),
228        E::BinaryOp { left, right, .. } => {
229            collect_referenced_columns(left, out);
230            collect_referenced_columns(right, out);
231        }
232        E::Agg { expr, .. } => collect_referenced_columns(expr, out),
233        E::Function { input, .. } => collect_referenced_columns(input, out),
234        E::ConcatStr { inputs, .. } => {
235            for input in inputs {
236                collect_referenced_columns(input, out);
237            }
238        }
239        E::Literal(_) | E::Wildcard => {}
240    }
241}
242
243enum OutputColumns {
244    All,
245    Some(HashSet<String>),
246    Unknown,
247}
248
249fn projection_select_output_columns(exprs: &[Expr]) -> OutputColumns {
250    let mut cols = HashSet::new();
251    for expr in exprs {
252        match expr {
253            E::Wildcard => return OutputColumns::All,
254            E::Alias { name, .. } => {
255                cols.insert(name.clone());
256            }
257            E::Column(name) => {
258                cols.insert(name.clone());
259            }
260            _ => return OutputColumns::Unknown,
261        }
262    }
263    OutputColumns::Some(cols)
264}
265
266fn projection_assigned_columns(exprs: &[Expr]) -> HashSet<String> {
267    let mut cols = HashSet::new();
268    for expr in exprs {
269        match expr {
270            E::Alias { name, .. } => {
271                cols.insert(name.clone());
272            }
273            E::Column(name) => {
274                cols.insert(name.clone());
275            }
276            _ => {}
277        }
278    }
279    cols
280}
281
282fn projection_pushdown(plan: LogicalPlan) -> LogicalPlan {
283    projection_pushdown_inner(plan, RequiredColumns::All).0
284}
285
286#[derive(Debug, Clone)]
287enum RequiredColumns {
288    All,
289    Some(HashSet<String>),
290}
291
292impl RequiredColumns {
293    fn union(self, other: Self) -> Self {
294        match (self, other) {
295            (RequiredColumns::All, _) | (_, RequiredColumns::All) => RequiredColumns::All,
296            (RequiredColumns::Some(mut a), RequiredColumns::Some(b)) => {
297                a.extend(b);
298                RequiredColumns::Some(a)
299            }
300        }
301    }
302}
303
304fn projection_pushdown_inner(
305    plan: LogicalPlan,
306    required: RequiredColumns,
307) -> (LogicalPlan, RequiredColumns) {
308    match plan {
309        LogicalPlan::Projection { input, exprs, kind } => match kind {
310            ProjectionKind::Select => {
311                let input_required = required_columns_for_select(&exprs);
312                let (new_input, _) = projection_pushdown_inner(*input, input_required);
313                (
314                    LogicalPlan::Projection {
315                        input: Box::new(new_input),
316                        exprs,
317                        kind: ProjectionKind::Select,
318                    },
319                    required,
320                )
321            }
322            ProjectionKind::WithColumns => {
323                // Conservative: keep required columns and any inputs to compute overwritten/new columns
324                let mut needed = HashSet::new();
325                if let RequiredColumns::Some(ref req) = required {
326                    needed.extend(req.clone());
327                }
328                for expr in &exprs {
329                    match expr {
330                        E::Alias { expr, .. } => needed.extend(referenced_columns(expr)),
331                        E::Column(_) => {}
332                        _ => {}
333                    }
334                }
335                let (new_input, _) =
336                    projection_pushdown_inner(*input, RequiredColumns::Some(needed));
337                (
338                    LogicalPlan::Projection {
339                        input: Box::new(new_input),
340                        exprs,
341                        kind: ProjectionKind::WithColumns,
342                    },
343                    required,
344                )
345            }
346        },
347        LogicalPlan::Filter { input, predicate } => {
348            let input_required = required
349                .clone()
350                .union(RequiredColumns::Some(referenced_columns(&predicate)));
351            let (new_input, _) = projection_pushdown_inner(*input, input_required);
352            (
353                LogicalPlan::Filter {
354                    input: Box::new(new_input),
355                    predicate,
356                },
357                required,
358            )
359        }
360        // Keep the validated concat schema intact.  Pushing a downstream projection into
361        // individual inputs would require rewriting this node's schema as well as proving that
362        // every input still produces the identical narrowed schema.  That optimisation is not
363        // needed for v0.8 correctness, so recurse without narrowing each input.
364        LogicalPlan::Concat { inputs, schema } => (
365            LogicalPlan::Concat {
366                inputs: inputs
367                    .into_iter()
368                    .map(|input| projection_pushdown_inner(input, RequiredColumns::All).0)
369                    .collect(),
370                schema,
371            },
372            required,
373        ),
374        LogicalPlan::Aggregate {
375            input,
376            group_by,
377            aggs,
378        } => {
379            let mut needed = HashSet::new();
380            for e in group_by.iter().chain(aggs.iter()) {
381                needed.extend(referenced_columns(e));
382            }
383            let (new_input, _) = projection_pushdown_inner(*input, RequiredColumns::Some(needed));
384            (
385                LogicalPlan::Aggregate {
386                    input: Box::new(new_input),
387                    group_by,
388                    aggs,
389                },
390                required,
391            )
392        }
393        LogicalPlan::Join {
394            left,
395            right,
396            keys,
397            how,
398        } => {
399            let (new_left, _) = projection_pushdown_inner(*left, RequiredColumns::All);
400            let (new_right, _) = projection_pushdown_inner(*right, RequiredColumns::All);
401            (
402                LogicalPlan::Join {
403                    left: Box::new(new_left),
404                    right: Box::new(new_right),
405                    keys,
406                    how,
407                },
408                required,
409            )
410        }
411        LogicalPlan::Sort { input, options } => {
412            let (new_input, _) = projection_pushdown_inner(*input, required.clone());
413            (
414                LogicalPlan::Sort {
415                    input: Box::new(new_input),
416                    options,
417                },
418                required,
419            )
420        }
421        LogicalPlan::Slice {
422            input,
423            offset,
424            len,
425            from_end,
426        } => {
427            let (new_input, _) = projection_pushdown_inner(*input, required.clone());
428            (
429                LogicalPlan::Slice {
430                    input: Box::new(new_input),
431                    offset,
432                    len,
433                    from_end,
434                },
435                required,
436            )
437        }
438        LogicalPlan::Unique { input, subset } => {
439            let (new_input, _) = projection_pushdown_inner(*input, required.clone());
440            (
441                LogicalPlan::Unique {
442                    input: Box::new(new_input),
443                    subset,
444                },
445                required,
446            )
447        }
448        LogicalPlan::FillNull { input, fill } => {
449            let (new_input, _) = projection_pushdown_inner(*input, required.clone());
450            (
451                LogicalPlan::FillNull {
452                    input: Box::new(new_input),
453                    fill,
454                },
455                required,
456            )
457        }
458        LogicalPlan::DropNulls { input, subset } => {
459            let (new_input, _) = projection_pushdown_inner(*input, required.clone());
460            (
461                LogicalPlan::DropNulls {
462                    input: Box::new(new_input),
463                    subset,
464                },
465                required,
466            )
467        }
468        LogicalPlan::NullCount { input } => {
469            let (new_input, _) = projection_pushdown_inner(*input, RequiredColumns::All);
470            (
471                LogicalPlan::NullCount {
472                    input: Box::new(new_input),
473                },
474                RequiredColumns::All,
475            )
476        }
477        LogicalPlan::Explode { input, column } => {
478            let (new_input, _) = projection_pushdown_inner(*input, RequiredColumns::All);
479            (
480                LogicalPlan::Explode {
481                    input: Box::new(new_input),
482                    column,
483                },
484                RequiredColumns::All,
485            )
486        }
487        LogicalPlan::Implode { input } => {
488            let (new_input, _) = projection_pushdown_inner(*input, RequiredColumns::All);
489            (
490                LogicalPlan::Implode {
491                    input: Box::new(new_input),
492                },
493                RequiredColumns::All,
494            )
495        }
496        LogicalPlan::CsvScan {
497            path,
498            predicate,
499            projection,
500        } => {
501            let mut needed = match required {
502                RequiredColumns::All => None,
503                RequiredColumns::Some(s) => Some(s),
504            };
505            if let Some(pred) = &predicate {
506                let cols = referenced_columns(pred);
507                needed = Some(match needed {
508                    Some(mut s) => {
509                        s.extend(cols);
510                        s
511                    }
512                    None => cols,
513                });
514            }
515            (
516                LogicalPlan::CsvScan {
517                    path,
518                    predicate,
519                    projection: merge_projection(projection, needed),
520                },
521                RequiredColumns::All,
522            )
523        }
524        LogicalPlan::ParquetScan {
525            path,
526            predicate,
527            projection,
528        } => {
529            let mut needed = match required {
530                RequiredColumns::All => None,
531                RequiredColumns::Some(s) => Some(s),
532            };
533            if let Some(pred) = &predicate {
534                let cols = referenced_columns(pred);
535                needed = Some(match needed {
536                    Some(mut s) => {
537                        s.extend(cols);
538                        s
539                    }
540                    None => cols,
541                });
542            }
543            (
544                LogicalPlan::ParquetScan {
545                    path,
546                    predicate,
547                    projection: merge_projection(projection, needed),
548                },
549                RequiredColumns::All,
550            )
551        }
552        other => (other, RequiredColumns::All),
553    }
554}
555
556fn required_columns_for_select(exprs: &[Expr]) -> RequiredColumns {
557    let mut needed = HashSet::new();
558    for expr in exprs {
559        match expr {
560            E::Wildcard => return RequiredColumns::All,
561            other => needed.extend(referenced_columns(other)),
562        }
563    }
564    RequiredColumns::Some(needed)
565}
566
567fn merge_projection(
568    existing: Option<Vec<String>>,
569    needed: Option<HashSet<String>>,
570) -> Option<Vec<String>> {
571    let Some(needed) = needed else {
572        return existing;
573    };
574
575    let mut out = Vec::new();
576    let mut seen = HashSet::new();
577
578    if let Some(existing) = existing {
579        for c in existing {
580            if seen.insert(c.clone()) {
581                out.push(c);
582            }
583        }
584    }
585
586    for c in needed {
587        if seen.insert(c.clone()) {
588            out.push(c);
589        }
590    }
591
592    Some(out)
593}
594
595#[cfg(test)]
596mod tests {
597    use std::sync::Arc;
598
599    use arrow::datatypes::{DataType, Field, Schema};
600
601    use super::Optimizer;
602    use crate::expr::{col, lit};
603    use crate::lazy::LogicalPlan;
604    use crate::lazy::ProjectionKind;
605
606    #[test]
607    fn predicate_pushdown_moves_filter_into_scan() {
608        let plan = LogicalPlan::Filter {
609            input: Box::new(LogicalPlan::CsvScan {
610                path: "data.csv".into(),
611                predicate: None,
612                projection: None,
613            }),
614            predicate: col("a").gt(lit(1_i64)),
615        };
616
617        let optimized = Optimizer::optimize(&plan);
618        match optimized {
619            LogicalPlan::CsvScan { predicate, .. } => assert!(predicate.is_some()),
620            other => panic!("expected CsvScan, got {other:?}"),
621        }
622    }
623
624    #[test]
625    fn predicate_pushdown_combines_multiple_filters_with_and() {
626        let plan = LogicalPlan::Filter {
627            input: Box::new(LogicalPlan::Filter {
628                input: Box::new(LogicalPlan::CsvScan {
629                    path: "data.csv".into(),
630                    predicate: None,
631                    projection: None,
632                }),
633                predicate: col("a").gt(lit(1_i64)),
634            }),
635            predicate: col("b").lt(lit(10_i64)),
636        };
637
638        let optimized = Optimizer::optimize(&plan);
639        match optimized {
640            LogicalPlan::CsvScan {
641                predicate: Some(p), ..
642            } => {
643                let s = format!("{p:?}");
644                assert!(s.contains("And"));
645            }
646            other => panic!("expected CsvScan with predicate, got {other:?}"),
647        }
648    }
649
650    #[test]
651    fn predicate_pushdown_does_not_cross_select_when_column_not_selected() {
652        let plan = LogicalPlan::Filter {
653            input: Box::new(LogicalPlan::Projection {
654                input: Box::new(LogicalPlan::CsvScan {
655                    path: "data.csv".into(),
656                    predicate: None,
657                    projection: None,
658                }),
659                exprs: vec![col("a")],
660                kind: ProjectionKind::Select,
661            }),
662            predicate: col("b").gt(lit(1_i64)),
663        };
664
665        let optimized = Optimizer::optimize(&plan);
666        assert!(matches!(optimized, LogicalPlan::Filter { .. }));
667    }
668
669    #[test]
670    fn projection_pushdown_sets_scan_projection() {
671        let plan = LogicalPlan::Projection {
672            input: Box::new(LogicalPlan::CsvScan {
673                path: "data.csv".into(),
674                predicate: None,
675                projection: None,
676            }),
677            exprs: vec![col("a"), col("b")],
678            kind: ProjectionKind::Select,
679        };
680
681        let optimized = Optimizer::optimize(&plan);
682        let s = optimized.display();
683        assert!(s.contains("projection"));
684    }
685
686    #[test]
687    fn concat_optimizer_flattens_only_compatible_nested_inputs_in_order() {
688        let schema = Arc::new(Schema::new(vec![Field::new(
689            "value",
690            DataType::Int64,
691            true,
692        )]));
693        let scan = |path: &str| LogicalPlan::CsvScan {
694            path: path.into(),
695            predicate: None,
696            projection: None,
697        };
698        let plan = LogicalPlan::Concat {
699            inputs: vec![
700                LogicalPlan::Concat {
701                    inputs: vec![scan("first.csv"), scan("second.csv")],
702                    schema: Some(schema.clone()),
703                },
704                scan("third.csv"),
705            ],
706            schema: Some(schema),
707        };
708
709        let display = Optimizer::optimize(&plan).display();
710        assert!(display.contains("concat inputs=3"));
711        assert!(display.find("first.csv").unwrap() < display.find("second.csv").unwrap());
712        assert!(display.find("second.csv").unwrap() < display.find("third.csv").unwrap());
713    }
714}