javajava-timedatetimeformatter

DateTimeFormatter Validation for Existing Year, Optional Month, and Optional Day


We are attempting to write a single DateTimeFormatter that helps us validate an ISO 8601 that allows our end user's to enter just a year, a year and a month, or a year, month, and day. We also want to validate the entered date actually exists.

In the following code there are two examples of dates and date validation acting funky. The first is a validation test for just year with an optional month. Which doesn't properly validate a month of '00'.

The second example shows a test (NOT) failing on incorrect optional months value.

Any friendly direction would be greatly appreciated.

import org.junit.jupiter.api.Test;

import java.time.LocalDate;
import java.time.Year;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.time.temporal.TemporalQuery;

import static org.junit.jupiter.api.Assertions.assertThrows;

public class DateTimeFormatterForStackOverflowTest {

    @Test
    public void test_year_or_year_and_month_not_valid() {

        DateTimeFormatter formatter = new DateTimeFormatterBuilder()
                .appendPattern("uuuu[-MM]")
                .toFormatter()
                .withResolverStyle(ResolverStyle.STRICT);

        this.expectException("1984-0", formatter, YearMonth::from, Year::from);
        // Doesn't throw exception. 
        this.expectException("1984-00", formatter, YearMonth::from, Year::from);
        // Doesn't throw exception.
        this.expectException("1984-13", formatter, YearMonth::from, Year::from);
        // Doesn't throw exception.
        this.expectException("1984-99", formatter, YearMonth::from, Year::from);
    }

    @Test
    public void test_year_or_year_month_or_year_month_day_not_valid() {

        DateTimeFormatter formatter = new DateTimeFormatterBuilder()
                .appendPattern("[uuuu-MM-dd][uuuu-MM][uuuu]")
                .toFormatter()
                .withResolverStyle(ResolverStyle.STRICT);

        this.expectException("1984-0", formatter, LocalDate::from, YearMonth::from, Year::from);
        // Doesn't throw exception.
        this.expectException("1984-00", formatter, LocalDate::from, YearMonth::from, Year::from);
        // Doesn't throw exception.
        this.expectException("1984-13", formatter, LocalDate::from, YearMonth::from, Year::from);
        // Doesn't throw exception.
        this.expectException("1984-99", formatter, LocalDate::from, YearMonth::from, Year::from);
        this.expectException("1984-00-01", formatter, LocalDate::from, YearMonth::from, Year::from);
        this.expectException("1984-13-01", formatter, LocalDate::from, YearMonth::from, Year::from);
        this.expectException("1984-01-0", formatter, LocalDate::from, YearMonth::from, Year::from);
        this.expectException("1984-01-00", formatter, LocalDate::from, YearMonth::from, Year::from);
        this.expectException("1984-01-32", formatter, LocalDate::from, YearMonth::from, Year::from);
        this.expectException("1984-12-00", formatter, LocalDate::from, YearMonth::from, Year::from);
        this.expectException("1984-12-32", formatter, LocalDate::from, YearMonth::from, Year::from);
    }

    private void expectException(String value, DateTimeFormatter formatter, TemporalQuery<?>... queries) {

        assertThrows(DateTimeParseException.class,
                () -> formatter.parseBest(value, queries));
    }
}

Test output:

org.opentest4j.AssertionFailedError: Expected java.time.format.DateTimeParseException to be thrown, but nothing was thrown.

    at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:152)
    at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:73)
    at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:35)
    at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:3083)
    at DateTimeFormatterForStackOverflowTest.expectException(DateTimeFormatterForStackOverflowTest.java:59)
    at DateTimeFormatterForStackOverflowTest.test_year_or_year_month_or_year_month_day_not_valid(DateTimeFormatterForStackOverflowTest.java:43)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.base/java.lang.reflect.Method.invoke(Method.java:566)
    at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:727)
    at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:60)
    at org.junit.jupiter.engine.execution.InvocationInterceptorChain$ValidatingInvocation.proceed(InvocationInterceptorChain.java:131)
    at org.junit.jupiter.engine.extension.TimeoutExtension.intercept(TimeoutExtension.java:156)
    at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestableMethod(TimeoutExtension.java:147)
    at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestMethod(TimeoutExtension.java:86)
    at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod$0(InterceptingExecutableInvoker.java:103)
    at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.lambda$invoke$0(InterceptingExecutableInvoker.java:93)
    at org.junit.jupiter.engine.execution.InvocationInterceptorChain$InterceptedInvocation.proceed(InvocationInterceptorChain.java:106)
    at org.junit.jupiter.engine.execution.InvocationInterceptorChain.proceed(InvocationInterceptorChain.java:64)
    at org.junit.jupiter.engine.execution.InvocationInterceptorChain.chainAndInvoke(InvocationInterceptorChain.java:45)
    at org.junit.jupiter.engine.execution.InvocationInterceptorChain.invoke(InvocationInterceptorChain.java:37)
    at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:92)
    at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:86)
    at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeTestMethod$7(TestMethodTestDescriptor.java:217)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeTestMethod(TestMethodTestDescriptor.java:213)
    at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:138)
    at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:68)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:151)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141)
    at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139)
    at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138)
    at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95)
    at org.junit.platform.engine.support.hierarchical.ForkJoinPoolHierarchicalTestExecutorService$ExclusiveTask.compute(ForkJoinPoolHierarchicalTestExecutorService.java:185)
    at java.base/java.util.concurrent.RecursiveAction.exec(RecursiveAction.java:189)
    at java.base/java.util.concurrent.ForkJoinTask.doExec$$$capture(ForkJoinTask.java:290)
    at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java)
    at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1020)
    at java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1656)
    at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1594)
    at java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:183)

Solution

  • Here is what we eventually did to solve our particular iso date validation issues.

    First we created 5 different validators (one for each of the different iso date we accept) and each of those validators implemented the following interface:

    package app.validation.type;
    
    public interface IsoDateFormatValidator {
    
        boolean isValid(String date);
    }
    
    

    We then created a validator that would go through a list of iso date based validators to see if the entered date is valid.

    package app.validation.type;
    
    import org.springframework.util.StringUtils;
    
    import javax.validation.ConstraintValidator;
    import javax.validation.ConstraintValidatorContext;
    import java.util.List;
    
    public class ValidIsoDateFormatValidator implements ConstraintValidator<ValidIsoDateFormat, String> {
    
        private final List<IsoDateFormatValidator> isoDateFormatValidatorList;
    
        public ValidIsoDateFormatValidator(List<IsoDateFormatValidator> isoDateFormatValidatorList) {
            this.isoDateFormatValidatorList = isoDateFormatValidatorList;
        }
    
        @Override
        public void initialize(ValidIsoDateFormat annotation) {
    
        }
    
        @Override
        public boolean isValid(String inputDate, ConstraintValidatorContext constraintValidatorContext) {
    
            boolean valid = true;
    
            if (StringUtils.hasText(inputDate)) {
    
                valid = isoDateFormatValidatorList.stream()
                        .map(v -> v.isValid(inputDate))
                        .anyMatch((Boolean.TRUE::equals));
            }
    
            return valid;
        }
    }
    

    Here are the individual iso date validators we used.

    Date/Time YYYY-MM-DD HH:MM:SS

    package app.validation.type;
    
    import java.time.LocalDateTime;
    import java.time.format.DateTimeFormatter;
    import java.time.format.DateTimeFormatterBuilder;
    import java.time.format.DateTimeParseException;
    import java.time.format.ResolverStyle;
    
    public class IsoDateTimeFormatValidator implements IsoDateFormatValidator {
    
        private final DateTimeFormatter formatter = new DateTimeFormatterBuilder()
                .appendPattern("uuuu-MM-dd HH:mm:ss")
                .toFormatter()
                .withResolverStyle(ResolverStyle.LENIENT);
    
        @Override
        public boolean isValid(String date) {
    
            boolean valid = false;
    
            try {
    
                LocalDateTime ldt =  LocalDateTime.parse(date, formatter);
                valid = true;
    
            } catch (DateTimeParseException ignored) {
    
            }
    
            return valid;
        }
    }
    

    Date/Time w/Milliseconds YYYY-MM-DD HH:MM:SS.SSS

    package app.validation.type;
    
    import java.time.format.DateTimeFormatter;
    import java.time.format.DateTimeFormatterBuilder;
    import java.time.format.DateTimeParseException;
    import java.time.format.ResolverStyle;
    import java.time.temporal.TemporalAccessor;
    
    public class IsoDateTimeMillisFormatValidator implements IsoDateFormatValidator {
    
        private final DateTimeFormatter formatter = new DateTimeFormatterBuilder()
                .appendPattern("uuuu-MM-dd HH:mm:ss.SSS")
                .toFormatter()
                .withResolverStyle(ResolverStyle.LENIENT);
    
        @Override
        public boolean isValid(String date) {
    
            boolean valid = false;
    
            try {
    
                TemporalAccessor ta = formatter.parse(date);
                valid = true;
    
            } catch (DateTimeParseException ignored) {
    
            }
    
            return valid;
        }
    }
    

    Year YYYY

    package app.validation.type;
    
    import java.time.format.DateTimeFormatter;
    import java.time.format.DateTimeFormatterBuilder;
    import java.time.format.DateTimeParseException;
    import java.time.temporal.TemporalAccessor;
    
    public class IsoYearFormatValidator implements IsoDateFormatValidator {
    
        private final DateTimeFormatter formatter = new DateTimeFormatterBuilder()
                .appendPattern("uuuu")
                .toFormatter();
    
        @Override
        public boolean isValid(String date) {
    
            boolean valid = false;
    
            try {
    
                TemporalAccessor ta = formatter.parse(date);
                valid = true;
    
            } catch (DateTimeParseException ignored) {
    
            }
    
            return valid;
        }
    }
    

    Year/Month YYYY/MM

    package app.validation.type;
    
    import java.time.format.DateTimeFormatter;
    import java.time.format.DateTimeFormatterBuilder;
    import java.time.format.DateTimeParseException;
    import java.time.temporal.TemporalAccessor;
    
    public class IsoYearMonthFormatValidator implements IsoDateFormatValidator {
    
        private final DateTimeFormatter formatter = new DateTimeFormatterBuilder()
                .appendPattern("uuuu-MM")
                .toFormatter();
    
        @Override
        public boolean isValid(String date) {
    
            boolean valid = false;
    
            try {
    
                TemporalAccessor ta = formatter.parse(date);
                valid = true;
    
            } catch (DateTimeParseException ignored) {
    
            }
    
            return valid;
        }
    }
    

    Year/Month/Day YYYY/MM/DD

    package app.validation.type;
    
    import java.time.LocalDate;
    import java.time.format.DateTimeFormatter;
    import java.time.format.DateTimeFormatterBuilder;
    import java.time.format.DateTimeParseException;
    import java.time.format.ResolverStyle;
    
    public class IsoYearMonthDayFormatValidator implements IsoDateFormatValidator {
    
        private final DateTimeFormatter formatter = new DateTimeFormatterBuilder()
                .appendPattern("uuuu-MM-dd")
                .toFormatter()
                .withResolverStyle ( ResolverStyle.LENIENT );
    
        @Override
        public boolean isValid(String date) {
    
            boolean valid = false;
    
            try {
    
                LocalDate ld = LocalDate.parse (date, formatter);
                valid = true;
    
            } catch (DateTimeParseException ignored) {
    
            }
    
            return valid;
        }
    }
    

    Of course if you have other iso date/time required validations that aren't listed here, that could be added since the ValidIsoDateFormatValidator takes a list of IsoDateFormatValidators.