blob: 76d9121b303d211968a55c9a07e4b8ffef0e581e [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
use std::cmp::Ordering;
use std::sync::Arc;
use crate::float_semantics::{float_gt, float_lt, spark_comparator};
use arrow::array::{
make_array, new_empty_array, Array, ArrayRef, AsArray, BooleanArray, ListArray,
MutableArrayData, PrimitiveArray, PrimitiveBuilder, StructArray, UInt32Array,
};
use arrow::buffer::{NullBuffer, OffsetBuffer};
use arrow::compute::take;
use arrow::datatypes::{ArrowPrimitiveType, DataType, Float32Type, Float64Type};
use datafusion::common::{exec_err, Result, ScalarValue};
use datafusion::functions_nested::min_max::{array_max_udf, array_min_udf};
use datafusion::logical_expr::{
ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature,
};
use num::Float;
/// Spark's array_min/array_max retain the first non-null value on an ordering tie.
/// In particular, signed zeros compare equal and all NaNs compare equal and greater
/// than non-NaNs. Nested arrays and structs use the same ordering, with nulls first.
#[derive(Debug, Hash, Eq, PartialEq)]
pub struct SparkArrayExtrema {
is_min: bool,
datafusion_udf: Arc<ScalarUDF>,
}
impl SparkArrayExtrema {
pub fn new(is_min: bool) -> Self {
Self {
is_min,
// Capture the original implementation, not a registry lookup: these UDFs
// replace the DataFusion names in Comet's function registry.
datafusion_udf: if is_min {
array_min_udf()
} else {
array_max_udf()
},
}
}
}
impl ScalarUDFImpl for SparkArrayExtrema {
fn name(&self) -> &str {
if self.is_min {
"array_min"
} else {
"array_max"
}
}
fn signature(&self) -> &Signature {
self.datafusion_udf.signature()
}
fn return_type(&self, arg_types: &[DataType]) -> Result<DataType> {
self.datafusion_udf.return_type(arg_types)
}
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result<ColumnarValue> {
let [input] = args.args.as_slice() else {
return exec_err!("{} takes exactly one argument", self.name());
};
let element_type = self.return_type(&[input.data_type()])?;
// DataFusion's non-primitive path reconstructs an array from scalars, which
// cannot infer a type from an empty iterator. Keep the declared element type.
if matches!(input, ColumnarValue::Array(array) if array.is_empty()) {
return Ok(ColumnarValue::Array(new_empty_array(&element_type)));
}
if !matches!(
element_type,
DataType::Float32 | DataType::Float64 | DataType::List(_) | DataType::Struct(_)
) {
return self.datafusion_udf.invoke_with_args(args);
}
let is_scalar = matches!(input, ColumnarValue::Scalar(_));
let array = match input {
ColumnarValue::Array(array) => Arc::clone(array),
ColumnarValue::Scalar(value) => value.to_array()?,
};
// Spark arrays use Arrow's 32-bit List layout.
let result = array_extrema(array.as_list::<i32>(), self.is_min)?;
if is_scalar {
Ok(ColumnarValue::Scalar(ScalarValue::try_from_array(
&result, 0,
)?))
} else {
Ok(ColumnarValue::Array(result))
}
}
}
fn array_extrema(array: &ListArray, is_min: bool) -> Result<ArrayRef> {
match array.value_type() {
DataType::Float32 => Ok(Arc::new(float_extrema::<Float32Type>(array, is_min))),
DataType::Float64 => Ok(Arc::new(float_extrema::<Float64Type>(array, is_min))),
_ => nested_extrema(array, is_min),
}
}
/// Scan the flat value buffer for every list length. Arrow's float min/max kernels
/// use a different ordering, so long lists must not switch to those kernels.
/// Checking only Arrow's winner cannot establish that its answer agrees with Spark:
/// for max([-NaN, 1.0]), Arrow's total ordering selects 1.0 while Spark selects NaN.
/// A corrective scan triggered only by a zero or NaN winner would miss that case.
fn float_extrema<T: ArrowPrimitiveType>(array: &ListArray, is_min: bool) -> PrimitiveArray<T>
where
T::Native: Float,
{
let values = array.values().as_primitive::<T>();
let buffer = values.values();
let nulls = values.nulls();
let mut result = PrimitiveBuilder::<T>::with_capacity(array.len());
for (row, offsets) in array.offsets().windows(2).enumerate() {
let mut best: Option<T::Native> = None;
if array.is_valid(row) {
let start = offsets[0] as usize;
let end = offsets[1] as usize;
for (index, &candidate) in buffer[start..end].iter().enumerate() {
if nulls.is_some_and(|nulls| nulls.is_null(start + index)) {
continue;
}
let replace = match best {
None => true,
Some(current) if is_min => float_lt(candidate, current),
Some(current) => float_gt(candidate, current),
};
if replace {
// Copy the winning value, never normalize its zero sign or NaN bits.
best = Some(candidate);
}
}
}
result.append_option(best);
}
result.finish()
}
fn nested_extrema(array: &ListArray, is_min: bool) -> Result<ArrayRef> {
let values = array.values();
let compare = spark_comparator(values.as_ref(), values.as_ref())?;
let nulls = values.nulls();
let ordering = if is_min {
Ordering::Less
} else {
Ordering::Greater
};
let mut indices = Vec::with_capacity(array.len());
for (row, offsets) in array.offsets().windows(2).enumerate() {
let mut best = None;
if array.is_valid(row) {
for candidate in offsets[0] as usize..offsets[1] as usize {
if nulls.is_some_and(|nulls| nulls.is_null(candidate)) {
continue;
}
if best.is_none_or(|current| compare(candidate, current) == ordering) {
best = Some(candidate);
}
}
}
indices.push(best.map(|index| index as u32));
}
take_extrema_values(values, &UInt32Array::from(indices))
}
#[inline(never)]
fn take_extrema_values(values: &ArrayRef, indices: &UInt32Array) -> Result<ArrayRef> {
match values.data_type() {
DataType::List(field) if !field.data_type().is_nested() => {
let lists = values.as_list::<i32>();
// A selected struct can contain a null list field with hidden child values.
// Combine source-list validity with null indices before counting or copying children.
let nulls = match lists.nulls().filter(|nulls| nulls.null_count() > 0) {
Some(nulls) => {
let validity = BooleanArray::new(nulls.inner().clone(), None);
let selected = take(&validity, indices, None)?;
let selected = selected.as_boolean();
let valid_values = NullBuffer::new(selected.values().clone());
NullBuffer::union(Some(&valid_values), selected.nulls())
}
None => indices.nulls().cloned(),
}
.filter(|nulls| nulls.null_count() > 0);
let source_offsets = lists.value_offsets();
let length =
|index: usize| (source_offsets[index + 1] - source_offsets[index]) as usize;
// Reserve selected lengths, not the average length of all candidates: large losing
// lists must not inflate even temporary child-buffer allocations.
let capacity = match &nulls {
Some(nulls) => nulls
.valid_indices()
.map(|row| length(indices.value(row) as usize))
.sum(),
None => indices
.values()
.iter()
.map(|&index| length(index as usize))
.sum(),
};
let data = lists.values().to_data();
let mut child = MutableArrayData::new(vec![&data], false, capacity);
let mut offsets = Vec::with_capacity(indices.len() + 1);
offsets.push(0);
match &nulls {
Some(nulls) => {
for row in nulls.valid_indices() {
offsets.resize(row + 1, child.len() as i32);
let index = indices.value(row) as usize;
child.try_extend(
0,
source_offsets[index] as usize,
source_offsets[index + 1] as usize,
)?;
offsets.push(child.len() as i32);
}
offsets.resize(indices.len() + 1, child.len() as i32);
}
None => {
for &index in indices.values() {
let index = index as usize;
child.try_extend(
0,
source_offsets[index] as usize,
source_offsets[index + 1] as usize,
)?;
offsets.push(child.len() as i32);
}
}
}
// SAFETY: Offsets start at zero and increase with child.len(). Winners occupy
// disjoint input ranges, so their total fits the input's i32 offsets.
let offsets = unsafe { OffsetBuffer::new_unchecked(offsets.into()) };
Ok(Arc::new(ListArray::try_new(
Arc::clone(field),
offsets,
make_array(child.freeze()),
nulls,
)?))
}
DataType::List(_) => {
// Arrow's list take estimates child capacity from all input lists, including
// large losing candidates. Start empty so even temporary buffers grow only
// with selected values, including for sparse or recursively nested results.
let data = values.to_data();
let mut result = MutableArrayData::new(vec![&data], true, 0);
for index in indices.iter() {
match index.filter(|&index| values.is_valid(index as usize)) {
Some(index) => result.try_extend(0, index as usize, index as usize + 1)?,
None => result.try_extend_nulls(1)?,
}
}
Ok(make_array(result.freeze()))
}
DataType::Struct(fields) => {
let columns = values
.as_struct()
.columns()
.iter()
.map(|column| take_extrema_values(column, indices))
.collect::<Result<Vec<_>>>()?;
let nulls = indices
.iter()
.map(|index| index.is_some_and(|index| values.is_valid(index as usize)))
.collect::<NullBuffer>();
Ok(Arc::new(StructArray::try_new_with_length(
fields.clone(),
columns,
Some(nulls),
indices.len(),
)?))
}
_ => Ok(take(values.as_ref(), indices, None)?),
}
}
#[cfg(test)]
mod tests;