1use std::path::Path;
2
3use crate::lazy::{LogicalPlan, Optimizer, ProjectionKind};
4use crate::ops::{FillNull, JoinKeys, JoinType, SortOptions};
5use crate::physical::budget::StreamOptions;
6use crate::physical::{BatchOpenContext, BudgetedMaterializedExecutor, DataFrameStream};
7use crate::{DataFrame, DataFrameError, Expr, Result};
8
9#[derive(Debug, Clone)]
11pub struct LazyFrame {
12 plan: LogicalPlan,
13}
14
15impl LazyFrame {
16 pub fn from_dataframe(df: DataFrame) -> Self {
18 Self {
19 plan: LogicalPlan::DataFrameScan { df },
20 }
21 }
22
23 pub fn scan_csv(path: impl AsRef<Path>) -> Result<Self> {
25 Ok(Self {
26 plan: LogicalPlan::CsvScan {
27 path: path.as_ref().to_path_buf(),
28 predicate: None,
29 projection: None,
30 },
31 })
32 }
33
34 pub fn scan_parquet(path: impl AsRef<Path>) -> Result<Self> {
36 Ok(Self {
37 plan: LogicalPlan::ParquetScan {
38 path: path.as_ref().to_path_buf(),
39 predicate: None,
40 projection: None,
41 },
42 })
43 }
44
45 pub fn concat(inputs: Vec<LazyFrame>) -> Result<Self> {
51 let mut plans = Vec::with_capacity(inputs.len());
52 let mut known_schemas = Vec::with_capacity(inputs.len());
53 for input in inputs {
54 known_schemas.push(known_plan_schema(&input.plan));
55 plans.push(input.plan);
56 }
57 if known_schemas.iter().all(Option::is_some) {
58 let plans = plans
59 .into_iter()
60 .zip(known_schemas)
61 .map(|(plan, schema)| (plan, schema.expect("all schemas were checked")))
62 .collect();
63 return Ok(Self {
64 plan: LogicalPlan::concat(plans)?,
65 });
66 }
67
68 let mut first_known: Option<arrow::datatypes::SchemaRef> = None;
69 for (index, schema) in known_schemas.iter().enumerate() {
70 let Some(schema) = schema else {
71 continue;
72 };
73 if let Some(first) = &first_known {
74 if schema.as_ref() != first.as_ref() {
75 return Err(DataFrameError::schema_mismatch(format!(
76 "concat_schema_mismatch: known input {index} differs from another known input"
77 )));
78 }
79 } else {
80 first_known = Some(schema.clone());
81 }
82 }
83 Ok(Self {
84 plan: LogicalPlan::concat_deferred(plans)?,
85 })
86 }
87
88 pub fn select(self, exprs: Vec<Expr>) -> Self {
90 Self {
91 plan: LogicalPlan::Projection {
92 input: Box::new(self.plan),
93 exprs,
94 kind: ProjectionKind::Select,
95 },
96 }
97 }
98
99 pub fn filter(self, predicate: Expr) -> Self {
101 Self {
102 plan: LogicalPlan::Filter {
103 input: Box::new(self.plan),
104 predicate,
105 },
106 }
107 }
108
109 pub fn with_columns(self, exprs: Vec<Expr>) -> Self {
111 Self {
112 plan: LogicalPlan::Projection {
113 input: Box::new(self.plan),
114 exprs,
115 kind: ProjectionKind::WithColumns,
116 },
117 }
118 }
119
120 pub fn group_by(self, by: Vec<Expr>) -> LazyGroupBy {
122 LazyGroupBy {
123 plan: self.plan,
124 by,
125 }
126 }
127
128 pub fn join<K: Into<JoinKeys>>(self, other: LazyFrame, keys: K, how: JoinType) -> Self {
130 let keys = keys.into();
131 Self {
132 plan: LogicalPlan::Join {
133 left: Box::new(self.plan),
134 right: Box::new(other.plan),
135 keys,
136 how,
137 },
138 }
139 }
140
141 pub fn sort(self, mut options: SortOptions) -> Self {
143 options.nulls_last = true;
144 options.stable = true;
145 Self {
146 plan: LogicalPlan::Sort {
147 input: Box::new(self.plan),
148 options,
149 },
150 }
151 }
152
153 pub fn head(self, n: usize) -> Self {
155 Self {
156 plan: LogicalPlan::Slice {
157 input: Box::new(self.plan),
158 offset: 0,
159 len: n,
160 from_end: false,
161 },
162 }
163 }
164
165 pub fn tail(self, n: usize) -> Self {
167 Self {
168 plan: LogicalPlan::Slice {
169 input: Box::new(self.plan),
170 offset: 0,
171 len: n,
172 from_end: true,
173 },
174 }
175 }
176
177 pub fn unique(self, subset: Option<Vec<String>>) -> Self {
179 Self {
180 plan: LogicalPlan::Unique {
181 input: Box::new(self.plan),
182 subset,
183 },
184 }
185 }
186
187 pub fn fill_null<T: Into<FillNull>>(self, fill: T) -> Self {
189 Self {
190 plan: LogicalPlan::FillNull {
191 input: Box::new(self.plan),
192 fill: fill.into(),
193 },
194 }
195 }
196
197 pub fn drop_nulls(self, subset: Option<Vec<String>>) -> Self {
199 Self {
200 plan: LogicalPlan::DropNulls {
201 input: Box::new(self.plan),
202 subset,
203 },
204 }
205 }
206
207 pub fn null_count(self) -> Self {
209 Self {
210 plan: LogicalPlan::NullCount {
211 input: Box::new(self.plan),
212 },
213 }
214 }
215
216 pub fn explode(self, column: impl Into<String>) -> Self {
218 Self {
219 plan: LogicalPlan::Explode {
220 input: Box::new(self.plan),
221 column: column.into(),
222 },
223 }
224 }
225
226 pub fn implode(self) -> Self {
228 Self {
229 plan: LogicalPlan::Implode {
230 input: Box::new(self.plan),
231 },
232 }
233 }
234
235 pub fn collect(self) -> Result<DataFrame> {
237 let optimized = Optimizer::optimize(&self.plan);
238 let physical = crate::physical::compile(&optimized)?;
239 let batches = crate::physical::Executor::execute(physical)?;
240 DataFrame::from_batches(batches)
241 }
242
243 pub fn collect_with_options(self, options: StreamOptions) -> Result<DataFrame> {
248 let mut stream = self.collect_streaming(options)?;
249 BudgetedMaterializedExecutor::new(stream.budget().clone()).collect_stream(&mut stream)
250 }
251
252 pub fn collect_streaming(self, options: StreamOptions) -> Result<DataFrameStream> {
258 let optimized = Optimizer::optimize(&self.plan);
259 let physical = crate::physical::compile(&optimized)?;
260 let compiled = crate::physical::compile_streaming(&physical, options)?;
261 let context = BatchOpenContext::new(options);
262 let source = crate::physical::open_streaming_plan(compiled, context.clone())?;
263 Ok(DataFrameStream::from_opened_source(source, context.budget))
264 }
265
266 pub fn explain(self, optimized: bool) -> String {
270 if optimized {
271 Optimizer::optimize(&self.plan).display()
272 } else {
273 self.plan.display()
274 }
275 }
276}
277
278fn known_plan_schema(plan: &LogicalPlan) -> Option<arrow::datatypes::SchemaRef> {
279 match plan {
280 LogicalPlan::DataFrameScan { df } => Some(df.schema()),
281 LogicalPlan::Concat { schema, .. } => schema.clone(),
282 LogicalPlan::Filter { input, .. }
283 | LogicalPlan::Sort { input, .. }
284 | LogicalPlan::Slice { input, .. }
285 | LogicalPlan::Unique { input, .. }
286 | LogicalPlan::FillNull { input, .. }
287 | LogicalPlan::DropNulls { input, .. } => known_plan_schema(input),
288 LogicalPlan::CsvScan { .. }
289 | LogicalPlan::ParquetScan { .. }
290 | LogicalPlan::Projection { .. }
291 | LogicalPlan::Aggregate { .. }
292 | LogicalPlan::Join { .. }
293 | LogicalPlan::NullCount { .. }
294 | LogicalPlan::Explode { .. }
295 | LogicalPlan::Implode { .. } => None,
296 }
297}
298
299#[derive(Debug, Clone)]
301pub struct LazyGroupBy {
302 by: Vec<Expr>,
303 plan: LogicalPlan,
304}
305
306impl LazyGroupBy {
307 pub fn agg(self, aggs: Vec<Expr>) -> LazyFrame {
309 LazyFrame {
310 plan: LogicalPlan::Aggregate {
311 input: Box::new(self.plan),
312 group_by: self.by,
313 aggs,
314 },
315 }
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use std::num::NonZeroUsize;
322 use std::sync::Arc;
323
324 use arrow::array::{Array, ArrayRef, Int64Array, StringArray};
325
326 use super::LazyFrame;
327 use crate::expr::{col, concat_str, lit, ConcatStrNullBehavior};
328 use crate::physical::budget::StreamOptions;
329 use crate::{DataFrame, Series};
330
331 fn df() -> DataFrame {
332 let a: ArrayRef = Arc::new(Int64Array::from(vec![1, 2, 3]));
333 let b: ArrayRef = Arc::new(Int64Array::from(vec![10, 20, 30]));
334 DataFrame::new(vec![
335 Series::from_arrow("a", vec![a]).unwrap(),
336 Series::from_arrow("b", vec![b]).unwrap(),
337 ])
338 .unwrap()
339 }
340
341 #[test]
342 fn explain_builds_plan_without_io() {
343 let lf = LazyFrame::scan_csv("test.csv").unwrap();
344 let s = lf.explain(false);
345 assert!(s.contains("scan[csv"));
346 }
347
348 #[test]
349 fn collect_executes_filter_and_select_on_dataframe_scan() {
350 let lf = LazyFrame::from_dataframe(df())
351 .filter(col("a").gt(lit(1_i64)))
352 .select(vec![col("b").alias("bb")]);
353 let out = lf.collect().unwrap();
354 assert_eq!(out.height(), 2);
355 let bb = out.column("bb").unwrap();
356 assert_eq!(bb.len(), 2);
357 }
358
359 #[test]
360 fn group_by_agg_executes_sum_and_count() {
361 let lf = LazyFrame::from_dataframe(df())
362 .group_by(vec![col("a")])
363 .agg(vec![
364 col("b").sum().alias("sum_b"),
365 col("b").count().alias("cnt_b"),
366 ]);
367 let out = lf.collect().unwrap();
368 assert_eq!(out.width(), 3);
369 assert_eq!(out.column("a").unwrap().len(), 3);
370 assert_eq!(out.column("sum_b").unwrap().len(), 3);
371 assert_eq!(out.column("cnt_b").unwrap().len(), 3);
372 }
373
374 #[test]
375 fn collect_streaming_opens_the_bounded_csv_source_without_eager_collect() {
376 let dir = tempfile::tempdir().unwrap();
377 let path = dir.path().join("stream.csv");
378 std::fs::write(&path, "a\n1\n2\n").unwrap();
379 let options = StreamOptions::new(
380 16 * 1024,
381 NonZeroUsize::new(1).unwrap(),
382 NonZeroUsize::new(1).unwrap(),
383 );
384
385 let mut stream = LazyFrame::scan_csv(&path)
386 .unwrap()
387 .collect_streaming(options)
388 .unwrap();
389 assert_eq!(stream.next_batch().unwrap().unwrap().height(), 1);
390 assert_eq!(stream.next_batch().unwrap().unwrap().height(), 1);
391 assert!(stream.next_batch().unwrap().is_none());
392 }
393
394 #[test]
395 fn collect_streaming_opens_the_bounded_parquet_source_without_eager_collect() {
396 let dir = tempfile::tempdir().unwrap();
397 let path = dir.path().join("stream.parquet");
398 let schema = Arc::new(arrow::datatypes::Schema::new(vec![
399 arrow::datatypes::Field::new("a", arrow::datatypes::DataType::Int64, true),
400 ]));
401 let batch = arrow::record_batch::RecordBatch::try_new(
402 schema,
403 vec![Arc::new(Int64Array::from(vec![1_i64, 2])) as ArrayRef],
404 )
405 .unwrap();
406 crate::io::write_parquet(&path, &DataFrame::from_batches(vec![batch]).unwrap()).unwrap();
407 let options = StreamOptions::new(
408 64 * 1024,
409 NonZeroUsize::new(1).unwrap(),
410 NonZeroUsize::new(1).unwrap(),
411 );
412
413 let mut stream = LazyFrame::scan_parquet(&path)
414 .unwrap()
415 .collect_streaming(options)
416 .unwrap();
417 assert_eq!(stream.next_batch().unwrap().unwrap().height(), 1);
418 assert_eq!(stream.next_batch().unwrap().unwrap().height(), 1);
419 assert!(stream.next_batch().unwrap().is_none());
420 }
421
422 #[test]
423 fn lazy_concat_preflights_deferred_csv_schemas_then_preserves_input_order() {
424 let dir = tempfile::tempdir().unwrap();
425 let first = dir.path().join("first.csv");
426 let second = dir.path().join("second.csv");
427 std::fs::write(&first, "a\n1\n2\n").unwrap();
428 std::fs::write(&second, "a\n3\n").unwrap();
429 let options = StreamOptions::new(
430 16 * 1024,
431 NonZeroUsize::new(1).unwrap(),
432 NonZeroUsize::new(1).unwrap(),
433 );
434
435 let mut stream = LazyFrame::concat(vec![
436 LazyFrame::scan_csv(&first).unwrap(),
437 LazyFrame::scan_csv(&second).unwrap(),
438 ])
439 .unwrap()
440 .collect_streaming(options)
441 .unwrap();
442 let mut values = Vec::new();
443 while let Some(batch) = stream.next_batch().unwrap() {
444 let batch = batch.to_arrow().pop().unwrap();
445 let values_array = batch
446 .column(0)
447 .as_any()
448 .downcast_ref::<Int64Array>()
449 .unwrap();
450 values.extend((0..values_array.len()).map(|index| values_array.value(index)));
451 }
452 assert_eq!(values, vec![1, 2, 3]);
453 }
454
455 #[test]
456 fn deferred_concat_schema_mismatch_fails_before_a_stream_is_returned() {
457 let dir = tempfile::tempdir().unwrap();
458 let first = dir.path().join("first.csv");
459 let second = dir.path().join("second.csv");
460 std::fs::write(&first, "a\n1\n").unwrap();
461 std::fs::write(&second, "a\nnot-a-number\n").unwrap();
462 let options = StreamOptions::new(
463 16 * 1024,
464 NonZeroUsize::new(1).unwrap(),
465 NonZeroUsize::new(1).unwrap(),
466 );
467
468 let result = LazyFrame::concat(vec![
469 LazyFrame::scan_csv(&first).unwrap(),
470 LazyFrame::scan_csv(&second).unwrap(),
471 ])
472 .unwrap()
473 .collect_streaming(options);
474 let err = match result {
475 Ok(_) => panic!("mismatched deferred concat must fail before returning a stream"),
476 Err(error) => error,
477 };
478 assert!(err.to_string().contains("concat_schema_mismatch"));
479 }
480
481 #[test]
482 fn streaming_select_filter_concat_str_and_bounded_collect_share_batch_semantics() {
483 let dir = tempfile::tempdir().unwrap();
484 let path = dir.path().join("operators.csv");
485 std::fs::write(&path, "a,left,right\n1,x,y\n2,m,n\n3,p,q\n").unwrap();
486 let options = StreamOptions::default();
490 let repeated = col("a").add(lit(1_i64));
491 let label = concat_str(
492 vec![col("left"), col("right")],
493 "-",
494 ConcatStrNullBehavior::Propagate,
495 )
496 .unwrap()
497 .alias("label");
498 let plan = LazyFrame::scan_csv(&path)
499 .unwrap()
500 .filter(col("a").gt(lit(1_i64)))
501 .select(vec![
502 repeated.clone().alias("next"),
503 repeated.alias("next_again"),
504 label,
505 ]);
506
507 let mut stream = plan.clone().collect_streaming(options).unwrap();
508 let mut rows = Vec::new();
509 while let Some(dataframe) = stream.next_batch().unwrap() {
510 let batch = dataframe.to_arrow().pop().unwrap();
511 let next = batch
512 .column(0)
513 .as_any()
514 .downcast_ref::<Int64Array>()
515 .unwrap();
516 let repeated = batch
517 .column(1)
518 .as_any()
519 .downcast_ref::<Int64Array>()
520 .unwrap();
521 let label = batch
522 .column(2)
523 .as_any()
524 .downcast_ref::<StringArray>()
525 .unwrap();
526 for index in 0..batch.num_rows() {
527 rows.push((
528 next.value(index),
529 repeated.value(index),
530 label.value(index).to_string(),
531 ));
532 }
533 }
534 assert_eq!(rows, vec![(3, 3, "m-n".into()), (4, 4, "p-q".into())]);
535
536 let materialized = plan.collect_with_options(options).unwrap();
537 assert_eq!(materialized.height(), 2);
538 assert_eq!(materialized.schema().fields()[2].name(), "label");
539 }
540
541 #[test]
542 fn streaming_with_columns_and_forward_head_preserve_schema_and_order() {
543 let dir = tempfile::tempdir().unwrap();
544 let path = dir.path().join("with-columns.csv");
545 std::fs::write(&path, "a\n1\n2\n3\n").unwrap();
546 let options = StreamOptions::new(
547 16 * 1024,
548 NonZeroUsize::new(1).unwrap(),
549 NonZeroUsize::new(1).unwrap(),
550 );
551
552 let mut stream = LazyFrame::scan_csv(&path)
553 .unwrap()
554 .with_columns(vec![col("a").add(lit(10_i64)).alias("next")])
555 .head(2)
556 .collect_streaming(options)
557 .unwrap();
558 let mut values = Vec::new();
559 while let Some(dataframe) = stream.next_batch().unwrap() {
560 let batch = dataframe.to_arrow().pop().unwrap();
561 assert_eq!(batch.schema().fields()[1].name(), "next");
562 let next = batch
563 .column(1)
564 .as_any()
565 .downcast_ref::<Int64Array>()
566 .unwrap();
567 values.extend((0..next.len()).map(|index| next.value(index)));
568 }
569 assert_eq!(values, vec![11, 12]);
570 }
571
572 #[test]
573 fn known_lazy_concat_schema_mismatch_fails_during_plan_construction() {
574 let first = df().lazy();
575 let incompatible = DataFrame::new(vec![Series::from_arrow(
576 "other",
577 vec![Arc::new(Int64Array::from(vec![1])) as ArrayRef],
578 )
579 .unwrap()])
580 .unwrap()
581 .lazy();
582
583 let err = LazyFrame::concat(vec![first, incompatible]).unwrap_err();
584 assert!(err.to_string().contains("concat_schema_mismatch"));
585 }
586}