java.time DateTimeFormatter parsing with flexible fallback values
Asked Answered
S

3

7

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
Subtle answered 13/4, 2018 at 7:56 Comment(6)
I too am confused by your Question. Do you want to supply default values? Use the DateTimeFormatterBuilder as you showed. Do you want overrides on certain parts of the date-time values? Adjust by calling the with… methods on the date-time object instantiated by the DateTimeFormatter. Edit your Question to clarify defaults versus overrides. Perhaps give some examples of inputs and desired outputs.Pettifogging
sorry for being unclear. Imagine I want to parse the dateformat dd/MM, then I would need to add parseDefaulting(ChronoField.YEAR, 1984) to my formatter, knowing this format does not have a year. If the next parsing (note I do not know about the formatters, suer supplied) is YYYY/dd/MM then adding parseDefaulting(ChronoField.YEAR, 1984) will return an exception as the year is already set. So I need to somehow distinguish between those date formats to be able to set the proper default values. Hope that makes sense.Subtle
I have reprodced a java.time.format.DateTimeParseException: Text '2018/12/06' could not be parsed: Conflict found: Year 1970 differs from Year 2018 from the last one of your examples. The formatter has fields year, era and year-of-era. I believe that parseDefaulting will fill in a field if that specific field has not been parsed, no matter that it could have been deduced from other fields. I don’t know the solution.Unreeve
I am speculating whether a solution could be found using DateTimeFormatter.parseUnresolved() or DateTimeFormatter.withResolverFields(). It is not obvious.Unreeve
Interesting question, but wouldn’t it be super-simple just to count slashes or match a regex and then branch accordingly?Glue
the slashes here are just an example, the user could also use dots like yyyy.MM.dd or dashes yyyy-MM-dd.. I think this would be supercomplex with a regex, as you would need to search for the letters (and count them), not for the characters in between, basically rebuilding the java time parsing logicSubtle
A
7

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
Arrange answered 13/4, 2018 at 12:54 Comment(1)
Thanks a tons for the suggestion! I took this one and the comment below and created a method where I can pass in another zoned date time and then merge the parsed temporalaccessor into that one. So far it works.Subtle
U
6

I’m in doubt whether you should want this, but I present it as an option.

private static LocalDate defaults = LocalDate.of(1970, Month.JANUARY, 1);

private static LocalDate parseWithDefaults(String pattern, String dateString) {
    TemporalAccessor parsed 
            = DateTimeFormatter.ofPattern(pattern, Locale.ROOT).parse(dateString);
    LocalDate result = defaults;
    for (TemporalField field : ChronoField.values()) {
        if (parsed.isSupported(field) && result.isSupported(field)) {
            result = result.with(field, parsed.getLong(field));
        }
    }
    return result;
}

I am going the opposite way: instead of taking the missing fields and adjusting them into the parsed object, I am taking a default LocalDate object and adjust the parsed fields into it. There are complicated rules for how this works, so I am afraid there may be a surprise or two in for us. Also, with a fully specified date like 2018/12/06, it uses 13 fields, so there is clearly some redundancy. However, I tried it with your three test examples:

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

It printed the expected

1970-12-06
2018-12-06
2018-12-06

A further thought

It sounds a bit like this piece of your software has been designed around this particular behaviour of Joda-Time. So even though you are migrating from Joda to java.time — a migration with which you should be happy — if it were me, I’d consider keeping Joda-Time around for this particular corner. It’s not the most pleasant of options, particularly not since no direct conversions exist between Joda-time and java.time (that I know of). You will need to weigh the pros and the cons yourself.

Unreeve answered 13/4, 2018 at 11:6 Comment(3)
thanks a lot for the code sample and the time you dedicated into this. Keeping an unmaintained library around is IMO a no go, even if it means we have to invest more time to port things over to java.time. Getting rid of a depedency is always good. I now did something similar as above by merging the temporal acccessor into a user specified date and it works so far.Subtle
Instead of iterating all ChronoField.values(), you just need the ones LocalDate uses right? I.e. ChronoField[] fieldsToOverride = { ChronoField.YEAR, ChronoField.MONTH_OF_YEAR, ChronoField.DAY_OF_MONTH };Mystagogue
@Mystagogue On one hand you are correct, if for instance an hour of day is parsed, we already know that it won’t help define a date, so it may feel like a waste testing for it. On the other hand according to the docs LocalDate supports 13 fields from ChronoField, not just the 3 you mention. There are situations where it matters, including the example parseWithDefaults("uuuu/MM/dd", "2018/12/06") in the answer, and many others.Unreeve
S
1

You might try out the parse engine of my lib Time4J as a kind of enhancement/improvement and then use following code which produces instances of java.time.LocalDate during parsing:

static ChronoFormatter<LocalDate> createParser(String pattern) {
    return ChronoFormatter // maybe consider caching the immutable formatter per pattern
        .ofPattern(
            pattern,
            PatternType.CLDR,
            Locale.ROOT, // locale-sensitive patterns require another locale
            PlainDate.axis(TemporalType.LOCAL_DATE) // converts to java.time.LocalDate
        )
        .withDefault(PlainDate.YEAR, 1970)
        .with(Leniency.STRICT);
}

public static void main(String[] args) throws Exception {
    System.out.println(createParser("uuuu/MM/dd").parse("2018/12/06")); // 2018-12-06
    System.out.println(createParser("yyyy/MM/dd").parse("2018/12/06")); // 2018-12-06
    System.out.println(createParser("MM/dd").parse("12/06")); // 1970-12-06
}

This works because - despite the strict parsing mode (where ambivalent element values are checked - the pattern symbol "y" will be mapped to "u" (proleptic gregorian year) as long as there is no era symbol "G" respective historic era element.

About the many other features of the alternative format engine, see the documentation. A builder for specialized element syntax or customized patterns is also available. Other variations of defining default values exist. Your Joda-default-code might be migrated this way (using the system timezone, but using UTC is easily possible, too):

parser.withDefaultSupplier( // also works if current year is changing
  PlainDate.YEAR,
  () -> SystemClock.inLocalView().today().getYear()
  // or: () -> SystemClock.inZonalView(ZonalOffset.UTC).getYear()
)

Another important notice about patterns in use

The pattern syntax of Joda and java.time are different. Are you aware of this fact? When migrating you have to convert the patterns anyway:

  • y => u
  • Y => y
  • x => Y
Straitjacket answered 14/4, 2018 at 9:56 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.