Skip to main content

alopex_dataframe/lazy/
logical_plan.rs

1use std::path::PathBuf;
2
3use arrow::datatypes::SchemaRef;
4
5use crate::ops::{FillNull, JoinKeys, JoinType, SortOptions};
6use crate::{DataFrame, DataFrameError, Expr, Result};
7
8/// How a projection node should be interpreted.
9#[derive(Debug, Clone)]
10pub enum ProjectionKind {
11    /// Select columns/expressions, producing a new schema.
12    Select,
13    /// Add or overwrite columns, preserving existing columns.
14    WithColumns,
15}
16
17/// Logical query plan nodes for `LazyFrame`.
18#[derive(Debug, Clone)]
19pub enum LogicalPlan {
20    /// Scan an in-memory `DataFrame`.
21    DataFrameScan { df: DataFrame },
22    /// Scan a CSV file (predicate/projection may be pushed down).
23    CsvScan {
24        path: PathBuf,
25        predicate: Option<Expr>,
26        projection: Option<Vec<String>>,
27    },
28    /// Scan a Parquet file (predicate/projection may be pushed down).
29    ParquetScan {
30        path: PathBuf,
31        predicate: Option<Expr>,
32        projection: Option<Vec<String>>,
33    },
34    /// Strict vertical concatenation of two or more compatible inputs.
35    Concat {
36        /// Inputs in declared output order.
37        inputs: Vec<LogicalPlan>,
38        /// Validated common schema, or deferred until bounded source preflight for lazy scans.
39        schema: Option<SchemaRef>,
40    },
41    /// Projection node (select or with_columns).
42    Projection {
43        input: Box<LogicalPlan>,
44        exprs: Vec<Expr>,
45        kind: ProjectionKind,
46    },
47    /// Filter node.
48    Filter {
49        input: Box<LogicalPlan>,
50        predicate: Expr,
51    },
52    /// Aggregate node (group keys and aggregations).
53    Aggregate {
54        input: Box<LogicalPlan>,
55        group_by: Vec<Expr>,
56        aggs: Vec<Expr>,
57    },
58    /// Join two inputs.
59    Join {
60        left: Box<LogicalPlan>,
61        right: Box<LogicalPlan>,
62        keys: JoinKeys,
63        how: JoinType,
64    },
65    /// Sort input rows.
66    Sort {
67        input: Box<LogicalPlan>,
68        options: SortOptions,
69    },
70    /// Slice rows (used for head/tail).
71    Slice {
72        input: Box<LogicalPlan>,
73        offset: usize,
74        len: usize,
75        from_end: bool,
76    },
77    /// Remove duplicate rows.
78    Unique {
79        input: Box<LogicalPlan>,
80        subset: Option<Vec<String>>,
81    },
82    /// Fill nulls using a scalar or strategy.
83    FillNull {
84        input: Box<LogicalPlan>,
85        fill: FillNull,
86    },
87    /// Drop rows containing nulls.
88    DropNulls {
89        input: Box<LogicalPlan>,
90        subset: Option<Vec<String>>,
91    },
92    /// Count nulls per column.
93    NullCount { input: Box<LogicalPlan> },
94    /// Explode one list column.
95    Explode {
96        input: Box<LogicalPlan>,
97        column: String,
98    },
99    /// Implode columns into one row of list columns.
100    Implode { input: Box<LogicalPlan> },
101}
102
103impl LogicalPlan {
104    /// Construct a strict vertical concat plan from schema-known inputs.
105    ///
106    /// All input schemas must have identical field names, order, data types,
107    /// and nullability.  The check happens before execution and no implicit
108    /// coercion is attempted.
109    pub fn concat(inputs: Vec<(LogicalPlan, SchemaRef)>) -> Result<Self> {
110        if inputs.len() < 2 {
111            return Err(DataFrameError::invalid_operation(
112                "concat requires at least two inputs",
113            ));
114        }
115        let schema = inputs[0].1.clone();
116        for (index, (_, input_schema)) in inputs.iter().enumerate().skip(1) {
117            if input_schema.as_ref() != schema.as_ref() {
118                return Err(DataFrameError::schema_mismatch(format!(
119                    "concat_schema_mismatch: input 0 != input {index}"
120                )));
121            }
122        }
123        Ok(Self::Concat {
124            inputs: inputs.into_iter().map(|(plan, _)| plan).collect(),
125            schema: Some(schema),
126        })
127    }
128
129    /// Construct a concat whose source schemas are intentionally unavailable until bounded open.
130    ///
131    /// The streaming executor preflights every child schema before publishing its first result.
132    pub fn concat_deferred(inputs: Vec<LogicalPlan>) -> Result<Self> {
133        if inputs.len() < 2 {
134            return Err(DataFrameError::invalid_operation(
135                "concat requires at least two inputs",
136            ));
137        }
138        Ok(Self::Concat {
139            inputs,
140            schema: None,
141        })
142    }
143
144    /// Render this plan as a readable string (used by `explain()` and tests).
145    pub fn display(&self) -> String {
146        let mut out = String::new();
147        self.fmt_into(&mut out, 0);
148        out
149    }
150
151    fn fmt_into(&self, out: &mut String, indent: usize) {
152        let pad = "  ".repeat(indent);
153        match self {
154            LogicalPlan::DataFrameScan { .. } => {
155                out.push_str(&format!("{pad}scan[dataframe]\n"));
156            }
157            LogicalPlan::CsvScan {
158                path,
159                predicate,
160                projection,
161            } => {
162                out.push_str(&format!("{pad}scan[csv path='{}']", path.display()));
163                if let Some(projection) = projection {
164                    out.push_str(&format!(" projection={:?}", projection));
165                }
166                if let Some(predicate) = predicate {
167                    out.push_str(&format!(" filters=[{}]", fmt_expr(predicate)));
168                }
169                out.push('\n');
170            }
171            LogicalPlan::ParquetScan {
172                path,
173                predicate,
174                projection,
175            } => {
176                out.push_str(&format!("{pad}scan[parquet path='{}']", path.display()));
177                if let Some(projection) = projection {
178                    out.push_str(&format!(" projection={:?}", projection));
179                }
180                if let Some(predicate) = predicate {
181                    out.push_str(&format!(" filters=[{}]", fmt_expr(predicate)));
182                }
183                out.push('\n');
184            }
185            LogicalPlan::Concat { inputs, .. } => {
186                out.push_str(&format!("{pad}concat inputs={}\n", inputs.len()));
187                for input in inputs {
188                    input.fmt_into(out, indent + 1);
189                }
190            }
191            LogicalPlan::Projection { input, exprs, kind } => {
192                let label = match kind {
193                    ProjectionKind::Select => "project",
194                    ProjectionKind::WithColumns => "with_columns",
195                };
196                out.push_str(&format!(
197                    "{pad}{label} [{}]\n",
198                    exprs.iter().map(fmt_expr).collect::<Vec<_>>().join(", ")
199                ));
200                input.fmt_into(out, indent + 1);
201            }
202            LogicalPlan::Filter { input, predicate } => {
203                out.push_str(&format!("{pad}filter [{}]\n", fmt_expr(predicate)));
204                input.fmt_into(out, indent + 1);
205            }
206            LogicalPlan::Aggregate {
207                input,
208                group_by,
209                aggs,
210            } => {
211                out.push_str(&format!(
212                    "{pad}aggregate by=[{}] aggs=[{}]\n",
213                    group_by.iter().map(fmt_expr).collect::<Vec<_>>().join(", "),
214                    aggs.iter().map(fmt_expr).collect::<Vec<_>>().join(", ")
215                ));
216                input.fmt_into(out, indent + 1);
217            }
218            LogicalPlan::Join {
219                left,
220                right,
221                keys,
222                how,
223            } => {
224                out.push_str(&format!(
225                    "{pad}join how={how:?} keys={}\n",
226                    fmt_join_keys(keys)
227                ));
228                left.fmt_into(out, indent + 1);
229                right.fmt_into(out, indent + 1);
230            }
231            LogicalPlan::Sort { input, options } => {
232                out.push_str(&format!(
233                    "{pad}sort by={:?} desc={:?} nulls_last={} stable={}\n",
234                    options.by, options.descending, options.nulls_last, options.stable
235                ));
236                input.fmt_into(out, indent + 1);
237            }
238            LogicalPlan::Slice {
239                input,
240                offset,
241                len,
242                from_end,
243            } => {
244                out.push_str(&format!(
245                    "{pad}slice offset={offset} len={len} from_end={from_end}\n"
246                ));
247                input.fmt_into(out, indent + 1);
248            }
249            LogicalPlan::Unique { input, subset } => {
250                out.push_str(&format!("{pad}unique subset={subset:?}\n"));
251                input.fmt_into(out, indent + 1);
252            }
253            LogicalPlan::FillNull { input, fill } => {
254                out.push_str(&format!("{pad}fill_null {}\n", fmt_fill_null(fill)));
255                input.fmt_into(out, indent + 1);
256            }
257            LogicalPlan::DropNulls { input, subset } => {
258                out.push_str(&format!("{pad}drop_nulls subset={subset:?}\n"));
259                input.fmt_into(out, indent + 1);
260            }
261            LogicalPlan::NullCount { input } => {
262                out.push_str(&format!("{pad}null_count\n"));
263                input.fmt_into(out, indent + 1);
264            }
265            LogicalPlan::Explode { input, column } => {
266                out.push_str(&format!("{pad}explode column={column}\n"));
267                input.fmt_into(out, indent + 1);
268            }
269            LogicalPlan::Implode { input } => {
270                out.push_str(&format!("{pad}implode\n"));
271                input.fmt_into(out, indent + 1);
272            }
273        }
274    }
275}
276
277fn fmt_join_keys(keys: &JoinKeys) -> String {
278    match keys {
279        JoinKeys::On(cols) => format!("on={cols:?}"),
280        JoinKeys::LeftRight { left_on, right_on } => {
281            format!("left_on={left_on:?} right_on={right_on:?}")
282        }
283    }
284}
285
286fn fmt_fill_null(fill: &FillNull) -> String {
287    match fill {
288        FillNull::Value(value) => format!("value={value:?}"),
289        FillNull::Strategy(strategy) => format!("strategy={strategy:?}"),
290    }
291}
292
293fn fmt_expr(expr: &Expr) -> String {
294    use crate::expr::{
295        AggFunc, DatetimeFunction, Expr as E, ExprFunction, ListFunction, Operator, Scalar,
296        StringFunction, UnaryOperator,
297    };
298
299    match expr {
300        E::Column(name) => format!("col({name})"),
301        E::Literal(Scalar::Null) => "lit(null)".to_string(),
302        E::Literal(Scalar::Boolean(v)) => format!("lit({v})"),
303        E::Literal(Scalar::Int64(v)) => format!("lit({v})"),
304        E::Literal(Scalar::Float64(v)) => format!("lit({v})"),
305        E::Literal(Scalar::Utf8(v)) => format!("lit({v:?})"),
306        E::Wildcard => "*".to_string(),
307        E::Alias { expr, name } => format!("{} as {name}", fmt_expr(expr)),
308        E::UnaryOp {
309            op: UnaryOperator::Not,
310            expr,
311        } => format!("not({})", fmt_expr(expr)),
312        E::BinaryOp { left, op, right } => {
313            let op_s = match op {
314                Operator::Add => "+",
315                Operator::Sub => "-",
316                Operator::Mul => "*",
317                Operator::Div => "/",
318                Operator::Eq => "==",
319                Operator::Neq => "!=",
320                Operator::Gt => ">",
321                Operator::Lt => "<",
322                Operator::Ge => ">=",
323                Operator::Le => "<=",
324                Operator::And => "and",
325                Operator::Or => "or",
326            };
327            format!("({} {op_s} {})", fmt_expr(left), fmt_expr(right))
328        }
329        E::Agg { func, expr } => {
330            let f = match func {
331                AggFunc::Sum => "sum",
332                AggFunc::Mean => "mean",
333                AggFunc::Count => "count",
334                AggFunc::Min => "min",
335                AggFunc::Max => "max",
336            };
337            format!("{f}({})", fmt_expr(expr))
338        }
339        E::Function { input, function } => {
340            let f = match function {
341                ExprFunction::String(function) => match function {
342                    StringFunction::ToLowercase => "str.to_lowercase".to_string(),
343                    StringFunction::ToUppercase => "str.to_uppercase".to_string(),
344                    StringFunction::Contains { pattern } => {
345                        format!("str.contains({pattern:?})")
346                    }
347                    StringFunction::Replace {
348                        pattern,
349                        replacement,
350                    } => format!("str.replace({pattern:?}, {replacement:?})"),
351                    StringFunction::StripChars { chars } => {
352                        format!("str.strip_chars({chars:?})")
353                    }
354                    StringFunction::Split { separator } => {
355                        format!("str.split({separator:?})")
356                    }
357                    StringFunction::LenChars => "str.len_chars".to_string(),
358                    StringFunction::Extract {
359                        pattern,
360                        capture_group,
361                    } => format!("str.extract({pattern:?}, {capture_group})"),
362                },
363                ExprFunction::Datetime(function) => match function {
364                    DatetimeFunction::Year => "dt.year".to_string(),
365                    DatetimeFunction::Month => "dt.month".to_string(),
366                    DatetimeFunction::Day => "dt.day".to_string(),
367                    DatetimeFunction::Weekday => "dt.weekday".to_string(),
368                    DatetimeFunction::ToString => "dt.to_string".to_string(),
369                    DatetimeFunction::ConvertTimeZone {
370                        from_offset,
371                        to_offset,
372                    } => format!("dt.convert_time_zone({from_offset:?}, {to_offset:?})"),
373                },
374                ExprFunction::List(function) => match function {
375                    ListFunction::Join {
376                        separator,
377                        null_value,
378                    } => format!("list.join({separator:?}, {null_value:?})"),
379                    ListFunction::Len => "list.len".to_string(),
380                    ListFunction::Contains { value } => {
381                        format!("list.contains({value:?})")
382                    }
383                },
384            };
385            format!("{f}({})", fmt_expr(input))
386        }
387        E::ConcatStr {
388            inputs,
389            separator,
390            null_behavior,
391        } => format!(
392            "concat_str([{}], separator={separator:?}, null_behavior={null_behavior:?})",
393            inputs.iter().map(fmt_expr).collect::<Vec<_>>().join(", ")
394        ),
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use std::sync::Arc;
401
402    use arrow::datatypes::{DataType, Field, Schema};
403
404    use super::{LogicalPlan, ProjectionKind};
405    use crate::expr::{col, lit};
406
407    fn int_schema(nullable: bool) -> arrow::datatypes::SchemaRef {
408        Arc::new(Schema::new(vec![Field::new(
409            "value",
410            DataType::Int64,
411            nullable,
412        )]))
413    }
414
415    #[test]
416    fn display_is_readable_and_stable() {
417        let plan = LogicalPlan::Filter {
418            input: Box::new(LogicalPlan::Projection {
419                input: Box::new(LogicalPlan::CsvScan {
420                    path: "data.csv".into(),
421                    predicate: None,
422                    projection: Some(vec!["a".to_string(), "b".to_string()]),
423                }),
424                exprs: vec![col("a"), col("b").alias("bb")],
425                kind: ProjectionKind::Select,
426            }),
427            predicate: col("a").gt(lit(1_i64)),
428        };
429
430        let s = plan.display();
431        assert!(s.contains("scan[csv"));
432        assert!(s.contains("project"));
433        assert!(s.contains("filter"));
434        assert!(s.contains("col(a)"));
435    }
436
437    #[test]
438    fn concat_rejects_schema_mismatch_at_plan_build_time() {
439        let err = LogicalPlan::concat(vec![
440            (
441                LogicalPlan::CsvScan {
442                    path: "first.csv".into(),
443                    predicate: None,
444                    projection: None,
445                },
446                int_schema(true),
447            ),
448            (
449                LogicalPlan::CsvScan {
450                    path: "second.csv".into(),
451                    predicate: None,
452                    projection: None,
453                },
454                int_schema(false),
455            ),
456        ])
457        .unwrap_err();
458
459        assert!(err.to_string().contains("concat_schema_mismatch"));
460    }
461
462    #[test]
463    fn concat_display_preserves_declared_input_order() {
464        let schema = int_schema(true);
465        let plan = LogicalPlan::concat(vec![
466            (
467                LogicalPlan::CsvScan {
468                    path: "first.csv".into(),
469                    predicate: None,
470                    projection: None,
471                },
472                schema.clone(),
473            ),
474            (
475                LogicalPlan::CsvScan {
476                    path: "second.csv".into(),
477                    predicate: None,
478                    projection: None,
479                },
480                schema,
481            ),
482        ])
483        .unwrap();
484
485        let display = plan.display();
486        assert!(display.find("first.csv").unwrap() < display.find("second.csv").unwrap());
487    }
488}