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 5f68765134498..ea530cbd75649 100644 --- a/server/src/main/java/org/elasticsearch/common/time/DateFormatters.java +++ b/server/src/main/java/org/elasticsearch/common/time/DateFormatters.java @@ -890,17 +890,6 @@ public class DateFormatters { private static final DateFormatter YEAR = new JavaDateFormatter("year", new DateTimeFormatterBuilder().appendValue(ChronoField.YEAR).toFormatter(Locale.ROOT)); - /* - * Returns a formatter for parsing the seconds since the epoch - */ - private static final DateFormatter EPOCH_SECOND = new JavaDateFormatter("epoch_second", - new DateTimeFormatterBuilder().appendValue(ChronoField.INSTANT_SECONDS).toFormatter(Locale.ROOT)); - - /* - * Parses the milliseconds since/before the epoch - */ - private static final DateFormatter EPOCH_MILLIS = EpochMillisDateFormatter.INSTANCE; - /* * Returns a formatter that combines a full date and two digit hour of * day. (yyyy-MM-dd'T'HH) @@ -1375,9 +1364,9 @@ public static DateFormatter forPattern(String input, Locale locale) { } else if ("yearMonthDay".equals(input) || "year_month_day".equals(input)) { return YEAR_MONTH_DAY; } else if ("epoch_second".equals(input)) { - return EPOCH_SECOND; + return EpochSecondsDateFormatter.INSTANCE; } else if ("epoch_millis".equals(input)) { - return EPOCH_MILLIS; + return EpochMillisDateFormatter.INSTANCE; // strict date formats here, must be at least 4 digits for year and two for months and two for day } else if ("strictBasicWeekDate".equals(input) || "strict_basic_week_date".equals(input)) { return STRICT_BASIC_WEEK_DATE; diff --git a/server/src/main/java/org/elasticsearch/common/time/EpochSecondsDateFormatter.java b/server/src/main/java/org/elasticsearch/common/time/EpochSecondsDateFormatter.java new file mode 100644 index 0000000000000..8d19f5d4bc3c5 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/common/time/EpochSecondsDateFormatter.java @@ -0,0 +1,85 @@ +/* + * 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 java.math.BigDecimal; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; +import java.time.temporal.TemporalAccessor; +import java.time.temporal.TemporalField; +import java.util.Map; +import java.util.regex.Pattern; + +public class EpochSecondsDateFormatter implements DateFormatter { + + public static DateFormatter INSTANCE = new EpochSecondsDateFormatter(); + private static final Pattern SPLIT_BY_DOT_PATTERN = Pattern.compile("\\."); + + private EpochSecondsDateFormatter() {} + + @Override + public TemporalAccessor parse(String input) { + try { + if (input.contains(".")) { + String[] inputs = SPLIT_BY_DOT_PATTERN.split(input, 2); + Long seconds = Long.valueOf(inputs[0]); + if (inputs[1].length() == 0) { + // this is BWC compatible to joda time, nothing after the dot is allowed + return Instant.ofEpochSecond(seconds, 0).atZone(ZoneOffset.UTC); + } + if (inputs[1].length() > 9) { + throw new DateTimeParseException("too much granularity after dot [" + input + "]", input, 0); + } + Long nanos = new BigDecimal(inputs[1]).movePointRight(9 - inputs[1].length()).longValueExact(); + return Instant.ofEpochSecond(seconds, nanos).atZone(ZoneOffset.UTC); + } else { + return Instant.ofEpochSecond(Long.valueOf(input)).atZone(ZoneOffset.UTC); + } + } catch (NumberFormatException e) { + throw new DateTimeParseException("invalid number [" + input + "]", input, 0, e); + } + } + + @Override + public DateFormatter withZone(ZoneId zoneId) { + return this; + } + + @Override + public String format(TemporalAccessor accessor) { + Instant instant = Instant.from(accessor); + if (instant.getNano() != 0) { + return String.valueOf(instant.getEpochSecond()) + "." + String.valueOf(instant.getNano()).replaceAll("0*$", ""); + } + return String.valueOf(instant.getEpochSecond()); + } + + @Override + public String pattern() { + return "epoch_seconds"; + } + + @Override + public DateFormatter parseDefaulting(Map fields) { + return this; + } +} 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 5203aa07d286e..61aa7dc4a4664 100644 --- a/server/src/test/java/org/elasticsearch/common/joda/JavaJodaTimeDuellingTests.java +++ b/server/src/test/java/org/elasticsearch/common/joda/JavaJodaTimeDuellingTests.java @@ -71,6 +71,8 @@ public void testCustomTimeFormats() { 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("-1", "epoch_second"); diff --git a/server/src/test/java/org/elasticsearch/common/time/DateFormattersTests.java b/server/src/test/java/org/elasticsearch/common/time/DateFormattersTests.java index f01db140a7057..73fe97cbd9cc1 100644 --- a/server/src/test/java/org/elasticsearch/common/time/DateFormattersTests.java +++ b/server/src/test/java/org/elasticsearch/common/time/DateFormattersTests.java @@ -21,6 +21,7 @@ import org.elasticsearch.test.ESTestCase; +import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeParseException; @@ -56,6 +57,42 @@ public void testEpochMilliParser() { assertSameFormat(formatter, 1); } + // this is not in the duelling tests, because the epoch second parser in joda time drops the milliseconds after the comma + // but is able to parse the rest + // as this feature is supported it also makes sense to make it exact + 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("too much granularity after dot [1234.1234567890]")); + e = expectThrows(DateTimeParseException.class, () -> formatter.parse("1234.123456789013221")); + assertThat(e.getMessage(), is("too much granularity after dot [1234.123456789013221]")); + e = expectThrows(DateTimeParseException.class, () -> formatter.parse("abc")); + assertThat(e.getMessage(), is("invalid number [abc]")); + e = expectThrows(DateTimeParseException.class, () -> formatter.parse("1234.abc")); + assertThat(e.getMessage(), is("invalid number [1234.abc]")); + + // different zone, should still yield the same output, as epoch is time zone independent + ZoneId zoneId = randomZone(); + DateFormatter zonedFormatter = formatter.withZone(zoneId); + + assertThatSameDateTime(formatter, zonedFormatter, randomLongBetween(-100_000_000, 100_000_000)); + assertSameFormat(formatter, randomLongBetween(-100_000_000, 100_000_000)); + assertThat(formatter.format(Instant.ofEpochSecond(1234, 567_000_000)), is("1234.567")); + } + public void testEpochMilliParsersWithDifferentFormatters() { DateFormatter formatter = DateFormatters.forPattern("strict_date_optional_time||epoch_millis"); TemporalAccessor accessor = formatter.parse("123");