Date/Time parsing: Use java time API instead of exception handling (#37222)

* Add benchmark

* Use java time API instead of exception handling

when several formatters are used, the existing way of parsing those is
to throw an exception catch it, and try the next one. This is is
considerably slower than the approach taken in joda time, so that
indexing is reduced when a date format like `x||y` is used and y is the
date format being used.

This commit now uses the java API to parse the date by appending the
date time formatters to each other and does not rely on exception
handling.

* fix benchmark

* fix tests by changing formatter, also expose printer

* restore optional printing logic to fix tests

* fix tests

* incorporate review comments
This commit is contained in:
Alexander Reelsen 2019-01-11 09:25:05 +01:00 committed by GitHub
parent bbd093059f
commit 9f3da013d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 158 additions and 237 deletions

View File

@ -0,0 +1,58 @@
/*
* Licensed to Elasticsearch under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.elasticsearch.benchmark.time;
import org.elasticsearch.common.joda.Joda;
import org.elasticsearch.common.time.DateFormatter;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
import java.time.temporal.TemporalAccessor;
import java.util.concurrent.TimeUnit;
@Fork(3)
@Warmup(iterations = 10)
@Measurement(iterations = 10)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
@SuppressWarnings("unused") //invoked by benchmarking framework
public class DateFormatterBenchmark {
private final DateFormatter javaFormatter = DateFormatter.forPattern("8year_month_day||ordinal_date||epoch_millis");
private final DateFormatter jodaFormatter = Joda.forPattern("year_month_day||ordinal_date||epoch_millis");
@Benchmark
public TemporalAccessor parseJavaDate() {
return javaFormatter.parse("1234567890");
}
@Benchmark
public TemporalAccessor parseJodaDate() {
return jodaFormatter.parse("1234567890");
}
}

View File

@ -145,7 +145,7 @@ public interface DateFormatter {
if (formatters.size() == 1) {
return formatters.get(0);
}
return new DateFormatters.MergedDateFormatter(input, formatters);
return DateFormatters.merge(input, formatters);
}
}

View File

@ -19,7 +19,6 @@
package org.elasticsearch.common.time;
import org.elasticsearch.ElasticsearchParseException;
import org.elasticsearch.common.Strings;
import java.time.DateTimeException;
@ -31,7 +30,6 @@ import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.time.format.SignStyle;
import java.time.temporal.ChronoField;
@ -40,10 +38,9 @@ import java.time.temporal.TemporalAccessor;
import java.time.temporal.TemporalAdjusters;
import java.time.temporal.TemporalQueries;
import java.time.temporal.WeekFields;
import java.util.Collections;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
import static java.time.temporal.ChronoField.DAY_OF_MONTH;
import static java.time.temporal.ChronoField.DAY_OF_WEEK;
@ -77,58 +74,6 @@ public class DateFormatters {
.appendValue(SECOND_OF_MINUTE, 2, 2, SignStyle.NOT_NEGATIVE)
.toFormatter(Locale.ROOT);
private static final DateTimeFormatter STRICT_DATE_OPTIONAL_TIME_FORMATTER_1 = new DateTimeFormatterBuilder()
.append(STRICT_YEAR_MONTH_DAY_FORMATTER)
.optionalStart()
.appendLiteral('T')
.append(STRICT_HOUR_MINUTE_SECOND_FORMATTER)
.optionalStart()
.appendFraction(MILLI_OF_SECOND, 3, 3, true)
.optionalEnd()
.optionalStart()
.appendZoneOrOffsetId()
.optionalEnd()
.optionalEnd()
.toFormatter(Locale.ROOT);
private static final DateTimeFormatter STRICT_DATE_OPTIONAL_TIME_FORMATTER_2 = new DateTimeFormatterBuilder()
.append(STRICT_YEAR_MONTH_DAY_FORMATTER)
.optionalStart()
.appendLiteral('T')
.append(STRICT_HOUR_MINUTE_SECOND_FORMATTER)
.optionalStart()
.appendFraction(MILLI_OF_SECOND, 3, 3, true)
.optionalEnd()
.optionalStart()
.appendOffset("+HHmm", "Z")
.optionalEnd()
.optionalEnd()
.toFormatter(Locale.ROOT);
/**
* Returns a generic ISO datetime parser where the date is mandatory and the time is optional.
*/
private static final DateFormatter STRICT_DATE_OPTIONAL_TIME =
new JavaDateFormatter("strict_date_optional_time", STRICT_DATE_OPTIONAL_TIME_FORMATTER_1,
STRICT_DATE_OPTIONAL_TIME_FORMATTER_1, STRICT_DATE_OPTIONAL_TIME_FORMATTER_2);
private static final DateTimeFormatter STRICT_DATE_OPTIONAL_TIME_FORMATTER_WITH_NANOS_1 = new DateTimeFormatterBuilder()
.append(STRICT_YEAR_MONTH_DAY_FORMATTER)
.optionalStart()
.appendLiteral('T')
.append(STRICT_HOUR_MINUTE_SECOND_FORMATTER)
.optionalStart()
.appendFraction(NANO_OF_SECOND, 3, 9, true)
.optionalEnd()
.optionalStart()
.appendZoneOrOffsetId()
.optionalEnd()
.optionalStart()
.appendOffset("+HHmm", "Z")
.optionalEnd()
.optionalEnd()
.toFormatter(Locale.ROOT);
private static final DateTimeFormatter STRICT_DATE_OPTIONAL_TIME_PRINTER = new DateTimeFormatterBuilder()
.append(STRICT_YEAR_MONTH_DAY_FORMATTER)
.appendLiteral('T')
@ -139,11 +84,52 @@ public class DateFormatters {
.optionalEnd()
.toFormatter(Locale.ROOT);
private static final DateTimeFormatter STRICT_DATE_OPTIONAL_TIME_FORMATTER = new DateTimeFormatterBuilder()
.append(STRICT_YEAR_MONTH_DAY_FORMATTER)
.optionalStart()
.appendLiteral('T')
.append(STRICT_HOUR_MINUTE_SECOND_FORMATTER)
.optionalStart()
.appendFraction(MILLI_OF_SECOND, 3, 3, true)
.optionalEnd()
.optionalStart()
.appendZoneOrOffsetId()
.optionalEnd()
.optionalStart()
.append(TIME_ZONE_FORMATTER_NO_COLON)
.optionalEnd()
.optionalEnd()
.toFormatter(Locale.ROOT);
/**
* Returns a generic ISO datetime parser where the date is mandatory and the time is optional.
*/
private static final DateFormatter STRICT_DATE_OPTIONAL_TIME =
new JavaDateFormatter("strict_date_optional_time", STRICT_DATE_OPTIONAL_TIME_PRINTER, STRICT_DATE_OPTIONAL_TIME_FORMATTER);
private static final DateTimeFormatter STRICT_DATE_OPTIONAL_TIME_FORMATTER_WITH_NANOS = new DateTimeFormatterBuilder()
.append(STRICT_YEAR_MONTH_DAY_FORMATTER)
.optionalStart()
.appendLiteral('T')
.append(STRICT_HOUR_MINUTE_SECOND_FORMATTER)
.optionalStart()
.appendFraction(NANO_OF_SECOND, 3, 9, true)
.optionalEnd()
.optionalStart()
.appendZoneOrOffsetId()
.optionalEnd()
.optionalStart()
.append(TIME_ZONE_FORMATTER_NO_COLON)
.optionalEnd()
.optionalEnd()
.toFormatter(Locale.ROOT);
/**
* Returns a generic ISO datetime parser where the date is mandatory and the time is optional with nanosecond resolution.
*/
private static final DateFormatter STRICT_DATE_OPTIONAL_TIME_NANOS = new JavaDateFormatter("strict_date_optional_time_nanos",
STRICT_DATE_OPTIONAL_TIME_PRINTER, STRICT_DATE_OPTIONAL_TIME_FORMATTER_WITH_NANOS_1);
STRICT_DATE_OPTIONAL_TIME_PRINTER, STRICT_DATE_OPTIONAL_TIME_FORMATTER_WITH_NANOS);
/////////////////////////////////////////
//
// BEGIN basic time formatters
@ -818,7 +804,7 @@ public class DateFormatters {
* yyyy-MM-dd'T'HH:mm:ss.SSSZ
*/
private static final DateFormatter DATE_OPTIONAL_TIME = new JavaDateFormatter("date_optional_time",
STRICT_DATE_OPTIONAL_TIME_FORMATTER_1,
STRICT_DATE_OPTIONAL_TIME_PRINTER,
new DateTimeFormatterBuilder()
.append(DATE_FORMATTER)
.optionalStart()
@ -836,26 +822,6 @@ public class DateFormatters {
.appendFraction(MILLI_OF_SECOND, 1, 3, true)
.optionalEnd()
.optionalStart().appendZoneOrOffsetId().optionalEnd()
.optionalEnd()
.optionalEnd()
.optionalEnd()
.toFormatter(Locale.ROOT),
new DateTimeFormatterBuilder()
.append(DATE_FORMATTER)
.optionalStart()
.appendLiteral('T')
.optionalStart()
.appendValue(HOUR_OF_DAY, 1, 2, SignStyle.NOT_NEGATIVE)
.optionalStart()
.appendLiteral(':')
.appendValue(MINUTE_OF_HOUR, 1, 2, SignStyle.NOT_NEGATIVE)
.optionalStart()
.appendLiteral(':')
.appendValue(SECOND_OF_MINUTE, 1, 2, SignStyle.NOT_NEGATIVE)
.optionalEnd()
.optionalStart()
.appendFraction(MILLI_OF_SECOND, 1, 3, true)
.optionalEnd()
.optionalStart().appendOffset("+HHmm", "Z").optionalEnd()
.optionalEnd()
.optionalEnd()
@ -1006,7 +972,7 @@ public class DateFormatters {
* (yyyy-MM-dd'T'HH:mm:ss.SSSZZ).
*/
private static final DateFormatter DATE_TIME = new JavaDateFormatter("date_time",
STRICT_DATE_OPTIONAL_TIME_FORMATTER_1,
STRICT_DATE_OPTIONAL_TIME_PRINTER,
new DateTimeFormatterBuilder().append(DATE_TIME_FORMATTER).appendZoneOrOffsetId().toFormatter(Locale.ROOT),
new DateTimeFormatterBuilder().append(DATE_TIME_FORMATTER).append(TIME_ZONE_FORMATTER_NO_COLON).toFormatter(Locale.ROOT)
);
@ -1483,90 +1449,22 @@ public class DateFormatters {
}
}
static class MergedDateFormatter implements DateFormatter {
static JavaDateFormatter merge(String pattern, List<DateFormatter> formatters) {
assert formatters.size() > 0;
private final String pattern;
// package private for tests
final List<DateFormatter> formatters;
private final List<DateMathParser> dateMathParsers;
MergedDateFormatter(String pattern, List<DateFormatter> formatters) {
assert formatters.size() > 0;
this.pattern = pattern;
this.formatters = Collections.unmodifiableList(formatters);
this.dateMathParsers = formatters.stream().map(DateFormatter::toDateMathParser).collect(Collectors.toList());
}
@Override
public TemporalAccessor parse(String input) {
IllegalArgumentException failure = null;
for (DateFormatter formatter : formatters) {
try {
return formatter.parse(input);
// TODO: remove DateTimeParseException when JavaDateFormatter throws IAE
} catch (IllegalArgumentException | DateTimeParseException e) {
if (failure == null) {
// wrap so the entire multi format is in the message
failure = new IllegalArgumentException("failed to parse date field [" + input + "] with format [" + pattern + "]",
e);
} else {
failure.addSuppressed(e);
}
}
List<DateTimeFormatter> dateTimeFormatters = new ArrayList<>(formatters.size());
DateTimeFormatter printer = null;
for (DateFormatter formatter : formatters) {
assert formatter instanceof JavaDateFormatter;
JavaDateFormatter javaDateFormatter = (JavaDateFormatter) formatter;
DateTimeFormatter dateTimeFormatter = javaDateFormatter.getParser();
if (printer == null) {
printer = javaDateFormatter.getPrinter();
}
throw failure;
dateTimeFormatters.add(dateTimeFormatter);
}
@Override
public DateFormatter withZone(ZoneId zoneId) {
return new MergedDateFormatter(pattern, formatters.stream().map(f -> f.withZone(zoneId)).collect(Collectors.toList()));
}
@Override
public DateFormatter withLocale(Locale locale) {
return new MergedDateFormatter(pattern, formatters.stream().map(f -> f.withLocale(locale)).collect(Collectors.toList()));
}
@Override
public String format(TemporalAccessor accessor) {
return formatters.get(0).format(accessor);
}
@Override
public String pattern() {
return pattern;
}
@Override
public Locale locale() {
return formatters.get(0).locale();
}
@Override
public ZoneId zone() {
return formatters.get(0).zone();
}
@Override
public DateMathParser toDateMathParser() {
return (text, now, roundUp, tz) -> {
ElasticsearchParseException failure = null;
for (DateMathParser parser : dateMathParsers) {
try {
return parser.parse(text, now, roundUp, tz);
} catch (ElasticsearchParseException e) {
if (failure == null) {
// wrap so the entire multi format is in the message
failure = new ElasticsearchParseException("failed to parse date field [" + text + "] with format ["
+ pattern + "]", e);
} else {
failure.addSuppressed(e);
}
}
}
throw failure;
};
}
return new JavaDateFormatter(pattern, printer, dateTimeFormatters.toArray(new DateTimeFormatter[0]));
}
private static final ZonedDateTime EPOCH_ZONED_DATE_TIME = Instant.EPOCH.atZone(ZoneOffset.UTC);

View File

@ -19,10 +19,11 @@
package org.elasticsearch.common.time;
import org.elasticsearch.common.Strings;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoField;
import java.time.temporal.TemporalAccessor;
import java.time.temporal.TemporalField;
@ -47,7 +48,7 @@ class JavaDateFormatter implements DateFormatter {
private final String format;
private final DateTimeFormatter printer;
private final DateTimeFormatter[] parsers;
private final DateTimeFormatter parser;
JavaDateFormatter(String format, DateTimeFormatter printer, DateTimeFormatter... parsers) {
if (printer == null) {
@ -62,61 +63,54 @@ class JavaDateFormatter implements DateFormatter {
throw new IllegalArgumentException("formatters must have the same locale");
}
if (parsers.length == 0) {
this.parsers = new DateTimeFormatter[]{printer};
this.parser = printer;
} else if (parsers.length == 1) {
this.parser = parsers[0];
} else {
this.parsers = parsers;
DateTimeFormatterBuilder builder = new DateTimeFormatterBuilder();
for (DateTimeFormatter parser : parsers) {
builder.appendOptional(parser);
}
this.parser = builder.toFormatter(Locale.ROOT);
}
this.format = format;
this.printer = printer;
}
DateTimeFormatter getParser() {
return parser;
}
DateTimeFormatter getPrinter() {
return printer;
}
@Override
public TemporalAccessor parse(String input) {
DateTimeParseException failure = null;
for (int i = 0; i < parsers.length; i++) {
try {
return parsers[i].parse(input);
} catch (DateTimeParseException e) {
if (failure == null) {
failure = e;
} else {
failure.addSuppressed(e);
}
}
if (Strings.isNullOrEmpty(input)) {
throw new IllegalArgumentException("cannot parse empty date");
}
// ensure that all parsers exceptions are returned instead of only the last one
throw failure;
return parser.parse(input);
}
@Override
public DateFormatter withZone(ZoneId zoneId) {
// shortcurt to not create new objects unnecessarily
if (zoneId.equals(parsers[0].getZone())) {
if (zoneId.equals(parser.getZone())) {
return this;
}
final DateTimeFormatter[] parsersWithZone = new DateTimeFormatter[parsers.length];
for (int i = 0; i < parsers.length; i++) {
parsersWithZone[i] = parsers[i].withZone(zoneId);
}
return new JavaDateFormatter(format, printer.withZone(zoneId), parsersWithZone);
return new JavaDateFormatter(format, printer.withZone(zoneId), parser.withZone(zoneId));
}
@Override
public DateFormatter withLocale(Locale locale) {
// shortcurt to not create new objects unnecessarily
if (locale.equals(parsers[0].getLocale())) {
if (locale.equals(parser.getLocale())) {
return this;
}
final DateTimeFormatter[] parsersWithZone = new DateTimeFormatter[parsers.length];
for (int i = 0; i < parsers.length; i++) {
parsersWithZone[i] = parsers[i].withLocale(locale);
}
return new JavaDateFormatter(format, printer.withLocale(locale), parsersWithZone);
return new JavaDateFormatter(format, printer.withLocale(locale), parser.withLocale(locale));
}
@Override
@ -132,17 +126,7 @@ class JavaDateFormatter implements DateFormatter {
JavaDateFormatter parseDefaulting(Map<TemporalField, Long> fields) {
final DateTimeFormatterBuilder parseDefaultingBuilder = new DateTimeFormatterBuilder().append(printer);
fields.forEach(parseDefaultingBuilder::parseDefaulting);
if (parsers.length == 1 && parsers[0].equals(printer)) {
return new JavaDateFormatter(format, parseDefaultingBuilder.toFormatter(Locale.ROOT));
} else {
final DateTimeFormatter[] parsersWithDefaulting = new DateTimeFormatter[parsers.length];
for (int i = 0; i < parsers.length; i++) {
DateTimeFormatterBuilder builder = new DateTimeFormatterBuilder().append(parsers[i]);
fields.forEach(builder::parseDefaulting);
parsersWithDefaulting[i] = builder.toFormatter(Locale.ROOT);
}
return new JavaDateFormatter(format, parseDefaultingBuilder.toFormatter(Locale.ROOT), parsersWithDefaulting);
}
return new JavaDateFormatter(format, parseDefaultingBuilder.toFormatter(Locale.ROOT));
}
@Override

View File

@ -72,8 +72,6 @@ public class JavaJodaTimeDuellingTests extends ESTestCase {
public void testDuellingFormatsValidParsing() {
assertSameDate("1522332219", "epoch_second");
assertSameDate("1522332219.", "epoch_second");
assertSameDate("1522332219.0", "epoch_second");
assertSameDate("0", "epoch_second");
assertSameDate("1", "epoch_second");
assertSameDate("1522332219321", "epoch_millis");

View File

@ -23,6 +23,7 @@ import org.elasticsearch.test.ESTestCase;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeParseException;
import java.time.temporal.TemporalAccessor;
import java.util.Locale;
@ -42,21 +43,11 @@ public class DateFormattersTests extends ESTestCase {
// as this feature is supported it also makes sense to make it exact
public void testEpochMillisParser() {
DateFormatter formatter = DateFormatters.forPattern("epoch_millis");
{
Instant instant = Instant.from(formatter.parse("12345.6789"));
assertThat(instant.getEpochSecond(), is(12L));
assertThat(instant.getNano(), is(345_678_900));
}
{
Instant instant = Instant.from(formatter.parse("12345"));
assertThat(instant.getEpochSecond(), is(12L));
assertThat(instant.getNano(), is(345_000_000));
}
{
Instant instant = Instant.from(formatter.parse("12345."));
assertThat(instant.getEpochSecond(), is(12L));
assertThat(instant.getNano(), is(345_000_000));
}
{
Instant instant = Instant.from(formatter.parse("0"));
assertThat(instant.getEpochSecond(), is(0L));
@ -79,25 +70,12 @@ public class DateFormattersTests extends ESTestCase {
public void testEpochSecondParser() {
DateFormatter formatter = DateFormatters.forPattern("epoch_second");
assertThat(Instant.from(formatter.parse("1234.567")).toEpochMilli(), is(1234567L));
assertThat(Instant.from(formatter.parse("1234.")).getNano(), is(0));
assertThat(Instant.from(formatter.parse("1234.")).getEpochSecond(), is(1234L));
assertThat(Instant.from(formatter.parse("1234.1")).getNano(), is(100_000_000));
assertThat(Instant.from(formatter.parse("1234.12")).getNano(), is(120_000_000));
assertThat(Instant.from(formatter.parse("1234.123")).getNano(), is(123_000_000));
assertThat(Instant.from(formatter.parse("1234.1234")).getNano(), is(123_400_000));
assertThat(Instant.from(formatter.parse("1234.12345")).getNano(), is(123_450_000));
assertThat(Instant.from(formatter.parse("1234.123456")).getNano(), is(123_456_000));
assertThat(Instant.from(formatter.parse("1234.1234567")).getNano(), is(123_456_700));
assertThat(Instant.from(formatter.parse("1234.12345678")).getNano(), is(123_456_780));
assertThat(Instant.from(formatter.parse("1234.123456789")).getNano(), is(123_456_789));
DateTimeParseException e = expectThrows(DateTimeParseException.class, () -> formatter.parse("1234.1234567890"));
assertThat(e.getMessage(), is("Text '1234.1234567890' could not be parsed, unparsed text found at index 4"));
e = expectThrows(DateTimeParseException.class, () -> formatter.parse("1234.123456789013221"));
assertThat(e.getMessage(), is("Text '1234.123456789013221' could not be parsed, unparsed text found at index 4"));
DateTimeParseException e = expectThrows(DateTimeParseException.class, () -> formatter.parse("1234.1"));
assertThat(e.getMessage(), is("Text '1234.1' could not be parsed, unparsed text found at index 4"));
e = expectThrows(DateTimeParseException.class, () -> formatter.parse("1234."));
assertThat(e.getMessage(), is("Text '1234.' could not be parsed, unparsed text found at index 4"));
e = expectThrows(DateTimeParseException.class, () -> formatter.parse("abc"));
assertThat(e.getMessage(), is("Text 'abc' could not be parsed at index 0"));
assertThat(e.getMessage(), is("Text 'abc' could not be parsed, unparsed text found at index 0"));
e = expectThrows(DateTimeParseException.class, () -> formatter.parse("1234.abc"));
assertThat(e.getMessage(), is("Text '1234.abc' could not be parsed, unparsed text found at index 4"));
}
@ -109,6 +87,14 @@ public class DateFormattersTests extends ESTestCase {
assertThat(formatter.pattern(), is("strict_date_optional_time||epoch_millis"));
}
public void testParsersWithMultipleInternalFormats() throws Exception {
ZonedDateTime first = DateFormatters.toZonedDateTime(
DateFormatters.forPattern("strict_date_optional_time_nanos").parse("2018-05-15T17:14:56+0100"));
ZonedDateTime second = DateFormatters.toZonedDateTime(
DateFormatters.forPattern("strict_date_optional_time_nanos").parse("2018-05-15T17:14:56+01:00"));
assertThat(first, is(second));
}
public void testLocales() {
assertThat(DateFormatters.forPattern("strict_date_optional_time").locale(), is(Locale.ROOT));
Locale locale = randomLocale(random());
@ -157,10 +143,7 @@ public class DateFormattersTests extends ESTestCase {
assertThat(DateFormatter.forPattern("8date_optional_time"), instanceOf(JavaDateFormatter.class));
// named formats too
DateFormatter formatter = DateFormatter.forPattern("8date_optional_time||ww-MM-dd");
assertThat(formatter, instanceOf(DateFormatters.MergedDateFormatter.class));
DateFormatters.MergedDateFormatter mergedFormatter = (DateFormatters.MergedDateFormatter) formatter;
assertThat(mergedFormatter.formatters.get(0), instanceOf(JavaDateFormatter.class));
assertThat(mergedFormatter.formatters.get(1), instanceOf(JavaDateFormatter.class));
assertThat(formatter, instanceOf(JavaDateFormatter.class));
}
public void testParsingStrictNanoDates() {