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

125 Views Asked by At

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)
2

There are 2 best solutions below

0
hooknc On BEST ANSWER

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.

3
VGR On

You can learn what’s happening by printing the type of the result of parseBest:

assertThrows(DateTimeParseException.class,
        () -> System.out.println(
            formatter.parseBest(value, queries).getClass()));

The result is:

java.time.Year

As you expected, "1984-00" cannot be parsed as a YearMonth. But you passed YearMonth::from, Year::from as your TemporalQuerys, so after parseBest discovers that the string cannot be parsed using YearMonth.from, it tries again using Year.from, which succeeds.

Why does it succeed? Because a Year only needs the year part (uuuu in your DateTimeFormatter) to be resolved. No attempt is ever made to resolve the month, so the 00 is ignored. It doesn’t matter to Year.from whether that part can be resolved to a valid month.

If I wanted to parse multiple types, and wanted to be sure the entire string is always valid, I would just use a simple approach:

public Temporal parse(String value) {
    String len = value.length();
    if (len == 4) {
        return Year.parse(value);
    } else if (len == 7) {
        return YearMonth.parse(value);
    } else {
        return LocalDate.parse(value);
    }
}