From 372c9b27e005cf781992ce0633dcc227271ef056 Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:13:03 -0500 Subject: [PATCH 1/4] test: characterize identity Date cast in comparison unwrapping Add an SLT test to `simplify_expr.slt` for the predicate `cast(d AS date) = DATE '...'` where `d` is a `Date32` column. This is an identity cast that should fold away, leaving a bare-column comparison. This commit records the *current* (buggy) behavior: the logical plan retains a residual `CAST(dates.d AS Date32)` instead of simplifying to `dates.d = Date32(...)`. The residual cast defeats downstream pruning / filter pushdown that expects a bare-column comparison. The next commit fixes this and this test's assertion is updated to the corrected plan, making the fix's effect visible in the diff. Signed-off-by: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> --- .../sqllogictest/test_files/simplify_expr.slt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/datafusion/sqllogictest/test_files/simplify_expr.slt b/datafusion/sqllogictest/test_files/simplify_expr.slt index 58ec7a1b262c3..c256e77fde5ba 100644 --- a/datafusion/sqllogictest/test_files/simplify_expr.slt +++ b/datafusion/sqllogictest/test_files/simplify_expr.slt @@ -146,3 +146,23 @@ 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: CAST(dates.d AS Date32) = 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; From 0ad3589f1fc6c879c4157c4363962d2b3103170b Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:15:49 -0500 Subject: [PATCH 2/4] fix: unwrap identity Date cast in comparison unwrapping `unwrap_cast_in_comparison` refused to fold an identity `CAST(col AS DATE)` on a `Date32` column, leaving a residual `Cast(col AS Date32)` in the predicate instead of comparing against the bare column. SQL `cast(col AS date)` plants a real `Expr::Cast` with no identity elision (unlike the `arrow_cast` UDF, whose `simplify()` short-circuits identity), so this cast reached `try_cast_literal_to_type` and was rejected. The residual cast defeats downstream pruning/pushdown that expects a bare-column comparison. The root cause is in `is_lossy_temporal_cast`: (is_date_type(from) && to.is_temporal()) || (is_date_type(to) && from.is_temporal()) For an identity `Date32 -> Date32` cast this is `true && true`, because `DataType::is_temporal()` is true for both `Date32` and `Date64`. The cast is misclassified as a lossy temporal cast, `try_cast_literal_to_type` returns `None`, and the residual cast is left in place. Fix: short-circuit an identity cast (`from_type == to_type`) as non-lossy at the top of `is_lossy_temporal_cast`. An identity cast can never change comparison semantics. This is deliberately scoped to *identical* types only, not "any date-to-date". `Date32` counts days while `Date64` counts milliseconds, but `try_cast_numeric_literal` uses `mul = 1` for both, so a `Date32 <-> Date64` cast would convert units wrongly and must stay blocked. A regression test asserts that. The SLT characterization test added in the previous commit now fails (its assertion still shows the residual cast); the next commit updates it to the corrected plan, so the diff shows exactly what the fix changed. Signed-off-by: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> --- datafusion/expr-common/src/casts.rs | 63 +++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) 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); From 0ec7b72804ff4966c14263b60267a7604c46cea9 Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:16:13 -0500 Subject: [PATCH 3/4] test: update identity Date cast assertion to folded plan With the identity short-circuit in `is_lossy_temporal_cast`, the predicate `cast(d AS date) = DATE '...'` on a `Date32` column now folds the identity cast away. Update the SLT assertion from the residual Filter: CAST(dates.d AS Date32) = Date32("2024-01-01") to the corrected bare-column comparison Filter: dates.d = Date32("2024-01-01") The diff of this commit is the observable effect of the fix. Signed-off-by: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> --- datafusion/sqllogictest/test_files/simplify_expr.slt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datafusion/sqllogictest/test_files/simplify_expr.slt b/datafusion/sqllogictest/test_files/simplify_expr.slt index c256e77fde5ba..cf7c761bf0250 100644 --- a/datafusion/sqllogictest/test_files/simplify_expr.slt +++ b/datafusion/sqllogictest/test_files/simplify_expr.slt @@ -158,7 +158,7 @@ query TT explain select d from dates where cast(d as date) = DATE '2024-01-01'; ---- logical_plan -01)Filter: CAST(dates.d AS Date32) = Date32("2024-01-01") +01)Filter: dates.d = Date32("2024-01-01") 02)--TableScan: dates projection=[d] physical_plan 01)FilterExec: d@0 = 2024-01-01 From 1fe86c8e70cbe1dc9128602e5212e137e839a554 Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:43:29 -0500 Subject: [PATCH 4/4] test: add IN predicate regression for identity Date cast unwrapping `IN` predicates go through a separate validation and rewrite path but rely on the same literal-cast helper as binary comparisons. Add an SLT regression that `cast(d AS date) IN (DATE '2024-01-01')` on a Date32 column folds to a bare-column comparison, extending the date-specific integration coverage beyond binary comparisons. Co-Authored-By: Claude Opus 4.8 --- .../sqllogictest/test_files/simplify_expr.slt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/datafusion/sqllogictest/test_files/simplify_expr.slt b/datafusion/sqllogictest/test_files/simplify_expr.slt index cf7c761bf0250..a291740b914f5 100644 --- a/datafusion/sqllogictest/test_files/simplify_expr.slt +++ b/datafusion/sqllogictest/test_files/simplify_expr.slt @@ -164,5 +164,18 @@ 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;