Skip to content

Commit ad069a9

Browse files
committed
Support lenient Date/Time parsing with @⁠DateTimeFormat
Closes spring-projectsgh-30649
1 parent c1f7d15 commit ad069a9

10 files changed

+227
-17
lines changed

spring-context/src/main/java/org/springframework/format/annotation/DateTimeFormat.java

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2021 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -126,6 +126,13 @@
126126
*/
127127
String[] fallbackPatterns() default {};
128128

129+
/**
130+
* Specify whether date/time parsing should be lenient.
131+
* <p>Default is {@code false}.
132+
* @since 6.2
133+
*/
134+
boolean lenient() default false;
135+
129136

130137
/**
131138
* Common ISO date time format patterns.

spring-context/src/main/java/org/springframework/format/datetime/DateTimeFormatAnnotationFormatterFactory.java

+2
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ protected Formatter<Date> getFormatter(DateTimeFormat annotation, Class<?> field
8383
formatter.setFallbackPatterns(resolvedFallbackPatterns.toArray(new String[0]));
8484
}
8585

86+
formatter.setLenient(annotation.lenient());
87+
8688
return formatter;
8789
}
8890

spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeFormatterFactory.java

+6-4
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,9 @@ public void setTimeZone(TimeZone timeZone) {
166166
* @see #createDateTimeFormatter(DateTimeFormatter)
167167
*/
168168
public DateTimeFormatter createDateTimeFormatter() {
169-
return createDateTimeFormatter(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM));
169+
DateTimeFormatter fallbackFormatter =
170+
DateTimeFormatterUtils.createLenientDateTimeFormatter(FormatStyle.MEDIUM);
171+
return createDateTimeFormatter(fallbackFormatter);
170172
}
171173

172174
/**
@@ -191,13 +193,13 @@ else if (this.iso != null && this.iso != ISO.NONE) {
191193
};
192194
}
193195
else if (this.dateStyle != null && this.timeStyle != null) {
194-
dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(this.dateStyle, this.timeStyle);
196+
dateTimeFormatter = DateTimeFormatterUtils.createLenientDateTimeFormatter(this.dateStyle, this.timeStyle);
195197
}
196198
else if (this.dateStyle != null) {
197-
dateTimeFormatter = DateTimeFormatter.ofLocalizedDate(this.dateStyle);
199+
dateTimeFormatter = DateTimeFormatterUtils.createLenientDateTimeFormatter(this.dateStyle, null);
198200
}
199201
else if (this.timeStyle != null) {
200-
dateTimeFormatter = DateTimeFormatter.ofLocalizedTime(this.timeStyle);
202+
dateTimeFormatter = DateTimeFormatterUtils.createLenientDateTimeFormatter(null, this.timeStyle);
201203
}
202204

203205
if (dateTimeFormatter != null && this.timeZone != null) {

spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeFormatterRegistrar.java

+4-3
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
*
4444
* @author Juergen Hoeller
4545
* @author Phillip Webb
46+
* @author Sam Brannen
4647
* @since 4.0
4748
* @see #setDateStyle
4849
* @see #setTimeStyle
@@ -211,9 +212,9 @@ private DateTimeFormatter getFormatter(Type type) {
211212

212213
private DateTimeFormatter getFallbackFormatter(Type type) {
213214
return switch (type) {
214-
case DATE -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT);
215-
case TIME -> DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT);
216-
case DATE_TIME -> DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
215+
case DATE -> DateTimeFormatterUtils.createLenientDateTimeFormatter(FormatStyle.SHORT, null);
216+
case TIME -> DateTimeFormatterUtils.createLenientDateTimeFormatter(null, FormatStyle.SHORT);
217+
case DATE_TIME -> DateTimeFormatterUtils.createLenientDateTimeFormatter(FormatStyle.SHORT);
217218
};
218219
}
219220

spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeFormatterUtils.java

+45
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,20 @@
1616

1717
package org.springframework.format.datetime.standard;
1818

19+
import java.time.chrono.IsoChronology;
1920
import java.time.format.DateTimeFormatter;
21+
import java.time.format.DateTimeFormatterBuilder;
22+
import java.time.format.FormatStyle;
2023
import java.time.format.ResolverStyle;
2124

25+
import org.springframework.lang.Nullable;
2226
import org.springframework.util.StringUtils;
2327

2428
/**
2529
* Internal {@link DateTimeFormatter} utilities.
2630
*
2731
* @author Juergen Hoeller
32+
* @author Sam Brannen
2833
* @since 5.3.5
2934
*/
3035
abstract class DateTimeFormatterUtils {
@@ -36,6 +41,7 @@ abstract class DateTimeFormatterUtils {
3641
* @param pattern the pattern to use
3742
* @return a new {@code DateTimeFormatter}
3843
* @see ResolverStyle#STRICT
44+
* @see #createLenientDateTimeFormatter(FormatStyle)
3945
*/
4046
static DateTimeFormatter createStrictDateTimeFormatter(String pattern) {
4147
// Using strict resolution to align with Joda-Time and standard DateFormat behavior:
@@ -45,4 +51,43 @@ static DateTimeFormatter createStrictDateTimeFormatter(String pattern) {
4551
return DateTimeFormatter.ofPattern(patternToUse).withResolverStyle(ResolverStyle.STRICT);
4652
}
4753

54+
/**
55+
* Create a {@link DateTimeFormatter} for the supplied date/time style, configured
56+
* with {@linkplain DateTimeFormatterBuilder#parseLenient() lenient} parsing.
57+
* <p>Note that the lenient parsing does not affect the {@linkplain ResolverStyle
58+
* resolution strategy}.
59+
* @param dateTimeStyle the date/time style to use
60+
* @return a new {@code DateTimeFormatter}
61+
* @since 6.2
62+
* @see DateTimeFormatterBuilder#parseLenient()
63+
* @see #createLenientDateTimeFormatter(FormatStyle, FormatStyle)
64+
* @see #createStrictDateTimeFormatter(String)
65+
*/
66+
static DateTimeFormatter createLenientDateTimeFormatter(FormatStyle dateTimeStyle) {
67+
return createLenientDateTimeFormatter(dateTimeStyle, dateTimeStyle);
68+
}
69+
70+
/**
71+
* Create a {@link DateTimeFormatter} for the supplied date and time styles, configured
72+
* with {@linkplain DateTimeFormatterBuilder#parseLenient() lenient} parsing.
73+
* <p>Note that the lenient parsing does not affect the {@linkplain ResolverStyle
74+
* resolution strategy}.
75+
* @param dateStyle the date style to use
76+
* @param timeStyle the time style to use
77+
* @return a new {@code DateTimeFormatter}
78+
* @since 6.2
79+
* @see DateTimeFormatterBuilder#parseLenient()
80+
* @see #createLenientDateTimeFormatter(FormatStyle)
81+
* @see #createStrictDateTimeFormatter(String)
82+
*/
83+
static DateTimeFormatter createLenientDateTimeFormatter(
84+
@Nullable FormatStyle dateStyle, @Nullable FormatStyle timeStyle) {
85+
86+
return new DateTimeFormatterBuilder()
87+
.parseLenient()
88+
.appendLocalized(dateStyle, timeStyle)
89+
.toFormatter()
90+
.withChronology(IsoChronology.INSTANCE);
91+
}
92+
4893
}

spring-context/src/main/java/org/springframework/format/datetime/standard/Jsr310DateTimeFormatAnnotationFormatterFactory.java

+3
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ protected DateTimeFormatter getFormatter(DateTimeFormat annotation, Class<?> fie
126126
if (StringUtils.hasLength(pattern)) {
127127
factory.setPattern(pattern);
128128
}
129+
130+
// factory.setLenient(annotation.lenient());
131+
129132
return factory.createDateTimeFormatter();
130133
}
131134

spring-context/src/test/java/org/springframework/format/datetime/DateFormattingTests.java

+92
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import org.springframework.validation.FieldError;
4646

4747
import static org.assertj.core.api.Assertions.assertThat;
48+
import static org.assertj.core.api.Assumptions.assumeThat;
4849

4950
/**
5051
* @author Phillip Webb
@@ -54,6 +55,16 @@
5455
*/
5556
class DateFormattingTests {
5657

58+
private static final String SPACE = " ";
59+
60+
// \u202F is a narrow non-breaking space (NNBSP).
61+
private static final String NNBSP = "\u202F";
62+
63+
// JDK <= 19 requires a standard space before "AM/PM".
64+
// JDK >= 20 requires a NNBSP before "AM/PM", by default.
65+
private static final String TIME_SEPARATOR = (Runtime.version().feature() < 20 ? SPACE : NNBSP);
66+
67+
5768
private final FormattingConversionService conversionService = new FormattingConversionService();
5869

5970
private DataBinder binder;
@@ -118,6 +129,59 @@ void testBindDateAnnotated() {
118129
assertThat(binder.getBindingResult().getFieldValue("styleDate")).isEqualTo("10/31/09");
119130
}
120131

132+
@Test
133+
void testBindDateTime() {
134+
testBindDateTime(TIME_SEPARATOR, false);
135+
}
136+
137+
@Test
138+
void testBindDateTime_PostJDK23() {
139+
assumeJava23OrHigher();
140+
141+
testBindDateTime(SPACE, true);
142+
}
143+
144+
private void testBindDateTime(String timeSeparator, boolean lenient) {
145+
DateFormatter dateFormatter = new DateFormatter();
146+
dateFormatter.setStylePattern("MM");
147+
dateFormatter.setLenient(lenient);
148+
DateFormatterRegistrar registrar = new DateFormatterRegistrar();
149+
registrar.setFormatter(dateFormatter);
150+
setup(registrar);
151+
152+
MutablePropertyValues propertyValues = new MutablePropertyValues();
153+
propertyValues.add("dateTime", "Oct 31, 2009, 12:00:00%sPM".formatted(timeSeparator));
154+
binder.bind(propertyValues);
155+
BindingResult bindingResult = binder.getBindingResult();
156+
assertThat(bindingResult.getErrorCount()).isEqualTo(0);
157+
String value = bindingResult.getFieldValue("dateTime").toString();
158+
// \p{Zs} matches any Unicode space character
159+
assertThat(value).startsWith("Oct 31, 2009").matches(".+?12:00:00\\p{Zs}PM");
160+
}
161+
162+
@Test
163+
void testBindDateTimeAnnotated() {
164+
testBindDateTimeAnnotated(TIME_SEPARATOR);
165+
}
166+
167+
@Test
168+
void testBindDateTimeAnnotated_PostJDK23() {
169+
assumeJava23OrHigher();
170+
171+
testBindDateTimeAnnotated(SPACE);
172+
}
173+
174+
private void testBindDateTimeAnnotated(String timeSeparator) {
175+
MutablePropertyValues propertyValues = new MutablePropertyValues();
176+
propertyValues.add("styleDateTime", "Oct 31, 2009, 12:00:00%sPM".formatted(timeSeparator));
177+
binder.bind(propertyValues);
178+
BindingResult bindingResult = binder.getBindingResult();
179+
assertThat(bindingResult.getErrorCount()).isEqualTo(0);
180+
String value = bindingResult.getFieldValue("styleDateTime").toString();
181+
// \p{Zs} matches any Unicode space character
182+
assertThat(value).startsWith("Oct 31, 2009").matches(".+?12:00:00\\p{Zs}PM");
183+
}
184+
121185
@Test
122186
void styleDateWithInvalidFormat() {
123187
String propertyName = "styleDate";
@@ -370,6 +434,13 @@ void patternDateWithUnsupportedPattern() {
370434
}
371435

372436

437+
private static void assumeJava23OrHigher() {
438+
// We currently use an "assumption" instead of @EnabledForJreRange, since
439+
// org.junit.jupiter.api.condition.JRE does not yet support JAVA_23.
440+
assumeThat(Runtime.version().feature()).isGreaterThanOrEqualTo(23);
441+
}
442+
443+
373444
@SuppressWarnings("unused")
374445
private static class SimpleDateBean {
375446

@@ -386,6 +457,11 @@ private static class SimpleDateBean {
386457
@DateTimeFormat(style = "S-")
387458
private Date styleDate;
388459

460+
private Date dateTime;
461+
462+
@DateTimeFormat(style = "MM", lenient = true)
463+
private Date styleDateTime;
464+
389465
@DateTimeFormat(style = "S-", fallbackPatterns = { "yyyy-MM-dd", "yyyyMMdd", "yyyy.MM.dd" })
390466
private Date styleDateWithFallbackPatterns;
391467

@@ -451,6 +527,22 @@ public void setStyleDate(Date styleDate) {
451527
this.styleDate = styleDate;
452528
}
453529

530+
public Date getDateTime() {
531+
return this.dateTime;
532+
}
533+
534+
public void setDateTime(Date dateTime) {
535+
this.dateTime = dateTime;
536+
}
537+
538+
public Date getStyleDateTime() {
539+
return this.styleDateTime;
540+
}
541+
542+
public void setStyleDateTime(Date styleDateTime) {
543+
this.styleDateTime = styleDateTime;
544+
}
545+
454546
public Date getStyleDateWithFallbackPatterns() {
455547
return this.styleDateWithFallbackPatterns;
456548
}

spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormatterFactoryBeanTests.java

+3-2
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,15 @@ void getObjectType() {
4545
@Test
4646
void getObject() {
4747
factory.afterPropertiesSet();
48-
assertThat(factory.getObject().toString()).isEqualTo(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).toString());
48+
DateTimeFormatter formatter = factory.getObject();
49+
assertThat(formatter).asString().contains(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).toString());
4950
}
5051

5152
@Test
5253
void getObjectIsAlwaysSingleton() {
5354
factory.afterPropertiesSet();
5455
DateTimeFormatter formatter = factory.getObject();
55-
assertThat(formatter.toString()).isEqualTo(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).toString());
56+
assertThat(formatter).asString().contains(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).toString());
5657
factory.setStylePattern("LL");
5758
assertThat(factory.getObject()).isSameAs(formatter);
5859
}

spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormatterFactoryTests.java

+2-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ class DateTimeFormatterFactoryTests {
5151

5252
@Test
5353
void createDateTimeFormatter() {
54-
assertThat(factory.createDateTimeFormatter().toString()).isEqualTo(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).toString());
54+
DateTimeFormatter formatter = factory.createDateTimeFormatter();
55+
assertThat(formatter).asString().contains(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).toString());
5556
}
5657

5758
@Test

0 commit comments

Comments
 (0)