This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-lang.git
The following commit(s) were added to refs/heads/master by this push:
new 75086d398 keep DateUtils.ceiling and round inside a DST overlap hour
(#1842)
75086d398 is described below
commit 75086d398acd1e5c64340e7276d775fbc4d767f5
Author: alhuda <[email protected]>
AuthorDate: Tue Oct 27 17:54:41 2026 +0530
keep DateUtils.ceiling and round inside a DST overlap hour (#1842)
modify() removes the fields below the target field in two ways: the
LANG-59 block subtracts milliseconds, seconds and minutes from the epoch
time, but only when the value rounds down, and the field loop then clears
whatever is left with Calendar.set(). Calendar.set() recomputes the
instant from the wall-clock fields, and GregorianCalendar resolves a wall
time inside a DST fall-back overlap to standard time, so ceiling and
round of an instant in the daylight pass of the repeated hour were moved
into the standard-time pass an hour later. Remove the three sub-hour
fields arithmetically in the loop as well, so the instant keeps its
offset; DATE and larger fields still use Calendar.set() for wall-clock
semantics, and truncate is unchanged.
---
.../org/apache/commons/lang3/time/DateUtils.java | 17 ++++++++++++-
.../apache/commons/lang3/time/DateUtilsTest.java | 29 ++++++++++++++++++++++
2 files changed, 45 insertions(+), 1 deletion(-)
diff --git a/src/main/java/org/apache/commons/lang3/time/DateUtils.java
b/src/main/java/org/apache/commons/lang3/time/DateUtils.java
index 2f354037e..1272f4f4e 100644
--- a/src/main/java/org/apache/commons/lang3/time/DateUtils.java
+++ b/src/main/java/org/apache/commons/lang3/time/DateUtils.java
@@ -1347,7 +1347,22 @@ private static Calendar modify(final Calendar val, final
int field, final Modify
}
// We need to remove this field
if (offset != 0) {
- val.set(aField[0], val.get(aField[0]) - offset);
+ switch (aField[0]) {
+ // Remove the sub-hour fields arithmetically, like the LANG-59
block above: Calendar.set() resolves
+ // an ambiguous wall time in a DST overlap to standard time,
shifting the result by an hour.
+ case Calendar.MILLISECOND:
+ val.setTimeInMillis(val.getTimeInMillis() - offset);
+ break;
+ case Calendar.SECOND:
+ val.setTimeInMillis(val.getTimeInMillis() - offset *
MILLIS_PER_SECOND);
+ break;
+ case Calendar.MINUTE:
+ val.setTimeInMillis(val.getTimeInMillis() - offset *
MILLIS_PER_MINUTE);
+ break;
+ default:
+ val.set(aField[0], val.get(aField[0]) - offset);
+ break;
+ }
}
}
throw new IllegalArgumentException("The field " + field + " is not
supported");
diff --git a/src/test/java/org/apache/commons/lang3/time/DateUtilsTest.java
b/src/test/java/org/apache/commons/lang3/time/DateUtilsTest.java
index 594dbe6dd..f2482d716 100644
--- a/src/test/java/org/apache/commons/lang3/time/DateUtilsTest.java
+++ b/src/test/java/org/apache/commons/lang3/time/DateUtilsTest.java
@@ -1807,6 +1807,35 @@ void testTruncateLang59() {
assertEquals(oct31MDT, DateUtils.round(oct31_01_02_03_04MDT,
Calendar.DATE), "Round Calendar.DATE");
}
+ /**
+ * Tests that ceiling and round keep the offset of a time inside a DST
overlap when a field below the target is at or above its halfway
+ * point, which modify() removes with Calendar.set() rather than the
LANG-59 arithmetic used for the round-down case.
+ */
+ @DefaultTimeZone("Europe/London")
+ @Test
+ void testCeilingAndRoundDstOverlap() {
+ // 2024-10-27 01:00-02:00 BST repeats as 01:00-02:00 GMT;
ZonedDateTime.of() picks the earlier offset, BST.
+ final ZonedDateTime bst = ZonedDateTime.of(2024, 10, 27, 1, 8, 58,
245_000_000, ZoneId.of("Europe/London"));
+ assertEquals(ZoneOffset.ofHours(1), bst.getOffset());
+ final ZonedDateTime gmt = bst.withLaterOffsetAtOverlap();
+ assertEquals(ZoneOffset.UTC, gmt.getOffset());
+ for (final ZonedDateTime zdt : new ZonedDateTime[] { bst, gmt }) {
+ final Date date = Date.from(zdt.toInstant());
+ final ZonedDateTime minute = zdt.truncatedTo(ChronoUnit.MINUTES);
+ final ZonedDateTime hour = zdt.truncatedTo(ChronoUnit.HOURS);
+
assertEquals(Date.from(zdt.truncatedTo(ChronoUnit.SECONDS).plusSeconds(1).toInstant()),
DateUtils.ceiling(date, Calendar.SECOND), "Ceiling Calendar.SECOND " + zdt);
+ assertEquals(Date.from(minute.plusMinutes(1).toInstant()),
DateUtils.ceiling(date, Calendar.MINUTE), "Ceiling Calendar.MINUTE " + zdt);
+ assertEquals(Date.from(minute.plusMinutes(1).toInstant()),
DateUtils.round(date, Calendar.MINUTE), "Round Calendar.MINUTE " + zdt);
+ assertEquals(Date.from(hour.plusHours(1).toInstant()),
DateUtils.ceiling(date, Calendar.HOUR_OF_DAY), "Ceiling Calendar.HOUR_OF_DAY "
+ zdt);
+ assertEquals(Date.from(hour.plusHours(1).toInstant()),
DateUtils.ceiling(date, Calendar.HOUR), "Ceiling Calendar.HOUR " + zdt);
+ assertEquals(Date.from(hour.toInstant()), DateUtils.round(date,
Calendar.HOUR_OF_DAY), "Round Calendar.HOUR_OF_DAY " + zdt);
+ // 38 minutes past rounds up to the next hour
+ final Date late = Date.from(zdt.withMinute(38).toInstant());
+ assertEquals(Date.from(hour.plusHours(1).toInstant()),
DateUtils.round(late, Calendar.HOUR_OF_DAY), "Round up Calendar.HOUR_OF_DAY " +
zdt);
+ assertEquals(Date.from(hour.plusHours(1).toInstant()),
DateUtils.round(late, Calendar.HOUR), "Round up Calendar.HOUR " + zdt);
+ }
+ }
+
/**
* Tests the calendar iterator for week ranges
*/