7
\$\begingroup\$

I wrote this code to parse dates from the output of the OCR, which means that the obtained date can be literally anything, so I put some restrictions in place:

  • Date is the the format of: field1?field2?field3, where the fields are either day, month or year and any delimiter can be used to split the numbers, this is also called the short format of dates.
  • The fields consist of only numbers. (So no months as text)
  • The locale is known.

I first played around with DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withLocale(locale), but it turned out to be only of use for formatting, and not for parsing, as it only gives one specific format per locale.

So I decided to roll out my own code whilst still intending to use as many Java library features as possible (mainly from java.util.Locale and java.time).

The test class:

public class DateParserTest {
    @Test
    public void testParseDutchDate() {
        List<String> dates = Arrays.asList(
            "02-10-2014",
            "2-10-2014",
            "02-10-14",
            "2-10-14",
            "02/10/2014",
            "02 10 2014"
        );

        for (String date : dates) {
            Locale locale = new Locale("nl");

            LocalDate localDate = DateParser.parseShortDate(date, locale);

            assertEquals(LocalDate.of(2014, 10, 2), localDate);
        }
    }

    @Test
    public void testParseAmericanDate() {
        List<String> dates = Arrays.asList(
            "10-02-2014",
            "10-2-2014",
            "10-02-14",
            "10-2-14",
            "10/02/2014",
            "10 02 2014"
        );

        for (String date : dates) {
            Locale locale = new Locale("en-US");

            LocalDate localDate = DateParser.parseShortDate(date, locale);

            assertEquals(LocalDate.of(2014, 10, 2), localDate);
        }
    }
}

The parser class:

public final class DateParser {
    private DateParser() {
        throw new UnsupportedOperationException();
    }

    private final static Pattern PARSE_DATE_PATTERN = Pattern.compile("(?iuU)^\\W*([\\w]+)\\W+([\\w]+)\\W+([\\w]+)\\W*$");

    private static final Pattern DATE_PATTERN_EXTRACTION_PATTERN = Pattern.compile("^(\\w+)\\W+(\\w+)\\W+(\\w+)$");

    public static LocalDate parseShortDate(final String text, final Locale locale) {
        Objects.requireNonNull(text, "text");
        Objects.requireNonNull(locale, "locale");

        Matcher matcher = PARSE_DATE_PATTERN.matcher(text);
        if (!matcher.matches()) {
            throw new IllegalStateException("Date " + text + " for locale " + locale + " could not be matched");
        }

        String match1 = matcher.group(1);
        String match2 = matcher.group(2);
        String match3 = matcher.group(3);

        String pattern = DateTimeFormatterBuilder.getLocalizedDateTimePattern(FormatStyle.SHORT, null, Chronology.ofLocale(locale), locale);

        Matcher datePatternMatcher = DATE_PATTERN_EXTRACTION_PATTERN.matcher(pattern);
        if (!datePatternMatcher.matches()) {
            throw new IllegalStateException("Date format " + pattern + " for locale " + locale + " could not be processed");
        }
        String datePatternMatch1 = datePatternMatcher.group(1);
        String datePatternMatch2 = datePatternMatcher.group(2);
        String datePatternMatch3 = datePatternMatcher.group(3);

        String resolvedPattern = new StringBuilder()
            .append(String.join("", Collections.nCopies(match1.length(), datePatternMatch1.substring(0, 1))))
            .append('-')
            .append(String.join("", Collections.nCopies(match2.length(), datePatternMatch2.substring(0, 1))))
            .append('-')
            .append(String.join("", Collections.nCopies(match3.length(), datePatternMatch3.substring(0, 1))))
            .toString();

        return LocalDate.parse(String.join("-", match1, match2, match3), DateTimeFormatter.ofPattern(resolvedPattern, locale));
    }
}

I'd like to have a general review with extra focus on how to be able to support as many input variations and locales as possible.

This type of problem also has some practical issues you will only find out when practicing by solving this problem, so I strongly encourage you to review this code with an IDE at hand to be able to test alternative suggestions.

\$\endgroup\$
2
\$\begingroup\$

I would recommend you to add in a test to check for every available Locale on the platform, if you can parse a date formatted using that locale.

The corresponding test would be:

@Test
public void testAllLocales() {
    LocalDate specificLocalDate = LocalDate.of(2014, 10, 2);

    Locale[] locales = Locale.getAvailableLocales();
    for (Locale locale : locales) {
        DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withLocale(locale);
        String date = specificLocalDate.format(dateTimeFormatter);

        LocalDate localDate = DateParser.parseShortDate(date, locale);

        assertEquals("for " + date + " using " + locale, specificLocalDate, localDate);
    }
}

Using this we find a few sneaky bugs:

  1. It does not work for the locale hr_HR, as it uses date format dd.MM.yy, which has a dot at the end. To fix this we need to change the DATE_PATTERN_EXTRACTION_PATTERN to Pattern.compile("^\\W*(\\w+)\\W+(\\w+)\\W+(\\w+)\\W*$"). Note that we now allow any number matches of non-words at the start and end of the date format.
  2. The DATE_PATTERN_EXTRACTION_PATTERN should work using Unicode, as languages may possibly use Unicode characters as separators, so it should be: Pattern.compile("(?iuU)^\\W*(\\w+)\\W+(\\w+)\\W+(\\w+)\\W*$").
  3. There is bug using the locale zh_HK, supposedly due to unicode characters in the formatted date, as it is not able to extract the three numbers from the date.
  4. The ja_JP_JP_#u-ca-japanese locale does not work, but this may be due to a JDK bug: https://stackoverflow.com/questions/26169008/the-ja-jp-jp-u-ca-japanese-locale-cannot-be-reparsed-by-its-own-pattern-using-t
\$\endgroup\$
1
\$\begingroup\$

I have to post this as an answer, as I cannot comment yet:

Do you really want to throw an IllegalStateException if you cannot extract the date? It implies that if your method ever got called with something you cannot parse or an empty string, that would be a programming error. Which, depending on how your code is used, may or may not be correct.

Also as a side note, I hope you're not catching that IllegalStateException in the calling code.

\$\endgroup\$
  • \$\begingroup\$ This is exactly the sort of thing that should go in an answer, and not in a comment =) \$\endgroup\$ – Pimgd Jan 27 '15 at 14:30
0
\$\begingroup\$

This could be solved with org.joda.time.format.DateTimeFormat.

As for the tests. Do not assert in the for loop. If the assertion fails then the whole test fails. So if it fails on the first date, then you don't know anything about other dates. Use parameterized tests instead.

\$\endgroup\$
  • 3
    \$\begingroup\$ How could this be solved with Jodatime? Have you tested if your alternative suggestions work? \$\endgroup\$ – skiwi Oct 3 '14 at 8:03

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.