Skip to content

Commit 8a9a815

Browse files
spinscalerjernst
authored andcommitted
Core: Create java time based DateMathParser (elastic#32131)
This adds a java time based date math parser class in order, which will replace the joda date based one in the future. For now the class also returns the date in milliseconds since the epoch.
1 parent 8c54795 commit 8a9a815

File tree

5 files changed

+614
-4
lines changed

5 files changed

+614
-4
lines changed

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

+19-3
Original file line numberDiff line numberDiff line change
@@ -754,11 +754,15 @@ public class DateFormatters {
754754
/////////////////////////////////////////
755755

756756
private static final DateTimeFormatter DATE_FORMATTER = new DateTimeFormatterBuilder()
757-
.appendValue(ChronoField.YEAR, 1, 4, SignStyle.NORMAL)
757+
.appendValue(ChronoField.YEAR, 1, 5, SignStyle.NORMAL)
758+
.optionalStart()
758759
.appendLiteral('-')
759760
.appendValue(MONTH_OF_YEAR, 1, 2, SignStyle.NOT_NEGATIVE)
761+
.optionalStart()
760762
.appendLiteral('-')
761763
.appendValue(DAY_OF_MONTH, 1, 2, SignStyle.NOT_NEGATIVE)
764+
.optionalEnd()
765+
.optionalEnd()
762766
.toFormatter(Locale.ROOT);
763767

764768
private static final DateTimeFormatter HOUR_MINUTE_FORMATTER = new DateTimeFormatterBuilder()
@@ -777,7 +781,11 @@ public class DateFormatters {
777781
.append(DATE_FORMATTER)
778782
.optionalStart()
779783
.appendLiteral('T')
780-
.append(HOUR_MINUTE_FORMATTER)
784+
.optionalStart()
785+
.appendValue(HOUR_OF_DAY, 1, 2, SignStyle.NOT_NEGATIVE)
786+
.optionalStart()
787+
.appendLiteral(':')
788+
.appendValue(MINUTE_OF_HOUR, 1, 2, SignStyle.NOT_NEGATIVE)
781789
.optionalStart()
782790
.appendLiteral(':')
783791
.appendValue(SECOND_OF_MINUTE, 1, 2, SignStyle.NOT_NEGATIVE)
@@ -787,12 +795,18 @@ public class DateFormatters {
787795
.optionalEnd()
788796
.optionalStart().appendZoneOrOffsetId().optionalEnd()
789797
.optionalEnd()
798+
.optionalEnd()
799+
.optionalEnd()
790800
.toFormatter(Locale.ROOT),
791801
new DateTimeFormatterBuilder()
792802
.append(DATE_FORMATTER)
793803
.optionalStart()
794804
.appendLiteral('T')
795-
.append(HOUR_MINUTE_FORMATTER)
805+
.optionalStart()
806+
.appendValue(HOUR_OF_DAY, 1, 2, SignStyle.NOT_NEGATIVE)
807+
.optionalStart()
808+
.appendLiteral(':')
809+
.appendValue(MINUTE_OF_HOUR, 1, 2, SignStyle.NOT_NEGATIVE)
796810
.optionalStart()
797811
.appendLiteral(':')
798812
.appendValue(SECOND_OF_MINUTE, 1, 2, SignStyle.NOT_NEGATIVE)
@@ -802,6 +816,8 @@ public class DateFormatters {
802816
.optionalEnd()
803817
.optionalStart().appendOffset("+HHmm", "Z").optionalEnd()
804818
.optionalEnd()
819+
.optionalEnd()
820+
.optionalEnd()
805821
.toFormatter(Locale.ROOT));
806822

807823
private static final DateTimeFormatter HOUR_MINUTE_SECOND_FORMATTER = new DateTimeFormatterBuilder()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
/*
2+
* Licensed to Elasticsearch under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.elasticsearch.common.time;
21+
22+
import org.elasticsearch.ElasticsearchParseException;
23+
24+
import java.time.DateTimeException;
25+
import java.time.DayOfWeek;
26+
import java.time.Instant;
27+
import java.time.LocalTime;
28+
import java.time.ZoneId;
29+
import java.time.ZoneOffset;
30+
import java.time.ZonedDateTime;
31+
import java.time.temporal.ChronoField;
32+
import java.time.temporal.TemporalAccessor;
33+
import java.time.temporal.TemporalAdjusters;
34+
import java.time.temporal.TemporalField;
35+
import java.time.temporal.TemporalQueries;
36+
import java.util.HashMap;
37+
import java.util.Map;
38+
import java.util.Objects;
39+
import java.util.function.LongSupplier;
40+
41+
/**
42+
* A parser for date/time formatted text with optional date math.
43+
*
44+
* The format of the datetime is configurable, and unix timestamps can also be used. Datemath
45+
* is appended to a datetime with the following syntax:
46+
* <code>||[+-/](\d+)?[yMwdhHms]</code>.
47+
*/
48+
public class DateMathParser {
49+
50+
// base fields which should be used for default parsing, when we round up
51+
private static final Map<TemporalField, Long> ROUND_UP_BASE_FIELDS = new HashMap<>(6);
52+
{
53+
ROUND_UP_BASE_FIELDS.put(ChronoField.MONTH_OF_YEAR, 1L);
54+
ROUND_UP_BASE_FIELDS.put(ChronoField.DAY_OF_MONTH, 1L);
55+
ROUND_UP_BASE_FIELDS.put(ChronoField.HOUR_OF_DAY, 23L);
56+
ROUND_UP_BASE_FIELDS.put(ChronoField.MINUTE_OF_HOUR, 59L);
57+
ROUND_UP_BASE_FIELDS.put(ChronoField.SECOND_OF_MINUTE, 59L);
58+
ROUND_UP_BASE_FIELDS.put(ChronoField.MILLI_OF_SECOND, 999L);
59+
}
60+
61+
private final CompoundDateTimeFormatter formatter;
62+
private final CompoundDateTimeFormatter roundUpFormatter;
63+
64+
public DateMathParser(CompoundDateTimeFormatter formatter) {
65+
Objects.requireNonNull(formatter);
66+
this.formatter = formatter;
67+
this.roundUpFormatter = formatter.parseDefaulting(ROUND_UP_BASE_FIELDS);
68+
}
69+
70+
public long parse(String text, LongSupplier now) {
71+
return parse(text, now, false, null);
72+
}
73+
74+
/**
75+
* Parse text, that potentially contains date math into the milliseconds since the epoch
76+
*
77+
* Examples are
78+
*
79+
* <code>2014-11-18||-2y</code> substracts two years from the input date
80+
* <code>now/m</code> rounds the current time to minute granularity
81+
*
82+
* Supported rounding units are
83+
* y year
84+
* M month
85+
* w week (beginning on a monday)
86+
* d day
87+
* h/H hour
88+
* m minute
89+
* s second
90+
*
91+
*
92+
* @param text the input
93+
* @param now a supplier to retrieve the current date in milliseconds, if needed for additions
94+
* @param roundUp should the result be rounded up
95+
* @param timeZone an optional timezone that should be applied before returning the milliseconds since the epoch
96+
* @return the parsed date in milliseconds since the epoch
97+
*/
98+
public long parse(String text, LongSupplier now, boolean roundUp, ZoneId timeZone) {
99+
long time;
100+
String mathString;
101+
if (text.startsWith("now")) {
102+
try {
103+
time = now.getAsLong();
104+
} catch (Exception e) {
105+
throw new ElasticsearchParseException("could not read the current timestamp", e);
106+
}
107+
mathString = text.substring("now".length());
108+
} else {
109+
int index = text.indexOf("||");
110+
if (index == -1) {
111+
return parseDateTime(text, timeZone, roundUp);
112+
}
113+
time = parseDateTime(text.substring(0, index), timeZone, false);
114+
mathString = text.substring(index + 2);
115+
}
116+
117+
return parseMath(mathString, time, roundUp, timeZone);
118+
}
119+
120+
private long parseMath(final String mathString, final long time, final boolean roundUp,
121+
ZoneId timeZone) throws ElasticsearchParseException {
122+
if (timeZone == null) {
123+
timeZone = ZoneOffset.UTC;
124+
}
125+
ZonedDateTime dateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(time), timeZone);
126+
for (int i = 0; i < mathString.length(); ) {
127+
char c = mathString.charAt(i++);
128+
final boolean round;
129+
final int sign;
130+
if (c == '/') {
131+
round = true;
132+
sign = 1;
133+
} else {
134+
round = false;
135+
if (c == '+') {
136+
sign = 1;
137+
} else if (c == '-') {
138+
sign = -1;
139+
} else {
140+
throw new ElasticsearchParseException("operator not supported for date math [{}]", mathString);
141+
}
142+
}
143+
144+
if (i >= mathString.length()) {
145+
throw new ElasticsearchParseException("truncated date math [{}]", mathString);
146+
}
147+
148+
final int num;
149+
if (!Character.isDigit(mathString.charAt(i))) {
150+
num = 1;
151+
} else {
152+
int numFrom = i;
153+
while (i < mathString.length() && Character.isDigit(mathString.charAt(i))) {
154+
i++;
155+
}
156+
if (i >= mathString.length()) {
157+
throw new ElasticsearchParseException("truncated date math [{}]", mathString);
158+
}
159+
num = Integer.parseInt(mathString.substring(numFrom, i));
160+
}
161+
if (round) {
162+
if (num != 1) {
163+
throw new ElasticsearchParseException("rounding `/` can only be used on single unit types [{}]", mathString);
164+
}
165+
}
166+
char unit = mathString.charAt(i++);
167+
switch (unit) {
168+
case 'y':
169+
if (round) {
170+
dateTime = dateTime.withDayOfYear(1).with(LocalTime.MIN);
171+
} else {
172+
dateTime = dateTime.plusYears(sign * num);
173+
}
174+
if (roundUp) {
175+
dateTime = dateTime.plusYears(1);
176+
}
177+
break;
178+
case 'M':
179+
if (round) {
180+
dateTime = dateTime.withDayOfMonth(1).with(LocalTime.MIN);
181+
} else {
182+
dateTime = dateTime.plusMonths(sign * num);
183+
}
184+
if (roundUp) {
185+
dateTime = dateTime.plusMonths(1);
186+
}
187+
break;
188+
case 'w':
189+
if (round) {
190+
dateTime = dateTime.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).with(LocalTime.MIN);
191+
} else {
192+
dateTime = dateTime.plusWeeks(sign * num);
193+
}
194+
if (roundUp) {
195+
dateTime = dateTime.plusWeeks(1);
196+
}
197+
break;
198+
case 'd':
199+
if (round) {
200+
dateTime = dateTime.with(LocalTime.MIN);
201+
} else {
202+
dateTime = dateTime.plusDays(sign * num);
203+
}
204+
if (roundUp) {
205+
dateTime = dateTime.plusDays(1);
206+
}
207+
break;
208+
case 'h':
209+
case 'H':
210+
if (round) {
211+
dateTime = dateTime.withMinute(0).withSecond(0).withNano(0);
212+
} else {
213+
dateTime = dateTime.plusHours(sign * num);
214+
}
215+
if (roundUp) {
216+
dateTime = dateTime.plusHours(1);
217+
}
218+
break;
219+
case 'm':
220+
if (round) {
221+
dateTime = dateTime.withSecond(0).withNano(0);
222+
} else {
223+
dateTime = dateTime.plusMinutes(sign * num);
224+
}
225+
if (roundUp) {
226+
dateTime = dateTime.plusMinutes(1);
227+
}
228+
break;
229+
case 's':
230+
if (round) {
231+
dateTime = dateTime.withNano(0);
232+
} else {
233+
dateTime = dateTime.plusSeconds(sign * num);
234+
}
235+
if (roundUp) {
236+
dateTime = dateTime.plusSeconds(1);
237+
}
238+
break;
239+
default:
240+
throw new ElasticsearchParseException("unit [{}] not supported for date math [{}]", unit, mathString);
241+
}
242+
if (roundUp) {
243+
dateTime = dateTime.minus(1, ChronoField.MILLI_OF_SECOND.getBaseUnit());
244+
}
245+
}
246+
return dateTime.toInstant().toEpochMilli();
247+
}
248+
249+
private long parseDateTime(String value, ZoneId timeZone, boolean roundUpIfNoTime) {
250+
CompoundDateTimeFormatter formatter = roundUpIfNoTime ? this.roundUpFormatter : this.formatter;
251+
try {
252+
if (timeZone == null) {
253+
return DateFormatters.toZonedDateTime(formatter.parse(value)).toInstant().toEpochMilli();
254+
} else {
255+
TemporalAccessor accessor = formatter.parse(value);
256+
ZoneId zoneId = TemporalQueries.zone().queryFrom(accessor);
257+
if (zoneId != null) {
258+
timeZone = zoneId;
259+
}
260+
261+
return DateFormatters.toZonedDateTime(accessor).withZoneSameLocal(timeZone).toInstant().toEpochMilli();
262+
}
263+
} catch (IllegalArgumentException | DateTimeException e) {
264+
throw new ElasticsearchParseException("failed to parse date field [{}]: [{}]", e, value, e.getMessage());
265+
}
266+
}
267+
}

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

+6
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,13 @@ public void testExplicitRounding() {
208208
assertDateMathEquals("2014-11-18||/M", "2014-10-31T23:00:00.000Z", 0, false, DateTimeZone.forID("CET"));
209209
assertDateMathEquals("2014-11-18||/M", "2014-11-30T22:59:59.999Z", 0, true, DateTimeZone.forID("CET"));
210210

211+
assertDateMathEquals("2014-11-17T14||/w", "2014-11-17", 0, false, null);
211212
assertDateMathEquals("2014-11-18T14||/w", "2014-11-17", 0, false, null);
213+
assertDateMathEquals("2014-11-19T14||/w", "2014-11-17", 0, false, null);
214+
assertDateMathEquals("2014-11-20T14||/w", "2014-11-17", 0, false, null);
215+
assertDateMathEquals("2014-11-21T14||/w", "2014-11-17", 0, false, null);
216+
assertDateMathEquals("2014-11-22T14||/w", "2014-11-17", 0, false, null);
217+
assertDateMathEquals("2014-11-23T14||/w", "2014-11-17", 0, false, null);
212218
assertDateMathEquals("2014-11-18T14||/w", "2014-11-23T23:59:59.999", 0, true, null);
213219
assertDateMathEquals("2014-11-18||/w", "2014-11-17", 0, false, null);
214220
assertDateMathEquals("2014-11-18||/w", "2014-11-23T23:59:59.999", 0, true, null);

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

+13-1
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ public void testDuellingFormatsValidParsing() {
102102

103103
assertSameDate("2018-12-31", "date");
104104
assertSameDate("18-5-6", "date");
105+
assertSameDate("10000-5-6", "date");
105106

106107
assertSameDate("2018-12-31T12", "date_hour");
107108
assertSameDate("2018-12-31T8", "date_hour");
@@ -117,7 +118,16 @@ public void testDuellingFormatsValidParsing() {
117118
assertSameDate("2018-12-31T12:12:12.1", "date_hour_minute_second_millis");
118119
assertSameDate("2018-12-31T12:12:12.1", "date_hour_minute_second_fraction");
119120

120-
assertSameDate("2018-12-31", "date_optional_time");
121+
assertSameDate("10000", "date_optional_time");
122+
assertSameDate("10000T", "date_optional_time");
123+
assertSameDate("2018", "date_optional_time");
124+
assertSameDate("2018T", "date_optional_time");
125+
assertSameDate("2018-05", "date_optional_time");
126+
assertSameDate("2018-05-30", "date_optional_time");
127+
assertSameDate("2018-05-30T20", "date_optional_time");
128+
assertSameDate("2018-05-30T20:21", "date_optional_time");
129+
assertSameDate("2018-05-30T20:21:23", "date_optional_time");
130+
assertSameDate("2018-05-30T20:21:23.123", "date_optional_time");
121131
assertSameDate("2018-12-1", "date_optional_time");
122132
assertSameDate("2018-12-31T10:15:30", "date_optional_time");
123133
assertSameDate("2018-12-31T10:15:3", "date_optional_time");
@@ -238,6 +248,7 @@ public void testDuelingStrictParsing() {
238248
assertParseException("2018W313T81212Z", "strict_basic_week_date_time_no_millis");
239249
assertParseException("2018W313T12812Z", "strict_basic_week_date_time_no_millis");
240250
assertSameDate("2018-12-31", "strict_date");
251+
assertParseException("10000-12-31", "strict_date");
241252
assertParseException("2018-8-31", "strict_date");
242253
assertSameDate("2018-12-31T12", "strict_date_hour");
243254
assertParseException("2018-12-31T8", "strict_date_hour");
@@ -254,6 +265,7 @@ public void testDuelingStrictParsing() {
254265
assertSameDate("2018-12-31", "strict_date_optional_time");
255266
assertParseException("2018-12-1", "strict_date_optional_time");
256267
assertParseException("2018-1-31", "strict_date_optional_time");
268+
assertParseException("10000-01-31", "strict_date_optional_time");
257269
assertSameDate("2018-12-31T10:15:30", "strict_date_optional_time");
258270
assertParseException("2018-12-31T10:15:3", "strict_date_optional_time");
259271
assertParseException("2018-12-31T10:5:30", "strict_date_optional_time");

0 commit comments

Comments
 (0)