Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions native/common/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ pub enum SparkError {
#[error("[ARITHMETIC_OVERFLOW] {from_type} overflow. If necessary set \"spark.sql.ansi.enabled\" to \"false\" to bypass this error.")]
ArithmeticOverflow { from_type: String },

// Spark's checked date/timestamp conversions throw this even with ANSI disabled.
#[error("long overflow")]
LongOverflow,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wondering can ArithmeticOverflow be reused?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing ArithmeticOverflow maps to Spark's structured SparkArithmeticException with error class ARITHMETIC_OVERFLOW, including ANSI configuration advice.

For DATE to TIMESTAMP, Spark instead calls daysToMicros -> instantToMicros -> Math.multiplyExact, which throws a plain ArithmeticException("long overflow") even with ANSI disabled. Reusing the existing variant unchanged would change both the exception class and message, so LongOverflow preserves that distinction. The regressions check the exact class and message with ANSI both on and off.


#[error("[ARITHMETIC_OVERFLOW] Overflow in integral divide. Use 'try_divide' to tolerate overflow and return NULL instead. If necessary set \"spark.sql.ansi.enabled\" to \"false\" to bypass this error.")]
IntegralDivideOverflow,

Expand Down Expand Up @@ -269,6 +273,7 @@ impl SparkError {
SparkError::CastOverFlow { .. } => "CastOverFlow",
SparkError::CannotParseDecimal => "CannotParseDecimal",
SparkError::ArithmeticOverflow { .. } => "ArithmeticOverflow",
SparkError::LongOverflow => "LongOverflow",
SparkError::IntegralDivideOverflow => "IntegralDivideOverflow",
SparkError::DecimalSumOverflow { .. } => "DecimalSumOverflow",
SparkError::DivideByZero => "DivideByZero",
Expand Down Expand Up @@ -580,6 +585,8 @@ impl SparkError {
/// Returns the appropriate Spark exception class for this error
pub fn exception_class(&self) -> &'static str {
match self {
SparkError::LongOverflow => "java/lang/ArithmeticException",

// ArithmeticException
SparkError::DivideByZero
| SparkError::RemainderByZero
Expand Down Expand Up @@ -684,6 +691,7 @@ impl SparkError {
SparkError::RemainderByZero => Some("REMAINDER_BY_ZERO"),
SparkError::IntervalDividedByZero => Some("INTERVAL_DIVIDED_BY_ZERO"),
SparkError::ArithmeticOverflow { .. } => Some("ARITHMETIC_OVERFLOW"),
SparkError::LongOverflow => None,
SparkError::IntegralDivideOverflow => Some("ARITHMETIC_OVERFLOW"),
SparkError::DecimalSumOverflow { .. } => Some("ARITHMETIC_OVERFLOW"),
SparkError::BinaryArithmeticOverflow { .. } => Some("BINARY_ARITHMETIC_OVERFLOW"),
Expand Down Expand Up @@ -898,6 +906,22 @@ mod tests {
assert!(json.contains("\"errorClass\":\"REMAINDER_BY_ZERO\""));
}

#[test]
fn test_long_overflow_json() {
let error = SparkError::LongOverflow;
let parsed: serde_json::Value = serde_json::from_str(&error.to_json()).unwrap();
assert_eq!(
parsed,
serde_json::json!({
"errorType": "LongOverflow",
"errorClass": "",
"params": {},
})
);
assert_eq!(error.exception_class(), "java/lang/ArithmeticException");
assert_eq!(error.to_string(), "long overflow");
}

#[test]
fn test_binary_overflow_json() {
let error = SparkError::BinaryArithmeticOverflow {
Expand Down
195 changes: 190 additions & 5 deletions native/spark-expr/src/conversion_funcs/cast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ use arrow::error::ArrowError;
use arrow::{
array::{
cast::AsArray, Array, ArrayRef, Int16Array, Int32Array, Int64Array, Int8Array,
OffsetSizeTrait,
OffsetSizeTrait, UInt64Array,
},
compute::{cast_with_options, CastOptions},
compute::{cast_with_options, take, CastOptions},
record_batch::RecordBatch,
util::display::FormatOptions,
};
Expand Down Expand Up @@ -230,6 +230,7 @@ pub(crate) fn cast_array(
return Ok(Arc::new(array));
}

let array = prepare_nested_cast_input(array)?;
let array = array_with_timezone(array, cast_options.timezone.clone(), Some(to_type))?;
let eval_mode = cast_options.eval_mode;

Expand Down Expand Up @@ -418,6 +419,36 @@ pub(crate) fn cast_array(
Ok(spark_cast_postprocess(cast_result?, &from_type, to_type))
}

/// Recursive casts must not evaluate child values hidden by a null parent or outside a slice.
fn prepare_nested_cast_input(array: ArrayRef) -> DataFusionResult<ArrayRef> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like this is for:

Checked casts must also ignore values that are not logically present. A native IF(flag, s, NULL) can leave an overflowing date in the child buffer of a null struct or map. Casting that null container must return null, not evaluate the hidden date and throw. Sliced lists and maps can likewise retain unused child values before or after their visible rows.

so it seems like the root cause is native IF(flag, s, NULL) doesnt actually respect the null buffer? should we turn to fix it instead?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IF is respecting Arrow's null semantics here. For IF(flag, column, NULL), DataFusion can use Arrow's nullif to mask the parent without rewriting the child buffers. A null struct is allowed to retain nonnull child values, as described in the Arrow struct-validity specification.

The recursive cast must honor that enclosing validity before evaluating the children. We also need this normalization for sliced lists and maps, whose child arrays retain values outside the visible slice independently of IF. Changing only IF would leave those cases uncovered. The regressions cover both hidden parent values and slices, preserve visible-overflow errors, and verify that map keys remain nonnull.

let has_unused_values = match array.data_type() {
DataType::Struct(_) => false,
DataType::List(_) => {
let list = array.as_list::<i32>();
list.value_offsets()[0] != 0
|| list.value_offsets()[list.len()] as usize != list.values().len()
}
DataType::Map(_, _) => {
let map = array.as_map();
map.value_offsets()[0] != 0
|| map.value_offsets()[map.len()] as usize != map.entries().len()
}
_ => return Ok(array),
};
if array.null_count() == 0 && !has_unused_values {
return Ok(array);
}

// Nullable identity indices preserve the rows and their validity. Arrow's take propagates
// struct nulls to children and compacts list/map entries, without introducing null map keys
// or changing field nullability. Contiguous, non-null containers need no normalization.
let indices = UInt64Array::new(
(0..array.len() as u64).collect::<Vec<_>>().into(),
array.nulls().cloned(),
);
Ok(take(array.as_ref(), &indices, None)?)
}

/// Determines if DataFusion supports the given cast in a way that is
/// compatible with Spark
fn is_datafusion_spark_compatible(from_type: &DataType, to_type: &DataType) -> bool {
Expand Down Expand Up @@ -847,9 +878,11 @@ fn cast_binary_to_string<O: OffsetSizeTrait>(
#[cfg(test)]
mod tests {
use super::*;
use arrow::array::{BinaryArray, ListArray, NullArray, PrimitiveArray, StringArray};
use arrow::buffer::OffsetBuffer;
use arrow::datatypes::{Field, Fields, Int32Type, TimestampMicrosecondType};
use arrow::array::{
BinaryArray, Date32Array, ListArray, NullArray, PrimitiveArray, StringArray,
};
use arrow::buffer::{NullBuffer, OffsetBuffer};
use arrow::datatypes::{Field, Fields, Int32Type, TimeUnit, TimestampMicrosecondType};

#[test]
fn test_cast_to_dictionary_is_rejected() {
Expand Down Expand Up @@ -1123,6 +1156,158 @@ mod tests {
assert!(result.is_err());
}

fn nested_date_cast_inputs(
days: Vec<i32>,
nulls: Option<NullBuffer>,
) -> Vec<(ArrayRef, DataType)> {
let offsets = OffsetBuffer::new((0..=days.len() as i32).collect::<Vec<_>>().into());
let dates: ArrayRef = Arc::new(Date32Array::from(days));
let timestamp = DataType::Timestamp(TimeUnit::Microsecond, None);
let fields = Fields::from(vec![Field::new("d", DataType::Date32, false)]);
let entries_fields = Fields::from(vec![
Field::new("key", DataType::Date32, false),
Field::new("value", DataType::Date32, false),
]);
vec![
(
Arc::new(StructArray::new(
fields,
vec![Arc::clone(&dates)],
nulls.clone(),
)),
DataType::Struct(Fields::from(vec![Field::new(
"d",
timestamp.clone(),
false,
)])),
),
(
Arc::new(ListArray::new(
Arc::new(Field::new("element", DataType::Date32, false)),
offsets.clone(),
Arc::clone(&dates),
nulls.clone(),
)),
DataType::List(Arc::new(Field::new("element", timestamp.clone(), false))),
),
(
Arc::new(MapArray::new(
Arc::new(Field::new(
"entries",
DataType::Struct(entries_fields.clone()),
false,
)),
offsets,
StructArray::new(entries_fields, vec![Arc::clone(&dates), dates], None),
nulls,
false,
)),
DataType::Map(
Arc::new(Field::new(
"entries",
DataType::Struct(Fields::from(vec![
Field::new(
"key",
DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
false,
),
Field::new("value", timestamp, false),
])),
false,
)),
false,
),
),
]
}

fn assert_nested_timestamp_values(array: &ArrayRef, expected: &[i64]) {
array.to_data().validate_full().unwrap();
let values = match array.data_type() {
DataType::Struct(_) => array.as_struct().column(0),
DataType::List(_) => array.as_list::<i32>().values(),
DataType::Map(_, _) => {
let keys = array
.as_map()
.keys()
.as_primitive::<TimestampMicrosecondType>();
assert_eq!(keys.null_count(), 0);
assert_eq!(keys.values().as_ref(), expected);
array.as_map().values()
}
_ => unreachable!(),
};
assert_eq!(
values
.as_primitive::<TimestampMicrosecondType>()
.iter()
.flatten()
.collect::<Vec<_>>(),
expected,
);
}

#[test]
fn test_cast_nested_dates_ignores_null_parent_values() {
// Non-nullable children contain overflowing dates beneath null parent rows, as IF can
// produce without changing its child buffers. Map keys must remain non-nullable.
for (array, to_type) in nested_date_cast_inputs(
vec![i32::MAX, 0, i32::MIN, 1],
Some(NullBuffer::from(vec![false, true, false, true])),
) {
for mode in [EvalMode::Legacy, EvalMode::Ansi, EvalMode::Try] {
let options = SparkCastOptions::new(mode, "UTC", false);
let casted = cast_array(Arc::clone(&array), &to_type, &options).unwrap();
assert_eq!(casted.data_type(), &to_type);
assert_eq!(casted.len(), 4);
assert!(casted.is_null(0) && casted.is_null(2));
assert!(!casted.is_null(1) && !casted.is_null(3));
assert_nested_timestamp_values(&casted, &[0, 86_400_000_000]);

let all_null = cast_array(array.slice(0, 1), &to_type, &options).unwrap();
assert_eq!(all_null.len(), 1);
assert!(all_null.is_null(0));
assert_nested_timestamp_values(&all_null, &[]);
}
}
}

#[test]
fn test_cast_nested_dates_ignores_values_outside_slice() {
for (array, to_type) in nested_date_cast_inputs(vec![i32::MAX, 0, 1, i32::MIN], None) {
for mode in [EvalMode::Legacy, EvalMode::Ansi, EvalMode::Try] {
let options = SparkCastOptions::new(mode, "UTC", false);
// Lists/maps keep their original child buffer even when the parent is sliced.
let casted = cast_array(array.slice(1, 2), &to_type, &options).unwrap();
assert_eq!(casted.len(), 2);
assert_eq!(casted.null_count(), 0);
assert_nested_timestamp_values(&casted, &[0, 86_400_000_000]);

let empty = cast_array(array.slice(1, 0), &to_type, &options).unwrap();
assert_eq!(empty.len(), 0);
assert_nested_timestamp_values(&empty, &[]);
}
}
}

#[test]
fn test_cast_nested_dates_still_checks_visible_overflow() {
for (array, to_type) in nested_date_cast_inputs(
vec![i32::MAX, i32::MAX, 0],
Some(NullBuffer::from(vec![false, true, true])),
) {
for mode in [EvalMode::Legacy, EvalMode::Ansi] {
let error = cast_array(
Arc::clone(&array),
&to_type,
&SparkCastOptions::new(mode, "UTC", false),
)
.unwrap_err();
assert!(error.to_string().contains("long overflow"));
}
}
}

#[test]
fn test_cast_struct_to_struct_drop_column() {
let a: ArrayRef = Arc::new(Int32Array::from(vec![
Expand Down
Loading
Loading