feat: support timezone, timezone_hour and timezone_minute in date_part - #25163
feat: support timezone, timezone_hour and timezone_minute in date_part#25163adriangb wants to merge 3 commits into
timezone, timezone_hour and timezone_minute in date_part#25163Conversation
`parse_tz` turns the optional timezone string carried by `DataType::Timestamp` into an `arrow::array::timezone::Tz`. It was private to `date_trunc.rs`; move it next to the other shared datetime helpers so other functions in the crate can reuse it instead of rolling their own. No behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ate_part`
`date_part('timezone', ...)` previously failed with
`Execution error: Date part 'timezone' not supported`. Add the three
PostgreSQL timezone fields:
- `timezone` - the UTC offset in seconds
- `timezone_hour` - whole hours of the offset
- `timezone_minute` - whole minutes of the offset, excluding the hours
The offset of a named timezone changes with daylight saving time, so these
are computed per row from the instant rather than from the type alone:
`Europe/Brussels` gives 3600 in January and 7200 in July. Offsets that are
not whole hours are handled (`Asia/Kolkata` -> 19800/5/30,
`Pacific/Chatham` -> 45900/12/45), and for a negative offset both the hour
and the minute carry the sign (`America/St_Johns` -> -12600/-3/-30), which
is PostgreSQL's convention.
These fields are only defined for timestamps that carry a timezone. A
timezone-naive timestamp has no offset, so like PostgreSQL we reject it
with a clear error rather than silently assuming UTC; the same applies to
dates, times, intervals and durations.
Like every other `date_part` field except `epoch`, the result is `Int32`
(PostgreSQL returns `double precision` for all fields; DataFusion already
diverges there for the integral fields).
`EXTRACT(TIMEZONE FROM ...)`, `EXTRACT(TIMEZONE_HOUR FROM ...)` and
`EXTRACT(TIMEZONE_MINUTE FROM ...)` work too - the SQL planner forwards the
`DateTimeField` to `date_part` as a string, and sqlparser already carries
those variants.
Expected values in the tests were cross-checked against PostgreSQL 17.
Part of apache#10368
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c255bf6 to
5b7548e
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #25163 +/- ##
==========================================
+ Coverage 81.91% 81.92% +0.01%
==========================================
Files 1132 1132
Lines 420570 421356 +786
Branches 420570 421356 +786
==========================================
+ Hits 344490 345192 +702
- Misses 55764 55771 +7
- Partials 20316 20393 +77 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Heads-up on a merge-order interaction: #25175 (timezone characterization tests) currently pins the current behaviour of these fields as errors — — in Once this PR lands, those two |
|
Self-review: a QA pass on my own PR. I checked every claim in the description against a real PostgreSQL 17.11 and a real DuckDB 1.5.2, and I re-ran the build and the tests. I found no correctness bug. The sign convention, the return type and the 1.
|
| engine | zone | instant | timezone |
timezone_hour |
timezone_minute |
|---|---|---|---|---|---|
| PostgreSQL 17.11 | Africa/Monrovia |
1900-01-01T12:00:00Z | -2588 | 0 | -43 |
| this PR | Africa/Monrovia |
1900-01-01T12:00:00Z | -2588 | 0 | -43 |
Truncation towards zero matches on the seconds too, so this is an exact match and not a near miss. No modern zone has a negative offset under one hour, so only historical LMT data reaches it.
Two suggestions:
- reword to "the minute carries the sign of the offset, and so does the hour when the offset is a whole hour or more";
- add the
Africa/Monroviarow totimezone_parts_match_postgres. It is the one case the current 15-row table does not reach, and both engines agree on it.
3. Move to_lowercase inside TimezonePart::parse
The call site is correct today, and I confirmed mixed case works after the #24906 rebase. But the invariant is fragile:
Err(_) => match TimezonePart::parse(&part_trim.to_lowercase()) {part_normalization strips quotes only; it does not lower the case. DatePart::from_str and is_epoch each lower the case inside themselves. TimezonePart::parse is the one function in the file that depends on its caller. A second caller would silently break date_part('TIMEZONE_HOUR', ...), and no local test catches it. Move .to_lowercase() into parse so the type owns its own contract.
4. Two error shapes for one condition
The two messages read as two different rules:
Date part 'timezone' is not supported for timezone-naive timestamps, got Timestamp(ns)Date part 'timezone' is only supported for timestamps with a timezone, got Date32
Both mean "this value has no UTC offset". One shape is easier to grep for and easier to document. Neither message tells the user what to do next. A hint helps: name AT TIME ZONE or datafusion.execution.time_zone.
5. Two small errors in the PR description
- The count is
349 tests; the real number is353. The lib suite goes from 344 to 353. - The "After this PR" block shows a column named
utc_offset_seconds, but the query carries no such alias.
I fix both in the rewritten description.
What I checked, and what it measured
Sign convention, against PostgreSQL 17.11. SET TimeZone TO '<zone>', then date_part('<part>', timestamptz '<instant>'). All 10 rows of the table in the PR description reproduce exactly, and 6 more zone and instant pairs do too:
| zone | instant | timezone |
timezone_hour |
timezone_minute |
|---|---|---|---|---|
America/St_Johns |
2024-01-01T12:00:00Z | -12600 | -3 | -30 |
America/St_Johns |
2024-07-01T12:00:00Z | -9000 | -2 | -30 |
Pacific/Chatham |
2024-01-01T12:00:00Z | 49500 | 13 | 45 |
Pacific/Chatham |
2024-07-01T12:00:00Z | 45900 | 12 | 45 |
Asia/Kolkata |
both | 19800 | 5 | 30 |
Asia/Kathmandu |
both | 20700 | 5 | 45 |
Europe/Brussels |
Jan / Jul | 3600 / 7200 | 1 / 2 | 0 / 0 |
America/Denver |
Jan / Jul | -25200 / -21600 | -7 / -6 | 0 / 0 |
Pacific/Marquesas |
both | -34200 | -9 | -30 |
UTC |
both | 0 | 0 | 0 |
So the negative minute is confirmed, not assumed.
DuckDB 1.5.2 supports all three fields, and it agrees with PostgreSQL on every zone above. Two useful differences:
- DuckDB returns
BIGINT, notdouble precision. So an integer return type has precedent, andInt32here is not an outlier. - DuckDB returns
0for a timezone-naive timestamp; PostgreSQL raisesunit "timezone" not supported for type timestamp without time zone. This PR follows PostgreSQL. That is the right call, but the description presents0as self-evidently wrong when a major engine ships it. Worth a mention rather than a silent omission.
DuckDB rejects DATE and INTERVAL input, as this PR does.
Return type. return_field_from_args gives Float64 for epoch, Int64 for nanosecond and Int32 for everything else. So the claim in the description holds, and Int32 is the consistent choice. The divergence from PostgreSQL's double precision is pre-existing and applies to hour, minute and the rest already. It does not need a call-out in this PR.
EXTRACT needs no planner change. Confirmed end to end. sqlparser 0.62 renders DateTimeField::TimezoneHour as TIMEZONE_HOUR, the planner forwards that string, and the .slt cases for all three fields pass.
Case insensitivity after the #24906 rebase. date_part('TIMEZONE', ...), date_part('TIMEZONE_HOUR', ...) and date_part('Timezone_Minute', ...) all work, in the unit tests and in the .slt. See nit 3 for the fragility, not for a defect. Leading and trailing whitespace fails (date_part(' timezone ', ...)), exactly as it fails for every other part on main. Quoted spellings such as '''timezone''' work.
Nullability. unary_opt can turn an out-of-range value into a NULL even when the input field is not nullable. date_part('hour', ...) on the same value does the same thing, so Arrow's own kernel already behaves this way. Pre-existing and consistent, so no action.
preimage. DatePart::from_str("timezone") fails, so preimage returns PreimageResult::None and no filter rewrite fires. Constant folding still works: EXPLAIN shows Projection: Int32(7200).
Both commits build green on their own.
- commit 1 (
refactor: move parse_tz):cargo check --workspace --all-targetsclean,cargo clippy -p datafusion-functions --all-targets -- -D warningsclean, 344 lib tests pass. - commit 2 (
feat: ... date_part):cargo clippy --all-targets -- -D warningsclean on the whole workspace, 353 lib tests pass,datetime/date_part.sltgreen.
Generated docs are in sync. I re-ran print_functions_docs -- scalar and diffed the date_part section against the committed file. The content matches; the only differences are the prettier reflow that dev/update_function_docs.sh applies. ./ci/scripts/doc_prettier_check.sh and typos are both clean.
The doc example is real. SELECT date_part('timezone', TIMESTAMP '2024-07-01T12:00:00' AT TIME ZONE 'Europe/Brussels') returns 7200, and the input type is Timestamp(ns, "Europe/Brussels").
Merge order. #25175 pins Date part 'timezone_hour' not supported in its SECTION 9. Whichever PR merges second must update the other. That PR already records the collision in its own description.
🤖 Generated with Claude Code
The documentation said "for a negative offset both parts carry the sign". That is inexact when the offset is smaller than one hour: the hour part is zero, and zero cannot show a sign, so only the minute part is negative. The implementation was already correct. `Africa/Monrovia` before 1972 has an offset of -00:43:08, and this returns -2588 / 0 / -43, which is what PostgreSQL 17 returns for the same instant. Only the wording was wrong. Adds that case to the PostgreSQL comparison table, which previously had no sub-hour offset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Which issue does this PR close?
date_partmight be a nice to have". The other bullets stay open, so this PR does not close the issue.Rationale for this change
What a user sees today
A timezone-aware timestamp carries a UTC offset, but nothing in DataFusion can read it back:
timezone_hourandtimezone_minutefail the same way. There is no workaround. The offset is not a property of the type either, so a user cannot look it up once and reuse it: for a named zone it moves with daylight saving time.What a user sees after this PR
7200is +02:00, the summer offset ofEurope/Brussels.The new capability, in plain terms
Three new
date_partfields report the UTC offset of a timezone-aware timestamp:Europe/Brussels, Julytimezone7200timezone_hour2timezone_minute0Three things follow from the definition:
Europe/Brusselsgives3600in January and7200in July.Asia/Kolkatagives19800 / 5 / 30.America/St_Johnsin January gives-12600 / -3 / -30.EXTRACT(TIMEZONE_HOUR FROM ...)is the alternative syntax for the same thing.What changes are included in this PR?
Two commits.
1.
refactor: move parse_tz from date_trunc into datetime::common. No behaviour change.parse_tzturns the optional timezone string ofDataType::Timestampinto anarrow::array::timezone::Tz. It was private todate_trunc.rs. It now sits next to the other shared datetime helpers, sodate_partreuses it instead of a second parser.2.
feat: support timezone, timezone_hour and timezone_minute in date_part. The three fields, with these design points:as_datetime_with_timezone::<T>, and the code reads its offset back. One pass ofPrimitiveArray::unary_optcomputes the field, so there is no intermediate offset array. Input nulls survive.Timestamp(_, None)carries no offset. The error text isDate part 'timezone' is not supported for timezone-naive timestamps, got Timestamp(ns). Dates, times, intervals and durations get a similar error. PostgreSQL rejects these inputs too. DuckDB instead returns0, which this PR treats as the weaker choice: a plain0is indistinguishable from a real UTC value.Int32. DataFusion already returnsInt32for every integraldate_partfield, and the two exceptions areepoch(Float64) andnanosecond(Int64). PostgreSQL returnsdouble precisionfor all fields, but DataFusion diverges from that today forhour,minuteand the rest. DuckDB returnsBIGINT. So an integer type is both consistent here and precedented elsewhere.EXTRACTneeds no planner change.SQLExpr::Extractforwards theDateTimeFieldtodate_partas itsDisplaystring. sqlparser already carries theTimezone,TimezoneHourandTimezoneMinutevariants, soEXTRACT(TIMEZONE_HOUR FROM ...)arrives as"TIMEZONE_HOUR"and matches without regard to case.No existing field changes behaviour. The new parts sit in the
Err(_)arm that used to returnDate part '{part}' not supported.Field research
The sign of
timezone_minuteon a negative offset is easy to get backwards, so I measured it rather than assumed it.PostgreSQL 17.11, in Docker, with
SET TimeZone TO '<zone>'and atimestamptzliteral:timezonetimezone_hourtimezone_minuteAsia/KolkataAsia/KathmanduAmerica/DenverAmerica/St_JohnsPacific/MarquesasEurope/BrusselsEurope/BrusselsPacific/ChathamPacific/ChathamUTCSo the minute follows the sign of the offset.
Pacific/Chathamis in the southern hemisphere, so its January value is the DST one. Also measured on the same server:NULLin givesNULLout.tzis not an accepted spelling.date,timeandintervalinput are all rejected.DuckDB 1.5.2 supports all three fields. This is worth stating plainly, because the opposite would also be a useful finding. DuckDB agrees with PostgreSQL on every zone and instant in the table above, sign included. Two differences from PostgreSQL:
BIGINT, where PostgreSQL returnsdouble precision.0for a timezone-naiveTIMESTAMP, where PostgreSQL raisesunit "timezone" not supported for type timestamp without time zone.DuckDB rejects
DATEandINTERVALinput, as this PR does.This PR matches both engines on every value, and it follows PostgreSQL on the timezone-naive error.
What is the testing strategy for this PR?
Unit tests in
datafusion/functions/src/datetime/date_part.rs, 9 new tests.timezone_parts_match_postgresis a table of 15 zone and instant combinations, asserted for all three fields. It covers every row of the PostgreSQL table above. The arrays are built by a relabel of UTC instants, so the cases line up exactly. The other 8 tests cover:EXTRACTproduces;TimeUnitvariants;NULLinput;Int32return field.sqllogictest cases appended to
datafusion/sqllogictest/test_files/datetime/date_part.slt:+05:30and-03:30;Pacific/Chathamcases;EXTRACT(...)syntax;arrow_typeofof all three results;NULLinput;Verified locally, on each commit separately:
cargo check --workspace --all-targetsclean,cargo clippy -p datafusion-functions --all-targets -- -D warningsclean, 344 lib tests pass.cargo clippy --all-targets -- -D warningsclean across the workspace, 353 lib tests pass, the full sqllogictest suite passes.cargo fmt --all,./ci/scripts/doc_prettier_check.shandtyposare all clean.Are there any user-facing changes?
Yes, and they are additive. Three new
date_partandEXTRACTfields. No public API changes, and no existing field changes behaviour, so this PR needs noapi changelabel.docs/source/user-guide/sql/scalar_functions.mdcomes from theuser_doc!block throughdev/update_function_docs.sh. It gains the three parts, a note on the DST and sign behaviour, the timezone-only restriction, and a worked example.One known limit:
datafusion.execution.time_zonedefaults toNone, sonow()is timezone-naive anddate_part('timezone', now())errors.SET datafusion.execution.time_zonemakes it work. That is #25166 and it is out of scope here.Merge order
#25175 is a characterization suite. Its SECTION 9 pins the current rejection:
Once this PR lands,
timezone_hourandtimezone_minutereturn-6and0for every row ofday_denver. So whichever of the two merges second must update the other, and its CI goes red until it does. That PR records the same collision in its own description.🤖 Generated with Claude Code