java.time DateTimeFormatter parsing with flexible fallback values

alr :

I am trying to port some code from joda time to java time.

JodaTime had the possibility to specify a fallback value for the year like this

parser.withDefaultYear((new DateTime(DateTimeZone.UTC)).getYear()).parseDateTime(text);

Regardless how the parser looks (if it includes a year or not), this will be parsed.

java.time has become much more stricter there. Even though there is the DateTimeFormatterBuilder.parseDefaulting() method, which allows you to specify fallbacks, this only works if that specific field is not specified in the date your want to parse or is marked as optional.

If you do not have any control about the incoming date format, as it is user supplied, this makes it super hard when to call parseDefaulting.

Is there any workaround, where I can specify something like either a generic fallback date, whose values get used by the formatter, if they are not specified or how I configure fallback values that are simply not used, when they are specified in the formatter?

Minimal, complete and verifiable example follows.

public static DateTimeFormatter ofPattern(String pattern) {
    return new DateTimeFormatterBuilder()
        .appendPattern(pattern)
        .parseDefaulting(ChronoField.YEAR, 1970)
        .toFormatter(Locale.ROOT);
}

public void testPatterns() {
    // works
    assertThat(LocalDate.from(ofPattern("MM/dd").parse("12/06")).toString(), is("1970-12-06"));
    assertThat(LocalDate.from(ofPattern("uuuu/MM/dd").parse("2018/12/06")).toString(), is("2018-12-06"));
    // fails with exception, as it uses year of era
    assertThat(LocalDate.from(ofPattern("yyyy/MM/dd").parse("2018/12/06")).toString(), is("2018-12-06"));
}

Desired result: The test should parse the strings and pass (“be green”).

Observed result: The last line of the test throws an exception with the following message and stack trace.

Text '2018/12/06' could not be parsed: Conflict found: Year 1970 differs from Year 2018

Exception in thread "main" java.time.format.DateTimeParseException: Text '2018/12/06' could not be parsed: Conflict found: Year 1970 differs from Year 2018
    at java.base/java.time.format.DateTimeFormatter.createError(DateTimeFormatter.java:1959)
    at java.base/java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:1820)
    at com.ajax.mypackage.MyTest.testPatterns(MyTest.java:33)
Caused by: java.time.DateTimeException: Conflict found: Year 1970 differs from Year 2018
    at java.base/java.time.chrono.AbstractChronology.addFieldValue(AbstractChronology.java:676)
    at java.base/java.time.chrono.IsoChronology.resolveYearOfEra(IsoChronology.java:620)
    at java.base/java.time.chrono.IsoChronology.resolveYearOfEra(IsoChronology.java:126)
    at java.base/java.time.chrono.AbstractChronology.resolveDate(AbstractChronology.java:463)
    at java.base/java.time.chrono.IsoChronology.resolveDate(IsoChronology.java:585)
    at java.base/java.time.chrono.IsoChronology.resolveDate(IsoChronology.java:126)
    at java.base/java.time.format.Parsed.resolveDateFields(Parsed.java:360)
    at java.base/java.time.format.Parsed.resolveFields(Parsed.java:266)
    at java.base/java.time.format.Parsed.resolve(Parsed.java:253)
    at java.base/java.time.format.DateTimeParseContext.toResolved(DateTimeParseContext.java:331)
    at java.base/java.time.format.DateTimeFormatter.parseResolved0(DateTimeFormatter.java:1994)
    at java.base/java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:1816)
    ... 1 more
mcgee :

parseDefaulting will set the value of the field if it's not found, even for fields that are not in the pattern, so you may end up with situations where both year and year-of-era are present in the parsed result.

To me, the easiest solution would be as suggested in the comments: check if the input contains a year (or something that looks like one, such as 4 digits) with a regex, or by checking the input's length, and then create the formatter accordingly (and without default values). Examples:

if (input_without_year) {
    LocalDate d = MonthDay
                      .parse("12/06", DateTimeFormatter.ofPattern("MM/dd"))
                      .atYear(1970);
} else {
    // use formatter with year, without default values
}

But if you want a generic solution, I'm afraid it's more complicated. One alternative is to parse the input and check if there are any year field in it. If there's none, then we change it to return a default value for the year:

public static TemporalAccessor parse(String pattern, String input) {
    DateTimeFormatter fmt = DateTimeFormatter.ofPattern(pattern, Locale.ROOT);
    final TemporalAccessor parsed = fmt.parse(input);
    // check year and year of era
    boolean hasYear = parsed.isSupported(ChronoField.YEAR);
    boolean hasYearEra = parsed.isSupported(ChronoField.YEAR_OF_ERA);
    if (!hasYear && !hasYearEra) {
        // parsed value doesn't have any year field
        // return another TemporalAccessor with default value for year
        // using year 1970 - change it to Year.now().getValue() for current year
        return withYear(parsed, 1970); // see this method's code below
    }
    return parsed;
}

First we parse and get a TemporalAccessor containing all the parsed fields. Then we check if it has year or year-of-era field. If it doesn't have any of those, we create another TemporalAccessor with some default value for year.

In the code above, I'm using 1970, but you can change it to whatever you need. The withYear method has some important details to notice:

  • I'm assuming that the input always has month and day. If it's not the case, you can change the code below to use default values for them
    • to check if a field is present, use the isSupported method
  • LocalDate.from internally uses a TemporalQuery, which in turn queries the epoch-day field, but when the parsed object doesn't have the year, it can't calculate the epoch-day, so I'm calculating it as well

The withYear method is as follows:

public static TemporalAccessor withYear(TemporalAccessor t, long year) {
    return new TemporalAccessor() {

        @Override
        public boolean isSupported(TemporalField field) {
            // epoch day is used by LocalDate.from
            if (field == ChronoField.YEAR_OF_ERA || field == ChronoField.EPOCH_DAY) {
                return true;
            } else {
                return t.isSupported(field);
            }
        }

        @Override
        public long getLong(TemporalField field) {
            if (field == ChronoField.YEAR_OF_ERA) {
                return year;
                // epoch day is used by LocalDate.from
            } else if (field == ChronoField.EPOCH_DAY) {
                // Assuming the input always have month and day
                // If that's not the case, you can change the code to use default values as well,
                // and use MonthDay.of(month, day)
                return MonthDay.from(t).atYear((int) year).toEpochDay();
            } else {
                return t.getLong(field);
            }
        }
    };
}

Now this works:

System.out.println(LocalDate.from(parse("MM/dd", "12/06"))); // 1970-12-06
System.out.println(LocalDate.from(parse("uuuu/MM/dd", "2018/12/06"))); // 2018-12-06
System.out.println(LocalDate.from(parse("yyyy/MM/dd", "2018/12/06"))); // 2018-12-06

But I still believe the first solution is simpler.

Alternative

Assuming that you're always creating a LocalDate, another alternative is to use parseBest:

public static LocalDate parseLocalDate(String pattern, String input) {
    DateTimeFormatter fmt = DateTimeFormatter.ofPattern(pattern, Locale.ROOT);

    // try to create a LocalDate first
    // if not possible, try to create a MonthDay
    TemporalAccessor parsed = fmt.parseBest(input, LocalDate::from, MonthDay::from);

    LocalDate dt = null;

    // check which type was created by the parser
    if (parsed instanceof LocalDate) {
        dt = (LocalDate) parsed;
    } else if (parsed instanceof MonthDay) {
        // using year 1970 - change it to Year.now().getValue() for current year
        dt = ((MonthDay) parsed).atYear(1970);
    } // else etc... - do as many checkings you need to handle all possible cases

    return dt;
}

The method parseBest receives a list of TemporalQuery instances (or equivalent method references, as the from methods above) and try to call them in order: in the code above, first it tries to create a LocalDate, and if it's not possible, try a MonthDay.

Then I check the type returned and act accordingly. You can expand this to check as many types you want, and you can also write your own TemporalQuery to handle specific cases.

With this, all cases also work:

System.out.println(parseLocalDate("MM/dd", "12/06")); // 1970-12-06
System.out.println(parseLocalDate("uuuu/MM/dd", "2018/12/06")); // 2018-12-06
System.out.println(parseLocalDate("yyyy/MM/dd", "2018/12/06")); // 2018-12-06

Guess you like

Origin http://10.200.1.11:23101/article/api/json?id=474094&siteId=1