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 5235ca74324c..e4185c3b0859 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-2021 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.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 @@ /** * 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 @@ -51,12 +58,21 @@ public class DateFormatter implements Formatter { 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); } @@ -201,8 +217,16 @@ public Date parse(String text, Locale locale) throws ParseException { 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. @@ -220,8 +244,8 @@ public Date parse(String text, Locale locale) throws ParseException { } 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/test/java/org/springframework/format/datetime/DateFormatterTests.java b/spring-context/src/test/java/org/springframework/format/datetime/DateFormatterTests.java index 7737902082a9..f74f36edbd95 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 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. @@ -23,9 +23,6 @@ import java.util.Locale; import java.util.TimeZone; -import org.joda.time.DateTimeZone; -import org.joda.time.format.DateTimeFormat; -import org.joda.time.format.DateTimeFormatter; import org.junit.jupiter.api.Test; import org.springframework.format.annotation.DateTimeFormat.ISO; @@ -33,83 +30,88 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; - - - /** * Tests for {@link DateFormatter}. * * @author Keith Donald * @author Phillip Webb + * @author Juergen Hoeller */ -public class DateFormatterTests { +class DateFormatterTests { private static final TimeZone UTC = TimeZone.getTimeZone("UTC"); @Test - public void shouldPrintAndParseDefault() throws Exception { + 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); } @Test - public void shouldPrintAndParseFromPattern() throws ParseException { + 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); } @Test - public void shouldPrintAndParseShort() throws Exception { + void shouldPrintAndParseShort() throws Exception { 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); } @Test - public void shouldPrintAndParseMedium() throws Exception { + void shouldPrintAndParseMedium() throws Exception { 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); } @Test - public void shouldPrintAndParseLong() throws Exception { + void shouldPrintAndParseLong() throws Exception { 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); } @Test - public void shouldPrintAndParseFull() throws Exception { + void shouldPrintAndParseFull() throws Exception { 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 - public 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)) @@ -117,79 +119,56 @@ public void shouldPrintAndParseISODate() throws Exception { } @Test - public 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 - public 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); - } - @Test - public void shouldSupportJodaStylePatterns() throws Exception { - String[] chars = { "S", "M", "-" }; - for (String d : chars) { - for (String t : chars) { - String style = d + t; - if (!style.equals("--")) { - Date date = getDate(2009, Calendar.JUNE, 10, 14, 23, 0, 0); - if (t.equals("-")) { - date = getDate(2009, Calendar.JUNE, 10); - } - else if (d.equals("-")) { - date = getDate(1970, Calendar.JANUARY, 1, 14, 23, 0, 0); - } - testJodaStylePatterns(style, Locale.US, date); - } - } - } - } - - private void testJodaStylePatterns(String style, Locale locale, Date date) throws Exception { - DateFormatter formatter = new DateFormatter(); - formatter.setTimeZone(UTC); - formatter.setStylePattern(style); - DateTimeFormatter jodaFormatter = DateTimeFormat.forStyle(style).withLocale(locale).withZone(DateTimeZone.UTC); - String jodaPrinted = jodaFormatter.print(date.getTime()); - assertThat(formatter.print(date, locale)) - .as("Unable to print style pattern " + style) - .isEqualTo(jodaPrinted); - assertThat(formatter.parse(jodaPrinted, locale)) - .as("Unable to parse style pattern " + style) - .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 - public void shouldThrowOnUnsupportedStylePattern() throws Exception { + 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 - public void shouldUseCorrectOrder() throws Exception { + void shouldUseCorrectOrder() { DateFormatter formatter = new DateFormatter(); formatter.setTimeZone(UTC); formatter.setStyle(DateFormat.SHORT); 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("");