From 1c4c4e070ceec1752a7595e50d80aa6c21e66235 Mon Sep 17 00:00:00 2001 From: Mryange Date: Fri, 24 Apr 2026 16:57:19 +0800 Subject: [PATCH] upd --- .../expressions/literal/DateTimeLiteral.java | 55 ++++++------ .../literal/TimestampTzLiteralTest.java | 43 ++++++++++ .../timestamptz/test_timestamptz_dst_gap.out | 14 +++ .../test_timestamptz_dst_gap.groovy | 86 +++++++++++++++++++ 4 files changed, 170 insertions(+), 28 deletions(-) create mode 100644 regression-test/data/datatype_p0/timestamptz/test_timestamptz_dst_gap.out create mode 100644 regression-test/suites/datatype_p0/timestamptz/test_timestamptz_dst_gap.groovy diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/literal/DateTimeLiteral.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/literal/DateTimeLiteral.java index 6fe85d077ee0e0..72479626670ce9 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/literal/DateTimeLiteral.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/literal/DateTimeLiteral.java @@ -154,23 +154,22 @@ public static Result 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; @@ -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; diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/literal/TimestampTzLiteralTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/literal/TimestampTzLiteralTest.java index a1961a61fbb1fe..4efb396a98e13a 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/literal/TimestampTzLiteralTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/literal/TimestampTzLiteralTest.java @@ -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"); + } } diff --git a/regression-test/data/datatype_p0/timestamptz/test_timestamptz_dst_gap.out b/regression-test/data/datatype_p0/timestamptz/test_timestamptz_dst_gap.out new file mode 100644 index 00000000000000..bdb242d3ced23d --- /dev/null +++ b/regression-test/data/datatype_p0/timestamptz/test_timestamptz_dst_gap.out @@ -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 + diff --git a/regression-test/suites/datatype_p0/timestamptz/test_timestamptz_dst_gap.groovy b/regression-test/suites/datatype_p0/timestamptz/test_timestamptz_dst_gap.groovy new file mode 100644 index 00000000000000..9a944ab77500a9 --- /dev/null +++ b/regression-test/suites/datatype_p0/timestamptz/test_timestamptz_dst_gap.groovy @@ -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; + """ +}