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 15 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 417785 +29621
Branches 388164 417785 +29621
==========================================
+ Hits 315109 341775 +26666
- Misses 54509 55880 +1371
- Partials 18546 20130 +1584 ☔ 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.
There was a problem hiding this comment.
Thank you for the feedback! I went with your second suggestion. I moved the hardcoded list into a new is_zero_offset_timezone function and added comments explaining why each timezone is included.
We can’t simply parse the timezone using arrow::array::timezone::Tz and check its offset because the offset can change depending on the time of year due to Daylight Saving Time. For example, Europe/London has a UTC offset of zero during winter. If we checked the offset dynamically during query planning, it could incorrectly be treated as a zero-offset timezone and cause bugs.
Because of this, keeping a strict whitelist of timezones that are permanently equivalent to UTC is the safest approach.
| 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().
There was a problem hiding this comment.
Thanks for the thorough review! I’ve updated the PR to address all four points:
- Updated the
is_lossy_temporal_castdoc comment to clearly explain the new timezone rules. - Clarified that timezone shifts are mathematically reversible (bijective), so they are not actually lossy.
- Added comments explaining that returning
truehere is an intentionally conservative guard. Shifting the literal would keep pushdown and pruning working, but blocking the unwrap is a safer immediate fix to guarantee correctness. - Refactored the logic to use a clean
match (from_tz, to_tz)statement and removed theunwrap().
Please take a look at the latest commits!
| } | ||
|
|
||
| #[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.
There was a problem hiding this comment.
Done! I've added all of these queries to datafusion/sqllogictest/test_files/datetime/timestamps.slt. I also added the two extra test cases (the symmetric guard check and the EXPLAIN block for the UTC session timezone) to ensure the optimization is still applied correctly when the offset is exactly zero.
| 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.
There was a problem hiding this comment.
Thanks for catching that! I have updated the PR description with the exact explanation. I also restored the original empty test case and added your suggested 15:00:01 query alongside it to ensure we have a strong assertion that the correct row is being selected.
| "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.
adriangb
left a comment
There was a problem hiding this comment.
Thank you for the update. You did all five of my requests, and the fix is correct.
I made a fresh build of this branch and a build without the fix. The four queries from the issue, <, IN, BETWEEN, the implicit coercion and a +08:00 session all give the correct rows with the fix. Without the fix they give the wrong rows. CI is green.
I have one blocker, three points to fix before we merge, and two nits. Each point has an inline comment. Most of them have a suggestion that you can apply with one click. I applied all of the suggestions together on my machine: cargo fmt, cargo clippy, the unit tests and datetime/timestamps.slt pass.
Blocker
- The PR now changes six CI workflow files. These changes are not part of the fix. Please remove them.
Fix before merge
- The doc comment says that the cast is bijective. It is not. The comment must give the correct reason for the guard.
- Only one direction of the cast needs the guard. The other direction loses an optimization and gains nothing.
- The zero-offset list has entries that Arrow rejects, and it misses entries that Arrow accepts.
Nits
- One slt comment says that a test exercises the guard, but the test does not.
- One slt comment has a tab character.
None of these points changes the correctness of the fix. Thank you again for the careful work.
| RETRY=("ci/scripts/retry" timeout 120) | ||
| "${RETRY[@]}" apt-get update | ||
| rm -f /etc/apt/sources.list.d/google-chrome.list | ||
| "${RETRY[@]}" apt-get update || true |
There was a problem hiding this comment.
Blocker. This PR now changes six workflow files. They remove the Google Chrome apt list and add || true after apt-get update.
These changes are not part of the fix, and the PR description does not mention them. || true hides all apt failures, also the permanent ones. main does not have this change.
Please remove the three ci: commits (838b442, 340c693, 72202c0). Your branch is 328 commits behind main, and a rebase on main is the best way to solve the CI problem:
git fetch upstream main
git rebase -i upstream/main # drop the three "ci:" commits| /// **Timezone Shifts:** | ||
| /// Conversions between timezone-naive and timezone-aware timestamps are | ||
| /// mathematically bijective (shifting the physical value by the timezone offset), | ||
| /// rather than many-to-one lossy. However, we return `true` here to block unwrapping | ||
| /// as an intentionally conservative guard. If we returned `false`, `unwrap_cast_in_comparison` | ||
| /// would strip the cast but fail to shift the underlying literal, returning incorrect | ||
| /// query results. (A robust alternative would be to allow the unwrap and shift the literal, | ||
| /// preserving pushdown and pruning.) Only UTC-equivalent timezones (where the shift is | ||
| /// exactly zero) are allowed to bypass this guard. |
There was a problem hiding this comment.
Fix before merge. The cast is not bijective. A naive local time in a DST gap has no instant. A naive local time in a DST fold has two instants. On this branch, both cases give an error:
SET datafusion.execution.time_zone = 'America/New_York';
SELECT TIMESTAMP '2024-11-03T01:30:00'::timestamptz; -- fold
SELECT TIMESTAMP '2024-03-10T02:30:00'::timestamptz; -- gap
-- Arrow error: Cast error: Cannot cast timezone to different timezoneThis is the real reason that "shift the literal instead" is not a small change. That alternative must handle these two cases first (see apache/arrow-rs#11038).
The conservative guard is the correct choice. But the comment must give the correct reason for it. The suggestion below also describes the one-direction change from my next comment.
| /// **Timezone Shifts:** | |
| /// Conversions between timezone-naive and timezone-aware timestamps are | |
| /// mathematically bijective (shifting the physical value by the timezone offset), | |
| /// rather than many-to-one lossy. However, we return `true` here to block unwrapping | |
| /// as an intentionally conservative guard. If we returned `false`, `unwrap_cast_in_comparison` | |
| /// would strip the cast but fail to shift the underlying literal, returning incorrect | |
| /// query results. (A robust alternative would be to allow the unwrap and shift the literal, | |
| /// preserving pushdown and pruning.) Only UTC-equivalent timezones (where the shift is | |
| /// exactly zero) are allowed to bypass this guard. | |
| /// **Timezone shifts:** Arrow casts a naive timestamp to a timezone-aware one by | |
| /// interpreting the naive value as local time in the target zone and shifting it by | |
| /// that zone's offset. `try_cast_numeric_literal` cannot apply the shift: it re-labels | |
| /// the integer. So a timezone-aware literal is reported as lossy against a naive | |
| /// target unless the zone's offset is always zero. The cast is not a bijection either: | |
| /// a local time in a DST gap has no instant, and a local time in a DST fold has two, | |
| /// so "shift the literal instead" is not a drop-in alternative. | |
| /// | |
| /// The opposite cast (timezone-aware -> naive) is a plain re-label in Arrow, so a naive | |
| /// literal is never lossy against a timezone-aware target. |
| if let (DataType::Timestamp(_, from_tz), DataType::Timestamp(_, to_tz)) = | ||
| (from_type, to_type) | ||
| { | ||
| match (from_tz, to_tz) { | ||
| (Some(tz), None) | (None, Some(tz)) | ||
| if !is_zero_offset_timezone(tz.as_ref()) => | ||
| { | ||
| return true; | ||
| } | ||
| _ => {} | ||
| } | ||
| } |
There was a problem hiding this comment.
Fix before merge. Only one direction needs the guard. Arrow shifts the value only for Timestamp(None) -> Timestamp(Some(tz)). The opposite cast is a re-label of the same integer (see _ => converted in arrow-cast). This query shows it:
SET datafusion.execution.time_zone = 'Asia/Singapore';
CREATE TABLE u AS SELECT '2024-10-31T16:00:00Z'::timestamptz AS tstz;
SELECT tstz::timestamp FROM u;
-- 2024-10-31T16:00:00 (the UTC wall clock, no shift)Thus CAST(tstz_col AS timestamp) = naive_literal was unwrapped correctly before this PR. With this PR the cast stays, and the rows are the same. I checked this on this branch and on a build without the fix.
The (None, Some(tz)) arm (a naive literal, a timezone-aware target) loses this optimization and gains nothing. The suggestion keeps only the arm that the bug needs. When the literal has a zone and the target does not, the two types are timestamps, so the date checks after this block cannot return true, and an early return is safe.
If you prefer to keep the wider guard, that is also fine with me. Then please write in the comment that it is intentionally wider than necessary, and keep the unit test as it is.
| if let (DataType::Timestamp(_, from_tz), DataType::Timestamp(_, to_tz)) = | |
| (from_type, to_type) | |
| { | |
| match (from_tz, to_tz) { | |
| (Some(tz), None) | (None, Some(tz)) | |
| if !is_zero_offset_timezone(tz.as_ref()) => | |
| { | |
| return true; | |
| } | |
| _ => {} | |
| } | |
| } | |
| if let (DataType::Timestamp(_, Some(tz)), DataType::Timestamp(_, None)) = | |
| (from_type, to_type) | |
| { | |
| return !is_zero_offset_timezone(tz.as_ref()); | |
| } |
| /// Returns true if the timezone is known to have a fixed zero offset from UTC. | ||
| /// | ||
| /// This is used to determine if a cast between a timezone-aware and timezone-naive | ||
| /// timestamp is lossy. If the timezone is strictly UTC-equivalent, the cast is | ||
| /// a lossless re-labeling of the integer value. | ||
| fn is_zero_offset_timezone(tz: &str) -> bool { | ||
| match tz { | ||
| // Standard UTC identifiers | ||
| "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.
Fix before merge. Some entries in this list are not valid Arrow timezones. Arrow rejects +0:00, -0:00 and Z:
Arrow error: Parser error: Invalid timezone "+0:00": failed to parse timezone
Arrow error: Parser error: Invalid timezone "Z": failed to parse timezone
Thus these entries never match. At the same time, Arrow accepts +0000, +00, -0000, Zulu, UCT, Universal and Etc/GMT0, and this list does not contain them. For all of them the cast stays. I checked this with EXPLAIN.
The direction of the error is safe. But the list is not correct today, and this shows that a list is hard to keep correct.
The suggestion parses the three fixed-offset shapes that Arrow accepts, and keeps a list only for the IANA aliases of UTC. Each name in the list is in chrono-tz. This also solves the DST problem that you described: a fixed offset does not change with the season, and the list has no geographic zones.
| /// Returns true if the timezone is known to have a fixed zero offset from UTC. | |
| /// | |
| /// This is used to determine if a cast between a timezone-aware and timezone-naive | |
| /// timestamp is lossy. If the timezone is strictly UTC-equivalent, the cast is | |
| /// a lossless re-labeling of the integer value. | |
| fn is_zero_offset_timezone(tz: &str) -> bool { | |
| match tz { | |
| // Standard UTC identifiers | |
| "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, | |
| } | |
| } | |
| /// Returns true if `tz` is a timezone whose offset from UTC is always zero, so that | |
| /// casting a naive timestamp to `Timestamp(_, Some(tz))` does not move the value. | |
| /// | |
| /// Arrow's timezone parser accepts three fixed-offset shapes (`+HH:MM`, `+HHMM`, | |
| /// `+HH`, with either sign) and otherwise an IANA name. A fixed offset is zero when | |
| /// all of its digits are zero. IANA names are accepted only from the list of UTC | |
| /// aliases below: a geographic zone such as `Europe/London` has a zero offset for | |
| /// part of the year only, so it is never accepted. The IANA lookup is case-sensitive, | |
| /// so `utc` is not a valid timezone and does not need to be listed. | |
| fn is_zero_offset_timezone(tz: &str) -> bool { | |
| match tz { | |
| "UTC" | "Etc/UTC" | "UCT" | "Etc/UCT" | "Universal" | "Etc/Universal" | |
| | "Zulu" | "Etc/Zulu" | "GMT" | "Etc/GMT" | "GMT0" | "Etc/GMT0" | "GMT+0" | |
| | "Etc/GMT+0" | "GMT-0" | "Etc/GMT-0" | "Greenwich" | "Etc/Greenwich" => true, | |
| _ => matches!( | |
| tz.strip_prefix(['+', '-']).map(str::as_bytes), | |
| Some(b"00" | b"0000" | b"00:00") | |
| ), | |
| } | |
| } |
| // 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)); | ||
| assert!(!is_lossy_temporal_cast(&ts_naive, &ts_etc_utc)); | ||
| assert!(!is_lossy_temporal_cast(&ts_naive, &ts_gmt)); | ||
|
|
||
| // 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)); |
There was a problem hiding this comment.
This test must change with the two suggestions above. It now covers two fixed-offset spellings, and it shows that only one direction is lossy.
| // 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)); | |
| assert!(!is_lossy_temporal_cast(&ts_naive, &ts_etc_utc)); | |
| assert!(!is_lossy_temporal_cast(&ts_naive, &ts_gmt)); | |
| // 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)); | |
| let ts_zero_offset = | |
| DataType::Timestamp(TimeUnit::Millisecond, Some("+00:00".into())); | |
| let ts_zero_offset_short = | |
| DataType::Timestamp(TimeUnit::Millisecond, Some("-0000".into())); | |
| let ts_offset = DataType::Timestamp(TimeUnit::Millisecond, Some("+08:00".into())); | |
| // Zero-offset zone <-> naive is NOT lossy: the cast does not move the value | |
| assert!(!is_lossy_temporal_cast(&ts_naive, &ts_utc)); | |
| assert!(!is_lossy_temporal_cast(&ts_utc, &ts_naive)); | |
| assert!(!is_lossy_temporal_cast(&ts_etc_utc, &ts_naive)); | |
| assert!(!is_lossy_temporal_cast(&ts_gmt, &ts_naive)); | |
| assert!(!is_lossy_temporal_cast(&ts_zero_offset, &ts_naive)); | |
| assert!(!is_lossy_temporal_cast(&ts_zero_offset_short, &ts_naive)); | |
| // A non-zero zone literal against a naive target is lossy: Arrow shifts the | |
| // column by the zone offset, and the re-labeled literal would not be shifted | |
| assert!(is_lossy_temporal_cast(&ts_sgt, &ts_naive)); | |
| assert!(is_lossy_temporal_cast(&ts_offset, &ts_naive)); | |
| // A naive literal against a timezone-aware target is not lossy: Arrow casts | |
| // timezone-aware -> naive by re-labeling the value | |
| assert!(!is_lossy_temporal_cast(&ts_naive, &ts_sgt)); | |
| assert!(!is_lossy_temporal_cast(&ts_naive, &ts_offset)); |
| ---- | ||
| 1 | ||
|
|
||
| # A timezone-aware column against a timezone-naive literal |
There was a problem hiding this comment.
Nit. This test does not exercise the guard. The coercion casts the literal, not the column, and that cast folds to a constant. EXPLAIN shows tstz = 1730390400000000000 on this branch and on main.
The test is still a good test. But please change the comment, so that a reader does not think it tests the other direction of the guard.
| # A timezone-aware column against a timezone-naive literal | |
| # A timezone-aware column against a timezone-naive literal. The coercion casts the | |
| # literal, not the column, so this query does not go through unwrap_cast at all |
| statement ok | ||
| set datafusion.execution.time_zone = 'UTC'; | ||
|
|
||
| # The explain output should show that the cast s::timestamptz has been removed |
There was a problem hiding this comment.
Nit. There is a tab character in this comment.
| # The explain output should show that the cast s::timestamptz has been removed | |
| # The explain output should show that the cast `ts::timestamptz` has been removed |
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.