diff --git a/datafusion/expr-common/src/casts.rs b/datafusion/expr-common/src/casts.rs index 320f7cec792d7..8c9616f7b8285 100644 --- a/datafusion/expr-common/src/casts.rs +++ b/datafusion/expr-common/src/casts.rs @@ -98,7 +98,19 @@ fn is_date_type(data_type: &DataType) -> bool { /// For example, `CAST(ts AS DATE) = DATE '2024-01-01'` means "any timestamp /// during that day", but unwrapping it to `ts = TIMESTAMP '2024-01-01 /// 00:00:00'` matches only midnight. +/// +/// An identity cast (`from_type == to_type`, e.g. `Date32 -> Date32`) never +/// changes comparison semantics and is therefore not lossy. This has to be +/// handled explicitly because `DataType::is_temporal()` is true for both +/// `Date32` and `Date64`, so `is_date_type(from) && to.is_temporal()` would +/// otherwise report an identity `Date -> Date` cast as lossy and block the +/// rewrite. Note this is deliberately limited to *identical* types: a genuine +/// `Date32 <-> Date64` cast changes units (days vs milliseconds) and must +/// still be treated as lossy here. fn is_lossy_temporal_cast(from_type: &DataType, to_type: &DataType) -> bool { + if from_type == to_type { + return false; + } (is_date_type(from_type) && to_type.is_temporal()) || (is_date_type(to_type) && from_type.is_temporal()) } @@ -813,6 +825,57 @@ mod tests { ); } + #[test] + fn test_try_cast_identity_date_allowed() { + // An identity Date cast (e.g. `CAST(date_col AS DATE)` where the column + // is already Date32) must fold: it never changes comparison semantics, + // so `try_cast_literal_to_type` should return the same value rather than + // treating it as a lossy temporal cast. + expect_cast( + ScalarValue::Date32(Some(19_723)), + DataType::Date32, + ExpectedCast::Value(ScalarValue::Date32(Some(19_723))), + ); + + expect_cast( + ScalarValue::Date64(Some(1_704_067_200_000)), + DataType::Date64, + ExpectedCast::Value(ScalarValue::Date64(Some(1_704_067_200_000))), + ); + + // is_lossy_temporal_cast must classify an identity cast as non-lossy. + assert!(!is_lossy_temporal_cast( + &DataType::Date32, + &DataType::Date32 + )); + assert!(!is_lossy_temporal_cast( + &DataType::Date64, + &DataType::Date64 + )); + } + + #[test] + fn test_try_cast_date32_date64_still_blocked() { + // `Date32` counts days and `Date64` counts milliseconds, but + // try_cast_numeric_literal uses mul = 1 for both, so a cross cast would + // convert units wrongly. The identity short-circuit must NOT open this + // up: Date32 <-> Date64 has to stay blocked. + assert!(is_lossy_temporal_cast(&DataType::Date32, &DataType::Date64)); + assert!(is_lossy_temporal_cast(&DataType::Date64, &DataType::Date32)); + + expect_cast( + ScalarValue::Date32(Some(1)), + DataType::Date64, + ExpectedCast::NoValue, + ); + + expect_cast( + ScalarValue::Date64(Some(86_400_000)), + DataType::Date32, + ExpectedCast::NoValue, + ); + } + #[test] fn test_timestamp_precision_narrowing_cast() { let ts_ns = DataType::Timestamp(TimeUnit::Nanosecond, None); diff --git a/datafusion/sqllogictest/test_files/simplify_expr.slt b/datafusion/sqllogictest/test_files/simplify_expr.slt index 58ec7a1b262c3..a291740b914f5 100644 --- a/datafusion/sqllogictest/test_files/simplify_expr.slt +++ b/datafusion/sqllogictest/test_files/simplify_expr.slt @@ -146,3 +146,36 @@ logical_plan physical_plan 01)ProjectionExec: expr=[column1@0 = 1 as opt1, column1@0 = 2 AND column1@0 != 2 as noopt1, column1@0 = 4 as opt2, column1@0 != 5 AND column1@0 = 5 as noopt2] 02)--DataSourceExec: partitions=1, partition_sizes=[1] + +# Identity Date cast in a comparison predicate. +# `cast(d AS date)` where `d` is already Date32 is an identity cast and should +# fold away, so the predicate compares against the bare column `d`. This enables +# downstream pruning / filter pushdown that expects a bare-column comparison. +statement ok +create table dates(d date) as values (DATE '2024-01-01'), (DATE '2024-01-02'); + +query TT +explain select d from dates where cast(d as date) = DATE '2024-01-01'; +---- +logical_plan +01)Filter: dates.d = Date32("2024-01-01") +02)--TableScan: dates projection=[d] +physical_plan +01)FilterExec: d@0 = 2024-01-01 +02)--DataSourceExec: partitions=1, partition_sizes=[1] + +# Identity Date cast inside an `IN` predicate. `IN` goes through a separate +# validation and rewrite path but relies on the same literal-cast helper, so the +# identity `cast(d AS date)` should likewise fold to a bare-column comparison. +query TT +explain select d from dates where cast(d as date) in (DATE '2024-01-01'); +---- +logical_plan +01)Filter: dates.d = Date32("2024-01-01") +02)--TableScan: dates projection=[d] +physical_plan +01)FilterExec: d@0 = 2024-01-01 +02)--DataSourceExec: partitions=1, partition_sizes=[1] + +statement ok +drop table dates;