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#[derive(Debug, Clone)]
10pub enum ProjectionKind {
11 Select,
13 WithColumns,
15}
16
17#[derive(Debug, Clone)]
19pub enum LogicalPlan {
20 DataFrameScan { df: DataFrame },
22 CsvScan {
24 path: PathBuf,
25 predicate: Option<Expr>,
26 projection: Option<Vec<String>>,
27 },
28 ParquetScan {
30 path: PathBuf,
31 predicate: Option<Expr>,
32 projection: Option<Vec<String>>,
33 },
34 Concat {
36 inputs: Vec<LogicalPlan>,
38 schema: Option<SchemaRef>,
40 },
41 Projection {
43 input: Box<LogicalPlan>,
44 exprs: Vec<Expr>,
45 kind: ProjectionKind,
46 },
47 Filter {
49 input: Box<LogicalPlan>,
50 predicate: Expr,
51 },
52 Aggregate {
54 input: Box<LogicalPlan>,
55 group_by: Vec<Expr>,
56 aggs: Vec<Expr>,
57 },
58 Join {
60 left: Box<LogicalPlan>,
61 right: Box<LogicalPlan>,
62 keys: JoinKeys,
63 how: JoinType,
64 },
65 Sort {
67 input: Box<LogicalPlan>,
68 options: SortOptions,
69 },
70 Slice {
72 input: Box<LogicalPlan>,
73 offset: usize,
74 len: usize,
75 from_end: bool,
76 },
77 Unique {
79 input: Box<LogicalPlan>,
80 subset: Option<Vec<String>>,
81 },
82 FillNull {
84 input: Box<LogicalPlan>,
85 fill: FillNull,
86 },
87 DropNulls {
89 input: Box<LogicalPlan>,
90 subset: Option<Vec<String>>,
91 },
92 NullCount { input: Box<LogicalPlan> },
94 Explode {
96 input: Box<LogicalPlan>,
97 column: String,
98 },
99 Implode { input: Box<LogicalPlan> },
101}
102
103impl LogicalPlan {
104 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 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 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}