Skip to main content

datafusion_python/
dataframe.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::collections::HashMap;
19use std::ffi::{CStr, CString};
20use std::ptr::NonNull;
21use std::str::FromStr;
22use std::sync::Arc;
23
24use arrow::array::{Array, ArrayRef, RecordBatch, RecordBatchReader, new_null_array};
25use arrow::compute::can_cast_types;
26use arrow::error::ArrowError;
27use arrow::ffi::FFI_ArrowSchema;
28use arrow::ffi_stream::FFI_ArrowArrayStream;
29use arrow::pyarrow::FromPyArrow;
30use cstr::cstr;
31use datafusion::arrow::datatypes::{Schema, SchemaRef};
32use datafusion::arrow::pyarrow::{PyArrowType, ToPyArrow};
33use datafusion::arrow::util::pretty;
34use datafusion::catalog::TableProvider;
35use datafusion::common::UnnestOptions;
36use datafusion::config::{CsvOptions, ParquetColumnOptions, ParquetOptions, TableParquetOptions};
37use datafusion::dataframe::{DataFrame, DataFrameWriteOptions};
38use datafusion::error::DataFusionError;
39use datafusion::execution::SendableRecordBatchStream;
40use datafusion::execution::context::TaskContext;
41use datafusion::logical_expr::dml::InsertOp;
42use datafusion::logical_expr::{LogicalPlan, SortExpr};
43use datafusion::parquet::basic::{BrotliLevel, Compression, GzipLevel, ZstdLevel};
44use datafusion::physical_plan::{
45    ExecutionPlan as DFExecutionPlan, collect as df_collect,
46    collect_partitioned as df_collect_partitioned, execute_stream as df_execute_stream,
47    execute_stream_partitioned as df_execute_stream_partitioned,
48};
49use datafusion::prelude::*;
50use datafusion_python_util::{is_ipython_env, spawn_future, wait_for_future};
51use futures::{StreamExt, TryStreamExt};
52use parking_lot::Mutex;
53use pyo3::PyErr;
54use pyo3::exceptions::PyValueError;
55use pyo3::prelude::*;
56use pyo3::pybacked::PyBackedStr;
57use pyo3::types::{PyCapsule, PyList, PyTuple, PyTupleMethods};
58
59use crate::common::data_type::PyScalarValue;
60use crate::errors::{PyDataFusionError, PyDataFusionResult, py_datafusion_err};
61use crate::expr::PyExpr;
62use crate::expr::sort_expr::{PySortExpr, to_sort_expressions};
63use crate::physical_plan::PyExecutionPlan;
64use crate::record_batch::{PyRecordBatchStream, poll_next_batch};
65use crate::sql::logical::PyLogicalPlan;
66use crate::table::{PyTable, TempViewTable};
67
68/// File-level static CStr for the Arrow array stream capsule name.
69static ARROW_ARRAY_STREAM_NAME: &CStr = cstr!("arrow_array_stream");
70
71// Type aliases to simplify very complex types used in this file and
72// avoid compiler complaints about deeply nested types in struct fields.
73type CachedBatches = Option<(Vec<RecordBatch>, bool)>;
74type SharedCachedBatches = Arc<Mutex<CachedBatches>>;
75
76/// Configuration for DataFrame display formatting
77#[derive(Debug, Clone)]
78pub struct FormatterConfig {
79    /// Maximum memory in bytes to use for display (default: 2MB)
80    pub max_bytes: usize,
81    /// Minimum number of rows to display (default: 10)
82    pub min_rows: usize,
83    /// Maximum number of rows to include in __repr__ output (default: 10)
84    pub max_rows: usize,
85}
86
87impl Default for FormatterConfig {
88    fn default() -> Self {
89        Self {
90            max_bytes: 2 * 1024 * 1024, // 2MB
91            min_rows: 10,
92            max_rows: 10,
93        }
94    }
95}
96
97impl FormatterConfig {
98    /// Validates that all configuration values are positive integers.
99    ///
100    /// # Returns
101    ///
102    /// `Ok(())` if all values are valid, or an `Err` with a descriptive error message.
103    pub fn validate(&self) -> Result<(), String> {
104        if self.max_bytes == 0 {
105            return Err("max_bytes must be a positive integer".to_string());
106        }
107
108        if self.min_rows == 0 {
109            return Err("min_rows must be a positive integer".to_string());
110        }
111
112        if self.max_rows == 0 {
113            return Err("max_rows must be a positive integer".to_string());
114        }
115
116        if self.min_rows > self.max_rows {
117            return Err("min_rows must be less than or equal to max_rows".to_string());
118        }
119
120        Ok(())
121    }
122}
123
124/// Holds the Python formatter and its configuration
125struct PythonFormatter<'py> {
126    /// The Python formatter object
127    formatter: Bound<'py, PyAny>,
128    /// The formatter configuration
129    config: FormatterConfig,
130}
131
132/// Get the Python formatter and its configuration
133fn get_python_formatter_with_config(py: Python) -> PyResult<PythonFormatter> {
134    let formatter = import_python_formatter(py)?;
135    let config = build_formatter_config_from_python(&formatter)?;
136    Ok(PythonFormatter { formatter, config })
137}
138
139/// Get the Python formatter from the datafusion.dataframe_formatter module
140fn import_python_formatter(py: Python<'_>) -> PyResult<Bound<'_, PyAny>> {
141    let formatter_module = py.import("datafusion.dataframe_formatter")?;
142    let get_formatter = formatter_module.getattr("get_formatter")?;
143    get_formatter.call0()
144}
145
146// Helper function to extract attributes with fallback to default
147fn get_attr<'a, T>(py_object: &'a Bound<'a, PyAny>, attr_name: &str, default_value: T) -> T
148where
149    T: for<'py> pyo3::FromPyObject<'py, 'py> + Clone,
150{
151    py_object
152        .getattr(attr_name)
153        .and_then(|v| v.extract::<T>().map_err(Into::<PyErr>::into))
154        .unwrap_or_else(|_| default_value.clone())
155}
156
157/// Helper function to create a FormatterConfig from a Python formatter object
158fn build_formatter_config_from_python(formatter: &Bound<'_, PyAny>) -> PyResult<FormatterConfig> {
159    let default_config = FormatterConfig::default();
160    let max_bytes = get_attr(formatter, "max_memory_bytes", default_config.max_bytes);
161    let min_rows = get_attr(formatter, "min_rows", default_config.min_rows);
162
163    // Backward compatibility: Try max_rows first (new name), fall back to repr_rows (deprecated),
164    // then use default. This ensures backward compatibility with custom formatter implementations
165    // during the deprecation period.
166    let max_rows = get_attr(formatter, "max_rows", 0usize);
167    let max_rows = if max_rows > 0 {
168        // max_rows attribute exists and has a value
169        max_rows
170    } else {
171        // Try the deprecated repr_rows attribute
172        let repr_rows = get_attr(formatter, "repr_rows", 0usize);
173        if repr_rows > 0 {
174            repr_rows
175        } else {
176            // Use default
177            default_config.max_rows
178        }
179    };
180
181    let config = FormatterConfig {
182        max_bytes,
183        min_rows,
184        max_rows,
185    };
186
187    // Return the validated config, converting String error to PyErr
188    config.validate().map_err(PyValueError::new_err)?;
189    Ok(config)
190}
191
192/// Python mapping of `ParquetOptions` (includes just the writer-related options).
193#[pyclass(
194    from_py_object,
195    frozen,
196    name = "ParquetWriterOptions",
197    module = "datafusion",
198    subclass
199)]
200#[derive(Clone, Default)]
201pub struct PyParquetWriterOptions {
202    options: ParquetOptions,
203}
204
205#[pymethods]
206impl PyParquetWriterOptions {
207    #[new]
208    #[allow(clippy::too_many_arguments)]
209    pub fn new(
210        data_pagesize_limit: usize,
211        write_batch_size: usize,
212        writer_version: &str,
213        skip_arrow_metadata: bool,
214        compression: Option<String>,
215        dictionary_enabled: Option<bool>,
216        dictionary_page_size_limit: usize,
217        statistics_enabled: Option<String>,
218        max_row_group_size: usize,
219        created_by: String,
220        column_index_truncate_length: Option<usize>,
221        statistics_truncate_length: Option<usize>,
222        data_page_row_count_limit: usize,
223        encoding: Option<String>,
224        bloom_filter_on_write: bool,
225        bloom_filter_fpp: Option<f64>,
226        bloom_filter_ndv: Option<u64>,
227        allow_single_file_parallelism: bool,
228        maximum_parallel_row_group_writers: usize,
229        maximum_buffered_record_batches_per_stream: usize,
230    ) -> PyResult<Self> {
231        let writer_version =
232            datafusion::common::parquet_config::DFParquetWriterVersion::from_str(writer_version)
233                .map_err(py_datafusion_err)?;
234        Ok(Self {
235            options: ParquetOptions {
236                data_pagesize_limit,
237                write_batch_size,
238                writer_version,
239                skip_arrow_metadata,
240                compression,
241                dictionary_enabled,
242                dictionary_page_size_limit,
243                statistics_enabled,
244                max_row_group_size,
245                created_by,
246                column_index_truncate_length,
247                statistics_truncate_length,
248                data_page_row_count_limit,
249                encoding,
250                bloom_filter_on_write,
251                bloom_filter_fpp,
252                bloom_filter_ndv,
253                allow_single_file_parallelism,
254                maximum_parallel_row_group_writers,
255                maximum_buffered_record_batches_per_stream,
256                ..Default::default()
257            },
258        })
259    }
260}
261
262/// Python mapping of `ParquetColumnOptions`.
263#[pyclass(
264    from_py_object,
265    frozen,
266    name = "ParquetColumnOptions",
267    module = "datafusion",
268    subclass
269)]
270#[derive(Clone, Default)]
271pub struct PyParquetColumnOptions {
272    options: ParquetColumnOptions,
273}
274
275#[pymethods]
276impl PyParquetColumnOptions {
277    #[new]
278    pub fn new(
279        bloom_filter_enabled: Option<bool>,
280        encoding: Option<String>,
281        dictionary_enabled: Option<bool>,
282        compression: Option<String>,
283        statistics_enabled: Option<String>,
284        bloom_filter_fpp: Option<f64>,
285        bloom_filter_ndv: Option<u64>,
286    ) -> Self {
287        Self {
288            options: ParquetColumnOptions {
289                bloom_filter_enabled,
290                encoding,
291                dictionary_enabled,
292                compression,
293                statistics_enabled,
294                bloom_filter_fpp,
295                bloom_filter_ndv,
296            },
297        }
298    }
299}
300
301/// A PyDataFrame is a representation of a logical plan and an API to compose statements.
302/// Use it to build a plan and `.collect()` to execute the plan and collect the result.
303/// The actual execution of a plan runs natively on Rust and Arrow on a multi-threaded environment.
304#[pyclass(
305    from_py_object,
306    name = "DataFrame",
307    module = "datafusion",
308    subclass,
309    frozen
310)]
311#[derive(Clone)]
312pub struct PyDataFrame {
313    df: Arc<DataFrame>,
314
315    // In IPython environment cache batches between __repr__ and _repr_html_ calls.
316    batches: SharedCachedBatches,
317
318    // Cache the last physical plan so that metrics are available after execution.
319    last_plan: Arc<Mutex<Option<Arc<dyn DFExecutionPlan>>>>,
320}
321
322impl PyDataFrame {
323    /// creates a new PyDataFrame
324    pub fn new(df: DataFrame) -> Self {
325        Self {
326            df: Arc::new(df),
327            batches: Arc::new(Mutex::new(None)),
328            last_plan: Arc::new(Mutex::new(None)),
329        }
330    }
331
332    /// Return a clone of the inner Arc<DataFrame> for crate-local callers.
333    pub(crate) fn inner_df(&self) -> Arc<DataFrame> {
334        Arc::clone(&self.df)
335    }
336
337    fn prepare_repr_string<'py>(
338        &self,
339        py: Python<'py>,
340        as_html: bool,
341    ) -> PyDataFusionResult<String> {
342        // Get the Python formatter and config
343        let PythonFormatter { formatter, config } = get_python_formatter_with_config(py)?;
344
345        let is_ipython = *is_ipython_env(py);
346
347        let (cached_batches, should_cache) = {
348            let mut cache = self.batches.lock();
349            let should_cache = is_ipython && cache.is_none();
350            let batches = cache.take();
351            (batches, should_cache)
352        };
353
354        let (batches, has_more) = match cached_batches {
355            Some(b) => b,
356            None => wait_for_future(
357                py,
358                collect_record_batches_to_display(self.df.as_ref().clone(), config),
359            )??,
360        };
361
362        if batches.is_empty() {
363            // This should not be reached, but do it for safety since we index into the vector below
364            return Ok("No data to display".to_string());
365        }
366
367        let table_uuid = uuid::Uuid::new_v4().to_string();
368
369        // Convert record batches to Py<PyAny> list
370        let py_batches = batches
371            .iter()
372            .map(|rb| rb.to_pyarrow(py))
373            .collect::<PyResult<Vec<Bound<'py, PyAny>>>>()?;
374
375        let py_schema = self.schema().into_pyobject(py)?;
376
377        let kwargs = pyo3::types::PyDict::new(py);
378        let py_batches_list = PyList::new(py, py_batches.as_slice())?;
379        kwargs.set_item("batches", py_batches_list)?;
380        kwargs.set_item("schema", py_schema)?;
381        kwargs.set_item("has_more", has_more)?;
382        kwargs.set_item("table_uuid", table_uuid)?;
383
384        let method_name = match as_html {
385            true => "format_html",
386            false => "format_str",
387        };
388
389        let html_result = formatter.call_method(method_name, (), Some(&kwargs))?;
390        let html_str: String = html_result.extract()?;
391
392        if should_cache {
393            let mut cache = self.batches.lock();
394            *cache = Some((batches.clone(), has_more));
395        }
396
397        Ok(html_str)
398    }
399
400    /// Create the physical plan, cache it in `last_plan`, and return the plan together
401    /// with a task context. Centralises the repeated three-line pattern that appears in
402    /// `collect`, `collect_partitioned`, `execute_stream`, and `execute_stream_partitioned`.
403    fn create_and_cache_plan(
404        &self,
405        py: Python,
406    ) -> PyDataFusionResult<(Arc<dyn DFExecutionPlan>, Arc<TaskContext>)> {
407        let df = self.df.as_ref().clone();
408        let new_plan = wait_for_future(py, df.create_physical_plan())??;
409        *self.last_plan.lock() = Some(Arc::clone(&new_plan));
410        let task_ctx = Arc::new(self.df.as_ref().task_ctx());
411        Ok((new_plan, task_ctx))
412    }
413
414    async fn collect_column_inner(&self, column: &str) -> Result<ArrayRef, DataFusionError> {
415        let batches = self
416            .df
417            .as_ref()
418            .clone()
419            .select_columns(&[column])?
420            .collect()
421            .await?;
422
423        let arrays = batches
424            .iter()
425            .map(|b| b.column(0).as_ref())
426            .collect::<Vec<_>>();
427
428        arrow_select::concat::concat(&arrays).map_err(Into::into)
429    }
430}
431
432/// Synchronous wrapper around partitioned [`SendableRecordBatchStream`]s used
433/// for the `__arrow_c_stream__` implementation.
434///
435/// It drains each partition's stream sequentially, yielding record batches in
436/// their original partition order. When a `projection` is set, each batch is
437/// converted via `record_batch_into_schema` to apply schema changes per batch.
438struct PartitionedDataFrameStreamReader {
439    streams: Vec<SendableRecordBatchStream>,
440    schema: SchemaRef,
441    projection: Option<SchemaRef>,
442    current: usize,
443}
444
445impl Iterator for PartitionedDataFrameStreamReader {
446    type Item = Result<RecordBatch, ArrowError>;
447
448    fn next(&mut self) -> Option<Self::Item> {
449        while self.current < self.streams.len() {
450            let stream = &mut self.streams[self.current];
451            let fut = poll_next_batch(stream);
452            let result = Python::attach(|py| wait_for_future(py, fut));
453
454            match result {
455                Ok(Ok(Some(batch))) => {
456                    let batch = if let Some(ref schema) = self.projection {
457                        match record_batch_into_schema(batch, schema.as_ref()) {
458                            Ok(b) => b,
459                            Err(e) => return Some(Err(e)),
460                        }
461                    } else {
462                        batch
463                    };
464                    return Some(Ok(batch));
465                }
466                Ok(Ok(None)) => {
467                    self.current += 1;
468                    continue;
469                }
470                Ok(Err(e)) => {
471                    return Some(Err(ArrowError::ExternalError(Box::new(e))));
472                }
473                Err(e) => {
474                    return Some(Err(ArrowError::ExternalError(Box::new(e))));
475                }
476            }
477        }
478
479        None
480    }
481}
482
483impl RecordBatchReader for PartitionedDataFrameStreamReader {
484    fn schema(&self) -> SchemaRef {
485        self.schema.clone()
486    }
487}
488
489#[pymethods]
490impl PyDataFrame {
491    /// Enable selection for `df[col]`, `df[col1, col2, col3]`, and `df[[col1, col2, col3]]`
492    fn __getitem__(&self, key: Bound<'_, PyAny>) -> PyDataFusionResult<Self> {
493        if let Ok(key) = key.extract::<PyBackedStr>() {
494            // df[col]
495            self.select_exprs(vec![key])
496        } else if let Ok(tuple) = key.cast::<PyTuple>() {
497            // df[col1, col2, col3]
498            let keys = tuple
499                .iter()
500                .map(|item| item.extract::<PyBackedStr>())
501                .collect::<PyResult<Vec<PyBackedStr>>>()?;
502            self.select_exprs(keys)
503        } else if let Ok(keys) = key.extract::<Vec<PyBackedStr>>() {
504            // df[[col1, col2, col3]]
505            self.select_exprs(keys)
506        } else {
507            let message = "DataFrame can only be indexed by string index or indices".to_string();
508            Err(PyDataFusionError::Common(message))
509        }
510    }
511
512    fn __repr__(&self, py: Python) -> PyDataFusionResult<String> {
513        self.prepare_repr_string(py, false)
514    }
515
516    #[staticmethod]
517    #[expect(unused_variables)]
518    fn default_str_repr<'py>(
519        batches: Vec<Bound<'py, PyAny>>,
520        schema: &Bound<'py, PyAny>,
521        has_more: bool,
522        table_uuid: &str,
523    ) -> PyResult<String> {
524        let batches = batches
525            .into_iter()
526            .map(|batch| RecordBatch::from_pyarrow_bound(&batch))
527            .collect::<PyResult<Vec<RecordBatch>>>()?
528            .into_iter()
529            .filter(|batch| batch.num_rows() > 0)
530            .collect::<Vec<_>>();
531
532        if batches.is_empty() {
533            return Ok("No data to display".to_owned());
534        }
535
536        let batches_as_displ =
537            pretty::pretty_format_batches(&batches).map_err(py_datafusion_err)?;
538
539        let additional_str = match has_more {
540            true => "\nData truncated.",
541            false => "",
542        };
543
544        Ok(format!("DataFrame()\n{batches_as_displ}{additional_str}"))
545    }
546
547    fn _repr_html_(&self, py: Python) -> PyDataFusionResult<String> {
548        self.prepare_repr_string(py, true)
549    }
550
551    /// Calculate summary statistics for a DataFrame
552    fn describe(&self, py: Python) -> PyDataFusionResult<Self> {
553        let df = self.df.as_ref().clone();
554        let stat_df = wait_for_future(py, df.describe())??;
555        Ok(Self::new(stat_df))
556    }
557
558    /// Returns the schema from the logical plan
559    fn schema(&self) -> PyArrowType<Schema> {
560        PyArrowType(self.df.schema().as_arrow().clone())
561    }
562
563    /// Convert this DataFrame into a Table Provider that can be used in register_table
564    /// By convention, into_... methods consume self and return the new object.
565    /// Disabling the clippy lint, so we can use &self
566    /// because we're working with Python bindings
567    /// where objects are shared
568    #[allow(clippy::wrong_self_convention)]
569    pub fn into_view(&self, temporary: bool) -> PyDataFusionResult<PyTable> {
570        let table_provider = if temporary {
571            Arc::new(TempViewTable::new(Arc::clone(&self.df))) as Arc<dyn TableProvider>
572        } else {
573            // Call the underlying Rust DataFrame::into_view method.
574            // Note that the Rust method consumes self; here we clone the inner Arc<DataFrame>
575            // so that we don't invalidate this PyDataFrame.
576            self.df.as_ref().clone().into_view()
577        };
578        Ok(PyTable::from(table_provider))
579    }
580
581    fn alias(&self, alias: &str) -> PyDataFusionResult<Self> {
582        let df = self.df.as_ref().clone().alias(alias)?;
583        Ok(Self::new(df))
584    }
585
586    #[pyo3(signature = (*args))]
587    fn select_exprs(&self, args: Vec<PyBackedStr>) -> PyDataFusionResult<Self> {
588        let args = args.iter().map(|s| s.as_ref()).collect::<Vec<&str>>();
589        let df = self.df.as_ref().clone().select_exprs(&args)?;
590        Ok(Self::new(df))
591    }
592
593    #[pyo3(signature = (*args))]
594    fn select(&self, args: Vec<PyExpr>) -> PyDataFusionResult<Self> {
595        let expr: Vec<Expr> = args.into_iter().map(|e| e.into()).collect();
596        let df = self.df.as_ref().clone().select(expr)?;
597        Ok(Self::new(df))
598    }
599
600    #[pyo3(signature = (*args))]
601    fn drop(&self, args: Vec<PyBackedStr>) -> PyDataFusionResult<Self> {
602        let cols = args.iter().map(|s| s.as_ref()).collect::<Vec<&str>>();
603        let df = self.df.as_ref().clone().drop_columns(&cols)?;
604        Ok(Self::new(df))
605    }
606
607    /// Apply window function expressions to the DataFrame
608    #[pyo3(signature = (*exprs))]
609    fn window(&self, exprs: Vec<PyExpr>) -> PyDataFusionResult<Self> {
610        let window_exprs = exprs.into_iter().map(|e| e.into()).collect();
611        let df = self.df.as_ref().clone().window(window_exprs)?;
612        Ok(Self::new(df))
613    }
614
615    fn filter(&self, predicate: PyExpr) -> PyDataFusionResult<Self> {
616        let df = self.df.as_ref().clone().filter(predicate.into())?;
617        Ok(Self::new(df))
618    }
619
620    fn parse_sql_expr(&self, expr: PyBackedStr) -> PyDataFusionResult<PyExpr> {
621        self.df
622            .as_ref()
623            .parse_sql_expr(&expr)
624            .map(PyExpr::from)
625            .map_err(PyDataFusionError::from)
626    }
627
628    fn with_column(&self, name: &str, expr: PyExpr) -> PyDataFusionResult<Self> {
629        let df = self.df.as_ref().clone().with_column(name, expr.into())?;
630        Ok(Self::new(df))
631    }
632
633    fn with_columns(&self, exprs: Vec<PyExpr>) -> PyDataFusionResult<Self> {
634        let mut df = self.df.as_ref().clone();
635        for expr in exprs {
636            let expr: Expr = expr.into();
637            let name = format!("{}", expr.schema_name());
638            df = df.with_column(name.as_str(), expr)?
639        }
640        Ok(Self::new(df))
641    }
642
643    /// Rename one column by applying a new projection. This is a no-op if the column to be
644    /// renamed does not exist.
645    fn with_column_renamed(&self, old_name: &str, new_name: &str) -> PyDataFusionResult<Self> {
646        let df = self
647            .df
648            .as_ref()
649            .clone()
650            .with_column_renamed(old_name, new_name)?;
651        Ok(Self::new(df))
652    }
653
654    fn aggregate(&self, group_by: Vec<PyExpr>, aggs: Vec<PyExpr>) -> PyDataFusionResult<Self> {
655        let group_by = group_by.into_iter().map(|e| e.into()).collect();
656        let aggs = aggs.into_iter().map(|e| e.into()).collect();
657        let df = self.df.as_ref().clone().aggregate(group_by, aggs)?;
658        Ok(Self::new(df))
659    }
660
661    #[pyo3(signature = (*exprs))]
662    fn sort(&self, exprs: Vec<PySortExpr>) -> PyDataFusionResult<Self> {
663        let exprs = to_sort_expressions(exprs);
664        let df = self.df.as_ref().clone().sort(exprs)?;
665        Ok(Self::new(df))
666    }
667
668    #[pyo3(signature = (count, offset=0))]
669    fn limit(&self, count: usize, offset: usize) -> PyDataFusionResult<Self> {
670        let df = self.df.as_ref().clone().limit(offset, Some(count))?;
671        Ok(Self::new(df))
672    }
673
674    /// Executes the plan, returning a list of `RecordBatch`es.
675    /// Unless some order is specified in the plan, there is no
676    /// guarantee of the order of the result.
677    fn collect<'py>(&self, py: Python<'py>) -> PyResult<Vec<Bound<'py, PyAny>>> {
678        let (plan, task_ctx) = self.create_and_cache_plan(py)?;
679        let batches =
680            wait_for_future(py, df_collect(plan, task_ctx))?.map_err(PyDataFusionError::from)?;
681        // cannot use PyResult<Vec<RecordBatch>> return type due to
682        // https://github.com/PyO3/pyo3/issues/1813
683        batches.into_iter().map(|rb| rb.to_pyarrow(py)).collect()
684    }
685
686    /// Cache DataFrame.
687    fn cache(&self, py: Python) -> PyDataFusionResult<Self> {
688        let df = wait_for_future(py, self.df.as_ref().clone().cache())??;
689        Ok(Self::new(df))
690    }
691
692    /// Executes this DataFrame and collects all results into a vector of vector of RecordBatch
693    /// maintaining the input partitioning.
694    fn collect_partitioned<'py>(&self, py: Python<'py>) -> PyResult<Vec<Vec<Bound<'py, PyAny>>>> {
695        let (plan, task_ctx) = self.create_and_cache_plan(py)?;
696        let batches = wait_for_future(py, df_collect_partitioned(plan, task_ctx))?
697            .map_err(PyDataFusionError::from)?;
698
699        batches
700            .into_iter()
701            .map(|rbs| rbs.into_iter().map(|rb| rb.to_pyarrow(py)).collect())
702            .collect()
703    }
704
705    fn collect_column<'py>(&self, py: Python<'py>, column: &str) -> PyResult<Bound<'py, PyAny>> {
706        wait_for_future(py, self.collect_column_inner(column))?
707            .map_err(PyDataFusionError::from)?
708            .to_data()
709            .to_pyarrow(py)
710    }
711
712    /// Print the result, 20 lines by default
713    #[pyo3(signature = (num=20))]
714    fn show(&self, py: Python, num: usize) -> PyDataFusionResult<()> {
715        let mut df = self.df.as_ref().clone();
716        df = match self.df.logical_plan() {
717            LogicalPlan::Explain(_) | LogicalPlan::Analyze(_) => {
718                // Explain and Analyzer require they are at the top
719                // of the plan, so do not add a limit.
720                df
721            }
722            _ => df.limit(0, Some(num))?,
723        };
724        print_dataframe(py, df)
725    }
726
727    /// Filter out duplicate rows
728    fn distinct(&self) -> PyDataFusionResult<Self> {
729        let df = self.df.as_ref().clone().distinct()?;
730        Ok(Self::new(df))
731    }
732
733    fn join(
734        &self,
735        right: PyDataFrame,
736        how: &str,
737        left_on: Vec<PyBackedStr>,
738        right_on: Vec<PyBackedStr>,
739        coalesce_keys: bool,
740    ) -> PyDataFusionResult<Self> {
741        let join_type = match how {
742            "inner" => JoinType::Inner,
743            "left" => JoinType::Left,
744            "right" => JoinType::Right,
745            "full" => JoinType::Full,
746            "semi" => JoinType::LeftSemi,
747            "anti" => JoinType::LeftAnti,
748            how => {
749                return Err(PyDataFusionError::Common(format!(
750                    "The join type {how} does not exist or is not implemented"
751                )));
752            }
753        };
754
755        let left_keys = left_on.iter().map(|s| s.as_ref()).collect::<Vec<&str>>();
756        let right_keys = right_on.iter().map(|s| s.as_ref()).collect::<Vec<&str>>();
757
758        let mut df = self.df.as_ref().clone().join(
759            right.df.as_ref().clone(),
760            join_type,
761            &left_keys,
762            &right_keys,
763            None,
764        )?;
765
766        if coalesce_keys {
767            let mutual_keys = left_keys
768                .iter()
769                .zip(right_keys.iter())
770                .filter(|(l, r)| l == r)
771                .map(|(key, _)| *key)
772                .collect::<Vec<_>>();
773
774            let fields_to_coalesce = mutual_keys
775                .iter()
776                .map(|name| {
777                    let qualified_fields = df
778                        .logical_plan()
779                        .schema()
780                        .qualified_fields_with_unqualified_name(name);
781                    (*name, qualified_fields)
782                })
783                .filter(|(_, fields)| fields.len() == 2)
784                .collect::<Vec<_>>();
785
786            let expr: Vec<Expr> = df
787                .logical_plan()
788                .schema()
789                .fields()
790                .into_iter()
791                .enumerate()
792                .map(|(idx, _)| df.logical_plan().schema().qualified_field(idx))
793                .filter_map(|(qualifier, field)| {
794                    if let Some((key_name, qualified_fields)) = fields_to_coalesce
795                        .iter()
796                        .find(|(_, qf)| qf.contains(&(qualifier, field)))
797                    {
798                        // Only add the coalesce expression once (when we encounter the first field)
799                        // Skip the second field (it's already included in to coalesce)
800                        if (qualifier, field) == qualified_fields[0] {
801                            let left_col = Expr::Column(Column::from(qualified_fields[0]));
802                            let right_col = Expr::Column(Column::from(qualified_fields[1]));
803                            return Some(coalesce(vec![left_col, right_col]).alias(*key_name));
804                        }
805                        None
806                    } else {
807                        Some(Expr::Column(Column::from((qualifier, field))))
808                    }
809                })
810                .collect();
811            df = df.select(expr)?;
812        }
813
814        Ok(Self::new(df))
815    }
816
817    fn join_on(
818        &self,
819        right: PyDataFrame,
820        on_exprs: Vec<PyExpr>,
821        how: &str,
822    ) -> PyDataFusionResult<Self> {
823        let join_type = match how {
824            "inner" => JoinType::Inner,
825            "left" => JoinType::Left,
826            "right" => JoinType::Right,
827            "full" => JoinType::Full,
828            "semi" => JoinType::LeftSemi,
829            "anti" => JoinType::LeftAnti,
830            how => {
831                return Err(PyDataFusionError::Common(format!(
832                    "The join type {how} does not exist or is not implemented"
833                )));
834            }
835        };
836        let exprs: Vec<Expr> = on_exprs.into_iter().map(|e| e.into()).collect();
837
838        let df = self
839            .df
840            .as_ref()
841            .clone()
842            .join_on(right.df.as_ref().clone(), join_type, exprs)?;
843        Ok(Self::new(df))
844    }
845
846    /// Print the query plan
847    #[pyo3(signature = (verbose=false, analyze=false, format=None))]
848    fn explain(
849        &self,
850        py: Python,
851        verbose: bool,
852        analyze: bool,
853        format: Option<&str>,
854    ) -> PyDataFusionResult<()> {
855        let explain_format = match format {
856            Some(f) => f
857                .parse::<datafusion::common::format::ExplainFormat>()
858                .map_err(|e| {
859                    PyDataFusionError::Common(format!("Invalid explain format '{}': {}", f, e))
860                })?,
861            None => datafusion::common::format::ExplainFormat::Indent,
862        };
863        let opts = datafusion::logical_expr::ExplainOption::default()
864            .with_verbose(verbose)
865            .with_analyze(analyze)
866            .with_format(explain_format);
867        let df = self.df.as_ref().clone().explain_with_options(opts)?;
868        print_dataframe(py, df)
869    }
870
871    /// Get the logical plan for this `DataFrame`
872    fn logical_plan(&self) -> PyResult<PyLogicalPlan> {
873        Ok(self.df.as_ref().clone().logical_plan().clone().into())
874    }
875
876    /// Get the optimized logical plan for this `DataFrame`
877    fn optimized_logical_plan(&self) -> PyDataFusionResult<PyLogicalPlan> {
878        Ok(self.df.as_ref().clone().into_optimized_plan()?.into())
879    }
880
881    /// Get the execution plan for this `DataFrame`
882    ///
883    /// If the DataFrame has already been executed (e.g. via `collect()`),
884    /// returns the cached plan which includes populated metrics.
885    fn execution_plan(&self, py: Python) -> PyDataFusionResult<PyExecutionPlan> {
886        if let Some(plan) = self.last_plan.lock().as_ref() {
887            return Ok(PyExecutionPlan::new(Arc::clone(plan)));
888        }
889        let plan = wait_for_future(py, self.df.as_ref().clone().create_physical_plan())??;
890        Ok(plan.into())
891    }
892
893    /// Repartition a `DataFrame` based on a logical partitioning scheme.
894    fn repartition(&self, num: usize) -> PyDataFusionResult<Self> {
895        let new_df = self
896            .df
897            .as_ref()
898            .clone()
899            .repartition(Partitioning::RoundRobinBatch(num))?;
900        Ok(Self::new(new_df))
901    }
902
903    /// Repartition a `DataFrame` based on a logical partitioning scheme.
904    #[pyo3(signature = (*args, num))]
905    fn repartition_by_hash(&self, args: Vec<PyExpr>, num: usize) -> PyDataFusionResult<Self> {
906        let expr = args.into_iter().map(|py_expr| py_expr.into()).collect();
907        let new_df = self
908            .df
909            .as_ref()
910            .clone()
911            .repartition(Partitioning::Hash(expr, num))?;
912        Ok(Self::new(new_df))
913    }
914
915    /// Calculate the union of two `DataFrame`s, preserving duplicate rows.The
916    /// two `DataFrame`s must have exactly the same schema
917    #[pyo3(signature = (py_df, distinct=false))]
918    fn union(&self, py_df: PyDataFrame, distinct: bool) -> PyDataFusionResult<Self> {
919        let new_df = if distinct {
920            self.df
921                .as_ref()
922                .clone()
923                .union_distinct(py_df.df.as_ref().clone())?
924        } else {
925            self.df.as_ref().clone().union(py_df.df.as_ref().clone())?
926        };
927
928        Ok(Self::new(new_df))
929    }
930
931    #[pyo3(signature = (columns, preserve_nulls=true, recursions=None))]
932    fn unnest_columns(
933        &self,
934        columns: Vec<String>,
935        preserve_nulls: bool,
936        recursions: Option<Vec<(String, String, usize)>>,
937    ) -> PyDataFusionResult<Self> {
938        let unnest_options = build_unnest_options(preserve_nulls, recursions);
939        let cols = columns.iter().map(|s| s.as_ref()).collect::<Vec<&str>>();
940        let df = self
941            .df
942            .as_ref()
943            .clone()
944            .unnest_columns_with_options(&cols, unnest_options)?;
945        Ok(Self::new(df))
946    }
947
948    /// Calculate the intersection of two `DataFrame`s.  The two `DataFrame`s must have exactly the same schema
949    #[pyo3(signature = (py_df, distinct=false))]
950    fn intersect(&self, py_df: PyDataFrame, distinct: bool) -> PyDataFusionResult<Self> {
951        let base = self.df.as_ref().clone();
952        let other = py_df.df.as_ref().clone();
953        let new_df = if distinct {
954            base.intersect_distinct(other)?
955        } else {
956            base.intersect(other)?
957        };
958        Ok(Self::new(new_df))
959    }
960
961    /// Calculate the exception of two `DataFrame`s.  The two `DataFrame`s must have exactly the same schema
962    #[pyo3(signature = (py_df, distinct=false))]
963    fn except_all(&self, py_df: PyDataFrame, distinct: bool) -> PyDataFusionResult<Self> {
964        let base = self.df.as_ref().clone();
965        let other = py_df.df.as_ref().clone();
966        let new_df = if distinct {
967            base.except_distinct(other)?
968        } else {
969            base.except(other)?
970        };
971        Ok(Self::new(new_df))
972    }
973
974    /// Union two DataFrames matching columns by name
975    #[pyo3(signature = (py_df, distinct=false))]
976    fn union_by_name(&self, py_df: PyDataFrame, distinct: bool) -> PyDataFusionResult<Self> {
977        let base = self.df.as_ref().clone();
978        let other = py_df.df.as_ref().clone();
979        let new_df = if distinct {
980            base.union_by_name_distinct(other)?
981        } else {
982            base.union_by_name(other)?
983        };
984        Ok(Self::new(new_df))
985    }
986
987    /// Deduplicate rows based on specific columns, keeping the first row per group
988    fn distinct_on(
989        &self,
990        on_expr: Vec<PyExpr>,
991        select_expr: Vec<PyExpr>,
992        sort_expr: Option<Vec<PySortExpr>>,
993    ) -> PyDataFusionResult<Self> {
994        let on_expr = on_expr.into_iter().map(|e| e.into()).collect();
995        let select_expr = select_expr.into_iter().map(|e| e.into()).collect();
996        let sort_expr = sort_expr.map(to_sort_expressions);
997        let df = self
998            .df
999            .as_ref()
1000            .clone()
1001            .distinct_on(on_expr, select_expr, sort_expr)?;
1002        Ok(Self::new(df))
1003    }
1004
1005    /// Sort by column expressions with ascending order and nulls last
1006    fn sort_by(&self, exprs: Vec<PyExpr>) -> PyDataFusionResult<Self> {
1007        let exprs = exprs.into_iter().map(|e| e.into()).collect();
1008        let df = self.df.as_ref().clone().sort_by(exprs)?;
1009        Ok(Self::new(df))
1010    }
1011
1012    /// Return fully qualified column expressions for the given column names
1013    fn find_qualified_columns(&self, names: Vec<String>) -> PyDataFusionResult<Vec<PyExpr>> {
1014        let name_refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
1015        let qualified = self.df.find_qualified_columns(&name_refs)?;
1016        Ok(qualified
1017            .into_iter()
1018            .map(|q| Expr::Column(Column::from(q)).into())
1019            .collect())
1020    }
1021
1022    /// Write a `DataFrame` to a CSV file.
1023    fn write_csv(
1024        &self,
1025        py: Python,
1026        path: &str,
1027        with_header: bool,
1028        write_options: Option<PyDataFrameWriteOptions>,
1029    ) -> PyDataFusionResult<()> {
1030        let csv_options = CsvOptions {
1031            has_header: Some(with_header),
1032            ..Default::default()
1033        };
1034        let write_options = write_options
1035            .map(DataFrameWriteOptions::from)
1036            .unwrap_or_default();
1037
1038        wait_for_future(
1039            py,
1040            self.df
1041                .as_ref()
1042                .clone()
1043                .write_csv(path, write_options, Some(csv_options)),
1044        )??;
1045        Ok(())
1046    }
1047
1048    /// Write a `DataFrame` to a Parquet file.
1049    #[pyo3(signature = (
1050        path,
1051        compression="zstd",
1052        compression_level=None,
1053        write_options=None,
1054        ))]
1055    fn write_parquet(
1056        &self,
1057        path: &str,
1058        compression: &str,
1059        compression_level: Option<u32>,
1060        write_options: Option<PyDataFrameWriteOptions>,
1061        py: Python,
1062    ) -> PyDataFusionResult<()> {
1063        fn verify_compression_level(cl: Option<u32>) -> Result<u32, PyErr> {
1064            cl.ok_or(PyValueError::new_err("compression_level is not defined"))
1065        }
1066
1067        let _validated = match compression.to_lowercase().as_str() {
1068            "snappy" => Compression::SNAPPY,
1069            "gzip" => Compression::GZIP(
1070                GzipLevel::try_new(compression_level.unwrap_or(6))
1071                    .map_err(|e| PyValueError::new_err(format!("{e}")))?,
1072            ),
1073            "brotli" => Compression::BROTLI(
1074                BrotliLevel::try_new(verify_compression_level(compression_level)?)
1075                    .map_err(|e| PyValueError::new_err(format!("{e}")))?,
1076            ),
1077            "zstd" => Compression::ZSTD(
1078                ZstdLevel::try_new(verify_compression_level(compression_level)? as i32)
1079                    .map_err(|e| PyValueError::new_err(format!("{e}")))?,
1080            ),
1081            "lzo" => Compression::LZO,
1082            "lz4" => Compression::LZ4,
1083            "lz4_raw" => Compression::LZ4_RAW,
1084            "uncompressed" => Compression::UNCOMPRESSED,
1085            _ => {
1086                return Err(PyDataFusionError::Common(format!(
1087                    "Unrecognized compression type {compression}"
1088                )));
1089            }
1090        };
1091
1092        let mut compression_string = compression.to_string();
1093        if let Some(level) = compression_level {
1094            compression_string.push_str(&format!("({level})"));
1095        }
1096
1097        let mut options = TableParquetOptions::default();
1098        options.global.compression = Some(compression_string);
1099        let write_options = write_options
1100            .map(DataFrameWriteOptions::from)
1101            .unwrap_or_default();
1102
1103        wait_for_future(
1104            py,
1105            self.df
1106                .as_ref()
1107                .clone()
1108                .write_parquet(path, write_options, Option::from(options)),
1109        )??;
1110        Ok(())
1111    }
1112
1113    /// Write a `DataFrame` to a Parquet file, using advanced options.
1114    fn write_parquet_with_options(
1115        &self,
1116        path: &str,
1117        options: PyParquetWriterOptions,
1118        column_specific_options: HashMap<String, PyParquetColumnOptions>,
1119        write_options: Option<PyDataFrameWriteOptions>,
1120        py: Python,
1121    ) -> PyDataFusionResult<()> {
1122        let table_options = TableParquetOptions {
1123            global: options.options,
1124            column_specific_options: column_specific_options
1125                .into_iter()
1126                .map(|(k, v)| (k, v.options))
1127                .collect(),
1128            ..Default::default()
1129        };
1130        let write_options = write_options
1131            .map(DataFrameWriteOptions::from)
1132            .unwrap_or_default();
1133        wait_for_future(
1134            py,
1135            self.df.as_ref().clone().write_parquet(
1136                path,
1137                write_options,
1138                Option::from(table_options),
1139            ),
1140        )??;
1141        Ok(())
1142    }
1143
1144    /// Executes a query and writes the results to a partitioned JSON file.
1145    fn write_json(
1146        &self,
1147        path: &str,
1148        py: Python,
1149        write_options: Option<PyDataFrameWriteOptions>,
1150    ) -> PyDataFusionResult<()> {
1151        let write_options = write_options
1152            .map(DataFrameWriteOptions::from)
1153            .unwrap_or_default();
1154        wait_for_future(
1155            py,
1156            self.df
1157                .as_ref()
1158                .clone()
1159                .write_json(path, write_options, None),
1160        )??;
1161        Ok(())
1162    }
1163
1164    fn write_table(
1165        &self,
1166        py: Python,
1167        table_name: &str,
1168        write_options: Option<PyDataFrameWriteOptions>,
1169    ) -> PyDataFusionResult<()> {
1170        let write_options = write_options
1171            .map(DataFrameWriteOptions::from)
1172            .unwrap_or_default();
1173        wait_for_future(
1174            py,
1175            self.df
1176                .as_ref()
1177                .clone()
1178                .write_table(table_name, write_options),
1179        )??;
1180        Ok(())
1181    }
1182
1183    /// Convert to Arrow Table
1184    /// Collect the batches and pass to Arrow Table
1185    fn to_arrow_table(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
1186        let batches = self.collect(py)?.into_pyobject(py)?;
1187
1188        // only use the DataFrame's schema if there are no batches, otherwise let the schema be
1189        // determined from the batches (avoids some inconsistencies with nullable columns)
1190        let args = if batches.len()? == 0 {
1191            let schema = self.schema().into_pyobject(py)?;
1192            PyTuple::new(py, &[batches, schema])?
1193        } else {
1194            PyTuple::new(py, &[batches])?
1195        };
1196
1197        // Instantiate pyarrow Table object and use its from_batches method
1198        let table_class = py.import("pyarrow")?.getattr("Table")?;
1199        let table: Py<PyAny> = table_class.call_method1("from_batches", args)?.into();
1200        Ok(table)
1201    }
1202
1203    #[pyo3(signature = (requested_schema=None))]
1204    fn __arrow_c_stream__<'py>(
1205        &'py self,
1206        py: Python<'py>,
1207        requested_schema: Option<Bound<'py, PyCapsule>>,
1208    ) -> PyDataFusionResult<Bound<'py, PyCapsule>> {
1209        let df = self.df.as_ref().clone();
1210        let streams = spawn_future(py, async move { df.execute_stream_partitioned().await })?;
1211
1212        let mut schema: Schema = self.df.schema().to_owned().as_arrow().clone();
1213        let mut projection: Option<SchemaRef> = None;
1214
1215        if let Some(schema_capsule) = requested_schema {
1216            let data: NonNull<FFI_ArrowSchema> = schema_capsule
1217                .pointer_checked(Some(c"arrow_schema"))?
1218                .cast();
1219            let schema_ptr = unsafe { data.as_ref() };
1220            let desired_schema = Schema::try_from(schema_ptr)?;
1221
1222            schema = project_schema(schema, desired_schema)?;
1223            projection = Some(Arc::new(schema.clone()));
1224        }
1225
1226        let schema_ref = Arc::new(schema.clone());
1227
1228        let reader = PartitionedDataFrameStreamReader {
1229            streams,
1230            schema: schema_ref,
1231            projection,
1232            current: 0,
1233        };
1234        let reader: Box<dyn RecordBatchReader + Send> = Box::new(reader);
1235
1236        // Create the Arrow stream and wrap it in a PyCapsule. The default
1237        // destructor provided by PyO3 will drop the stream unless ownership is
1238        // transferred to PyArrow during import.
1239        let stream = FFI_ArrowArrayStream::new(reader);
1240        let name = CString::new(ARROW_ARRAY_STREAM_NAME.to_bytes()).unwrap();
1241        let capsule = PyCapsule::new(py, stream, Some(name))?;
1242        Ok(capsule)
1243    }
1244
1245    fn execute_stream(&self, py: Python) -> PyDataFusionResult<PyRecordBatchStream> {
1246        let (plan, task_ctx) = self.create_and_cache_plan(py)?;
1247        let stream = spawn_future(py, async move { df_execute_stream(plan, task_ctx) })?;
1248        Ok(PyRecordBatchStream::new(stream))
1249    }
1250
1251    fn execute_stream_partitioned(&self, py: Python) -> PyResult<Vec<PyRecordBatchStream>> {
1252        let (plan, task_ctx) = self.create_and_cache_plan(py)?;
1253        let streams = spawn_future(
1254            py,
1255            async move { df_execute_stream_partitioned(plan, task_ctx) },
1256        )?;
1257        Ok(streams.into_iter().map(PyRecordBatchStream::new).collect())
1258    }
1259
1260    /// Convert to pandas dataframe with pyarrow
1261    /// Collect the batches, pass to Arrow Table & then convert to Pandas DataFrame
1262    fn to_pandas(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
1263        let table = self.to_arrow_table(py)?;
1264
1265        // See also: https://arrow.apache.org/docs/python/generated/pyarrow.Table.html#pyarrow.Table.to_pandas
1266        let result = table.call_method0(py, "to_pandas")?;
1267        Ok(result)
1268    }
1269
1270    /// Convert to Python list using pyarrow
1271    /// Each list item represents one row encoded as dictionary
1272    fn to_pylist(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
1273        let table = self.to_arrow_table(py)?;
1274
1275        // See also: https://arrow.apache.org/docs/python/generated/pyarrow.Table.html#pyarrow.Table.to_pylist
1276        let result = table.call_method0(py, "to_pylist")?;
1277        Ok(result)
1278    }
1279
1280    /// Convert to Python dictionary using pyarrow
1281    /// Each dictionary key is a column and the dictionary value represents the column values
1282    fn to_pydict(&self, py: Python) -> PyResult<Py<PyAny>> {
1283        let table = self.to_arrow_table(py)?;
1284
1285        // See also: https://arrow.apache.org/docs/python/generated/pyarrow.Table.html#pyarrow.Table.to_pydict
1286        let result = table.call_method0(py, "to_pydict")?;
1287        Ok(result)
1288    }
1289
1290    /// Convert to polars dataframe with pyarrow
1291    /// Collect the batches, pass to Arrow Table & then convert to polars DataFrame
1292    fn to_polars(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
1293        let table = self.to_arrow_table(py)?;
1294        let dataframe = py.import("polars")?.getattr("DataFrame")?;
1295        let args = PyTuple::new(py, &[table])?;
1296        let result: Py<PyAny> = dataframe.call1(args)?.into();
1297        Ok(result)
1298    }
1299
1300    // Executes this DataFrame to get the total number of rows.
1301    fn count(&self, py: Python) -> PyDataFusionResult<usize> {
1302        Ok(wait_for_future(py, self.df.as_ref().clone().count())??)
1303    }
1304
1305    /// Fill null values with a specified value for specific columns
1306    #[pyo3(signature = (value, columns=None))]
1307    fn fill_null(
1308        &self,
1309        value: Py<PyAny>,
1310        columns: Option<Vec<PyBackedStr>>,
1311        py: Python,
1312    ) -> PyDataFusionResult<Self> {
1313        let scalar_value: PyScalarValue = value.extract(py)?;
1314
1315        let cols = match columns {
1316            Some(col_names) => col_names.iter().map(|c| c.to_string()).collect(),
1317            None => Vec::new(), // Empty vector means fill null for all columns
1318        };
1319
1320        let df = self.df.as_ref().clone().fill_null(scalar_value.0, cols)?;
1321        Ok(Self::new(df))
1322    }
1323}
1324
1325#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1326#[pyclass(
1327    from_py_object,
1328    frozen,
1329    eq,
1330    eq_int,
1331    name = "InsertOp",
1332    module = "datafusion"
1333)]
1334pub enum PyInsertOp {
1335    APPEND,
1336    REPLACE,
1337    OVERWRITE,
1338}
1339
1340impl From<PyInsertOp> for InsertOp {
1341    fn from(value: PyInsertOp) -> Self {
1342        match value {
1343            PyInsertOp::APPEND => InsertOp::Append,
1344            PyInsertOp::REPLACE => InsertOp::Replace,
1345            PyInsertOp::OVERWRITE => InsertOp::Overwrite,
1346        }
1347    }
1348}
1349
1350#[derive(Debug, Clone)]
1351#[pyclass(
1352    from_py_object,
1353    frozen,
1354    name = "DataFrameWriteOptions",
1355    module = "datafusion"
1356)]
1357pub struct PyDataFrameWriteOptions {
1358    insert_operation: InsertOp,
1359    single_file_output: bool,
1360    partition_by: Vec<String>,
1361    sort_by: Vec<SortExpr>,
1362}
1363
1364impl From<PyDataFrameWriteOptions> for DataFrameWriteOptions {
1365    fn from(value: PyDataFrameWriteOptions) -> Self {
1366        DataFrameWriteOptions::new()
1367            .with_insert_operation(value.insert_operation)
1368            .with_single_file_output(value.single_file_output)
1369            .with_partition_by(value.partition_by)
1370            .with_sort_by(value.sort_by)
1371    }
1372}
1373
1374#[pymethods]
1375impl PyDataFrameWriteOptions {
1376    #[new]
1377    fn new(
1378        insert_operation: Option<PyInsertOp>,
1379        single_file_output: bool,
1380        partition_by: Option<Vec<String>>,
1381        sort_by: Option<Vec<PySortExpr>>,
1382    ) -> Self {
1383        let insert_operation = insert_operation.map(Into::into).unwrap_or(InsertOp::Append);
1384        let sort_by = sort_by
1385            .unwrap_or_default()
1386            .into_iter()
1387            .map(Into::into)
1388            .collect();
1389        Self {
1390            insert_operation,
1391            single_file_output,
1392            partition_by: partition_by.unwrap_or_default(),
1393            sort_by,
1394        }
1395    }
1396}
1397
1398fn build_unnest_options(
1399    preserve_nulls: bool,
1400    recursions: Option<Vec<(String, String, usize)>>,
1401) -> UnnestOptions {
1402    let mut opts = UnnestOptions::default().with_preserve_nulls(preserve_nulls);
1403    if let Some(recs) = recursions {
1404        opts.recursions = recs
1405            .into_iter()
1406            .map(
1407                |(input, output, depth)| datafusion::common::RecursionUnnestOption {
1408                    input_column: datafusion::common::Column::from(input.as_str()),
1409                    output_column: datafusion::common::Column::from(output.as_str()),
1410                    depth,
1411                },
1412            )
1413            .collect();
1414    }
1415    opts
1416}
1417
1418/// Print DataFrame
1419fn print_dataframe(py: Python, df: DataFrame) -> PyDataFusionResult<()> {
1420    // Get string representation of record batches
1421    let batches = wait_for_future(py, df.collect())??;
1422    let result = if batches.is_empty() {
1423        "DataFrame has no rows".to_string()
1424    } else {
1425        match pretty::pretty_format_batches(&batches) {
1426            Ok(batch) => format!("DataFrame()\n{batch}"),
1427            Err(err) => format!("Error: {:?}", err.to_string()),
1428        }
1429    };
1430
1431    // Import the Python 'builtins' module to access the print function
1432    // Note that println! does not print to the Python debug console and is not visible in notebooks for instance
1433    let print = py.import("builtins")?.getattr("print")?;
1434    print.call1((result,))?;
1435    Ok(())
1436}
1437
1438fn project_schema(from_schema: Schema, to_schema: Schema) -> Result<Schema, ArrowError> {
1439    let merged_schema = Schema::try_merge(vec![from_schema, to_schema.clone()])?;
1440
1441    let project_indices: Vec<usize> = to_schema
1442        .fields
1443        .iter()
1444        .map(|field| field.name())
1445        .filter_map(|field_name| merged_schema.index_of(field_name).ok())
1446        .collect();
1447
1448    merged_schema.project(&project_indices)
1449}
1450// NOTE: `arrow::compute::cast` in combination with `RecordBatch::try_select` or
1451// DataFusion's `schema::cast_record_batch` do not fully cover the required
1452// transformations here. They will not create missing columns and may insert
1453// nulls for non-nullable fields without erroring. To maintain current behavior
1454// we perform the casting and null checks manually.
1455fn record_batch_into_schema(
1456    record_batch: RecordBatch,
1457    schema: &Schema,
1458) -> Result<RecordBatch, ArrowError> {
1459    let schema = Arc::new(schema.clone());
1460    let base_schema = record_batch.schema();
1461    if base_schema.fields().is_empty() {
1462        // Nothing to project
1463        return Ok(RecordBatch::new_empty(schema));
1464    }
1465
1466    let array_size = record_batch.column(0).len();
1467    let mut data_arrays = Vec::with_capacity(schema.fields().len());
1468
1469    for field in schema.fields() {
1470        let desired_data_type = field.data_type();
1471        if let Some(original_data) = record_batch.column_by_name(field.name()) {
1472            let original_data_type = original_data.data_type();
1473
1474            if can_cast_types(original_data_type, desired_data_type) {
1475                data_arrays.push(arrow::compute::kernels::cast(
1476                    original_data,
1477                    desired_data_type,
1478                )?);
1479            } else if field.is_nullable() {
1480                data_arrays.push(new_null_array(desired_data_type, array_size));
1481            } else {
1482                return Err(ArrowError::CastError(format!(
1483                    "Attempting to cast to non-nullable and non-castable field {} during schema projection.",
1484                    field.name()
1485                )));
1486            }
1487        } else {
1488            if !field.is_nullable() {
1489                return Err(ArrowError::CastError(format!(
1490                    "Attempting to set null to non-nullable field {} during schema projection.",
1491                    field.name()
1492                )));
1493            }
1494            data_arrays.push(new_null_array(desired_data_type, array_size));
1495        }
1496    }
1497
1498    RecordBatch::try_new(schema, data_arrays)
1499}
1500
1501/// This is a helper function to return the first non-empty record batch from executing a DataFrame.
1502/// It additionally returns a bool, which indicates if there are more record batches available.
1503/// We do this so we can determine if we should indicate to the user that the data has been
1504/// truncated. This collects until we have archived both of these two conditions
1505///
1506/// - We have collected our minimum number of rows
1507/// - We have reached our limit, either data size or maximum number of rows
1508///
1509/// Otherwise it will return when the stream has exhausted. If you want a specific number of
1510/// rows, set min_rows == max_rows.
1511async fn collect_record_batches_to_display(
1512    df: DataFrame,
1513    config: FormatterConfig,
1514) -> Result<(Vec<RecordBatch>, bool), DataFusionError> {
1515    let FormatterConfig {
1516        max_bytes,
1517        min_rows,
1518        max_rows,
1519    } = config;
1520
1521    let partitioned_stream = df.execute_stream_partitioned().await?;
1522    let mut stream = futures::stream::iter(partitioned_stream).flatten();
1523    let mut size_estimate_so_far = 0;
1524    let mut rows_so_far = 0;
1525    let mut record_batches = Vec::default();
1526    let mut has_more = false;
1527
1528    // Collect rows until we hit a limit (memory or max_rows) OR reach the guaranteed minimum.
1529    // The minimum rows constraint overrides both memory and row limits to ensure a baseline
1530    // of data is always displayed, even if it temporarily exceeds those limits.
1531    // This provides better UX by guaranteeing users see at least min_rows rows.
1532    while (size_estimate_so_far < max_bytes && rows_so_far < max_rows) || rows_so_far < min_rows {
1533        let mut rb = match stream.next().await {
1534            None => {
1535                break;
1536            }
1537            Some(Ok(r)) => r,
1538            Some(Err(e)) => return Err(e),
1539        };
1540
1541        let mut rows_in_rb = rb.num_rows();
1542        if rows_in_rb > 0 {
1543            size_estimate_so_far += rb.get_array_memory_size();
1544
1545            // When memory limit is exceeded, scale back row count proportionally to stay within budget
1546            if size_estimate_so_far > max_bytes {
1547                let ratio = max_bytes as f32 / size_estimate_so_far as f32;
1548                let total_rows = rows_in_rb + rows_so_far;
1549
1550                // Calculate reduced rows maintaining the memory/data proportion
1551                let mut reduced_row_num = (total_rows as f32 * ratio).round() as usize;
1552                // Ensure we always respect the minimum rows guarantee
1553                if reduced_row_num < min_rows {
1554                    reduced_row_num = min_rows.min(total_rows);
1555                }
1556
1557                let limited_rows_this_rb = reduced_row_num - rows_so_far;
1558                if limited_rows_this_rb < rows_in_rb {
1559                    rows_in_rb = limited_rows_this_rb;
1560                    rb = rb.slice(0, limited_rows_this_rb);
1561                    has_more = true;
1562                }
1563            }
1564
1565            if rows_in_rb + rows_so_far > max_rows {
1566                rb = rb.slice(0, max_rows - rows_so_far);
1567                has_more = true;
1568            }
1569
1570            rows_so_far += rb.num_rows();
1571            record_batches.push(rb);
1572        }
1573    }
1574
1575    if record_batches.is_empty() {
1576        return Ok((Vec::default(), false));
1577    }
1578
1579    if !has_more {
1580        // Data was not already truncated, so check to see if more record batches remain
1581        has_more = match stream.try_next().await {
1582            Ok(None) => false, // reached end
1583            Ok(Some(_)) => true,
1584            Err(_) => false, // Stream disconnected
1585        };
1586    }
1587
1588    Ok((record_batches, has_more))
1589}