| // 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. |
| |
| //! Converts Spark physical plan to DataFusion physical plan |
| |
| pub mod expression_registry; |
| pub mod macros; |
| pub mod operator_registry; |
| |
| use crate::execution::operators::init_csv_datasource_exec; |
| use crate::execution::operators::AlignedArrowStreamReader; |
| use crate::execution::operators::IcebergScanExec; |
| use crate::execution::{ |
| expressions::list_positions::ListPositionsExpr, |
| expressions::subquery::Subquery, |
| operators::{ |
| ExecutionError, ExpandExec, ParquetCompression, ParquetWriterExec, SampleExec, ScanExec, |
| ShuffleScanExec, |
| }, |
| planner::expression_registry::ExpressionRegistry, |
| planner::operator_registry::OperatorRegistry, |
| serde::to_arrow_datatype, |
| shuffle::{SchemaAlignExec, ShuffleWriterExec}, |
| }; |
| use crate::jvm_bridge::{jni_call, JVMClasses}; |
| use arrow::compute::CastOptions; |
| use arrow::datatypes::{DataType, Field, FieldRef, Schema, TimeUnit, DECIMAL128_MAX_PRECISION}; |
| use arrow::ffi_stream::FFI_ArrowArrayStream; |
| use datafusion::functions_aggregate::bit_and_or_xor::{bit_and_udaf, bit_or_udaf, bit_xor_udaf}; |
| use datafusion::functions_aggregate::count::count_udaf; |
| use datafusion::functions_aggregate::min_max::max_udaf; |
| use datafusion::functions_aggregate::min_max::min_udaf; |
| use datafusion::functions_aggregate::sum::sum_udaf; |
| use datafusion::physical_expr::aggregate::{AggregateExprBuilder, AggregateFunctionExpr}; |
| use datafusion::physical_plan::windows::{BoundedWindowAggExec, WindowAggExec}; |
| use datafusion::physical_plan::InputOrderMode; |
| use datafusion::{ |
| arrow::{compute::SortOptions, datatypes::SchemaRef}, |
| common::DataFusionError, |
| config::ConfigOptions, |
| execution::FunctionRegistry, |
| functions_aggregate::first_last::{FirstValue, LastValue}, |
| logical_expr::Operator as DataFusionOperator, |
| physical_expr::{ |
| expressions::{ |
| in_list, BinaryExpr, CaseExpr, CastExpr, Column, IsNullExpr, |
| Literal as DataFusionLiteral, |
| }, |
| PhysicalExpr, PhysicalSortExpr, ScalarFunctionExpr, |
| }, |
| physical_plan::{ |
| aggregates::{AggregateMode as DFAggregateMode, PhysicalGroupBy}, |
| empty::EmptyExec, |
| joins::{utils::JoinFilter, HashJoinExec, PartitionMode, SortMergeJoinExec}, |
| limit::LocalLimitExec, |
| projection::ProjectionExec, |
| sorts::sort::SortExec, |
| ExecutionPlan, |
| }, |
| prelude::SessionContext, |
| }; |
| use datafusion_comet_spark_expr::{ |
| create_comet_physical_fun, create_comet_physical_fun_with_eval_mode, BinaryOutputStyle, |
| BloomFilterAgg, BloomFilterMightContain, CsvWriteOptions, EvalMode, SparkArraysZipFunc, |
| SparkBloomFilterVersion, SparkPercentile, SumInteger, ToCsv, |
| }; |
| use datafusion_spark::function::aggregate::collect::{SparkCollectList, SparkCollectSet}; |
| use iceberg::expr::Bind; |
| |
| use crate::execution::operators::ExecutionError::GeneralError; |
| use crate::execution::shuffle::{CometPartitioning, CompressionCodec}; |
| use crate::execution::spark_plan::SparkPlan; |
| use crate::parquet::parquet_support::prepare_object_store_with_configs; |
| use datafusion::common::scalar::ScalarStructBuilder; |
| use datafusion::common::{ |
| tree_node::{Transformed, TransformedResult, TreeNode, TreeNodeRecursion, TreeNodeRewriter}, |
| JoinType as DFJoinType, NullEquality, ScalarValue, |
| }; |
| use datafusion::datasource::listing::PartitionedFile; |
| use datafusion::logical_expr::type_coercion::functions::fields_with_udf; |
| use datafusion::logical_expr::type_coercion::other::get_coerce_type_for_case_expression; |
| use datafusion::logical_expr::{ |
| AggregateUDF, ReturnFieldArgs, ScalarUDF, TypeSignature, WindowFrame, WindowFrameBound, |
| WindowFrameUnits, WindowFunctionDefinition, |
| }; |
| use datafusion::physical_expr::expressions::{Literal, StatsType}; |
| use datafusion::physical_expr::window::WindowExpr; |
| use datafusion::physical_expr::LexOrdering; |
| |
| use crate::parquet::parquet_exec::init_datasource_exec; |
| use arrow::array::{ |
| new_empty_array, Array, ArrayRef, BinaryBuilder, BooleanArray, Date32Array, Decimal128Array, |
| Float32Array, Float64Array, Int16Array, Int32Array, Int64Array, Int8Array, ListArray, |
| NullArray, StringBuilder, TimestampMicrosecondArray, |
| }; |
| use arrow::buffer::{BooleanBuffer, NullBuffer, OffsetBuffer}; |
| use arrow::row::{OwnedRow, RowConverter, SortField}; |
| use datafusion::common::utils::SingleRowListArrayBuilder; |
| use datafusion::common::UnnestOptions; |
| use datafusion::physical_plan::filter::FilterExec; |
| use datafusion::physical_plan::joins::NestedLoopJoinExec; |
| use datafusion::physical_plan::limit::GlobalLimitExec; |
| use datafusion::physical_plan::unnest::{ListUnnest, UnnestExec}; |
| use datafusion_comet_proto::spark_expression::ListLiteral; |
| use datafusion_comet_proto::spark_operator::SparkFilePartition; |
| use datafusion_comet_proto::{ |
| spark_expression::{ |
| self, agg_expr::ExprStruct as AggExprStruct, expr::ExprStruct, literal::Value, AggExpr, |
| Expr, ScalarFunc, |
| }, |
| spark_operator::{ |
| self, lower_window_frame_bound::LowerFrameBoundStruct, operator::OpStruct, |
| upper_window_frame_bound::UpperFrameBoundStruct, AggregateMode as ProtoAggregateMode, |
| BuildSide, CompressionCodec as SparkCompressionCodec, JoinType, Operator, WindowFrameType, |
| }, |
| spark_partitioning::{partitioning::PartitioningStruct, Partitioning as SparkPartitioning}, |
| }; |
| use datafusion_comet_spark_expr::{ |
| jvm_udf::JvmScalarUdfExpr, ApproxPercentile, ArrayInsert, Avg, AvgDecimal, Cast, CheckOverflow, |
| Correlation, Covariance, CreateNamedStruct, DecimalRescaleCheckOverflow, GetArrayStructFields, |
| GetStructField, HllPlusPlus, IfExpr, ListExtract, NormalizeNaNAndZero, SparkCastOptions, |
| Stddev, SumDecimal, ToJson, UnboundColumn, Variance, WideDecimalBinaryExpr, WideDecimalOp, |
| }; |
| use itertools::Itertools; |
| use jni::objects::{Global, JObject}; |
| use num::{BigInt, ToPrimitive}; |
| use object_store::path::Path; |
| use std::cmp::max; |
| use std::{collections::HashMap, sync::Arc}; |
| use url::Url; |
| |
| // For clippy error on type_complexity. |
| type PhyAggResult = Result<Vec<AggregateFunctionExpr>, ExecutionError>; |
| type PhyExprResult = Result<Vec<(Arc<dyn PhysicalExpr>, String)>, ExecutionError>; |
| type PartitionPhyExprResult = Result<Vec<Arc<dyn PhysicalExpr>>, ExecutionError>; |
| pub type PlanCreationResult = |
| Result<(Vec<ScanExec>, Vec<ShuffleScanExec>, Arc<SparkPlan>), ExecutionError>; |
| |
| struct JoinParameters { |
| pub left: Arc<SparkPlan>, |
| pub right: Arc<SparkPlan>, |
| pub join_on: Vec<(Arc<dyn PhysicalExpr>, Arc<dyn PhysicalExpr>)>, |
| pub join_filter: Option<JoinFilter>, |
| pub join_type: DFJoinType, |
| } |
| |
| /// Return a copy of `data_type` with every nested field marked nullable. Map key fields are left |
| /// non-nullable to preserve Arrow's map invariant. Primitive types are returned unchanged. |
| fn make_all_fields_nullable(data_type: &DataType) -> DataType { |
| fn nullable_field(field: &Field, nullable: bool) -> FieldRef { |
| Arc::new( |
| Field::new( |
| field.name(), |
| make_all_fields_nullable(field.data_type()), |
| nullable, |
| ) |
| .with_metadata(field.metadata().clone()), |
| ) |
| } |
| match data_type { |
| DataType::Struct(fields) => { |
| DataType::Struct(fields.iter().map(|f| nullable_field(f, true)).collect()) |
| } |
| DataType::List(field) => DataType::List(nullable_field(field, true)), |
| DataType::LargeList(field) => DataType::LargeList(nullable_field(field, true)), |
| DataType::FixedSizeList(field, len) => { |
| DataType::FixedSizeList(nullable_field(field, true), *len) |
| } |
| DataType::Map(entries, sorted) => { |
| // Map entries are a non-nullable struct of {key (non-null), value}. Recurse into the |
| // key/value types but keep the key field non-nullable per Arrow's map invariant. |
| match entries.data_type() { |
| DataType::Struct(kv) => { |
| let new_kv = kv |
| .iter() |
| .enumerate() |
| .map(|(i, f)| nullable_field(f, i != 0)) |
| .collect(); |
| let new_entries = Field::new( |
| entries.name(), |
| DataType::Struct(new_kv), |
| entries.is_nullable(), |
| ) |
| .with_metadata(entries.metadata().clone()); |
| DataType::Map(Arc::new(new_entries), *sorted) |
| } |
| _ => data_type.clone(), |
| } |
| } |
| other => other.clone(), |
| } |
| } |
| |
| /// If `expr` evaluates to `Timestamp(_, Some(_))` against `schema`, wrap it in a |
| /// metadata-only cast to `Timestamp(_, None)`. This is required because |
| /// DataFusion's `SortMergeJoinExec` comparator only supports timezone-less |
| /// timestamp types, while Spark's `TimestampType` serializes as |
| /// `Timestamp(µs, "UTC")`. The cast preserves ordering on the same time unit. |
| fn strip_timestamp_tz( |
| expr: Arc<dyn PhysicalExpr>, |
| schema: &Schema, |
| ) -> Result<Arc<dyn PhysicalExpr>, ExecutionError> { |
| match expr.data_type(schema)? { |
| DataType::Timestamp(unit, Some(_)) => Ok(Arc::new(CastExpr::new( |
| expr, |
| DataType::Timestamp(unit, None), |
| None, |
| ))), |
| _ => Ok(expr), |
| } |
| } |
| |
| #[derive(Default)] |
| pub struct BinaryExprOptions { |
| pub is_integral_div: bool, |
| /// See `MathExpr.check_divide_overflow` in expr.proto |
| pub check_divide_overflow: bool, |
| } |
| |
| pub const TEST_EXEC_CONTEXT_ID: i64 = -1; |
| |
| /// The query planner for converting Spark query plans to DataFusion query plans. |
| pub struct PhysicalPlanner { |
| // The execution context id of this planner. |
| exec_context_id: i64, |
| partition: i32, |
| session_ctx: Arc<SessionContext>, |
| query_context_registry: Arc<datafusion_comet_spark_expr::QueryContextMap>, |
| /// Captured at `createPlan` time on `ExecutionContext`; see that struct for the |
| /// propagation rationale. `None` when no driving Spark task is available. |
| task_context: Option<Arc<Global<JObject<'static>>>>, |
| } |
| |
| impl Default for PhysicalPlanner { |
| fn default() -> Self { |
| Self::new(Arc::new(SessionContext::new()), 0) |
| } |
| } |
| |
| impl PhysicalPlanner { |
| pub fn new(session_ctx: Arc<SessionContext>, partition: i32) -> Self { |
| Self { |
| exec_context_id: TEST_EXEC_CONTEXT_ID, |
| session_ctx, |
| partition, |
| query_context_registry: datafusion_comet_spark_expr::create_query_context_map(), |
| task_context: None, |
| } |
| } |
| |
| pub fn with_exec_id(mut self, exec_context_id: i64) -> Self { |
| self.exec_context_id = exec_context_id; |
| self |
| } |
| |
| /// Attach a propagated Spark `TaskContext` global reference. Called by the JNI `executePlan` |
| /// entry with whatever was captured at `createPlan` time. The planner clones this `Option` |
| /// into every `JvmScalarUdfExpr` it builds. |
| pub fn with_task_context( |
| mut self, |
| task_context: Option<Arc<Global<JObject<'static>>>>, |
| ) -> Self { |
| self.task_context = task_context; |
| self |
| } |
| |
| /// Return session context of this planner. |
| pub fn session_ctx(&self) -> &Arc<SessionContext> { |
| &self.session_ctx |
| } |
| |
| /// Return partition id of this planner. |
| pub fn partition(&self) -> i32 { |
| self.partition |
| } |
| |
| /// get DataFusion PartitionedFiles from a Spark FilePartition |
| fn get_partitioned_files( |
| &self, |
| partition: &SparkFilePartition, |
| ) -> Result<Vec<PartitionedFile>, ExecutionError> { |
| let mut files = Vec::with_capacity(partition.partitioned_file.len()); |
| partition.partitioned_file.iter().try_for_each(|file| { |
| assert!(file.start + file.length <= file.file_size); |
| |
| let mut partitioned_file = PartitionedFile::new_with_range( |
| String::new(), // Dummy file path. |
| file.file_size as u64, |
| file.start, |
| file.start + file.length, |
| ); |
| |
| // Spark sends the path over as URL-encoded, parse that first. |
| let url = |
| Url::parse(file.file_path.as_ref()).map_err(|e| GeneralError(e.to_string()))?; |
| // Convert that to a Path object to use in the PartitionedFile. |
| let path = Path::from_url_path(url.path()).map_err(|e| GeneralError(e.to_string()))?; |
| partitioned_file.object_meta.location = path; |
| |
| // Process partition values |
| // Create an empty input schema for partition values because they are all literals. |
| let empty_schema = Arc::new(Schema::empty()); |
| let partition_values: Result<Vec<_>, _> = file |
| .partition_values |
| .iter() |
| .map(|partition_value| { |
| let literal = |
| self.create_expr(partition_value, Arc::<Schema>::clone(&empty_schema))?; |
| literal |
| .downcast_ref::<DataFusionLiteral>() |
| .ok_or_else(|| { |
| GeneralError("Expected literal of partition value".to_string()) |
| }) |
| .map(|literal| literal.value().clone()) |
| }) |
| .collect(); |
| let partition_values = partition_values?; |
| |
| partitioned_file.partition_values = partition_values; |
| |
| files.push(partitioned_file); |
| Ok::<(), ExecutionError>(()) |
| })?; |
| |
| Ok(files) |
| } |
| |
| /// Create a DataFusion physical expression from Spark physical expression |
| pub(crate) fn create_expr( |
| &self, |
| spark_expr: &Expr, |
| input_schema: SchemaRef, |
| ) -> Result<Arc<dyn PhysicalExpr>, ExecutionError> { |
| // Register QueryContext if present |
| if let (Some(expr_id), Some(ctx_proto)) = |
| (spark_expr.expr_id, spark_expr.query_context.as_ref()) |
| { |
| // Deserialize QueryContext from protobuf |
| let query_ctx = datafusion_comet_spark_expr::QueryContext::new( |
| ctx_proto.sql_text.clone(), |
| ctx_proto.start_index, |
| ctx_proto.stop_index, |
| ctx_proto.object_type.clone(), |
| ctx_proto.object_name.clone(), |
| ctx_proto.line, |
| ctx_proto.start_position, |
| ); |
| |
| // Register query context for error reporting |
| let registry = &self.query_context_registry; |
| registry.register(expr_id, query_ctx); |
| } |
| |
| // Try to use the modular registry first - this automatically handles any registered expression types |
| if ExpressionRegistry::global().can_handle(spark_expr) { |
| return ExpressionRegistry::global().create_expr(spark_expr, input_schema, self); |
| } |
| |
| // Fall back to the original monolithic match for other expressions |
| match spark_expr.expr_struct.as_ref().unwrap() { |
| ExprStruct::Bound(bound) => { |
| let idx = bound.index as usize; |
| if idx >= input_schema.fields().len() { |
| return Err(GeneralError(format!( |
| "Column index {idx} is out of bound. Schema: {input_schema}" |
| ))); |
| } |
| let field = input_schema.field(idx); |
| Ok(Arc::new(Column::new(field.name().as_str(), idx))) |
| } |
| ExprStruct::Unbound(unbound) => { |
| let data_type = to_arrow_datatype(unbound.datatype.as_ref().unwrap()); |
| Ok(Arc::new(UnboundColumn::new( |
| unbound.name.as_str(), |
| data_type, |
| ))) |
| } |
| ExprStruct::Literal(literal) => { |
| let data_type = to_arrow_datatype(literal.datatype.as_ref().unwrap()); |
| let scalar_value = if literal.is_null { |
| match data_type { |
| DataType::Boolean => ScalarValue::Boolean(None), |
| DataType::Int8 => ScalarValue::Int8(None), |
| DataType::Int16 => ScalarValue::Int16(None), |
| DataType::Int32 => ScalarValue::Int32(None), |
| DataType::Int64 => ScalarValue::Int64(None), |
| DataType::Float32 => ScalarValue::Float32(None), |
| DataType::Float64 => ScalarValue::Float64(None), |
| DataType::Utf8 => ScalarValue::Utf8(None), |
| DataType::Date32 => ScalarValue::Date32(None), |
| DataType::Timestamp(TimeUnit::Microsecond, timezone) => { |
| ScalarValue::TimestampMicrosecond(None, timezone) |
| } |
| DataType::Binary => ScalarValue::Binary(None), |
| DataType::Decimal128(p, s) => ScalarValue::Decimal128(None, p, s), |
| DataType::Struct(fields) => ScalarStructBuilder::new_null(fields), |
| DataType::Map(f, s) => DataType::Map(f, s).try_into()?, |
| DataType::List(f) => DataType::List(f).try_into()?, |
| DataType::Null => ScalarValue::Null, |
| DataType::Time64(TimeUnit::Nanosecond) => { |
| ScalarValue::Time64Nanosecond(None) |
| } |
| DataType::Duration(TimeUnit::Microsecond) => { |
| ScalarValue::DurationMicrosecond(None) |
| } |
| dt => { |
| return Err(GeneralError(format!("{dt:?} is not supported in Comet"))) |
| } |
| } |
| } else { |
| match literal.value.as_ref().unwrap() { |
| Value::BoolVal(value) => ScalarValue::Boolean(Some(*value)), |
| Value::ByteVal(value) => ScalarValue::Int8(Some(*value as i8)), |
| Value::ShortVal(value) => ScalarValue::Int16(Some(*value as i16)), |
| Value::IntVal(value) => match data_type { |
| DataType::Int32 => ScalarValue::Int32(Some(*value)), |
| DataType::Date32 => ScalarValue::Date32(Some(*value)), |
| dt => { |
| return Err(GeneralError(format!( |
| "Expected either 'Int32' or 'Date32' for IntVal, but found {dt:?}" |
| ))) |
| } |
| }, |
| Value::LongVal(value) => match data_type { |
| DataType::Int64 => ScalarValue::Int64(Some(*value)), |
| DataType::Timestamp(TimeUnit::Microsecond, None) => { |
| ScalarValue::TimestampMicrosecond(Some(*value), None) |
| } |
| DataType::Timestamp(TimeUnit::Microsecond, Some(tz)) => { |
| ScalarValue::TimestampMicrosecond(Some(*value), Some(tz)) |
| } |
| DataType::Duration(TimeUnit::Microsecond) => { |
| ScalarValue::DurationMicrosecond(Some(*value)) |
| } |
| dt => { |
| return Err(GeneralError(format!( |
| "Expected 'Int64', 'Timestamp', or 'Duration(Microsecond)' for LongVal, but found {dt:?}" |
| ))) |
| } |
| }, |
| Value::FloatVal(value) => ScalarValue::Float32(Some(*value)), |
| Value::DoubleVal(value) => ScalarValue::Float64(Some(*value)), |
| Value::StringVal(value) => ScalarValue::Utf8(Some(value.clone())), |
| Value::BytesVal(value) => ScalarValue::Binary(Some(value.clone())), |
| Value::DecimalVal(value) => { |
| let big_integer = BigInt::from_signed_bytes_be(value); |
| let integer = big_integer.to_i128().ok_or_else(|| { |
| GeneralError(format!( |
| "Cannot parse {big_integer:?} as i128 for Decimal literal" |
| )) |
| })?; |
| |
| match data_type { |
| DataType::Decimal128(p, s) => { |
| ScalarValue::Decimal128(Some(integer), p, s) |
| } |
| dt => { |
| return Err(GeneralError(format!( |
| "Decimal literal's data type should be Decimal128 but got {dt:?}" |
| ))) |
| } |
| } |
| } |
| Value::ListVal(values) => { |
| if let DataType::List(_) = data_type { |
| SingleRowListArrayBuilder::new(literal_to_array_ref(data_type, values.clone())?).build_list_scalar() |
| } else { |
| return Err(GeneralError(format!( |
| "Expected DataType::List but got {data_type:?}" |
| ))); |
| } |
| } |
| } |
| }; |
| Ok(Arc::new(DataFusionLiteral::new(scalar_value))) |
| } |
| ExprStruct::Cast(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), input_schema)?; |
| let datatype = to_arrow_datatype(expr.datatype.as_ref().unwrap()); |
| let eval_mode = from_protobuf_eval_mode(expr.eval_mode)?; |
| |
| // Look up query context from registry if expr_id is present |
| let query_context = spark_expr.expr_id.and_then(|expr_id| { |
| let registry = &self.query_context_registry; |
| registry.get(expr_id) |
| }); |
| |
| Ok(Arc::new(Cast::new( |
| child, |
| datatype, |
| SparkCastOptions::new_with_version( |
| eval_mode, |
| &expr.timezone, |
| expr.allow_incompat, |
| expr.is_spark4_plus, |
| ), |
| spark_expr.expr_id, |
| query_context, |
| ))) |
| } |
| ExprStruct::CheckOverflow(expr) => { |
| let child = |
| self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&input_schema))?; |
| let data_type = to_arrow_datatype(expr.datatype.as_ref().unwrap()); |
| let fail_on_error = expr.fail_on_error; |
| |
| // WideDecimalBinaryExpr already handles overflow — skip redundant check |
| // but only if its output type matches CheckOverflow's declared type |
| if child.downcast_ref::<WideDecimalBinaryExpr>().is_some() { |
| let child_type = child.data_type(&input_schema)?; |
| if child_type == data_type { |
| return Ok(child); |
| } |
| } |
| |
| // Fuse Cast(Decimal128→Decimal128) + CheckOverflow into single rescale+check |
| // Only fuse when the Cast target type matches the CheckOverflow output type |
| if let Some(cast) = child.downcast_ref::<Cast>() { |
| if let ( |
| DataType::Decimal128(p_out, s_out), |
| Ok(DataType::Decimal128(_p_in, s_in)), |
| ) = (&data_type, cast.child.data_type(&input_schema)) |
| { |
| let cast_target = cast.data_type(&input_schema)?; |
| if cast_target == data_type { |
| return Ok(Arc::new(DecimalRescaleCheckOverflow::new( |
| Arc::clone(&cast.child), |
| s_in, |
| *p_out, |
| *s_out, |
| fail_on_error, |
| ))); |
| } |
| } |
| } |
| |
| // Look up query context from registry if expr_id is present |
| let query_context = spark_expr.expr_id.and_then(|expr_id| { |
| let registry = &self.query_context_registry; |
| registry.get(expr_id) |
| }); |
| |
| Ok(Arc::new(CheckOverflow::new( |
| child, |
| data_type, |
| fail_on_error, |
| spark_expr.expr_id, |
| query_context, |
| ))) |
| } |
| ExprStruct::ScalarFunc(expr) => { |
| let func = self.create_scalar_function_expr(expr, input_schema); |
| match expr.func.as_ref() { |
| // DataFusion map_extract returns array of struct entries even if lookup by key |
| // Apache Spark wants a single value, so wrap the result into additional list extraction |
| "map_extract" => Ok(Arc::new(ListExtract::new( |
| func?, |
| Arc::new(Literal::new(ScalarValue::Int32(Some(1)))), |
| None, |
| true, |
| false, |
| None, // No expr_id for internal map_extract wrapper |
| Arc::clone(&self.query_context_registry), |
| ))), |
| // DataFusion 49 hardcodes return type for MD5 built in function as UTF8View |
| // which is not yet supported in Comet |
| // Converting forcibly to UTF8. To be removed after UTF8View supported |
| "md5" => Ok(Arc::new(Cast::new( |
| func?, |
| DataType::Utf8, |
| SparkCastOptions::new_without_timezone(EvalMode::Try, true), |
| None, |
| None, |
| ))), |
| _ => func, |
| } |
| } |
| ExprStruct::CaseWhen(case_when) => { |
| let when_then_pairs = case_when |
| .when |
| .iter() |
| .map(|x| self.create_expr(x, Arc::clone(&input_schema))) |
| .zip( |
| case_when |
| .then |
| .iter() |
| .map(|then| self.create_expr(then, Arc::clone(&input_schema))), |
| ) |
| .try_fold(Vec::new(), |mut acc, (a, b)| { |
| acc.push((a?, b?)); |
| Ok::<Vec<(Arc<dyn PhysicalExpr>, Arc<dyn PhysicalExpr>)>, ExecutionError>( |
| acc, |
| ) |
| })?; |
| |
| let else_phy_expr = match &case_when.else_expr { |
| None => None, |
| Some(_) => Some(self.create_expr( |
| case_when.else_expr.as_ref().unwrap(), |
| Arc::clone(&input_schema), |
| )?), |
| }; |
| |
| create_case_expr(when_then_pairs, else_phy_expr, &input_schema) |
| } |
| ExprStruct::In(expr) => { |
| let value = |
| self.create_expr(expr.in_value.as_ref().unwrap(), Arc::clone(&input_schema))?; |
| let list = expr |
| .lists |
| .iter() |
| .map(|x| self.create_expr(x, Arc::clone(&input_schema))) |
| .collect::<Result<Vec<_>, _>>()?; |
| |
| in_list(value, list, &expr.negated, input_schema.as_ref()).map_err(|e| e.into()) |
| } |
| ExprStruct::If(expr) => { |
| let if_expr = |
| self.create_expr(expr.if_expr.as_ref().unwrap(), Arc::clone(&input_schema))?; |
| let true_expr = |
| self.create_expr(expr.true_expr.as_ref().unwrap(), Arc::clone(&input_schema))?; |
| let false_expr = |
| self.create_expr(expr.false_expr.as_ref().unwrap(), input_schema)?; |
| Ok(Arc::new(IfExpr::new(if_expr, true_expr, false_expr))) |
| } |
| ExprStruct::NormalizeNanAndZero(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), input_schema)?; |
| let data_type = to_arrow_datatype(expr.datatype.as_ref().unwrap()); |
| Ok(Arc::new(NormalizeNaNAndZero::new(data_type, child))) |
| } |
| ExprStruct::PreciseTimestampConversion(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), input_schema)?; |
| let data_type = to_arrow_datatype(expr.datatype.as_ref().unwrap()); |
| // Spark's PreciseTimestampConversion reinterprets the value between |
| // Timestamp (microseconds) and Long without any conversion. Arrow's cast kernel |
| // performs exactly this zero-cost reinterpret between Timestamp(µs) and Int64, |
| // so we use DataFusion's CastExpr (arrow semantics) rather than Comet's |
| // Spark-compatible Cast (which would scale by 1_000_000). |
| Ok(Arc::new(CastExpr::new(child, data_type, None))) |
| } |
| ExprStruct::Subquery(expr) => { |
| let id = expr.id; |
| let data_type = to_arrow_datatype(expr.datatype.as_ref().unwrap()); |
| Ok(Arc::new(Subquery::new(self.exec_context_id, id, data_type))) |
| } |
| ExprStruct::BloomFilterMightContain(expr) => { |
| let bloom_filter_expr = self.create_expr( |
| expr.bloom_filter.as_ref().unwrap(), |
| Arc::clone(&input_schema), |
| )?; |
| |
| // We only provide the values as argument, the bloom filter is created only in plan time. |
| let value_expr = self.create_expr(expr.value.as_ref().unwrap(), input_schema)?; |
| let args = vec![value_expr]; |
| let udf = |
| ScalarUDF::new_from_impl(BloomFilterMightContain::try_new(bloom_filter_expr)?); |
| |
| let field_ref = Arc::new(Field::new("might_contain", DataType::Boolean, true)); |
| let expr: ScalarFunctionExpr = ScalarFunctionExpr::new( |
| "might_contain", |
| Arc::new(udf), |
| args, |
| field_ref, |
| Arc::new(ConfigOptions::default()), |
| ); |
| Ok(Arc::new(expr)) |
| } |
| ExprStruct::CreateNamedStruct(expr) => { |
| let values = expr |
| .values |
| .iter() |
| .map(|expr| self.create_expr(expr, Arc::clone(&input_schema))) |
| .collect::<Result<Vec<_>, _>>()?; |
| let names = expr.names.clone(); |
| Ok(Arc::new(CreateNamedStruct::new(values, names))) |
| } |
| ExprStruct::GetStructField(expr) => { |
| let child = |
| self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&input_schema))?; |
| Ok(Arc::new(GetStructField::new(child, expr.ordinal as usize))) |
| } |
| ExprStruct::ToJson(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), input_schema)?; |
| Ok(Arc::new(ToJson::new( |
| child, |
| &expr.timezone, |
| expr.ignore_null_fields, |
| ))) |
| } |
| ExprStruct::ToPrettyString(expr) => { |
| let mut spark_cast_options = |
| SparkCastOptions::new(EvalMode::Try, &expr.timezone, true); |
| let null_string = "NULL"; |
| spark_cast_options.null_string = null_string.to_string(); |
| spark_cast_options.binary_output_style = |
| from_protobuf_binary_output_style(expr.binary_output_style).ok(); |
| let child = self.create_expr(expr.child.as_ref().unwrap(), input_schema)?; |
| let cast = Arc::new(Cast::new( |
| Arc::clone(&child), |
| DataType::Utf8, |
| spark_cast_options, |
| None, |
| None, |
| )); |
| Ok(Arc::new(IfExpr::new( |
| Arc::new(IsNullExpr::new(child)), |
| Arc::new(Literal::new(ScalarValue::Utf8(Some( |
| null_string.to_string(), |
| )))), |
| cast, |
| ))) |
| } |
| ExprStruct::ListExtract(expr) => { |
| let child = |
| self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&input_schema))?; |
| let ordinal = |
| self.create_expr(expr.ordinal.as_ref().unwrap(), Arc::clone(&input_schema))?; |
| let default_value = expr |
| .default_value |
| .as_ref() |
| .map(|e| self.create_expr(e, Arc::clone(&input_schema))) |
| .transpose()?; |
| Ok(Arc::new(ListExtract::new( |
| child, |
| ordinal, |
| default_value, |
| expr.one_based, |
| expr.fail_on_error, |
| spark_expr.expr_id, |
| Arc::clone(&self.query_context_registry), |
| ))) |
| } |
| ExprStruct::GetArrayStructFields(expr) => { |
| let child = |
| self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&input_schema))?; |
| |
| Ok(Arc::new(GetArrayStructFields::new( |
| child, |
| expr.ordinal as usize, |
| ))) |
| } |
| ExprStruct::ArrayInsert(expr) => { |
| let src_array_expr = self.create_expr( |
| expr.src_array_expr.as_ref().unwrap(), |
| Arc::clone(&input_schema), |
| )?; |
| let pos_expr = |
| self.create_expr(expr.pos_expr.as_ref().unwrap(), Arc::clone(&input_schema))?; |
| let item_expr = |
| self.create_expr(expr.item_expr.as_ref().unwrap(), Arc::clone(&input_schema))?; |
| Ok(Arc::new(ArrayInsert::new( |
| src_array_expr, |
| pos_expr, |
| item_expr, |
| expr.legacy_negative_index, |
| ))) |
| } |
| ExprStruct::ToCsv(expr) => { |
| let csv_struct_expr = |
| self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&input_schema))?; |
| let options = expr.options.clone().unwrap(); |
| let csv_write_options = CsvWriteOptions::new( |
| options.delimiter, |
| options.quote, |
| options.escape, |
| options.null_value, |
| options.quote_all, |
| options.ignore_leading_white_space, |
| options.ignore_trailing_white_space, |
| ); |
| Ok(Arc::new(ToCsv::new( |
| csv_struct_expr, |
| &options.timezone, |
| csv_write_options, |
| ))) |
| } |
| ExprStruct::ArraysZip(expr) => { |
| if expr.values.is_empty() { |
| return Err(GeneralError( |
| "arrays_zip requires at least one argument".to_string(), |
| )); |
| } |
| |
| let children = expr |
| .values |
| .iter() |
| .map(|child| self.create_expr(child, Arc::clone(&input_schema))) |
| .collect::<Result<Vec<_>, _>>()?; |
| |
| Ok(Arc::new(SparkArraysZipFunc::new( |
| children, |
| expr.names.clone(), |
| ))) |
| } |
| ExprStruct::JvmScalarUdf(udf) => { |
| let args = udf |
| .args |
| .iter() |
| .map(|e| self.create_expr(e, Arc::clone(&input_schema))) |
| .collect::<Result<Vec<_>, _>>()?; |
| let return_type = |
| to_arrow_datatype(udf.return_type.as_ref().ok_or_else(|| { |
| GeneralError("JvmScalarUdf missing return_type".to_string()) |
| })?); |
| // Invariant: task_context is propagated for every JvmScalarUdfExpr built during |
| // normal execution. The TEST_EXEC_CONTEXT_ID path is the only context in which |
| // task_context may legitimately be None (unit tests, direct native driver runs). |
| debug_assert!( |
| self.task_context.is_some() || self.exec_context_id == TEST_EXEC_CONTEXT_ID, |
| "task_context must be set for non-test execution" |
| ); |
| Ok(Arc::new(JvmScalarUdfExpr::new( |
| udf.class_name.clone(), |
| args, |
| return_type, |
| udf.return_nullable, |
| self.task_context.clone(), |
| ))) |
| } |
| expr => Err(GeneralError(format!("Not implemented: {expr:?}"))), |
| } |
| } |
| |
| /// Create a DataFusion physical sort expression from Spark physical expression |
| fn create_sort_expr<'a>( |
| &'a self, |
| spark_expr: &'a Expr, |
| input_schema: SchemaRef, |
| ) -> Result<PhysicalSortExpr, ExecutionError> { |
| match spark_expr.expr_struct.as_ref().unwrap() { |
| ExprStruct::SortOrder(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), input_schema)?; |
| let descending = expr.direction == 1; |
| let nulls_first = expr.null_ordering == 0; |
| |
| let options = SortOptions { |
| descending, |
| nulls_first, |
| }; |
| |
| Ok(PhysicalSortExpr { |
| expr: child, |
| options, |
| }) |
| } |
| expr => Err(GeneralError(format!("{expr:?} isn't a SortOrder"))), |
| } |
| } |
| |
| #[allow(clippy::too_many_arguments)] |
| pub fn create_binary_expr( |
| &self, |
| spark_expr: &Expr, |
| left: &Expr, |
| right: &Expr, |
| return_type: Option<&spark_expression::DataType>, |
| op: DataFusionOperator, |
| input_schema: SchemaRef, |
| eval_mode: EvalMode, |
| ) -> Result<Arc<dyn PhysicalExpr>, ExecutionError> { |
| self.create_binary_expr_with_options( |
| spark_expr, |
| left, |
| right, |
| return_type, |
| op, |
| input_schema, |
| BinaryExprOptions::default(), |
| eval_mode, |
| ) |
| } |
| |
| #[allow(clippy::too_many_arguments)] |
| pub fn create_binary_expr_with_options( |
| &self, |
| spark_expr: &Expr, |
| left: &Expr, |
| right: &Expr, |
| return_type: Option<&spark_expression::DataType>, |
| op: DataFusionOperator, |
| input_schema: SchemaRef, |
| options: BinaryExprOptions, |
| eval_mode: EvalMode, |
| ) -> Result<Arc<dyn PhysicalExpr>, ExecutionError> { |
| // Look up query context from registry if expr_id is present |
| let query_context = spark_expr.expr_id.and_then(|expr_id| { |
| let registry = &self.query_context_registry; |
| registry.get(expr_id) |
| }); |
| |
| let left = self.create_expr(left, Arc::clone(&input_schema))?; |
| let right = self.create_expr(right, Arc::clone(&input_schema))?; |
| match ( |
| &op, |
| left.data_type(&input_schema), |
| right.data_type(&input_schema), |
| ) { |
| ( |
| DataFusionOperator::Plus | DataFusionOperator::Minus | DataFusionOperator::Multiply, |
| Ok(DataType::Decimal128(p1, s1)), |
| Ok(DataType::Decimal128(p2, s2)), |
| ) if ((op == DataFusionOperator::Plus || op == DataFusionOperator::Minus) |
| && max(s1, s2) as u8 + max(p1 - s1 as u8, p2 - s2 as u8) |
| >= DECIMAL128_MAX_PRECISION) |
| || (op == DataFusionOperator::Multiply && p1 + p2 >= DECIMAL128_MAX_PRECISION) => |
| { |
| let data_type = return_type.map(to_arrow_datatype).unwrap(); |
| let (p_out, s_out) = match &data_type { |
| DataType::Decimal128(p, s) => (*p, *s), |
| dt => { |
| return Err(ExecutionError::GeneralError(format!( |
| "Expected Decimal128 return type, got {dt:?}" |
| ))) |
| } |
| }; |
| let wide_op = match op { |
| DataFusionOperator::Plus => WideDecimalOp::Add, |
| DataFusionOperator::Minus => WideDecimalOp::Subtract, |
| DataFusionOperator::Multiply => WideDecimalOp::Multiply, |
| _ => unreachable!(), |
| }; |
| Ok(Arc::new(WideDecimalBinaryExpr::new( |
| left, right, wide_op, p_out, s_out, eval_mode, |
| ))) |
| } |
| ( |
| DataFusionOperator::Divide, |
| Ok(DataType::Decimal128(_p1, _s1)), |
| Ok(DataType::Decimal128(_p2, _s2)), |
| ) => { |
| let data_type = return_type.map(to_arrow_datatype).unwrap(); |
| let func_name = if options.is_integral_div { |
| // Decimal256 division in Arrow may overflow, so we still need this variant of decimal_div. |
| // Otherwise, we may be able to reuse the previous case-match instead of here, |
| // see more: https://github.com/apache/datafusion-comet/pull/1428#discussion_r1972648463 |
| "decimal_integral_div" |
| } else { |
| "decimal_div" |
| }; |
| // check_divide_overflow rides in the generic fail_on_error slot; only |
| // decimal_integral_div consumes it |
| let fun_expr = create_comet_physical_fun_with_eval_mode( |
| func_name, |
| data_type.clone(), |
| &self.session_ctx.state(), |
| Some(options.check_divide_overflow), |
| eval_mode, |
| )?; |
| Ok(Arc::new(ScalarFunctionExpr::new( |
| func_name, |
| fun_expr, |
| vec![left, right], |
| Arc::new(Field::new(func_name, data_type, true)), |
| Arc::new(ConfigOptions::default()), |
| ))) |
| } |
| // Date +/- Int8/Int16/Int32: DataFusion 52's arrow-arith kernels only |
| // support Date32 +/- Interval types, not raw integers. Use the Spark |
| // date_add / date_sub UDFs which handle Int8/Int16/Int32 directly. |
| ( |
| DataFusionOperator::Plus, |
| Ok(DataType::Date32), |
| Ok(DataType::Int8 | DataType::Int16 | DataType::Int32), |
| ) => { |
| let udf = Arc::new(ScalarUDF::new_from_impl( |
| datafusion_spark::function::datetime::date_add::SparkDateAdd::new(), |
| )); |
| Ok(Arc::new(ScalarFunctionExpr::new( |
| "date_add", |
| udf, |
| vec![left, right], |
| Arc::new(Field::new("date_add", DataType::Date32, true)), |
| Arc::new(ConfigOptions::default()), |
| ))) |
| } |
| ( |
| DataFusionOperator::Minus, |
| Ok(DataType::Date32), |
| Ok(DataType::Int8 | DataType::Int16 | DataType::Int32), |
| ) => { |
| let udf = Arc::new(ScalarUDF::new_from_impl( |
| datafusion_spark::function::datetime::date_sub::SparkDateSub::new(), |
| )); |
| Ok(Arc::new(ScalarFunctionExpr::new( |
| "date_sub", |
| udf, |
| vec![left, right], |
| Arc::new(Field::new("date_sub", DataType::Date32, true)), |
| Arc::new(ConfigOptions::default()), |
| ))) |
| } |
| _ => { |
| let data_type = return_type.map(to_arrow_datatype).unwrap(); |
| if [EvalMode::Try, EvalMode::Ansi].contains(&eval_mode) |
| && (data_type.is_integer() |
| || (data_type.is_floating() && op == DataFusionOperator::Divide)) |
| { |
| let op_str = match op { |
| DataFusionOperator::Plus => "checked_add", |
| DataFusionOperator::Minus => "checked_sub", |
| DataFusionOperator::Multiply => "checked_mul", |
| DataFusionOperator::Divide => "checked_div", |
| _ => { |
| todo!("ANSI mode for Operator yet to be implemented!"); |
| } |
| }; |
| let fun_expr = create_comet_physical_fun_with_eval_mode( |
| op_str, |
| data_type.clone(), |
| &self.session_ctx.state(), |
| None, |
| eval_mode, |
| )?; |
| let scalar_expr = Arc::new(ScalarFunctionExpr::new( |
| op_str, |
| fun_expr, |
| vec![left, right], |
| Arc::new(Field::new(op_str, data_type, true)), |
| Arc::new(ConfigOptions::default()), |
| )); |
| |
| // Wrap with CheckedBinaryExpr to add query_context to errors |
| use crate::execution::expressions::arithmetic::CheckedBinaryExpr; |
| Ok(Arc::new(CheckedBinaryExpr::new(scalar_expr, query_context))) |
| } else { |
| Ok(Arc::new(BinaryExpr::new(left, op, right))) |
| } |
| } |
| } |
| } |
| |
| /// DataFusion's nested comparison kernel (`apply_cmp_for_nested`) requires both operands to |
| /// have identical data types, including nested field nullability, whereas Spark comparisons |
| /// ignore nullability. When a comparison's operands are nested types that differ only in |
| /// nullability (e.g. a higher-order `transform` produces `List(non-null Struct)` while the |
| /// other side is `List(nullable Struct)`), cast both to their nullability-union type so the |
| /// kernel accepts them. Non-comparison ops and non-nested or already-matching types are left |
| /// untouched. |
| pub fn reconcile_nested_comparison_types( |
| left: Arc<dyn PhysicalExpr>, |
| right: Arc<dyn PhysicalExpr>, |
| op: &DataFusionOperator, |
| input_schema: &SchemaRef, |
| ) -> (Arc<dyn PhysicalExpr>, Arc<dyn PhysicalExpr>) { |
| use DataFusionOperator::*; |
| let is_cmp = matches!( |
| op, |
| Eq | NotEq | Lt | LtEq | Gt | GtEq | IsDistinctFrom | IsNotDistinctFrom |
| ); |
| if !is_cmp { |
| return (left, right); |
| } |
| let (lt, rt) = match (left.data_type(input_schema), right.data_type(input_schema)) { |
| (Ok(lt), Ok(rt)) => (lt, rt), |
| _ => return (left, right), |
| }; |
| // Only nested types route through `apply_cmp_for_nested`; primitives coerce fine. |
| let nested = matches!( |
| lt, |
| DataType::List(_) |
| | DataType::LargeList(_) |
| | DataType::FixedSizeList(_, _) |
| | DataType::Struct(_) |
| | DataType::Map(_, _) |
| ); |
| if !nested || lt.equals_datatype(&rt) { |
| return (left, right); |
| } |
| // `Field::try_merge` unions nullability recursively while preserving structure (and the |
| // Map/list invariants). Bail out unchanged if the structures are genuinely incompatible. |
| let mut merged = Field::new("c", lt.clone(), true); |
| if merged |
| .try_merge(&Field::new("c", rt.clone(), true)) |
| .is_err() |
| { |
| return (left, right); |
| } |
| let target = merged.data_type().clone(); |
| let cast_to_target = |e: Arc<dyn PhysicalExpr>, dt: &DataType| -> Arc<dyn PhysicalExpr> { |
| if dt.equals_datatype(&target) { |
| e |
| } else { |
| Arc::new(CastExpr::new(e, target.clone(), None)) |
| } |
| }; |
| (cast_to_target(left, <), cast_to_target(right, &rt)) |
| } |
| |
| /// Create a DataFusion physical plan from Spark physical plan. There is a level of |
| /// abstraction where a tree of SparkPlan nodes is returned. There is a 1:1 mapping from a |
| /// protobuf Operator (that represents a Spark operator) to a native SparkPlan struct. We |
| /// need this 1:1 mapping so that we can report metrics back to Spark. The native execution |
| /// plan that is generated for each Operator is sometimes a single ExecutionPlan, but in some |
| /// cases we generate a tree of ExecutionPlans and we need to collect metrics for all of these |
| /// plans so we store references to them in the SparkPlan struct. |
| /// |
| /// `inputs` is a vector of input source IDs. It is used to create `ScanExec`s. Each `ScanExec` |
| /// will be assigned a unique ID from `inputs` and the ID will be used to identify the input |
| /// source at JNI API. |
| /// |
| /// Note that `ScanExec` will pull initial input batch during initialization. It is because we |
| /// need to know the exact schema (not only data type but also dictionary-encoding) at |
| /// `ScanExec`s. It is because some DataFusion operators, e.g., `ProjectionExec`, gets child |
| /// operator schema during initialization and uses it later for `RecordBatch`. We may be |
| /// able to get rid of it once `RecordBatch` relaxes schema check. |
| /// |
| /// Note that we return created `Scan`s which will be kept at JNI API. JNI calls will use it to |
| /// feed in new input batch from Spark JVM side. |
| pub(crate) fn create_plan<'a>( |
| &'a self, |
| spark_plan: &'a Operator, |
| inputs: &mut Vec<Arc<Global<JObject<'static>>>>, |
| partition_count: usize, |
| ) -> PlanCreationResult { |
| // Try to use the modular registry first - this automatically handles any registered operator types |
| if OperatorRegistry::global().can_handle(spark_plan) { |
| return OperatorRegistry::global().create_plan( |
| spark_plan, |
| inputs, |
| partition_count, |
| self, |
| ); |
| } |
| |
| // Fall back to the original monolithic match for other operators |
| let children = &spark_plan.children; |
| match spark_plan.op_struct.as_ref().unwrap() { |
| OpStruct::Filter(filter) => { |
| assert_eq!(children.len(), 1); |
| let (scans, shuffle_scans, child) = |
| self.create_plan(&children[0], inputs, partition_count)?; |
| let predicate = |
| self.create_expr(filter.predicate.as_ref().unwrap(), child.schema())?; |
| |
| let filter: Arc<dyn ExecutionPlan> = Arc::new(FilterExec::try_new( |
| predicate, |
| Arc::clone(&child.native_plan), |
| )?); |
| |
| Ok(( |
| scans, |
| shuffle_scans, |
| Arc::new(SparkPlan::new(spark_plan.plan_id, filter, vec![child])), |
| )) |
| } |
| OpStruct::HashAgg(agg) => { |
| assert_eq!(children.len(), 1); |
| let (scans, shuffle_scans, child) = |
| self.create_plan(&children[0], inputs, partition_count)?; |
| |
| let group_exprs: PhyExprResult = agg |
| .grouping_exprs |
| .iter() |
| .enumerate() |
| .map(|(idx, expr)| { |
| self.create_expr(expr, child.schema()) |
| .map(|r| (r, format!("col_{idx}"))) |
| }) |
| .collect(); |
| let group_by = PhysicalGroupBy::new_single(group_exprs?); |
| let schema = child.schema(); |
| |
| let proto_mode = ProtoAggregateMode::try_from(agg.mode).map_err(|_| { |
| ExecutionError::GeneralError(format!( |
| "Unsupported aggregate mode: {}", |
| agg.mode |
| )) |
| })?; |
| let mode = match proto_mode { |
| ProtoAggregateMode::Partial => DFAggregateMode::Partial, |
| ProtoAggregateMode::Final => DFAggregateMode::Final, |
| // PartialMerge: Partial + MergeAsPartial |
| ProtoAggregateMode::PartialMerge => DFAggregateMode::Partial, |
| }; |
| |
| // Check if any expression uses PartialMerge mode. When present, |
| // those expressions are wrapped with MergeAsPartial to get merge |
| // semantics inside a Partial-mode AggregateExec. |
| let partial_merge_value = ProtoAggregateMode::PartialMerge as i32; |
| let has_partial_merge = proto_mode == ProtoAggregateMode::PartialMerge |
| || agg.expr_modes.contains(&partial_merge_value); |
| |
| let agg_exprs: PhyAggResult = agg |
| .agg_exprs |
| .iter() |
| .map(|expr| self.create_agg_expr(expr, Arc::clone(&schema))) |
| .collect(); |
| |
| let aggr_expr: Vec<Arc<AggregateFunctionExpr>> = if has_partial_merge { |
| // Wrap PartialMerge expressions with MergeAsPartial. |
| // State fields in the child's output start at initial_input_buffer_offset. |
| let mut state_offset = agg.initial_input_buffer_offset as usize; |
| let per_expr_modes: Vec<i32> = if !agg.expr_modes.is_empty() { |
| agg.expr_modes.clone() |
| } else { |
| vec![agg.mode; agg.agg_exprs.len()] |
| }; |
| |
| agg_exprs? |
| .into_iter() |
| .enumerate() |
| .map(|(idx, expr)| { |
| if per_expr_modes[idx] == partial_merge_value { |
| // PartialMerge: wrap with MergeAsPartial |
| let state_fields = expr |
| .state_fields() |
| .map_err(|e| ExecutionError::GeneralError(e.to_string()))?; |
| let num_state_fields = state_fields.len(); |
| |
| let state_cols: Vec<Arc<dyn PhysicalExpr>> = (0..num_state_fields) |
| .map(|i| { |
| let col_idx = state_offset + i; |
| let field = schema.field(col_idx); |
| Arc::new(Column::new(field.name(), col_idx)) |
| as Arc<dyn PhysicalExpr> |
| }) |
| .collect(); |
| state_offset += num_state_fields; |
| |
| let merge_udf = |
| crate::execution::merge_as_partial::MergeAsPartialUDF::new( |
| &expr, |
| ) |
| .map_err(|e| ExecutionError::DataFusionError(e.to_string()))?; |
| let merge_udf_arc = Arc::new( |
| datafusion::logical_expr::AggregateUDF::new_from_impl( |
| merge_udf, |
| ), |
| ); |
| |
| let merge_expr = |
| AggregateExprBuilder::new(merge_udf_arc, state_cols) |
| .schema(Arc::clone(&schema)) |
| .alias(format!("col_{idx}")) |
| .with_ignore_nulls(expr.ignore_nulls()) |
| .with_distinct(expr.is_distinct()) |
| .build() |
| .map_err(|e| { |
| ExecutionError::DataFusionError(e.to_string()) |
| })?; |
| |
| Ok(Arc::new(merge_expr)) |
| } else { |
| Ok(Arc::new(expr)) |
| } |
| }) |
| .collect::<Result<Vec<_>, ExecutionError>>()? |
| } else { |
| agg_exprs?.into_iter().map(Arc::new).collect() |
| }; |
| |
| // Build per-aggregate filter expressions from the FILTER (WHERE ...) clause. |
| // Filters are only present in Partial mode; Final/PartialMerge always get None. |
| let filter_exprs: Result<Vec<Option<Arc<dyn PhysicalExpr>>>, ExecutionError> = agg |
| .agg_exprs |
| .iter() |
| .map(|expr| { |
| if let Some(f) = expr.filter.as_ref() { |
| self.create_expr(f, Arc::clone(&schema)).map(Some) |
| } else { |
| Ok(None) |
| } |
| }) |
| .collect(); |
| |
| let aggregate: Arc<dyn ExecutionPlan> = Arc::new( |
| datafusion::physical_plan::aggregates::AggregateExec::try_new( |
| mode, |
| group_by, |
| aggr_expr, |
| filter_exprs?, |
| Arc::clone(&child.native_plan), |
| Arc::clone(&schema), |
| )?, |
| ); |
| |
| Ok(( |
| scans, |
| shuffle_scans, |
| Arc::new(SparkPlan::new(spark_plan.plan_id, aggregate, vec![child])), |
| )) |
| } |
| |
| OpStruct::BroadcastNestedLoopJoin(bnlj) => { |
| let (join_params, scans, shuffle_scans) = self.parse_join_parameters( |
| inputs, |
| children, |
| &[], |
| &[], |
| bnlj.join_type, |
| &bnlj.condition, |
| partition_count, |
| )?; |
| |
| let left = Arc::clone(&join_params.left.native_plan); |
| let right = Arc::clone(&join_params.right.native_plan); |
| |
| let nested_loop_join = Arc::new(NestedLoopJoinExec::try_new( |
| left, |
| right, |
| join_params.join_filter, |
| &join_params.join_type, |
| None, |
| )?); |
| |
| if bnlj.build_side == BuildSide::BuildRight as i32 { |
| let swapped_join = nested_loop_join.as_ref().swap_inputs()?; |
| let mut additional_native_plans = vec![]; |
| if swapped_join.is::<ProjectionExec>() { |
| additional_native_plans.push(Arc::clone(swapped_join.children()[0])); |
| } |
| Ok(( |
| scans, |
| shuffle_scans, |
| Arc::new(SparkPlan::new_with_additional( |
| spark_plan.plan_id, |
| swapped_join, |
| vec![join_params.left, join_params.right], |
| additional_native_plans, |
| )), |
| )) |
| } else { |
| Ok(( |
| scans, |
| shuffle_scans, |
| Arc::new(SparkPlan::new( |
| spark_plan.plan_id, |
| nested_loop_join, |
| vec![join_params.left, join_params.right], |
| )), |
| )) |
| } |
| } |
| OpStruct::Limit(limit) => { |
| assert_eq!(children.len(), 1); |
| let num = limit.limit; |
| let offset: i32 = limit.offset; |
| if num != -1 && offset > num { |
| return Err(GeneralError(format!( |
| "Invalid limit/offset combination: [{num}. {offset}]" |
| ))); |
| } |
| let (scans, shuffle_scans, child) = |
| self.create_plan(&children[0], inputs, partition_count)?; |
| let limit: Arc<dyn ExecutionPlan> = if offset == 0 { |
| Arc::new(LocalLimitExec::new( |
| Arc::clone(&child.native_plan), |
| num as usize, |
| )) |
| } else { |
| let fetch = if num == -1 { |
| None |
| } else { |
| Some((num - offset) as usize) |
| }; |
| Arc::new(GlobalLimitExec::new( |
| Arc::clone(&child.native_plan), |
| offset as usize, |
| fetch, |
| )) |
| }; |
| Ok(( |
| scans, |
| shuffle_scans, |
| Arc::new(SparkPlan::new(spark_plan.plan_id, limit, vec![child])), |
| )) |
| } |
| OpStruct::Sample(sample) => { |
| assert_eq!(children.len(), 1); |
| let (scans, shuffle_scans, child) = |
| self.create_plan(&children[0], inputs, partition_count)?; |
| // Spark seeds a fresh sampler per partition with `seed + partitionIndex`. |
| let seed = sample.seed.wrapping_add(self.partition().into()); |
| let sample_exec: Arc<dyn ExecutionPlan> = Arc::new(SampleExec::new( |
| Arc::clone(&child.native_plan), |
| sample.lower_bound, |
| sample.upper_bound, |
| seed, |
| )); |
| Ok(( |
| scans, |
| shuffle_scans, |
| Arc::new(SparkPlan::new(spark_plan.plan_id, sample_exec, vec![child])), |
| )) |
| } |
| OpStruct::Sort(sort) => { |
| assert_eq!(children.len(), 1); |
| let (scans, shuffle_scans, child) = |
| self.create_plan(&children[0], inputs, partition_count)?; |
| |
| let exprs: Result<Vec<PhysicalSortExpr>, ExecutionError> = sort |
| .sort_orders |
| .iter() |
| .map(|expr| self.create_sort_expr(expr, child.schema())) |
| .collect(); |
| |
| let fetch = sort.fetch.map(|num| num as usize); |
| |
| let mut sort_exec: Arc<dyn ExecutionPlan> = Arc::new( |
| SortExec::new( |
| LexOrdering::new(exprs?).unwrap(), |
| Arc::clone(&child.native_plan), |
| ) |
| .with_fetch(fetch), |
| ); |
| |
| if let Some(skip) = sort.skip.filter(|&n| n > 0).map(|n| n as usize) { |
| sort_exec = Arc::new(GlobalLimitExec::new(sort_exec, skip, None)); |
| } |
| |
| Ok(( |
| scans, |
| shuffle_scans, |
| Arc::new(SparkPlan::new( |
| spark_plan.plan_id, |
| sort_exec, |
| vec![Arc::clone(&child)], |
| )), |
| )) |
| } |
| OpStruct::NativeScan(scan) => { |
| // Extract common data and single partition's file list |
| // Per-partition injection happens in Scala before sending to native |
| let common = scan |
| .common |
| .as_ref() |
| .ok_or_else(|| GeneralError("NativeScan missing common data".into()))?; |
| |
| let data_schema = |
| convert_spark_types_to_arrow_schema(common.data_schema.as_slice()); |
| let required_schema: SchemaRef = |
| convert_spark_types_to_arrow_schema(common.required_schema.as_slice()); |
| let partition_schema: SchemaRef = |
| convert_spark_types_to_arrow_schema(common.partition_schema.as_slice()); |
| let projection_vector: Vec<usize> = common |
| .projection_vector |
| .iter() |
| .map(|offset| *offset as usize) |
| .collect(); |
| |
| let partition_files = scan |
| .file_partition |
| .as_ref() |
| .ok_or_else(|| GeneralError("NativeScan missing file_partition".into()))?; |
| |
| // Check if this partition has any files (bucketed scan with bucket pruning may have empty partitions) |
| if partition_files.partitioned_file.is_empty() { |
| let empty_exec = Arc::new(EmptyExec::new(required_schema)); |
| return Ok(( |
| vec![], |
| vec![], |
| Arc::new(SparkPlan::new(spark_plan.plan_id, empty_exec, vec![])), |
| )); |
| } |
| |
| // Convert the Spark expressions to Physical expressions |
| let data_filters: Result<Vec<Arc<dyn PhysicalExpr>>, ExecutionError> = common |
| .data_filters |
| .iter() |
| .map(|expr| self.create_expr(expr, Arc::clone(&required_schema))) |
| .collect(); |
| |
| let default_values: Option<HashMap<Column, ScalarValue>> = if !common |
| .default_values |
| .is_empty() |
| { |
| // We have default values. Extract the two lists (same length) of values and |
| // indexes in the schema, and then create a HashMap to use in the SchemaMapper. |
| let default_values: Result<Vec<ScalarValue>, DataFusionError> = common |
| .default_values |
| .iter() |
| .map(|expr| { |
| let literal = self.create_expr(expr, Arc::clone(&required_schema))?; |
| let df_literal = |
| literal.downcast_ref::<DataFusionLiteral>().ok_or_else(|| { |
| GeneralError("Expected literal of default value.".to_string()) |
| })?; |
| Ok(df_literal.value().clone()) |
| }) |
| .collect(); |
| let default_values = default_values?; |
| let default_values_indexes: Vec<usize> = common |
| .default_values_indexes |
| .iter() |
| .map(|offset| *offset as usize) |
| .collect(); |
| Some( |
| default_values_indexes |
| .into_iter() |
| .zip(default_values) |
| .map(|(idx, scalar_value)| { |
| let field = required_schema.field(idx); |
| let column = Column::new(field.name().as_str(), idx); |
| (column, scalar_value) |
| }) |
| .collect(), |
| ) |
| } else { |
| None |
| }; |
| |
| // Get one file from this partition (we know it's not empty due to early return above) |
| let one_file = partition_files |
| .partitioned_file |
| .first() |
| .map(|f| f.file_path.clone()) |
| .expect("partition should have files after empty check"); |
| |
| let object_store_options: HashMap<String, String> = common |
| .object_store_options |
| .iter() |
| .map(|(k, v)| (k.clone(), v.clone())) |
| .collect(); |
| let (object_store_url, _) = prepare_object_store_with_configs( |
| self.session_ctx.runtime_env(), |
| one_file, |
| &object_store_options, |
| )?; |
| |
| // Get files for this partition |
| let files = self.get_partitioned_files(partition_files)?; |
| let file_groups: Vec<Vec<PartitionedFile>> = vec![files]; |
| |
| let scan = init_datasource_exec( |
| required_schema, |
| Some(data_schema), |
| Some(partition_schema), |
| object_store_url, |
| file_groups, |
| Some(projection_vector), |
| Some(data_filters?), |
| default_values, |
| common.session_timezone.as_str(), |
| common.case_sensitive, |
| common.return_null_struct_if_all_fields_missing, |
| common.allow_type_promotion, |
| common.allow_timestamp_ltz_to_ntz, |
| self.session_ctx(), |
| common.encryption_enabled, |
| common.use_field_id, |
| common.ignore_missing_field_id, |
| )?; |
| Ok(( |
| vec![], |
| vec![], |
| Arc::new(SparkPlan::new(spark_plan.plan_id, scan, vec![])), |
| )) |
| } |
| OpStruct::CsvScan(scan) => { |
| let data_schema = convert_spark_types_to_arrow_schema(scan.data_schema.as_slice()); |
| let partition_schema = |
| convert_spark_types_to_arrow_schema(scan.partition_schema.as_slice()); |
| let projection_vector: Vec<usize> = |
| scan.projection_vector.iter().map(|i| *i as usize).collect(); |
| let object_store_options: HashMap<String, String> = scan |
| .object_store_options |
| .iter() |
| .map(|(k, v)| (k.clone(), v.clone())) |
| .collect(); |
| let one_file = scan |
| .file_partitions |
| .first() |
| .and_then(|f| f.partitioned_file.first()) |
| .map(|f| f.file_path.clone()) |
| .ok_or(GeneralError("Failed to locate file".to_string()))?; |
| let (object_store_url, _) = prepare_object_store_with_configs( |
| self.session_ctx.runtime_env(), |
| one_file, |
| &object_store_options, |
| )?; |
| let files = |
| self.get_partitioned_files(&scan.file_partitions[self.partition as usize])?; |
| let file_groups: Vec<Vec<PartitionedFile>> = vec![files]; |
| let scan = init_csv_datasource_exec( |
| object_store_url, |
| file_groups, |
| data_schema, |
| partition_schema, |
| projection_vector, |
| &scan.csv_options.clone().unwrap(), |
| )?; |
| Ok(( |
| vec![], |
| vec![], |
| Arc::new(SparkPlan::new(spark_plan.plan_id, scan, vec![])), |
| )) |
| } |
| OpStruct::Scan(scan) => { |
| let data_types = scan.fields.iter().map(to_arrow_datatype).collect_vec(); |
| |
| // If it is not test execution context for unit test, we should have at least one |
| // input source |
| if self.exec_context_id != TEST_EXEC_CONTEXT_ID && inputs.is_empty() { |
| return Err(GeneralError("No input for scan".to_string())); |
| } |
| |
| // Consumes the first input source for the scan. The Java side passes an |
| // `org.apache.arrow.c.ArrowArrayStream` whose `memoryAddress` points at the C |
| // struct; native takes ownership via `AlignedArrowStreamReader::from_raw`. |
| let input_source = if self.exec_context_id == TEST_EXEC_CONTEXT_ID |
| && inputs.is_empty() |
| { |
| // For unit test, we will set input batch to scan directly by `set_input_batch`. |
| None |
| } else { |
| let java_stream = inputs.remove(0); |
| let address: i64 = JVMClasses::with_env(|env| unsafe { |
| jni_call!(env, arrow_array_stream(java_stream.as_obj()).memory_address() -> i64) |
| })?; |
| let reader = unsafe { |
| AlignedArrowStreamReader::from_raw(address as *mut FFI_ArrowArrayStream) |
| } |
| .map_err(|e| { |
| GeneralError(format!("Failed to import ArrowArrayStream from JVM: {e}")) |
| })?; |
| Some(Arc::new(std::sync::Mutex::new(reader))) |
| }; |
| |
| // The `ScanExec` operator will take actual arrays from Spark during execution |
| let scan = |
| ScanExec::new(self.exec_context_id, input_source, &scan.source, data_types)?; |
| |
| Ok(( |
| vec![scan.clone()], |
| vec![], |
| Arc::new(SparkPlan::new(spark_plan.plan_id, Arc::new(scan), vec![])), |
| )) |
| } |
| OpStruct::IcebergScan(scan) => { |
| // Extract common data and single partition's file tasks |
| // Per-partition injection happens in Scala before sending to native |
| let common = scan |
| .common |
| .as_ref() |
| .ok_or_else(|| GeneralError("IcebergScan missing common data".into()))?; |
| |
| let required_schema = |
| convert_spark_types_to_arrow_schema(common.required_schema.as_slice()); |
| let catalog_properties: HashMap<String, String> = common |
| .catalog_properties |
| .iter() |
| .map(|(k, v)| (k.clone(), v.clone())) |
| .collect(); |
| let metadata_location = common.metadata_location.clone(); |
| let catalog_name = common.catalog_name.clone(); |
| let tasks = parse_file_scan_tasks_from_common(common, &scan.file_scan_tasks)?; |
| let data_file_concurrency_limit = common.data_file_concurrency_limit as usize; |
| |
| let iceberg_scan = IcebergScanExec::new( |
| metadata_location, |
| required_schema, |
| catalog_properties, |
| catalog_name, |
| tasks, |
| data_file_concurrency_limit, |
| )?; |
| |
| Ok(( |
| vec![], |
| vec![], |
| Arc::new(SparkPlan::new( |
| spark_plan.plan_id, |
| Arc::new(iceberg_scan), |
| vec![], |
| )), |
| )) |
| } |
| OpStruct::ShuffleWriter(writer) => { |
| assert_eq!(children.len(), 1); |
| let (scans, shuffle_scans, child) = |
| self.create_plan(&children[0], inputs, partition_count)?; |
| |
| let writer_input = align_shuffle_writer_input( |
| Arc::clone(&child.native_plan), |
| &writer.expected_output_schema, |
| )?; |
| |
| let partitioning = self.create_partitioning( |
| writer.partitioning.as_ref().unwrap(), |
| writer_input.schema(), |
| )?; |
| |
| let codec = match writer.codec.try_into() { |
| Ok(SparkCompressionCodec::None) => Ok(CompressionCodec::None), |
| Ok(SparkCompressionCodec::Snappy) => Ok(CompressionCodec::Snappy), |
| Ok(SparkCompressionCodec::Zstd) => { |
| Ok(CompressionCodec::Zstd(writer.compression_level)) |
| } |
| Ok(SparkCompressionCodec::Lz4) => Ok(CompressionCodec::Lz4Frame), |
| _ => Err(GeneralError(format!( |
| "Unsupported shuffle compression codec: {:?}", |
| writer.codec |
| ))), |
| }?; |
| |
| let write_buffer_size = writer.write_buffer_size as usize; |
| // Zero on the wire means the limit is disabled; normalize it here so the writer |
| // only ever sees a real limit or none at all. |
| let max_buffer_bytes = |
| (writer.max_buffer_bytes > 0).then_some(writer.max_buffer_bytes as usize); |
| let shuffle_writer = Arc::new(ShuffleWriterExec::try_new( |
| writer_input, |
| partitioning, |
| codec, |
| writer.output_data_file.clone(), |
| writer.output_index_file.clone(), |
| writer.tracing_enabled, |
| write_buffer_size, |
| max_buffer_bytes, |
| )?); |
| |
| Ok(( |
| scans, |
| shuffle_scans, |
| Arc::new(SparkPlan::new( |
| spark_plan.plan_id, |
| shuffle_writer, |
| vec![Arc::clone(&child)], |
| )), |
| )) |
| } |
| OpStruct::ParquetWriter(writer) => { |
| assert_eq!(children.len(), 1); |
| let (scans, shuffle_scans, child) = |
| self.create_plan(&children[0], inputs, partition_count)?; |
| |
| let codec = match writer.compression.try_into() { |
| Ok(SparkCompressionCodec::None) => Ok(ParquetCompression::None), |
| Ok(SparkCompressionCodec::Snappy) => Ok(ParquetCompression::Snappy), |
| Ok(SparkCompressionCodec::Zstd) => Ok(ParquetCompression::Zstd(3)), |
| Ok(SparkCompressionCodec::Lz4) => Ok(ParquetCompression::Lz4), |
| Ok(SparkCompressionCodec::Gzip) => Ok(ParquetCompression::Gzip), |
| _ => Err(GeneralError(format!( |
| "Unsupported parquet compression codec: {:?}", |
| writer.compression |
| ))), |
| }?; |
| |
| let object_store_options: HashMap<String, String> = writer |
| .object_store_options |
| .iter() |
| .map(|(k, v)| (k.clone(), v.clone())) |
| .collect(); |
| |
| let parquet_writer = Arc::new(ParquetWriterExec::try_new( |
| Arc::clone(&child.native_plan), |
| writer.output_path.clone(), |
| writer |
| .work_dir |
| .as_ref() |
| .expect("work_dir is provided") |
| .clone(), |
| writer.job_id.clone(), |
| writer.task_attempt_id, |
| codec, |
| self.partition, |
| writer.column_names.clone(), |
| object_store_options, |
| )?); |
| |
| Ok(( |
| scans, |
| shuffle_scans, |
| Arc::new(SparkPlan::new( |
| spark_plan.plan_id, |
| parquet_writer, |
| vec![Arc::clone(&child)], |
| )), |
| )) |
| } |
| OpStruct::Expand(expand) => { |
| assert_eq!(children.len(), 1); |
| let (scans, shuffle_scans, child) = |
| self.create_plan(&children[0], inputs, partition_count)?; |
| |
| let mut projections = vec![]; |
| let mut projection = vec![]; |
| |
| expand.project_list.iter().try_for_each(|expr| { |
| let expr = self.create_expr(expr, child.schema())?; |
| projection.push(expr); |
| |
| if projection.len() == expand.num_expr_per_project as usize { |
| projections.push(projection.clone()); |
| projection = vec![]; |
| } |
| |
| Ok::<(), ExecutionError>(()) |
| })?; |
| |
| assert!( |
| !projections.is_empty(), |
| "Expand should have at least one projection" |
| ); |
| |
| let datatypes = projections[0] |
| .iter() |
| .map(|expr| expr.data_type(&child.schema())) |
| .collect::<Result<Vec<DataType>, _>>()?; |
| let fields: Vec<Field> = datatypes |
| .iter() |
| .enumerate() |
| .map(|(idx, dt)| Field::new(format!("col_{idx}"), dt.clone(), true)) |
| .collect(); |
| let schema = Arc::new(Schema::new(fields)); |
| |
| // `Expand` operator keeps the input batch and expands it to multiple output |
| // batches. However, `ScanExec` will reuse input arrays for the next |
| // input batch. Therefore, we need to copy the input batch to avoid |
| // the data corruption. Note that we only need to copy the input batch |
| // if the child operator is `ScanExec`, because other operators after `ScanExec` |
| // will create new arrays for the output batch. |
| let input = Arc::clone(&child.native_plan); |
| let expand = Arc::new(ExpandExec::new(projections, input, schema)); |
| Ok(( |
| scans, |
| shuffle_scans, |
| Arc::new(SparkPlan::new(spark_plan.plan_id, expand, vec![child])), |
| )) |
| } |
| OpStruct::Explode(explode) => { |
| assert_eq!(children.len(), 1); |
| let (scans, shuffle_scans, child) = |
| self.create_plan(&children[0], inputs, partition_count)?; |
| |
| // Create the expression for the array to explode |
| let child_expr = if let Some(child_expr) = &explode.child { |
| self.create_expr(child_expr, child.schema())? |
| } else { |
| return Err(ExecutionError::GeneralError( |
| "Explode operator requires a child expression".to_string(), |
| )); |
| }; |
| |
| // Create projection expressions for other columns |
| let projections: Vec<Arc<dyn PhysicalExpr>> = explode |
| .project_list |
| .iter() |
| .map(|expr| self.create_expr(expr, child.schema())) |
| .collect::<Result<Vec<_>, _>>()?; |
| |
| // For posexplode, a parallel List<Int32> positions column is added before the |
| // array column so UnnestExec can unnest both in parallel. |
| let child_schema = child.schema(); |
| let mut project_exprs: Vec<(Arc<dyn PhysicalExpr>, String)> = projections |
| .iter() |
| .map(|expr| { |
| let field = expr |
| .return_field(&child_schema) |
| .expect("Failed to get field from expression"); |
| let name = field.name().to_string(); |
| (Arc::clone(expr), name) |
| }) |
| .collect(); |
| |
| let array_field = child_expr |
| .return_field(&child_schema) |
| .expect("Failed to get field from array expression"); |
| let array_col_name = array_field.name().to_string(); |
| |
| if explode.position { |
| let positions_expr: Arc<dyn PhysicalExpr> = |
| Arc::new(ListPositionsExpr::new(Arc::clone(&child_expr))); |
| project_exprs.push((positions_expr, "pos".to_string())); |
| } |
| project_exprs.push((Arc::clone(&child_expr), array_col_name.clone())); |
| |
| let project_exec = Arc::new(ProjectionExec::try_new( |
| project_exprs, |
| Arc::clone(&child.native_plan), |
| )?); |
| |
| let project_schema = project_exec.schema(); |
| |
| // Build the output schema for UnnestExec |
| let mut output_fields: Vec<Field> = Vec::new(); |
| |
| // Add all projection columns (non-array columns) |
| for i in 0..projections.len() { |
| output_fields.push(project_schema.field(i).clone()); |
| } |
| |
| let array_input_index = if explode.position { |
| // With outer=true, UnnestExec preserves rows whose array is empty or NULL |
| // and emits a NULL position for them, so pos must be nullable in that case. |
| output_fields.push(Field::new("pos", DataType::Int32, explode.outer)); |
| projections.len() + 1 |
| } else { |
| projections.len() |
| }; |
| |
| // Extract the element type from the list/array type |
| let array_field = project_schema.field(array_input_index); |
| let element_type = match array_field.data_type() { |
| DataType::List(field) => field.data_type().clone(), |
| dt => { |
| return Err(ExecutionError::GeneralError(format!( |
| "Expected List type for explode, got {:?}", |
| dt |
| ))) |
| } |
| }; |
| |
| output_fields.push(Field::new( |
| array_field.name(), |
| element_type, |
| true, // Element is nullable after unnesting |
| )); |
| |
| let output_schema = Arc::new(Schema::new(output_fields)); |
| |
| let mut list_unnests = Vec::with_capacity(2); |
| if explode.position { |
| list_unnests.push(ListUnnest { |
| index_in_input_schema: projections.len(), |
| depth: 1, |
| }); |
| } |
| list_unnests.push(ListUnnest { |
| index_in_input_schema: array_input_index, |
| depth: 1, |
| }); |
| |
| let unnest_options = UnnestOptions { |
| preserve_nulls: explode.outer, |
| recursions: vec![], |
| }; |
| |
| let unnest_exec = Arc::new(UnnestExec::new( |
| project_exec, |
| list_unnests, |
| vec![], // No struct columns to unnest |
| output_schema, |
| unnest_options, |
| )?); |
| |
| Ok(( |
| scans, |
| shuffle_scans, |
| Arc::new(SparkPlan::new(spark_plan.plan_id, unnest_exec, vec![child])), |
| )) |
| } |
| OpStruct::SortMergeJoin(join) => { |
| let (join_params, scans, shuffle_scans) = self.parse_join_parameters( |
| inputs, |
| children, |
| &join.left_join_keys, |
| &join.right_join_keys, |
| join.join_type, |
| &join.condition, |
| partition_count, |
| )?; |
| |
| let sort_options = join |
| .sort_options |
| .iter() |
| .map(|sort_option| { |
| let sort_expr = self |
| .create_sort_expr(sort_option, join_params.left.schema()) |
| .unwrap(); |
| SortOptions { |
| descending: sort_expr.options.descending, |
| nulls_first: sort_expr.options.nulls_first, |
| } |
| }) |
| .collect(); |
| |
| let left = Arc::clone(&join_params.left.native_plan); |
| let right = Arc::clone(&join_params.right.native_plan); |
| |
| let left_schema = left.schema(); |
| let right_schema = right.schema(); |
| let join_on = join_params |
| .join_on |
| .into_iter() |
| .map(|(l, r)| { |
| Ok(( |
| strip_timestamp_tz(l, left_schema.as_ref())?, |
| strip_timestamp_tz(r, right_schema.as_ref())?, |
| )) |
| }) |
| .collect::<Result<Vec<_>, ExecutionError>>()?; |
| |
| let join = Arc::new(SortMergeJoinExec::try_new( |
| Arc::clone(&left), |
| Arc::clone(&right), |
| join_on, |
| join_params.join_filter, |
| join_params.join_type, |
| sort_options, |
| // null doesn't equal to null in Spark join key. If the join key is |
| // `EqualNullSafe`, Spark will rewrite it during planning. |
| NullEquality::NullEqualsNothing, |
| )?); |
| |
| Ok(( |
| scans, |
| shuffle_scans, |
| Arc::new(SparkPlan::new( |
| spark_plan.plan_id, |
| join, |
| vec![ |
| Arc::clone(&join_params.left), |
| Arc::clone(&join_params.right), |
| ], |
| )), |
| )) |
| } |
| OpStruct::HashJoin(join) => { |
| let (join_params, scans, shuffle_scans) = self.parse_join_parameters( |
| inputs, |
| children, |
| &join.left_join_keys, |
| &join.right_join_keys, |
| join.join_type, |
| &join.condition, |
| partition_count, |
| )?; |
| |
| let left = Arc::clone(&join_params.left.native_plan); |
| let right = Arc::clone(&join_params.right.native_plan); |
| |
| // Null-aware anti-join must run in CollectLeft mode. In Partitioned mode |
| // each partition only sees per-partition null/emptiness state, which can |
| // produce wrong NOT IN results across partitions. DataFusion's JoinSelection |
| // rewrites null-aware joins to CollectLeft for this reason, but Comet |
| // executes the physical plan directly so we must pick the mode here. |
| let partition_mode = if join.null_aware_anti_join { |
| PartitionMode::CollectLeft |
| } else { |
| PartitionMode::Partitioned |
| }; |
| |
| let hash_join = Arc::new(HashJoinExec::try_new( |
| left, |
| right, |
| join_params.join_on, |
| join_params.join_filter, |
| &join_params.join_type, |
| None, |
| partition_mode, |
| // null doesn't equal to null in Spark join key. If the join key is |
| // `EqualNullSafe`, Spark will rewrite it during planning. |
| NullEquality::NullEqualsNothing, |
| join.null_aware_anti_join, |
| )?); |
| |
| // If the hash join is build right, we need to swap the left and right. |
| // Exception: null-aware anti-join requires LeftAnti + build-right semantics |
| // (which matches DataFusion's default), and swap_inputs would turn LeftAnti |
| // into RightAnti, which DataFusion rejects with null_aware=true. |
| if join.build_side == BuildSide::BuildLeft as i32 || join.null_aware_anti_join { |
| Ok(( |
| scans, |
| shuffle_scans, |
| Arc::new(SparkPlan::new( |
| spark_plan.plan_id, |
| hash_join, |
| vec![join_params.left, join_params.right], |
| )), |
| )) |
| } else { |
| let swapped_hash_join = |
| hash_join.as_ref().swap_inputs(PartitionMode::Partitioned)?; |
| |
| let mut additional_native_plans = vec![]; |
| if swapped_hash_join.is::<ProjectionExec>() { |
| // a projection was added to the hash join |
| additional_native_plans.push(Arc::clone(swapped_hash_join.children()[0])); |
| } |
| |
| Ok(( |
| scans, |
| shuffle_scans, |
| Arc::new(SparkPlan::new_with_additional( |
| spark_plan.plan_id, |
| swapped_hash_join, |
| vec![join_params.left, join_params.right], |
| additional_native_plans, |
| )), |
| )) |
| } |
| } |
| OpStruct::Window(wnd) => { |
| let (scans, shuffle_scans, child) = |
| self.create_plan(&children[0], inputs, partition_count)?; |
| let input_schema = child.schema(); |
| let sort_exprs: Result<Vec<PhysicalSortExpr>, ExecutionError> = wnd |
| .order_by_list |
| .iter() |
| .map(|expr| self.create_sort_expr(expr, Arc::clone(&input_schema))) |
| .collect(); |
| |
| let partition_exprs: Result<Vec<Arc<dyn PhysicalExpr>>, ExecutionError> = wnd |
| .partition_by_list |
| .iter() |
| .map(|expr| self.create_expr(expr, Arc::clone(&input_schema))) |
| .collect(); |
| |
| let sort_exprs = &sort_exprs?; |
| let partition_exprs = &partition_exprs?; |
| |
| let window_expr: Result<Vec<Arc<dyn WindowExpr>>, ExecutionError> = wnd |
| .window_expr |
| .iter() |
| .map(|expr| { |
| self.create_window_expr( |
| expr, |
| Arc::clone(&input_schema), |
| partition_exprs, |
| sort_exprs, |
| ) |
| }) |
| .collect(); |
| |
| // Route to `BoundedWindowAggExec` when every window expression can |
| // run with bounded memory. This uses DataFusion's |
| // `evaluate_stateful` / row-by-row `evaluate` path, which is the |
| // correct implementation for `LEAD` / `LAG` with `IGNORE NULLS` |
| // (`WindowAggExec` calls `evaluate_all`, whose |
| // `evaluate_all_with_ignore_null` has a sign-wrap bug for `LEAD` |
| // that produces all-NULL output). |
| // |
| // Fall back to `WindowAggExec` otherwise. That covers |
| // `PERCENT_RANK` / `CUME_DIST` / `NTILE` |
| // (`!uses_bounded_memory()` — "Can not execute X in a streaming |
| // fashion") and keeps the Spark-compatible Comet UDAFs |
| // (`SumDecimal` / `SumInteger` / `AvgDecimal` / `Avg`) on the |
| // non-streaming path since they don't implement `retract_batch`. |
| // Because `process_agg_func` already picks DataFusion's |
| // retract-capable built-ins for sliding aggregate frames, |
| // ever-expanding aggregate frames (all that route to |
| // `BoundedWindowAggExec` as `PlainAggregateWindowExpr`) never |
| // trigger a retract call. |
| let window_expr = window_expr?; |
| let all_bounded = window_expr.iter().all(|e| e.uses_bounded_memory()); |
| let window_agg: Arc<dyn ExecutionPlan> = if all_bounded { |
| Arc::new(BoundedWindowAggExec::try_new( |
| window_expr, |
| Arc::clone(&child.native_plan), |
| InputOrderMode::Sorted, |
| !partition_exprs.is_empty(), |
| )?) |
| } else { |
| Arc::new(WindowAggExec::try_new( |
| window_expr, |
| Arc::clone(&child.native_plan), |
| !partition_exprs.is_empty(), |
| )?) |
| }; |
| |
| // DataFusion's window functions don't always return the same Arrow |
| // type that Spark expects (e.g. `row_number` returns UInt64 while |
| // Spark expects Int32). If any window expression carries a |
| // `result_type` that differs from the actual output type, wrap the |
| // aggregate in a projection that casts the mismatched columns. |
| let final_plan: Arc<dyn ExecutionPlan> = { |
| let agg_schema = window_agg.schema(); |
| let input_field_count = input_schema.fields().len(); |
| let mut needs_cast = false; |
| let mut proj_exprs: Vec<(Arc<dyn PhysicalExpr>, String)> = |
| Vec::with_capacity(agg_schema.fields().len()); |
| for (idx, field) in agg_schema.fields().iter().enumerate() { |
| let col: Arc<dyn PhysicalExpr> = Arc::new(Column::new(field.name(), idx)); |
| let expr: Arc<dyn PhysicalExpr> = if idx >= input_field_count { |
| let w = &wnd.window_expr[idx - input_field_count]; |
| match &w.result_type { |
| Some(t) => { |
| let expected = to_arrow_datatype(t); |
| if &expected != field.data_type() { |
| needs_cast = true; |
| Arc::new(CastExpr::new(col, expected, None)) |
| } else { |
| col |
| } |
| } |
| None => col, |
| } |
| } else { |
| col |
| }; |
| proj_exprs.push((expr, field.name().to_string())); |
| } |
| if needs_cast { |
| Arc::new(ProjectionExec::try_new(proj_exprs, window_agg)?) |
| } else { |
| window_agg |
| } |
| }; |
| |
| Ok(( |
| scans, |
| shuffle_scans, |
| Arc::new(SparkPlan::new(spark_plan.plan_id, final_plan, vec![child])), |
| )) |
| } |
| OpStruct::ShuffleScan(scan) => { |
| let data_types = scan.fields.iter().map(to_arrow_datatype).collect_vec(); |
| |
| if self.exec_context_id != TEST_EXEC_CONTEXT_ID && inputs.is_empty() { |
| return Err(GeneralError("No input for shuffle scan".to_string())); |
| } |
| |
| let input_source = |
| if self.exec_context_id == TEST_EXEC_CONTEXT_ID && inputs.is_empty() { |
| None |
| } else { |
| Some(inputs.remove(0)) |
| }; |
| |
| let shuffle_scan = |
| ShuffleScanExec::new(self.exec_context_id, input_source, data_types)?; |
| |
| Ok(( |
| vec![], |
| vec![shuffle_scan.clone()], |
| Arc::new(SparkPlan::new( |
| spark_plan.plan_id, |
| Arc::new(shuffle_scan), |
| vec![], |
| )), |
| )) |
| } |
| _ => Err(GeneralError(format!( |
| "Unsupported or unregistered operator type: {:?}", |
| spark_plan.op_struct |
| ))), |
| } |
| } |
| |
| #[allow(clippy::too_many_arguments)] |
| fn parse_join_parameters( |
| &self, |
| inputs: &mut Vec<Arc<Global<JObject<'static>>>>, |
| children: &[Operator], |
| left_join_keys: &[Expr], |
| right_join_keys: &[Expr], |
| join_type: i32, |
| condition: &Option<Expr>, |
| partition_count: usize, |
| ) -> Result<(JoinParameters, Vec<ScanExec>, Vec<ShuffleScanExec>), ExecutionError> { |
| assert_eq!(children.len(), 2); |
| let (mut left_scans, mut left_shuffle_scans, left) = |
| self.create_plan(&children[0], inputs, partition_count)?; |
| let (mut right_scans, mut right_shuffle_scans, right) = |
| self.create_plan(&children[1], inputs, partition_count)?; |
| |
| left_scans.append(&mut right_scans); |
| left_shuffle_scans.append(&mut right_shuffle_scans); |
| |
| let left_join_exprs: Vec<_> = left_join_keys |
| .iter() |
| .map(|expr| self.create_expr(expr, left.schema())) |
| .collect::<Result<Vec<_>, _>>()?; |
| let right_join_exprs: Vec<_> = right_join_keys |
| .iter() |
| .map(|expr| self.create_expr(expr, right.schema())) |
| .collect::<Result<Vec<_>, _>>()?; |
| |
| let join_on = left_join_exprs |
| .into_iter() |
| .zip(right_join_exprs) |
| .collect::<Vec<_>>(); |
| |
| let join_type = match join_type.try_into() { |
| Ok(JoinType::Inner) => DFJoinType::Inner, |
| Ok(JoinType::LeftOuter) => DFJoinType::Left, |
| Ok(JoinType::RightOuter) => DFJoinType::Right, |
| Ok(JoinType::FullOuter) => DFJoinType::Full, |
| Ok(JoinType::LeftSemi) => DFJoinType::LeftSemi, |
| Ok(JoinType::LeftAnti) => DFJoinType::LeftAnti, |
| Err(_) => { |
| return Err(GeneralError(format!( |
| "Unsupported join type: {join_type:?}" |
| ))); |
| } |
| }; |
| |
| // Handle join filter as DataFusion `JoinFilter` struct |
| let join_filter = if let Some(expr) = condition { |
| let left_schema = left.schema(); |
| let right_schema = right.schema(); |
| let left_fields = left_schema.fields(); |
| let right_fields = right_schema.fields(); |
| let all_fields: Vec<_> = left_fields |
| .into_iter() |
| .chain(right_fields) |
| .cloned() |
| .collect(); |
| let full_schema = Arc::new(Schema::new(all_fields)); |
| |
| // Because we cast dictionary array to array in scan operator, |
| // we need to change dictionary type to data type for join filter expression. |
| let fields: Vec<_> = full_schema |
| .fields() |
| .iter() |
| .map(|f| match f.data_type() { |
| DataType::Dictionary(_, val_type) => Arc::new(Field::new( |
| f.name(), |
| val_type.as_ref().clone(), |
| f.is_nullable(), |
| )), |
| _ => Arc::clone(f), |
| }) |
| .collect(); |
| |
| let full_schema = Arc::new(Schema::new(fields)); |
| |
| let physical_expr = self.create_expr(expr, full_schema)?; |
| let (left_field_indices, right_field_indices) = |
| expr_to_columns(&physical_expr, left_fields.len(), right_fields.len())?; |
| let column_indices = JoinFilter::build_column_indices( |
| left_field_indices.clone(), |
| right_field_indices.clone(), |
| ); |
| |
| let filter_fields: Vec<Field> = left_field_indices |
| .clone() |
| .into_iter() |
| .map(|i| left.schema().field(i).clone()) |
| .chain( |
| right_field_indices |
| .clone() |
| .into_iter() |
| .map(|i| right.schema().field(i).clone()), |
| ) |
| // Because we cast dictionary array to array in scan operator, |
| // we need to change dictionary type to data type for join filter expression. |
| .map(|f| match f.data_type() { |
| DataType::Dictionary(_, val_type) => { |
| Field::new(f.name(), val_type.as_ref().clone(), f.is_nullable()) |
| } |
| _ => f.clone(), |
| }) |
| .collect_vec(); |
| |
| let filter_schema = Schema::new_with_metadata(filter_fields, HashMap::new()); |
| |
| // Rewrite the physical expression to use the new column indices. |
| // DataFusion's join filter is bound to intermediate schema which contains |
| // only the fields used in the filter expression. But the Spark's join filter |
| // expression is bound to the full schema. We need to rewrite the physical |
| // expression to use the new column indices. |
| let rewritten_physical_expr = rewrite_physical_expr( |
| physical_expr, |
| left_schema.fields.len(), |
| right_schema.fields.len(), |
| &left_field_indices, |
| &right_field_indices, |
| )?; |
| |
| Some(JoinFilter::new( |
| rewritten_physical_expr, |
| column_indices, |
| filter_schema.into(), |
| )) |
| } else { |
| None |
| }; |
| |
| Ok(( |
| JoinParameters { |
| left: Arc::clone(&left), |
| right: Arc::clone(&right), |
| join_on, |
| join_type, |
| join_filter, |
| }, |
| left_scans, |
| left_shuffle_scans, |
| )) |
| } |
| |
| /// Create a DataFusion physical aggregate expression from Spark physical aggregate expression |
| fn create_agg_expr( |
| &self, |
| spark_expr: &AggExpr, |
| schema: SchemaRef, |
| ) -> Result<AggregateFunctionExpr, ExecutionError> { |
| // Register QueryContext if present |
| if let (Some(expr_id), Some(ctx_proto)) = |
| (spark_expr.expr_id, spark_expr.query_context.as_ref()) |
| { |
| // Deserialize QueryContext from protobuf |
| let query_ctx = datafusion_comet_spark_expr::QueryContext::new( |
| ctx_proto.sql_text.clone(), |
| ctx_proto.start_index, |
| ctx_proto.stop_index, |
| ctx_proto.object_type.clone(), |
| ctx_proto.object_name.clone(), |
| ctx_proto.line, |
| ctx_proto.start_position, |
| ); |
| |
| // Register query context for error reporting |
| let registry = &self.query_context_registry; |
| registry.register(expr_id, query_ctx); |
| } |
| |
| match spark_expr.expr_struct.as_ref().unwrap() { |
| AggExprStruct::Count(expr) => { |
| assert!(!expr.children.is_empty()); |
| let children = expr |
| .children |
| .iter() |
| .map(|child| self.create_expr(child, Arc::clone(&schema))) |
| .collect::<Result<Vec<_>, _>>()?; |
| |
| AggregateExprBuilder::new(count_udaf(), children) |
| .schema(schema) |
| .alias("count") |
| .with_ignore_nulls(false) |
| .with_distinct(false) |
| .build() |
| .map_err(|e| ExecutionError::DataFusionError(e.to_string())) |
| } |
| AggExprStruct::Min(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| let datatype = to_arrow_datatype(expr.datatype.as_ref().unwrap()); |
| let child = Arc::new(CastExpr::new(child, datatype.clone(), None)); |
| |
| AggregateExprBuilder::new(min_udaf(), vec![child]) |
| .schema(schema) |
| .alias("min") |
| .with_ignore_nulls(false) |
| .with_distinct(false) |
| .build() |
| .map_err(|e| ExecutionError::DataFusionError(e.to_string())) |
| } |
| AggExprStruct::Max(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| let datatype = to_arrow_datatype(expr.datatype.as_ref().unwrap()); |
| let child = Arc::new(CastExpr::new(child, datatype.clone(), None)); |
| |
| AggregateExprBuilder::new(max_udaf(), vec![child]) |
| .schema(schema) |
| .alias("max") |
| .with_ignore_nulls(false) |
| .with_distinct(false) |
| .build() |
| .map_err(|e| ExecutionError::DataFusionError(e.to_string())) |
| } |
| AggExprStruct::Sum(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| let datatype = to_arrow_datatype(expr.datatype.as_ref().unwrap()); |
| |
| let builder = match datatype { |
| DataType::Decimal128(_, _) => { |
| let eval_mode = from_protobuf_eval_mode(expr.eval_mode)?; |
| let func = AggregateUDF::new_from_impl(SumDecimal::try_new( |
| datatype, |
| eval_mode, |
| spark_expr.expr_id, |
| Arc::clone(&self.query_context_registry), |
| )?); |
| AggregateExprBuilder::new(Arc::new(func), vec![child]) |
| } |
| DataType::Int8 | DataType::Int16 | DataType::Int32 | DataType::Int64 => { |
| let eval_mode = from_protobuf_eval_mode(expr.eval_mode)?; |
| let func = |
| AggregateUDF::new_from_impl(SumInteger::try_new(datatype, eval_mode)?); |
| AggregateExprBuilder::new(Arc::new(func), vec![child]) |
| } |
| _ => { |
| // cast to the result data type of SUM if necessary, we should not expect |
| // a cast failure since it should have already been checked at Spark side |
| let child = |
| Arc::new(CastExpr::new(Arc::clone(&child), datatype.clone(), None)); |
| AggregateExprBuilder::new(sum_udaf(), vec![child]) |
| } |
| }; |
| builder |
| .schema(schema) |
| .alias("sum") |
| .with_ignore_nulls(false) |
| .with_distinct(false) |
| .build() |
| .map_err(|e| e.into()) |
| } |
| AggExprStruct::Avg(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| let datatype = to_arrow_datatype(expr.datatype.as_ref().unwrap()); |
| let input_datatype = to_arrow_datatype(expr.sum_datatype.as_ref().unwrap()); |
| |
| let builder = match datatype { |
| DataType::Decimal128(_, _) => { |
| let eval_mode = from_protobuf_eval_mode(expr.eval_mode)?; |
| let func = AggregateUDF::new_from_impl(AvgDecimal::new( |
| datatype, |
| input_datatype, |
| eval_mode, |
| spark_expr.expr_id, |
| Arc::clone(&self.query_context_registry), |
| )); |
| AggregateExprBuilder::new(Arc::new(func), vec![child]) |
| } |
| _ => { |
| // For all other numeric types (Int8/16/32/64, Float32/64): |
| // Cast to Float64 for accumulation |
| let child: Arc<dyn PhysicalExpr> = |
| Arc::new(CastExpr::new(Arc::clone(&child), DataType::Float64, None)); |
| let func = AggregateUDF::new_from_impl(Avg::new("avg", DataType::Float64)); |
| AggregateExprBuilder::new(Arc::new(func), vec![child]) |
| } |
| }; |
| builder |
| .schema(schema) |
| .alias("avg") |
| .with_ignore_nulls(false) |
| .with_distinct(false) |
| .build() |
| .map_err(|e| e.into()) |
| } |
| AggExprStruct::First(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| let func = AggregateUDF::new_from_impl(FirstValue::new()); |
| |
| AggregateExprBuilder::new(Arc::new(func), vec![child]) |
| .schema(schema) |
| .alias("first") |
| .with_ignore_nulls(expr.ignore_nulls) |
| .with_distinct(false) |
| .build() |
| .map_err(|e| e.into()) |
| } |
| AggExprStruct::Last(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| let func = AggregateUDF::new_from_impl(LastValue::new()); |
| |
| AggregateExprBuilder::new(Arc::new(func), vec![child]) |
| .schema(schema) |
| .alias("last") |
| .with_ignore_nulls(expr.ignore_nulls) |
| .with_distinct(false) |
| .build() |
| .map_err(|e| e.into()) |
| } |
| AggExprStruct::BitAndAgg(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| |
| AggregateExprBuilder::new(bit_and_udaf(), vec![child]) |
| .schema(schema) |
| .alias("bit_and") |
| .with_ignore_nulls(false) |
| .with_distinct(false) |
| .build() |
| .map_err(|e| e.into()) |
| } |
| AggExprStruct::BitOrAgg(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| |
| AggregateExprBuilder::new(bit_or_udaf(), vec![child]) |
| .schema(schema) |
| .alias("bit_or") |
| .with_ignore_nulls(false) |
| .with_distinct(false) |
| .build() |
| .map_err(|e| e.into()) |
| } |
| AggExprStruct::BitXorAgg(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| |
| AggregateExprBuilder::new(bit_xor_udaf(), vec![child]) |
| .schema(schema) |
| .alias("bit_xor") |
| .with_ignore_nulls(false) |
| .with_distinct(false) |
| .build() |
| .map_err(|e| e.into()) |
| } |
| AggExprStruct::Covariance(expr) => { |
| let child1 = |
| self.create_expr(expr.child1.as_ref().unwrap(), Arc::clone(&schema))?; |
| let child2 = |
| self.create_expr(expr.child2.as_ref().unwrap(), Arc::clone(&schema))?; |
| let datatype = to_arrow_datatype(expr.datatype.as_ref().unwrap()); |
| match expr.stats_type { |
| 0 => { |
| let func = AggregateUDF::new_from_impl(Covariance::new( |
| "covariance", |
| datatype, |
| StatsType::Sample, |
| expr.null_on_divide_by_zero, |
| )); |
| |
| Self::create_aggr_func_expr( |
| "covariance", |
| schema, |
| vec![child1, child2], |
| func, |
| ) |
| } |
| 1 => { |
| let func = AggregateUDF::new_from_impl(Covariance::new( |
| "covariance_pop", |
| datatype, |
| StatsType::Population, |
| expr.null_on_divide_by_zero, |
| )); |
| |
| Self::create_aggr_func_expr( |
| "covariance_pop", |
| schema, |
| vec![child1, child2], |
| func, |
| ) |
| } |
| stats_type => Err(GeneralError(format!( |
| "Unknown StatisticsType {stats_type:?} for Variance" |
| ))), |
| } |
| } |
| AggExprStruct::Variance(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| let datatype = to_arrow_datatype(expr.datatype.as_ref().unwrap()); |
| match expr.stats_type { |
| 0 => { |
| let func = AggregateUDF::new_from_impl(Variance::new( |
| "variance", |
| datatype, |
| StatsType::Sample, |
| expr.null_on_divide_by_zero, |
| )); |
| |
| Self::create_aggr_func_expr("variance", schema, vec![child], func) |
| } |
| 1 => { |
| let func = AggregateUDF::new_from_impl(Variance::new( |
| "variance_pop", |
| datatype, |
| StatsType::Population, |
| expr.null_on_divide_by_zero, |
| )); |
| |
| Self::create_aggr_func_expr("variance_pop", schema, vec![child], func) |
| } |
| stats_type => Err(GeneralError(format!( |
| "Unknown StatisticsType {stats_type:?} for Variance" |
| ))), |
| } |
| } |
| AggExprStruct::Stddev(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| let datatype = to_arrow_datatype(expr.datatype.as_ref().unwrap()); |
| match expr.stats_type { |
| 0 => { |
| let func = AggregateUDF::new_from_impl(Stddev::new( |
| "stddev", |
| datatype, |
| StatsType::Sample, |
| expr.null_on_divide_by_zero, |
| )); |
| |
| Self::create_aggr_func_expr("stddev", schema, vec![child], func) |
| } |
| 1 => { |
| let func = AggregateUDF::new_from_impl(Stddev::new( |
| "stddev_pop", |
| datatype, |
| StatsType::Population, |
| expr.null_on_divide_by_zero, |
| )); |
| |
| Self::create_aggr_func_expr("stddev_pop", schema, vec![child], func) |
| } |
| stats_type => Err(GeneralError(format!( |
| "Unknown StatisticsType {stats_type:?} for stddev" |
| ))), |
| } |
| } |
| AggExprStruct::Correlation(expr) => { |
| let child1 = |
| self.create_expr(expr.child1.as_ref().unwrap(), Arc::clone(&schema))?; |
| let child2 = |
| self.create_expr(expr.child2.as_ref().unwrap(), Arc::clone(&schema))?; |
| let datatype = to_arrow_datatype(expr.datatype.as_ref().unwrap()); |
| let func = AggregateUDF::new_from_impl(Correlation::new( |
| "correlation", |
| datatype, |
| expr.null_on_divide_by_zero, |
| )); |
| Self::create_aggr_func_expr("correlation", schema, vec![child1, child2], func) |
| } |
| AggExprStruct::Percentile(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| let percentile = |
| self.create_expr(expr.percentage.as_ref().unwrap(), Arc::clone(&schema))?; |
| // Spark's exact Percentile uses full-precision linear interpolation. Comet uses |
| // its own UDAF rather than DataFusion's percentile_cont because DataFusion |
| // quantizes the interpolation weight. |
| let percentile_value = percentile_value(expr.percentage.as_ref().unwrap())?; |
| let func = AggregateUDF::new_from_impl(SparkPercentile::try_new(percentile_value)?); |
| AggregateExprBuilder::new(func.into(), vec![child, percentile]) |
| .schema(schema) |
| .alias("percentile") |
| .with_ignore_nulls(false) |
| .with_distinct(false) |
| .build() |
| .map_err(|e| e.into()) |
| } |
| AggExprStruct::ApproxPercentile(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| let input_type = to_arrow_datatype(expr.input_type.as_ref().unwrap()); |
| let func = AggregateUDF::new_from_impl(ApproxPercentile::new( |
| expr.percentiles.clone(), |
| expr.accuracy, |
| input_type, |
| expr.return_array, |
| )); |
| Self::create_aggr_func_expr("approx_percentile", schema, vec![child], func) |
| } |
| AggExprStruct::BloomFilterAgg(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| let num_items = |
| self.create_expr(expr.num_items.as_ref().unwrap(), Arc::clone(&schema))?; |
| let num_bits = |
| self.create_expr(expr.num_bits.as_ref().unwrap(), Arc::clone(&schema))?; |
| let datatype = to_arrow_datatype(expr.datatype.as_ref().unwrap()); |
| let version = match expr.version() { |
| spark_expression::BloomFilterVersion::V2 => SparkBloomFilterVersion::V2, |
| // Default (Unspecified or V1) preserves the pre-Spark-4.1 format that |
| // Comet has always emitted, keeping older Spark versions byte-equivalent. |
| _ => SparkBloomFilterVersion::V1, |
| }; |
| let func = AggregateUDF::new_from_impl(BloomFilterAgg::new( |
| Arc::clone(&num_items), |
| Arc::clone(&num_bits), |
| datatype, |
| version, |
| )); |
| Self::create_aggr_func_expr("bloom_filter_agg", schema, vec![child], func) |
| } |
| AggExprStruct::CollectSet(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| let child = Self::coerce_collect_child_nullability(child, &schema)?; |
| let func = AggregateUDF::new_from_impl(SparkCollectSet::new()); |
| Self::create_aggr_func_expr("collect_set", schema, vec![child], func) |
| } |
| AggExprStruct::CollectList(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| let child = Self::coerce_collect_child_nullability(child, &schema)?; |
| let func = AggregateUDF::new_from_impl(SparkCollectList::new()); |
| Self::create_aggr_func_expr("collect_list", schema, vec![child], func) |
| } |
| AggExprStruct::Hllpp(expr) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| let func = AggregateUDF::new_from_impl(HllPlusPlus::new(expr.precision)); |
| Self::create_aggr_func_expr("approx_count_distinct", schema, vec![child], func) |
| } |
| } |
| } |
| |
| /// Create a DataFusion windows physical expression from Spark physical expression |
| fn create_window_expr<'a>( |
| &'a self, |
| spark_expr: &'a spark_operator::WindowExpr, |
| input_schema: SchemaRef, |
| partition_by: &[Arc<dyn PhysicalExpr>], |
| sort_exprs: &[PhysicalSortExpr], |
| ) -> Result<Arc<dyn WindowExpr>, ExecutionError> { |
| let window_func: WindowFunctionDefinition; |
| let window_func_name: String; |
| let window_args: Vec<Arc<dyn PhysicalExpr>>; |
| if let Some(func) = &spark_expr.built_in_window_function { |
| match &func.expr_struct { |
| Some(ExprStruct::ScalarFunc(f)) => { |
| window_func_name = f.func.clone(); |
| window_args = f |
| .args |
| .iter() |
| .map(|expr| self.create_expr(expr, Arc::clone(&input_schema))) |
| .collect::<Result<Vec<_>, ExecutionError>>()?; |
| window_func = |
| self.find_df_window_function(&window_func_name) |
| .ok_or_else(|| { |
| GeneralError(format!( |
| "{window_func_name} not supported for window function" |
| )) |
| })?; |
| } |
| other => { |
| return Err(GeneralError(format!( |
| "{other:?} not supported for window function" |
| ))) |
| } |
| }; |
| } else if let Some(agg_func) = &spark_expr.agg_func { |
| // Is the frame ever-expanding (start = UnboundedPreceding)? When it is, |
| // DataFusion uses `PlainAggregateWindowExpr` which does not call |
| // `retract_batch`, so we can safely use Comet's Spark-compatible |
| // UDAFs (SumDecimal/SumInteger/AvgDecimal/Avg). Otherwise it uses |
| // `SlidingAggregateWindowExpr` which requires retract — Comet's UDAFs |
| // don't implement it, so the caller must fall back to DataFusion's |
| // built-ins (which do). |
| let is_ever_expanding = spark_expr |
| .spec |
| .as_ref() |
| .and_then(|s| s.frame_specification.as_ref()) |
| .and_then(|f| f.lower_bound.as_ref()) |
| .and_then(|lb| lb.lower_frame_bound_struct.as_ref()) |
| .map(|inner| matches!(inner, LowerFrameBoundStruct::UnboundedPreceding(_))) |
| .unwrap_or(true); |
| let (func, args) = |
| self.process_agg_func(agg_func, Arc::clone(&input_schema), is_ever_expanding)?; |
| window_func_name = func.name().to_string(); |
| window_args = args; |
| window_func = func; |
| } else { |
| return Err(GeneralError( |
| "Both func and agg_func are not set".to_string(), |
| )); |
| } |
| |
| let spark_window_frame = match spark_expr |
| .spec |
| .as_ref() |
| .and_then(|inner| inner.frame_specification.as_ref()) |
| { |
| Some(frame) => frame, |
| _ => { |
| return Err(ExecutionError::DeserializeError( |
| "Cannot deserialize window frame".to_string(), |
| )) |
| } |
| }; |
| |
| let units = match spark_window_frame.frame_type() { |
| WindowFrameType::Rows => WindowFrameUnits::Rows, |
| WindowFrameType::Range => WindowFrameUnits::Range, |
| }; |
| |
| let lower_bound: WindowFrameBound = match spark_window_frame |
| .lower_bound |
| .as_ref() |
| .and_then(|inner| inner.lower_frame_bound_struct.as_ref()) |
| { |
| Some(l) => match l { |
| LowerFrameBoundStruct::UnboundedPreceding(_) => match units { |
| WindowFrameUnits::Rows => { |
| WindowFrameBound::Preceding(ScalarValue::UInt64(None)) |
| } |
| WindowFrameUnits::Range => { |
| WindowFrameBound::Preceding(ScalarValue::Int64(None)) |
| } |
| WindowFrameUnits::Groups => { |
| return Err(GeneralError( |
| "WindowFrameUnits::Groups is not supported.".to_string(), |
| )); |
| } |
| }, |
| LowerFrameBoundStruct::Preceding(offset) => { |
| // Spark encodes ROWS bound direction via the sign of the offset: |
| // negative => PRECEDING, positive => FOLLOWING. The proto |
| // `LowerWindowFrameBound` only carries a `Preceding` variant, so |
| // Comet overloads it for both cases. Route to the matching |
| // DataFusion `WindowFrameBound` based on the sign. |
| match units { |
| WindowFrameUnits::Rows => { |
| let abs = offset.offset.unsigned_abs(); |
| if offset.offset < 0 { |
| WindowFrameBound::Preceding(ScalarValue::UInt64(Some(abs))) |
| } else { |
| WindowFrameBound::Following(ScalarValue::UInt64(Some(abs))) |
| } |
| } |
| WindowFrameUnits::Range => { |
| let scalar = match offset.range_offset.as_ref() { |
| Some(lit) => numeric_literal_to_scalar(lit)?, |
| None => ScalarValue::Int64(Some(offset.offset.abs())), |
| }; |
| WindowFrameBound::Preceding(scalar) |
| } |
| WindowFrameUnits::Groups => { |
| return Err(GeneralError( |
| "WindowFrameUnits::Groups is not supported.".to_string(), |
| )); |
| } |
| } |
| } |
| LowerFrameBoundStruct::CurrentRow(_) => WindowFrameBound::CurrentRow, |
| }, |
| None => match units { |
| WindowFrameUnits::Rows => WindowFrameBound::Preceding(ScalarValue::UInt64(None)), |
| WindowFrameUnits::Range => WindowFrameBound::Preceding(ScalarValue::Int64(None)), |
| WindowFrameUnits::Groups => { |
| return Err(GeneralError( |
| "WindowFrameUnits::Groups is not supported.".to_string(), |
| )); |
| } |
| }, |
| }; |
| |
| let upper_bound: WindowFrameBound = match spark_window_frame |
| .upper_bound |
| .as_ref() |
| .and_then(|inner| inner.upper_frame_bound_struct.as_ref()) |
| { |
| Some(u) => match u { |
| UpperFrameBoundStruct::UnboundedFollowing(_) => match units { |
| WindowFrameUnits::Rows => { |
| WindowFrameBound::Following(ScalarValue::UInt64(None)) |
| } |
| WindowFrameUnits::Range => { |
| WindowFrameBound::Following(ScalarValue::Int64(None)) |
| } |
| WindowFrameUnits::Groups => { |
| return Err(GeneralError( |
| "WindowFrameUnits::Groups is not supported.".to_string(), |
| )); |
| } |
| }, |
| UpperFrameBoundStruct::Following(offset) => match units { |
| WindowFrameUnits::Rows => { |
| // Mirror the lower-bound sign handling: the upper proto |
| // variant is `Following`, but Spark encodes the bound |
| // direction via sign. Negative => PRECEDING, positive => |
| // FOLLOWING. |
| let abs = offset.offset.unsigned_abs(); |
| if offset.offset < 0 { |
| WindowFrameBound::Preceding(ScalarValue::UInt64(Some(abs))) |
| } else { |
| WindowFrameBound::Following(ScalarValue::UInt64(Some(abs))) |
| } |
| } |
| WindowFrameUnits::Range => { |
| let scalar = match offset.range_offset.as_ref() { |
| Some(lit) => numeric_literal_to_scalar(lit)?, |
| None => ScalarValue::Int64(Some(offset.offset)), |
| }; |
| WindowFrameBound::Following(scalar) |
| } |
| WindowFrameUnits::Groups => { |
| return Err(GeneralError( |
| "WindowFrameUnits::Groups is not supported.".to_string(), |
| )); |
| } |
| }, |
| UpperFrameBoundStruct::CurrentRow(_) => WindowFrameBound::CurrentRow, |
| }, |
| None => match units { |
| WindowFrameUnits::Rows => WindowFrameBound::Following(ScalarValue::UInt64(None)), |
| WindowFrameUnits::Range => WindowFrameBound::Following(ScalarValue::Int64(None)), |
| WindowFrameUnits::Groups => { |
| return Err(GeneralError( |
| "WindowFrameUnits::Groups is not supported.".to_string(), |
| )); |
| } |
| }, |
| }; |
| |
| let window_frame = WindowFrame::new_bounds(units, lower_bound, upper_bound); |
| let lex_orderings = LexOrdering::new(sort_exprs.to_vec()); |
| let sort_phy_exprs = lex_orderings.as_deref().unwrap_or(&[]); |
| |
| datafusion::physical_plan::windows::create_window_expr( |
| &window_func, |
| window_func_name, |
| &window_args, |
| partition_by, |
| sort_phy_exprs, |
| window_frame.into(), |
| input_schema, |
| spark_expr.ignore_nulls, |
| false, // TODO: Spark does not support DISTINCT ... OVER |
| None, |
| ) |
| .map_err(|e| ExecutionError::DataFusionError(e.to_string())) |
| } |
| |
| fn process_agg_func( |
| &self, |
| agg_func: &AggExpr, |
| schema: SchemaRef, |
| is_ever_expanding: bool, |
| ) -> Result<(WindowFunctionDefinition, Vec<Arc<dyn PhysicalExpr>>), ExecutionError> { |
| // Wrap a freshly-constructed AggregateUDF impl as a WindowFunctionDefinition. |
| fn udaf<U: datafusion::logical_expr::AggregateUDFImpl + 'static>( |
| udaf: U, |
| ) -> WindowFunctionDefinition { |
| WindowFunctionDefinition::AggregateUDF(Arc::new(AggregateUDF::new_from_impl(udaf))) |
| } |
| |
| // Resolve a window-capable function by name via the session registry, returning |
| // a clean "X not supported for window function" error if missing. |
| let by_name = |name: &str| -> Result<WindowFunctionDefinition, ExecutionError> { |
| self.find_df_window_function(name) |
| .ok_or_else(|| GeneralError(format!("{name} not supported for window function"))) |
| }; |
| |
| match &agg_func.expr_struct { |
| Some(AggExprStruct::Count(expr)) => { |
| let children = expr |
| .children |
| .iter() |
| .map(|child| self.create_expr(child, Arc::clone(&schema))) |
| .collect::<Result<Vec<_>, _>>()?; |
| Ok((by_name("count")?, children)) |
| } |
| Some(AggExprStruct::Min(expr)) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| Ok((by_name("min")?, vec![child])) |
| } |
| Some(AggExprStruct::Max(expr)) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| Ok((by_name("max")?, vec![child])) |
| } |
| Some(AggExprStruct::Sum(expr)) => { |
| // For ever-expanding frames, use Comet's Spark-compatible Sum UDAFs |
| // (SumDecimal / SumInteger) which enforce Spark overflow semantics. |
| // For sliding frames, those UDAFs can't be used (no retract_batch), |
| // so delegate to DataFusion's built-in `sum`, which supports retract |
| // but doesn't enforce Spark's decimal precision overflow-to-NULL. |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| let arrow_type = to_arrow_datatype(expr.datatype.as_ref().unwrap()); |
| match arrow_type { |
| DataType::Decimal128(_, _) if is_ever_expanding => { |
| let eval_mode = from_protobuf_eval_mode(expr.eval_mode)?; |
| let func = SumDecimal::try_new( |
| arrow_type, |
| eval_mode, |
| agg_func.expr_id, |
| Arc::clone(&self.query_context_registry), |
| )?; |
| Ok((udaf(func), vec![child])) |
| } |
| DataType::Int8 | DataType::Int16 | DataType::Int32 | DataType::Int64 |
| if is_ever_expanding => |
| { |
| let eval_mode = from_protobuf_eval_mode(expr.eval_mode)?; |
| let func = SumInteger::try_new(arrow_type, eval_mode)?; |
| Ok((udaf(func), vec![child])) |
| } |
| _ => { |
| let actual = child.data_type(&schema)?; |
| let child: Arc<dyn PhysicalExpr> = if actual != arrow_type { |
| Arc::new(CastExpr::new(child, arrow_type, None)) |
| } else { |
| child |
| }; |
| Ok((by_name("sum")?, vec![child])) |
| } |
| } |
| } |
| Some(AggExprStruct::Avg(expr)) => { |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| let datatype = to_arrow_datatype(expr.datatype.as_ref().unwrap()); |
| let input_datatype = to_arrow_datatype(expr.sum_datatype.as_ref().unwrap()); |
| match datatype { |
| DataType::Decimal128(_, _) if is_ever_expanding => { |
| let eval_mode = from_protobuf_eval_mode(expr.eval_mode)?; |
| let func = AvgDecimal::new( |
| datatype, |
| input_datatype, |
| eval_mode, |
| agg_func.expr_id, |
| Arc::clone(&self.query_context_registry), |
| ); |
| Ok((udaf(func), vec![child])) |
| } |
| _ if is_ever_expanding => { |
| let child: Arc<dyn PhysicalExpr> = |
| Arc::new(CastExpr::new(child, DataType::Float64, None)); |
| let func = Avg::new("avg", DataType::Float64); |
| Ok((udaf(func), vec![child])) |
| } |
| _ => { |
| // Sliding frame — DataFusion's built-in `avg` handles retract. |
| // Cast non-decimal input to Float64 to match Spark's Avg result type. |
| let child: Arc<dyn PhysicalExpr> = match datatype { |
| DataType::Decimal128(_, _) => child, |
| _ => Arc::new(CastExpr::new(child, DataType::Float64, None)), |
| }; |
| Ok((by_name("avg")?, vec![child])) |
| } |
| } |
| } |
| Some(AggExprStruct::First(expr)) => { |
| // Spark's FIRST_VALUE → DataFusion's `first_value` UDAF. The UDAF honors |
| // ignore-nulls via the WindowExpr-level `ignore_nulls` flag. |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| Ok((by_name("first_value")?, vec![child])) |
| } |
| Some(AggExprStruct::Last(expr)) => { |
| // Spark's LAST_VALUE → DataFusion's `last_value` UDAF. |
| let child = self.create_expr(expr.child.as_ref().unwrap(), Arc::clone(&schema))?; |
| Ok((by_name("last_value")?, vec![child])) |
| } |
| other => Err(GeneralError(format!( |
| "{other:?} not supported for window function" |
| ))), |
| } |
| } |
| |
| /// Find DataFusion's built-in window function by name. |
| fn find_df_window_function(&self, name: &str) -> Option<WindowFunctionDefinition> { |
| let registry = &self.session_ctx.state(); |
| // Prefer the WindowUDF (frame-aware) over the AggregateUDF when both exist. |
| // Functions like `nth_value` are registered twice in DataFusion: once as an |
| // aggregate and once as a window function. The window variant has the |
| // correct IGNORE NULLS / frame semantics for an OVER clause; the aggregate |
| // variant wrapped into PlainAggregateWindowExpr does not. Aggregate-only |
| // functions (count/sum/min/max/avg/...) have no UDWF and fall through. |
| registry |
| .udwf(name) |
| .map(WindowFunctionDefinition::WindowUDF) |
| .ok() |
| .or_else(|| { |
| registry |
| .udaf(name) |
| .map(WindowFunctionDefinition::AggregateUDF) |
| .ok() |
| }) |
| } |
| |
| /// Create a DataFusion physical partitioning from Spark physical partitioning |
| fn create_partitioning( |
| &self, |
| spark_partitioning: &SparkPartitioning, |
| input_schema: SchemaRef, |
| ) -> Result<CometPartitioning, ExecutionError> { |
| match spark_partitioning.partitioning_struct.as_ref().unwrap() { |
| PartitioningStruct::HashPartition(hash_partition) => { |
| let exprs: PartitionPhyExprResult = hash_partition |
| .hash_expression |
| .iter() |
| .map(|x| self.create_expr(x, Arc::clone(&input_schema))) |
| .collect(); |
| Ok(CometPartitioning::Hash( |
| exprs?, |
| hash_partition.num_partitions as usize, |
| )) |
| } |
| PartitioningStruct::RangePartition(range_partition) => { |
| // Generate the lexical ordering for comparisons |
| let exprs: Result<Vec<PhysicalSortExpr>, ExecutionError> = range_partition |
| .sort_orders |
| .iter() |
| .map(|expr| self.create_sort_expr(expr, Arc::clone(&input_schema))) |
| .collect(); |
| let lex_ordering = LexOrdering::new(exprs?).unwrap(); |
| |
| // Generate the row converter for comparing incoming batches to boundary rows |
| let sort_fields: Vec<SortField> = lex_ordering |
| .iter() |
| .map(|sort_expr| { |
| sort_expr |
| .expr |
| .data_type(input_schema.as_ref()) |
| .map(|dt| SortField::new_with_options(dt, sort_expr.options)) |
| }) |
| .collect::<Result<Vec<_>, _>>()?; |
| |
| // Deserialize the literals to columnar collections of ScalarValues |
| let mut scalar_values: Vec<Vec<ScalarValue>> = vec![vec![]; lex_ordering.len()]; |
| for boundary_row in &range_partition.boundary_rows { |
| // For each serialized expr in a boundary row, convert to a Literal |
| // expression, then extract the ScalarValue from the Literal and push it |
| // into the collection of ScalarValues |
| for (col_idx, col_values) in scalar_values |
| .iter_mut() |
| .enumerate() |
| .take(lex_ordering.len()) |
| { |
| let expr = self.create_expr( |
| &boundary_row.partition_bounds[col_idx], |
| Arc::clone(&input_schema), |
| )?; |
| let literal_expr = expr.downcast_ref::<Literal>().expect("Literal"); |
| col_values.push(literal_expr.value().clone()); |
| } |
| } |
| |
| // Convert the collection of ScalarValues to collection of Arrow Arrays |
| let arrays: Vec<ArrayRef> = scalar_values |
| .iter() |
| .map(|scalar_vec| ScalarValue::iter_to_array(scalar_vec.iter().cloned())) |
| .collect::<Result<Vec<_>, _>>()?; |
| |
| // Create a RowConverter and use to create OwnedRows from the Arrays |
| let converter = RowConverter::new(sort_fields)?; |
| let boundary_rows = converter.convert_columns(&arrays)?; |
| // Rows are only a view into Arrow Arrays. We need to create OwnedRows with their |
| // own internal memory ownership to pass as our boundary values to the partitioner. |
| let boundary_owned_rows: Vec<OwnedRow> = |
| boundary_rows.iter().map(|row| row.owned()).collect(); |
| |
| Ok(CometPartitioning::RangePartitioning( |
| lex_ordering, |
| range_partition.num_partitions as usize, |
| Arc::new(converter), |
| boundary_owned_rows, |
| )) |
| } |
| PartitioningStruct::SinglePartition(_) => Ok(CometPartitioning::SinglePartition), |
| PartitioningStruct::RoundRobinPartition(rr_partition) => { |
| // Treat negative max_hash_columns as 0 (no limit) |
| let max_hash_columns = if rr_partition.max_hash_columns <= 0 { |
| 0 |
| } else { |
| rr_partition.max_hash_columns as usize |
| }; |
| Ok(CometPartitioning::RoundRobin( |
| rr_partition.num_partitions as usize, |
| max_hash_columns, |
| )) |
| } |
| } |
| } |
| |
| fn create_scalar_function_expr( |
| &self, |
| expr: &ScalarFunc, |
| input_schema: SchemaRef, |
| ) -> Result<Arc<dyn PhysicalExpr>, ExecutionError> { |
| let args = expr |
| .args |
| .iter() |
| .map(|x| self.create_expr(x, Arc::clone(&input_schema))) |
| .collect::<Result<Vec<_>, _>>()?; |
| |
| let fun_name = &expr.func; |
| let input_expr_types = args |
| .iter() |
| .map(|x| x.data_type(input_schema.as_ref())) |
| .collect::<Result<Vec<_>, _>>()?; |
| |
| let (data_type, coerced_input_types) = |
| match expr.return_type.as_ref().map(to_arrow_datatype) { |
| Some(t) => (t, input_expr_types.clone()), |
| None => { |
| let fun_name = match fun_name.as_ref() { |
| "read_side_padding" => "rpad", // use the same return type as rpad |
| other => other, |
| }; |
| let func = self.session_ctx.udf(fun_name)?; |
| |
| // Type coercion strategy: |
| // |
| // In DF52, Comet used coerce_types() which returns NotImplemented |
| // for most UDFs, so input types were kept unchanged. In DF53, |
| // fields_with_udf() runs full coercion which aggressively promotes |
| // types (e.g. Utf8 to Utf8View via Variadic signatures, Int32 to Int64 |
| // via Exact signatures). This breaks Comet's native implementations. |
| // |
| // Strategy: |
| // 1. Try coerce_types() — only UDFs that explicitly implement it |
| // will return Ok. Same as DF52 behavior. |
| // 2. For "well-supported" signatures (Coercible, String, Numeric, |
| // Comparable), use fields_with_udf(). These preserve input types |
| // (e.g. Utf8 stays Utf8, not promoted to Utf8View). |
| // 3. For all other signatures (Variadic, Exact, etc.), keep original |
| // types unchanged. Same as DF52 behavior. |
| let coerced_types = match func.coerce_types(&input_expr_types) { |
| Ok(types) => types, |
| Err(_) if needs_fields_coercion(&func.signature().type_signature) => { |
| let input_fields: Vec<_> = input_expr_types |
| .iter() |
| .enumerate() |
| .map(|(i, dt)| { |
| Arc::new(Field::new(format!("arg{i}"), dt.clone(), true)) |
| }) |
| .collect(); |
| let arg_fields = fields_with_udf(&input_fields, func.as_ref())?; |
| arg_fields.iter().map(|f| f.data_type().clone()).collect() |
| } |
| Err(_) => input_expr_types.clone(), |
| }; |
| |
| let arg_fields: Vec<_> = coerced_types |
| .iter() |
| .enumerate() |
| .map(|(i, dt)| Arc::new(Field::new(format!("arg{i}"), dt.clone(), true))) |
| .collect(); |
| |
| // TODO this should try and find scalar |
| let arguments = args |
| .iter() |
| .map(|e| e.as_ref().downcast_ref::<Literal>().map(|lit| lit.value())) |
| .collect::<Vec<_>>(); |
| |
| let args = ReturnFieldArgs { |
| arg_fields: &arg_fields, |
| scalar_arguments: &arguments, |
| }; |
| |
| let data_type = Arc::clone(&func.inner().return_field_from_args(args)?) |
| .data_type() |
| .clone(); |
| |
| (data_type, coerced_types) |
| } |
| }; |
| |
| let fun_expr = create_comet_physical_fun( |
| fun_name, |
| data_type.clone(), |
| &self.session_ctx.state(), |
| Some(expr.fail_on_error), |
| )?; |
| |
| let args = args |
| .into_iter() |
| .zip(input_expr_types.into_iter().zip(coerced_input_types)) |
| .map(|(expr, (from_type, to_type))| { |
| if from_type != to_type { |
| Arc::new(CastExpr::new( |
| expr, |
| to_type, |
| Some(CastOptions { |
| safe: false, |
| ..Default::default() |
| }), |
| )) |
| } else { |
| expr |
| } |
| }) |
| .collect::<Vec<_>>(); |
| |
| let scalar_expr: Arc<dyn PhysicalExpr> = Arc::new(ScalarFunctionExpr::new( |
| fun_name, |
| fun_expr, |
| args.to_vec(), |
| Arc::new(Field::new(fun_name, data_type.clone(), true)), |
| Arc::new(ConfigOptions::default()), |
| )); |
| |
| // DF53 changed some UDFs (e.g. md5) to return StringViewArray at execution |
| // time (apache/datafusion#20045). Comet does not yet support view types, so |
| // cast the result back to the non-view variant. |
| let scalar_expr = match data_type { |
| DataType::Utf8View => Arc::new(CastExpr::new( |
| scalar_expr, |
| DataType::Utf8, |
| Some(CastOptions { |
| safe: false, |
| ..Default::default() |
| }), |
| )) as Arc<dyn PhysicalExpr>, |
| DataType::BinaryView => Arc::new(CastExpr::new( |
| scalar_expr, |
| DataType::Binary, |
| Some(CastOptions { |
| safe: false, |
| ..Default::default() |
| }), |
| )) as Arc<dyn PhysicalExpr>, |
| _ => scalar_expr, |
| }; |
| |
| Ok(scalar_expr) |
| } |
| |
| /// `collect_list` / `collect_set` build their result list with all element fields marked |
| /// nullable, regardless of the input's nullability. However `SparkCollectList::return_type` |
| /// (and `SparkCollectSet`) derive the element type directly from the child, preserving any |
| /// non-nullable nested field. When the child is a nested type with a non-nullable inner field |
| /// (e.g. a struct field), the declared aggregate output disagrees with the array the |
| /// accumulator actually produces, and DataFusion's grouped `AggregateExec` fails validating |
| /// its output batch ("column types must match schema types"). Cast the child to the |
| /// all-nullable variant of its type so the declared and produced types stay consistent. |
| fn coerce_collect_child_nullability( |
| child: Arc<dyn PhysicalExpr>, |
| schema: &SchemaRef, |
| ) -> Result<Arc<dyn PhysicalExpr>, ExecutionError> { |
| let child_type = child.data_type(schema.as_ref())?; |
| let nullable_type = make_all_fields_nullable(&child_type); |
| if child_type.equals_datatype(&nullable_type) { |
| Ok(child) |
| } else { |
| Ok(Arc::new(CastExpr::new(child, nullable_type, None))) |
| } |
| } |
| |
| fn create_aggr_func_expr( |
| name: &str, |
| schema: SchemaRef, |
| children: Vec<Arc<dyn PhysicalExpr>>, |
| func: AggregateUDF, |
| ) -> Result<AggregateFunctionExpr, ExecutionError> { |
| AggregateExprBuilder::new(Arc::new(func), children) |
| .schema(schema) |
| .alias(name) |
| .with_ignore_nulls(false) |
| .with_distinct(false) |
| .build() |
| .map_err(|e| e.into()) |
| } |
| } |
| |
| fn percentile_value(expr: &spark_expression::Expr) -> Result<f64, ExecutionError> { |
| match &expr.expr_struct { |
| Some(ExprStruct::Literal(literal)) if !literal.is_null => match &literal.value { |
| Some(Value::DoubleVal(value)) => Ok(*value), |
| Some(Value::FloatVal(value)) => Ok(*value as f64), |
| _ => Err(GeneralError( |
| "Percentile value must be a floating-point literal".to_string(), |
| )), |
| }, |
| _ => Err(GeneralError( |
| "Percentile value must be a non-null literal".to_string(), |
| )), |
| } |
| } |
| |
| /// Collects the indices of the columns in the input schema that are used in the expression |
| /// and returns them as a pair of vectors, one for the left side and one for the right side. |
| fn expr_to_columns( |
| expr: &Arc<dyn PhysicalExpr>, |
| left_field_len: usize, |
| right_field_len: usize, |
| ) -> Result<(Vec<usize>, Vec<usize>), ExecutionError> { |
| let mut left_field_indices: Vec<usize> = vec![]; |
| let mut right_field_indices: Vec<usize> = vec![]; |
| |
| expr.apply(&mut |expr: &Arc<dyn PhysicalExpr>| { |
| Ok({ |
| if let Some(column) = expr.downcast_ref::<Column>() { |
| if column.index() > left_field_len + right_field_len { |
| return Err(DataFusionError::Internal(format!( |
| "Column index {} out of range", |
| column.index() |
| ))); |
| } else if column.index() < left_field_len { |
| left_field_indices.push(column.index()); |
| } else { |
| right_field_indices.push(column.index() - left_field_len); |
| } |
| } |
| TreeNodeRecursion::Continue |
| }) |
| })?; |
| |
| left_field_indices.sort(); |
| right_field_indices.sort(); |
| |
| Ok((left_field_indices, right_field_indices)) |
| } |
| |
| /// Convert a Spark numeric Literal proto into a `ScalarValue` whose data type |
| /// matches the literal's declared type. Used for RANGE window frame offsets, |
| /// where the offset's type must match the ORDER BY column's type. Only numeric |
| /// types are supported; the Scala side rejects non-numeric RANGE offsets before |
| /// reaching here. |
| fn numeric_literal_to_scalar( |
| lit: &spark_expression::Literal, |
| ) -> Result<ScalarValue, ExecutionError> { |
| let data_type = to_arrow_datatype(lit.datatype.as_ref().ok_or_else(|| { |
| GeneralError("RANGE frame offset literal is missing datatype".to_string()) |
| })?); |
| |
| if lit.is_null { |
| return Err(GeneralError( |
| "RANGE frame offset must not be null".to_string(), |
| )); |
| } |
| |
| let value = lit |
| .value |
| .as_ref() |
| .ok_or_else(|| GeneralError("RANGE frame offset literal has no value".to_string()))?; |
| |
| let scalar = match value { |
| Value::ByteVal(v) => ScalarValue::Int8(Some(*v as i8)), |
| Value::ShortVal(v) => ScalarValue::Int16(Some(*v as i16)), |
| Value::IntVal(v) => ScalarValue::Int32(Some(*v)), |
| Value::LongVal(v) => ScalarValue::Int64(Some(*v)), |
| Value::FloatVal(v) => ScalarValue::Float32(Some(*v)), |
| Value::DoubleVal(v) => ScalarValue::Float64(Some(*v)), |
| Value::DecimalVal(bytes) => { |
| let big_integer = BigInt::from_signed_bytes_be(bytes); |
| let integer = big_integer.to_i128().ok_or_else(|| { |
| GeneralError(format!( |
| "Cannot parse {big_integer:?} as i128 for Decimal RANGE frame offset" |
| )) |
| })?; |
| match data_type { |
| DataType::Decimal128(p, s) => ScalarValue::Decimal128(Some(integer), p, s), |
| ref dt => { |
| return Err(GeneralError(format!( |
| "Decimal RANGE frame offset has non-Decimal128 datatype: {dt:?}" |
| ))) |
| } |
| } |
| } |
| other => { |
| return Err(GeneralError(format!( |
| "Unsupported value variant for RANGE frame offset: {other:?}" |
| ))) |
| } |
| }; |
| |
| Ok(scalar) |
| } |
| |
| /// A physical join filter rewritter which rewrites the column indices in the expression |
| /// to use the new column indices. See `rewrite_physical_expr`. |
| struct JoinFilterRewriter<'a> { |
| left_field_len: usize, |
| right_field_len: usize, |
| left_field_indices: &'a [usize], |
| right_field_indices: &'a [usize], |
| } |
| |
| impl JoinFilterRewriter<'_> { |
| fn new<'a>( |
| left_field_len: usize, |
| right_field_len: usize, |
| left_field_indices: &'a [usize], |
| right_field_indices: &'a [usize], |
| ) -> JoinFilterRewriter<'a> { |
| JoinFilterRewriter { |
| left_field_len, |
| right_field_len, |
| left_field_indices, |
| right_field_indices, |
| } |
| } |
| } |
| |
| impl TreeNodeRewriter for JoinFilterRewriter<'_> { |
| type Node = Arc<dyn PhysicalExpr>; |
| |
| fn f_down(&mut self, node: Self::Node) -> datafusion::common::Result<Transformed<Self::Node>> { |
| if let Some(column) = node.downcast_ref::<Column>() { |
| if column.index() < self.left_field_len { |
| // left side |
| let new_index = self |
| .left_field_indices |
| .iter() |
| .position(|&x| x == column.index()) |
| .ok_or_else(|| { |
| DataFusionError::Internal(format!( |
| "Column index {} not found in left field indices", |
| column.index() |
| )) |
| })?; |
| Ok(Transformed::yes(Arc::new(Column::new( |
| column.name(), |
| new_index, |
| )))) |
| } else if column.index() < self.left_field_len + self.right_field_len { |
| // right side |
| let new_index = self |
| .right_field_indices |
| .iter() |
| .position(|&x| x + self.left_field_len == column.index()) |
| .ok_or_else(|| { |
| DataFusionError::Internal(format!( |
| "Column index {} not found in right field indices", |
| column.index() |
| )) |
| })?; |
| Ok(Transformed::yes(Arc::new(Column::new( |
| column.name(), |
| new_index + self.left_field_indices.len(), |
| )))) |
| } else { |
| Err(DataFusionError::Internal(format!( |
| "Column index {} out of range", |
| column.index() |
| ))) |
| } |
| } else { |
| Ok(Transformed::no(node)) |
| } |
| } |
| } |
| |
| /// Rewrites the physical expression to use the new column indices. |
| /// This is necessary when the physical expression is used in a join filter, as the column |
| /// indices are different from the original schema. |
| fn rewrite_physical_expr( |
| expr: Arc<dyn PhysicalExpr>, |
| left_field_len: usize, |
| right_field_len: usize, |
| left_field_indices: &[usize], |
| right_field_indices: &[usize], |
| ) -> Result<Arc<dyn PhysicalExpr>, ExecutionError> { |
| let mut rewriter = JoinFilterRewriter::new( |
| left_field_len, |
| right_field_len, |
| left_field_indices, |
| right_field_indices, |
| ); |
| |
| Ok(expr.rewrite(&mut rewriter).data()?) |
| } |
| |
| pub fn from_protobuf_eval_mode(value: i32) -> Result<EvalMode, prost::UnknownEnumValue> { |
| match spark_expression::EvalMode::try_from(value)? { |
| spark_expression::EvalMode::Legacy => Ok(EvalMode::Legacy), |
| spark_expression::EvalMode::Try => Ok(EvalMode::Try), |
| spark_expression::EvalMode::Ansi => Ok(EvalMode::Ansi), |
| } |
| } |
| |
| fn convert_spark_types_to_arrow_schema( |
| spark_types: &[spark_operator::SparkStructField], |
| ) -> SchemaRef { |
| let arrow_fields = spark_types |
| .iter() |
| .map(|spark_type| { |
| let field = Field::new( |
| String::clone(&spark_type.name), |
| to_arrow_datatype(spark_type.data_type.as_ref().unwrap()), |
| spark_type.nullable, |
| ); |
| if spark_type.metadata.is_empty() { |
| field |
| } else { |
| field.with_metadata(spark_type.metadata.clone()) |
| } |
| }) |
| .collect_vec(); |
| let arrow_schema: SchemaRef = Arc::new(Schema::new(arrow_fields)); |
| arrow_schema |
| } |
| |
| /// Wrap `child` in a `SchemaAlignExec` when its output drifts from what Spark catalyst |
| /// declared. See <https://github.com/apache/datafusion-comet/issues/4515>. |
| fn align_shuffle_writer_input( |
| child: Arc<dyn ExecutionPlan>, |
| expected_proto: &[spark_operator::SparkStructField], |
| ) -> Result<Arc<dyn ExecutionPlan>, ExecutionError> { |
| if expected_proto.is_empty() { |
| return Ok(child); |
| } |
| let expected = convert_spark_types_to_arrow_schema(expected_proto); |
| SchemaAlignExec::try_new_or_passthrough(child, &expected) |
| .map_err(|e| ExecutionError::DataFusionError(e.to_string())) |
| } |
| |
| /// Converts a protobuf PartitionValue to an iceberg Literal. |
| /// |
| fn partition_value_to_literal( |
| proto_value: &spark_operator::PartitionValue, |
| ) -> Result<Option<iceberg::spec::Literal>, ExecutionError> { |
| use spark_operator::iceberg_literal::Value; |
| |
| let Some(lit) = proto_value.literal.as_ref() else { |
| return Err(GeneralError( |
| "PartitionValue has no literal set".to_string(), |
| )); |
| }; |
| |
| if lit.is_null { |
| return Ok(None); |
| } |
| |
| let literal = match &lit.value { |
| Some(Value::BoolVal(v)) => iceberg::spec::Literal::bool(*v), |
| Some(Value::IntVal(v)) => iceberg::spec::Literal::int(*v), |
| Some(Value::LongVal(v)) => iceberg::spec::Literal::long(*v), |
| Some(Value::FloatVal(v)) => iceberg::spec::Literal::float(*v), |
| Some(Value::DoubleVal(v)) => iceberg::spec::Literal::double(*v), |
| Some(Value::DateVal(v)) => iceberg::spec::Literal::date(*v), |
| Some(Value::TimestampVal(v)) => iceberg::spec::Literal::timestamp(*v), |
| Some(Value::TimestampTzVal(v)) => iceberg::spec::Literal::timestamptz(*v), |
| Some(Value::StringVal(s)) => iceberg::spec::Literal::string(s.clone()), |
| Some(Value::DecimalVal(d)) => { |
| iceberg::spec::Literal::decimal(decimal_bytes_to_i128(&d.unscaled)?) |
| } |
| Some(Value::UuidVal(bytes)) => { |
| if bytes.len() != 16 { |
| return Err(GeneralError(format!( |
| "Invalid UUID bytes length: {} (expected 16)", |
| bytes.len() |
| ))); |
| } |
| let uuid = uuid::Uuid::from_slice(bytes) |
| .map_err(|e| GeneralError(format!("Failed to parse UUID: {}", e)))?; |
| iceberg::spec::Literal::uuid(uuid) |
| } |
| Some(Value::FixedVal(bytes)) => iceberg::spec::Literal::fixed(bytes.to_vec()), |
| Some(Value::BinaryVal(bytes)) => iceberg::spec::Literal::binary(bytes.to_vec()), |
| None => { |
| return Err(GeneralError( |
| "IcebergLiteral has no value set and is_null is false".to_string(), |
| )); |
| } |
| }; |
| |
| Ok(Some(literal)) |
| } |
| |
| /// Decodes an unscaled decimal (two's-complement big-endian) into i128. |
| fn decimal_bytes_to_i128(bytes: &[u8]) -> Result<i128, ExecutionError> { |
| if bytes.len() > 16 { |
| return Err(GeneralError(format!( |
| "Decimal bytes too large: {} bytes (max 16 for i128)", |
| bytes.len() |
| ))); |
| } |
| let mut buf = [0u8; 16]; |
| let offset = 16 - bytes.len(); |
| buf[offset..].copy_from_slice(bytes); |
| // Sign-extend negative values. |
| if !bytes.is_empty() && (bytes[0] & 0x80) != 0 { |
| for byte in buf.iter_mut().take(offset) { |
| *byte = 0xFF; |
| } |
| } |
| Ok(i128::from_be_bytes(buf)) |
| } |
| |
| /// Converts a protobuf PartitionData to an iceberg Struct. |
| /// |
| /// Uses the existing Struct::from_iter() API from iceberg-rust to construct the struct |
| /// from the list of partition values. |
| /// This can potentially be upstreamed to iceberg_rust |
| fn partition_data_to_struct( |
| proto_partition: &spark_operator::PartitionData, |
| ) -> Result<iceberg::spec::Struct, ExecutionError> { |
| let literals: Vec<Option<iceberg::spec::Literal>> = proto_partition |
| .values |
| .iter() |
| .map(partition_value_to_literal) |
| .collect::<Result<Vec<_>, _>>()?; |
| |
| Ok(iceberg::spec::Struct::from_iter(literals)) |
| } |
| |
| /// Converts protobuf FileScanTasks from Scala into iceberg-rust FileScanTask objects. |
| /// |
| /// Each task contains a residual predicate that is used for row-group level filtering |
| /// during Parquet scanning. |
| /// |
| /// This function uses deduplication pools from the IcebergScanCommon to avoid redundant |
| /// parsing of schemas, partition specs, partition types, name mappings, and other repeated data. |
| fn parse_file_scan_tasks_from_common( |
| proto_common: &spark_operator::IcebergScanCommon, |
| proto_tasks: &[spark_operator::IcebergFileScanTask], |
| ) -> Result<Vec<iceberg::scan::FileScanTask>, ExecutionError> { |
| // Parse each unique schema once, not once per task |
| let schema_cache: Vec<Arc<iceberg::spec::Schema>> = proto_common |
| .schema_pool |
| .iter() |
| .map(|json| { |
| serde_json::from_str(json).map(Arc::new).map_err(|e| { |
| ExecutionError::GeneralError(format!( |
| "Failed to parse schema JSON from pool: {}", |
| e |
| )) |
| }) |
| }) |
| .collect::<Result<Vec<_>, _>>()?; |
| |
| let partition_spec_cache: Vec<Option<Arc<iceberg::spec::PartitionSpec>>> = proto_common |
| .partition_spec_pool |
| .iter() |
| .map(|json| { |
| serde_json::from_str::<iceberg::spec::PartitionSpec>(json) |
| .ok() |
| .map(Arc::new) |
| }) |
| .collect(); |
| |
| let name_mapping_cache: Vec<Option<Arc<iceberg::spec::NameMapping>>> = proto_common |
| .name_mapping_pool |
| .iter() |
| .map(|json| { |
| serde_json::from_str::<iceberg::spec::NameMapping>(json) |
| .ok() |
| .map(Arc::new) |
| }) |
| .collect(); |
| |
| // Flat pool of unique delete files. A delete file applies to many data files under Iceberg's |
| // default partition delete granularity, so DeleteFileList entries reference this pool by index |
| // rather than embedding copies. |
| let delete_file_pool: Vec<iceberg::scan::FileScanTaskDeleteFile> = proto_common |
| .delete_file_pool |
| .iter() |
| .map(|del| { |
| let file_type = match del.content_type.as_str() { |
| "POSITION_DELETES" => iceberg::spec::DataContentType::PositionDeletes, |
| "EQUALITY_DELETES" => iceberg::spec::DataContentType::EqualityDeletes, |
| other => { |
| return Err(GeneralError(format!( |
| "Invalid delete content type '{}'", |
| other |
| ))) |
| } |
| }; |
| |
| Ok(iceberg::scan::FileScanTaskDeleteFile { |
| file_path: del.file_path.clone(), |
| file_type, |
| // Not serialized; filled in by IcebergScanExec::fill_delete_file_sizes. |
| file_size_in_bytes: 0, |
| partition_spec_id: del.partition_spec_id, |
| equality_ids: if del.equality_ids.is_empty() { |
| None |
| } else { |
| Some(del.equality_ids.clone()) |
| }, |
| // Plaintext StandardKeyMetadata forwarded verbatim from the JVM; decoded by |
| // iceberg-rust with no KMS unwrap. None for unencrypted delete files. |
| key_metadata: del.key_metadata.clone().map(Vec::into_boxed_slice), |
| }) |
| }) |
| .collect::<Result<Vec<_>, ExecutionError>>()?; |
| |
| let delete_files_cache: Vec<Vec<iceberg::scan::FileScanTaskDeleteFile>> = proto_common |
| .delete_files_pool |
| .iter() |
| .map(|list| { |
| list.delete_file_indices |
| .iter() |
| .map(|&idx| { |
| delete_file_pool.get(idx as usize).cloned().ok_or_else(|| { |
| GeneralError(format!( |
| "Invalid delete_file_index: {} (pool size: {})", |
| idx, |
| delete_file_pool.len() |
| )) |
| }) |
| }) |
| .collect::<Result<Vec<_>, ExecutionError>>() |
| }) |
| .collect::<Result<Vec<_>, _>>()?; |
| |
| let results: Result<Vec<_>, _> = proto_tasks |
| .iter() |
| .map(|proto_task| { |
| let schema_ref = Arc::clone( |
| schema_cache |
| .get(proto_task.schema_idx as usize) |
| .ok_or_else(|| { |
| ExecutionError::GeneralError(format!( |
| "Invalid schema_idx: {} (pool size: {})", |
| proto_task.schema_idx, |
| schema_cache.len() |
| )) |
| })?, |
| ); |
| |
| let data_file_format = iceberg::spec::DataFileFormat::Parquet; |
| |
| let deletes = if let Some(idx) = proto_task.delete_files_idx { |
| delete_files_cache |
| .get(idx as usize) |
| .ok_or_else(|| { |
| ExecutionError::GeneralError(format!( |
| "Invalid delete_files_idx: {} (pool size: {})", |
| idx, |
| delete_files_cache.len() |
| )) |
| })? |
| .clone() |
| } else { |
| vec![] |
| }; |
| |
| let bound_predicate = if let Some(idx) = proto_task.residual_idx { |
| proto_common |
| .residual_pool |
| .get(idx as usize) |
| .and_then(iceberg_predicate_to_predicate) |
| .and_then(|pred| { |
| // The residual predicate only drives row-group pruning; the post-scan |
| // filter still enforces correctness. iceberg-rust cannot bind a datum |
| // whose type has no conversion to the column type, so on a bind failure |
| // we skip pushdown rather than fail the scan. |
| // rewrite_not first: iceberg-rust's scan evaluators require negation-normal |
| // form and reject a bare NOT (e.g. from a `NOT (a = b)` residual). |
| match pred.rewrite_not().bind(Arc::clone(&schema_ref), true) { |
| Ok(bound) => Some(bound), |
| Err(e) => { |
| log::warn!("Skipping Iceberg predicate pushdown; bind failed: {e}"); |
| None |
| } |
| } |
| }) |
| } else { |
| None |
| }; |
| |
| let partition = if let Some(partition_data_idx) = proto_task.partition_data_idx { |
| let partition_data_proto = proto_common |
| .partition_data_pool |
| .get(partition_data_idx as usize) |
| .ok_or_else(|| { |
| ExecutionError::GeneralError(format!( |
| "Invalid partition_data_idx: {} (pool size: {})", |
| partition_data_idx, |
| proto_common.partition_data_pool.len() |
| )) |
| })?; |
| |
| match partition_data_to_struct(partition_data_proto) { |
| Ok(s) => Some(s), |
| Err(e) => { |
| return Err(ExecutionError::GeneralError(format!( |
| "Failed to deserialize partition data: {}", |
| e |
| ))) |
| } |
| } |
| } else { |
| None |
| }; |
| |
| let partition_spec = proto_task |
| .partition_spec_idx |
| .and_then(|idx| partition_spec_cache.get(idx as usize)) |
| .and_then(|opt| opt.clone()); |
| |
| let name_mapping = proto_task |
| .name_mapping_idx |
| .and_then(|idx| name_mapping_cache.get(idx as usize)) |
| .and_then(|opt| opt.clone()); |
| |
| let project_field_ids = proto_common |
| .project_field_ids_pool |
| .get(proto_task.project_field_ids_idx as usize) |
| .ok_or_else(|| { |
| ExecutionError::GeneralError(format!( |
| "Invalid project_field_ids_idx: {} (pool size: {})", |
| proto_task.project_field_ids_idx, |
| proto_common.project_field_ids_pool.len() |
| )) |
| })? |
| .field_ids |
| .clone(); |
| |
| Ok(iceberg::scan::FileScanTask { |
| file_size_in_bytes: proto_task.file_size_in_bytes, |
| data_file_path: proto_task.data_file_path.clone(), |
| start: proto_task.start, |
| length: proto_task.length, |
| record_count: proto_task.record_count, |
| data_file_format, |
| schema: schema_ref, |
| project_field_ids, |
| predicate: bound_predicate, |
| deletes, |
| partition, |
| partition_spec, |
| name_mapping, |
| case_sensitive: false, |
| // Plaintext StandardKeyMetadata forwarded verbatim from the JVM; decoded by |
| // iceberg-rust with no KMS unwrap. None for unencrypted data files. |
| key_metadata: proto_task.key_metadata.clone().map(Vec::into_boxed_slice), |
| }) |
| }) |
| .collect(); |
| |
| results |
| } |
| |
| /// Create CASE WHEN expression and add casting as needed |
| fn create_case_expr( |
| when_then_pairs: Vec<(Arc<dyn PhysicalExpr>, Arc<dyn PhysicalExpr>)>, |
| else_expr: Option<Arc<dyn PhysicalExpr>>, |
| input_schema: &Schema, |
| ) -> Result<Arc<dyn PhysicalExpr>, ExecutionError> { |
| let then_types: Vec<DataType> = when_then_pairs |
| .iter() |
| .map(|x| x.1.data_type(input_schema)) |
| .collect::<Result<Vec<_>, _>>()?; |
| |
| let else_type: Option<DataType> = else_expr |
| .as_ref() |
| .map(|x| Arc::clone(x).data_type(input_schema)) |
| .transpose()? |
| .or(Some(DataType::Null)); |
| |
| if let Some(coerce_type) = get_coerce_type_for_case_expression(&then_types, else_type.as_ref()) |
| { |
| let cast_options = SparkCastOptions::new_without_timezone(EvalMode::Legacy, false); |
| |
| let when_then_pairs = when_then_pairs |
| .iter() |
| .map(|x| { |
| let t: Arc<dyn PhysicalExpr> = Arc::new(Cast::new( |
| Arc::clone(&x.1), |
| coerce_type.clone(), |
| cast_options.clone(), |
| None, |
| None, |
| )); |
| (Arc::clone(&x.0), t) |
| }) |
| .collect::<Vec<(Arc<dyn PhysicalExpr>, Arc<dyn PhysicalExpr>)>>(); |
| |
| let else_phy_expr: Option<Arc<dyn PhysicalExpr>> = else_expr.clone().map(|x| { |
| Arc::new(Cast::new( |
| x, |
| coerce_type.clone(), |
| cast_options.clone(), |
| None, |
| None, |
| )) as Arc<dyn PhysicalExpr> |
| }); |
| Ok(Arc::new(CaseExpr::try_new( |
| None, |
| when_then_pairs, |
| else_phy_expr, |
| )?)) |
| } else { |
| Ok(Arc::new(CaseExpr::try_new( |
| None, |
| when_then_pairs, |
| else_expr.clone(), |
| )?)) |
| } |
| } |
| |
| fn from_protobuf_binary_output_style( |
| value: i32, |
| ) -> Result<BinaryOutputStyle, prost::UnknownEnumValue> { |
| match spark_expression::BinaryOutputStyle::try_from(value)? { |
| spark_expression::BinaryOutputStyle::Utf8 => Ok(BinaryOutputStyle::Utf8), |
| spark_expression::BinaryOutputStyle::Basic => Ok(BinaryOutputStyle::Basic), |
| spark_expression::BinaryOutputStyle::Base64 => Ok(BinaryOutputStyle::Base64), |
| spark_expression::BinaryOutputStyle::Hex => Ok(BinaryOutputStyle::Hex), |
| spark_expression::BinaryOutputStyle::HexDiscrete => Ok(BinaryOutputStyle::HexDiscrete), |
| } |
| } |
| |
| fn literal_to_array_ref( |
| data_type: DataType, |
| list_literal: ListLiteral, |
| ) -> Result<ArrayRef, ExecutionError> { |
| let nulls = &list_literal.null_mask; |
| match data_type { |
| DataType::Null => Ok(Arc::new(NullArray::new(nulls.len()))), |
| DataType::Boolean => Ok(Arc::new(BooleanArray::new( |
| BooleanBuffer::from(list_literal.boolean_values), |
| Some(nulls.clone().into()), |
| ))), |
| DataType::Int8 => Ok(Arc::new(Int8Array::new( |
| list_literal |
| .byte_values |
| .iter() |
| .map(|&x| x as i8) |
| .collect::<Vec<_>>() |
| .into(), |
| Some(nulls.clone().into()), |
| ))), |
| DataType::Int16 => Ok(Arc::new(Int16Array::new( |
| list_literal |
| .short_values |
| .iter() |
| .map(|&x| x as i16) |
| .collect::<Vec<_>>() |
| .into(), |
| Some(nulls.clone().into()), |
| ))), |
| DataType::Int32 => Ok(Arc::new(Int32Array::new( |
| list_literal.int_values.into(), |
| Some(nulls.clone().into()), |
| ))), |
| DataType::Int64 => Ok(Arc::new(Int64Array::new( |
| list_literal.long_values.into(), |
| Some(nulls.clone().into()), |
| ))), |
| DataType::Float32 => Ok(Arc::new(Float32Array::new( |
| list_literal.float_values.into(), |
| Some(nulls.clone().into()), |
| ))), |
| DataType::Float64 => Ok(Arc::new(Float64Array::new( |
| list_literal.double_values.into(), |
| Some(nulls.clone().into()), |
| ))), |
| DataType::Date32 => Ok(Arc::new(Date32Array::new( |
| list_literal.int_values.into(), |
| Some(nulls.clone().into()), |
| ))), |
| DataType::Timestamp(TimeUnit::Microsecond, None) => { |
| Ok(Arc::new(TimestampMicrosecondArray::new( |
| list_literal.long_values.into(), |
| Some(nulls.clone().into()), |
| ))) |
| } |
| DataType::Timestamp(TimeUnit::Microsecond, Some(tz)) => Ok(Arc::new( |
| TimestampMicrosecondArray::new( |
| list_literal.long_values.into(), |
| Some(nulls.clone().into()), |
| ) |
| .with_timezone(Arc::clone(&tz)), |
| )), |
| DataType::Binary => { |
| // Using a builder as it is cumbersome to create BinaryArray from a vector with nulls |
| // and calculate correct offsets |
| let item_capacity = list_literal.bytes_values.len(); |
| let data_capacity = list_literal |
| .bytes_values |
| .first() |
| .map(|s| s.len() * item_capacity) |
| .unwrap_or(0); |
| let mut arr = BinaryBuilder::with_capacity(item_capacity, data_capacity); |
| |
| for (i, v) in list_literal.bytes_values.into_iter().enumerate() { |
| if nulls[i] { |
| arr.append_value(v); |
| } else { |
| arr.append_null(); |
| } |
| } |
| |
| Ok(Arc::new(arr.finish())) |
| } |
| DataType::Utf8 => { |
| // Using a builder as it is cumbersome to create StringArray from a vector with nulls |
| // and calculate correct offsets |
| let item_capacity = list_literal.string_values.len(); |
| let data_capacity = list_literal |
| .string_values |
| .first() |
| .map(|s| s.len() * item_capacity) |
| .unwrap_or(0); |
| let mut arr = StringBuilder::with_capacity(item_capacity, data_capacity); |
| |
| for (i, v) in list_literal.string_values.into_iter().enumerate() { |
| if nulls[i] { |
| arr.append_value(v); |
| } else { |
| arr.append_null(); |
| } |
| } |
| |
| Ok(Arc::new(arr.finish())) |
| } |
| DataType::Decimal128(p, s) => Ok(Arc::new( |
| Decimal128Array::new( |
| list_literal |
| .decimal_values |
| .into_iter() |
| .map(|v| { |
| let big_integer = BigInt::from_signed_bytes_be(&v); |
| big_integer |
| .to_i128() |
| .ok_or_else(|| { |
| GeneralError(format!( |
| "Cannot parse {big_integer:?} as i128 for Decimal literal" |
| )) |
| }) |
| .unwrap() |
| }) |
| .collect::<Vec<_>>() |
| .into(), |
| Some(nulls.clone().into()), |
| ) |
| .with_precision_and_scale(p, s)?, |
| )), |
| // list of primitive types |
| DataType::List(f) if !matches!(f.data_type(), DataType::List(_)) => { |
| literal_to_array_ref(f.data_type().clone(), list_literal) |
| } |
| DataType::List(ref f) => { |
| let dt = f.data_type().clone(); |
| |
| // Build offsets and collect non-null child arrays |
| let mut offsets = Vec::with_capacity(list_literal.list_values.len() + 1); |
| // Offsets starts with 0 |
| offsets.push(0i32); |
| let mut child_arrays: Vec<ArrayRef> = Vec::new(); |
| |
| for (i, child_literal) in list_literal.list_values.iter().enumerate() { |
| // Check if the current child literal is non-null and not the empty array |
| if list_literal.null_mask[i] && *child_literal != ListLiteral::default() { |
| // Non-null entry: process the child array |
| let child_array = literal_to_array_ref(dt.clone(), child_literal.clone())?; |
| let len = child_array.len() as i32; |
| offsets.push(offsets.last().unwrap() + len); |
| child_arrays.push(child_array); |
| } else { |
| // Null entry: just repeat the last offset (empty slot) |
| offsets.push(*offsets.last().unwrap()); |
| } |
| } |
| |
| // Concatenate all non-null child arrays' values into one array |
| let output_array = if !child_arrays.is_empty() { |
| let child_refs: Vec<&dyn Array> = child_arrays.iter().map(|a| a.as_ref()).collect(); |
| arrow::compute::concat(&child_refs)? |
| } else { |
| // All entries are null or the list is empty |
| new_empty_array(&dt) |
| }; |
| |
| // Create and return the parent ListArray |
| Ok(Arc::new(ListArray::new( |
| FieldRef::from(Field::new("item", output_array.data_type().clone(), true)), |
| OffsetBuffer::new(offsets.into()), |
| output_array, |
| Some(NullBuffer::from(list_literal.null_mask.clone())), |
| ))) |
| } |
| dt => Err(GeneralError(format!( |
| "DataType::List literal does not support {dt:?} type" |
| ))), |
| } |
| } |
| |
| // ============================================================================ |
| // Iceberg Residual Predicate Conversion |
| // ============================================================================ |
| |
| /// Converts a serialized Iceberg residual predicate into an iceberg-rust `Predicate` for row-group |
| /// pruning. This is only a pruning hint -- the post-scan CometFilter enforces correctness -- so any |
| /// node or literal that cannot be represented degrades to `None` (no pushdown) rather than an |
| /// error. Mirrors the proto 1:1 onto iceberg-rust's `Reference` builders. |
| fn iceberg_predicate_to_predicate( |
| proto: &spark_operator::IcebergPredicate, |
| ) -> Option<iceberg::expr::Predicate> { |
| use iceberg::expr::{Predicate, Reference}; |
| use spark_operator::iceberg_predicate::Node; |
| use spark_operator::IcebergPredicateOperator as Op; |
| |
| match proto.node.as_ref()? { |
| Node::Unary(u) => { |
| let column = Reference::new(u.column.clone()); |
| match u.op() { |
| Op::IsNull => Some(column.is_null()), |
| Op::NotNull => Some(column.is_not_null()), |
| Op::IsNan => Some(column.is_nan()), |
| Op::NotNan => Some(column.is_not_nan()), |
| _ => None, |
| } |
| } |
| Node::Binary(b) => { |
| let column = Reference::new(b.column.clone()); |
| let datum = iceberg_literal_to_datum(b.value.as_ref()?)?; |
| match b.op() { |
| Op::Eq => Some(column.equal_to(datum)), |
| Op::NotEq => Some(column.not_equal_to(datum)), |
| Op::LessThan => Some(column.less_than(datum)), |
| Op::LessThanOrEq => Some(column.less_than_or_equal_to(datum)), |
| Op::GreaterThan => Some(column.greater_than(datum)), |
| Op::GreaterThanOrEq => Some(column.greater_than_or_equal_to(datum)), |
| Op::StartsWith => Some(column.starts_with(datum)), |
| Op::NotStartsWith => Some(column.not_starts_with(datum)), |
| _ => None, |
| } |
| } |
| Node::Set(s) => { |
| // Only IN prunes from stats; not_in is inherently unprunable, so it is never emitted. |
| if s.op() != Op::In { |
| return None; |
| } |
| let column = Reference::new(s.column.clone()); |
| let datums = s |
| .values |
| .iter() |
| .map(iceberg_literal_to_datum) |
| .collect::<Option<Vec<_>>>()?; |
| Some(column.is_in(datums)) |
| } |
| // And/Or are whole-or-nothing: both children must convert. Dropping a child is unsafe. |
| // A dropped disjunct strengthens an Or directly; a dropped conjunct is safe for a bare And |
| // but not under a NOT, where De Morgan turns And into Or (`NOT(A AND B)` = `NOT A OR NOT B`) |
| // and dropping strengthens it, wrongly pruning row groups. Since Not is only pushed to |
| // negation-normal form after decode (rewrite_not below), the Not(And(..)) shape does reach |
| // here, so the And arm cannot assume positive position. Degrading to no-pushdown is always |
| // safe; the post-scan CometFilter is exact. See `combine_logical` for the parity invariant. |
| Node::And(l) => { |
| let left = l.left.as_deref().and_then(iceberg_predicate_to_predicate); |
| let right = l.right.as_deref().and_then(iceberg_predicate_to_predicate); |
| combine_logical(left, right, Predicate::and) |
| } |
| Node::Or(o) => { |
| let left = o.left.as_deref().and_then(iceberg_predicate_to_predicate); |
| let right = o.right.as_deref().and_then(iceberg_predicate_to_predicate); |
| combine_logical(left, right, Predicate::or) |
| } |
| Node::Not(child) => iceberg_predicate_to_predicate(child).map(|p| !p), |
| } |
| } |
| |
| /// Combines the two children of a logical residual node (And/Or), returning `None` unless both |
| /// converted. A missing child is not expected: the Scala serde emits a logical node only when both |
| /// children convert, and Rust decodes every node it emits, so both sides always convert for a |
| /// well-formed residual. A `None` here therefore means the two serde paths have diverged (e.g. a |
| /// new `IcebergLiteral` variant on the serialize side without a matching decode arm), so it trips a |
| /// debug-only assertion and degrades to no-pushdown in release. See [`iceberg_predicate_to_predicate`] |
| /// for why a partial logical node must never be pushed. |
| fn combine_logical( |
| left: Option<iceberg::expr::Predicate>, |
| right: Option<iceberg::expr::Predicate>, |
| combine: fn(iceberg::expr::Predicate, iceberg::expr::Predicate) -> iceberg::expr::Predicate, |
| ) -> Option<iceberg::expr::Predicate> { |
| match (left, right) { |
| (Some(l), Some(r)) => Some(combine(l, r)), |
| _ => { |
| debug_assert!( |
| false, |
| "Iceberg residual logical node with an unconvertible child: the Scala serde emits \ |
| both children and Rust decodes every node it emits, so the two serde paths have \ |
| diverged" |
| ); |
| None |
| } |
| } |
| } |
| |
| /// Converts a serialized `IcebergLiteral` into an iceberg-rust `Datum` for predicate pushdown. |
| /// Returns `None` for null and for byte-array-backed types (decimal/uuid/fixed/binary), which |
| /// iceberg-rust cannot use in the page index yet; the driver does not emit those for predicates, |
| /// so this only guards against unexpected input. Correctness is unaffected -- a dropped literal |
| /// just skips pushdown. |
| fn iceberg_literal_to_datum(lit: &spark_operator::IcebergLiteral) -> Option<iceberg::spec::Datum> { |
| use spark_operator::iceberg_literal::Value; |
| |
| if lit.is_null { |
| return None; |
| } |
| |
| match lit.value.as_ref()? { |
| Value::BoolVal(v) => Some(iceberg::spec::Datum::bool(*v)), |
| Value::IntVal(v) => Some(iceberg::spec::Datum::int(*v)), |
| Value::LongVal(v) => Some(iceberg::spec::Datum::long(*v)), |
| Value::FloatVal(v) => Some(iceberg::spec::Datum::float(*v)), |
| Value::DoubleVal(v) => Some(iceberg::spec::Datum::double(*v)), |
| Value::DateVal(v) => Some(iceberg::spec::Datum::date(*v)), |
| Value::TimestampVal(v) => Some(iceberg::spec::Datum::timestamp_micros(*v)), |
| Value::TimestampTzVal(v) => Some(iceberg::spec::Datum::timestamptz_micros(*v)), |
| Value::StringVal(v) => Some(iceberg::spec::Datum::string(v.clone())), |
| Value::DecimalVal(_) | Value::UuidVal(_) | Value::FixedVal(_) | Value::BinaryVal(_) => None, |
| } |
| } |
| |
| /// Returns true for signature types that need fields_with_udf() for coercion. |
| /// |
| /// "Well-supported" signatures (Coercible, String, Numeric, Comparable) preserve |
| /// input types naturally (e.g. Utf8 stays Utf8) and need fields_with_udf() because |
| /// they don't implement coerce_types(). Other signatures (Variadic, Exact, etc.) |
| /// should keep original types to match DF52 behavior and avoid unwanted promotions |
| /// like Utf8 to Utf8View or Int32 to Int64. |
| fn needs_fields_coercion(sig: &TypeSignature) -> bool { |
| match sig { |
| TypeSignature::Coercible(_) |
| | TypeSignature::String(_) |
| | TypeSignature::Numeric(_) |
| | TypeSignature::Comparable(_) => true, |
| TypeSignature::OneOf(sigs) => sigs.iter().any(needs_fields_coercion), |
| _ => false, |
| } |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use futures::{poll, StreamExt}; |
| use std::{sync::Arc, task::Poll}; |
| |
| use arrow::array::{ |
| Array, DictionaryArray, Int32Array, Int8Array, ListArray, RecordBatch, StringArray, |
| }; |
| use arrow::datatypes::{DataType, Field, FieldRef, Fields, Schema}; |
| use datafusion::catalog::memory::DataSourceExec; |
| use datafusion::config::TableParquetOptions; |
| use datafusion::datasource::listing::PartitionedFile; |
| use datafusion::datasource::object_store::ObjectStoreUrl; |
| use datafusion::datasource::physical_plan::{ |
| FileGroup, FileScanConfigBuilder, FileSource, ParquetSource, |
| }; |
| use datafusion::error::DataFusionError; |
| use datafusion::logical_expr::ScalarUDF; |
| use datafusion::physical_plan::ExecutionPlan; |
| use datafusion::{assert_batches_eq, physical_plan::common::collect, prelude::SessionContext}; |
| use datafusion_physical_expr_adapter::PhysicalExprAdapterFactory; |
| use tempfile::TempDir; |
| use tokio::sync::mpsc; |
| |
| use crate::execution::{operators::InputBatch, planner::PhysicalPlanner}; |
| |
| use crate::execution::operators::ExecutionError; |
| use crate::execution::planner::literal_to_array_ref; |
| use crate::parquet::parquet_support::SparkParquetOptions; |
| use crate::parquet::schema_adapter::SparkPhysicalExprAdapterFactory; |
| use datafusion_comet_proto::spark_expression::expr::ExprStruct; |
| use datafusion_comet_proto::spark_expression::ListLiteral; |
| use datafusion_comet_proto::{ |
| spark_expression::expr::ExprStruct::*, |
| spark_expression::Expr, |
| spark_expression::{self, literal}, |
| spark_operator, |
| spark_operator::{operator::OpStruct, Operator}, |
| }; |
| use datafusion_comet_spark_expr::EvalMode; |
| |
| #[test] |
| fn test_unpack_dictionary_primitive() { |
| let op_scan = Operator { |
| plan_id: 0, |
| children: vec![], |
| op_struct: Some(OpStruct::Scan(spark_operator::Scan { |
| fields: vec![spark_expression::DataType { |
| type_id: 3, // Int32 |
| type_info: None, |
| }], |
| source: "".to_string(), |
| })), |
| }; |
| |
| let op = create_filter(op_scan, 3); |
| let planner = PhysicalPlanner::default(); |
| let row_count = 100; |
| |
| // Create a dictionary array with 100 values, and use it as input to the execution. |
| let keys = Int32Array::new((0..(row_count as i32)).map(|n| n % 4).collect(), None); |
| let values = Int32Array::from(vec![0, 1, 2, 3]); |
| let input_array = DictionaryArray::new(keys, Arc::new(values)); |
| let input_batch = InputBatch::Batch(vec![Arc::new(input_array)], row_count); |
| |
| let (mut scans, _shuffle_scans, datafusion_plan) = |
| planner.create_plan(&op, &mut vec![], 1).unwrap(); |
| scans[0].set_input_batch(input_batch); |
| |
| let session_ctx = SessionContext::new(); |
| let task_ctx = session_ctx.task_ctx(); |
| let mut stream = datafusion_plan.native_plan.execute(0, task_ctx).unwrap(); |
| |
| let runtime = tokio::runtime::Runtime::new().unwrap(); |
| runtime.block_on(async move { |
| let mut eof_sent = false; |
| let mut got_result = false; |
| loop { |
| match poll!(stream.next()) { |
| Poll::Ready(Some(batch)) => { |
| assert!(batch.is_ok(), "got error {}", batch.unwrap_err()); |
| let batch = batch.unwrap(); |
| assert_eq!(batch.num_rows(), row_count / 4); |
| // dictionary should be unpacked |
| assert!(matches!(batch.column(0).data_type(), DataType::Int32)); |
| got_result = true; |
| } |
| Poll::Ready(None) => { |
| break; |
| } |
| Poll::Pending => { |
| // Stream needs more input (e.g. FilterExec's batch coalescer |
| // is accumulating). Send EOF to flush. |
| if !eof_sent { |
| scans[0].set_input_batch(InputBatch::EOF); |
| eof_sent = true; |
| } |
| } |
| } |
| } |
| assert!(got_result, "Expected at least one result batch"); |
| }); |
| } |
| |
| const STRING_TYPE_ID: i32 = 7; |
| |
| #[test] |
| fn test_unpack_dictionary_string() { |
| let op_scan = Operator { |
| plan_id: 0, |
| children: vec![], |
| op_struct: Some(OpStruct::Scan(spark_operator::Scan { |
| fields: vec![spark_expression::DataType { |
| type_id: STRING_TYPE_ID, // String |
| type_info: None, |
| }], |
| source: "".to_string(), |
| })), |
| }; |
| |
| let lit = spark_expression::Literal { |
| value: Some(literal::Value::StringVal("foo".to_string())), |
| datatype: Some(spark_expression::DataType { |
| type_id: STRING_TYPE_ID, |
| type_info: None, |
| }), |
| is_null: false, |
| }; |
| |
| let op = create_filter_literal(op_scan, STRING_TYPE_ID, lit); |
| let planner = PhysicalPlanner::default(); |
| |
| let row_count = 100; |
| |
| let keys = Int32Array::new((0..(row_count as i32)).map(|n| n % 4).collect(), None); |
| let values = StringArray::from(vec!["foo", "bar", "hello", "comet"]); |
| let input_array = DictionaryArray::new(keys, Arc::new(values)); |
| let input_batch = InputBatch::Batch(vec![Arc::new(input_array)], row_count); |
| |
| let (mut scans, _shuffle_scans, datafusion_plan) = |
| planner.create_plan(&op, &mut vec![], 1).unwrap(); |
| |
| // Scan's schema is determined by the input batch, so we need to set it before execution. |
| scans[0].set_input_batch(input_batch); |
| |
| let session_ctx = SessionContext::new(); |
| let task_ctx = session_ctx.task_ctx(); |
| let mut stream = datafusion_plan.native_plan.execute(0, task_ctx).unwrap(); |
| |
| let runtime = tokio::runtime::Runtime::new().unwrap(); |
| runtime.block_on(async move { |
| let mut eof_sent = false; |
| let mut got_result = false; |
| loop { |
| match poll!(stream.next()) { |
| Poll::Ready(Some(batch)) => { |
| assert!(batch.is_ok(), "got error {}", batch.unwrap_err()); |
| let batch = batch.unwrap(); |
| assert_eq!(batch.num_rows(), row_count / 4); |
| // string/binary should no longer be packed with dictionary |
| assert!(matches!(batch.column(0).data_type(), DataType::Utf8)); |
| got_result = true; |
| } |
| Poll::Ready(None) => { |
| break; |
| } |
| Poll::Pending => { |
| // Stream needs more input (e.g. FilterExec's batch coalescer |
| // is accumulating). Send EOF to flush. |
| if !eof_sent { |
| scans[0].set_input_batch(InputBatch::EOF); |
| eof_sent = true; |
| } |
| } |
| } |
| } |
| assert!(got_result, "Expected at least one result batch"); |
| }); |
| } |
| |
| #[tokio::test()] |
| #[allow(clippy::field_reassign_with_default)] |
| async fn to_datafusion_filter() { |
| let op_scan = create_scan(); |
| let op = create_filter(op_scan, 0); |
| let planner = PhysicalPlanner::default(); |
| |
| let (mut scans, _shuffle_scans, datafusion_plan) = |
| planner.create_plan(&op, &mut vec![], 1).unwrap(); |
| |
| let scan = &mut scans[0]; |
| scan.set_input_batch(InputBatch::EOF); |
| |
| let session_ctx = SessionContext::new(); |
| let task_ctx = session_ctx.task_ctx(); |
| |
| let stream = datafusion_plan |
| .native_plan |
| .execute(0, Arc::clone(&task_ctx)) |
| .unwrap(); |
| let output = collect(stream).await.unwrap(); |
| assert!(output.is_empty()); |
| } |
| |
| #[tokio::test()] |
| async fn from_datafusion_error_to_comet() { |
| let err_msg = "exec error"; |
| let err = datafusion::common::DataFusionError::Execution(err_msg.to_string()); |
| let comet_err: ExecutionError = err.into(); |
| assert_eq!(comet_err.to_string(), "Error from DataFusion: exec error."); |
| } |
| |
| // Creates a filter operator which takes an `Int32Array` and selects rows that are equal to |
| // `value`. |
| fn create_filter(child_op: spark_operator::Operator, value: i32) -> spark_operator::Operator { |
| let lit = spark_expression::Literal { |
| value: Some(literal::Value::IntVal(value)), |
| datatype: Some(spark_expression::DataType { |
| type_id: 3, |
| type_info: None, |
| }), |
| is_null: false, |
| }; |
| |
| create_filter_literal(child_op, 3, lit) |
| } |
| |
| fn create_filter_literal( |
| child_op: spark_operator::Operator, |
| type_id: i32, |
| lit: spark_expression::Literal, |
| ) -> spark_operator::Operator { |
| let left = spark_expression::Expr { |
| expr_struct: Some(Bound(spark_expression::BoundReference { |
| index: 0, |
| datatype: Some(spark_expression::DataType { |
| type_id, |
| type_info: None, |
| }), |
| })), |
| query_context: None, |
| expr_id: None, |
| }; |
| let right = spark_expression::Expr { |
| expr_struct: Some(Literal(lit)), |
| query_context: None, |
| expr_id: None, |
| }; |
| |
| let expr = spark_expression::Expr { |
| expr_struct: Some(Eq(Box::new(spark_expression::BinaryExpr { |
| left: Some(Box::new(left)), |
| right: Some(Box::new(right)), |
| }))), |
| query_context: None, |
| expr_id: None, |
| }; |
| |
| Operator { |
| plan_id: 0, |
| children: vec![child_op], |
| op_struct: Some(OpStruct::Filter(spark_operator::Filter { |
| predicate: Some(expr), |
| })), |
| } |
| } |
| |
| #[test] |
| fn spark_plan_metrics_filter() { |
| let op_scan = create_scan(); |
| let op = create_filter(op_scan, 0); |
| let planner = PhysicalPlanner::default(); |
| |
| let (_scans, _shuffle_scans, filter_exec) = |
| planner.create_plan(&op, &mut vec![], 1).unwrap(); |
| |
| assert_eq!("FilterExec", filter_exec.native_plan.name()); |
| assert_eq!(1, filter_exec.children.len()); |
| assert_eq!(0, filter_exec.additional_native_plans.len()); |
| } |
| |
| #[test] |
| fn spark_plan_metrics_hash_join() { |
| let op_scan = create_scan(); |
| let op_join = Operator { |
| plan_id: 0, |
| children: vec![op_scan.clone(), op_scan.clone()], |
| op_struct: Some(OpStruct::HashJoin(spark_operator::HashJoin { |
| left_join_keys: vec![create_bound_reference(0)], |
| right_join_keys: vec![create_bound_reference(0)], |
| join_type: 0, |
| condition: None, |
| build_side: 0, |
| null_aware_anti_join: false, |
| })), |
| }; |
| |
| let planner = PhysicalPlanner::default(); |
| |
| let (_scans, _shuffle_scans, hash_join_exec) = |
| planner.create_plan(&op_join, &mut vec![], 1).unwrap(); |
| |
| assert_eq!("HashJoinExec", hash_join_exec.native_plan.name()); |
| assert_eq!(2, hash_join_exec.children.len()); |
| assert_eq!("ScanExec", hash_join_exec.children[0].native_plan.name()); |
| assert_eq!("ScanExec", hash_join_exec.children[1].native_plan.name()); |
| } |
| |
| fn create_bound_reference(index: i32) -> Expr { |
| Expr { |
| expr_struct: Some(Bound(spark_expression::BoundReference { |
| index, |
| datatype: Some(create_proto_datatype()), |
| })), |
| query_context: None, |
| expr_id: None, |
| } |
| } |
| |
| fn create_scan() -> Operator { |
| Operator { |
| plan_id: 0, |
| children: vec![], |
| op_struct: Some(OpStruct::Scan(spark_operator::Scan { |
| fields: vec![create_proto_datatype()], |
| source: "".to_string(), |
| })), |
| } |
| } |
| |
| fn create_proto_datatype() -> spark_expression::DataType { |
| spark_expression::DataType { |
| type_id: 3, |
| type_info: None, |
| } |
| } |
| |
| #[test] |
| fn test_create_array() { |
| let session_ctx = SessionContext::new(); |
| session_ctx.register_udf(ScalarUDF::from( |
| datafusion_functions_nested::make_array::MakeArray::new(), |
| )); |
| let task_ctx = session_ctx.task_ctx(); |
| let planner = PhysicalPlanner::new(Arc::from(session_ctx), 0); |
| |
| // Create a plan for |
| // ProjectionExec: expr=[make_array(col_0@0) as col_0] |
| // ScanExec: source=[CometScan parquet (unknown)], schema=[col_0: Int32] |
| let op_scan = Operator { |
| plan_id: 0, |
| children: vec![], |
| op_struct: Some(OpStruct::Scan(spark_operator::Scan { |
| fields: vec![ |
| spark_expression::DataType { |
| type_id: 3, // Int32 |
| type_info: None, |
| }, |
| spark_expression::DataType { |
| type_id: 3, // Int32 |
| type_info: None, |
| }, |
| spark_expression::DataType { |
| type_id: 3, // Int32 |
| type_info: None, |
| }, |
| ], |
| source: "".to_string(), |
| })), |
| }; |
| |
| let array_col = spark_expression::Expr { |
| expr_struct: Some(Bound(spark_expression::BoundReference { |
| index: 0, |
| datatype: Some(spark_expression::DataType { |
| type_id: 3, |
| type_info: None, |
| }), |
| })), |
| query_context: None, |
| expr_id: None, |
| }; |
| |
| let array_col_1 = spark_expression::Expr { |
| expr_struct: Some(Bound(spark_expression::BoundReference { |
| index: 1, |
| datatype: Some(spark_expression::DataType { |
| type_id: 3, |
| type_info: None, |
| }), |
| })), |
| query_context: None, |
| expr_id: None, |
| }; |
| |
| let projection = Operator { |
| children: vec![op_scan], |
| plan_id: 0, |
| op_struct: Some(OpStruct::Projection(spark_operator::Projection { |
| project_list: vec![spark_expression::Expr { |
| expr_struct: Some(ExprStruct::ScalarFunc(spark_expression::ScalarFunc { |
| func: "make_array".to_string(), |
| args: vec![array_col, array_col_1], |
| return_type: None, |
| fail_on_error: false, |
| })), |
| query_context: None, |
| expr_id: None, |
| }], |
| })), |
| }; |
| |
| let (mut scans, _shuffle_scans, datafusion_plan) = |
| planner.create_plan(&projection, &mut vec![], 1).unwrap(); |
| |
| let mut stream = datafusion_plan.native_plan.execute(0, task_ctx).unwrap(); |
| |
| let runtime = tokio::runtime::Runtime::new().unwrap(); |
| let (tx, mut rx) = mpsc::channel(1); |
| |
| // Separate thread to send the EOF signal once we've processed the only input batch |
| runtime.spawn(async move { |
| let a = Int32Array::from(vec![0, 3]); |
| let b = Int32Array::from(vec![1, 4]); |
| let c = Int32Array::from(vec![2, 5]); |
| let input_batch1 = InputBatch::Batch(vec![Arc::new(a), Arc::new(b), Arc::new(c)], 2); |
| let input_batch2 = InputBatch::EOF; |
| |
| let batches = vec![input_batch1, input_batch2]; |
| |
| for batch in batches.into_iter() { |
| tx.send(batch).await.unwrap(); |
| } |
| }); |
| |
| runtime.block_on(async move { |
| loop { |
| let batch = rx.recv().await.unwrap(); |
| scans[0].set_input_batch(batch); |
| match poll!(stream.next()) { |
| Poll::Ready(Some(batch)) => { |
| assert!(batch.is_ok(), "got error {}", batch.unwrap_err()); |
| let batch = batch.unwrap(); |
| assert_eq!(batch.num_rows(), 2); |
| let expected = [ |
| "+--------+", |
| "| col_0 |", |
| "+--------+", |
| "| [0, 1] |", |
| "| [3, 4] |", |
| "+--------+", |
| ]; |
| assert_batches_eq!(expected, &[batch]); |
| } |
| Poll::Ready(None) => { |
| break; |
| } |
| _ => {} |
| } |
| } |
| }); |
| } |
| |
| #[test] |
| fn test_array_repeat() { |
| let session_ctx = SessionContext::new(); |
| let task_ctx = session_ctx.task_ctx(); |
| let planner = PhysicalPlanner::new(Arc::from(session_ctx), 0); |
| |
| // Mock scan operator with 3 INT32 columns |
| let op_scan = Operator { |
| plan_id: 0, |
| children: vec![], |
| op_struct: Some(OpStruct::Scan(spark_operator::Scan { |
| fields: vec![ |
| spark_expression::DataType { |
| type_id: 3, // Int32 |
| type_info: None, |
| }, |
| spark_expression::DataType { |
| type_id: 3, // Int32 |
| type_info: None, |
| }, |
| spark_expression::DataType { |
| type_id: 3, // Int32 |
| type_info: None, |
| }, |
| ], |
| source: "".to_string(), |
| })), |
| }; |
| |
| // Mock expression to read a INT32 column with position 0 |
| let array_col = spark_expression::Expr { |
| expr_struct: Some(Bound(spark_expression::BoundReference { |
| index: 0, |
| datatype: Some(spark_expression::DataType { |
| type_id: 3, |
| type_info: None, |
| }), |
| })), |
| query_context: None, |
| expr_id: None, |
| }; |
| |
| // Mock expression to read a INT32 column with position 1 |
| let array_col_1 = spark_expression::Expr { |
| expr_struct: Some(Bound(spark_expression::BoundReference { |
| index: 1, |
| datatype: Some(spark_expression::DataType { |
| type_id: 3, |
| type_info: None, |
| }), |
| })), |
| query_context: None, |
| expr_id: None, |
| }; |
| |
| // Make a projection operator with array_repeat(array_col, array_col_1) |
| let projection = Operator { |
| children: vec![op_scan], |
| plan_id: 0, |
| op_struct: Some(OpStruct::Projection(spark_operator::Projection { |
| project_list: vec![spark_expression::Expr { |
| expr_struct: Some(ExprStruct::ScalarFunc(spark_expression::ScalarFunc { |
| func: "array_repeat".to_string(), |
| args: vec![array_col, array_col_1], |
| return_type: None, |
| fail_on_error: false, |
| })), |
| query_context: None, |
| expr_id: None, |
| }], |
| })), |
| }; |
| |
| // Create a physical plan |
| let (mut scans, _shuffle_scans, datafusion_plan) = |
| planner.create_plan(&projection, &mut vec![], 1).unwrap(); |
| |
| // Start executing the plan in a separate thread |
| // The plan waits for incoming batches and emitting result as input comes |
| let mut stream = datafusion_plan.native_plan.execute(0, task_ctx).unwrap(); |
| |
| let runtime = tokio::runtime::Runtime::new().unwrap(); |
| // create async channel |
| let (tx, mut rx) = mpsc::channel(1); |
| |
| // Send data as input to the plan being executed in a separate thread |
| runtime.spawn(async move { |
| // create data batch |
| // 0, 1, 2 |
| // 3, 4, 5 |
| // 6, null, null |
| let a = Int32Array::from(vec![Some(0), Some(3), Some(6)]); |
| let b = Int32Array::from(vec![Some(1), Some(4), None]); |
| let c = Int32Array::from(vec![Some(2), Some(5), None]); |
| let input_batch1 = InputBatch::Batch(vec![Arc::new(a), Arc::new(b), Arc::new(c)], 3); |
| let input_batch2 = InputBatch::EOF; |
| |
| let batches = vec![input_batch1, input_batch2]; |
| |
| for batch in batches.into_iter() { |
| tx.send(batch).await.unwrap(); |
| } |
| }); |
| |
| // Wait for the plan to finish executing and assert the result |
| runtime.block_on(async move { |
| loop { |
| let batch = rx.recv().await.unwrap(); |
| scans[0].set_input_batch(batch); |
| match poll!(stream.next()) { |
| Poll::Ready(Some(batch)) => { |
| assert!(batch.is_ok(), "got error {}", batch.unwrap_err()); |
| let batch = batch.unwrap(); |
| let expected = [ |
| "+--------------+", |
| "| col_0 |", |
| "+--------------+", |
| "| [0] |", |
| "| [3, 3, 3, 3] |", |
| "| |", |
| "+--------------+", |
| ]; |
| assert_batches_eq!(expected, &[batch]); |
| } |
| Poll::Ready(None) => { |
| break; |
| } |
| _ => {} |
| } |
| } |
| }); |
| } |
| |
| /// Executes a `test_data_query` SQL query |
| /// and saves the result into a temp folder using parquet format |
| /// Read the file back to the memory using a custom schema |
| async fn make_parquet_data( |
| test_data_query: &str, |
| read_schema: Schema, |
| ) -> Result<RecordBatch, DataFusionError> { |
| let session_ctx = SessionContext::new(); |
| |
| // generate test data in the temp folder |
| let tmp_dir = TempDir::new()?; |
| let test_path = tmp_dir.path().to_str().unwrap(); |
| |
| let plan = session_ctx |
| .sql(test_data_query) |
| .await? |
| .create_physical_plan() |
| .await?; |
| |
| // Write a parquet file into temp folder |
| session_ctx.write_parquet(plan, test_path, None).await?; |
| |
| // Register all parquet with temp data as file groups |
| let mut file_groups: Vec<FileGroup> = vec![]; |
| for entry in std::fs::read_dir(test_path)? { |
| let entry = entry?; |
| let path = entry.path(); |
| |
| if path.extension().and_then(|ext| ext.to_str()) == Some("parquet") { |
| if let Some(path_str) = path.to_str() { |
| file_groups.push(FileGroup::new(vec![PartitionedFile::from_path( |
| path_str.into(), |
| )?])); |
| } |
| } |
| } |
| |
| let source = Arc::new( |
| ParquetSource::new(Arc::new(read_schema.clone())) |
| .with_table_parquet_options(TableParquetOptions::new()), |
| ) as Arc<dyn FileSource>; |
| |
| let spark_parquet_options = SparkParquetOptions::new(EvalMode::Legacy, "UTC", false); |
| |
| let expr_adapter_factory: Arc<dyn PhysicalExprAdapterFactory> = Arc::new( |
| SparkPhysicalExprAdapterFactory::new(spark_parquet_options, None), |
| ); |
| |
| let object_store_url = ObjectStoreUrl::local_filesystem(); |
| let file_scan_config = FileScanConfigBuilder::new(object_store_url, source) |
| .with_expr_adapter(Some(expr_adapter_factory)) |
| .with_file_groups(file_groups) |
| .build(); |
| |
| // Run native read |
| let scan = Arc::new(DataSourceExec::new(Arc::new(file_scan_config.clone()))); |
| let result: Vec<_> = scan.execute(0, session_ctx.task_ctx())?.collect().await; |
| Ok(result.first().unwrap().as_ref().unwrap().clone()) |
| } |
| |
| /* |
| Testing a nested types scenario |
| |
| select arr[0].a, arr[0].c from ( |
| select array(named_struct('a', 1, 'b', 'n', 'c', 'x')) arr) |
| */ |
| #[tokio::test] |
| async fn test_nested_types_list_of_struct_by_index() -> Result<(), DataFusionError> { |
| let test_data = "select make_array(named_struct('a', 1, 'b', 'n', 'c', 'x')) c0"; |
| |
| // Define schema Comet reads with |
| let required_schema = Schema::new(Fields::from(vec![Field::new( |
| "c0", |
| DataType::List( |
| Field::new( |
| "element", |
| DataType::Struct(Fields::from(vec![ |
| Field::new("a", DataType::Int32, true), |
| Field::new("c", DataType::Utf8, true), |
| ] as Vec<Field>)), |
| true, |
| ) |
| .into(), |
| ), |
| true, |
| )])); |
| |
| let actual = make_parquet_data(test_data, required_schema).await?; |
| |
| let expected = [ |
| "+----------------+", |
| "| c0 |", |
| "+----------------+", |
| "| [{a: 1, c: x}] |", |
| "+----------------+", |
| ]; |
| assert_batches_eq!(expected, &[actual]); |
| |
| Ok(()) |
| } |
| |
| /* |
| Testing a nested types scenario map[struct, struct] |
| |
| select map_keys(m).b from ( |
| select map(named_struct('a', 1, 'b', 'n', 'c', 'x'), named_struct('a', 1, 'b', 'n', 'c', 'x')) m |
| */ |
| #[tokio::test] |
| async fn test_nested_types_map_keys() -> Result<(), DataFusionError> { |
| let test_data = "select map([named_struct('a', 1, 'b', 'n', 'c', 'x')], [named_struct('a', 2, 'b', 'm', 'c', 'y')]) c0"; |
| let required_schema = Schema::new(Fields::from(vec![Field::new( |
| "c0", |
| DataType::Map( |
| Field::new( |
| "entries", |
| DataType::Struct(Fields::from(vec![ |
| Field::new( |
| "key", |
| DataType::Struct(Fields::from(vec![Field::new( |
| "b", |
| DataType::Utf8, |
| true, |
| )])), |
| false, |
| ), |
| Field::new( |
| "value", |
| DataType::Struct(Fields::from(vec![ |
| Field::new("a", DataType::Int64, true), |
| Field::new("b", DataType::Utf8, true), |
| Field::new("c", DataType::Utf8, true), |
| ])), |
| true, |
| ), |
| ] as Vec<Field>)), |
| false, |
| ) |
| .into(), |
| false, |
| ), |
| true, |
| )])); |
| |
| let actual = make_parquet_data(test_data, required_schema).await?; |
| let expected = [ |
| "+------------------------------+", |
| "| c0 |", |
| "+------------------------------+", |
| "| {{b: n}: {a: 2, b: m, c: y}} |", |
| "+------------------------------+", |
| ]; |
| assert_batches_eq!(expected, std::slice::from_ref(&actual)); |
| |
| Ok(()) |
| } |
| |
| // Read struct using schema where schema fields do not overlap with |
| // struct fields |
| #[tokio::test] |
| async fn test_nested_types_extract_missing_struct_names_non_overlap( |
| ) -> Result<(), DataFusionError> { |
| let test_data = "select named_struct('a', 1, 'b', 'abc') c0"; |
| let required_schema = Schema::new(Fields::from(vec![Field::new( |
| "c0", |
| DataType::Struct(Fields::from(vec![ |
| Field::new("c", DataType::Int64, true), |
| Field::new("d", DataType::Utf8, true), |
| ])), |
| true, |
| )])); |
| let actual = make_parquet_data(test_data, required_schema).await?; |
| let expected = ["+----+", "| c0 |", "+----+", "| |", "+----+"]; |
| assert_batches_eq!(expected, &[actual]); |
| Ok(()) |
| } |
| |
| // Read struct using custom schema to read just a single field from the struct |
| #[tokio::test] |
| async fn test_nested_types_extract_missing_struct_names_single_field( |
| ) -> Result<(), DataFusionError> { |
| let test_data = "select named_struct('a', 1, 'b', 'abc') c0"; |
| let required_schema = Schema::new(Fields::from(vec![Field::new( |
| "c0", |
| DataType::Struct(Fields::from(vec![Field::new("a", DataType::Int64, true)])), |
| true, |
| )])); |
| let actual = make_parquet_data(test_data, required_schema).await?; |
| let expected = [ |
| "+--------+", |
| "| c0 |", |
| "+--------+", |
| "| {a: 1} |", |
| "+--------+", |
| ]; |
| assert_batches_eq!(expected, &[actual]); |
| Ok(()) |
| } |
| |
| // Read struct using custom schema to handle a missing field |
| #[tokio::test] |
| async fn test_nested_types_extract_missing_struct_names_missing_field( |
| ) -> Result<(), DataFusionError> { |
| let test_data = "select named_struct('a', 1, 'b', 'abc') c0"; |
| let required_schema = Schema::new(Fields::from(vec![Field::new( |
| "c0", |
| DataType::Struct(Fields::from(vec![ |
| Field::new("a", DataType::Int64, true), |
| Field::new("x", DataType::Int64, true), |
| ])), |
| true, |
| )])); |
| let actual = make_parquet_data(test_data, required_schema).await?; |
| let expected = [ |
| "+-------------+", |
| "| c0 |", |
| "+-------------+", |
| "| {a: 1, x: } |", |
| "+-------------+", |
| ]; |
| assert_batches_eq!(expected, &[actual]); |
| Ok(()) |
| } |
| |
| #[tokio::test] |
| async fn test_literal_to_list() -> Result<(), DataFusionError> { |
| /* |
| [ |
| [ |
| [1, 2, 3], |
| [4, 5, 6], |
| [7, 8, 9, null], |
| [], |
| null |
| ], |
| [ |
| [10, null, 12] |
| ], |
| null, |
| [] |
| ] |
| */ |
| let data = ListLiteral { |
| list_values: vec![ |
| ListLiteral { |
| list_values: vec![ |
| ListLiteral { |
| int_values: vec![1, 2, 3], |
| null_mask: vec![true, true, true], |
| ..Default::default() |
| }, |
| ListLiteral { |
| int_values: vec![4, 5, 6], |
| null_mask: vec![true, true, true], |
| ..Default::default() |
| }, |
| ListLiteral { |
| int_values: vec![7, 8, 9, 0], |
| null_mask: vec![true, true, true, false], |
| ..Default::default() |
| }, |
| ListLiteral { |
| ..Default::default() |
| }, |
| ListLiteral { |
| ..Default::default() |
| }, |
| ], |
| null_mask: vec![true, true, true, false, true], |
| ..Default::default() |
| }, |
| ListLiteral { |
| list_values: vec![ListLiteral { |
| int_values: vec![10, 0, 11], |
| null_mask: vec![true, false, true], |
| ..Default::default() |
| }], |
| null_mask: vec![true], |
| ..Default::default() |
| }, |
| ListLiteral { |
| ..Default::default() |
| }, |
| ListLiteral { |
| ..Default::default() |
| }, |
| ], |
| null_mask: vec![true, true, false, true], |
| ..Default::default() |
| }; |
| |
| let nested_type = DataType::List(FieldRef::from(Field::new( |
| "item", |
| DataType::List( |
| Field::new( |
| "item", |
| DataType::List( |
| Field::new( |
| "item", |
| DataType::Int32, |
| true, // Int32 nullable |
| ) |
| .into(), |
| ), |
| true, // inner list nullable |
| ) |
| .into(), |
| ), |
| true, // outer list nullable |
| ))); |
| |
| let array = literal_to_array_ref(nested_type, data)?; |
| |
| // Top-level should be ListArray<ListArray<Int32>> |
| let list_outer = array.as_any().downcast_ref::<ListArray>().unwrap(); |
| assert_eq!(list_outer.len(), 4); |
| |
| // First outer element: ListArray<Int32> |
| let first_elem = list_outer.value(0); |
| let list_inner = first_elem.as_any().downcast_ref::<ListArray>().unwrap(); |
| assert_eq!(list_inner.len(), 5); |
| |
| // Inner values |
| let v0 = list_inner.value(0); |
| let vals0 = v0.as_any().downcast_ref::<Int32Array>().unwrap(); |
| assert_eq!(vals0.values(), &[1, 2, 3]); |
| |
| let v1 = list_inner.value(1); |
| let vals1 = v1.as_any().downcast_ref::<Int32Array>().unwrap(); |
| assert_eq!(vals1.values(), &[4, 5, 6]); |
| |
| let v2 = list_inner.value(2); |
| let vals2 = v2.as_any().downcast_ref::<Int32Array>().unwrap(); |
| assert_eq!(vals2.values(), &[7, 8, 9, 0]); |
| |
| // Second outer element |
| let second_elem = list_outer.value(1); |
| let list_inner2 = second_elem.as_any().downcast_ref::<ListArray>().unwrap(); |
| assert_eq!(list_inner2.len(), 1); |
| |
| let v3 = list_inner2.value(0); |
| let vals3 = v3.as_any().downcast_ref::<Int32Array>().unwrap(); |
| assert_eq!(vals3.values(), &[10, 0, 11]); |
| |
| Ok(()) |
| } |
| |
| /// Test that reproduces the "Cast error: Casting from Int8 to Date32 not supported" error |
| /// that occurs when performing date subtraction with Int8 (TINYINT) values. |
| /// This corresponds to the Scala test "date_sub with int arrays" in CometExpressionSuite. |
| /// |
| /// The error occurs because DataFusion's BinaryExpr tries to cast Int8 to Date32 |
| /// when evaluating date - int8, but this cast is not supported. |
| #[test] |
| fn test_date_sub_with_int8_cast_error() { |
| use arrow::array::Date32Array; |
| |
| let planner = PhysicalPlanner::default(); |
| let row_count = 3; |
| |
| // Create a Scan operator with Date32 (DATE) and Int8 (TINYINT) columns |
| let op_scan = Operator { |
| plan_id: 0, |
| children: vec![], |
| op_struct: Some(OpStruct::Scan(spark_operator::Scan { |
| fields: vec![ |
| spark_expression::DataType { |
| type_id: 12, // DATE (Date32) |
| type_info: None, |
| }, |
| spark_expression::DataType { |
| type_id: 1, // INT8 (TINYINT) |
| type_info: None, |
| }, |
| ], |
| source: "".to_string(), |
| })), |
| }; |
| |
| // Create bound reference for the DATE column (index 0) |
| let date_col = spark_expression::Expr { |
| expr_struct: Some(Bound(spark_expression::BoundReference { |
| index: 0, |
| datatype: Some(spark_expression::DataType { |
| type_id: 12, // DATE |
| type_info: None, |
| }), |
| })), |
| expr_id: None, |
| query_context: None, |
| }; |
| |
| // Create bound reference for the INT8 column (index 1) |
| let int8_col = spark_expression::Expr { |
| expr_struct: Some(Bound(spark_expression::BoundReference { |
| index: 1, |
| datatype: Some(spark_expression::DataType { |
| type_id: 1, // INT8 |
| type_info: None, |
| }), |
| })), |
| expr_id: None, |
| query_context: None, |
| }; |
| |
| // Create a Subtract expression: date_col - int8_col |
| // This is equivalent to the SQL: SELECT _20 - _2 FROM tbl (date_sub operation) |
| // In the protobuf, subtract uses MathExpr type |
| let subtract_expr = spark_expression::Expr { |
| expr_struct: Some(ExprStruct::Subtract(Box::new(spark_expression::MathExpr { |
| left: Some(Box::new(date_col)), |
| right: Some(Box::new(int8_col)), |
| return_type: Some(spark_expression::DataType { |
| type_id: 12, // DATE - result should be DATE |
| type_info: None, |
| }), |
| eval_mode: 0, // Legacy mode |
| check_divide_overflow: false, |
| }))), |
| expr_id: None, |
| query_context: None, |
| }; |
| |
| // Create a projection operator with the subtract expression |
| let projection = Operator { |
| children: vec![op_scan], |
| plan_id: 1, |
| op_struct: Some(OpStruct::Projection(spark_operator::Projection { |
| project_list: vec![subtract_expr], |
| })), |
| }; |
| |
| // Create the physical plan |
| let (mut scans, _shuffle_scans, datafusion_plan) = |
| planner.create_plan(&projection, &mut vec![], 1).unwrap(); |
| |
| // Create test data: Date32 and Int8 columns |
| let date_array = Date32Array::from(vec![Some(19000), Some(19001), Some(19002)]); |
| let int8_array = Int8Array::from(vec![Some(1i8), Some(2i8), Some(3i8)]); |
| |
| // Set input batch for the scan |
| let input_batch = |
| InputBatch::Batch(vec![Arc::new(date_array), Arc::new(int8_array)], row_count); |
| scans[0].set_input_batch(input_batch); |
| |
| let session_ctx = SessionContext::new(); |
| let task_ctx = session_ctx.task_ctx(); |
| let mut stream = datafusion_plan.native_plan.execute(0, task_ctx).unwrap(); |
| |
| let runtime = tokio::runtime::Runtime::new().unwrap(); |
| let (tx, mut rx) = mpsc::channel(1); |
| |
| // Separate thread to send the EOF signal once we've processed the only input batch |
| runtime.spawn(async move { |
| // Create test data again for the second batch |
| let date_array = Date32Array::from(vec![Some(19000), Some(19001), Some(19002)]); |
| let int8_array = Int8Array::from(vec![Some(1i8), Some(2i8), Some(3i8)]); |
| let input_batch1 = |
| InputBatch::Batch(vec![Arc::new(date_array), Arc::new(int8_array)], row_count); |
| let input_batch2 = InputBatch::EOF; |
| |
| let batches = vec![input_batch1, input_batch2]; |
| |
| for batch in batches.into_iter() { |
| tx.send(batch).await.unwrap(); |
| } |
| }); |
| |
| runtime.block_on(async move { |
| loop { |
| let batch = rx.recv().await.unwrap(); |
| scans[0].set_input_batch(batch); |
| match poll!(stream.next()) { |
| Poll::Ready(Some(result)) => { |
| // We expect success - the Int8 should be automatically cast to Int32 |
| assert!( |
| result.is_ok(), |
| "Expected success for date - int8 operation but got error: {:?}", |
| result.unwrap_err() |
| ); |
| |
| let batch = result.unwrap(); |
| assert_eq!(batch.num_rows(), row_count); |
| |
| // The result should be Date32 type |
| assert_eq!(batch.column(0).data_type(), &DataType::Date32); |
| |
| // Verify the values: 19000-1=18999, 19001-2=18999, 19002-3=18999 |
| let date_array = batch |
| .column(0) |
| .as_any() |
| .downcast_ref::<Date32Array>() |
| .unwrap(); |
| assert_eq!(date_array.value(0), 18999); // 19000 - 1 |
| assert_eq!(date_array.value(1), 18999); // 19001 - 2 |
| assert_eq!(date_array.value(2), 18999); // 19002 - 3 |
| } |
| Poll::Ready(None) => { |
| break; |
| } |
| _ => {} |
| } |
| } |
| }); |
| } |
| } |