diff --git a/spring-context/src/main/java/org/springframework/format/datetime/DateFormatter.java b/spring-context/src/main/java/org/springframework/format/datetime/DateFormatter.java
index c3f8811a914..0d6bde41743 100644
--- a/spring-context/src/main/java/org/springframework/format/datetime/DateFormatter.java
+++ b/spring-context/src/main/java/org/springframework/format/datetime/DateFormatter.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2002-2023 the original author or authors.
+ * Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -22,8 +22,10 @@ import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.EnumMap;
+import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Map;
+import java.util.Set;
import java.util.TimeZone;
import org.springframework.format.Formatter;
@@ -35,9 +37,14 @@ import org.springframework.util.StringUtils;
/**
* A formatter for {@link java.util.Date} types.
+ *
*
Supports the configuration of an explicit date time pattern, timezone,
* locale, and fallback date time patterns for lenient parsing.
*
+ *
Common ISO patterns for UTC instants are applied at millisecond precision.
+ * Note that {@link org.springframework.format.datetime.standard.InstantFormatter}
+ * is recommended for flexible UTC parsing into a {@link java.time.Instant} instead.
+ *
* @author Keith Donald
* @author Juergen Hoeller
* @author Phillip Webb
@@ -49,15 +56,23 @@ public class DateFormatter implements Formatter {
private static final TimeZone UTC = TimeZone.getTimeZone("UTC");
- // We use an EnumMap instead of Map.of(...) since the former provides better performance.
private static final Map ISO_PATTERNS;
+ private static final Map ISO_FALLBACK_PATTERNS;
+
static {
+ // We use an EnumMap instead of Map.of(...) since the former provides better performance.
Map formats = new EnumMap<>(ISO.class);
formats.put(ISO.DATE, "yyyy-MM-dd");
formats.put(ISO.TIME, "HH:mm:ss.SSSXXX");
formats.put(ISO.DATE_TIME, "yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
ISO_PATTERNS = Collections.unmodifiableMap(formats);
+
+ // Fallback format for the time part without milliseconds.
+ Map fallbackFormats = new EnumMap<>(ISO.class);
+ fallbackFormats.put(ISO.TIME, "HH:mm:ssXXX");
+ fallbackFormats.put(ISO.DATE_TIME, "yyyy-MM-dd'T'HH:mm:ssXXX");
+ ISO_FALLBACK_PATTERNS = Collections.unmodifiableMap(fallbackFormats);
}
@@ -202,8 +217,16 @@ public class DateFormatter implements Formatter {
return getDateFormat(locale).parse(text);
}
catch (ParseException ex) {
+ Set fallbackPatterns = new LinkedHashSet<>();
+ String isoPattern = ISO_FALLBACK_PATTERNS.get(this.iso);
+ if (isoPattern != null) {
+ fallbackPatterns.add(isoPattern);
+ }
if (!ObjectUtils.isEmpty(this.fallbackPatterns)) {
- for (String pattern : this.fallbackPatterns) {
+ Collections.addAll(fallbackPatterns, this.fallbackPatterns);
+ }
+ if (!fallbackPatterns.isEmpty()) {
+ for (String pattern : fallbackPatterns) {
try {
DateFormat dateFormat = configureDateFormat(new SimpleDateFormat(pattern, locale));
// Align timezone for parsing format with printing format if ISO is set.
@@ -221,8 +244,8 @@ public class DateFormatter implements Formatter {
}
if (this.source != null) {
ParseException parseException = new ParseException(
- String.format("Unable to parse date time value \"%s\" using configuration from %s", text, this.source),
- ex.getErrorOffset());
+ String.format("Unable to parse date time value \"%s\" using configuration from %s", text, this.source),
+ ex.getErrorOffset());
parseException.initCause(ex);
throw parseException;
}
diff --git a/spring-context/src/main/java/org/springframework/format/datetime/standard/InstantFormatter.java b/spring-context/src/main/java/org/springframework/format/datetime/standard/InstantFormatter.java
index 5f02c4a7835..e40cb3174a7 100644
--- a/spring-context/src/main/java/org/springframework/format/datetime/standard/InstantFormatter.java
+++ b/spring-context/src/main/java/org/springframework/format/datetime/standard/InstantFormatter.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2002-2023 the original author or authors.
+ * Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -45,12 +45,12 @@ public class InstantFormatter implements Formatter {
return Instant.ofEpochMilli(Long.parseLong(text));
}
catch (NumberFormatException ex) {
- if (text.length() > 0 && Character.isAlphabetic(text.charAt(0))) {
+ if (!text.isEmpty() && Character.isAlphabetic(text.charAt(0))) {
// assuming RFC-1123 value a la "Tue, 3 Jun 2008 11:05:30 GMT"
return Instant.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(text));
}
else {
- // assuming UTC instant a la "2007-12-03T10:15:30.00Z"
+ // assuming UTC instant a la "2007-12-03T10:15:30.000Z"
return Instant.parse(text);
}
}
diff --git a/spring-context/src/test/java/org/springframework/format/datetime/DateFormatterTests.java b/spring-context/src/test/java/org/springframework/format/datetime/DateFormatterTests.java
index 0335da8bb68..f74f36edbd9 100644
--- a/spring-context/src/test/java/org/springframework/format/datetime/DateFormatterTests.java
+++ b/spring-context/src/test/java/org/springframework/format/datetime/DateFormatterTests.java
@@ -35,6 +35,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
*
* @author Keith Donald
* @author Phillip Webb
+ * @author Juergen Hoeller
*/
class DateFormatterTests {
@@ -45,6 +46,7 @@ class DateFormatterTests {
void shouldPrintAndParseDefault() throws Exception {
DateFormatter formatter = new DateFormatter();
formatter.setTimeZone(UTC);
+
Date date = getDate(2009, Calendar.JUNE, 1);
assertThat(formatter.print(date, Locale.US)).isEqualTo("Jun 1, 2009");
assertThat(formatter.parse("Jun 1, 2009", Locale.US)).isEqualTo(date);
@@ -54,6 +56,7 @@ class DateFormatterTests {
void shouldPrintAndParseFromPattern() throws ParseException {
DateFormatter formatter = new DateFormatter("yyyy-MM-dd");
formatter.setTimeZone(UTC);
+
Date date = getDate(2009, Calendar.JUNE, 1);
assertThat(formatter.print(date, Locale.US)).isEqualTo("2009-06-01");
assertThat(formatter.parse("2009-06-01", Locale.US)).isEqualTo(date);
@@ -64,6 +67,7 @@ class DateFormatterTests {
DateFormatter formatter = new DateFormatter();
formatter.setTimeZone(UTC);
formatter.setStyle(DateFormat.SHORT);
+
Date date = getDate(2009, Calendar.JUNE, 1);
assertThat(formatter.print(date, Locale.US)).isEqualTo("6/1/09");
assertThat(formatter.parse("6/1/09", Locale.US)).isEqualTo(date);
@@ -74,6 +78,7 @@ class DateFormatterTests {
DateFormatter formatter = new DateFormatter();
formatter.setTimeZone(UTC);
formatter.setStyle(DateFormat.MEDIUM);
+
Date date = getDate(2009, Calendar.JUNE, 1);
assertThat(formatter.print(date, Locale.US)).isEqualTo("Jun 1, 2009");
assertThat(formatter.parse("Jun 1, 2009", Locale.US)).isEqualTo(date);
@@ -84,6 +89,7 @@ class DateFormatterTests {
DateFormatter formatter = new DateFormatter();
formatter.setTimeZone(UTC);
formatter.setStyle(DateFormat.LONG);
+
Date date = getDate(2009, Calendar.JUNE, 1);
assertThat(formatter.print(date, Locale.US)).isEqualTo("June 1, 2009");
assertThat(formatter.parse("June 1, 2009", Locale.US)).isEqualTo(date);
@@ -94,16 +100,18 @@ class DateFormatterTests {
DateFormatter formatter = new DateFormatter();
formatter.setTimeZone(UTC);
formatter.setStyle(DateFormat.FULL);
+
Date date = getDate(2009, Calendar.JUNE, 1);
assertThat(formatter.print(date, Locale.US)).isEqualTo("Monday, June 1, 2009");
assertThat(formatter.parse("Monday, June 1, 2009", Locale.US)).isEqualTo(date);
}
@Test
- void shouldPrintAndParseISODate() throws Exception {
+ void shouldPrintAndParseIsoDate() throws Exception {
DateFormatter formatter = new DateFormatter();
formatter.setTimeZone(UTC);
formatter.setIso(ISO.DATE);
+
Date date = getDate(2009, Calendar.JUNE, 1, 14, 23, 5, 3);
assertThat(formatter.print(date, Locale.US)).isEqualTo("2009-06-01");
assertThat(formatter.parse("2009-6-01", Locale.US))
@@ -111,33 +119,44 @@ class DateFormatterTests {
}
@Test
- void shouldPrintAndParseISOTime() throws Exception {
+ void shouldPrintAndParseIsoTime() throws Exception {
DateFormatter formatter = new DateFormatter();
formatter.setTimeZone(UTC);
formatter.setIso(ISO.TIME);
+
Date date = getDate(2009, Calendar.JANUARY, 1, 14, 23, 5, 3);
assertThat(formatter.print(date, Locale.US)).isEqualTo("14:23:05.003Z");
assertThat(formatter.parse("14:23:05.003Z", Locale.US))
.isEqualTo(getDate(1970, Calendar.JANUARY, 1, 14, 23, 5, 3));
+
+ date = getDate(2009, Calendar.JANUARY, 1, 14, 23, 5, 0);
+ assertThat(formatter.print(date, Locale.US)).isEqualTo("14:23:05.000Z");
+ assertThat(formatter.parse("14:23:05Z", Locale.US))
+ .isEqualTo(getDate(1970, Calendar.JANUARY, 1, 14, 23, 5, 0).toInstant());
}
@Test
- void shouldPrintAndParseISODateTime() throws Exception {
+ void shouldPrintAndParseIsoDateTime() throws Exception {
DateFormatter formatter = new DateFormatter();
formatter.setTimeZone(UTC);
formatter.setIso(ISO.DATE_TIME);
+
Date date = getDate(2009, Calendar.JUNE, 1, 14, 23, 5, 3);
assertThat(formatter.print(date, Locale.US)).isEqualTo("2009-06-01T14:23:05.003Z");
assertThat(formatter.parse("2009-06-01T14:23:05.003Z", Locale.US)).isEqualTo(date);
+
+ date = getDate(2009, Calendar.JUNE, 1, 14, 23, 5, 0);
+ assertThat(formatter.print(date, Locale.US)).isEqualTo("2009-06-01T14:23:05.000Z");
+ assertThat(formatter.parse("2009-06-01T14:23:05Z", Locale.US)).isEqualTo(date.toInstant());
}
@Test
void shouldThrowOnUnsupportedStylePattern() {
DateFormatter formatter = new DateFormatter();
formatter.setStylePattern("OO");
- assertThatIllegalStateException().isThrownBy(() ->
- formatter.parse("2009", Locale.US))
- .withMessageContaining("Unsupported style pattern 'OO'");
+
+ assertThatIllegalStateException().isThrownBy(() -> formatter.parse("2009", Locale.US))
+ .withMessageContaining("Unsupported style pattern 'OO'");
}
@Test
@@ -148,8 +167,8 @@ class DateFormatterTests {
formatter.setStylePattern("L-");
formatter.setIso(ISO.DATE_TIME);
formatter.setPattern("yyyy");
- Date date = getDate(2009, Calendar.JUNE, 1, 14, 23, 5, 3);
+ Date date = getDate(2009, Calendar.JUNE, 1, 14, 23, 5, 3);
assertThat(formatter.print(date, Locale.US)).as("uses pattern").isEqualTo("2009");
formatter.setPattern("");
diff --git a/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java b/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java
index 3a2bd371c6c..cffdb185a98 100644
--- a/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java
+++ b/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java
@@ -644,10 +644,10 @@ class DateTimeFormattingTests {
@DateTimeFormat(style = "M-")
private LocalDate styleLocalDate;
- @DateTimeFormat(style = "S-", fallbackPatterns = { "yyyy-MM-dd", "yyyyMMdd", "yyyy.MM.dd" })
+ @DateTimeFormat(style = "S-", fallbackPatterns = {"yyyy-MM-dd", "yyyyMMdd", "yyyy.MM.dd"})
private LocalDate styleLocalDateWithFallbackPatterns;
- @DateTimeFormat(pattern = "yyyy-MM-dd", fallbackPatterns = { "M/d/yy", "yyyyMMdd", "yyyy.MM.dd" })
+ @DateTimeFormat(pattern = "yyyy-MM-dd", fallbackPatterns = {"M/d/yy", "yyyyMMdd", "yyyy.MM.dd"})
private LocalDate patternLocalDateWithFallbackPatterns;
private LocalTime localTime;
@@ -655,7 +655,7 @@ class DateTimeFormattingTests {
@DateTimeFormat(style = "-M")
private LocalTime styleLocalTime;
- @DateTimeFormat(style = "-M", fallbackPatterns = { "HH:mm:ss", "HH:mm"})
+ @DateTimeFormat(style = "-M", fallbackPatterns = {"HH:mm:ss", "HH:mm"})
private LocalTime styleLocalTimeWithFallbackPatterns;
private LocalDateTime localDateTime;
@@ -675,7 +675,7 @@ class DateTimeFormattingTests {
@DateTimeFormat(iso = ISO.DATE_TIME)
private LocalDateTime isoLocalDateTime;
- @DateTimeFormat(iso = ISO.DATE_TIME, fallbackPatterns = { "yyyy-MM-dd HH:mm:ss", "M/d/yy HH:mm"})
+ @DateTimeFormat(iso = ISO.DATE_TIME, fallbackPatterns = {"yyyy-MM-dd HH:mm:ss", "M/d/yy HH:mm"})
private LocalDateTime isoLocalDateTimeWithFallbackPatterns;
private Instant instant;
diff --git a/spring-context/src/test/java/org/springframework/format/datetime/standard/InstantFormatterTests.java b/spring-context/src/test/java/org/springframework/format/datetime/standard/InstantFormatterTests.java
index a2655c78c32..c57bc66bac5 100644
--- a/spring-context/src/test/java/org/springframework/format/datetime/standard/InstantFormatterTests.java
+++ b/spring-context/src/test/java/org/springframework/format/datetime/standard/InstantFormatterTests.java
@@ -20,6 +20,7 @@ import java.text.ParseException;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
+import java.util.Locale;
import java.util.Random;
import java.util.stream.Stream;
@@ -50,13 +51,12 @@ class InstantFormatterTests {
private final InstantFormatter instantFormatter = new InstantFormatter();
+
@ParameterizedTest
@ArgumentsSource(ISOSerializedInstantProvider.class)
void should_parse_an_ISO_formatted_string_representation_of_an_Instant(String input) throws ParseException {
Instant expected = DateTimeFormatter.ISO_INSTANT.parse(input, Instant::from);
-
- Instant actual = instantFormatter.parse(input, null);
-
+ Instant actual = instantFormatter.parse(input, Locale.US);
assertThat(actual).isEqualTo(expected);
}
@@ -64,9 +64,7 @@ class InstantFormatterTests {
@ArgumentsSource(RFC1123SerializedInstantProvider.class)
void should_parse_an_RFC1123_formatted_string_representation_of_an_Instant(String input) throws ParseException {
Instant expected = DateTimeFormatter.RFC_1123_DATE_TIME.parse(input, Instant::from);
-
- Instant actual = instantFormatter.parse(input, null);
-
+ Instant actual = instantFormatter.parse(input, Locale.US);
assertThat(actual).isEqualTo(expected);
}
@@ -74,20 +72,18 @@ class InstantFormatterTests {
@ArgumentsSource(RandomInstantProvider.class)
void should_serialize_an_Instant_using_ISO_format_and_ignoring_Locale(Instant input) {
String expected = DateTimeFormatter.ISO_INSTANT.format(input);
-
- String actual = instantFormatter.print(input, null);
-
+ String actual = instantFormatter.print(input, Locale.US);
assertThat(actual).isEqualTo(expected);
}
@ParameterizedTest
@ArgumentsSource(RandomEpochMillisProvider.class)
void should_parse_into_an_Instant_from_epoch_milli(Instant input) throws ParseException {
- Instant actual = instantFormatter.parse(Long.toString(input.toEpochMilli()), null);
-
+ Instant actual = instantFormatter.parse(Long.toString(input.toEpochMilli()), Locale.US);
assertThat(actual).isEqualTo(input);
}
+
private static class RandomInstantProvider implements ArgumentsProvider {
private static final long DATA_SET_SIZE = 10;
@@ -109,6 +105,7 @@ class InstantFormatterTests {
}
}
+
private static class ISOSerializedInstantProvider extends RandomInstantProvider {
@Override
@@ -117,6 +114,7 @@ class InstantFormatterTests {
}
}
+
private static class RFC1123SerializedInstantProvider extends RandomInstantProvider {
// RFC-1123 supports only 4-digit years
@@ -130,6 +128,8 @@ class InstantFormatterTests {
.map(DateTimeFormatter.RFC_1123_DATE_TIME.withZone(systemDefault())::format);
}
}
+
+
private static final class RandomEpochMillisProvider implements ArgumentsProvider {
private static final long DATA_SET_SIZE = 10;