| // Licensed to the Apache Software Foundation (ASF) under one | |
| // or more contributor license agreements. See the NOTICE file | |
| // distributed with this work for additional information | |
| // regarding copyright ownership. The ASF licenses this file | |
| // to you under the Apache License, Version 2.0 (the | |
| // "License"); you may not use this file except in compliance | |
| // with the License. You may obtain a copy of the License at | |
| // | |
| // http://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, | |
| // software distributed under the License is distributed on an | |
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| // KIND, either express or implied. See the License for the | |
| // specific language governing permissions and limitations | |
| // under the License. | |
| use std::collections::HashMap; | |
| use std::ffi::CStr; | |
| use std::ptr::NonNull; | |
| use std::str::FromStr; | |
| use std::sync::Arc; | |
| use arrow::array::{Array, ArrayRef, RecordBatch, RecordBatchReader, new_null_array}; | |
| use arrow::compute::can_cast_types; | |
| use arrow::error::ArrowError; | |
| use arrow::ffi::FFI_ArrowSchema; | |
| use arrow::ffi_stream::FFI_ArrowArrayStream; | |
| use arrow::pyarrow::FromPyArrow; | |
| use cstr::cstr; | |
| use datafusion::arrow::datatypes::{Schema, SchemaRef}; | |
| use datafusion::arrow::pyarrow::{PyArrowType, ToPyArrow}; | |
| use datafusion::arrow::util::pretty; | |
| use datafusion::catalog::TableProvider; | |
| use datafusion::common::UnnestOptions; | |
| use datafusion::config::{CsvOptions, ParquetColumnOptions, ParquetOptions, TableParquetOptions}; | |
| use datafusion::dataframe::{DataFrame, DataFrameWriteOptions}; | |
| use datafusion::error::DataFusionError; | |
| use datafusion::execution::SendableRecordBatchStream; | |
| use datafusion::execution::context::TaskContext; | |
| use datafusion::logical_expr::dml::InsertOp; | |
| use datafusion::logical_expr::{LogicalPlan, SortExpr}; | |
| use datafusion::parquet::basic::{BrotliLevel, Compression, GzipLevel, ZstdLevel}; | |
| use datafusion::physical_plan::{ | |
| ExecutionPlan as DFExecutionPlan, collect as df_collect, | |
| collect_partitioned as df_collect_partitioned, execute_stream as df_execute_stream, | |
| execute_stream_partitioned as df_execute_stream_partitioned, | |
| }; | |
| use datafusion::prelude::*; | |
| use datafusion_python_util::{is_ipython_env, spawn_future, wait_for_future}; | |
| use futures::{StreamExt, TryStreamExt}; | |
| use parking_lot::Mutex; | |
| use pyo3::PyErr; | |
| use pyo3::exceptions::PyValueError; | |
| use pyo3::prelude::*; | |
| use pyo3::pybacked::PyBackedStr; | |
| use pyo3::types::{PyCapsule, PyList, PyTuple, PyTupleMethods}; | |
| use crate::common::data_type::PyScalarValue; | |
| use crate::errors::{PyDataFusionError, PyDataFusionResult, py_datafusion_err}; | |
| use crate::expr::PyExpr; | |
| use crate::expr::sort_expr::{PySortExpr, to_sort_expressions}; | |
| use crate::physical_plan::PyExecutionPlan; | |
| use crate::record_batch::{PyRecordBatchStream, poll_next_batch}; | |
| use crate::sql::logical::PyLogicalPlan; | |
| use crate::table::{PyTable, TempViewTable}; | |
| /// File-level static CStr for the Arrow array stream capsule name. | |
| static ARROW_ARRAY_STREAM_NAME: &CStr = cstr!("arrow_array_stream"); | |
| // Type aliases to simplify very complex types used in this file and | |
| // avoid compiler complaints about deeply nested types in struct fields. | |
| type CachedBatches = Option<(Vec<RecordBatch>, bool)>; | |
| type SharedCachedBatches = Arc<Mutex<CachedBatches>>; | |
| /// Configuration for DataFrame display formatting | |
| #[derive(Debug, Clone)] | |
| pub struct FormatterConfig { | |
| /// Maximum memory in bytes to use for display (default: 2MB) | |
| pub max_bytes: usize, | |
| /// Minimum number of rows to display (default: 10) | |
| pub min_rows: usize, | |
| /// Maximum number of rows to include in __repr__ output (default: 10) | |
| pub max_rows: usize, | |
| } | |
| impl Default for FormatterConfig { | |
| fn default() -> Self { | |
| Self { | |
| max_bytes: 2 * 1024 * 1024, // 2MB | |
| min_rows: 10, | |
| max_rows: 10, | |
| } | |
| } | |
| } | |
| impl FormatterConfig { | |
| /// Validates that all configuration values are positive integers. | |
| /// | |
| /// # Returns | |
| /// | |
| /// `Ok(())` if all values are valid, or an `Err` with a descriptive error message. | |
| pub fn validate(&self) -> Result<(), String> { | |
| if self.max_bytes == 0 { | |
| return Err("max_bytes must be a positive integer".to_string()); | |
| } | |
| if self.min_rows == 0 { | |
| return Err("min_rows must be a positive integer".to_string()); | |
| } | |
| if self.max_rows == 0 { | |
| return Err("max_rows must be a positive integer".to_string()); | |
| } | |
| if self.min_rows > self.max_rows { | |
| return Err("min_rows must be less than or equal to max_rows".to_string()); | |
| } | |
| Ok(()) | |
| } | |
| } | |
| /// Holds the Python formatter and its configuration | |
| struct PythonFormatter<'py> { | |
| /// The Python formatter object | |
| formatter: Bound<'py, PyAny>, | |
| /// The formatter configuration | |
| config: FormatterConfig, | |
| } | |
| /// Get the Python formatter and its configuration | |
| fn get_python_formatter_with_config(py: Python) -> PyResult<PythonFormatter> { | |
| let formatter = import_python_formatter(py)?; | |
| let config = build_formatter_config_from_python(&formatter)?; | |
| Ok(PythonFormatter { formatter, config }) | |
| } | |
| /// Get the Python formatter from the datafusion.dataframe_formatter module | |
| fn import_python_formatter(py: Python<'_>) -> PyResult<Bound<'_, PyAny>> { | |
| let formatter_module = py.import("datafusion.dataframe_formatter")?; | |
| let get_formatter = formatter_module.getattr("get_formatter")?; | |
| get_formatter.call0() | |
| } | |
| // Helper function to extract attributes with fallback to default | |
| fn get_attr<'a, T>(py_object: &'a Bound<'a, PyAny>, attr_name: &str, default_value: T) -> T | |
| where | |
| T: for<'py> pyo3::FromPyObject<'py, 'py> + Clone, | |
| { | |
| py_object | |
| .getattr(attr_name) | |
| .and_then(|v| v.extract::<T>().map_err(Into::<PyErr>::into)) | |
| .unwrap_or_else(|_| default_value.clone()) | |
| } | |
| /// Helper function to create a FormatterConfig from a Python formatter object | |
| fn build_formatter_config_from_python(formatter: &Bound<'_, PyAny>) -> PyResult<FormatterConfig> { | |
| let default_config = FormatterConfig::default(); | |
| let max_bytes = get_attr(formatter, "max_memory_bytes", default_config.max_bytes); | |
| let min_rows = get_attr(formatter, "min_rows", default_config.min_rows); | |
| // Backward compatibility: Try max_rows first (new name), fall back to repr_rows (deprecated), | |
| // then use default. This ensures backward compatibility with custom formatter implementations | |
| // during the deprecation period. | |
| let max_rows = get_attr(formatter, "max_rows", 0usize); | |
| let max_rows = if max_rows > 0 { | |
| // max_rows attribute exists and has a value | |
| max_rows | |
| } else { | |
| // Try the deprecated repr_rows attribute | |
| let repr_rows = get_attr(formatter, "repr_rows", 0usize); | |
| if repr_rows > 0 { | |
| repr_rows | |
| } else { | |
| // Use default | |
| default_config.max_rows | |
| } | |
| }; | |
| let config = FormatterConfig { | |
| max_bytes, | |
| min_rows, | |
| max_rows, | |
| }; | |
| // Return the validated config, converting String error to PyErr | |
| config.validate().map_err(PyValueError::new_err)?; | |
| Ok(config) | |
| } | |
| /// Python mapping of `ParquetOptions` (includes just the writer-related options). | |
| #[pyclass( | |
| from_py_object, | |
| frozen, | |
| name = "ParquetWriterOptions", | |
| module = "datafusion", | |
| subclass | |
| )] | |
| #[derive(Clone, Default)] | |
| pub struct PyParquetWriterOptions { | |
| options: ParquetOptions, | |
| } | |
| #[pymethods] | |
| impl PyParquetWriterOptions { | |
| #[new] | |
| #[allow(clippy::too_many_arguments)] | |
| pub fn new( | |
| data_pagesize_limit: usize, | |
| write_batch_size: usize, | |
| writer_version: &str, | |
| skip_arrow_metadata: bool, | |
| compression: Option<String>, | |
| dictionary_enabled: Option<bool>, | |
| dictionary_page_size_limit: usize, | |
| statistics_enabled: Option<String>, | |
| max_row_group_size: usize, | |
| created_by: String, | |
| column_index_truncate_length: Option<usize>, | |
| statistics_truncate_length: Option<usize>, | |
| data_page_row_count_limit: usize, | |
| encoding: Option<String>, | |
| bloom_filter_on_write: bool, | |
| bloom_filter_fpp: Option<f64>, | |
| bloom_filter_ndv: Option<u64>, | |
| allow_single_file_parallelism: bool, | |
| maximum_parallel_row_group_writers: usize, | |
| maximum_buffered_record_batches_per_stream: usize, | |
| ) -> PyResult<Self> { | |
| let writer_version = | |
| datafusion::common::parquet_config::DFParquetWriterVersion::from_str(writer_version) | |
| .map_err(py_datafusion_err)?; | |
| Ok(Self { | |
| options: ParquetOptions { | |
| data_pagesize_limit, | |
| write_batch_size, | |
| writer_version, | |
| skip_arrow_metadata, | |
| compression, | |
| dictionary_enabled, | |
| dictionary_page_size_limit, | |
| statistics_enabled, | |
| max_row_group_size, | |
| created_by, | |
| column_index_truncate_length, | |
| statistics_truncate_length, | |
| data_page_row_count_limit, | |
| encoding, | |
| bloom_filter_on_write, | |
| bloom_filter_fpp, | |
| bloom_filter_ndv, | |
| allow_single_file_parallelism, | |
| maximum_parallel_row_group_writers, | |
| maximum_buffered_record_batches_per_stream, | |
| ..Default::default() | |
| }, | |
| }) | |
| } | |
| } | |
| /// Python mapping of `ParquetColumnOptions`. | |
| #[pyclass( | |
| from_py_object, | |
| frozen, | |
| name = "ParquetColumnOptions", | |
| module = "datafusion", | |
| subclass | |
| )] | |
| #[derive(Clone, Default)] | |
| pub struct PyParquetColumnOptions { | |
| options: ParquetColumnOptions, | |
| } | |
| #[pymethods] | |
| impl PyParquetColumnOptions { | |
| #[new] | |
| pub fn new( | |
| bloom_filter_enabled: Option<bool>, | |
| encoding: Option<String>, | |
| dictionary_enabled: Option<bool>, | |
| compression: Option<String>, | |
| statistics_enabled: Option<String>, | |
| bloom_filter_fpp: Option<f64>, | |
| bloom_filter_ndv: Option<u64>, | |
| ) -> Self { | |
| Self { | |
| options: ParquetColumnOptions { | |
| bloom_filter_enabled, | |
| encoding, | |
| dictionary_enabled, | |
| compression, | |
| statistics_enabled, | |
| bloom_filter_fpp, | |
| bloom_filter_ndv, | |
| }, | |
| } | |
| } | |
| } | |
| /// A PyDataFrame is a representation of a logical plan and an API to compose statements. | |
| /// Use it to build a plan and `.collect()` to execute the plan and collect the result. | |
| /// The actual execution of a plan runs natively on Rust and Arrow on a multi-threaded environment. | |
| #[pyclass( | |
| from_py_object, | |
| name = "DataFrame", | |
| module = "datafusion", | |
| subclass, | |
| frozen | |
| )] | |
| #[derive(Clone)] | |
| pub struct PyDataFrame { | |
| df: Arc<DataFrame>, | |
| // In IPython environment cache batches between __repr__ and _repr_html_ calls. | |
| batches: SharedCachedBatches, | |
| // Cache the last physical plan so that metrics are available after execution. | |
| last_plan: Arc<Mutex<Option<Arc<dyn DFExecutionPlan>>>>, | |
| } | |
| impl PyDataFrame { | |
| /// creates a new PyDataFrame | |
| pub fn new(df: DataFrame) -> Self { | |
| Self { | |
| df: Arc::new(df), | |
| batches: Arc::new(Mutex::new(None)), | |
| last_plan: Arc::new(Mutex::new(None)), | |
| } | |
| } | |
| /// Return a clone of the inner Arc<DataFrame> for crate-local callers. | |
| pub(crate) fn inner_df(&self) -> Arc<DataFrame> { | |
| Arc::clone(&self.df) | |
| } | |
| fn prepare_repr_string<'py>( | |
| &self, | |
| py: Python<'py>, | |
| as_html: bool, | |
| ) -> PyDataFusionResult<String> { | |
| // Get the Python formatter and config | |
| let PythonFormatter { formatter, config } = get_python_formatter_with_config(py)?; | |
| let is_ipython = *is_ipython_env(py); | |
| let (cached_batches, should_cache) = { | |
| let mut cache = self.batches.lock(); | |
| let should_cache = is_ipython && cache.is_none(); | |
| let batches = cache.take(); | |
| (batches, should_cache) | |
| }; | |
| let (batches, has_more) = match cached_batches { | |
| Some(b) => b, | |
| None => wait_for_future( | |
| py, | |
| collect_record_batches_to_display(self.df.as_ref().clone(), config), | |
| )??, | |
| }; | |
| if batches.is_empty() { | |
| // This should not be reached, but do it for safety since we index into the vector below | |
| return Ok("No data to display".to_string()); | |
| } | |
| let table_uuid = uuid::Uuid::new_v4().to_string(); | |
| // Convert record batches to Py<PyAny> list | |
| let py_batches = batches | |
| .iter() | |
| .map(|rb| rb.to_pyarrow(py)) | |
| .collect::<PyResult<Vec<Bound<'py, PyAny>>>>()?; | |
| let py_schema = self.schema().into_pyobject(py)?; | |
| let kwargs = pyo3::types::PyDict::new(py); | |
| let py_batches_list = PyList::new(py, py_batches.as_slice())?; | |
| kwargs.set_item("batches", py_batches_list)?; | |
| kwargs.set_item("schema", py_schema)?; | |
| kwargs.set_item("has_more", has_more)?; | |
| kwargs.set_item("table_uuid", table_uuid)?; | |
| let method_name = match as_html { | |
| true => "format_html", | |
| false => "format_str", | |
| }; | |
| let html_result = formatter.call_method(method_name, (), Some(&kwargs))?; | |
| let html_str: String = html_result.extract()?; | |
| if should_cache { | |
| let mut cache = self.batches.lock(); | |
| *cache = Some((batches.clone(), has_more)); | |
| } | |
| Ok(html_str) | |
| } | |
| /// Create the physical plan, cache it in `last_plan`, and return the plan together | |
| /// with a task context. Centralises the repeated three-line pattern that appears in | |
| /// `collect`, `collect_partitioned`, `execute_stream`, and `execute_stream_partitioned`. | |
| fn create_and_cache_plan( | |
| &self, | |
| py: Python, | |
| ) -> PyDataFusionResult<(Arc<dyn DFExecutionPlan>, Arc<TaskContext>)> { | |
| let df = self.df.as_ref().clone(); | |
| let new_plan = wait_for_future(py, df.create_physical_plan())??; | |
| *self.last_plan.lock() = Some(Arc::clone(&new_plan)); | |
| let task_ctx = Arc::new(self.df.as_ref().task_ctx()); | |
| Ok((new_plan, task_ctx)) | |
| } | |
| async fn collect_column_inner(&self, column: &str) -> Result<ArrayRef, DataFusionError> { | |
| let batches = self | |
| .df | |
| .as_ref() | |
| .clone() | |
| .select_columns(&[column])? | |
| .collect() | |
| .await?; | |
| let arrays = batches | |
| .iter() | |
| .map(|b| b.column(0).as_ref()) | |
| .collect::<Vec<_>>(); | |
| arrow_select::concat::concat(&arrays).map_err(Into::into) | |
| } | |
| } | |
| /// Synchronous wrapper around partitioned [`SendableRecordBatchStream`]s used | |
| /// for the `__arrow_c_stream__` implementation. | |
| /// | |
| /// It drains each partition's stream sequentially, yielding record batches in | |
| /// their original partition order. When a `projection` is set, each batch is | |
| /// converted via `record_batch_into_schema` to apply schema changes per batch. | |
| struct PartitionedDataFrameStreamReader { | |
| streams: Vec<SendableRecordBatchStream>, | |
| schema: SchemaRef, | |
| projection: Option<SchemaRef>, | |
| current: usize, | |
| } | |
| impl Iterator for PartitionedDataFrameStreamReader { | |
| type Item = Result<RecordBatch, ArrowError>; | |
| fn next(&mut self) -> Option<Self::Item> { | |
| while self.current < self.streams.len() { | |
| let stream = &mut self.streams[self.current]; | |
| let fut = poll_next_batch(stream); | |
| let result = Python::attach(|py| wait_for_future(py, fut)); | |
| match result { | |
| Ok(Ok(Some(batch))) => { | |
| let batch = if let Some(ref schema) = self.projection { | |
| match record_batch_into_schema(batch, schema.as_ref()) { | |
| Ok(b) => b, | |
| Err(e) => return Some(Err(e)), | |
| } | |
| } else { | |
| batch | |
| }; | |
| return Some(Ok(batch)); | |
| } | |
| Ok(Ok(None)) => { | |
| self.current += 1; | |
| continue; | |
| } | |
| Ok(Err(e)) => { | |
| return Some(Err(ArrowError::ExternalError(Box::new(e)))); | |
| } | |
| Err(e) => { | |
| return Some(Err(ArrowError::ExternalError(Box::new(e)))); | |
| } | |
| } | |
| } | |
| None | |
| } | |
| } | |
| impl RecordBatchReader for PartitionedDataFrameStreamReader { | |
| fn schema(&self) -> SchemaRef { | |
| self.schema.clone() | |
| } | |
| } | |
| #[pymethods] | |
| impl PyDataFrame { | |
| /// Enable selection for `df[col]`, `df[col1, col2, col3]`, and `df[[col1, col2, col3]]` | |
| fn __getitem__(&self, key: Bound<'_, PyAny>) -> PyDataFusionResult<Self> { | |
| if let Ok(key) = key.extract::<PyBackedStr>() { | |
| // df[col] | |
| self.select_exprs(vec![key]) | |
| } else if let Ok(tuple) = key.cast::<PyTuple>() { | |
| // df[col1, col2, col3] | |
| let keys = tuple | |
| .iter() | |
| .map(|item| item.extract::<PyBackedStr>()) | |
| .collect::<PyResult<Vec<PyBackedStr>>>()?; | |
| self.select_exprs(keys) | |
| } else if let Ok(keys) = key.extract::<Vec<PyBackedStr>>() { | |
| // df[[col1, col2, col3]] | |
| self.select_exprs(keys) | |
| } else { | |
| let message = "DataFrame can only be indexed by string index or indices".to_string(); | |
| Err(PyDataFusionError::Common(message)) | |
| } | |
| } | |
| fn __repr__(&self, py: Python) -> PyDataFusionResult<String> { | |
| self.prepare_repr_string(py, false) | |
| } | |
| #[staticmethod] | |
| #[expect(unused_variables)] | |
| fn default_str_repr<'py>( | |
| batches: Vec<Bound<'py, PyAny>>, | |
| schema: &Bound<'py, PyAny>, | |
| has_more: bool, | |
| table_uuid: &str, | |
| ) -> PyResult<String> { | |
| let batches = batches | |
| .into_iter() | |
| .map(|batch| RecordBatch::from_pyarrow_bound(&batch)) | |
| .collect::<PyResult<Vec<RecordBatch>>>()? | |
| .into_iter() | |
| .filter(|batch| batch.num_rows() > 0) | |
| .collect::<Vec<_>>(); | |
| if batches.is_empty() { | |
| return Ok("No data to display".to_owned()); | |
| } | |
| let batches_as_displ = | |
| pretty::pretty_format_batches(&batches).map_err(py_datafusion_err)?; | |
| let additional_str = match has_more { | |
| true => "\nData truncated.", | |
| false => "", | |
| }; | |
| Ok(format!("DataFrame()\n{batches_as_displ}{additional_str}")) | |
| } | |
| fn _repr_html_(&self, py: Python) -> PyDataFusionResult<String> { | |
| self.prepare_repr_string(py, true) | |
| } | |
| /// Calculate summary statistics for a DataFrame | |
| fn describe(&self, py: Python) -> PyDataFusionResult<Self> { | |
| let df = self.df.as_ref().clone(); | |
| let stat_df = wait_for_future(py, df.describe())??; | |
| Ok(Self::new(stat_df)) | |
| } | |
| /// Returns the schema from the logical plan | |
| fn schema(&self) -> PyArrowType<Schema> { | |
| PyArrowType(self.df.schema().as_arrow().clone()) | |
| } | |
| /// Convert this DataFrame into a Table Provider that can be used in register_table | |
| /// By convention, into_... methods consume self and return the new object. | |
| /// Disabling the clippy lint, so we can use &self | |
| /// because we're working with Python bindings | |
| /// where objects are shared | |
| #[allow(clippy::wrong_self_convention)] | |
| pub fn into_view(&self, temporary: bool) -> PyDataFusionResult<PyTable> { | |
| let table_provider = if temporary { | |
| Arc::new(TempViewTable::new(Arc::clone(&self.df))) as Arc<dyn TableProvider> | |
| } else { | |
| // Call the underlying Rust DataFrame::into_view method. | |
| // Note that the Rust method consumes self; here we clone the inner Arc<DataFrame> | |
| // so that we don't invalidate this PyDataFrame. | |
| self.df.as_ref().clone().into_view() | |
| }; | |
| Ok(PyTable::from(table_provider)) | |
| } | |
| fn alias(&self, alias: &str) -> PyDataFusionResult<Self> { | |
| let df = self.df.as_ref().clone().alias(alias)?; | |
| Ok(Self::new(df)) | |
| } | |
| #[pyo3(signature = (*args))] | |
| fn select_exprs(&self, args: Vec<PyBackedStr>) -> PyDataFusionResult<Self> { | |
| let args = args.iter().map(|s| s.as_ref()).collect::<Vec<&str>>(); | |
| let df = self.df.as_ref().clone().select_exprs(&args)?; | |
| Ok(Self::new(df)) | |
| } | |
| #[pyo3(signature = (*args))] | |
| fn select(&self, args: Vec<PyExpr>) -> PyDataFusionResult<Self> { | |
| let expr: Vec<Expr> = args.into_iter().map(|e| e.into()).collect(); | |
| let df = self.df.as_ref().clone().select(expr)?; | |
| Ok(Self::new(df)) | |
| } | |
| #[pyo3(signature = (*args))] | |
| fn drop(&self, args: Vec<PyBackedStr>) -> PyDataFusionResult<Self> { | |
| let cols = args.iter().map(|s| s.as_ref()).collect::<Vec<&str>>(); | |
| let df = self.df.as_ref().clone().drop_columns(&cols)?; | |
| Ok(Self::new(df)) | |
| } | |
| /// Apply window function expressions to the DataFrame | |
| #[pyo3(signature = (*exprs))] | |
| fn window(&self, exprs: Vec<PyExpr>) -> PyDataFusionResult<Self> { | |
| let window_exprs = exprs.into_iter().map(|e| e.into()).collect(); | |
| let df = self.df.as_ref().clone().window(window_exprs)?; | |
| Ok(Self::new(df)) | |
| } | |
| fn filter(&self, predicate: PyExpr) -> PyDataFusionResult<Self> { | |
| let df = self.df.as_ref().clone().filter(predicate.into())?; | |
| Ok(Self::new(df)) | |
| } | |
| fn parse_sql_expr(&self, expr: PyBackedStr) -> PyDataFusionResult<PyExpr> { | |
| self.df | |
| .as_ref() | |
| .parse_sql_expr(&expr) | |
| .map(PyExpr::from) | |
| .map_err(PyDataFusionError::from) | |
| } | |
| fn with_column(&self, name: &str, expr: PyExpr) -> PyDataFusionResult<Self> { | |
| let df = self.df.as_ref().clone().with_column(name, expr.into())?; | |
| Ok(Self::new(df)) | |
| } | |
| fn with_columns(&self, exprs: Vec<PyExpr>) -> PyDataFusionResult<Self> { | |
| let mut df = self.df.as_ref().clone(); | |
| for expr in exprs { | |
| let expr: Expr = expr.into(); | |
| let name = format!("{}", expr.schema_name()); | |
| df = df.with_column(name.as_str(), expr)? | |
| } | |
| Ok(Self::new(df)) | |
| } | |
| /// Rename one column by applying a new projection. This is a no-op if the column to be | |
| /// renamed does not exist. | |
| fn with_column_renamed(&self, old_name: &str, new_name: &str) -> PyDataFusionResult<Self> { | |
| let df = self | |
| .df | |
| .as_ref() | |
| .clone() | |
| .with_column_renamed(old_name, new_name)?; | |
| Ok(Self::new(df)) | |
| } | |
| fn aggregate(&self, group_by: Vec<PyExpr>, aggs: Vec<PyExpr>) -> PyDataFusionResult<Self> { | |
| let group_by = group_by.into_iter().map(|e| e.into()).collect(); | |
| let aggs = aggs.into_iter().map(|e| e.into()).collect(); | |
| let df = self.df.as_ref().clone().aggregate(group_by, aggs)?; | |
| Ok(Self::new(df)) | |
| } | |
| #[pyo3(signature = (*exprs))] | |
| fn sort(&self, exprs: Vec<PySortExpr>) -> PyDataFusionResult<Self> { | |
| let exprs = to_sort_expressions(exprs); | |
| let df = self.df.as_ref().clone().sort(exprs)?; | |
| Ok(Self::new(df)) | |
| } | |
| #[pyo3(signature = (count, offset=0))] | |
| fn limit(&self, count: usize, offset: usize) -> PyDataFusionResult<Self> { | |
| let df = self.df.as_ref().clone().limit(offset, Some(count))?; | |
| Ok(Self::new(df)) | |
| } | |
| /// Executes the plan, returning a list of `RecordBatch`es. | |
| /// Unless some order is specified in the plan, there is no | |
| /// guarantee of the order of the result. | |
| fn collect<'py>(&self, py: Python<'py>) -> PyResult<Vec<Bound<'py, PyAny>>> { | |
| let (plan, task_ctx) = self.create_and_cache_plan(py)?; | |
| let batches = | |
| wait_for_future(py, df_collect(plan, task_ctx))?.map_err(PyDataFusionError::from)?; | |
| // cannot use PyResult<Vec<RecordBatch>> return type due to | |
| // https://github.com/PyO3/pyo3/issues/1813 | |
| batches.into_iter().map(|rb| rb.to_pyarrow(py)).collect() | |
| } | |
| /// Cache DataFrame. | |
| fn cache(&self, py: Python) -> PyDataFusionResult<Self> { | |
| let df = wait_for_future(py, self.df.as_ref().clone().cache())??; | |
| Ok(Self::new(df)) | |
| } | |
| /// Executes this DataFrame and collects all results into a vector of vector of RecordBatch | |
| /// maintaining the input partitioning. | |
| fn collect_partitioned<'py>(&self, py: Python<'py>) -> PyResult<Vec<Vec<Bound<'py, PyAny>>>> { | |
| let (plan, task_ctx) = self.create_and_cache_plan(py)?; | |
| let batches = wait_for_future(py, df_collect_partitioned(plan, task_ctx))? | |
| .map_err(PyDataFusionError::from)?; | |
| batches | |
| .into_iter() | |
| .map(|rbs| rbs.into_iter().map(|rb| rb.to_pyarrow(py)).collect()) | |
| .collect() | |
| } | |
| fn collect_column<'py>(&self, py: Python<'py>, column: &str) -> PyResult<Bound<'py, PyAny>> { | |
| wait_for_future(py, self.collect_column_inner(column))? | |
| .map_err(PyDataFusionError::from)? | |
| .to_data() | |
| .to_pyarrow(py) | |
| } | |
| /// Print the result, 20 lines by default | |
| #[pyo3(signature = (num=20))] | |
| fn show(&self, py: Python, num: usize) -> PyDataFusionResult<()> { | |
| let mut df = self.df.as_ref().clone(); | |
| df = match self.df.logical_plan() { | |
| LogicalPlan::Explain(_) | LogicalPlan::Analyze(_) => { | |
| // Explain and Analyzer require they are at the top | |
| // of the plan, so do not add a limit. | |
| df | |
| } | |
| _ => df.limit(0, Some(num))?, | |
| }; | |
| print_dataframe(py, df) | |
| } | |
| /// Filter out duplicate rows | |
| fn distinct(&self) -> PyDataFusionResult<Self> { | |
| let df = self.df.as_ref().clone().distinct()?; | |
| Ok(Self::new(df)) | |
| } | |
| fn join( | |
| &self, | |
| right: PyDataFrame, | |
| how: &str, | |
| left_on: Vec<PyBackedStr>, | |
| right_on: Vec<PyBackedStr>, | |
| coalesce_keys: bool, | |
| ) -> PyDataFusionResult<Self> { | |
| let join_type = match how { | |
| "inner" => JoinType::Inner, | |
| "left" => JoinType::Left, | |
| "right" => JoinType::Right, | |
| "full" => JoinType::Full, | |
| "semi" => JoinType::LeftSemi, | |
| "anti" => JoinType::LeftAnti, | |
| how => { | |
| return Err(PyDataFusionError::Common(format!( | |
| "The join type {how} does not exist or is not implemented" | |
| ))); | |
| } | |
| }; | |
| let left_keys = left_on.iter().map(|s| s.as_ref()).collect::<Vec<&str>>(); | |
| let right_keys = right_on.iter().map(|s| s.as_ref()).collect::<Vec<&str>>(); | |
| let mut df = self.df.as_ref().clone().join( | |
| right.df.as_ref().clone(), | |
| join_type, | |
| &left_keys, | |
| &right_keys, | |
| None, | |
| )?; | |
| if coalesce_keys { | |
| let mutual_keys = left_keys | |
| .iter() | |
| .zip(right_keys.iter()) | |
| .filter(|(l, r)| l == r) | |
| .map(|(key, _)| *key) | |
| .collect::<Vec<_>>(); | |
| let fields_to_coalesce = mutual_keys | |
| .iter() | |
| .map(|name| { | |
| let qualified_fields = df | |
| .logical_plan() | |
| .schema() | |
| .qualified_fields_with_unqualified_name(name); | |
| (*name, qualified_fields) | |
| }) | |
| .filter(|(_, fields)| fields.len() == 2) | |
| .collect::<Vec<_>>(); | |
| let expr: Vec<Expr> = df | |
| .logical_plan() | |
| .schema() | |
| .fields() | |
| .into_iter() | |
| .enumerate() | |
| .map(|(idx, _)| df.logical_plan().schema().qualified_field(idx)) | |
| .filter_map(|(qualifier, field)| { | |
| if let Some((key_name, qualified_fields)) = fields_to_coalesce | |
| .iter() | |
| .find(|(_, qf)| qf.contains(&(qualifier, field))) | |
| { | |
| // Only add the coalesce expression once (when we encounter the first field) | |
| // Skip the second field (it's already included in to coalesce) | |
| if (qualifier, field) == qualified_fields[0] { | |
| let left_col = Expr::Column(Column::from(qualified_fields[0])); | |
| let right_col = Expr::Column(Column::from(qualified_fields[1])); | |
| return Some(coalesce(vec![left_col, right_col]).alias(*key_name)); | |
| } | |
| None | |
| } else { | |
| Some(Expr::Column(Column::from((qualifier, field)))) | |
| } | |
| }) | |
| .collect(); | |
| df = df.select(expr)?; | |
| } | |
| Ok(Self::new(df)) | |
| } | |
| fn join_on( | |
| &self, | |
| right: PyDataFrame, | |
| on_exprs: Vec<PyExpr>, | |
| how: &str, | |
| ) -> PyDataFusionResult<Self> { | |
| let join_type = match how { | |
| "inner" => JoinType::Inner, | |
| "left" => JoinType::Left, | |
| "right" => JoinType::Right, | |
| "full" => JoinType::Full, | |
| "semi" => JoinType::LeftSemi, | |
| "anti" => JoinType::LeftAnti, | |
| how => { | |
| return Err(PyDataFusionError::Common(format!( | |
| "The join type {how} does not exist or is not implemented" | |
| ))); | |
| } | |
| }; | |
| let exprs: Vec<Expr> = on_exprs.into_iter().map(|e| e.into()).collect(); | |
| let df = self | |
| .df | |
| .as_ref() | |
| .clone() | |
| .join_on(right.df.as_ref().clone(), join_type, exprs)?; | |
| Ok(Self::new(df)) | |
| } | |
| /// Print the query plan | |
| #[pyo3(signature = ( | |
| verbose=false, | |
| analyze=false, | |
| format=None, | |
| show_statistics=None, | |
| analyze_level=None, | |
| analyze_categories=None | |
| ))] | |
| #[allow(clippy::too_many_arguments)] | |
| fn explain( | |
| &self, | |
| py: Python, | |
| verbose: bool, | |
| analyze: bool, | |
| format: Option<&str>, | |
| show_statistics: Option<bool>, | |
| analyze_level: Option<&str>, | |
| analyze_categories: Option<Vec<String>>, | |
| ) -> PyDataFusionResult<()> { | |
| let explain_format = match format { | |
| Some(f) => f | |
| .parse::<datafusion::common::format::ExplainFormat>() | |
| .map_err(|e| { | |
| PyDataFusionError::Common(format!("Invalid explain format '{}': {}", f, e)) | |
| })?, | |
| None => datafusion::common::format::ExplainFormat::Indent, | |
| }; | |
| let analyze_level = analyze_level | |
| .map(|l| l.parse::<datafusion::common::format::MetricType>()) | |
| .transpose()?; | |
| let analyze_categories = analyze_categories | |
| .map(|cats| { | |
| cats.iter() | |
| .map(|c| c.parse::<datafusion::common::format::MetricCategory>()) | |
| .collect::<datafusion::common::Result<Vec<_>>>() | |
| .map(datafusion::common::format::ExplainAnalyzeCategories::Only) | |
| }) | |
| .transpose()?; | |
| let opts = datafusion::logical_expr::ExplainOption::default() | |
| .with_verbose(verbose) | |
| .with_analyze(analyze) | |
| .with_format(explain_format) | |
| .with_show_statistics(show_statistics) | |
| .with_analyze_level(analyze_level) | |
| .with_analyze_categories(analyze_categories); | |
| let df = self.df.as_ref().clone().explain_with_options(opts)?; | |
| print_dataframe(py, df) | |
| } | |
| /// Get the logical plan for this `DataFrame` | |
| fn logical_plan(&self) -> PyResult<PyLogicalPlan> { | |
| Ok(self.df.as_ref().clone().logical_plan().clone().into()) | |
| } | |
| /// Get the optimized logical plan for this `DataFrame` | |
| fn optimized_logical_plan(&self) -> PyDataFusionResult<PyLogicalPlan> { | |
| Ok(self.df.as_ref().clone().into_optimized_plan()?.into()) | |
| } | |
| /// Get the execution plan for this `DataFrame` | |
| /// | |
| /// If the DataFrame has already been executed (e.g. via `collect()`), | |
| /// returns the cached plan which includes populated metrics. | |
| fn execution_plan(&self, py: Python) -> PyDataFusionResult<PyExecutionPlan> { | |
| if let Some(plan) = self.last_plan.lock().as_ref() { | |
| return Ok(PyExecutionPlan::new(Arc::clone(plan))); | |
| } | |
| let plan = wait_for_future(py, self.df.as_ref().clone().create_physical_plan())??; | |
| Ok(plan.into()) | |
| } | |
| /// Repartition a `DataFrame` based on a logical partitioning scheme. | |
| fn repartition(&self, num: usize) -> PyDataFusionResult<Self> { | |
| let new_df = self | |
| .df | |
| .as_ref() | |
| .clone() | |
| .repartition(Partitioning::RoundRobinBatch(num))?; | |
| Ok(Self::new(new_df)) | |
| } | |
| /// Repartition a `DataFrame` based on a logical partitioning scheme. | |
| #[pyo3(signature = (*args, num))] | |
| fn repartition_by_hash(&self, args: Vec<PyExpr>, num: usize) -> PyDataFusionResult<Self> { | |
| let expr = args.into_iter().map(|py_expr| py_expr.into()).collect(); | |
| let new_df = self | |
| .df | |
| .as_ref() | |
| .clone() | |
| .repartition(Partitioning::Hash(expr, num))?; | |
| Ok(Self::new(new_df)) | |
| } | |
| /// Calculate the union of two `DataFrame`s, preserving duplicate rows.The | |
| /// two `DataFrame`s must have exactly the same schema | |
| #[pyo3(signature = (py_df, distinct=false))] | |
| fn union(&self, py_df: PyDataFrame, distinct: bool) -> PyDataFusionResult<Self> { | |
| let new_df = if distinct { | |
| self.df | |
| .as_ref() | |
| .clone() | |
| .union_distinct(py_df.df.as_ref().clone())? | |
| } else { | |
| self.df.as_ref().clone().union(py_df.df.as_ref().clone())? | |
| }; | |
| Ok(Self::new(new_df)) | |
| } | |
| #[pyo3(signature = (columns, preserve_nulls=true, recursions=None))] | |
| fn unnest_columns( | |
| &self, | |
| columns: Vec<String>, | |
| preserve_nulls: bool, | |
| recursions: Option<Vec<(String, String, usize)>>, | |
| ) -> PyDataFusionResult<Self> { | |
| let unnest_options = build_unnest_options(preserve_nulls, recursions); | |
| let cols = columns.iter().map(|s| s.as_ref()).collect::<Vec<&str>>(); | |
| let df = self | |
| .df | |
| .as_ref() | |
| .clone() | |
| .unnest_columns_with_options(&cols, unnest_options)?; | |
| Ok(Self::new(df)) | |
| } | |
| /// Calculate the intersection of two `DataFrame`s. The two `DataFrame`s must have exactly the same schema | |
| #[pyo3(signature = (py_df, distinct=false))] | |
| fn intersect(&self, py_df: PyDataFrame, distinct: bool) -> PyDataFusionResult<Self> { | |
| let base = self.df.as_ref().clone(); | |
| let other = py_df.df.as_ref().clone(); | |
| let new_df = if distinct { | |
| base.intersect_distinct(other)? | |
| } else { | |
| base.intersect(other)? | |
| }; | |
| Ok(Self::new(new_df)) | |
| } | |
| /// Calculate the exception of two `DataFrame`s. The two `DataFrame`s must have exactly the same schema | |
| #[pyo3(signature = (py_df, distinct=false))] | |
| fn except_all(&self, py_df: PyDataFrame, distinct: bool) -> PyDataFusionResult<Self> { | |
| let base = self.df.as_ref().clone(); | |
| let other = py_df.df.as_ref().clone(); | |
| let new_df = if distinct { | |
| base.except_distinct(other)? | |
| } else { | |
| base.except(other)? | |
| }; | |
| Ok(Self::new(new_df)) | |
| } | |
| /// Union two DataFrames matching columns by name | |
| #[pyo3(signature = (py_df, distinct=false))] | |
| fn union_by_name(&self, py_df: PyDataFrame, distinct: bool) -> PyDataFusionResult<Self> { | |
| let base = self.df.as_ref().clone(); | |
| let other = py_df.df.as_ref().clone(); | |
| let new_df = if distinct { | |
| base.union_by_name_distinct(other)? | |
| } else { | |
| base.union_by_name(other)? | |
| }; | |
| Ok(Self::new(new_df)) | |
| } | |
| /// Deduplicate rows based on specific columns, keeping the first row per group | |
| fn distinct_on( | |
| &self, | |
| on_expr: Vec<PyExpr>, | |
| select_expr: Vec<PyExpr>, | |
| sort_expr: Option<Vec<PySortExpr>>, | |
| ) -> PyDataFusionResult<Self> { | |
| let on_expr = on_expr.into_iter().map(|e| e.into()).collect(); | |
| let select_expr = select_expr.into_iter().map(|e| e.into()).collect(); | |
| let sort_expr = sort_expr.map(to_sort_expressions); | |
| let df = self | |
| .df | |
| .as_ref() | |
| .clone() | |
| .distinct_on(on_expr, select_expr, sort_expr)?; | |
| Ok(Self::new(df)) | |
| } | |
| /// Sort by column expressions with ascending order and nulls last | |
| fn sort_by(&self, exprs: Vec<PyExpr>) -> PyDataFusionResult<Self> { | |
| let exprs = exprs.into_iter().map(|e| e.into()).collect(); | |
| let df = self.df.as_ref().clone().sort_by(exprs)?; | |
| Ok(Self::new(df)) | |
| } | |
| /// Return fully qualified column expressions for the given column names | |
| fn find_qualified_columns(&self, names: Vec<String>) -> PyDataFusionResult<Vec<PyExpr>> { | |
| let name_refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect(); | |
| let qualified = self.df.find_qualified_columns(&name_refs)?; | |
| Ok(qualified | |
| .into_iter() | |
| .map(|q| Expr::Column(Column::from(q)).into()) | |
| .collect()) | |
| } | |
| /// Write a `DataFrame` to a CSV file. | |
| fn write_csv( | |
| &self, | |
| py: Python, | |
| path: &str, | |
| with_header: bool, | |
| write_options: Option<PyDataFrameWriteOptions>, | |
| ) -> PyDataFusionResult<()> { | |
| let csv_options = CsvOptions { | |
| has_header: Some(with_header), | |
| ..Default::default() | |
| }; | |
| let write_options = write_options | |
| .map(DataFrameWriteOptions::from) | |
| .unwrap_or_default(); | |
| wait_for_future( | |
| py, | |
| self.df | |
| .as_ref() | |
| .clone() | |
| .write_csv(path, write_options, Some(csv_options)), | |
| )??; | |
| Ok(()) | |
| } | |
| /// Write a `DataFrame` to a Parquet file. | |
| #[pyo3(signature = ( | |
| path, | |
| compression="zstd", | |
| compression_level=None, | |
| write_options=None, | |
| ))] | |
| fn write_parquet( | |
| &self, | |
| path: &str, | |
| compression: &str, | |
| compression_level: Option<u32>, | |
| write_options: Option<PyDataFrameWriteOptions>, | |
| py: Python, | |
| ) -> PyDataFusionResult<()> { | |
| fn verify_compression_level(cl: Option<u32>) -> Result<u32, PyErr> { | |
| cl.ok_or(PyValueError::new_err("compression_level is not defined")) | |
| } | |
| let _validated = match compression.to_lowercase().as_str() { | |
| "snappy" => Compression::SNAPPY, | |
| "gzip" => Compression::GZIP( | |
| GzipLevel::try_new(compression_level.unwrap_or(6)) | |
| .map_err(|e| PyValueError::new_err(format!("{e}")))?, | |
| ), | |
| "brotli" => Compression::BROTLI( | |
| BrotliLevel::try_new(verify_compression_level(compression_level)?) | |
| .map_err(|e| PyValueError::new_err(format!("{e}")))?, | |
| ), | |
| "zstd" => Compression::ZSTD( | |
| ZstdLevel::try_new(verify_compression_level(compression_level)? as i32) | |
| .map_err(|e| PyValueError::new_err(format!("{e}")))?, | |
| ), | |
| "lzo" => Compression::LZO, | |
| "lz4" => Compression::LZ4, | |
| "lz4_raw" => Compression::LZ4_RAW, | |
| "uncompressed" => Compression::UNCOMPRESSED, | |
| _ => { | |
| return Err(PyDataFusionError::Common(format!( | |
| "Unrecognized compression type {compression}" | |
| ))); | |
| } | |
| }; | |
| let mut compression_string = compression.to_string(); | |
| if let Some(level) = compression_level { | |
| compression_string.push_str(&format!("({level})")); | |
| } | |
| let mut options = TableParquetOptions::default(); | |
| options.global.compression = Some(compression_string); | |
| let write_options = write_options | |
| .map(DataFrameWriteOptions::from) | |
| .unwrap_or_default(); | |
| wait_for_future( | |
| py, | |
| self.df | |
| .as_ref() | |
| .clone() | |
| .write_parquet(path, write_options, Option::from(options)), | |
| )??; | |
| Ok(()) | |
| } | |
| /// Write a `DataFrame` to a Parquet file, using advanced options. | |
| fn write_parquet_with_options( | |
| &self, | |
| path: &str, | |
| options: PyParquetWriterOptions, | |
| column_specific_options: HashMap<String, PyParquetColumnOptions>, | |
| write_options: Option<PyDataFrameWriteOptions>, | |
| py: Python, | |
| ) -> PyDataFusionResult<()> { | |
| let table_options = TableParquetOptions { | |
| global: options.options, | |
| column_specific_options: column_specific_options | |
| .into_iter() | |
| .map(|(k, v)| (k, v.options)) | |
| .collect(), | |
| ..Default::default() | |
| }; | |
| let write_options = write_options | |
| .map(DataFrameWriteOptions::from) | |
| .unwrap_or_default(); | |
| wait_for_future( | |
| py, | |
| self.df.as_ref().clone().write_parquet( | |
| path, | |
| write_options, | |
| Option::from(table_options), | |
| ), | |
| )??; | |
| Ok(()) | |
| } | |
| /// Executes a query and writes the results to a partitioned JSON file. | |
| fn write_json( | |
| &self, | |
| path: &str, | |
| py: Python, | |
| write_options: Option<PyDataFrameWriteOptions>, | |
| ) -> PyDataFusionResult<()> { | |
| let write_options = write_options | |
| .map(DataFrameWriteOptions::from) | |
| .unwrap_or_default(); | |
| wait_for_future( | |
| py, | |
| self.df | |
| .as_ref() | |
| .clone() | |
| .write_json(path, write_options, None), | |
| )??; | |
| Ok(()) | |
| } | |
| fn write_table( | |
| &self, | |
| py: Python, | |
| table_name: &str, | |
| write_options: Option<PyDataFrameWriteOptions>, | |
| ) -> PyDataFusionResult<()> { | |
| let write_options = write_options | |
| .map(DataFrameWriteOptions::from) | |
| .unwrap_or_default(); | |
| wait_for_future( | |
| py, | |
| self.df | |
| .as_ref() | |
| .clone() | |
| .write_table(table_name, write_options), | |
| )??; | |
| Ok(()) | |
| } | |
| /// Convert to Arrow Table | |
| /// Collect the batches and pass to Arrow Table | |
| fn to_arrow_table(&self, py: Python<'_>) -> PyResult<Py<PyAny>> { | |
| let batches = self.collect(py)?.into_pyobject(py)?; | |
| // only use the DataFrame's schema if there are no batches, otherwise let the schema be | |
| // determined from the batches (avoids some inconsistencies with nullable columns) | |
| let args = if batches.len()? == 0 { | |
| let schema = self.schema().into_pyobject(py)?; | |
| PyTuple::new(py, &[batches, schema])? | |
| } else { | |
| PyTuple::new(py, &[batches])? | |
| }; | |
| // Instantiate pyarrow Table object and use its from_batches method | |
| let table_class = py.import("pyarrow")?.getattr("Table")?; | |
| let table: Py<PyAny> = table_class.call_method1("from_batches", args)?.into(); | |
| Ok(table) | |
| } | |
| #[pyo3(signature = (requested_schema=None))] | |
| fn __arrow_c_stream__<'py>( | |
| &'py self, | |
| py: Python<'py>, | |
| requested_schema: Option<Bound<'py, PyCapsule>>, | |
| ) -> PyDataFusionResult<Bound<'py, PyCapsule>> { | |
| let df = self.df.as_ref().clone(); | |
| let streams = spawn_future(py, async move { df.execute_stream_partitioned().await })?; | |
| let mut schema: Schema = self.df.schema().to_owned().as_arrow().clone(); | |
| let mut projection: Option<SchemaRef> = None; | |
| if let Some(schema_capsule) = requested_schema { | |
| let data: NonNull<FFI_ArrowSchema> = schema_capsule | |
| .pointer_checked(Some(c"arrow_schema"))? | |
| .cast(); | |
| let schema_ptr = unsafe { data.as_ref() }; | |
| let desired_schema = Schema::try_from(schema_ptr)?; | |
| schema = project_schema(schema, desired_schema)?; | |
| projection = Some(Arc::new(schema.clone())); | |
| } | |
| let schema_ref = Arc::new(schema.clone()); | |
| let reader = PartitionedDataFrameStreamReader { | |
| streams, | |
| schema: schema_ref, | |
| projection, | |
| current: 0, | |
| }; | |
| let reader: Box<dyn RecordBatchReader + Send> = Box::new(reader); | |
| // Create the Arrow stream and wrap it in a PyCapsule. The default | |
| // destructor provided by PyO3 will drop the stream unless ownership is | |
| // transferred to PyArrow during import. | |
| let stream = FFI_ArrowArrayStream::new(reader); | |
| let capsule = PyCapsule::new_with_value(py, stream, ARROW_ARRAY_STREAM_NAME)?; | |
| Ok(capsule) | |
| } | |
| fn execute_stream(&self, py: Python) -> PyDataFusionResult<PyRecordBatchStream> { | |
| let (plan, task_ctx) = self.create_and_cache_plan(py)?; | |
| let stream = spawn_future(py, async move { df_execute_stream(plan, task_ctx) })?; | |
| Ok(PyRecordBatchStream::new(stream)) | |
| } | |
| fn execute_stream_partitioned(&self, py: Python) -> PyResult<Vec<PyRecordBatchStream>> { | |
| let (plan, task_ctx) = self.create_and_cache_plan(py)?; | |
| let streams = spawn_future( | |
| py, | |
| async move { df_execute_stream_partitioned(plan, task_ctx) }, | |
| )?; | |
| Ok(streams.into_iter().map(PyRecordBatchStream::new).collect()) | |
| } | |
| /// Convert to pandas dataframe with pyarrow | |
| /// Collect the batches, pass to Arrow Table & then convert to Pandas DataFrame | |
| fn to_pandas(&self, py: Python<'_>) -> PyResult<Py<PyAny>> { | |
| let table = self.to_arrow_table(py)?; | |
| // See also: https://arrow.apache.org/docs/python/generated/pyarrow.Table.html#pyarrow.Table.to_pandas | |
| let result = table.call_method0(py, "to_pandas")?; | |
| Ok(result) | |
| } | |
| /// Convert to Python list using pyarrow | |
| /// Each list item represents one row encoded as dictionary | |
| fn to_pylist(&self, py: Python<'_>) -> PyResult<Py<PyAny>> { | |
| let table = self.to_arrow_table(py)?; | |
| // See also: https://arrow.apache.org/docs/python/generated/pyarrow.Table.html#pyarrow.Table.to_pylist | |
| let result = table.call_method0(py, "to_pylist")?; | |
| Ok(result) | |
| } | |
| /// Convert to Python dictionary using pyarrow | |
| /// Each dictionary key is a column and the dictionary value represents the column values | |
| fn to_pydict(&self, py: Python) -> PyResult<Py<PyAny>> { | |
| let table = self.to_arrow_table(py)?; | |
| // See also: https://arrow.apache.org/docs/python/generated/pyarrow.Table.html#pyarrow.Table.to_pydict | |
| let result = table.call_method0(py, "to_pydict")?; | |
| Ok(result) | |
| } | |
| /// Convert to polars dataframe with pyarrow | |
| /// Collect the batches, pass to Arrow Table & then convert to polars DataFrame | |
| fn to_polars(&self, py: Python<'_>) -> PyResult<Py<PyAny>> { | |
| let table = self.to_arrow_table(py)?; | |
| let dataframe = py.import("polars")?.getattr("DataFrame")?; | |
| let args = PyTuple::new(py, &[table])?; | |
| let result: Py<PyAny> = dataframe.call1(args)?.into(); | |
| Ok(result) | |
| } | |
| // Executes this DataFrame to get the total number of rows. | |
| fn count(&self, py: Python) -> PyDataFusionResult<usize> { | |
| Ok(wait_for_future(py, self.df.as_ref().clone().count())??) | |
| } | |
| /// Fill null values with a specified value for specific columns | |
| #[pyo3(signature = (value, columns=None))] | |
| fn fill_null( | |
| &self, | |
| value: Py<PyAny>, | |
| columns: Option<Vec<PyBackedStr>>, | |
| py: Python, | |
| ) -> PyDataFusionResult<Self> { | |
| let scalar_value: PyScalarValue = value.extract(py)?; | |
| let cols = match columns { | |
| Some(col_names) => col_names.iter().map(|c| c.to_string()).collect(), | |
| None => Vec::new(), // Empty vector means fill null for all columns | |
| }; | |
| let cols = cols.iter().map(String::as_str).collect::<Vec<_>>(); | |
| let df = self.df.as_ref().fill_null(&scalar_value.0, &cols)?; | |
| Ok(Self::new(df)) | |
| } | |
| /// Fill NaN values with a specified value for specific floating-point columns | |
| #[pyo3(signature = (value, columns=None))] | |
| fn fill_nan( | |
| &self, | |
| value: Py<PyAny>, | |
| columns: Option<Vec<PyBackedStr>>, | |
| py: Python, | |
| ) -> PyDataFusionResult<Self> { | |
| let scalar_value: PyScalarValue = value.extract(py)?; | |
| let cols = match columns { | |
| Some(col_names) => col_names.iter().map(|c| c.to_string()).collect(), | |
| None => Vec::new(), // Empty vector means fill NaN for all columns | |
| }; | |
| let cols = cols.iter().map(String::as_str).collect::<Vec<_>>(); | |
| let df = self.df.as_ref().fill_nan(&scalar_value.0, &cols)?; | |
| Ok(Self::new(df)) | |
| } | |
| } | |
| #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] | |
| #[pyclass( | |
| from_py_object, | |
| frozen, | |
| eq, | |
| eq_int, | |
| name = "InsertOp", | |
| module = "datafusion" | |
| )] | |
| pub enum PyInsertOp { | |
| APPEND, | |
| REPLACE, | |
| OVERWRITE, | |
| } | |
| impl From<PyInsertOp> for InsertOp { | |
| fn from(value: PyInsertOp) -> Self { | |
| match value { | |
| PyInsertOp::APPEND => InsertOp::Append, | |
| PyInsertOp::REPLACE => InsertOp::Replace, | |
| PyInsertOp::OVERWRITE => InsertOp::Overwrite, | |
| } | |
| } | |
| } | |
| #[derive(Debug, Clone)] | |
| #[pyclass( | |
| from_py_object, | |
| frozen, | |
| name = "DataFrameWriteOptions", | |
| module = "datafusion" | |
| )] | |
| pub struct PyDataFrameWriteOptions { | |
| insert_operation: InsertOp, | |
| single_file_output: bool, | |
| partition_by: Vec<String>, | |
| sort_by: Vec<SortExpr>, | |
| } | |
| impl From<PyDataFrameWriteOptions> for DataFrameWriteOptions { | |
| fn from(value: PyDataFrameWriteOptions) -> Self { | |
| DataFrameWriteOptions::new() | |
| .with_insert_operation(value.insert_operation) | |
| .with_single_file_output(value.single_file_output) | |
| .with_partition_by(value.partition_by) | |
| .with_sort_by(value.sort_by) | |
| } | |
| } | |
| #[pymethods] | |
| impl PyDataFrameWriteOptions { | |
| #[new] | |
| fn new( | |
| insert_operation: Option<PyInsertOp>, | |
| single_file_output: bool, | |
| partition_by: Option<Vec<String>>, | |
| sort_by: Option<Vec<PySortExpr>>, | |
| ) -> Self { | |
| let insert_operation = insert_operation.map(Into::into).unwrap_or(InsertOp::Append); | |
| let sort_by = sort_by | |
| .unwrap_or_default() | |
| .into_iter() | |
| .map(Into::into) | |
| .collect(); | |
| Self { | |
| insert_operation, | |
| single_file_output, | |
| partition_by: partition_by.unwrap_or_default(), | |
| sort_by, | |
| } | |
| } | |
| } | |
| fn build_unnest_options( | |
| preserve_nulls: bool, | |
| recursions: Option<Vec<(String, String, usize)>>, | |
| ) -> UnnestOptions { | |
| let mut opts = UnnestOptions::default().with_preserve_nulls(preserve_nulls); | |
| if let Some(recs) = recursions { | |
| opts.recursions = recs | |
| .into_iter() | |
| .map( | |
| |(input, output, depth)| datafusion::common::RecursionUnnestOption { | |
| input_column: datafusion::common::Column::from(input.as_str()), | |
| output_column: datafusion::common::Column::from(output.as_str()), | |
| depth, | |
| }, | |
| ) | |
| .collect(); | |
| } | |
| opts | |
| } | |
| /// Print DataFrame | |
| fn print_dataframe(py: Python, df: DataFrame) -> PyDataFusionResult<()> { | |
| // Get string representation of record batches | |
| let batches = wait_for_future(py, df.collect())??; | |
| let result = if batches.is_empty() { | |
| "DataFrame has no rows".to_string() | |
| } else { | |
| match pretty::pretty_format_batches(&batches) { | |
| Ok(batch) => format!("DataFrame()\n{batch}"), | |
| Err(err) => format!("Error: {:?}", err.to_string()), | |
| } | |
| }; | |
| // Import the Python 'builtins' module to access the print function | |
| // Note that println! does not print to the Python debug console and is not visible in notebooks for instance | |
| let print = py.import("builtins")?.getattr("print")?; | |
| print.call1((result,))?; | |
| Ok(()) | |
| } | |
| fn project_schema(from_schema: Schema, to_schema: Schema) -> Result<Schema, ArrowError> { | |
| let merged_schema = Schema::try_merge(vec![from_schema, to_schema.clone()])?; | |
| let project_indices: Vec<usize> = to_schema | |
| .fields | |
| .iter() | |
| .map(|field| field.name()) | |
| .filter_map(|field_name| merged_schema.index_of(field_name).ok()) | |
| .collect(); | |
| merged_schema.project(&project_indices) | |
| } | |
| // NOTE: `arrow::compute::cast` in combination with `RecordBatch::try_select` or | |
| // DataFusion's `schema::cast_record_batch` do not fully cover the required | |
| // transformations here. They will not create missing columns and may insert | |
| // nulls for non-nullable fields without erroring. To maintain current behavior | |
| // we perform the casting and null checks manually. | |
| fn record_batch_into_schema( | |
| record_batch: RecordBatch, | |
| schema: &Schema, | |
| ) -> Result<RecordBatch, ArrowError> { | |
| let schema = Arc::new(schema.clone()); | |
| let base_schema = record_batch.schema(); | |
| if base_schema.fields().is_empty() { | |
| // Nothing to project | |
| return Ok(RecordBatch::new_empty(schema)); | |
| } | |
| let array_size = record_batch.column(0).len(); | |
| let mut data_arrays = Vec::with_capacity(schema.fields().len()); | |
| for field in schema.fields() { | |
| let desired_data_type = field.data_type(); | |
| if let Some(original_data) = record_batch.column_by_name(field.name()) { | |
| let original_data_type = original_data.data_type(); | |
| if can_cast_types(original_data_type, desired_data_type) { | |
| data_arrays.push(arrow::compute::kernels::cast( | |
| original_data, | |
| desired_data_type, | |
| )?); | |
| } else if field.is_nullable() { | |
| data_arrays.push(new_null_array(desired_data_type, array_size)); | |
| } else { | |
| return Err(ArrowError::CastError(format!( | |
| "Attempting to cast to non-nullable and non-castable field {} during schema projection.", | |
| field.name() | |
| ))); | |
| } | |
| } else { | |
| if !field.is_nullable() { | |
| return Err(ArrowError::CastError(format!( | |
| "Attempting to set null to non-nullable field {} during schema projection.", | |
| field.name() | |
| ))); | |
| } | |
| data_arrays.push(new_null_array(desired_data_type, array_size)); | |
| } | |
| } | |
| RecordBatch::try_new(schema, data_arrays) | |
| } | |
| /// This is a helper function to return the first non-empty record batch from executing a DataFrame. | |
| /// It additionally returns a bool, which indicates if there are more record batches available. | |
| /// We do this so we can determine if we should indicate to the user that the data has been | |
| /// truncated. This collects until we have archived both of these two conditions | |
| /// | |
| /// - We have collected our minimum number of rows | |
| /// - We have reached our limit, either data size or maximum number of rows | |
| /// | |
| /// Otherwise it will return when the stream has exhausted. If you want a specific number of | |
| /// rows, set min_rows == max_rows. | |
| async fn collect_record_batches_to_display( | |
| df: DataFrame, | |
| config: FormatterConfig, | |
| ) -> Result<(Vec<RecordBatch>, bool), DataFusionError> { | |
| let FormatterConfig { | |
| max_bytes, | |
| min_rows, | |
| max_rows, | |
| } = config; | |
| let partitioned_stream = df.execute_stream_partitioned().await?; | |
| let mut stream = futures::stream::iter(partitioned_stream).flatten(); | |
| let mut size_estimate_so_far = 0; | |
| let mut rows_so_far = 0; | |
| let mut record_batches = Vec::default(); | |
| let mut has_more = false; | |
| // Collect rows until we hit a limit (memory or max_rows) OR reach the guaranteed minimum. | |
| // The minimum rows constraint overrides both memory and row limits to ensure a baseline | |
| // of data is always displayed, even if it temporarily exceeds those limits. | |
| // This provides better UX by guaranteeing users see at least min_rows rows. | |
| while (size_estimate_so_far < max_bytes && rows_so_far < max_rows) || rows_so_far < min_rows { | |
| let mut rb = match stream.next().await { | |
| None => { | |
| break; | |
| } | |
| Some(Ok(r)) => r, | |
| Some(Err(e)) => return Err(e), | |
| }; | |
| let mut rows_in_rb = rb.num_rows(); | |
| if rows_in_rb > 0 { | |
| size_estimate_so_far += rb.get_array_memory_size(); | |
| // When memory limit is exceeded, scale back row count proportionally to stay within budget | |
| if size_estimate_so_far > max_bytes { | |
| let ratio = max_bytes as f32 / size_estimate_so_far as f32; | |
| let total_rows = rows_in_rb + rows_so_far; | |
| // Calculate reduced rows maintaining the memory/data proportion | |
| let mut reduced_row_num = (total_rows as f32 * ratio).round() as usize; | |
| // Ensure we always respect the minimum rows guarantee | |
| if reduced_row_num < min_rows { | |
| reduced_row_num = min_rows.min(total_rows); | |
| } | |
| let limited_rows_this_rb = reduced_row_num - rows_so_far; | |
| if limited_rows_this_rb < rows_in_rb { | |
| rows_in_rb = limited_rows_this_rb; | |
| rb = rb.slice(0, limited_rows_this_rb); | |
| has_more = true; | |
| } | |
| } | |
| if rows_in_rb + rows_so_far > max_rows { | |
| rb = rb.slice(0, max_rows - rows_so_far); | |
| has_more = true; | |
| } | |
| rows_so_far += rb.num_rows(); | |
| record_batches.push(rb); | |
| } | |
| } | |
| if record_batches.is_empty() { | |
| return Ok((Vec::default(), false)); | |
| } | |
| if !has_more { | |
| // Data was not already truncated, so check to see if more record batches remain | |
| has_more = match stream.try_next().await { | |
| Ok(None) => false, // reached end | |
| Ok(Some(_)) => true, | |
| Err(_) => false, // Stream disconnected | |
| }; | |
| } | |
| Ok((record_batches, has_more)) | |
| } |