diff --git a/server/src/main/java/org/elasticsearch/common/time/CompoundDateTimeFormatter.java b/server/src/main/java/org/elasticsearch/common/time/CompoundDateTimeFormatter.java index 31683b43ebd..0332c03814d 100644 --- a/server/src/main/java/org/elasticsearch/common/time/CompoundDateTimeFormatter.java +++ b/server/src/main/java/org/elasticsearch/common/time/CompoundDateTimeFormatter.java @@ -20,8 +20,14 @@ package org.elasticsearch.common.time; import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; import java.time.format.DateTimeParseException; import java.time.temporal.TemporalAccessor; +import java.time.temporal.TemporalField; +import java.util.Arrays; +import java.util.Locale; +import java.util.Map; +import java.util.function.Consumer; /** * wrapper class around java.time.DateTimeFormatter that supports multiple formats for easier parsing, @@ -29,6 +35,13 @@ import java.time.temporal.TemporalAccessor; */ public class CompoundDateTimeFormatter { + private static final Consumer SAME_TIME_ZONE_VALIDATOR = (parsers) -> { + long distinctZones = Arrays.stream(parsers).map(DateTimeFormatter::getZone).distinct().count(); + if (distinctZones > 1) { + throw new IllegalArgumentException("formatters must have the same time zone"); + } + }; + final DateTimeFormatter printer; final DateTimeFormatter[] parsers; @@ -36,6 +49,7 @@ public class CompoundDateTimeFormatter { if (parsers.length == 0) { throw new IllegalArgumentException("at least one date time formatter is required"); } + SAME_TIME_ZONE_VALIDATOR.accept(parsers); this.printer = parsers[0]; this.parsers = parsers; } @@ -58,7 +72,18 @@ public class CompoundDateTimeFormatter { throw failure; } + /** + * Configure a specific time zone for a date formatter + * + * @param zoneId The zoneId this formatter shoulduse + * @return The new formatter with all parsers switched to the specified timezone + */ public CompoundDateTimeFormatter withZone(ZoneId zoneId) { + // shortcurt to not create new objects unnecessarily + if (zoneId.equals(parsers[0].getZone())) { + return this; + } + final DateTimeFormatter[] parsersWithZone = new DateTimeFormatter[parsers.length]; for (int i = 0; i < parsers.length; i++) { parsersWithZone[i] = parsers[i].withZone(zoneId); @@ -67,6 +92,20 @@ public class CompoundDateTimeFormatter { return new CompoundDateTimeFormatter(parsersWithZone); } + /** + * Configure defaults for missing values in a parser, then return a new compound date formatter + */ + CompoundDateTimeFormatter parseDefaulting(Map fields) { + 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 CompoundDateTimeFormatter(parsersWithDefaulting); + } + public String format(TemporalAccessor accessor) { return printer.format(accessor); } diff --git a/server/src/main/java/org/elasticsearch/common/time/DateFormatters.java b/server/src/main/java/org/elasticsearch/common/time/DateFormatters.java index baaad48a318..37efff5a0be 100644 --- a/server/src/main/java/org/elasticsearch/common/time/DateFormatters.java +++ b/server/src/main/java/org/elasticsearch/common/time/DateFormatters.java @@ -701,11 +701,15 @@ public class DateFormatters { ///////////////////////////////////////// private static final DateTimeFormatter DATE_FORMATTER = new DateTimeFormatterBuilder() - .appendValue(ChronoField.YEAR, 1, 4, SignStyle.NORMAL) + .appendValue(ChronoField.YEAR, 1, 5, SignStyle.NORMAL) + .optionalStart() .appendLiteral('-') .appendValue(MONTH_OF_YEAR, 1, 2, SignStyle.NOT_NEGATIVE) + .optionalStart() .appendLiteral('-') .appendValue(DAY_OF_MONTH, 1, 2, SignStyle.NOT_NEGATIVE) + .optionalEnd() + .optionalEnd() .toFormatter(Locale.ROOT); private static final DateTimeFormatter HOUR_MINUTE_FORMATTER = new DateTimeFormatterBuilder() @@ -723,7 +727,11 @@ public class DateFormatters { .append(DATE_FORMATTER) .optionalStart() .appendLiteral('T') - .append(HOUR_MINUTE_FORMATTER) + .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) @@ -733,12 +741,18 @@ public class DateFormatters { .optionalEnd() .optionalStart().appendZoneOrOffsetId().optionalEnd() .optionalEnd() + .optionalEnd() + .optionalEnd() .toFormatter(Locale.ROOT), new DateTimeFormatterBuilder() .append(DATE_FORMATTER) .optionalStart() .appendLiteral('T') - .append(HOUR_MINUTE_FORMATTER) + .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) @@ -748,6 +762,8 @@ public class DateFormatters { .optionalEnd() .optionalStart().appendOffset("+HHmm", "Z").optionalEnd() .optionalEnd() + .optionalEnd() + .optionalEnd() .toFormatter(Locale.ROOT)); private static final DateTimeFormatter HOUR_MINUTE_SECOND_FORMATTER = new DateTimeFormatterBuilder() diff --git a/server/src/main/java/org/elasticsearch/common/time/DateMathParser.java b/server/src/main/java/org/elasticsearch/common/time/DateMathParser.java new file mode 100644 index 00000000000..39f6dabbdb2 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/common/time/DateMathParser.java @@ -0,0 +1,267 @@ +/* + * 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.common.time; + +import org.elasticsearch.ElasticsearchParseException; + +import java.time.DateTimeException; +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoField; +import java.time.temporal.TemporalAccessor; +import java.time.temporal.TemporalAdjusters; +import java.time.temporal.TemporalField; +import java.time.temporal.TemporalQueries; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.LongSupplier; + +/** + * A parser for date/time formatted text with optional date math. + * + * The format of the datetime is configurable, and unix timestamps can also be used. Datemath + * is appended to a datetime with the following syntax: + * ||[+-/](\d+)?[yMwdhHms]. + */ +public class DateMathParser { + + // base fields which should be used for default parsing, when we round up + private static final Map ROUND_UP_BASE_FIELDS = new HashMap<>(6); + { + ROUND_UP_BASE_FIELDS.put(ChronoField.MONTH_OF_YEAR, 1L); + ROUND_UP_BASE_FIELDS.put(ChronoField.DAY_OF_MONTH, 1L); + ROUND_UP_BASE_FIELDS.put(ChronoField.HOUR_OF_DAY, 23L); + ROUND_UP_BASE_FIELDS.put(ChronoField.MINUTE_OF_HOUR, 59L); + ROUND_UP_BASE_FIELDS.put(ChronoField.SECOND_OF_MINUTE, 59L); + ROUND_UP_BASE_FIELDS.put(ChronoField.MILLI_OF_SECOND, 999L); + } + + private final CompoundDateTimeFormatter formatter; + private final CompoundDateTimeFormatter roundUpFormatter; + + public DateMathParser(CompoundDateTimeFormatter formatter) { + Objects.requireNonNull(formatter); + this.formatter = formatter; + this.roundUpFormatter = formatter.parseDefaulting(ROUND_UP_BASE_FIELDS); + } + + public long parse(String text, LongSupplier now) { + return parse(text, now, false, null); + } + + /** + * Parse text, that potentially contains date math into the milliseconds since the epoch + * + * Examples are + * + * 2014-11-18||-2y substracts two years from the input date + * now/m rounds the current time to minute granularity + * + * Supported rounding units are + * y year + * M month + * w week (beginning on a monday) + * d day + * h/H hour + * m minute + * s second + * + * + * @param text the input + * @param now a supplier to retrieve the current date in milliseconds, if needed for additions + * @param roundUp should the result be rounded up + * @param timeZone an optional timezone that should be applied before returning the milliseconds since the epoch + * @return the parsed date in milliseconds since the epoch + */ + public long parse(String text, LongSupplier now, boolean roundUp, ZoneId timeZone) { + long time; + String mathString; + if (text.startsWith("now")) { + try { + time = now.getAsLong(); + } catch (Exception e) { + throw new ElasticsearchParseException("could not read the current timestamp", e); + } + mathString = text.substring("now".length()); + } else { + int index = text.indexOf("||"); + if (index == -1) { + return parseDateTime(text, timeZone, roundUp); + } + time = parseDateTime(text.substring(0, index), timeZone, false); + mathString = text.substring(index + 2); + } + + return parseMath(mathString, time, roundUp, timeZone); + } + + private long parseMath(final String mathString, final long time, final boolean roundUp, + ZoneId timeZone) throws ElasticsearchParseException { + if (timeZone == null) { + timeZone = ZoneOffset.UTC; + } + ZonedDateTime dateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(time), timeZone); + for (int i = 0; i < mathString.length(); ) { + char c = mathString.charAt(i++); + final boolean round; + final int sign; + if (c == '/') { + round = true; + sign = 1; + } else { + round = false; + if (c == '+') { + sign = 1; + } else if (c == '-') { + sign = -1; + } else { + throw new ElasticsearchParseException("operator not supported for date math [{}]", mathString); + } + } + + if (i >= mathString.length()) { + throw new ElasticsearchParseException("truncated date math [{}]", mathString); + } + + final int num; + if (!Character.isDigit(mathString.charAt(i))) { + num = 1; + } else { + int numFrom = i; + while (i < mathString.length() && Character.isDigit(mathString.charAt(i))) { + i++; + } + if (i >= mathString.length()) { + throw new ElasticsearchParseException("truncated date math [{}]", mathString); + } + num = Integer.parseInt(mathString.substring(numFrom, i)); + } + if (round) { + if (num != 1) { + throw new ElasticsearchParseException("rounding `/` can only be used on single unit types [{}]", mathString); + } + } + char unit = mathString.charAt(i++); + switch (unit) { + case 'y': + if (round) { + dateTime = dateTime.withDayOfYear(1).with(LocalTime.MIN); + } else { + dateTime = dateTime.plusYears(sign * num); + } + if (roundUp) { + dateTime = dateTime.plusYears(1); + } + break; + case 'M': + if (round) { + dateTime = dateTime.withDayOfMonth(1).with(LocalTime.MIN); + } else { + dateTime = dateTime.plusMonths(sign * num); + } + if (roundUp) { + dateTime = dateTime.plusMonths(1); + } + break; + case 'w': + if (round) { + dateTime = dateTime.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).with(LocalTime.MIN); + } else { + dateTime = dateTime.plusWeeks(sign * num); + } + if (roundUp) { + dateTime = dateTime.plusWeeks(1); + } + break; + case 'd': + if (round) { + dateTime = dateTime.with(LocalTime.MIN); + } else { + dateTime = dateTime.plusDays(sign * num); + } + if (roundUp) { + dateTime = dateTime.plusDays(1); + } + break; + case 'h': + case 'H': + if (round) { + dateTime = dateTime.withMinute(0).withSecond(0).withNano(0); + } else { + dateTime = dateTime.plusHours(sign * num); + } + if (roundUp) { + dateTime = dateTime.plusHours(1); + } + break; + case 'm': + if (round) { + dateTime = dateTime.withSecond(0).withNano(0); + } else { + dateTime = dateTime.plusMinutes(sign * num); + } + if (roundUp) { + dateTime = dateTime.plusMinutes(1); + } + break; + case 's': + if (round) { + dateTime = dateTime.withNano(0); + } else { + dateTime = dateTime.plusSeconds(sign * num); + } + if (roundUp) { + dateTime = dateTime.plusSeconds(1); + } + break; + default: + throw new ElasticsearchParseException("unit [{}] not supported for date math [{}]", unit, mathString); + } + if (roundUp) { + dateTime = dateTime.minus(1, ChronoField.MILLI_OF_SECOND.getBaseUnit()); + } + } + return dateTime.toInstant().toEpochMilli(); + } + + private long parseDateTime(String value, ZoneId timeZone, boolean roundUpIfNoTime) { + CompoundDateTimeFormatter formatter = roundUpIfNoTime ? this.roundUpFormatter : this.formatter; + try { + if (timeZone == null) { + return DateFormatters.toZonedDateTime(formatter.parse(value)).toInstant().toEpochMilli(); + } else { + TemporalAccessor accessor = formatter.parse(value); + ZoneId zoneId = TemporalQueries.zone().queryFrom(accessor); + if (zoneId != null) { + timeZone = zoneId; + } + + return DateFormatters.toZonedDateTime(accessor).withZoneSameLocal(timeZone).toInstant().toEpochMilli(); + } + } catch (IllegalArgumentException | DateTimeException e) { + throw new ElasticsearchParseException("failed to parse date field [{}]: [{}]", e, value, e.getMessage()); + } + } +} diff --git a/server/src/test/java/org/elasticsearch/common/joda/DateMathParserTests.java b/server/src/test/java/org/elasticsearch/common/joda/DateMathParserTests.java index ca9a6b3a1ab..2fad9738cb5 100644 --- a/server/src/test/java/org/elasticsearch/common/joda/DateMathParserTests.java +++ b/server/src/test/java/org/elasticsearch/common/joda/DateMathParserTests.java @@ -208,7 +208,13 @@ public class DateMathParserTests extends ESTestCase { assertDateMathEquals("2014-11-18||/M", "2014-10-31T23:00:00.000Z", 0, false, DateTimeZone.forID("CET")); assertDateMathEquals("2014-11-18||/M", "2014-11-30T22:59:59.999Z", 0, true, DateTimeZone.forID("CET")); + assertDateMathEquals("2014-11-17T14||/w", "2014-11-17", 0, false, null); assertDateMathEquals("2014-11-18T14||/w", "2014-11-17", 0, false, null); + assertDateMathEquals("2014-11-19T14||/w", "2014-11-17", 0, false, null); + assertDateMathEquals("2014-11-20T14||/w", "2014-11-17", 0, false, null); + assertDateMathEquals("2014-11-21T14||/w", "2014-11-17", 0, false, null); + assertDateMathEquals("2014-11-22T14||/w", "2014-11-17", 0, false, null); + assertDateMathEquals("2014-11-23T14||/w", "2014-11-17", 0, false, null); assertDateMathEquals("2014-11-18T14||/w", "2014-11-23T23:59:59.999", 0, true, null); assertDateMathEquals("2014-11-18||/w", "2014-11-17", 0, false, null); assertDateMathEquals("2014-11-18||/w", "2014-11-23T23:59:59.999", 0, true, null); diff --git a/server/src/test/java/org/elasticsearch/common/joda/JavaJodaTimeDuellingTests.java b/server/src/test/java/org/elasticsearch/common/joda/JavaJodaTimeDuellingTests.java index dd2aa0e325e..1551a315b26 100644 --- a/server/src/test/java/org/elasticsearch/common/joda/JavaJodaTimeDuellingTests.java +++ b/server/src/test/java/org/elasticsearch/common/joda/JavaJodaTimeDuellingTests.java @@ -94,6 +94,7 @@ public class JavaJodaTimeDuellingTests extends ESTestCase { assertSameDate("2018-12-31", "date"); assertSameDate("18-5-6", "date"); + assertSameDate("10000-5-6", "date"); assertSameDate("2018-12-31T12", "date_hour"); assertSameDate("2018-12-31T8", "date_hour"); @@ -109,7 +110,16 @@ public class JavaJodaTimeDuellingTests extends ESTestCase { assertSameDate("2018-12-31T12:12:12.1", "date_hour_minute_second_millis"); assertSameDate("2018-12-31T12:12:12.1", "date_hour_minute_second_fraction"); - assertSameDate("2018-12-31", "date_optional_time"); + assertSameDate("10000", "date_optional_time"); + assertSameDate("10000T", "date_optional_time"); + assertSameDate("2018", "date_optional_time"); + assertSameDate("2018T", "date_optional_time"); + assertSameDate("2018-05", "date_optional_time"); + assertSameDate("2018-05-30", "date_optional_time"); + assertSameDate("2018-05-30T20", "date_optional_time"); + assertSameDate("2018-05-30T20:21", "date_optional_time"); + assertSameDate("2018-05-30T20:21:23", "date_optional_time"); + assertSameDate("2018-05-30T20:21:23.123", "date_optional_time"); assertSameDate("2018-12-1", "date_optional_time"); assertSameDate("2018-12-31T10:15:30", "date_optional_time"); assertSameDate("2018-12-31T10:15:3", "date_optional_time"); @@ -230,6 +240,7 @@ public class JavaJodaTimeDuellingTests extends ESTestCase { assertParseException("2018W313T81212Z", "strict_basic_week_date_time_no_millis"); assertParseException("2018W313T12812Z", "strict_basic_week_date_time_no_millis"); assertSameDate("2018-12-31", "strict_date"); + assertParseException("10000-12-31", "strict_date"); assertParseException("2018-8-31", "strict_date"); assertSameDate("2018-12-31T12", "strict_date_hour"); assertParseException("2018-12-31T8", "strict_date_hour"); @@ -246,6 +257,7 @@ public class JavaJodaTimeDuellingTests extends ESTestCase { assertSameDate("2018-12-31", "strict_date_optional_time"); assertParseException("2018-12-1", "strict_date_optional_time"); assertParseException("2018-1-31", "strict_date_optional_time"); + assertParseException("10000-01-31", "strict_date_optional_time"); assertSameDate("2018-12-31T10:15:30", "strict_date_optional_time"); assertParseException("2018-12-31T10:15:3", "strict_date_optional_time"); assertParseException("2018-12-31T10:5:30", "strict_date_optional_time"); diff --git a/server/src/test/java/org/elasticsearch/common/time/DateMathParserTests.java b/server/src/test/java/org/elasticsearch/common/time/DateMathParserTests.java new file mode 100644 index 00000000000..d6dc7bb36f2 --- /dev/null +++ b/server/src/test/java/org/elasticsearch/common/time/DateMathParserTests.java @@ -0,0 +1,309 @@ +/* + * 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.common.time; + +import org.elasticsearch.ElasticsearchParseException; +import org.elasticsearch.ExceptionsHelper; +import org.elasticsearch.test.ESTestCase; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.LongSupplier; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; + +public class DateMathParserTests extends ESTestCase { + + private final CompoundDateTimeFormatter formatter = DateFormatters.forPattern("dateOptionalTime||epoch_millis"); + private final DateMathParser parser = new DateMathParser(formatter); + + public void testBasicDates() { + assertDateMathEquals("2014", "2014-01-01T00:00:00.000"); + assertDateMathEquals("2014-05", "2014-05-01T00:00:00.000"); + assertDateMathEquals("2014-05-30", "2014-05-30T00:00:00.000"); + assertDateMathEquals("2014-05-30T20", "2014-05-30T20:00:00.000"); + assertDateMathEquals("2014-05-30T20:21", "2014-05-30T20:21:00.000"); + assertDateMathEquals("2014-05-30T20:21:35", "2014-05-30T20:21:35.000"); + assertDateMathEquals("2014-05-30T20:21:35.123", "2014-05-30T20:21:35.123"); + } + + public void testRoundingDoesNotAffectExactDate() { + assertDateMathEquals("2014-11-12T22:55:00.000Z", "2014-11-12T22:55:00.000Z", 0, true, null); + assertDateMathEquals("2014-11-12T22:55:00.000Z", "2014-11-12T22:55:00.000Z", 0, false, null); + + assertDateMathEquals("2014-11-12T22:55:00.000", "2014-11-12T21:55:00.000Z", 0, true, ZoneId.of("+01:00")); + assertDateMathEquals("2014-11-12T22:55:00.000", "2014-11-12T21:55:00.000Z", 0, false, ZoneId.of("+01:00")); + + assertDateMathEquals("2014-11-12T22:55:00.000+01:00", "2014-11-12T21:55:00.000Z", 0, true, null); + assertDateMathEquals("2014-11-12T22:55:00.000+01:00", "2014-11-12T21:55:00.000Z", 0, false, null); + } + + public void testTimezone() { + // timezone works within date format + assertDateMathEquals("2014-05-30T20:21+02:00", "2014-05-30T18:21:00.000"); + + // test alternative ways of writing zero offsets, according to ISO 8601 +00:00, +00, +0000 should work. + // joda also seems to allow for -00:00, -00, -0000 + assertDateMathEquals("2014-05-30T18:21+00:00", "2014-05-30T18:21:00.000"); + assertDateMathEquals("2014-05-30T18:21+00", "2014-05-30T18:21:00.000"); + assertDateMathEquals("2014-05-30T18:21+0000", "2014-05-30T18:21:00.000"); + assertDateMathEquals("2014-05-30T18:21-00:00", "2014-05-30T18:21:00.000"); + assertDateMathEquals("2014-05-30T18:21-00", "2014-05-30T18:21:00.000"); + assertDateMathEquals("2014-05-30T18:21-0000", "2014-05-30T18:21:00.000"); + + // but also externally + assertDateMathEquals("2014-05-30T20:21", "2014-05-30T18:21:00.000", 0, false, ZoneId.of("+02:00")); + assertDateMathEquals("2014-05-30T18:21", "2014-05-30T18:21:00.000", 0, false, ZoneId.of("+00:00")); + assertDateMathEquals("2014-05-30T18:21", "2014-05-30T18:21:00.000", 0, false, ZoneId.of("+00:00")); + assertDateMathEquals("2014-05-30T18:21", "2014-05-30T18:21:00.000", 0, false, ZoneId.of("+00")); + assertDateMathEquals("2014-05-30T18:21", "2014-05-30T18:21:00.000", 0, false, ZoneId.of("+0000")); + assertDateMathEquals("2014-05-30T18:21", "2014-05-30T18:21:00.000", 0, false, ZoneId.of("-00:00")); + assertDateMathEquals("2014-05-30T18:21", "2014-05-30T18:21:00.000", 0, false, ZoneId.of("-00")); + assertDateMathEquals("2014-05-30T18:21", "2014-05-30T18:21:00.000", 0, false, ZoneId.of("-0000")); + + // and timezone in the date has priority + assertDateMathEquals("2014-05-30T20:21+03:00", "2014-05-30T17:21:00.000", 0, false, ZoneId.of("-08:00")); + assertDateMathEquals("2014-05-30T20:21Z", "2014-05-30T20:21:00.000", 0, false, ZoneId.of("-08:00")); + } + + public void testBasicMath() { + assertDateMathEquals("2014-11-18||+y", "2015-11-18"); + assertDateMathEquals("2014-11-18||-2y", "2012-11-18"); + + assertDateMathEquals("2014-11-18||+3M", "2015-02-18"); + assertDateMathEquals("2014-11-18||-M", "2014-10-18"); + + assertDateMathEquals("2014-11-18||+1w", "2014-11-25"); + assertDateMathEquals("2014-11-18||-3w", "2014-10-28"); + + assertDateMathEquals("2014-11-18||+22d", "2014-12-10"); + assertDateMathEquals("2014-11-18||-423d", "2013-09-21"); + + assertDateMathEquals("2014-11-18T14||+13h", "2014-11-19T03"); + assertDateMathEquals("2014-11-18T14||-1h", "2014-11-18T13"); + assertDateMathEquals("2014-11-18T14||+13H", "2014-11-19T03"); + assertDateMathEquals("2014-11-18T14||-1H", "2014-11-18T13"); + + assertDateMathEquals("2014-11-18T14:27||+10240m", "2014-11-25T17:07"); + assertDateMathEquals("2014-11-18T14:27||-10m", "2014-11-18T14:17"); + + assertDateMathEquals("2014-11-18T14:27:32||+60s", "2014-11-18T14:28:32"); + assertDateMathEquals("2014-11-18T14:27:32||-3600s", "2014-11-18T13:27:32"); + } + + public void testLenientEmptyMath() { + assertDateMathEquals("2014-05-30T20:21||", "2014-05-30T20:21:00.000"); + } + + public void testMultipleAdjustments() { + assertDateMathEquals("2014-11-18||+1M-1M", "2014-11-18"); + assertDateMathEquals("2014-11-18||+1M-1m", "2014-12-17T23:59"); + assertDateMathEquals("2014-11-18||-1m+1M", "2014-12-17T23:59"); + assertDateMathEquals("2014-11-18||+1M/M", "2014-12-01"); + assertDateMathEquals("2014-11-18||+1M/M+1h", "2014-12-01T01"); + } + + public void testNow() { + final long now = parser.parse("2014-11-18T14:27:32", () -> 0, false, null); + + assertDateMathEquals("now", "2014-11-18T14:27:32", now, false, null); + assertDateMathEquals("now+M", "2014-12-18T14:27:32", now, false, null); + assertDateMathEquals("now-2d", "2014-11-16T14:27:32", now, false, null); + assertDateMathEquals("now/m", "2014-11-18T14:27", now, false, null); + + // timezone does not affect now + assertDateMathEquals("now/m", "2014-11-18T14:27", now, false, ZoneId.of("+02:00")); + } + + public void testRoundingPreservesEpochAsBaseDate() { + // If a user only specifies times, then the date needs to always be 1970-01-01 regardless of rounding + CompoundDateTimeFormatter formatter = DateFormatters.forPattern("HH:mm:ss"); + DateMathParser parser = new DateMathParser(formatter); + ZonedDateTime zonedDateTime = DateFormatters.toZonedDateTime(formatter.parse("04:52:20")); + assertThat(zonedDateTime.getYear(), is(1970)); + long millisStart = zonedDateTime.toInstant().toEpochMilli(); + assertEquals(millisStart, parser.parse("04:52:20", () -> 0, false, null)); + // due to rounding up, we have to add the number of milliseconds here manually + long millisEnd = DateFormatters.toZonedDateTime(formatter.parse("04:52:20")).toInstant().toEpochMilli() + 999; + assertEquals(millisEnd, parser.parse("04:52:20", () -> 0, true, null)); + } + + // Implicit rounding happening when parts of the date are not specified + public void testImplicitRounding() { + assertDateMathEquals("2014-11-18", "2014-11-18", 0, false, null); + assertDateMathEquals("2014-11-18", "2014-11-18T23:59:59.999Z", 0, true, null); + + assertDateMathEquals("2014-11-18T09:20", "2014-11-18T09:20", 0, false, null); + assertDateMathEquals("2014-11-18T09:20", "2014-11-18T09:20:59.999Z", 0, true, null); + + assertDateMathEquals("2014-11-18", "2014-11-17T23:00:00.000Z", 0, false, ZoneId.of("CET")); + assertDateMathEquals("2014-11-18", "2014-11-18T22:59:59.999Z", 0, true, ZoneId.of("CET")); + + assertDateMathEquals("2014-11-18T09:20", "2014-11-18T08:20:00.000Z", 0, false, ZoneId.of("CET")); + assertDateMathEquals("2014-11-18T09:20", "2014-11-18T08:20:59.999Z", 0, true, ZoneId.of("CET")); + + // implicit rounding with explicit timezone in the date format + CompoundDateTimeFormatter formatter = DateFormatters.forPattern("yyyy-MM-ddXXX"); + DateMathParser parser = new DateMathParser(formatter); + long time = parser.parse("2011-10-09+01:00", () -> 0, false, null); + assertEquals(this.parser.parse("2011-10-09T00:00:00.000+01:00", () -> 0), time); + time = parser.parse("2011-10-09+01:00", () -> 0, true, null); + assertEquals(this.parser.parse("2011-10-09T23:59:59.999+01:00", () -> 0), time); + } + + // Explicit rounding using the || separator + public void testExplicitRounding() { + assertDateMathEquals("2014-11-18||/y", "2014-01-01", 0, false, null); + assertDateMathEquals("2014-11-18||/y", "2014-12-31T23:59:59.999", 0, true, null); + assertDateMathEquals("2014||/y", "2014-01-01", 0, false, null); + assertDateMathEquals("2014-01-01T00:00:00.001||/y", "2014-12-31T23:59:59.999", 0, true, null); + // rounding should also take into account time zone + assertDateMathEquals("2014-11-18||/y", "2013-12-31T23:00:00.000Z", 0, false, ZoneId.of("CET")); + assertDateMathEquals("2014-11-18||/y", "2014-12-31T22:59:59.999Z", 0, true, ZoneId.of("CET")); + + assertDateMathEquals("2014-11-18||/M", "2014-11-01", 0, false, null); + assertDateMathEquals("2014-11-18||/M", "2014-11-30T23:59:59.999", 0, true, null); + assertDateMathEquals("2014-11||/M", "2014-11-01", 0, false, null); + assertDateMathEquals("2014-11||/M", "2014-11-30T23:59:59.999", 0, true, null); + assertDateMathEquals("2014-11-18||/M", "2014-10-31T23:00:00.000Z", 0, false, ZoneId.of("CET")); + assertDateMathEquals("2014-11-18||/M", "2014-11-30T22:59:59.999Z", 0, true, ZoneId.of("CET")); + + assertDateMathEquals("2014-11-18T14||/w", "2014-11-17", 0, false, null); + assertDateMathEquals("2014-11-18T14||/w", "2014-11-23T23:59:59.999", 0, true, null); + assertDateMathEquals("2014-11-17T14||/w", "2014-11-17", 0, false, null); + assertDateMathEquals("2014-11-18T14||/w", "2014-11-17", 0, false, null); + assertDateMathEquals("2014-11-19T14||/w", "2014-11-17", 0, false, null); + assertDateMathEquals("2014-11-20T14||/w", "2014-11-17", 0, false, null); + assertDateMathEquals("2014-11-21T14||/w", "2014-11-17", 0, false, null); + assertDateMathEquals("2014-11-22T14||/w", "2014-11-17", 0, false, null); + assertDateMathEquals("2014-11-23T14||/w", "2014-11-17", 0, false, null); + assertDateMathEquals("2014-11-18||/w", "2014-11-23T23:59:59.999", 0, true, null); + assertDateMathEquals("2014-11-18||/w", "2014-11-16T23:00:00.000Z", 0, false, ZoneId.of("+01:00")); + assertDateMathEquals("2014-11-18||/w", "2014-11-17T01:00:00.000Z", 0, false, ZoneId.of("-01:00")); + assertDateMathEquals("2014-11-18||/w", "2014-11-16T23:00:00.000Z", 0, false, ZoneId.of("CET")); + assertDateMathEquals("2014-11-18||/w", "2014-11-23T22:59:59.999Z", 0, true, ZoneId.of("CET")); + assertDateMathEquals("2014-07-22||/w", "2014-07-20T22:00:00.000Z", 0, false, ZoneId.of("CET")); // with DST + + assertDateMathEquals("2014-11-18T14||/d", "2014-11-18", 0, false, null); + assertDateMathEquals("2014-11-18T14||/d", "2014-11-18T23:59:59.999", 0, true, null); + assertDateMathEquals("2014-11-18||/d", "2014-11-18", 0, false, null); + assertDateMathEquals("2014-11-18||/d", "2014-11-18T23:59:59.999", 0, true, null); + + assertDateMathEquals("2014-11-18T14:27||/h", "2014-11-18T14", 0, false, null); + assertDateMathEquals("2014-11-18T14:27||/h", "2014-11-18T14:59:59.999", 0, true, null); + assertDateMathEquals("2014-11-18T14||/H", "2014-11-18T14", 0, false, null); + assertDateMathEquals("2014-11-18T14||/H", "2014-11-18T14:59:59.999", 0, true, null); + assertDateMathEquals("2014-11-18T14:27||/h", "2014-11-18T14", 0, false, null); + assertDateMathEquals("2014-11-18T14:27||/h", "2014-11-18T14:59:59.999", 0, true, null); + assertDateMathEquals("2014-11-18T14||/H", "2014-11-18T14", 0, false, null); + assertDateMathEquals("2014-11-18T14||/H", "2014-11-18T14:59:59.999", 0, true, null); + + assertDateMathEquals("2014-11-18T14:27:32||/m", "2014-11-18T14:27", 0, false, null); + assertDateMathEquals("2014-11-18T14:27:32||/m", "2014-11-18T14:27:59.999", 0, true, null); + assertDateMathEquals("2014-11-18T14:27||/m", "2014-11-18T14:27", 0, false, null); + assertDateMathEquals("2014-11-18T14:27||/m", "2014-11-18T14:27:59.999", 0, true, null); + + assertDateMathEquals("2014-11-18T14:27:32.123||/s", "2014-11-18T14:27:32", 0, false, null); + assertDateMathEquals("2014-11-18T14:27:32.123||/s", "2014-11-18T14:27:32.999", 0, true, null); + assertDateMathEquals("2014-11-18T14:27:32||/s", "2014-11-18T14:27:32", 0, false, null); + assertDateMathEquals("2014-11-18T14:27:32||/s", "2014-11-18T14:27:32.999", 0, true, null); + } + + public void testTimestamps() { + assertDateMathEquals("1418248078000", "2014-12-10T21:47:58.000"); + assertDateMathEquals("32484216259000", "2999-05-20T17:24:19.000"); + assertDateMathEquals("253382837059000", "9999-05-20T17:24:19.000"); + + // datemath still works on timestamps + assertDateMathEquals("1418248078000||/m", "2014-12-10T21:47:00.000"); + + // also check other time units + DateMathParser parser = new DateMathParser(DateFormatters.forPattern("epoch_second||dateOptionalTime")); + long datetime = parser.parse("1418248078", () -> 0); + assertDateEquals(datetime, "1418248078", "2014-12-10T21:47:58.000"); + + // a timestamp before 10000 is a year + assertDateMathEquals("9999", "9999-01-01T00:00:00.000"); + // 10000 is also a year, breaking bwc, used to be a timestamp + assertDateMathEquals("10000", "10000-01-01T00:00:00.000"); + // but 10000 with T is still a date format + assertDateMathEquals("10000T", "10000-01-01T00:00:00.000"); + } + + void assertParseException(String msg, String date, String exc) { + try { + parser.parse(date, () -> 0); + fail("Date: " + date + "\n" + msg); + } catch (ElasticsearchParseException e) { + assertThat(ExceptionsHelper.detailedMessage(e), containsString(exc)); + } + } + + public void testIllegalMathFormat() { + assertParseException("Expected date math unsupported operator exception", "2014-11-18||*5", "operator not supported"); + assertParseException("Expected date math incompatible rounding exception", "2014-11-18||/2m", "rounding"); + assertParseException("Expected date math illegal unit type exception", "2014-11-18||+2a", "unit [a] not supported"); + assertParseException("Expected date math truncation exception", "2014-11-18||+12", "truncated"); + assertParseException("Expected date math truncation exception", "2014-11-18||-", "truncated"); + } + + public void testIllegalDateFormat() { + assertParseException("Expected bad timestamp exception", Long.toString(Long.MAX_VALUE) + "0", "failed to parse date field"); + assertParseException("Expected bad date format exception", "123bogus", "could not be parsed"); + } + + public void testOnlyCallsNowIfNecessary() { + final AtomicBoolean called = new AtomicBoolean(); + final LongSupplier now = () -> { + called.set(true); + return 42L; + }; + parser.parse("2014-11-18T14:27:32", now, false, null); + assertFalse(called.get()); + parser.parse("now/d", now, false, null); + assertTrue(called.get()); + } + + private void assertDateMathEquals(String toTest, String expected) { + assertDateMathEquals(toTest, expected, 0, false, null); + } + + private void assertDateMathEquals(String toTest, String expected, final long now, boolean roundUp, ZoneId timeZone) { + long gotMillis = parser.parse(toTest, () -> now, roundUp, timeZone); + assertDateEquals(gotMillis, toTest, expected); + } + + private void assertDateEquals(long gotMillis, String original, String expected) { + long expectedMillis = parser.parse(expected, () -> 0); + if (gotMillis != expectedMillis) { + ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(gotMillis), ZoneOffset.UTC); + fail("Date math not equal\n" + + "Original : " + original + "\n" + + "Parsed : " + formatter.format(zonedDateTime) + "\n" + + "Expected : " + expected + "\n" + + "Expected milliseconds : " + expectedMillis + "\n" + + "Actual milliseconds : " + gotMillis + "\n"); + } + } +}