diff --git a/server/src/main/java/org/opensearch/common/time/DateFormatters.java b/server/src/main/java/org/opensearch/common/time/DateFormatters.java index 04e42543573c5..35e564000dbf8 100644 --- a/server/src/main/java/org/opensearch/common/time/DateFormatters.java +++ b/server/src/main/java/org/opensearch/common/time/DateFormatters.java @@ -1334,6 +1334,38 @@ public class DateFormatters { ) ); + public static final DateFormatter LOCAL_DATETIME_FORMATTER = new JavaDateFormatter( + "local_date_time", + new OpenSearchDateTimeFormatter(STRICT_DATE_OPTIONAL_TIME_PRINTER), + new LocalDateTimeFormatter( + new DateTimeFormatterBuilder().append(DATE_FORMATTER) + .optionalStart() + .optionalStart() + .appendLiteral(' ') + .optionalEnd() + .optionalStart() + .appendLiteral('T') + .optionalEnd() + .appendValue(HOUR_OF_DAY, 1, 2, SignStyle.NOT_NEGATIVE) + .appendLiteral(':') + .appendValue(MINUTE_OF_HOUR, 1, 2, SignStyle.NOT_NEGATIVE) + .optionalStart() + .appendLiteral(':') + .appendValue(SECOND_OF_MINUTE, 1, 2, SignStyle.NOT_NEGATIVE) + .optionalStart() + .appendFraction(NANO_OF_SECOND, 1, 9, true) + .optionalEnd() + .optionalStart() + .appendLiteral(',') + .appendFraction(NANO_OF_SECOND, 1, 9, false) + .optionalEnd() + .optionalEnd() + .optionalEnd() + .toFormatter(Locale.ROOT) + .withResolverStyle(ResolverStyle.STRICT) + ) + ); + private static final DateTimeFormatter HOUR_MINUTE_SECOND_FORMATTER = new DateTimeFormatterBuilder().append(HOUR_MINUTE_FORMATTER) .appendLiteral(":") .appendValue(SECOND_OF_MINUTE, 1, 2, SignStyle.NOT_NEGATIVE) @@ -2189,6 +2221,8 @@ static DateFormatter forPattern(String input) { return STRICT_YEAR_MONTH_DAY; } else if (FormatNames.RFC3339.matches(input)) { return RFC3339_DATE_FORMATTER; + } else if (FormatNames.LOCAL_DATE_TIME.matches(input)) { + return LOCAL_DATETIME_FORMATTER; } else { try { return new JavaDateFormatter( diff --git a/server/src/main/java/org/opensearch/common/time/DateTime.java b/server/src/main/java/org/opensearch/common/time/DateTime.java index 85a0ff78b6923..39683f60cdfb4 100644 --- a/server/src/main/java/org/opensearch/common/time/DateTime.java +++ b/server/src/main/java/org/opensearch/common/time/DateTime.java @@ -9,6 +9,7 @@ package org.opensearch.common.time; import java.time.DateTimeException; +import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.temporal.ChronoField; @@ -159,6 +160,15 @@ public OffsetDateTime toOffsetDatetime() { throw new DateTimeException("No zone offset information found"); } + /** + * Creates an {@link java.time.LocalDateTime} + * + * return the {@link java.time.LocalDateTime} + */ + public LocalDateTime toLocalDatetime() { + return LocalDateTime.of(year, month, day, hour, minute, second, nano); + } + /** * * @hidden */ diff --git a/server/src/main/java/org/opensearch/common/time/DateUtils.java b/server/src/main/java/org/opensearch/common/time/DateUtils.java index 7ab395a1117e7..d81126936d20c 100644 --- a/server/src/main/java/org/opensearch/common/time/DateUtils.java +++ b/server/src/main/java/org/opensearch/common/time/DateUtils.java @@ -35,12 +35,15 @@ import org.opensearch.common.logging.DeprecationLogger; import org.joda.time.DateTimeZone; +import java.text.ParsePosition; import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.time.ZoneOffset; import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -57,6 +60,18 @@ * @opensearch.internal */ public class DateUtils { + public static final char DATE_SEPARATOR = '-'; + public static final char TIME_SEPARATOR = ':'; + public static final char SEPARATOR_UPPER = 'T'; + static final char PLUS = '+'; + static final char MINUS = '-'; + static final char SEPARATOR_LOWER = 't'; + static final char SEPARATOR_SPACE = ' '; + static final char FRACTION_SEPARATOR_1 = '.'; + static final char FRACTION_SEPARATOR_2 = ','; + static final char ZULU_UPPER = 'Z'; + static final char ZULU_LOWER = 'z'; + public static DateTimeZone zoneIdToDateTimeZone(ZoneId zoneId) { if (zoneId == null) { return null; @@ -430,4 +445,136 @@ public static ZonedDateTime nowWithMillisResolution(Clock clock) { Clock millisResolutionClock = Clock.tick(clock, Duration.ofMillis(1)); return ZonedDateTime.now(millisResolutionClock); } + + private static boolean isDigit(char c) { + return (c >= '0' && c <= '9'); + } + + private static int digit(char c) { + return c - '0'; + } + + static int readInt(final char[] strNum, ParsePosition pos, int n) { + int start = pos.getIndex(), end = start + n; + if (end > strNum.length) { + pos.setErrorIndex(end); + throw new DateTimeParseException("Unexpected end of expression at position " + strNum.length, new String(strNum), end); + } + + int result = 0; + for (int i = start; i < end; i++) { + final char c = strNum[i]; + if (isDigit(c) == false) { + pos.setErrorIndex(i); + throw new DateTimeParseException("Character " + c + " is not a digit", new String(strNum), i); + } + int digit = digit(c); + result = result * 10 + digit; + } + pos.setIndex(end); + return result; + } + + static int readIntUnchecked(final char[] strNum, ParsePosition pos, int n) { + int start = pos.getIndex(), end = start + n; + int result = 0; + for (int i = start; i < end; i++) { + final char c = strNum[i]; + int digit = digit(c); + result = result * 10 + digit; + } + pos.setIndex(end); + return result; + } + + private static boolean isValidOffset(char[] chars, int offset) { + if (offset >= chars.length) { + return false; + } + return true; + } + + static void consumeChar(char[] chars, ParsePosition pos, char expected) { + int offset = pos.getIndex(); + if (isValidOffset(chars, offset) == false) { + throw new DateTimeParseException("Unexpected end of input", new String(chars), offset); + } + + if (chars[offset] != expected) { + throw new DateTimeParseException("Expected character " + expected + " at position " + offset, new String(chars), offset); + } + pos.setIndex(offset + 1); + } + + static void consumeNextChar(char[] chars, ParsePosition pos) { + int offset = pos.getIndex(); + if (isValidOffset(chars, offset) == false) { + throw new DateTimeParseException("Unexpected end of input", new String(chars), offset); + } + pos.setIndex(offset + 1); + } + + static boolean checkPositionContains(char[] chars, ParsePosition pos, char... expected) { + int offset = pos.getIndex(); + if (offset >= chars.length) { + throw new DateTimeParseException("Unexpected end of input", new String(chars), offset); + } + + boolean found = false; + for (char e : expected) { + if (chars[offset] == e) { + found = true; + break; + } + } + return found; + } + + static void consumeChar(char[] chars, ParsePosition pos, char... expected) { + int offset = pos.getIndex(); + if (offset >= chars.length) { + throw new DateTimeParseException("Unexpected end of input", new String(chars), offset); + } + + boolean found = false; + for (char e : expected) { + if (chars[offset] == e) { + found = true; + pos.setIndex(offset + 1); + break; + } + } + if (!found) { + throw new DateTimeParseException( + "Expected character " + Arrays.toString(expected) + " at position " + offset, + new String(chars), + offset + ); + } + } + + static void assertNoMoreChars(char[] chars, ParsePosition pos) { + if (chars.length > pos.getIndex()) { + throw new DateTimeParseException("Trailing junk data after position " + pos.getIndex(), new String(chars), pos.getIndex()); + } + } + + public static int indexOfNonDigit(final char[] text, int offset) { + for (int i = offset; i < text.length; i++) { + if (isDigit(text[i]) == false) { + return i; + } + } + return -1; + } + + public static void consumeDigits(final char[] text, ParsePosition pos) { + final int idx = indexOfNonDigit(text, pos.getIndex()); + if (idx == -1) { + pos.setErrorIndex(text.length); + pos.setIndex(text.length); + } else { + pos.setIndex(idx); + } + } } diff --git a/server/src/main/java/org/opensearch/common/time/FormatNames.java b/server/src/main/java/org/opensearch/common/time/FormatNames.java index d0c570ece67ba..55323fc001587 100644 --- a/server/src/main/java/org/opensearch/common/time/FormatNames.java +++ b/server/src/main/java/org/opensearch/common/time/FormatNames.java @@ -45,6 +45,7 @@ public enum FormatNames { ISO8601(null, "iso8601"), RFC3339(null, "rfc3339"), + LOCAL_DATE_TIME("localDateTime", "local_date_time"), BASIC_DATE("basicDate", "basic_date"), BASIC_DATE_TIME("basicDateTime", "basic_date_time"), BASIC_DATE_TIME_NO_MILLIS("basicDateTimeNoMillis", "basic_date_time_no_millis"), diff --git a/server/src/main/java/org/opensearch/common/time/LocalDateTimeFormatter.java b/server/src/main/java/org/opensearch/common/time/LocalDateTimeFormatter.java new file mode 100644 index 0000000000000..89a3f7fa79d25 --- /dev/null +++ b/server/src/main/java/org/opensearch/common/time/LocalDateTimeFormatter.java @@ -0,0 +1,231 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.common.time; + +import java.text.ParsePosition; +import java.time.DateTimeException; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; +import java.time.temporal.TemporalAccessor; +import java.util.Locale; + +/** + * Defines a local datetime format where the date is mandatory and the time is optional. + *
+ * The returned formatter can only be used for parsing, printing is unsupported. + *
+ * This parser can parse local datetimes without zone information. + * The parser is strict by default, thus time string {@code 24:00} cannot be parsed. + *
+ * It accepts formats described by the following syntax: + *
+ * Year: + * YYYY (eg 1997) + * Year and month: + * YYYY-MM (eg 1997-07) + * Complete date: + * YYYY-MM-DD (eg 1997-07-16) + * Complete date plus hours and minutes: + * YYYY-MM-DDTSPhh:mm (eg 1997-07-16 19:20) + * Complete date plus hours, minutes and seconds: + * YYYY-MM-DDTSPhh:mm:ss (eg 1997-07-16 19:20:30) + * Complete date plus hours, minutes, seconds and a decimal fraction of a second + * YYYY-MM-DDTSPhh:mm:ss.s (eg 1997-07-16T19:20:30.45) + * YYYY-MM-DDTSPhh:mm:ss,s (eg 1997-07-16T19:20:30,45) + * where: + * + * YYYY = four-digit year + * MM = two-digit month (01=January, etc.) + * DD = two-digit day of month (01 through 31) + * hh = two digits of hour (00 through 23) (am/pm NOT allowed) + * mm = two digits of minute (00 through 59) + * ss = two digits of second (00 through 59) + * s = one or more(max 9) digits representing a decimal fraction of a second + * TSP = date time seperator (T or " ") + *+ */ +final class LocalDateTimeFormatter extends OpenSearchDateTimeFormatter { + + private ZoneId zone; + + public LocalDateTimeFormatter(String pattern) { + super(pattern); + } + + public LocalDateTimeFormatter(java.time.format.DateTimeFormatter formatter) { + super(formatter); + } + + public LocalDateTimeFormatter(java.time.format.DateTimeFormatter formatter, ZoneId zone) { + super(formatter); + this.zone = zone; + } + + @Override + public OpenSearchDateTimeFormatter withZone(ZoneId zoneId) { + return new LocalDateTimeFormatter(getFormatter().withZone(zoneId), zoneId); + } + + @Override + public OpenSearchDateTimeFormatter withLocale(Locale locale) { + return new LocalDateTimeFormatter(getFormatter().withLocale(locale)); + } + + @Override + public Object parseObject(String text, ParsePosition pos) { + try { + return parse(text); + } catch (DateTimeException e) { + return null; + } + } + + @Override + public TemporalAccessor parse(final String dateTime) { + LocalDateTime parsedDatetime = parse(dateTime, new ParsePosition(0)).toLocalDatetime(); + return zone == null ? parsedDatetime : parsedDatetime.atZone(zone); + } + + public DateTime parse(String date, ParsePosition pos) { + if (date == null) { + throw new NullPointerException("date cannot be null"); + } + + final int len = date.length() - pos.getIndex(); + if (len <= 0) { + throw new DateTimeParseException("out of bound parse position", date, pos.getIndex()); + } + final char[] chars = date.substring(pos.getIndex()).toCharArray(); + + // Date portion + + // YEAR + final int years = getYear(chars, pos); + if (4 == len) { + return DateTime.ofYear(years); + } + + // MONTH + DateUtils.consumeChar(chars, pos, DateUtils.DATE_SEPARATOR); + final int months = getMonth(chars, pos); + if (7 == len) { + return DateTime.ofYearMonth(years, months); + } + + // DAY + DateUtils.consumeChar(chars, pos, DateUtils.DATE_SEPARATOR); + final int days = getDay(chars, pos); + if (10 == len) { + return DateTime.ofDate(years, months, days); + } + + // HOURS + DateUtils.consumeChar(chars, pos, DateUtils.SEPARATOR_UPPER, DateUtils.SEPARATOR_LOWER, DateUtils.SEPARATOR_SPACE); + final int hours = getHour(chars, pos); + + // MINUTES + DateUtils.consumeChar(chars, pos, DateUtils.TIME_SEPARATOR); + final int minutes = getMinute(chars, pos); + if (16 == len) { + return DateTime.of(years, months, days, hours, minutes, null); + } + + // SECONDS or TIMEZONE + return handleTime(chars, pos, years, months, days, hours, minutes); + } + + private static int getHour(final char[] chars, ParsePosition pos) { + return DateUtils.readInt(chars, pos, 2); + } + + private static int getMinute(final char[] chars, ParsePosition pos) { + return DateUtils.readInt(chars, pos, 2); + } + + private static int getDay(final char[] chars, ParsePosition pos) { + return DateUtils.readInt(chars, pos, 2); + } + + private static DateTime handleTime(char[] chars, ParsePosition pos, int year, int month, int day, int hour, int minute) { + DateUtils.consumeChar(chars, pos, DateUtils.TIME_SEPARATOR); + return handleSeconds(year, month, day, hour, minute, chars, pos); + } + + private static int getMonth(final char[] chars, ParsePosition pos) { + return DateUtils.readInt(chars, pos, 2); + } + + private static int getYear(final char[] chars, ParsePosition pos) { + return DateUtils.readInt(chars, pos, 4); + } + + private static int getSeconds(final char[] chars, ParsePosition pos) { + return DateUtils.readInt(chars, pos, 2); + } + + private static int getFractions(final char[] chars, final ParsePosition pos, final int len) { + final int fractions; + fractions = DateUtils.readIntUnchecked(chars, pos, len); + switch (len) { + case 0: + throw new DateTimeParseException("Must have at least 1 fraction digit", new String(chars), pos.getIndex()); + case 1: + return fractions * 100_000_000; + case 2: + return fractions * 10_000_000; + case 3: + return fractions * 1_000_000; + case 4: + return fractions * 100_000; + case 5: + return fractions * 10_000; + case 6: + return fractions * 1_000; + case 7: + return fractions * 100; + case 8: + return fractions * 10; + default: + return fractions; + } + } + + private static DateTime handleSeconds(int year, int month, int day, int hour, int minute, char[] chars, ParsePosition pos) { + // From here the specification is more lenient + final int seconds = getSeconds(chars, pos); + int currPos = pos.getIndex(); + final int remaining = chars.length - currPos; + if (remaining == 0) { + return DateTime.of(year, month, day, hour, minute, seconds, 0, null, 0); + } + + int fractions = 0; + int fractionDigits = 0; + if (remaining >= 1 && DateUtils.checkPositionContains(chars, pos, DateUtils.FRACTION_SEPARATOR_1, DateUtils.FRACTION_SEPARATOR_2)) { + // We have fractional seconds; + DateUtils.consumeNextChar(chars, pos); + ParsePosition initPosition = new ParsePosition(pos.getIndex()); + DateUtils.consumeDigits(chars, pos); + // We have an end of fractions + final int len = pos.getIndex() - initPosition.getIndex(); + fractions = getFractions(chars, initPosition, len); + fractionDigits = len; + DateUtils.assertNoMoreChars(chars, pos); + } else { + throw new DateTimeParseException("Unexpected character at position " + (pos.getIndex()), new String(chars), pos.getIndex()); + } + + return fractionDigits > 0 + ? DateTime.of(year, month, day, hour, minute, seconds, fractions, null, fractionDigits) + : DateTime.of(year, month, day, hour, minute, seconds, null); + } +} diff --git a/server/src/main/java/org/opensearch/common/time/RFC3339DateTimeFormatter.java b/server/src/main/java/org/opensearch/common/time/RFC3339DateTimeFormatter.java index 62b4ce805e4d5..675313a0f78ce 100644 --- a/server/src/main/java/org/opensearch/common/time/RFC3339DateTimeFormatter.java +++ b/server/src/main/java/org/opensearch/common/time/RFC3339DateTimeFormatter.java @@ -15,7 +15,6 @@ import java.time.ZoneOffset; import java.time.format.DateTimeParseException; import java.time.temporal.TemporalAccessor; -import java.util.Arrays; import java.util.Locale; /** @@ -54,17 +53,6 @@ * */ final class RFC3339DateTimeFormatter extends OpenSearchDateTimeFormatter { - public static final char DATE_SEPARATOR = '-'; - public static final char TIME_SEPARATOR = ':'; - public static final char SEPARATOR_UPPER = 'T'; - private static final char PLUS = '+'; - private static final char MINUS = '-'; - private static final char SEPARATOR_LOWER = 't'; - private static final char SEPARATOR_SPACE = ' '; - private static final char FRACTION_SEPARATOR_1 = '.'; - private static final char FRACTION_SEPARATOR_2 = ','; - private static final char ZULU_UPPER = 'Z'; - private static final char ZULU_LOWER = 'z'; private ZoneId zone; @@ -126,25 +114,25 @@ public DateTime parse(String date, ParsePosition pos) { } // MONTH - consumeChar(chars, pos, DATE_SEPARATOR); + DateUtils.consumeChar(chars, pos, DateUtils.DATE_SEPARATOR); final int months = getMonth(chars, pos); if (7 == len) { return DateTime.ofYearMonth(years, months); } // DAY - consumeChar(chars, pos, DATE_SEPARATOR); + DateUtils.consumeChar(chars, pos, DateUtils.DATE_SEPARATOR); final int days = getDay(chars, pos); if (10 == len) { return DateTime.ofDate(years, months, days); } // HOURS - consumeChar(chars, pos, SEPARATOR_UPPER, SEPARATOR_LOWER, SEPARATOR_SPACE); + DateUtils.consumeChar(chars, pos, DateUtils.SEPARATOR_UPPER, DateUtils.SEPARATOR_LOWER, DateUtils.SEPARATOR_SPACE); final int hours = getHour(chars, pos); // MINUTES - consumeChar(chars, pos, TIME_SEPARATOR); + DateUtils.consumeChar(chars, pos, DateUtils.TIME_SEPARATOR); final int minutes = getMinute(chars, pos); if (16 == len) { return DateTime.of(years, months, days, hours, minutes, null); @@ -154,137 +142,24 @@ public DateTime parse(String date, ParsePosition pos) { return handleTime(chars, pos, years, months, days, hours, minutes); } - private static boolean isDigit(char c) { - return (c >= '0' && c <= '9'); - } - - private static int digit(char c) { - return c - '0'; - } - - private static int readInt(final char[] strNum, ParsePosition pos, int n) { - int start = pos.getIndex(), end = start + n; - if (end > strNum.length) { - pos.setErrorIndex(end); - throw new DateTimeParseException("Unexpected end of expression at position " + strNum.length, new String(strNum), end); - } - - int result = 0; - for (int i = start; i < end; i++) { - final char c = strNum[i]; - if (isDigit(c) == false) { - pos.setErrorIndex(i); - throw new DateTimeParseException("Character " + c + " is not a digit", new String(strNum), i); - } - int digit = digit(c); - result = result * 10 + digit; - } - pos.setIndex(end); - return result; - } - - private static int readIntUnchecked(final char[] strNum, ParsePosition pos, int n) { - int start = pos.getIndex(), end = start + n; - int result = 0; - for (int i = start; i < end; i++) { - final char c = strNum[i]; - int digit = digit(c); - result = result * 10 + digit; - } - pos.setIndex(end); - return result; - } - private static int getHour(final char[] chars, ParsePosition pos) { - return readInt(chars, pos, 2); + return DateUtils.readInt(chars, pos, 2); } private static int getMinute(final char[] chars, ParsePosition pos) { - return readInt(chars, pos, 2); + return DateUtils.readInt(chars, pos, 2); } private static int getDay(final char[] chars, ParsePosition pos) { - return readInt(chars, pos, 2); - } - - private static boolean isValidOffset(char[] chars, int offset) { - if (offset >= chars.length) { - return false; - } - return true; - } - - private static void consumeChar(char[] chars, ParsePosition pos, char expected) { - int offset = pos.getIndex(); - if (isValidOffset(chars, offset) == false) { - throw new DateTimeParseException("Unexpected end of input", new String(chars), offset); - } - - if (chars[offset] != expected) { - throw new DateTimeParseException("Expected character " + expected + " at position " + offset, new String(chars), offset); - } - pos.setIndex(offset + 1); - } - - private static void consumeNextChar(char[] chars, ParsePosition pos) { - int offset = pos.getIndex(); - if (isValidOffset(chars, offset) == false) { - throw new DateTimeParseException("Unexpected end of input", new String(chars), offset); - } - pos.setIndex(offset + 1); - } - - private static boolean checkPositionContains(char[] chars, ParsePosition pos, char... expected) { - int offset = pos.getIndex(); - if (offset >= chars.length) { - throw new DateTimeParseException("Unexpected end of input", new String(chars), offset); - } - - boolean found = false; - for (char e : expected) { - if (chars[offset] == e) { - found = true; - break; - } - } - return found; - } - - private static void consumeChar(char[] chars, ParsePosition pos, char... expected) { - int offset = pos.getIndex(); - if (offset >= chars.length) { - throw new DateTimeParseException("Unexpected end of input", new String(chars), offset); - } - - boolean found = false; - for (char e : expected) { - if (chars[offset] == e) { - found = true; - pos.setIndex(offset + 1); - break; - } - } - if (!found) { - throw new DateTimeParseException( - "Expected character " + Arrays.toString(expected) + " at position " + offset, - new String(chars), - offset - ); - } - } - - private static void assertNoMoreChars(char[] chars, ParsePosition pos) { - if (chars.length > pos.getIndex()) { - throw new DateTimeParseException("Trailing junk data after position " + pos.getIndex(), new String(chars), pos.getIndex()); - } + return DateUtils.readInt(chars, pos, 2); } private static ZoneOffset parseTimezone(char[] chars, ParsePosition pos) { int offset = pos.getIndex(); final int left = chars.length - offset; - if (checkPositionContains(chars, pos, ZULU_LOWER, ZULU_UPPER)) { - consumeNextChar(chars, pos); - assertNoMoreChars(chars, pos); + if (DateUtils.checkPositionContains(chars, pos, DateUtils.ZULU_LOWER, DateUtils.ZULU_UPPER)) { + DateUtils.consumeNextChar(chars, pos); + DateUtils.assertNoMoreChars(chars, pos); return ZoneOffset.UTC; } @@ -293,17 +168,17 @@ private static ZoneOffset parseTimezone(char[] chars, ParsePosition pos) { } final char sign = chars[offset]; - consumeNextChar(chars, pos); + DateUtils.consumeNextChar(chars, pos); int hours = getHour(chars, pos); - consumeChar(chars, pos, TIME_SEPARATOR); + DateUtils.consumeChar(chars, pos, DateUtils.TIME_SEPARATOR); int minutes = getMinute(chars, pos); - if (sign == MINUS) { + if (sign == DateUtils.MINUS) { if (hours == 0 && minutes == 0) { throw new DateTimeParseException("Unknown 'Local Offset Convention' date-time not allowed", new String(chars), offset); } hours = -hours; minutes = -minutes; - } else if (sign != PLUS) { + } else if (sign != DateUtils.PLUS) { throw new DateTimeParseException("Invalid character starting at position " + offset, new String(chars), offset); } @@ -312,14 +187,14 @@ private static ZoneOffset parseTimezone(char[] chars, ParsePosition pos) { private static DateTime handleTime(char[] chars, ParsePosition pos, int year, int month, int day, int hour, int minute) { switch (chars[pos.getIndex()]) { - case TIME_SEPARATOR: - consumeChar(chars, pos, TIME_SEPARATOR); + case DateUtils.TIME_SEPARATOR: + DateUtils.consumeChar(chars, pos, DateUtils.TIME_SEPARATOR); return handleSeconds(year, month, day, hour, minute, chars, pos); - case PLUS: - case MINUS: - case ZULU_UPPER: - case ZULU_LOWER: + case DateUtils.PLUS: + case DateUtils.MINUS: + case DateUtils.ZULU_UPPER: + case DateUtils.ZULU_LOWER: final ZoneOffset zoneOffset = parseTimezone(chars, pos); return DateTime.of(year, month, day, hour, minute, zoneOffset); } @@ -327,20 +202,20 @@ private static DateTime handleTime(char[] chars, ParsePosition pos, int year, in } private static int getMonth(final char[] chars, ParsePosition pos) { - return readInt(chars, pos, 2); + return DateUtils.readInt(chars, pos, 2); } private static int getYear(final char[] chars, ParsePosition pos) { - return readInt(chars, pos, 4); + return DateUtils.readInt(chars, pos, 4); } private static int getSeconds(final char[] chars, ParsePosition pos) { - return readInt(chars, pos, 2); + return DateUtils.readInt(chars, pos, 2); } private static int getFractions(final char[] chars, final ParsePosition pos, final int len) { final int fractions; - fractions = readIntUnchecked(chars, pos, len); + fractions = DateUtils.readIntUnchecked(chars, pos, len); switch (len) { case 0: throw new DateTimeParseException("Must have at least 1 fraction digit", new String(chars), pos.getIndex()); @@ -365,24 +240,6 @@ private static int getFractions(final char[] chars, final ParsePosition pos, fin } } - public static int indexOfNonDigit(final char[] text, int offset) { - for (int i = offset; i < text.length; i++) { - if (isDigit(text[i]) == false) { - return i; - } - } - return -1; - } - - public static void consumeDigits(final char[] text, ParsePosition pos) { - final int idx = indexOfNonDigit(text, pos.getIndex()); - if (idx == -1) { - pos.setErrorIndex(text.length); - } else { - pos.setIndex(idx); - } - } - private static DateTime handleSeconds(int year, int month, int day, int hour, int minute, char[] chars, ParsePosition pos) { // From here the specification is more lenient final int seconds = getSeconds(chars, pos); @@ -395,16 +252,16 @@ private static DateTime handleSeconds(int year, int month, int day, int hour, in ZoneOffset offset = null; int fractions = 0; int fractionDigits = 0; - if (remaining == 1 && checkPositionContains(chars, pos, ZULU_LOWER, ZULU_UPPER)) { - consumeNextChar(chars, pos); + if (remaining == 1 && DateUtils.checkPositionContains(chars, pos, DateUtils.ZULU_LOWER, DateUtils.ZULU_UPPER)) { + DateUtils.consumeNextChar(chars, pos); // Do nothing we are done offset = ZoneOffset.UTC; - assertNoMoreChars(chars, pos); - } else if (remaining >= 1 && checkPositionContains(chars, pos, FRACTION_SEPARATOR_1, FRACTION_SEPARATOR_2)) { + DateUtils.assertNoMoreChars(chars, pos); + } else if (remaining >= 1 && DateUtils.checkPositionContains(chars, pos, DateUtils.FRACTION_SEPARATOR_1, DateUtils.FRACTION_SEPARATOR_2)) { // We have fractional seconds; - consumeNextChar(chars, pos); + DateUtils.consumeNextChar(chars, pos); ParsePosition initPosition = new ParsePosition(pos.getIndex()); - consumeDigits(chars, pos); + DateUtils.consumeDigits(chars, pos); if (pos.getErrorIndex() == -1) { // We have an end of fractions final int len = pos.getIndex() - initPosition.getIndex(); @@ -414,7 +271,7 @@ private static DateTime handleSeconds(int year, int month, int day, int hour, in } else { throw new DateTimeParseException("No timezone information", new String(chars), pos.getIndex()); } - } else if (remaining >= 1 && checkPositionContains(chars, pos, PLUS, MINUS)) { + } else if (remaining >= 1 && DateUtils.checkPositionContains(chars, pos, DateUtils.PLUS, DateUtils.MINUS)) { // No fractional sections offset = parseTimezone(chars, pos); } else { diff --git a/server/src/test/java/org/opensearch/common/time/DateFormattersTests.java b/server/src/test/java/org/opensearch/common/time/DateFormattersTests.java index 85c3e78cc8c45..549f97b90ef3a 100644 --- a/server/src/test/java/org/opensearch/common/time/DateFormattersTests.java +++ b/server/src/test/java/org/opensearch/common/time/DateFormattersTests.java @@ -601,6 +601,62 @@ public void testRFC3339ParserAgainstDifferentFormatters() { } } + public void testLocalDateTimeParsing() { + DateFormatter formatter = DateFormatters.forPattern("local_date_time"); + + // timezone not allowed with just date + formatter.format(formatter.parse("2018")); + formatter.format(formatter.parse("2018-05")); + formatter.format(formatter.parse("2018-05-15")); + + formatter.format(formatter.parse("2018-05-15T17:14")); + formatter.format(formatter.parse("2018-05-15 17:14")); + formatter.format(formatter.parse("2018-05-15T17:14")); + formatter.format(formatter.parse("2018-05-15 17:14")); + + formatter.format(formatter.parse("2018-05-15T17:14:56")); + formatter.format(formatter.parse("2018-05-15 17:14:56")); + + // milliseconds can be separated using comma or decimal point + formatter.format(formatter.parse("2018-05-15 17:14:56.123")); + formatter.format(formatter.parse("2018-05-15T17:14:56.123")); + formatter.format(formatter.parse("2018-05-15 17:14:56,123")); + formatter.format(formatter.parse("2018-05-15T17:14:56,123")); + + // microseconds can be separated using comma or decimal point + formatter.format(formatter.parse("2018-05-15T17:14:56.123456")); + formatter.format(formatter.parse("2018-05-15 17:14:56.123456")); + formatter.format(formatter.parse("2018-05-15T17:14:56,123456")); + formatter.format(formatter.parse("2018-05-15 17:14:56,123456")); + + // nanoseconds can be separated using comma or decimal point + formatter.format(formatter.parse("2018-05-15T17:14:56.123456789")); + formatter.format(formatter.parse("2018-05-15 17:14:56.123456789")); + formatter.format(formatter.parse("2018-05-15T17:14:56,123456789")); + formatter.format(formatter.parse("2018-05-15 17:14:56,123456789")); + + // Invalid dates should throw an exception + IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> formatter.parse("abc")); + assertThat(e.getMessage(), is("failed to parse date field [abc] with format [local_date_time]")); + // offset not applicable + e = expectThrows(IllegalArgumentException.class, () -> formatter.parse("2017-02-21T15:27:39+01:00")); + assertThat(e.getMessage(), is("failed to parse date field [2017-02-21T15:27:39+01:00] with format [local_date_time]")); + e = expectThrows(IllegalArgumentException.class, () -> formatter.parse("2017-02-21T15:27:39Z")); + assertThat(e.getMessage(), is("failed to parse date field [2017-02-21T15:27:39Z] with format [local_date_time]")); + e = expectThrows(IllegalArgumentException.class, () -> formatter.parse("2017-02-21T15:27:39_00:00")); + assertThat(e.getMessage(), is("failed to parse date field [2017-02-21T15:27:39_00:00] with format [local_date_time]")); + + // Invalid fraction + e = expectThrows(IllegalArgumentException.class, () -> formatter.parse("2017-02-21T15:27:39.abcZ")); + assertThat(e.getMessage(), is("failed to parse date field [2017-02-21T15:27:39.abcZ] with format [local_date_time]")); + // Invalid date + e = expectThrows(IllegalArgumentException.class, () -> formatter.parse("201702-21T15:27:39.123456")); + assertThat(e.getMessage(), is("failed to parse date field [201702-21T15:27:39.123456] with format [local_date_time]")); + // More than 9 digits of nanosecond resolution + e = expectThrows(IllegalArgumentException.class, () -> formatter.parse("2017-02-21T15:00:00.1234567891")); + assertThat(e.getMessage(), is("failed to parse date field [2017-02-21T15:00:00.1234567891] with format [local_date_time]")); + } + public void testRoundupFormatterWithEpochDates() { assertRoundupFormatter("epoch_millis", "1234567890", 1234567890L); // also check nanos of the epoch_millis formatter if it is rounded up to the nano second