Skip to content

Commit

Permalink
Support lenient Date/Time parsing with @⁠DateTimeFormat
Browse files Browse the repository at this point in the history
  • Loading branch information
sbrannen committed Jul 5, 2024
1 parent c1f7d15 commit ad069a9
Show file tree
Hide file tree
Showing 10 changed files with 227 additions and 17 deletions.
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -126,6 +126,13 @@
*/
String[] fallbackPatterns() default {};

/**
* Specify whether date/time parsing should be lenient.
* <p>Default is {@code false}.
* @since 6.2
*/
boolean lenient() default false;


/**
* Common ISO date time format patterns.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ protected Formatter<Date> getFormatter(DateTimeFormat annotation, Class<?> field
formatter.setFallbackPatterns(resolvedFallbackPatterns.toArray(new String[0]));
}

formatter.setLenient(annotation.lenient());

return formatter;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,9 @@ public void setTimeZone(TimeZone timeZone) {
* @see #createDateTimeFormatter(DateTimeFormatter)
*/
public DateTimeFormatter createDateTimeFormatter() {
return createDateTimeFormatter(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM));
DateTimeFormatter fallbackFormatter =
DateTimeFormatterUtils.createLenientDateTimeFormatter(FormatStyle.MEDIUM);
return createDateTimeFormatter(fallbackFormatter);
}

/**
Expand All @@ -191,13 +193,13 @@ else if (this.iso != null && this.iso != ISO.NONE) {
};
}
else if (this.dateStyle != null && this.timeStyle != null) {
dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(this.dateStyle, this.timeStyle);
dateTimeFormatter = DateTimeFormatterUtils.createLenientDateTimeFormatter(this.dateStyle, this.timeStyle);
}
else if (this.dateStyle != null) {
dateTimeFormatter = DateTimeFormatter.ofLocalizedDate(this.dateStyle);
dateTimeFormatter = DateTimeFormatterUtils.createLenientDateTimeFormatter(this.dateStyle, null);
}
else if (this.timeStyle != null) {
dateTimeFormatter = DateTimeFormatter.ofLocalizedTime(this.timeStyle);
dateTimeFormatter = DateTimeFormatterUtils.createLenientDateTimeFormatter(null, this.timeStyle);
}

if (dateTimeFormatter != null && this.timeZone != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
*
* @author Juergen Hoeller
* @author Phillip Webb
* @author Sam Brannen
* @since 4.0
* @see #setDateStyle
* @see #setTimeStyle
Expand Down Expand Up @@ -211,9 +212,9 @@ private DateTimeFormatter getFormatter(Type type) {

private DateTimeFormatter getFallbackFormatter(Type type) {
return switch (type) {
case DATE -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT);
case TIME -> DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT);
case DATE_TIME -> DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT);
case DATE -> DateTimeFormatterUtils.createLenientDateTimeFormatter(FormatStyle.SHORT, null);
case TIME -> DateTimeFormatterUtils.createLenientDateTimeFormatter(null, FormatStyle.SHORT);
case DATE_TIME -> DateTimeFormatterUtils.createLenientDateTimeFormatter(FormatStyle.SHORT);
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,20 @@

package org.springframework.format.datetime.standard;

import java.time.chrono.IsoChronology;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.FormatStyle;
import java.time.format.ResolverStyle;

import org.springframework.lang.Nullable;
import org.springframework.util.StringUtils;

/**
* Internal {@link DateTimeFormatter} utilities.
*
* @author Juergen Hoeller
* @author Sam Brannen
* @since 5.3.5
*/
abstract class DateTimeFormatterUtils {
Expand All @@ -36,6 +41,7 @@ abstract class DateTimeFormatterUtils {
* @param pattern the pattern to use
* @return a new {@code DateTimeFormatter}
* @see ResolverStyle#STRICT
* @see #createLenientDateTimeFormatter(FormatStyle)
*/
static DateTimeFormatter createStrictDateTimeFormatter(String pattern) {
// Using strict resolution to align with Joda-Time and standard DateFormat behavior:
Expand All @@ -45,4 +51,43 @@ static DateTimeFormatter createStrictDateTimeFormatter(String pattern) {
return DateTimeFormatter.ofPattern(patternToUse).withResolverStyle(ResolverStyle.STRICT);
}

/**
* Create a {@link DateTimeFormatter} for the supplied date/time style, configured
* with {@linkplain DateTimeFormatterBuilder#parseLenient() lenient} parsing.
* <p>Note that the lenient parsing does not affect the {@linkplain ResolverStyle
* resolution strategy}.
* @param dateTimeStyle the date/time style to use
* @return a new {@code DateTimeFormatter}
* @since 6.2
* @see DateTimeFormatterBuilder#parseLenient()
* @see #createLenientDateTimeFormatter(FormatStyle, FormatStyle)
* @see #createStrictDateTimeFormatter(String)
*/
static DateTimeFormatter createLenientDateTimeFormatter(FormatStyle dateTimeStyle) {
return createLenientDateTimeFormatter(dateTimeStyle, dateTimeStyle);
}

/**
* Create a {@link DateTimeFormatter} for the supplied date and time styles, configured
* with {@linkplain DateTimeFormatterBuilder#parseLenient() lenient} parsing.
* <p>Note that the lenient parsing does not affect the {@linkplain ResolverStyle
* resolution strategy}.
* @param dateStyle the date style to use
* @param timeStyle the time style to use
* @return a new {@code DateTimeFormatter}
* @since 6.2
* @see DateTimeFormatterBuilder#parseLenient()
* @see #createLenientDateTimeFormatter(FormatStyle)
* @see #createStrictDateTimeFormatter(String)
*/
static DateTimeFormatter createLenientDateTimeFormatter(
@Nullable FormatStyle dateStyle, @Nullable FormatStyle timeStyle) {

return new DateTimeFormatterBuilder()
.parseLenient()
.appendLocalized(dateStyle, timeStyle)
.toFormatter()
.withChronology(IsoChronology.INSTANCE);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ protected DateTimeFormatter getFormatter(DateTimeFormat annotation, Class<?> fie
if (StringUtils.hasLength(pattern)) {
factory.setPattern(pattern);
}

// factory.setLenient(annotation.lenient());

return factory.createDateTimeFormatter();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
import org.springframework.validation.FieldError;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assumptions.assumeThat;

/**
* @author Phillip Webb
Expand All @@ -54,6 +55,16 @@
*/
class DateFormattingTests {

private static final String SPACE = " ";

// \u202F is a narrow non-breaking space (NNBSP).
private static final String NNBSP = "\u202F";

// JDK <= 19 requires a standard space before "AM/PM".
// JDK >= 20 requires a NNBSP before "AM/PM", by default.
private static final String TIME_SEPARATOR = (Runtime.version().feature() < 20 ? SPACE : NNBSP);


private final FormattingConversionService conversionService = new FormattingConversionService();

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

@Test
void testBindDateTime() {
testBindDateTime(TIME_SEPARATOR, false);
}

@Test
void testBindDateTime_PostJDK23() {
assumeJava23OrHigher();

testBindDateTime(SPACE, true);
}

private void testBindDateTime(String timeSeparator, boolean lenient) {
DateFormatter dateFormatter = new DateFormatter();
dateFormatter.setStylePattern("MM");
dateFormatter.setLenient(lenient);
DateFormatterRegistrar registrar = new DateFormatterRegistrar();
registrar.setFormatter(dateFormatter);
setup(registrar);

MutablePropertyValues propertyValues = new MutablePropertyValues();
propertyValues.add("dateTime", "Oct 31, 2009, 12:00:00%sPM".formatted(timeSeparator));
binder.bind(propertyValues);
BindingResult bindingResult = binder.getBindingResult();
assertThat(bindingResult.getErrorCount()).isEqualTo(0);
String value = bindingResult.getFieldValue("dateTime").toString();
// \p{Zs} matches any Unicode space character
assertThat(value).startsWith("Oct 31, 2009").matches(".+?12:00:00\\p{Zs}PM");
}

@Test
void testBindDateTimeAnnotated() {
testBindDateTimeAnnotated(TIME_SEPARATOR);
}

@Test
void testBindDateTimeAnnotated_PostJDK23() {
assumeJava23OrHigher();

testBindDateTimeAnnotated(SPACE);
}

private void testBindDateTimeAnnotated(String timeSeparator) {
MutablePropertyValues propertyValues = new MutablePropertyValues();
propertyValues.add("styleDateTime", "Oct 31, 2009, 12:00:00%sPM".formatted(timeSeparator));
binder.bind(propertyValues);
BindingResult bindingResult = binder.getBindingResult();
assertThat(bindingResult.getErrorCount()).isEqualTo(0);
String value = bindingResult.getFieldValue("styleDateTime").toString();
// \p{Zs} matches any Unicode space character
assertThat(value).startsWith("Oct 31, 2009").matches(".+?12:00:00\\p{Zs}PM");
}

@Test
void styleDateWithInvalidFormat() {
String propertyName = "styleDate";
Expand Down Expand Up @@ -370,6 +434,13 @@ void patternDateWithUnsupportedPattern() {
}


private static void assumeJava23OrHigher() {
// We currently use an "assumption" instead of @EnabledForJreRange, since
// org.junit.jupiter.api.condition.JRE does not yet support JAVA_23.
assumeThat(Runtime.version().feature()).isGreaterThanOrEqualTo(23);
}


@SuppressWarnings("unused")
private static class SimpleDateBean {

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

private Date dateTime;

@DateTimeFormat(style = "MM", lenient = true)
private Date styleDateTime;

@DateTimeFormat(style = "S-", fallbackPatterns = { "yyyy-MM-dd", "yyyyMMdd", "yyyy.MM.dd" })
private Date styleDateWithFallbackPatterns;

Expand Down Expand Up @@ -451,6 +527,22 @@ public void setStyleDate(Date styleDate) {
this.styleDate = styleDate;
}

public Date getDateTime() {
return this.dateTime;
}

public void setDateTime(Date dateTime) {
this.dateTime = dateTime;
}

public Date getStyleDateTime() {
return this.styleDateTime;
}

public void setStyleDateTime(Date styleDateTime) {
this.styleDateTime = styleDateTime;
}

public Date getStyleDateWithFallbackPatterns() {
return this.styleDateWithFallbackPatterns;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,15 @@ void getObjectType() {
@Test
void getObject() {
factory.afterPropertiesSet();
assertThat(factory.getObject().toString()).isEqualTo(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).toString());
DateTimeFormatter formatter = factory.getObject();
assertThat(formatter).asString().contains(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).toString());
}

@Test
void getObjectIsAlwaysSingleton() {
factory.afterPropertiesSet();
DateTimeFormatter formatter = factory.getObject();
assertThat(formatter.toString()).isEqualTo(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).toString());
assertThat(formatter).asString().contains(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).toString());
factory.setStylePattern("LL");
assertThat(factory.getObject()).isSameAs(formatter);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ class DateTimeFormatterFactoryTests {

@Test
void createDateTimeFormatter() {
assertThat(factory.createDateTimeFormatter().toString()).isEqualTo(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).toString());
DateTimeFormatter formatter = factory.createDateTimeFormatter();
assertThat(formatter).asString().contains(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).toString());
}

@Test
Expand Down
Loading

0 comments on commit ad069a9

Please sign in to comment.