-
Notifications
You must be signed in to change notification settings - Fork 2.4k
fix: use session timezone for timestamp subtraction #25094
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -102,7 +102,13 @@ impl AnalyzerRule for TypeCoercion { | |
|
|
||
| // recurse | ||
| let transformed_plan = plan | ||
| .transform_up_with_subqueries(|plan| analyze_internal(&EMPTY_SCHEMA, plan))? | ||
| .transform_up_with_subqueries(|plan| { | ||
| analyze_internal( | ||
| &EMPTY_SCHEMA, | ||
| plan, | ||
| config.execution.time_zone.as_deref(), | ||
| ) | ||
| })? | ||
| .data; | ||
|
|
||
| // finish | ||
|
|
@@ -116,6 +122,7 @@ impl AnalyzerRule for TypeCoercion { | |
| fn analyze_internal( | ||
| external_schema: &DFSchema, | ||
| plan: LogicalPlan, | ||
| session_time_zone: Option<&str>, | ||
| ) -> Result<Transformed<LogicalPlan>> { | ||
| // get schema representing all available input fields. This is used for data type | ||
| // resolution only, so order does not matter here | ||
|
|
@@ -157,7 +164,8 @@ fn analyze_internal( | |
| plan | ||
| }; | ||
|
|
||
| let mut expr_rewrite = TypeCoercionRewriter::new(&schema); | ||
| let mut expr_rewrite = | ||
| TypeCoercionRewriter::new(&schema).with_session_time_zone(session_time_zone); | ||
|
|
||
| let name_preserver = NamePreserver::new(&plan); | ||
| // apply coercion rewrite all expressions in the plan individually | ||
|
|
@@ -175,13 +183,25 @@ fn analyze_internal( | |
| /// Rewrite expressions to apply type coercion. | ||
| pub struct TypeCoercionRewriter<'a> { | ||
| pub(crate) schema: &'a DFSchema, | ||
| session_time_zone: Option<&'a str>, | ||
| } | ||
|
|
||
| impl<'a> TypeCoercionRewriter<'a> { | ||
| /// Create a new [`TypeCoercionRewriter`] with a provided schema | ||
| /// representing both the inputs and output of the [`LogicalPlan`] node. | ||
| pub fn new(schema: &'a DFSchema) -> Self { | ||
| Self { schema } | ||
| Self { | ||
| schema, | ||
| session_time_zone: None, | ||
| } | ||
| } | ||
|
|
||
| pub(crate) fn with_session_time_zone( | ||
| mut self, | ||
| session_time_zone: Option<&'a str>, | ||
| ) -> Self { | ||
| self.session_time_zone = session_time_zone; | ||
| self | ||
| } | ||
|
|
||
| /// Coerce the [`LogicalPlan`]. | ||
|
|
@@ -398,9 +418,14 @@ impl<'a> TypeCoercionRewriter<'a> { | |
| ) -> Result<(Expr, Expr)> { | ||
| let left_data_type = left.get_type(left_schema)?; | ||
| let right_data_type = right.get_type(right_schema)?; | ||
| let (left_type, right_type) = | ||
| let (left_type, right_type) = if let Some(types) = | ||
| self.timestamp_subtraction_input_types(&left_data_type, &op, &right_data_type) | ||
| { | ||
| types | ||
| } else { | ||
| BinaryTypeCoercer::new(&left_data_type, &op, &right_data_type) | ||
| .get_input_types()?; | ||
| .get_input_types()? | ||
| }; | ||
| let left_cast_ok = can_cast_types(&left_data_type, &left_type); | ||
| let right_cast_ok = can_cast_types(&right_data_type, &right_type); | ||
|
|
||
|
|
@@ -434,6 +459,47 @@ impl<'a> TypeCoercionRewriter<'a> { | |
| Ok((left_expr, right_expr)) | ||
| } | ||
|
|
||
| /// Coerces the timezone-naive side of timestamp subtraction using the session | ||
| /// timezone, matching PostgreSQL and DuckDB. The timezone-aware side keeps its | ||
| /// timezone while both operands are widened to the same precision. | ||
| fn timestamp_subtraction_input_types( | ||
| &self, | ||
| left_type: &DataType, | ||
| op: &Operator, | ||
| right_type: &DataType, | ||
| ) -> Option<(DataType, DataType)> { | ||
| if op != &Operator::Minus { | ||
| return None; | ||
| } | ||
| let session_time_zone = self.session_time_zone?; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we add a test to assert the current/expected behavior when statement ok
RESET datafusion.execution.time_zone
statement ok
SET datafusion.explain.logical_plan_only = true
# With no session timezone the naive operand is still read as UTC:
# 2024-11-01T00:00:00-04:00 is 04:00Z, and the naive value is taken as 00:00Z.
statement ok
CREATE TABLE no_session_tz AS SELECT
arrow_cast('2024-11-01T00:00:00-04:00', 'Timestamp(Nanosecond, Some("America/New_York"))') AS ts_tz,
'2024-11-01T00:00:00'::timestamp AS ts;
query ??
SELECT ts_tz - ts, ts - ts_tz FROM no_session_tz;
----
0 days 4 hours 0 mins 0.000000000 secs 0 days -4 hours 0 mins 0.000000000 secs
query TT
EXPLAIN SELECT ts_tz - ts FROM no_session_tz;
----
logical_plan
01)Projection: no_session_tz.ts_tz - no_session_tz.ts
02)--TableScan: no_session_tz projection=[ts_tz, ts]
statement ok
SET datafusion.explain.logical_plan_only = false(I ran this against the PR branch: the values are 4 hours / -4 hours and no cast is inserted, so it records today's behaviour.) |
||
| let (left_time_zone, right_time_zone) = match (left_type, right_type) { | ||
| ( | ||
| DataType::Timestamp(_, Some(left_time_zone)), | ||
| DataType::Timestamp(_, None), | ||
| ) => ( | ||
| Some(Arc::clone(left_time_zone)), | ||
| Some(Arc::from(session_time_zone)), | ||
| ), | ||
| ( | ||
| DataType::Timestamp(_, None), | ||
| DataType::Timestamp(_, Some(right_time_zone)), | ||
| ) => ( | ||
| Some(Arc::from(session_time_zone)), | ||
| Some(Arc::clone(right_time_zone)), | ||
| ), | ||
| _ => return None, | ||
| }; | ||
| let DataType::Timestamp(unit, _) = comparison_coercion(left_type, right_type)? | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we match on the two units directly, or make |
||
| else { | ||
| return None; | ||
| }; | ||
|
|
||
| Some(( | ||
| DataType::Timestamp(unit, left_time_zone), | ||
| DataType::Timestamp(unit, right_time_zone), | ||
| )) | ||
| } | ||
|
|
||
| fn coerce_date_time_math_op( | ||
| expr: Expr, | ||
| op: &Operator, | ||
|
|
@@ -588,8 +654,12 @@ impl TreeNodeRewriter for TypeCoercionRewriter<'_> { | |
| outer_ref_columns, | ||
| spans, | ||
| }) => { | ||
| let new_plan = | ||
| analyze_internal(self.schema, Arc::unwrap_or_clone(subquery))?.data; | ||
| let new_plan = analyze_internal( | ||
| self.schema, | ||
| Arc::unwrap_or_clone(subquery), | ||
| self.session_time_zone, | ||
| )? | ||
| .data; | ||
| Ok(Transformed::yes(Expr::ScalarSubquery(Subquery { | ||
| subquery: Arc::new(new_plan), | ||
| outer_ref_columns, | ||
|
|
@@ -600,6 +670,7 @@ impl TreeNodeRewriter for TypeCoercionRewriter<'_> { | |
| let new_plan = analyze_internal( | ||
| self.schema, | ||
| Arc::unwrap_or_clone(subquery.subquery), | ||
| self.session_time_zone, | ||
| )? | ||
| .data; | ||
| Ok(Transformed::yes(Expr::Exists(Exists { | ||
|
|
@@ -619,6 +690,7 @@ impl TreeNodeRewriter for TypeCoercionRewriter<'_> { | |
| let new_plan = analyze_internal( | ||
| self.schema, | ||
| Arc::unwrap_or_clone(subquery.subquery), | ||
| self.session_time_zone, | ||
| )? | ||
| .data; | ||
| let expr_type = expr.get_type(self.schema)?; | ||
|
|
@@ -648,6 +720,7 @@ impl TreeNodeRewriter for TypeCoercionRewriter<'_> { | |
| let new_plan = analyze_internal( | ||
| self.schema, | ||
| Arc::unwrap_or_clone(subquery.subquery), | ||
| self.session_time_zone, | ||
| )? | ||
| .data; | ||
| let expr_type = expr.get_type(self.schema)?; | ||
|
|
@@ -2721,7 +2794,7 @@ mod test { | |
| vec![Field::new("a", DataType::Int64, true)].into(), | ||
| std::collections::HashMap::new(), | ||
| )?); | ||
| let mut rewriter = TypeCoercionRewriter { schema: &schema }; | ||
| let mut rewriter = TypeCoercionRewriter::new(&schema); | ||
| let expr = is_true(lit(12i32).gt(lit(13i64))); | ||
| let expected = is_true(cast(lit(12i32), DataType::Int64).gt(lit(13i64))); | ||
| let result = expr.rewrite(&mut rewriter).data()?; | ||
|
|
@@ -2732,7 +2805,7 @@ mod test { | |
| vec![Field::new("a", DataType::Int64, true)].into(), | ||
| std::collections::HashMap::new(), | ||
| )?); | ||
| let mut rewriter = TypeCoercionRewriter { schema: &schema }; | ||
| let mut rewriter = TypeCoercionRewriter::new(&schema); | ||
| let expr = is_true(lit(12i32).eq(lit(13i64))); | ||
| let expected = is_true(cast(lit(12i32), DataType::Int64).eq(lit(13i64))); | ||
| let result = expr.rewrite(&mut rewriter).data()?; | ||
|
|
@@ -2743,7 +2816,7 @@ mod test { | |
| vec![Field::new("a", DataType::Int64, true)].into(), | ||
| std::collections::HashMap::new(), | ||
| )?); | ||
| let mut rewriter = TypeCoercionRewriter { schema: &schema }; | ||
| let mut rewriter = TypeCoercionRewriter::new(&schema); | ||
| let expr = is_true(lit(12i32).lt(lit(13i64))); | ||
| let expected = is_true(cast(lit(12i32), DataType::Int64).lt(lit(13i64))); | ||
| let result = expr.rewrite(&mut rewriter).data()?; | ||
|
|
@@ -3201,6 +3274,53 @@ mod test { | |
| ) | ||
| } | ||
|
|
||
| #[test] | ||
| fn timestamp_subtraction_uses_session_timezone() -> Result<()> { | ||
| let schema = DFSchema::from_unqualified_fields( | ||
| vec![ | ||
| Field::new( | ||
| "tstz", | ||
| DataType::Timestamp( | ||
| TimeUnit::Second, | ||
| Some("America/New_York".into()), | ||
| ), | ||
| true, | ||
| ), | ||
| Field::new("ts", DataType::Timestamp(TimeUnit::Nanosecond, None), true), | ||
| ] | ||
| .into(), | ||
| std::collections::HashMap::new(), | ||
| )?; | ||
| let input = Arc::new(LogicalPlan::EmptyRelation(EmptyRelation { | ||
| produce_one_row: false, | ||
| schema: Arc::new(schema), | ||
| })); | ||
| let subtract = |left, right| { | ||
| Expr::BinaryExpr(BinaryExpr::new( | ||
| Box::new(col(left)), | ||
| Operator::Minus, | ||
| Box::new(col(right)), | ||
| )) | ||
| }; | ||
| let plan = LogicalPlan::Projection(Projection::try_new( | ||
| vec![subtract("tstz", "ts"), subtract("ts", "tstz")], | ||
| input, | ||
| )?); | ||
|
|
||
| let mut options = ConfigOptions::default(); | ||
| options.execution.time_zone = Some("+08:00".to_string()); | ||
| let rule = Arc::new(TypeCoercion::new()); | ||
| assert_analyzed_plan_with_config_eq_snapshot!( | ||
| options, | ||
| rule, | ||
| plan, | ||
| @r#" | ||
| Projection: CAST(tstz AS Timestamp(ns, "America/New_York")) - CAST(ts AS Timestamp(ns, "+08:00")), CAST(ts AS Timestamp(ns, "+08:00")) - CAST(tstz AS Timestamp(ns, "America/New_York")) | ||
| EmptyRelation: rows=0 | ||
| "#, | ||
| ) | ||
| } | ||
|
|
||
| #[test] | ||
| fn in_subquery_cast_subquery() -> Result<()> { | ||
| let empty_int32 = empty_with_type(DataType::Int32); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -244,7 +244,7 @@ fn evaluate_expr_with_null_column<'a>( | |
| } | ||
|
|
||
| fn coerce(expr: Expr, schema: &DFSchema) -> Result<Expr> { | ||
| let mut expr_rewrite = TypeCoercionRewriter { schema }; | ||
| let mut expr_rewrite = TypeCoercionRewriter::new(schema); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This caller does not get the session timezone, so the new rule does not apply here. See comment above. |
||
| expr.rewrite(&mut expr_rewrite).data() | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1980,6 +1980,60 @@ SELECT '2000-01-01T00:00:00'::timestamp - '2010-01-01T00:00:00'::timestamp; | |
| ---- | ||
| -3653 days 0 hours 0 mins 0.000000000 secs | ||
|
|
||
| # Regression test for https://github.com/apache/datafusion/issues/13212 | ||
| statement ok | ||
| SET TIME ZONE = '+08' | ||
|
|
||
| query ??? | ||
| WITH timestamps(ts_tz, ts) AS ( | ||
| VALUES ('2024-11-01T00:00:00+00:00'::timestamptz, '2024-11-01T00:00:00'::timestamp) | ||
| ) | ||
| SELECT | ||
| '2024-11-01T00:00:00+00:00'::timestamptz - '2024-11-01T00:00:00'::timestamp, | ||
| ts_tz - ts, | ||
| ts - ts_tz | ||
| FROM timestamps; | ||
| ---- | ||
| 0 days 8 hours 0 mins 0.000000000 secs 0 days 8 hours 0 mins 0.000000000 secs 0 days -8 hours 0 mins 0.000000000 secs | ||
|
|
||
| # The session timezone is propagated when coercing a scalar subquery. | ||
| query ? | ||
| SELECT ( | ||
| SELECT ts_tz - ts | ||
| FROM ( | ||
| VALUES ('2024-11-01T00:00:00+00:00'::timestamptz, '2024-11-01T00:00:00'::timestamp) | ||
| ) AS timestamps(ts_tz, ts) | ||
| ); | ||
| ---- | ||
| 0 days 8 hours 0 mins 0.000000000 secs | ||
|
|
||
| # The session timezone, not the aware operand's timezone, controls the cast. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The rule seems to diverge for SET datafusion.execution.time_zone = '+08:00';
CREATE TABLE t AS SELECT arrow_cast('2024-11-01T04:00:00Z', 'Timestamp(Nanosecond, Some("America/New_York"))') AS ts_tz, '2024-11-01T00:00:00'::timestamp AS ts;
SELECT ts_tz = ts, ts_tz - ts FROM t;returns |
||
| query ?? | ||
| WITH timestamps(ts_tz, ts) AS ( | ||
| VALUES ( | ||
| arrow_cast('2024-11-01T00:00:00-04:00', 'Timestamp(Nanosecond, Some("America/New_York"))'), | ||
| '2024-11-01T00:00:00'::timestamp | ||
| ) | ||
| ) | ||
| SELECT ts_tz - ts, ts - ts_tz | ||
| FROM timestamps; | ||
| ---- | ||
| 0 days 12 hours 0 mins 0.000000000 secs 0 days -12 hours 0 mins 0.000000000 secs | ||
|
|
||
| statement ok | ||
| SET TIME ZONE = 'America/New_York' | ||
|
|
||
| # The timezone-naive operand is interpreted in EST and EDT, respectively. | ||
| query ?? | ||
| SELECT | ||
| '2024-01-15T00:00:00+00:00'::timestamptz - '2024-01-15T00:00:00'::timestamp, | ||
| '2024-07-15T00:00:00+00:00'::timestamptz - '2024-07-15T00:00:00'::timestamp; | ||
| ---- | ||
| 0 days -5 hours 0 mins 0.000000000 secs 0 days -4 hours 0 mins 0.000000000 secs | ||
|
|
||
| statement ok | ||
| RESET datafusion.execution.time_zone | ||
|
|
||
| # Interval - Timestamp => error | ||
| query error Cannot coerce arithmetic expression Interval\(MonthDayNano\) - Timestamp\(ns\) to valid types | ||
| SELECT i - ts1 from FOO; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could this go in
BinaryTypeCoercerinstead of the analyzer?This special case lives in
TypeCoercionRewriter, so the PR has to send the session timezone through four subquery call sites and throughExprSimplifier::coerce. One caller still does not get it: thecoercefunction inoptimizer/src/utils.rs.BinaryTypeCoercerinexpr-commonis the one source of coercion rules. The analyzer, the simplifier, the physicalBinaryExpr::data_type, the statistics solver, and interval arithmetic all use it. A rule inBinaryTypeCoercerapplies to all of them with no plumbing.The cause of the bug is also in that file. In the arithmetic arm of
signature_inner, the first branch asks arrow for a result type. Arrow acceptsTimestamp(u, Some) - Timestamp(u, None)when the units are equal and reads the naive side as UTC. When the units differ, the pair falls through totemporal_coercion_strict_timezone, which casts the naive side to the aware side's timezone. This is what makes results depend on the units. A check before the arrow probe fixes that.On
mainwithSET TIME ZONE = '+08:00':