fix: unwrap_cast_in_comparison drops the timezone shift when unwrapping CAST(timestamp AS timestamptz) = literal - #25099
fix: unwrap_cast_in_comparison drops the timezone shift when unwrapping CAST(timestamp AS timestamptz) = literal#25099Ruchirtripathi wants to merge 10 commits into
Conversation
…n timezone matching is lossy This prevents the optimizer from dropping timezone shifts when casting between a timezone-aware and timezone-naive timestamp in comparisons.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #25099 +/- ##
==========================================
+ Coverage 81.17% 81.80% +0.62%
==========================================
Files 1109 1130 +21
Lines 388164 417733 +29569
Branches 388164 417733 +29569
==========================================
+ Hits 315109 341712 +26603
- Misses 54509 55887 +1378
- Partials 18546 20134 +1588 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Fixes incorrect results when the optimizer unwraps casts between timezone-naive and timezone-aware timestamps, which can drop required timezone shifts for non-UTC contexts.
Changes:
- Update
is_lossy_temporal_castto treat naive ↔ tz-aware timestamp casts as lossy for non-UTC timezones. - Add unit coverage for the new lossy-cast behavior around timestamp timezones.
- Update an
sqllogictestexpectation for a timestamp comparison involvingAT TIME ZONE.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| datafusion/expr-common/src/casts.rs | Adds lossy detection for naive ↔ tz-aware timestamp casts (non-UTC) and a unit test for the behavior. |
| datafusion/sqllogictest/test_files/datetime/timestamps.slt | Adjusts expected output for a timezone-related timestamp comparison query. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if let (DataType::Timestamp(_, from_tz), DataType::Timestamp(_, to_tz)) = | ||
| (from_type, to_type) | ||
| && from_tz.is_some() != to_tz.is_some() | ||
| { | ||
| let tz = from_tz.as_ref().or(to_tz.as_ref()).unwrap().as_ref(); | ||
| if tz != "UTC" | ||
| && tz != "+00:00" | ||
| && tz != "-00:00" | ||
| && tz != "+0:00" | ||
| && tz != "-0:00" | ||
| && tz != "Z" | ||
| { | ||
| return true; | ||
| } | ||
| } |
| SELECT column1 FROM t_europe WHERE column1 = '2024-01-31T16:00:01' AT TIME ZONE 'America/Los_Angeles'; | ||
| ---- | ||
| 2024-02-01T00:00:01+01:00 | ||
|
|
| #[test] | ||
| fn test_is_lossy_temporal_cast_timestamp_tz() { | ||
| let ts_naive = DataType::Timestamp(TimeUnit::Millisecond, None); | ||
| let ts_utc = DataType::Timestamp(TimeUnit::Millisecond, Some("UTC".into())); | ||
| let ts_sgt = | ||
| DataType::Timestamp(TimeUnit::Millisecond, Some("Asia/Singapore".into())); | ||
|
|
||
| // Naive <-> UTC is NOT lossy (UTC offset is 0, so literal cast is exact) | ||
| assert!(!is_lossy_temporal_cast(&ts_naive, &ts_utc)); | ||
| assert!(!is_lossy_temporal_cast(&ts_utc, &ts_naive)); | ||
|
|
||
| // Naive <-> Non-UTC is lossy because it ignores session timezone | ||
| assert!(is_lossy_temporal_cast(&ts_naive, &ts_sgt)); | ||
| assert!(is_lossy_temporal_cast(&ts_sgt, &ts_naive)); | ||
|
|
||
| // Tz-aware <-> Tz-aware is not lossy (both are UTC under the hood) | ||
| assert!(!is_lossy_temporal_cast(&ts_utc, &ts_sgt)); | ||
| assert!(!is_lossy_temporal_cast(&ts_sgt, &ts_utc)); | ||
| } |
adriangb
left a comment
There was a problem hiding this comment.
Thank you for the fix. The approach is correct, and I verified that it corrects the bug. I have some requests before we merge.
| if tz != "UTC" | ||
| && tz != "+00:00" | ||
| && tz != "-00:00" | ||
| && tz != "+0:00" | ||
| && tz != "-0:00" | ||
| && tz != "Z" | ||
| { |
There was a problem hiding this comment.
This list is not exhaustive. Etc/UTC and GMT have an offset of zero, but the code does not accept them, and thus the optimizer keeps the cast:
-- "UTC": the optimizer removes the cast
EXPLAIN SELECT * FROM t
WHERE arrow_cast(ts, 'Timestamp(Nanosecond, Some("UTC"))')
= arrow_cast(TIMESTAMP '2024-11-01T00:00:00', 'Timestamp(Nanosecond, Some("UTC"))');
-- predicate: ts = 1730419200000000000
-- "Etc/UTC": the optimizer keeps the cast, although the offset is also zero
EXPLAIN SELECT * FROM t
WHERE arrow_cast(ts, 'Timestamp(Nanosecond, Some("Etc/UTC"))')
= arrow_cast(TIMESTAMP '2024-11-01T00:00:00', 'Timestamp(Nanosecond, Some("Etc/UTC"))');
-- predicate: CAST(ts AS Timestamp(Nanosecond, Some("Etc/UTC"))) = ...GMT gives the same result as Etc/UTC. The direction of the error is safe, thus the rows stay correct. But the code loses the optimization for these names, and a list of strings is difficult to keep correct.
arrow::array::timezone::Tz parses all of these names. Please parse the timezone and test the offset instead of the list. If you keep the list, please move it into a function with a name such as is_zero_offset_timezone, and give the reason for each item.
| if is_date_type(from_type) && is_date_type(to_type) { | ||
| return false; | ||
| } | ||
| if let (DataType::Timestamp(_, from_tz), DataType::Timestamp(_, to_tz)) = |
There was a problem hiding this comment.
I verified that this guard corrects the four queries in the issue, thank you. Four points on the block:
-
Please add the new rule to the doc comment of
is_lossy_temporal_cast. The comment gives the rules for identity casts, date casts andDate32/Date64casts in detail. It says nothing about timezones. -
A cast between a naive timestamp and a timezone-aware timestamp is not lossy. The cast is bijective: it moves the value by the offset of the timezone. The doc comment of this function describes a different problem, which is a cast that is many-to-one. The guard gives the correct result, but the name and the comment now disagree with the code. Please make the reason clear at this position.
-
The issue gives a second solution: keep the unwrap, but move the literal by the same offset. That solution keeps the optimization. The present solution stops the unwrap, and thus the engine loses the pushdown and the pruning for each of these comparisons. Did you examine the second solution? If you prefer the present solution, please add a comment that says that the guard is intentionally conservative.
-
unwrap()is safe here, becauseis_some() != is_some()makes sure that one side has a value. But amatchon the two options is more clear, and it removes theunwrap().
| } | ||
|
|
||
| #[test] | ||
| fn test_is_lossy_temporal_cast_timestamp_tz() { |
There was a problem hiding this comment.
This test examines is_lossy_temporal_cast alone. It does not show that a query gives the correct rows. If a subsequent change makes unwrap_cast_in_comparison drop the timezone shift again, this test stays green, and the bug comes back without a failure.
Please add the queries from the issue to datafusion/sqllogictest/test_files/datetime/timestamps.slt. They are the only tests that show the behavior that this PR corrects:
statement ok
set datafusion.execution.time_zone = 'Asia/Singapore';
statement ok
create table t as select TIMESTAMP '2024-11-01T00:00:00' as ts;
statement ok
create table u as select '2024-10-31T16:00:00Z'::timestamptz as tstz;
# 2024-11-01 00:00 in Singapore is 2024-10-31 16:00 UTC
query I
select count(*) from t where ts::timestamptz = '2024-10-31T16:00:00Z'::timestamptz;
----
1
query I
select count(*) from t where ts::timestamptz = '2024-11-01T00:00:00Z'::timestamptz;
----
0
# the same rewrite occurs for an implicit coercion
query I
select count(*) from t where ts = '2024-10-31T16:00:00Z'::timestamptz;
----
1
# control: a column against a column, thus the optimizer unwraps nothing
query I
select count(*) from t, u where t.ts::timestamptz = u.tstz;
----
1Please add two more cases:
- A timezone-aware column against a timezone-naive literal. This is the opposite direction of the cast, and the guard is symmetric.
- An
explainfor a UTC session timezone, which shows that the optimizer still removes the cast. Without this test, a subsequent guard that is too strong can remove the optimization for all timezones, and each test above stays green.
| query P | ||
| SELECT column1 FROM t_europe WHERE column1 = '2024-01-31T16:00:01' AT TIME ZONE 'America/Los_Angeles'; | ||
| ---- | ||
| 2024-02-01T00:00:01+01:00 |
There was a problem hiding this comment.
I verified this change on your branch. The empty result is correct, but the PR does not give the reason, and the description says the opposite (see the note from the Copilot review). Please put the reason in the PR description.
The arithmetic:
t_europeholds three instants:2023-12-31T23:00:01Z,2024-01-31T23:00:01Zand2024-02-29T23:00:01Z. Theuniontest below this one shows the same three instants.- The literal
'2024-01-31T16:00:01' AT TIME ZONE 'America/Los_Angeles'is the instant2024-02-01T00:00:01Z. Thet_utctest below this one shows the same instant. - No row of
t_europeis equal to that instant. Thus the empty result is correct, and the deleted row was a result of the bug.
An empty result is a weak assertion. A guard that is too strong also gives an empty result, and this test cannot see the difference. Please keep a row here. The instant 2024-01-31T23:00:01Z is 15:00:01 in Los Angeles, thus this query selects the second row:
query P
SELECT column1 FROM t_europe WHERE column1 = '2024-01-31T15:00:01' AT TIME ZONE 'America/Los_Angeles';
----
2024-02-01T00:00:01+01:00
I ran this query two times on your branch: one time with your change, and one time with the new guard removed. Without the guard it gives zero rows, which is incorrect. With the guard it gives the row above. It is thus a correct test for this bug. Please keep the empty case also.
| "UTC" | "Etc/UTC" | "GMT" | "Etc/GMT" | "Greenwich" | "Z" => true, | ||
| // Common fixed offset zero strings parsed by Arrow | ||
| "+00:00" | "-00:00" | "+0:00" | "-0:00" => true, | ||
| _ => false, |
There was a problem hiding this comment.
Is there no function in arrow-rs or chrono we could use for this?
There was a problem hiding this comment.
The arrow::array::timezone::Tz enum only allows fetching the offset dynamically for a specific NaiveDateTime via offset_from_utc_datetime(). However, we cannot simply instantiate an arbitrary date (like the Unix Epoch) and check if the offset is zero because geographic timezones like Europe/London evaluate to an offset of 0 during winter time. This would create false positives during query planning.
Because we need to evaluate this statically during optimization (where we don't want to instantiate values just to check offsets), explicitly whitelisting the known permanent zero-offset strings is currently the safest and most robust approach.
Which issue does this PR close?
Closes #25095
Rationale for this change
Comparing a timezone-naive timestamp column against a timezone-aware literal (or vice versa) was incorrectly returning the wrong rows when the session timezone was not UTC.
The optimizer's
unwrap_cast_in_comparisonrule was rewriting:into:
However, the lower-level function governing this unwrap,
is_lossy_temporal_cast, failed to recognize that casting between timezone-naive and timezone-aware timestamps acts as a timezone shift. This shift effectively changes the underlying integer value by the local timezone offset.Because
is_lossy_temporal_castdid not recognize this as a lossy operation, the optimizer incorrectly stripped the cast and copied the underlying literal's UTC integer without applying the required timezone shift. As a result, the query returned rows offset by exactly the session timezone offset.What changes are included in this PR?
Updated
is_lossy_temporal_castindatafusion/expr-common/src/casts.rsto treat casts between timezone-naive and timezone-aware timestamps as lossy operations unless the timezone is UTC.This prevents
unwrap_cast_in_comparisonfrom stripping timezone shifts from the execution layer, allowing the physical layer to correctly handle the timezone conversion through Arrow's compute kernels.Updated the
sqllogictestindatafusion/sqllogictest/test_files/datetime/timestamps.slt, which was previously asserting the incorrect empty result for:What is the testing strategy for this PR?
Added a new unit test,
test_is_lossy_temporal_cast_timestamp_tz, indatafusion/expr-common/src/casts.rsto explicitly verify that:Adjusted the expectations in
datafusion/sqllogictest/test_files/datetime/timestamps.sltto reflect the correct behavior.Are there any user-facing changes?
Yes. This is a bug fix.
Queries comparing a timezone-naive timestamp column against a
timestamptzliteral will now return the correct rows according to the session timezone, matching the behavior of PostgreSQL and DuckDB.