Skip to content

Commit 7d8a646

Browse files
authored
Merge pull request #130 from Embucket/fix-df55-physical-timestamp-cast
Keep semantic timestamp casts in physical comparison pruning
2 parents 251dc59 + 1ab1c1b commit 7d8a646

4 files changed

Lines changed: 176 additions & 23 deletions

File tree

‎datafusion/expr-common/src/casts.rs‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,20 @@ pub fn is_supported_type(data_type: &DataType) -> bool {
8484
|| is_supported_binary_type(data_type)
8585
}
8686

87+
/// Returns true when moving a timestamp cast across a comparison may change its result.
88+
///
89+
/// Narrowing can truncate values, widening can overflow the integer storage,
90+
/// and attaching a timezone to a timezone-less timestamp shifts its value.
91+
/// Removing or changing an existing timezone does not change the stored value
92+
/// when the unit stays the same, so those casts remain eligible for unwrapping.
93+
pub fn changes_timestamp_semantics(from_type: &DataType, to_type: &DataType) -> bool {
94+
matches!(
95+
(from_type, to_type),
96+
(DataType::Timestamp(from_unit, from_tz), DataType::Timestamp(to_unit, to_tz))
97+
if from_unit != to_unit || (from_tz.is_none() && to_tz.is_some())
98+
)
99+
}
100+
87101
fn is_date_type(data_type: &DataType) -> bool {
88102
matches!(data_type, DataType::Date32 | DataType::Date64)
89103
}
@@ -1015,6 +1029,23 @@ mod tests {
10151029
));
10161030
}
10171031

1032+
#[test]
1033+
fn test_timestamp_cast_semantics_change() {
1034+
let ts_us = DataType::Timestamp(TimeUnit::Microsecond, None);
1035+
let ts_ns = DataType::Timestamp(TimeUnit::Nanosecond, None);
1036+
let ts_tz = DataType::Timestamp(TimeUnit::Microsecond, Some("+01:00".into()));
1037+
assert!(changes_timestamp_semantics(&ts_us, &ts_ns));
1038+
assert!(changes_timestamp_semantics(&ts_ns, &ts_us));
1039+
assert!(changes_timestamp_semantics(&ts_us, &ts_tz));
1040+
assert!(!changes_timestamp_semantics(&ts_tz, &ts_us));
1041+
assert!(!changes_timestamp_semantics(
1042+
&ts_tz,
1043+
&DataType::Timestamp(TimeUnit::Microsecond, Some("+02:00".into()))
1044+
));
1045+
assert!(!changes_timestamp_semantics(&ts_us, &ts_us));
1046+
assert!(!changes_timestamp_semantics(&DataType::Int64, &ts_us));
1047+
}
1048+
10181049
#[test]
10191050
fn test_is_date_narrowing_cast() {
10201051
// Only Date64 -> Date32 narrows (ms -> days, many-to-one).

‎datafusion/optimizer/src/simplify_expressions/unwrap_cast.rs‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ use datafusion_common::{internal_err, tree_node::Transformed};
6060
use datafusion_expr::{BinaryExpr, lit};
6161
use datafusion_expr::{Cast, Expr, Operator, TryCast, simplify::SimplifyContext};
6262
use datafusion_expr_common::casts::{
63-
is_date_narrowing_cast, is_supported_type, try_cast_literal_to_type,
63+
changes_timestamp_semantics, is_date_narrowing_cast, is_supported_type,
64+
try_cast_literal_to_type,
6465
};
6566

6667
pub(super) fn unwrap_cast_in_comparison_for_binary(
@@ -204,15 +205,6 @@ pub(super) fn is_cast_expr_and_support_unwrap_cast_in_comparison_for_inlist(
204205
true
205206
}
206207

207-
fn changes_timestamp_semantics(from_type: &DataType, to_type: &DataType) -> bool {
208-
// Changing units can truncate or overflow; attaching a timezone can shift values.
209-
matches!(
210-
(from_type, to_type),
211-
(DataType::Timestamp(from_unit, from_tz), DataType::Timestamp(to_unit, to_tz))
212-
if from_unit != to_unit || from_tz != to_tz
213-
)
214-
}
215-
216208
///// Tries to move a cast from an expression (such as column) to the literal other side of a comparison operator./
217209
///
218210
/// Specifically, rewrites

‎datafusion/physical-expr/src/simplifier/unwrap_cast.rs‎

Lines changed: 100 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,7 @@ use arrow::datatypes::{DataType, Schema};
3737
use datafusion_common::{Result, ScalarValue, tree_node::Transformed};
3838
use datafusion_expr::Operator;
3939
use datafusion_expr_common::casts::{
40-
is_date_narrowing_cast, is_timestamp_precision_narrowing_cast,
41-
try_cast_literal_to_type,
40+
changes_timestamp_semantics, is_date_narrowing_cast, try_cast_literal_to_type,
4241
};
4342

4443
use crate::PhysicalExpr;
@@ -130,7 +129,7 @@ fn try_unwrap_cast_comparison(
130129
// Get the data type of the inner expression
131130
let inner_type = inner_expr.data_type(schema)?;
132131

133-
if is_timestamp_precision_narrowing_cast(&inner_type, cast_type)
132+
if changes_timestamp_semantics(&inner_type, cast_type)
134133
|| is_date_narrowing_cast(&inner_type, cast_type)
135134
{
136135
return Ok(None);
@@ -251,6 +250,101 @@ mod tests {
251250
assert!(!result.transformed);
252251
}
253252

253+
#[test]
254+
fn test_no_unwrap_timestamp_timezone_or_unit_change() {
255+
let source_type = DataType::Timestamp(TimeUnit::Microsecond, None);
256+
let schema = Schema::new(vec![Field::new("ts", source_type.clone(), false)]);
257+
for target_type in [
258+
DataType::Timestamp(TimeUnit::Microsecond, Some("+01:00".into())),
259+
DataType::Timestamp(TimeUnit::Nanosecond, None),
260+
] {
261+
let target_literal = ScalarValue::TimestampMicrosecond(Some(0), None)
262+
.cast_to(&target_type)
263+
.unwrap();
264+
let column = col("ts", &schema).unwrap();
265+
for cast in [
266+
Arc::new(CastExpr::new(
267+
Arc::clone(&column),
268+
target_type.clone(),
269+
None,
270+
)) as Arc<dyn PhysicalExpr>,
271+
Arc::new(TryCastExpr::new(Arc::clone(&column), target_type.clone())),
272+
] {
273+
let literal = lit(target_literal.clone());
274+
for comparison in [
275+
Arc::new(BinaryExpr::new(
276+
Arc::clone(&cast),
277+
Operator::Eq,
278+
Arc::clone(&literal),
279+
)),
280+
Arc::new(BinaryExpr::new(Arc::clone(&literal), Operator::Eq, cast)),
281+
] {
282+
let result = unwrap_cast_in_comparison(comparison, &schema).unwrap();
283+
assert!(!result.transformed, "cast to {target_type:?} was unwrapped");
284+
}
285+
}
286+
}
287+
}
288+
289+
#[test]
290+
fn test_unwrap_identity_timestamp_cast() {
291+
let ts_type = DataType::Timestamp(TimeUnit::Microsecond, None);
292+
let schema = Schema::new(vec![Field::new("ts", ts_type.clone(), false)]);
293+
let column = col("ts", &schema).unwrap();
294+
let comparison = Arc::new(BinaryExpr::new(
295+
Arc::new(CastExpr::new(column, ts_type, None)),
296+
Operator::Eq,
297+
lit(ScalarValue::TimestampMicrosecond(Some(0), None)),
298+
));
299+
let result = unwrap_cast_in_comparison(comparison, &schema).unwrap();
300+
assert!(result.transformed);
301+
}
302+
303+
#[test]
304+
fn test_unwrap_timestamp_timezone_removal_or_replacement() {
305+
let source_type =
306+
DataType::Timestamp(TimeUnit::Microsecond, Some("+01:00".into()));
307+
let schema = Schema::new(vec![Field::new("ts", source_type, false)]);
308+
for target_type in [
309+
DataType::Timestamp(TimeUnit::Microsecond, None),
310+
DataType::Timestamp(TimeUnit::Microsecond, Some("+02:00".into())),
311+
] {
312+
let column = col("ts", &schema).unwrap();
313+
for cast in [
314+
Arc::new(CastExpr::new(
315+
Arc::clone(&column),
316+
target_type.clone(),
317+
None,
318+
)) as Arc<dyn PhysicalExpr>,
319+
Arc::new(TryCastExpr::new(Arc::clone(&column), target_type.clone())),
320+
] {
321+
let comparison = Arc::new(BinaryExpr::new(
322+
cast,
323+
Operator::Eq,
324+
lit(ScalarValue::TimestampMicrosecond(
325+
Some(42),
326+
match &target_type {
327+
DataType::Timestamp(_, tz) => tz.clone(),
328+
_ => unreachable!(),
329+
},
330+
)),
331+
));
332+
let result = unwrap_cast_in_comparison(comparison, &schema).unwrap();
333+
assert!(
334+
result.transformed,
335+
"cast to {target_type:?} was not unwrapped"
336+
);
337+
let optimized = result.data;
338+
let binary = optimized.downcast_ref::<BinaryExpr>().unwrap();
339+
assert!(!is_cast_expr(binary.left()));
340+
assert_eq!(
341+
binary.right().downcast_ref::<Literal>().unwrap().value(),
342+
&ScalarValue::TimestampMicrosecond(Some(42), Some("+01:00".into()))
343+
);
344+
}
345+
}
346+
}
347+
254348
#[test]
255349
fn test_no_unwrap_when_types_unsupported() {
256350
let schema = Schema::new(vec![Field::new("f1", DataType::Float32, false)]);
@@ -601,7 +695,7 @@ mod tests {
601695
}
602696

603697
#[test]
604-
fn test_unwrap_timestamp_precision_widening() {
698+
fn test_no_unwrap_timestamp_precision_widening() {
605699
let schema = Schema::new(vec![Field::new(
606700
"ts",
607701
DataType::Timestamp(TimeUnit::Millisecond, None),
@@ -620,14 +714,8 @@ mod tests {
620714

621715
let result = unwrap_cast_in_comparison(binary_expr, &schema).unwrap();
622716

623-
assert!(result.transformed);
624-
let optimized_binary = result.data.downcast_ref::<BinaryExpr>().unwrap();
625-
assert!(!is_cast_expr(optimized_binary.left()));
626-
let right_literal = optimized_binary.right().downcast_ref::<Literal>().unwrap();
627-
assert_eq!(
628-
right_literal.value(),
629-
&ScalarValue::TimestampMillisecond(Some(1), None)
630-
);
717+
// The literal fits, but other column values may overflow when widened.
718+
assert!(!result.transformed);
631719
}
632720

633721
#[test]

‎datafusion/pruning/src/pruning_predicate.rs‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2185,7 +2185,7 @@ mod tests {
21852185
use datafusion_physical_expr::utils::collect_columns;
21862186
use insta::assert_snapshot;
21872187

2188-
use arrow::array::Decimal128Array;
2188+
use arrow::array::{Decimal128Array, TimestampMicrosecondArray};
21892189
use arrow::{
21902190
array::{BinaryArray, Int32Array, Int64Array, StringArray, UInt64Array},
21912191
datatypes::TimeUnit,
@@ -3618,6 +3618,48 @@ mod tests {
36183618
Ok(())
36193619
}
36203620

3621+
#[test]
3622+
fn row_group_predicate_keeps_timezone_cast_match() -> Result<()> {
3623+
let schema = Arc::new(Schema::new(vec![Field::new(
3624+
"ts",
3625+
DataType::Timestamp(TimeUnit::Microsecond, None),
3626+
false,
3627+
)]));
3628+
let target_type =
3629+
DataType::Timestamp(TimeUnit::Microsecond, Some("+01:00".into()));
3630+
let matching_literal =
3631+
ScalarValue::TimestampMicrosecond(Some(0), None).cast_to(&target_type)?;
3632+
let expr = try_cast(col("ts"), target_type).eq(lit(matching_literal));
3633+
let physical = logical2physical(&expr, &schema);
3634+
let batch = RecordBatch::try_new(
3635+
Arc::clone(&schema),
3636+
vec![Arc::new(TimestampMicrosecondArray::from(vec![Some(0)]))],
3637+
)?;
3638+
let evaluated = physical.evaluate(&batch)?.into_array(1)?;
3639+
assert!(evaluated.as_boolean().value(0), "test row must match");
3640+
3641+
let statistics = TestStatistics::new()
3642+
.with(
3643+
"ts",
3644+
ContainerStats::default()
3645+
.with_min(Arc::new(TimestampMicrosecondArray::from(vec![
3646+
Some(0),
3647+
Some(1),
3648+
])))
3649+
.with_max(Arc::new(TimestampMicrosecondArray::from(vec![
3650+
Some(0),
3651+
Some(1),
3652+
]))),
3653+
)
3654+
.with_null_counts("ts", [Some(0), Some(0)])
3655+
.with_row_counts("ts", [Some(1), Some(1)]);
3656+
let pruning = PruningPredicateBuilder::new()
3657+
.with_file_schema(schema)
3658+
.try_build(physical)?;
3659+
assert_eq!(pruning.prune(&statistics)?, vec![true, false]);
3660+
Ok(())
3661+
}
3662+
36213663
#[test]
36223664
fn row_group_predicate_cast_string_string() -> Result<()> {
36233665
let schema = Schema::new(vec![Field::new("c1", DataType::Utf8View, false)]);

0 commit comments

Comments
 (0)