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 f4442b1c9 Keep FastDateFormat able to parse the '+hh:mm' offsets its
own 'Z' patterns format (#1857)
f4442b1c9 is described below
commit f4442b1c9c02945fea6165a9011dd1671fe5bce5
Author: 陈志谦 <[email protected]>
AuthorDate: Tue Nov 3 06:09:00 2026 +0800
Keep FastDateFormat able to parse the '+hh:mm' offsets its own 'Z' patterns
format (#1857)
FastDatePrinter formats three or more 'Z' pattern letters as the ISO 8601
extended offset "+hh:mm" (FastDatePrinterTest#testTimeZoneAsZ pins this),
but FastDateParser only accepted the colon-less RFC 822 form "+hhmm" for
those widths. A FastDateFormat therefore failed to parse its own output:
getInstance("yyyy-MM-ddZZZ").format(...) == "2023-10-27-04:00"
parse("2023-10-27-04:00") -> null, error index 10
For 'Z' with three or more letters, select a TimeZoneStrategy whose numeric
offset alternative also accepts "+hh:mm"; FastTimeZone.getGmtTimeZone()
already resolves the extended form. The variant is chosen only where the
printer emits it: 'z' and one-letter 'Z' keep exact SimpleDateFormat parity
(FastDateParserSDFTest passes unmodified), and two-letter 'Z' keeps its
existing ISO 8601 strategy. The variant accepts a strict superset of the
previous inputs and is cached per locale and cleared by
FastDateParser.clear().
New FastDateFormat_ZRoundTripTest fails on master and passes with this
change; the FastDate* suites (60k+ checks) pass.
Signed-off-by: simpleqt <[email protected]>
---
.../apache/commons/lang3/time/FastDateParser.java | 43 ++++++++++--
.../lang3/time/FastDateFormat_ZRoundTripTest.java | 78 ++++++++++++++++++++++
.../commons/lang3/time/FastDateParserTest.java | 4 +-
3 files changed, 119 insertions(+), 6 deletions(-)
diff --git a/src/main/java/org/apache/commons/lang3/time/FastDateParser.java
b/src/main/java/org/apache/commons/lang3/time/FastDateParser.java
index 887b6f8ab..0f0a177fc 100644
--- a/src/main/java/org/apache/commons/lang3/time/FastDateParser.java
+++ b/src/main/java/org/apache/commons/lang3/time/FastDateParser.java
@@ -527,8 +527,34 @@ public String toString() {
}
}
+ // The RFC 822 numeric offset form "+hhmm"; SimpleDateFormat rejects
"+hh:mm" here, and so must 'z'.
private static final String RFC_822_TIME_ZONE =
"[+-](?:2[0-3]|[01]\\d)[0-5]\\d";
+ /**
+ * Same as {@link #RFC_822_TIME_ZONE} but also accepts the ISO 8601
extended form {@code "+hh:mm"}.
+ */
+ private static final String ISO_8601_EXTENDED_TIME_ZONE =
"[+-](?:2[0-3]|[01]\\d):?[0-5]\\d";
+
+ /**
+ * Cache of {@link TimeZoneStrategy} instances that accept the ISO
8601 extended form, keyed by locale. Kept separate from the field caches
because 'z'
+ * (and 'Z' with a single letter) must keep exact SimpleDateFormat
parity and reject the extended form.
+ */
+ private static final ConcurrentMap<Locale, Strategy>
ISO_EXTENDED_ZONE_CACHE = new ConcurrentHashMap<>(3);
+
+ static void clearIsoExtendedCache() {
+ ISO_EXTENDED_ZONE_CACHE.clear();
+ }
+
+ /**
+ * Gets a {@link TimeZoneStrategy} that also parses the ISO 8601
extended offset form {@code "+hh:mm"}.
+ *
+ * @param locale The Locale.
+ * @return A shared strategy instance for the given Locale.
+ */
+ static Strategy getIsoExtendedStrategy(final Locale locale) {
+ return
ISO_EXTENDED_ZONE_CACHE.computeIfAbsent(LocaleUtils.toLocale(locale), k -> new
TimeZoneStrategy(k, true));
+ }
+
private static final String GMT_OPTION = TimeZones.GMT_ID +
"[+-]\\d{1,2}:\\d{2}";
/**
@@ -563,12 +589,14 @@ static boolean skipTimeZone(final String tzId) {
/**
* Constructs a Strategy that parses a TimeZone.
*
- * @param locale The Locale.
+ * @param locale The Locale.
+ * @param iso8601Extended Whether to also accept the ISO 8601 extended
offset form {@code "+hh:mm"} that FastDatePrinter emits for three or more 'Z'
+ * pattern letters.
*/
- TimeZoneStrategy(final Locale locale) {
+ TimeZoneStrategy(final Locale locale, final boolean iso8601Extended) {
this.locale = LocaleUtils.toLocale(locale);
final StringBuilder sb = new StringBuilder();
- sb.append("((?iu)" + RFC_822_TIME_ZONE + "|" + GMT_OPTION);
+ sb.append("((?iu)" + (iso8601Extended ?
ISO_8601_EXTENDED_TIME_ZONE : RFC_822_TIME_ZONE) + "|" + GMT_OPTION);
final Set<String> sorted = new TreeSet<>(LONGER_FIRST_LOWERCASE);
// Order is undefined.
// TODO Use of getZoneStrings() is discouraged per its Javadoc.
@@ -863,6 +891,7 @@ private static Map<String, Integer>
appendDisplayNames(final Calendar calendar,
*/
static void clear() {
Stream.of(CACHES).filter(Objects::nonNull).forEach(ConcurrentMap::clear);
+ TimeZoneStrategy.clearIsoExtendedCache();
}
/**
@@ -1064,7 +1093,8 @@ public Locale getLocale() {
*/
private Strategy getLocaleSpecificStrategy(final int field, final Calendar
definingCalendar) {
return getCache(field).computeIfAbsent(locale,
- k -> field == Calendar.ZONE_OFFSET ? new
TimeZoneStrategy(locale) : new CaseInsensitiveTextStrategy(field,
definingCalendar, locale));
+ k -> field == Calendar.ZONE_OFFSET ? new
TimeZoneStrategy(locale, false)
+ : new CaseInsensitiveTextStrategy(field,
definingCalendar, locale));
}
/*
@@ -1144,6 +1174,11 @@ private Strategy getStrategy(final char f, final int
width, final Calendar defin
if (width == 2) {
return ISO8601TimeZoneStrategy.ISO_8601_3_STRATEGY;
}
+ if (width >= 3) {
+ // FastDatePrinter formats three or more 'Z' as the ISO 8601
extended offset "+hh:mm", so parsing
+ // 'Z' must accept that form as well. 'z' keeps exact
SimpleDateFormat parity and rejects it.
+ return TimeZoneStrategy.getIsoExtendedStrategy(locale);
+ }
// falls-through
case 'z':
return getLocaleSpecificStrategy(Calendar.ZONE_OFFSET,
definingCalendar);
diff --git
a/src/test/java/org/apache/commons/lang3/time/FastDateFormat_ZRoundTripTest.java
b/src/test/java/org/apache/commons/lang3/time/FastDateFormat_ZRoundTripTest.java
new file mode 100644
index 000000000..b22ea9f4e
--- /dev/null
+++
b/src/test/java/org/apache/commons/lang3/time/FastDateFormat_ZRoundTripTest.java
@@ -0,0 +1,78 @@
+/*
+ * 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
+ *
+ * https://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.
+ */
+package org.apache.commons.lang3.time;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.text.ParsePosition;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+
+import org.apache.commons.lang3.AbstractLangTest;
+import org.junit.jupiter.api.Test;
+import org.junitpioneer.jupiter.ReadsDefaultLocale;
+import org.junitpioneer.jupiter.ReadsDefaultTimeZone;
+
+/**
+ * Tests that {@link FastDateFormat} can parse what its own 'Z' patterns
format: FastDatePrinter emits the
+ * ISO 8601 extended offset form "+hh:mm" for a pattern with two, or three or
more, 'Z' letters, so the
+ * parser side must accept that form.
+ */
+class FastDateFormat_ZRoundTripTest extends AbstractLangTest {
+
+ private static final TimeZone NEW_YORK =
TimeZones.getTimeZone("America/New_York");
+
+ private static Date parse(final String pattern, final String text) {
+ final ParsePosition parsePosition = new ParsePosition(0);
+ final Date date = FastDateFormat.getInstance(pattern, NEW_YORK,
Locale.US).parse(text, parsePosition);
+ assertNotNull(date, () -> pattern + " failed to parse '" + text + "'
(error index " + parsePosition.getErrorIndex() + ")");
+ return date;
+ }
+
+ @Test
+ @ReadsDefaultLocale
+ @ReadsDefaultTimeZone
+ void testParseExplicitColonOffset() {
+ // Same instant as the known-good 'ZZ' parsing of the identical text.
+ assertEquals(parse("yyyy-MM-ddZZ", "2023-10-27-05:30"),
parse("yyyy-MM-ddZZZ", "2023-10-27-05:30"));
+ }
+
+ @Test
+ @ReadsDefaultLocale
+ @ReadsDefaultTimeZone
+ void testRoundTripOneAndTwoZ() {
+ final Date date = new Date(1698446400000L);
+ assertEquals("2023-10-27-0400",
FastDateFormat.getInstance("yyyy-MM-ddZ", NEW_YORK, Locale.US).format(date));
+ assertEquals("2023-10-27-04:00",
FastDateFormat.getInstance("yyyy-MM-ddZZ", NEW_YORK, Locale.US).format(date));
+ assertEquals(parse("yyyy-MM-ddZZ", "2023-10-27-04:00"),
parse("yyyy-MM-ddZ", "2023-10-27-0400"));
+ }
+
+ @Test
+ @ReadsDefaultLocale
+ @ReadsDefaultTimeZone
+ void testRoundTripThreeOrMoreZ() {
+ for (int zCount = 3; zCount <= 6; zCount++) {
+ final String pattern = "yyyy-MM-dd" + "ZZZZZZZZ".substring(0,
zCount);
+ final FastDateFormat format = FastDateFormat.getInstance(pattern,
NEW_YORK, Locale.US);
+ final String text = format.format(new Date(1698446400000L));
+ assertEquals("2023-10-27-04:00", text, pattern);
+ assertEquals(parse("yyyy-MM-ddZZ", "2023-10-27-04:00"),
parse(pattern, text), pattern);
+ }
+ }
+}
diff --git
a/src/test/java/org/apache/commons/lang3/time/FastDateParserTest.java
b/src/test/java/org/apache/commons/lang3/time/FastDateParserTest.java
index a14352c2e..87b1aaaec 100644
--- a/src/test/java/org/apache/commons/lang3/time/FastDateParserTest.java
+++ b/src/test/java/org/apache/commons/lang3/time/FastDateParserTest.java
@@ -636,9 +636,9 @@ void testParseMissingTimeZoneName() throws
ReflectiveOperationException {
final Object cachedStrategy = FieldUtils.readField(zonePattern,
"strategy", true);
// The report supplies no concrete regex/TreeMap mismatch. Simulate a
matched name missing
// from the lookup using a private strategy instance, leaving the
shared cache untouched.
- final Constructor<?> constructor =
cachedStrategy.getClass().getDeclaredConstructor(Locale.class);
+ final Constructor<?> constructor =
cachedStrategy.getClass().getDeclaredConstructor(Locale.class, Boolean.TYPE);
constructor.setAccessible(true);
- final Object strategy = constructor.newInstance(Locale.US);
+ final Object strategy = constructor.newInstance(Locale.US,
Boolean.FALSE);
final Map<?, ?> names = (Map<?, ?>) FieldUtils.readField(strategy,
"tzNames", true);
assertNotNull(names.remove("PST"));
FieldUtils.writeField(zonePattern, "strategy", strategy, true);