Using new Java 8 DateTimeFormatter to do strict date parsing

I'm editing to limit what kind of string will be considered valid by using a custom formatter created with a DateTimeFormatterBuilder.

public class DateFormmaterTest {

    static DateTimeFormatter CUSTOM_BASIC_ISO_DATE = new DateTimeFormatterBuilder()
            .parseCaseInsensitive().appendValue(YEAR, 4)
            .appendValue(MONTH_OF_YEAR, 2).appendValue(DAY_OF_MONTH, 2)
            .optionalStart().toFormatter()
            .withResolverStyle(ResolverStyle.STRICT)
            .withChronology(IsoChronology.INSTANCE);

    public static void main(String[] args) {

        LocalDate date1 = LocalDate.parse("19800228-5000",
                CUSTOM_BASIC_ISO_DATE);

        System.out.println(date1);

    }
}

2/29/1982 is invalid and would throw the following:

Caused by: java.time.DateTimeException: Invalid date 'February 29' as '1982' is not a leap year
    at java.time.LocalDate.create(LocalDate.java:429)

A date of 19800228-5000 would work with BASIC_ISO_DATE because it allows the optional offset which you don't want allowed. My CUSTOM_BASIC_ISO_DATE formatter will not allow that and throw the following:

Exception in thread "main" java.time.format.DateTimeParseException: Text '19800228-5000' could not be parsed, unparsed text found at index 8. 

Note, if you are sure of the string length, yyyyMMdd then you could always work with the substring of first 8 chars to negate the need for the resolver. However that is two different things. The resolver will flag invalid date formats on input and the substring would of course just strip the extra chars out.


I had similar requirement recently. I was overhauling a legacy application that was written back when Java 5 was the newest release. I needed to keep parsing specific date and date/time formats, but I wanted the same 'strict' behaviour that the old SimpleDateFormat class used to provide. I also had a need to convert to/from java.util.Date for compatibility with older existing code.

Here's my utility class and unit tests:

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.ResolverStyle;
import java.time.temporal.ChronoField;
import java.time.temporal.TemporalAccessor;
import java.util.Date;

/**
 * Static utility class for converting from java.util.Date to modern Java time classes,
 * and some predefined DateTimeFormatter instances to handle formats used in data files
 * that this application processes.
 */
public class DateTimeUtils {

    private DateTimeUtils() {}

    // Jim Tough - 2020-09-14
    // These two date/time formats are part of the data file specification.
    // We are stuck with these formats now.
    private static final String DTF_YYYYMMDDHHMMSS_FORMAT_STRING = "uuuuMMdd HH:mm:ss";
    private static final String DTF_YYYYMMDDHHMM_FORMAT_STRING = "uuuuMMddHHmm";
    private static final String DTF_YYYYMMDD_FORMAT_STRING = "uuuuMMdd";

    public static final DateTimeFormatter DTF_YYYYMMDDHHMMSS =
            DateTimeFormatter.ofPattern(DTF_YYYYMMDDHHMMSS_FORMAT_STRING)
                    .withResolverStyle(ResolverStyle.STRICT);

    public static final DateTimeFormatter DTF_YYYYMMDDHHMM =
            DateTimeFormatter.ofPattern(DTF_YYYYMMDDHHMM_FORMAT_STRING)
                    .withResolverStyle(ResolverStyle.STRICT);

    public static final DateTimeFormatter DTF_YYYYMMDD =
            DateTimeFormatter.ofPattern(DTF_YYYYMMDD_FORMAT_STRING)
                    .withResolverFields(ChronoField.YEAR, ChronoField.MONTH_OF_YEAR, ChronoField.DAY_OF_MONTH)
                    .withResolverStyle(ResolverStyle.STRICT);

    //-------------------------------------------------------------------------

    public static LocalDate convertToLocalDateViaInstant(Date dateToConvert) {
        if (dateToConvert == null) {
            return null;
        }
        return dateToConvert.toInstant()
                .atZone(ZoneId.systemDefault())
                .toLocalDate();
    }

    public static LocalDateTime convertToLocalDateTimeViaInstant(Date dateToConvert) {
        if (dateToConvert == null) {
            return null;
        }
        return dateToConvert
                .toInstant()
                .atZone(ZoneId.systemDefault())
                .toLocalDateTime();
    }

    public static Date convertToDateViaInstant(LocalDate dateToConvert) {
        if (dateToConvert == null) {
            return null;
        }
        return java.util.Date.from(dateToConvert.atStartOfDay()
                .atZone(ZoneId.systemDefault())
                .toInstant());
    }

    public static Date convertToDateViaInstant(LocalDateTime dateToConvert) {
        if (dateToConvert == null) {
            return null;
        }
        return java.util.Date.from(
                dateToConvert.atZone(ZoneId.systemDefault()).toInstant()
        );
    }

    //-------------------------------------------------------------------------

    /**
     * Parse the supplied string using the supplied {@code DateTimeFormatter} and return the result
     * as a {@code LocalDate}
     * @param dtf Non-null
     * @param s String, or null value
     * @return {@code LocalDate}, or null if the supplied string parameter is null
     * @throws java.time.DateTimeException when supplied string cannot be parsed by the
     *   {@code DateTimeFormatter} and converted to a {@code LocalDate}
     */
    public static LocalDate parseAsLocalDate(DateTimeFormatter dtf, String s) {
        if (s == null) {
            return null;
        }
        TemporalAccessor ta = dtf.parse(s);
        return LocalDate.from(ta);
    }

    /**
     * Parse the supplied string using the supplied {@code DateTimeFormatter} and return the result
     * as a {@code LocalDateTime}
     * @param dtf Non-null
     * @param s String, or null value
     * @return {@code LocalDateTime}, or null if the supplied string parameter is null
     * @throws java.time.DateTimeException when supplied string cannot be parsed by the
     *   {@code DateTimeFormatter} and converted to a {@code LocalDateTime}
     */
    public static LocalDateTime parseAsLocalDateTime(DateTimeFormatter dtf, String s) {
        if (s == null) {
            return null;
        }
        TemporalAccessor ta = dtf.parse(s);
        return LocalDateTime.from(ta);
    }

}

unit tests...

import org.junit.jupiter.api.Test;

import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Month;

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

/**
 * @author JTough
 */
public class DateTimeUtilsTest {

    @Test
    void testConvertToLocalDateViaInstant_nominal() {
        LocalDate ld = DateTimeUtils.convertToLocalDateViaInstant(new java.util.Date());

        assertNotNull(ld);
    }

    @Test
    void testConvertToLocalDateViaInstantWithNullValue() {
        assertNull(DateTimeUtils.convertToLocalDateViaInstant(null));
    }

    //-------------------------------------------------------------------------

    @Test
    void testConvertToLocalDateTimeViaInstant_nominal() {
        LocalDateTime ldt = DateTimeUtils.convertToLocalDateTimeViaInstant(new java.util.Date());

        assertNotNull(ldt);
    }

    @Test
    void testConvertToLocalDateTimeViaInstantWithNullValue() {
        assertNull(DateTimeUtils.convertToLocalDateTimeViaInstant(null));
    }

    //-------------------------------------------------------------------------

    @Test
    void testConvertToDateViaInstant_nominal_A() {
        java.util.Date d = DateTimeUtils.convertToDateViaInstant(LocalDate.now());

        assertNotNull(d);
    }

    @Test
    void testConvertToDateViaInstantWithNullValueA() {
        assertNull(DateTimeUtils.convertToDateViaInstant((LocalDate)null));
    }

    //-------------------------------------------------------------------------

    @Test
    void testConvertToDateViaInstant_nominal_B() {
        java.util.Date d = DateTimeUtils.convertToDateViaInstant(LocalDateTime.now());

        assertNotNull(d);
    }

    @Test
    void testConvertToDateViaInstantWithNullValueB() {
        assertNull(DateTimeUtils.convertToDateViaInstant((LocalDate)null));
        assertNull(DateTimeUtils.convertToDateViaInstant((LocalDateTime)null));
    }

    //-------------------------------------------------------------------------

    @Test
    void testParseAsLocalDate_nominal_A() {
        LocalDate ld = DateTimeUtils.parseAsLocalDate(DateTimeUtils.DTF_YYYYMMDD, "20201225");

        assertNotNull(ld);
        assertEquals(2020, ld.getYear());
        assertEquals(Month.DECEMBER, ld.getMonth());
        assertEquals(25, ld.getDayOfMonth());
    }

    @Test
    void testParseAsLocalDate_nominal_B() {
        LocalDate ld = DateTimeUtils.parseAsLocalDate(DateTimeUtils.DTF_YYYYMMDD, "20200229");

        assertNotNull(ld);
        assertEquals(2020, ld.getYear());
        assertEquals(Month.FEBRUARY, ld.getMonth());
        assertEquals(29, ld.getDayOfMonth());
    }

    @Test
    void testParseAsLocalDateWithInvalidDayOfMonth() {
        assertThrows(DateTimeException.class, ()->DateTimeUtils.parseAsLocalDate(DateTimeUtils.DTF_YYYYMMDD, "20200230"));
    }

    @Test
    void testParseAsLocalDateWithGarbageInputString() {
        assertThrows(DateTimeException.class, ()->DateTimeUtils.parseAsLocalDate(DateTimeUtils.DTF_YYYYMMDD, "garbage"));
    }

    //-------------------------------------------------------------------------

    @Test
    void testParseAsLocalDateTime_withoutseconds_nominal_A() {
        LocalDateTime ldt = DateTimeUtils.parseAsLocalDateTime(DateTimeUtils.DTF_YYYYMMDDHHMM, "202012251359");

        assertNotNull(ldt);
        assertEquals(2020, ldt.getYear());
        assertEquals(Month.DECEMBER, ldt.getMonth());
        assertEquals(25, ldt.getDayOfMonth());
        assertEquals(13, ldt.getHour());
        assertEquals(59, ldt.getMinute());
    }

    @Test
    void testParseAsLocalDateTime_withseconds_nominal_A() {
        LocalDateTime ldt = DateTimeUtils.parseAsLocalDateTime(DateTimeUtils.DTF_YYYYMMDDHHMMSS, "20201225 13:59:33");

        assertNotNull(ldt);
        assertEquals(2020, ldt.getYear());
        assertEquals(Month.DECEMBER, ldt.getMonth());
        assertEquals(25, ldt.getDayOfMonth());
        assertEquals(13, ldt.getHour());
        assertEquals(59, ldt.getMinute());
        assertEquals(33, ldt.getSecond());
    }

    @Test
    void testParseAsLocalDateTime_withoutseconds_nominal_B() {
        LocalDateTime ldt = DateTimeUtils.parseAsLocalDateTime(DateTimeUtils.DTF_YYYYMMDDHHMM, "202002291234");

        assertNotNull(ldt);
        assertEquals(2020, ldt.getYear());
        assertEquals(Month.FEBRUARY, ldt.getMonth());
        assertEquals(29, ldt.getDayOfMonth());
        assertEquals(12, ldt.getHour());
        assertEquals(34, ldt.getMinute());
    }

    @Test
    void testParseAsLocalDateTime_withseconds_nominal_B() {
        LocalDateTime ldt = DateTimeUtils.parseAsLocalDateTime(DateTimeUtils.DTF_YYYYMMDDHHMMSS, "20200229 12:34:56");

        assertNotNull(ldt);
        assertEquals(2020, ldt.getYear());
        assertEquals(Month.FEBRUARY, ldt.getMonth());
        assertEquals(29, ldt.getDayOfMonth());
        assertEquals(12, ldt.getHour());
        assertEquals(34, ldt.getMinute());
        assertEquals(56, ldt.getSecond());
    }

    @Test
    void testParseAsLocalDateTimeWithInvalidDayOfMonth() {
        assertThrows(DateTimeException.class, ()->DateTimeUtils.parseAsLocalDateTime(DateTimeUtils.DTF_YYYYMMDDHHMM, "202002301234"));
    }

    @Test
    void testParseAsLocalDateTimeWithInvalidHour() {
        assertThrows(DateTimeException.class, ()->DateTimeUtils.parseAsLocalDateTime(DateTimeUtils.DTF_YYYYMMDDHHMM, "202012252500"));
    }

    @Test
    void testParseAsLocalDateTimeWithInvalidMinute() {
        assertThrows(DateTimeException.class, ()->DateTimeUtils.parseAsLocalDateTime(DateTimeUtils.DTF_YYYYMMDDHHMM, "202012251366"));
    }

    @Test
    void testParseAsLocalDateTimeWithGarbageInputString() {
        assertThrows(DateTimeException.class, ()->DateTimeUtils.parseAsLocalDateTime(DateTimeUtils.DTF_YYYYMMDD, "garbage"));
    }

}

Java 8 uses uuuu for year, not yyyy. In Java 8, yyyy means "year of era" (BC or AD) and the error message complains that MonthOfYear, DayOfMonth and YearOfEra is not enough information to construct the date because era is not known.

To fix that, use uuuu in your format string, e.g. DateTimeFormatter.ofPattern("uuuuMMdd")

Or, if you want to keep using yyyy, you can set the default era, e.g.

private static final DateTimeFormatter FORMAT = new DateTimeFormatterBuilder()
            .appendPattern("yyyyMMdd")
            .parseDefaulting(ChronoField.ERA, 1 /* era is AD */)
            .toFormatter()
            .withChronology(IsoChronology.INSTANCE)
            .withResolverStyle(ResolverStyle.STRICT);