Skip to content
Merged
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
63 changes: 63 additions & 0 deletions datafusion/expr-common/src/casts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down Expand Up @@ -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);
Expand Down
33 changes: 33 additions & 0 deletions datafusion/sqllogictest/test_files/simplify_expr.slt
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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.

Nice addition! One optional follow-up would be to add an IN predicate regression, for example cast(d AS date) IN (DATE '2024-01-01'). IN goes through a separate validation and rewrite path that also relies on the same literal-cast helper, so this would help extend the date-specific integration coverage beyond binary comparisons.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

added in 1fe86c8

----
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;
Loading