Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -154,23 +154,22 @@ public static Result<DateTimeLiteral, AnalysisException> parseDateTimeLiteral(St

ZoneId zoneId = temporal.query(TemporalQueries.zone());
if (zoneId != null) {
// get correct DST of that time.
// Convert the parsed civil time to an absolute instant first; this correctly
// handles DST spring-forward gaps (Java snaps forward) and fall-back folds.
// Then convert that instant to the session timezone so the local fields are
// consistent. The old approach computed an offset from `thatTime` and applied
// it to the *pre-snap* local fields, which was wrong for gap times.
Instant thatTime = ZonedDateTime
.of((int) year, (int) month, (int) day, (int) hour, (int) minute, (int) second, 0, zoneId)
.toInstant();

int offset = DateUtils.getTimeZone().getRules().getOffset(thatTime).getTotalSeconds()
- zoneId.getRules().getOffset(thatTime).getTotalSeconds();
if (offset != 0) {
DateTimeLiteral tempLiteral = new DateTimeLiteral(year, month, day, hour, minute, second);
DateTimeLiteral result = (DateTimeLiteral) tempLiteral.plusSeconds(offset);
second = result.second;
minute = result.minute;
hour = result.hour;
day = result.day;
month = result.month;
year = result.year;
}
ZonedDateTime inSessionTz = thatTime.atZone(DateUtils.getTimeZone());
year = inSessionTz.getYear();
month = inSessionTz.getMonthValue();
day = inSessionTz.getDayOfMonth();
hour = inSessionTz.getHour();
minute = inSessionTz.getMinute();
second = inSessionTz.getSecond();
}

long microSecond = DateUtils.getOrDefault(temporal, ChronoField.NANO_OF_SECOND) / 100L;
Expand Down Expand Up @@ -237,28 +236,28 @@ protected void init(String s) throws AnalysisException {

ZoneId zoneId = temporal.query(TemporalQueries.zone());
if (zoneId != null) {
// get correct DST of that time.
// Convert the parsed civil time to an absolute instant first; this correctly
// handles DST spring-forward gaps (Java snaps forward) and fall-back folds.
// Then project that instant into the target timezone so the stored fields are
// always consistent. The old approach computed an offset at `thatTime` and
// applied it to the *pre-snap* local fields, which was wrong for gap times.
Instant thatTime = ZonedDateTime
.of((int) year, (int) month, (int) day, (int) hour, (int) minute, (int) second, 0, zoneId)
.toInstant();

int offset = 0;
ZoneId targetZone;
if (this.dataType instanceof TimeStampTzType) {
offset = ZoneId.of("UTC").getRules().getOffset(thatTime).getTotalSeconds()
- zoneId.getRules().getOffset(thatTime).getTotalSeconds();
targetZone = ZoneId.of("UTC");
} else {
offset = DateUtils.getTimeZone().getRules().getOffset(thatTime).getTotalSeconds()
- zoneId.getRules().getOffset(thatTime).getTotalSeconds();
}
if (offset != 0) {
DateTimeLiteral result = (DateTimeLiteral) this.plusSeconds(offset);
this.second = result.second;
this.minute = result.minute;
this.hour = result.hour;
this.day = result.day;
this.month = result.month;
this.year = result.year;
targetZone = DateUtils.getTimeZone();
}
ZonedDateTime inTarget = thatTime.atZone(targetZone);
this.year = inTarget.getYear();
this.month = inTarget.getMonthValue();
this.day = inTarget.getDayOfMonth();
this.hour = inTarget.getHour();
this.minute = inTarget.getMinute();
this.second = inTarget.getSecond();
}

microSecond = DateUtils.getOrDefault(temporal, ChronoField.NANO_OF_SECOND) / 100L;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,4 +300,47 @@ void testPlusSecondMicrosecond() {
Assertions.assertEquals(58, result.second);
Assertions.assertEquals(900000, result.microSecond);
}

/**
* Regression test for DST spring-forward gap handling.
*
* 2024-03-10 02:30:00 does not exist in America/New_York (clocks spring forward
* from 02:00 EST to 03:00 EDT). Java's ZonedDateTime snaps this to 03:30 EDT
* = 07:30 UTC. Both the named-timezone path and the implicit-session-timezone path
* must resolve to the same UTC instant.
*
* Bug: the named-timezone path applied the *post-gap* UTC offset (-04:00 EDT) to
* the *pre-snap* local time (02:30), producing 06:30 UTC instead of 07:30 UTC.
*/
@Test
void testSpringForwardGapConsistency() {
// Path 1: named timezone suffix in the literal
// 2024-03-10 02:30:00 America/New_York is inside the spring-forward gap.
// Java ZonedDateTime snaps it to 03:30 EDT = 07:30 UTC.
TimestampTzLiteral namedTz = new TimestampTzLiteral("2024-03-10 02:30:00 America/New_York");
// UTC fields stored in the literal must reflect 07:30 UTC
Assertions.assertEquals(2024, namedTz.year, "year mismatch for named-tz path");
Assertions.assertEquals(3, namedTz.month, "month mismatch for named-tz path");
Assertions.assertEquals(10, namedTz.day, "day mismatch for named-tz path");
Assertions.assertEquals(7, namedTz.hour, "hour mismatch for named-tz path (expected 07:30 UTC after snap)");
Assertions.assertEquals(30, namedTz.minute, "minute mismatch for named-tz path");
Assertions.assertEquals(0, namedTz.second, "second mismatch for named-tz path");

// Times just before/after the gap should be unaffected.
// 01:30 EST (-05:00) = 06:30 UTC
TimestampTzLiteral beforeGap = new TimestampTzLiteral("2024-03-10 01:30:00 America/New_York");
Assertions.assertEquals(6, beforeGap.hour, "before-gap hour should be 06 UTC");
Assertions.assertEquals(30, beforeGap.minute, "before-gap minute should be 30");

// 03:30 EDT (-04:00) = 07:30 UTC
TimestampTzLiteral afterGap = new TimestampTzLiteral("2024-03-10 03:30:00 America/New_York");
Assertions.assertEquals(7, afterGap.hour, "after-gap hour should be 07 UTC");
Assertions.assertEquals(30, afterGap.minute, "after-gap minute should be 30");

// The gap-time result must equal the after-gap result (both snap to 07:30 UTC).
Assertions.assertEquals(afterGap.hour, namedTz.hour,
"gap time and post-gap time must resolve to same UTC hour");
Assertions.assertEquals(afterGap.minute, namedTz.minute,
"gap time and post-gap time must resolve to same UTC minute");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- This file is automatically generated. You should know what you did if you want to edit this
-- !dst_gap_utc --
1 named_gap_ny 2024-03-10 07:30:00.000000+00:00
2 explicit_before_gap 2024-03-10 06:30:00.000000+00:00
3 explicit_after_gap 2024-03-10 07:30:00.000000+00:00
4 implicit_gap_ny 2024-03-10 07:30:00.000000+00:00
5 implicit_after_gap 2024-03-10 07:30:00.000000+00:00

-- !gap_named_eq_implicit --
1

-- !count_distinct_gap --
2

Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

// Regression test for DST spring-forward gap handling in TIMESTAMPTZ literals.
//
// 2024-03-10 02:30:00 does not exist in America/New_York: at 02:00 EST clocks
// spring forward to 03:00 EDT. Java ZonedDateTime snaps this to 03:30 EDT
// (= 07:30 UTC).
//
// Bug: inserting '2024-03-10 02:30:00 America/New_York' (named-tz literal path)
// produced 06:30 UTC, whereas the implicit session-timezone path produced the
// correct 07:30 UTC. The two paths must agree.

suite("test_timestamptz_dst_gap") {

sql "DROP TABLE IF EXISTS tz_dst_gap_reg;"

sql """
CREATE TABLE tz_dst_gap_reg (
id INT,
label VARCHAR(64),
ts_tz TIMESTAMPTZ(6)
)
DUPLICATE KEY(id)
DISTRIBUTED BY HASH(id) BUCKETS 1
PROPERTIES('replication_num' = '1');
"""

// Path 1: named timezone suffix in the literal (session tz = Asia/Shanghai)
sql "SET time_zone = 'Asia/Shanghai';"
sql """
INSERT INTO tz_dst_gap_reg VALUES
(1, 'named_gap_ny', '2024-03-10 02:30:00 America/New_York'),
(2, 'explicit_before_gap', '2024-03-10 01:30:00 -05:00'),
(3, 'explicit_after_gap', '2024-03-10 03:30:00 -04:00');
"""

// Path 2: implicit session timezone (session tz = America/New_York)
sql "SET time_zone = 'America/New_York';"
sql """
INSERT INTO tz_dst_gap_reg VALUES
(4, 'implicit_gap_ny', '2024-03-10 02:30:00'),
(5, 'implicit_after_gap', '2024-03-10 03:30:00');
"""

// Render all values in UTC and compare
sql "SET time_zone = '+00:00';"

// id=1 (named-tz gap) and id=4 (implicit-tz gap) must resolve to the same
// UTC instant: 07:30 UTC (snap-forward from 02:30 → 03:30 EDT = 07:30 UTC).
// id=3 and id=5 are the same unambiguous post-gap time: 07:30 UTC as well.
// id=2 is the pre-gap reference: 01:30 EST = 06:30 UTC.
order_qt_dst_gap_utc """
SELECT id, label, CAST(ts_tz AS VARCHAR(64)) AS rendered_utc
FROM tz_dst_gap_reg
ORDER BY id;
"""

// id=1 and id=4 must be equal (same UTC instant)
qt_gap_named_eq_implicit """
SELECT COUNT(*) AS both_same_utc
FROM tz_dst_gap_reg a
JOIN tz_dst_gap_reg b ON a.ts_tz = b.ts_tz
WHERE a.id = 1 AND b.id = 4;
"""

// COUNT(DISTINCT ts_tz): gap time and post-gap time are the same instant,
// so id=1,3,4,5 collapse to 1 distinct value; id=2 is distinct → total 2.
qt_count_distinct_gap """
SELECT COUNT(DISTINCT ts_tz) AS cnt FROM tz_dst_gap_reg;
"""
}
Loading