4

Goal

Provide a flexible parser for LocalDate instances that can handle input in one of the following formats:

  • yyyy
  • yyyyMM
  • yyyyMMdd

Implementation Attempt

The following class attempts to handle both the first and the second pattern. Parsing works for the year input, but year + month results in the exception outlined below.

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.temporal.ChronoField;

public class DateTest {

    public static void main(String[] args) {
        DateTimeFormatter parser = new DateTimeFormatterBuilder()
        .parseDefaulting(ChronoField.MONTH_OF_YEAR, 1)
        .parseDefaulting(ChronoField.DAY_OF_MONTH, 1)
        .appendPattern("yyyy")
        .optionalStart().appendPattern("MM").optionalEnd().toFormatter();

        System.out.println(parser.parse("2014", LocalDate::from)); // Works
        System.out.println(parser.parse("201411", LocalDate::from)); // Fails
    }
}

The second parse() attempt results in the following exception:

Exception in thread "main" java.time.format.DateTimeParseException: Text '201411' could not be parsed at index 0
at java.time.format.DateTimeFormatter.parseResolved0(DateTimeFormatter.java:1949)
at java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:1851)

I think my understanding of how optional partial patterns work is lacking. Is my goal of one parser with a flexible format even achievable, or do I need to check on input length and select from a list of parsers? As always, help is appreciated.

Michael Schmid
  • 318
  • 3
  • 12

4 Answers4

4

Here is the solution. You can define possible patterns inside appendPattern(). And to optional put defaults.

   DateTimeFormatter parser = new DateTimeFormatterBuilder()
            .appendPattern("[yyyy][yyyyMM][yyyyMMdd]")
            .optionalStart()
              .parseDefaulting(ChronoField.MONTH_OF_YEAR, 1)
              .parseDefaulting(ChronoField.DAY_OF_MONTH, 1)
            .optionalEnd()
            .toFormatter();
    System.out.println(parser.parse("2014",LocalDate::from)); // Works
    System.out.println(parser.parse("201411",LocalDate::from)); // Works
    System.out.println(parser.parse("20141102",LocalDate::from)); // Works

The output is

2014-01-01
2014-11-01
2014-11-02
Danila Zharenkov
  • 1,720
  • 1
  • 15
  • 27
  • Bingo! That indeed produces the expected output. I cannot believe how easy the use of optionals actually was :) For sake of completeness: the optionalStart() and optionalEnd() in Danila's solution is superfluous, this also works: `DateTimeFormatter parser = new DateTimeFormatterBuilder().appendPattern("[yyyy][yyyyMM][yyyyMMdd]").parseDefaulting(ChronoField.MONTH_OF_YEAR, 1) .parseDefaulting(ChronoField.DAY_OF_MONTH, 1).toFormatter();` Thanks for that answer! – Michael Schmid Apr 25 '18 at 15:00
3

The real cause of your problem is sign-handling. Your input has no sign but the parser element "yyyy" is greedy to parse as many digits as possible and expects a positive sign because there are more than four digits found.

My analysis was done in two different ways:

  • debugging (in order to see what is really behind the unclear error message)

  • simulating the behaviour in another parse engine based on my lib Time4J for getting a better error message:

    ChronoFormatter<LocalDate> cf =
    ChronoFormatter
        .ofPattern(
            "yyyy[MM]",
            PatternType.THREETEN,
            Locale.ROOT,
            PlainDate.axis(TemporalType.LOCAL_DATE)
        )
        .withDefault(PlainDate.MONTH_AS_NUMBER, 1)
        .withDefault(PlainDate.DAY_OF_MONTH, 1)
        .with(Leniency.STRICT);
    System.out.println(cf.parse("201411")); 
    // java.text.ParseException: Positive sign must be present for big number.
    

You could circumvent the problem by instructing the builder to always use only four digits for the year:

DateTimeFormatter parser =
    new DateTimeFormatterBuilder()
        .appendValue(ChronoField.YEAR, 4)
        .optionalStart()
        .appendPattern("MM[dd]")
        .optionalEnd()
        .parseDefaulting(ChronoField.MONTH_OF_YEAR, 1)
        .parseDefaulting(ChronoField.DAY_OF_MONTH, 1)
        .toFormatter();

System.out.println(parser.parse("2014", LocalDate::from)); // 2014-01-01
System.out.println(parser.parse("201411", LocalDate::from)); // 2014-11-01
System.out.println(parser.parse("20141130", LocalDate::from)); // 2014-11-30

Pay attention to the position of the defaulting elements in the builder. They are not called at the start but at the end because the processing of defaulting elements is unfortunately position-sensitive in java.time. And I have also added an extra optional section for the day of month inside the first optional section. This solution seems to be cleaner for me instead of using a sequence of 3 optional sections as suggested by Danila Zharenkov because latter one could also parse quite different inputs with many more digits (possible misuse of optional sections as replacement for or-patterns especially in lenient parsing).

About position-sensitive behaviour of defaulting elements here a citation from API-documentation:

During parsing, the current state of the parse is inspected. If the specified field has no associated value, because it has not been parsed successfully at that point, then the specified value is injected into the parse result. Injection is immediate, thus the field-value pair will be visible to any subsequent elements in the formatter. As such, this method is normally called at the end of the builder.


By the way: In my lib Time4J I can also define real or-patterns using the symbol "|" and then create this formatter:

ChronoFormatter<LocalDate> cf =
    ChronoFormatter
        .ofPattern(
            "yyyyMMdd|yyyyMM|yyyy",
            PatternType.CLDR,
            Locale.ROOT,
            PlainDate.axis(TemporalType.LOCAL_DATE)
        )
        .withDefault(PlainDate.MONTH_AS_NUMBER, 1)
        .withDefault(PlainDate.DAY_OF_MONTH, 1)
        .with(Leniency.STRICT);
Karol Dowbecki
  • 43,645
  • 9
  • 78
  • 111
Meno Hochschild
  • 42,708
  • 7
  • 104
  • 126
  • Thanks for providing so much insight! I had no idea that parseDefaulting() works differently depending on position. Parsing seems to be one of the less-intuitive areas of java.time, I found many similar questions on the web. For Readers: both Danila's and Meno's answers provide a working solution for the problem. I chose this answer because the definition of the pattern seems a bit more readable to me, and because the same approach can be easily applied to timestamp formatters (which would require many "OR-optionals" in Danilo's version. – Michael Schmid Apr 25 '18 at 15:25
  • Additional advantage: the DateTimeFormatterBuilder can also be used to format LocalDate instances to their "full" date representation. – Michael Schmid Apr 25 '18 at 15:30
1

In this part of the code you already set a value for month and day .parseDefaulting(ChronoField.MONTH_OF_YEAR, 1) .parseDefaulting(ChronoField.DAY_OF_MONTH, 1) Then you're trying to pass an input for month and year in your code System.out.println(parser.parse("201411", LocalDate::from)); That you already set.

  • Thanks for your answer, but I think you misunderstood the point of the `parseDefaulting()` calls: they only provide defaults for temporal units that are not present in parsed input. If i follow your suggestion and leave out the defaults I get the following (expected) exception: `Exception in thread "main" java.time.format.DateTimeParseException: Text '2014' could not be parsed: Unable to obtain LocalDate from TemporalAccessor: {Year=2014},ISO of type java.time.format.Parsed at java.time.format.DateTimeFormatter.createError(DateTimeFormatter.java:1920) at java.time.format.DateTimeFo...` – Michael Schmid Apr 25 '18 at 14:21
1

You set a value for month and day but pass a month and year. That's the problem.

You may want to use :

.parseDefaulting(ChronoField.MONTH_OF_YEAR, 1)
.parseDefaulting(ChronoField.YEAR_OF_ERA, ZonedDateTime.now().getYear())
Arnauld Alex
  • 339
  • 1
  • 3
  • 13
  • Thanks for your answer. I've tried your suggestion, but now the first parse attempt fails: `Exception in thread "main" java.time.format.DateTimeParseException: Text '2014' could not be parsed at index 0 at java.time.format.DateTimeFormatter.parseResolved0(DateTimeFormatter.java:1949) at java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:1851)` – Michael Schmid Apr 25 '18 at 14:18
  • I found this link very useful, it seems your date is really similar to BASIC_ISO_DATE, please take a look. [DOC](https://docs.oracle.com/javase/8/docs/api/java/time/format/DateTimeFormatter.html) Otherwise, I recommend you to define your builder like that : `DateTimeFormatter parser = new DateTimeFormatterBuilder().parseDefaulting(ChronoField.MONTH_OF_YEAR, 1).parseDefaulting(ChronoField.YEAR_OF_ERA, ZonedDateTime.now().getYear()).appendPattern("yyyy").appendPattern("yyyyMM").toFormatter();` – Arnauld Alex Apr 25 '18 at 14:39
  • Like that there is no problem with optional start/end and you avoid some error/mistake. Hope it helps. – Arnauld Alex Apr 25 '18 at 14:41
  • You are right, the "full version" of my format actually is BASIC_ISO_DATE. I've tried using both BASIC_ISO_DATE and this: `DateTimeFormatter parser = new DateTimeFormatterBuilder().append(DateTimeFormatter.BASIC_ISO_DATE).parseDefaulting(ChronoField.DAY_OF_MONTH, 1) .parseDefaulting(ChronoField.MONTH_OF_YEAR, 1).parseDefaulting(ChronoField.YEAR_OF_ERA, ZonedDateTime.now().getYear()).toFormatter();` Both result in the same DateTimeParseException as above. I'm also beginning to think that optional patterns work fundamentally different than how I expected them to. – Michael Schmid Apr 25 '18 at 14:55