Skip to content

Commit a154751

Browse files
authored
Parse composite patterns using ClassicFormat.parseObject backport(#40100) (#40503)
Java-time fails parsing composite patterns when first pattern matches only the prefix of the input. It expects pattern in longest to shortest order. Because of this constructing just one DateTimeFormatter with appendOptional is not sufficient. Parsers have to be iterated and if the parsing fails, the next one in order should be used. In order to not degrade performance parsing should not be throw exceptions on failure. Format.parseObject was used as it only returns null when parsing failed and allows to check if full input was read. closes #39916 backport #40100
1 parent 7c66769 commit a154751

File tree

8 files changed

+126
-68
lines changed

8 files changed

+126
-68
lines changed

server/src/main/java/org/elasticsearch/common/joda/JodaDateFormatter.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ public JodaDateFormatter(String pattern, DateTimeFormatter parser, DateTimeForma
4545
this.parser = parser;
4646
}
4747

48+
/**
49+
* Try to parse input to a java time TemporalAccessor using joda-time library.
50+
* @see DateFormatter#parse(String)
51+
* @throws IllegalArgumentException if the text to parse is invalid
52+
* @throws java.time.DateTimeException if the parsing result exceeds the supported range of <code>ZoneDateTime</code>
53+
* or if the parsed instant exceeds the maximum or minimum instant
54+
*/
4855
@Override
4956
public TemporalAccessor parse(String input) {
5057
final DateTime dt = parser.parseDateTime(input);

server/src/main/java/org/elasticsearch/common/time/DateFormatter.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@
2323
import org.elasticsearch.common.joda.Joda;
2424
import org.joda.time.DateTime;
2525

26+
import java.time.DateTimeException;
2627
import java.time.Instant;
2728
import java.time.ZoneId;
2829
import java.time.ZoneOffset;
2930
import java.time.ZonedDateTime;
30-
import java.time.format.DateTimeParseException;
3131
import java.time.temporal.TemporalAccessor;
3232
import java.util.ArrayList;
3333
import java.util.List;
@@ -38,8 +38,10 @@ public interface DateFormatter {
3838
/**
3939
* Try to parse input to a java time TemporalAccessor
4040
* @param input An arbitrary string resembling the string representation of a date or time
41-
* @throws DateTimeParseException If parsing fails, this exception will be thrown.
41+
* @throws IllegalArgumentException If parsing fails, this exception will be thrown.
4242
* Note that it can contained suppressed exceptions when several formatters failed parse this value
43+
* @throws DateTimeException if the parsing result exceeds the supported range of <code>ZoneDateTime</code>
44+
* or if the parsed instant exceeds the maximum or minimum instant
4345
* @return The java time object containing the parsed input
4446
*/
4547
TemporalAccessor parse(String input);

server/src/main/java/org/elasticsearch/common/time/DateFormatters.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1585,7 +1585,7 @@ static JavaDateFormatter merge(String pattern, List<DateFormatter> formatters) {
15851585
if (printer == null) {
15861586
printer = javaDateFormatter.getPrinter();
15871587
}
1588-
dateTimeFormatters.add(javaDateFormatter.getParser());
1588+
dateTimeFormatters.addAll(javaDateFormatter.getParsers());
15891589
roundupBuilder.appendOptional(javaDateFormatter.getRoundupParser());
15901590
}
15911591
DateTimeFormatter roundUpParser = roundupBuilder.toFormatter(Locale.ROOT);
@@ -1632,7 +1632,7 @@ public static ZonedDateTime from(TemporalAccessor accessor) {
16321632
if (zoneId == null) {
16331633
zoneId = ZoneOffset.UTC;
16341634
}
1635-
1635+
16361636
LocalDate localDate = accessor.query(TemporalQueries.localDate());
16371637
LocalTime localTime = accessor.query(TemporalQueries.localTime());
16381638
boolean isLocalDateSet = localDate != null;

server/src/main/java/org/elasticsearch/common/time/JavaDateFormatter.java

Lines changed: 67 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,19 @@
2121

2222
import org.elasticsearch.common.Strings;
2323

24+
import java.text.ParsePosition;
2425
import java.time.ZoneId;
2526
import java.time.format.DateTimeFormatter;
2627
import java.time.format.DateTimeFormatterBuilder;
28+
import java.time.format.DateTimeParseException;
2729
import java.time.temporal.ChronoField;
2830
import java.time.temporal.TemporalAccessor;
2931
import java.time.temporal.TemporalField;
3032
import java.util.Arrays;
33+
import java.util.Collection;
34+
import java.util.Collections;
3135
import java.util.HashMap;
36+
import java.util.List;
3237
import java.util.Locale;
3338
import java.util.Map;
3439
import java.util.Objects;
@@ -38,6 +43,7 @@ class JavaDateFormatter implements DateFormatter {
3843

3944
// base fields which should be used for default parsing, when we round up for date math
4045
private static final Map<TemporalField, Long> ROUND_UP_BASE_FIELDS = new HashMap<>(6);
46+
4147
{
4248
ROUND_UP_BASE_FIELDS.put(ChronoField.MONTH_OF_YEAR, 1L);
4349
ROUND_UP_BASE_FIELDS.put(ChronoField.DAY_OF_MONTH, 1L);
@@ -49,22 +55,15 @@ class JavaDateFormatter implements DateFormatter {
4955

5056
private final String format;
5157
private final DateTimeFormatter printer;
52-
private final DateTimeFormatter parser;
58+
private final List<DateTimeFormatter> parsers;
5359
private final DateTimeFormatter roundupParser;
5460

55-
private JavaDateFormatter(String format, DateTimeFormatter printer, DateTimeFormatter roundupParser, DateTimeFormatter parser) {
56-
this.format = "8" + format;
57-
this.printer = printer;
58-
this.roundupParser = roundupParser;
59-
this.parser = parser;
60-
}
61-
6261
JavaDateFormatter(String format, DateTimeFormatter printer, DateTimeFormatter... parsers) {
6362
this(format, printer, builder -> ROUND_UP_BASE_FIELDS.forEach(builder::parseDefaulting), parsers);
6463
}
6564

6665
JavaDateFormatter(String format, DateTimeFormatter printer, Consumer<DateTimeFormatterBuilder> roundupParserConsumer,
67-
DateTimeFormatter... parsers) {
66+
DateTimeFormatter... parsers) {
6867
if (printer == null) {
6968
throw new IllegalArgumentException("printer may not be null");
7069
}
@@ -76,28 +75,23 @@ private JavaDateFormatter(String format, DateTimeFormatter printer, DateTimeForm
7675
if (distinctLocales > 1) {
7776
throw new IllegalArgumentException("formatters must have the same locale");
7877
}
78+
this.printer = printer;
79+
this.format = "8" + format;
80+
7981
if (parsers.length == 0) {
80-
this.parser = printer;
81-
} else if (parsers.length == 1) {
82-
this.parser = parsers[0];
82+
this.parsers = Collections.singletonList(printer);
8383
} else {
84-
DateTimeFormatterBuilder builder = new DateTimeFormatterBuilder();
85-
for (DateTimeFormatter parser : parsers) {
86-
builder.appendOptional(parser);
87-
}
88-
this.parser = builder.toFormatter(Locale.ROOT);
84+
this.parsers = Arrays.asList(parsers);
8985
}
90-
this.format = "8" + format;
91-
this.printer = printer;
9286

9387
DateTimeFormatterBuilder builder = new DateTimeFormatterBuilder();
9488
if (format.contains("||") == false) {
95-
builder.append(this.parser);
89+
builder.append(this.parsers.get(0));
9690
}
9791
roundupParserConsumer.accept(builder);
98-
DateTimeFormatter roundupFormatter = builder.toFormatter(parser.getLocale());
92+
DateTimeFormatter roundupFormatter = builder.toFormatter(locale());
9993
if (printer.getZone() != null) {
100-
roundupFormatter = roundupFormatter.withZone(printer.getZone());
94+
roundupFormatter = roundupFormatter.withZone(zone());
10195
}
10296
this.roundupParser = roundupFormatter;
10397
}
@@ -106,10 +100,6 @@ DateTimeFormatter getRoundupParser() {
106100
return roundupParser;
107101
}
108102

109-
DateTimeFormatter getParser() {
110-
return parser;
111-
}
112-
113103
DateTimeFormatter getPrinter() {
114104
return printer;
115105
}
@@ -119,27 +109,66 @@ public TemporalAccessor parse(String input) {
119109
if (Strings.isNullOrEmpty(input)) {
120110
throw new IllegalArgumentException("cannot parse empty date");
121111
}
122-
return parser.parse(input);
112+
113+
try {
114+
return doParse(input);
115+
} catch (DateTimeParseException e) {
116+
throw new IllegalArgumentException("failed to parse date field [" + input + "] with format [" + format + "]", e);
117+
}
118+
}
119+
120+
/**
121+
* Attempt parsing the input without throwing exception. If multiple parsers are provided,
122+
* it will continue iterating if the previous parser failed. The pattern must fully match, meaning whole input was used.
123+
* This also means that this method depends on <code>DateTimeFormatter.ClassicFormat.parseObject</code>
124+
* which does not throw exceptions when parsing failed.
125+
*
126+
* The approach with collection of parsers was taken because java-time requires ordering on optional (composite)
127+
* patterns. Joda does not suffer from this.
128+
* https://bugs.openjdk.java.net/browse/JDK-8188771
129+
*
130+
* @param input An arbitrary string resembling the string representation of a date or time
131+
* @return a TemporalAccessor if parsing was successful.
132+
* @throws DateTimeParseException when unable to parse with any parsers
133+
*/
134+
private TemporalAccessor doParse(String input) {
135+
if (parsers.size() > 1) {
136+
for (DateTimeFormatter formatter : parsers) {
137+
ParsePosition pos = new ParsePosition(0);
138+
Object object = formatter.toFormat().parseObject(input, pos);
139+
if (parsingSucceeded(object, input, pos) == true) {
140+
return (TemporalAccessor) object;
141+
}
142+
}
143+
throw new DateTimeParseException("Failed to parse with all enclosed parsers", input, 0);
144+
}
145+
return this.parsers.get(0).parse(input);
146+
}
147+
148+
private boolean parsingSucceeded(Object object, String input, ParsePosition pos) {
149+
return object != null && pos.getIndex() == input.length();
123150
}
124151

125152
@Override
126153
public DateFormatter withZone(ZoneId zoneId) {
127154
// shortcurt to not create new objects unnecessarily
128-
if (zoneId.equals(parser.getZone())) {
155+
if (zoneId.equals(zone())) {
129156
return this;
130157
}
131158

132-
return new JavaDateFormatter(format, printer.withZone(zoneId), roundupParser.withZone(zoneId), parser.withZone(zoneId));
159+
return new JavaDateFormatter(format, printer.withZone(zoneId),
160+
parsers.stream().map(p -> p.withZone(zoneId)).toArray(size -> new DateTimeFormatter[size]));
133161
}
134162

135163
@Override
136164
public DateFormatter withLocale(Locale locale) {
137165
// shortcurt to not create new objects unnecessarily
138-
if (locale.equals(parser.getLocale())) {
166+
if (locale.equals(locale())) {
139167
return this;
140168
}
141169

142-
return new JavaDateFormatter(format, printer.withLocale(locale), roundupParser.withLocale(locale), parser.withLocale(locale));
170+
return new JavaDateFormatter(format, printer.withLocale(locale),
171+
parsers.stream().map(p -> p.withLocale(locale)).toArray(size -> new DateTimeFormatter[size]));
143172
}
144173

145174
@Override
@@ -164,7 +193,7 @@ public ZoneId zone() {
164193

165194
@Override
166195
public DateMathParser toDateMathParser() {
167-
return new JavaDateMathParser(format, parser, roundupParser);
196+
return new JavaDateMathParser(format, this, getRoundupParser());
168197
}
169198

170199
@Override
@@ -180,12 +209,16 @@ public boolean equals(Object obj) {
180209
JavaDateFormatter other = (JavaDateFormatter) obj;
181210

182211
return Objects.equals(format, other.format) &&
183-
Objects.equals(locale(), other.locale()) &&
184-
Objects.equals(this.printer.getZone(), other.printer.getZone());
212+
Objects.equals(locale(), other.locale()) &&
213+
Objects.equals(this.printer.getZone(), other.printer.getZone());
185214
}
186215

187216
@Override
188217
public String toString() {
189218
return String.format(Locale.ROOT, "format[%s] locale[%s]", format, locale());
190219
}
220+
221+
Collection<DateTimeFormatter> getParsers() {
222+
return parsers;
223+
}
191224
}

server/src/main/java/org/elasticsearch/common/time/JavaDateMathParser.java

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import java.time.temporal.TemporalAdjusters;
3636
import java.time.temporal.TemporalQueries;
3737
import java.util.Objects;
38+
import java.util.function.Function;
3839
import java.util.function.LongSupplier;
3940

4041
/**
@@ -46,11 +47,11 @@
4647
*/
4748
public class JavaDateMathParser implements DateMathParser {
4849

49-
private final DateTimeFormatter formatter;
50+
private final JavaDateFormatter formatter;
5051
private final DateTimeFormatter roundUpFormatter;
5152
private final String format;
5253

53-
JavaDateMathParser(String format, DateTimeFormatter formatter, DateTimeFormatter roundUpFormatter) {
54+
JavaDateMathParser(String format, JavaDateFormatter formatter, DateTimeFormatter roundUpFormatter) {
5455
Objects.requireNonNull(formatter);
5556
this.format = format;
5657
this.formatter = formatter;
@@ -214,12 +215,12 @@ private long parseDateTime(String value, ZoneId timeZone, boolean roundUpIfNoTim
214215
throw new IllegalArgumentException("cannot parse empty date");
215216
}
216217

217-
DateTimeFormatter formatter = roundUpIfNoTime ? this.roundUpFormatter : this.formatter;
218+
Function<String,TemporalAccessor> formatter = roundUpIfNoTime ? this.roundUpFormatter::parse : this.formatter::parse;
218219
try {
219220
if (timeZone == null) {
220-
return DateFormatters.from(formatter.parse(value)).toInstant().toEpochMilli();
221+
return DateFormatters.from(formatter.apply(value)).toInstant().toEpochMilli();
221222
} else {
222-
TemporalAccessor accessor = formatter.parse(value);
223+
TemporalAccessor accessor = formatter.apply(value);
223224
ZoneId zoneId = TemporalQueries.zone().queryFrom(accessor);
224225
if (zoneId != null) {
225226
timeZone = zoneId;
@@ -228,7 +229,8 @@ private long parseDateTime(String value, ZoneId timeZone, boolean roundUpIfNoTim
228229
return DateFormatters.from(accessor).withZoneSameLocal(timeZone).toInstant().toEpochMilli();
229230
}
230231
} catch (IllegalArgumentException | DateTimeException e) {
231-
throw new ElasticsearchParseException("failed to parse date field [{}] in format [{}]: [{}]", e, value, format, e.getMessage());
232+
throw new ElasticsearchParseException("failed to parse date field [{}] with format [{}]: [{}]",
233+
e, value, format, e.getMessage());
232234
}
233235
}
234236
}

server/src/test/java/org/elasticsearch/common/joda/JavaJodaTimeDuellingTests.java

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,11 @@
2929
import java.time.ZoneOffset;
3030
import java.time.ZonedDateTime;
3131
import java.time.format.DateTimeFormatter;
32-
import java.time.format.DateTimeParseException;
3332
import java.time.temporal.TemporalAccessor;
3433
import java.util.Locale;
3534

3635
import static org.hamcrest.Matchers.containsString;
3736
import static org.hamcrest.Matchers.is;
38-
import static org.hamcrest.Matchers.startsWith;
3937

4038
public class JavaJodaTimeDuellingTests extends ESTestCase {
4139

@@ -290,7 +288,7 @@ public void testDuellingFormatsValidParsing() {
290288
// joda comes up with a different exception message here, so we have to adapt
291289
assertJodaParseException("2012-W1-8", "week_date",
292290
"Cannot parse \"2012-W1-8\": Value 8 for dayOfWeek must be in the range [1,7]");
293-
assertJavaTimeParseException("2012-W1-8", "week_date", "Text '2012-W1-8' could not be parsed");
291+
assertJavaTimeParseException("2012-W1-8", "week_date");
294292

295293
assertSameDate("2012-W48-6T10:15:30.123Z", "week_date_time");
296294
assertSameDate("2012-W48-6T10:15:30.123456789Z", "week_date_time");
@@ -330,6 +328,17 @@ public void testDuellingFormatsValidParsing() {
330328
assertSameDate("2012-W1-1", "weekyear_week_day");
331329
}
332330

331+
public void testCompositeParsing(){
332+
//in all these examples the second pattern will be used
333+
assertSameDate("2014-06-06T12:01:02.123", "yyyy-MM-dd'T'HH:mm:ss||yyyy-MM-dd'T'HH:mm:ss.SSS");
334+
assertSameDate("2014-06-06T12:01:02.123", "strictDateTimeNoMillis||yyyy-MM-dd'T'HH:mm:ss.SSS");
335+
assertSameDate("2014-06-06T12:01:02.123", "yyyy-MM-dd'T'HH:mm:ss+HH:MM||yyyy-MM-dd'T'HH:mm:ss.SSS");
336+
}
337+
338+
public void testExceptionWhenCompositeParsingFails(){
339+
assertParseException("2014-06-06T12:01:02.123", "yyyy-MM-dd'T'HH:mm:ss||yyyy-MM-dd'T'HH:mm:ss.SS");
340+
}
341+
333342
public void testDuelingStrictParsing() {
334343
assertSameDate("2018W313", "strict_basic_week_date");
335344
assertParseException("18W313", "strict_basic_week_date");
@@ -477,7 +486,7 @@ public void testDuelingStrictParsing() {
477486
// joda comes up with a different exception message here, so we have to adapt
478487
assertJodaParseException("2012-W01-8", "strict_week_date",
479488
"Cannot parse \"2012-W01-8\": Value 8 for dayOfWeek must be in the range [1,7]");
480-
assertJavaTimeParseException("2012-W01-8", "strict_week_date", "Text '2012-W01-8' could not be parsed");
489+
assertJavaTimeParseException("2012-W01-8", "strict_week_date");
481490

482491
assertSameDate("2012-W48-6T10:15:30.123Z", "strict_week_date_time");
483492
assertSameDate("2012-W48-6T10:15:30.123456789Z", "strict_week_date_time");
@@ -624,7 +633,7 @@ public void testParsingMissingTimezone() {
624633

625634
private void assertSamePrinterOutput(String format, ZonedDateTime javaDate, DateTime jodaDate) {
626635
assertThat(jodaDate.getMillis(), is(javaDate.toInstant().toEpochMilli()));
627-
String javaTimeOut = DateFormatters.forPattern(format).format(javaDate);
636+
String javaTimeOut = DateFormatter.forPattern(format).format(javaDate);
628637
String jodaTimeOut = DateFormatter.forPattern(format).formatJoda(jodaDate);
629638
if (JavaVersion.current().getVersion().get(0) == 8 && javaTimeOut.endsWith(".0")
630639
&& (format.equals("epoch_second") || format.equals("epoch_millis"))) {
@@ -639,7 +648,7 @@ private void assertSamePrinterOutput(String format, ZonedDateTime javaDate, Date
639648

640649
private void assertSameDate(String input, String format) {
641650
DateFormatter jodaFormatter = Joda.forPattern(format);
642-
DateFormatter javaFormatter = DateFormatters.forPattern(format);
651+
DateFormatter javaFormatter = DateFormatter.forPattern(format);
643652
assertSameDate(input, format, jodaFormatter, javaFormatter);
644653
}
645654

@@ -657,7 +666,7 @@ private void assertSameDate(String input, String format, DateFormatter jodaForma
657666

658667
private void assertParseException(String input, String format) {
659668
assertJodaParseException(input, format, "Invalid format: \"" + input);
660-
assertJavaTimeParseException(input, format, "Text '" + input + "' could not be parsed");
669+
assertJavaTimeParseException(input, format);
661670
}
662671

663672
private void assertJodaParseException(String input, String format, String expectedMessage) {
@@ -666,9 +675,10 @@ private void assertJodaParseException(String input, String format, String expect
666675
assertThat(e.getMessage(), containsString(expectedMessage));
667676
}
668677

669-
private void assertJavaTimeParseException(String input, String format, String expectedMessage) {
670-
DateFormatter javaTimeFormatter = DateFormatters.forPattern(format);
671-
DateTimeParseException dateTimeParseException = expectThrows(DateTimeParseException.class, () -> javaTimeFormatter.parse(input));
672-
assertThat(dateTimeParseException.getMessage(), startsWith(expectedMessage));
678+
private void assertJavaTimeParseException(String input, String format) {
679+
DateFormatter javaTimeFormatter = DateFormatter.forPattern("8"+format);
680+
IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> javaTimeFormatter.parse(input));
681+
assertThat(e.getMessage(), containsString(input));
682+
assertThat(e.getMessage(), containsString(format));
673683
}
674684
}

0 commit comments

Comments
 (0)