fix: use session timezone for timestamp subtraction - #25094
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
The claimed nested-subquery coverage does not currently exercise subtraction inside a subquery.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Fixes #13212 by applying the session timezone when coercing mixed timezone-aware and timezone-naive timestamp subtraction.
Changes:
- Propagates session timezone through type coercion, simplification, and subqueries.
- Preserves timezone metadata while normalizing timestamp precision.
- Adds analyzer, physical-expression, and SQL regression tests.
File summaries
| File | Description |
|---|---|
datafusion/sqllogictest/test_files/datetime/timestamps.slt |
Tests mixed timestamp subtraction across timezones. |
datafusion/optimizer/src/utils.rs |
Uses the new rewriter constructor. |
datafusion/optimizer/src/simplify_expressions/expr_simplifier.rs |
Supplies session timezone during expression coercion. |
datafusion/optimizer/src/scalar_subquery_to_join.rs |
Uses the new rewriter constructor. |
datafusion/optimizer/src/analyzer/type_coercion.rs |
Implements timezone-aware subtraction coercion and propagation. |
datafusion/core/tests/expr_api/mod.rs |
Tests direct physical-expression creation. |
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #25094 +/- ##
==========================================
+ Coverage 81.74% 81.80% +0.05%
==========================================
Files 1128 1130 +2
Lines 416644 417799 +1155
Branches 416644 417799 +1155
==========================================
+ Hits 340592 341783 +1191
+ Misses 55995 55883 -112
- Partials 20057 20133 +76 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| 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) |
There was a problem hiding this comment.
Could this go in BinaryTypeCoercer instead of the analyzer?
This special case lives in TypeCoercionRewriter, so the PR has to send the session timezone through four subquery call sites and through ExprSimplifier::coerce. One caller still does not get it: the coerce function in optimizer/src/utils.rs.
BinaryTypeCoercer in expr-common is the one source of coercion rules. The analyzer, the simplifier, the physical BinaryExpr::data_type, the statistics solver, and interval arithmetic all use it. A rule in BinaryTypeCoercer applies 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 accepts Timestamp(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 to temporal_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 main with SET TIME ZONE = '+08:00':
SELECT arrow_cast('2024-11-01T00:00:00Z', 'Timestamp(Nanosecond, Some("+08:00"))') - '2024-11-01T00:00:00'::timestamp; -- 0 hours (wrong)
SELECT arrow_cast('2024-11-01T00:00:00Z', 'Timestamp(Millisecond, Some("+08:00"))') - '2024-11-01T00:00:00'::timestamp; -- 8 hours (right)|
|
||
| fn coerce(expr: Expr, schema: &DFSchema) -> Result<Expr> { | ||
| let mut expr_rewrite = TypeCoercionRewriter { schema }; | ||
| let mut expr_rewrite = TypeCoercionRewriter::new(schema); |
There was a problem hiding this comment.
This caller does not get the session timezone, so the new rule does not apply here. See comment above.
| if op != &Operator::Minus { | ||
| return None; | ||
| } | ||
| let session_time_zone = self.session_time_zone?; |
There was a problem hiding this comment.
Can we add a test to assert the current/expected behavior when datafusion.execution.time_zone is None? Something like:
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.)
| ), | ||
| _ => return None, | ||
| }; | ||
| let DataType::Timestamp(unit, _) = comparison_coercion(left_type, right_type)? |
There was a problem hiding this comment.
Could we match on the two units directly, or make timeunit_coercion visible and call it instead of going through comparison_coercion?
| ---- | ||
| 0 days 8 hours 0 mins 0.000000000 secs | ||
|
|
||
| # The session timezone, not the aware operand's timezone, controls the cast. |
There was a problem hiding this comment.
The rule seems to diverge for - and =. On this branch:
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 true and 0 days 12 hours: = reads ts in America/New_York (so the two values are the same instant) while - reads it in the session timezone +08:00 (so they are 12 hours apart). Two values that compare equal yet differ by twelve hours.
Which issue does this PR close?
datafusion.execution.time_zoneis not used for basic time zone inference #13212.Rationale for this change
When a timezone-aware timestamp is subtracted from a timezone-naive timestamp, DataFusion does not use
datafusion.execution.time_zoneto interpret the naive value.For example, with the session timezone set to
+08:00, subtracting2024-11-01 00:00:00from2024-11-01 00:00:00+00:00returns zero instead of eight hours.PostgreSQL and DuckDB handle this by implicitly casting the timezone-naive operand to the session timezone. DataFusion already produces the expected result when that cast is written explicitly, so the missing behavior belongs in type coercion.
What changes are included in this PR?
This PR:
timestamptz - timestampexpressions using the session timezone.ExprSimplifier::coerce, ensuringSessionContext::create_physical_exprbehaves consistently with SQL planning.The change is limited to timestamp subtraction. Comparison operators retain their existing behavior and should be handled separately.
The existing DST-boundary limitation described in #25084 remains. Because this PR inserts the previously missing cast automatically, mixed timestamp subtraction can now encounter that limitation for ambiguous or nonexistent local times.
When no session timezone is configured, existing behavior is unchanged.
What is the testing strategy for this PR?
The added tests cover:
datafusion.execution.time_zoneis not used for basic time zone inference #13212.SessionContext::create_physical_exprAPI.The following checks pass:
The extended workspace test suite from the contributor guide also passes, including all 512 SQL logic test files.
Are there any user-facing changes?
There are no public API changes.