From 6c6b49755e8453a5f007b5018d8f1722750e0e6f Mon Sep 17 00:00:00 2001 From: Prabhat Sharma Date: Wed, 22 Nov 2023 15:04:10 +0530 Subject: [PATCH] Added New DateTime parser implementation Signed-off-by: Prabhat Sharma --- CHANGELOG.md | 1 + .../common/time/CustomDateTimeFormatter.java | 109 +++++ .../common/time/DateFormatters.java | 37 ++ .../org/opensearch/common/time/DateTime.java | 225 ++++++++++ .../opensearch/common/time/FormatNames.java | 1 + .../common/time/JavaDateFormatter.java | 85 ++-- .../opensearch/common/time/RFC3339Parser.java | 396 ++++++++++++++++++ .../common/time/DateFormattersTests.java | 42 ++ 8 files changed, 866 insertions(+), 30 deletions(-) create mode 100644 server/src/main/java/org/opensearch/common/time/CustomDateTimeFormatter.java create mode 100644 server/src/main/java/org/opensearch/common/time/DateTime.java create mode 100644 server/src/main/java/org/opensearch/common/time/RFC3339Parser.java diff --git a/CHANGELOG.md b/CHANGELOG.md index b6cfe6dd92dd8..b5b0161877a15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - GHA to verify checklist items completion in PR descriptions ([#10800](https://github.com/opensearch-project/OpenSearch/pull/10800)) - Allow to pass the list settings through environment variables (like [], ["a", "b", "c"], ...) ([#10625](https://github.com/opensearch-project/OpenSearch/pull/10625)) - [Admission Control] Integrate CPU AC with ResourceUsageCollector and add CPU AC stats to nodes/stats ([#10887](https://github.com/opensearch-project/OpenSearch/pull/10887)) +- New DateTime format for RFC3339 compatible date fields ([#11465](https://github.com/opensearch-project/OpenSearch/pull/11465)) ### Dependencies - Bump `log4j-core` from 2.18.0 to 2.19.0 diff --git a/server/src/main/java/org/opensearch/common/time/CustomDateTimeFormatter.java b/server/src/main/java/org/opensearch/common/time/CustomDateTimeFormatter.java new file mode 100644 index 0000000000000..4eeb17336fca0 --- /dev/null +++ b/server/src/main/java/org/opensearch/common/time/CustomDateTimeFormatter.java @@ -0,0 +1,109 @@ +/* + * 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.Format; +import java.text.ParsePosition; +import java.time.ZoneId; +import java.time.chrono.Chronology; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; +import java.time.temporal.TemporalQuery; +import java.util.Locale; + +public class CustomDateTimeFormatter { + private final DateTimeFormatter formatter; + + public CustomDateTimeFormatter(String pattern) { + this.formatter = DateTimeFormatter.ofPattern(pattern, Locale.ROOT); + } + + public CustomDateTimeFormatter(String pattern, Locale locale) { + this.formatter = DateTimeFormatter.ofPattern(pattern, locale); + } + + public CustomDateTimeFormatter(DateTimeFormatter formatter) { + this.formatter = formatter; + } + + public static CustomDateTimeFormatter ofPattern(String pattern) { + return new CustomDateTimeFormatter(pattern); + } + + public static CustomDateTimeFormatter ofPattern(String pattern, Locale locale) { + return new CustomDateTimeFormatter(pattern, locale); + } + + public static final CustomDateTimeFormatter ISO_LOCAL_DATE = new CustomDateTimeFormatter(DateTimeFormatter.ISO_LOCAL_DATE); + public static final CustomDateTimeFormatter ISO_LOCAL_TIME = new CustomDateTimeFormatter(DateTimeFormatter.ISO_LOCAL_TIME); + public static final CustomDateTimeFormatter ISO_LOCAL_DATE_TIME = new CustomDateTimeFormatter(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + public static final CustomDateTimeFormatter ISO_INSTANT = new CustomDateTimeFormatter(DateTimeFormatter.ISO_INSTANT); + public static final CustomDateTimeFormatter ISO_OFFSET_DATE_TIME = new CustomDateTimeFormatter(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + public static final CustomDateTimeFormatter ISO_ZONED_DATE_TIME = new CustomDateTimeFormatter(DateTimeFormatter.ISO_ZONED_DATE_TIME); + public static final CustomDateTimeFormatter ISO_DATE = new CustomDateTimeFormatter(DateTimeFormatter.ISO_DATE); + public static final CustomDateTimeFormatter ISO_TIME = new CustomDateTimeFormatter(DateTimeFormatter.ISO_TIME); + public static final CustomDateTimeFormatter ISO_OFFSET_TIME = new CustomDateTimeFormatter(DateTimeFormatter.ISO_OFFSET_TIME); + public static final CustomDateTimeFormatter ISO_OFFSET_DATE = new CustomDateTimeFormatter(DateTimeFormatter.ISO_OFFSET_DATE); + public static final CustomDateTimeFormatter ISO_ORDINAL_DATE = new CustomDateTimeFormatter(DateTimeFormatter.ISO_ORDINAL_DATE); + public static final CustomDateTimeFormatter ISO_WEEK_DATE = new CustomDateTimeFormatter(DateTimeFormatter.ISO_WEEK_DATE); + public static final CustomDateTimeFormatter ISO_DATE_TIME = new CustomDateTimeFormatter(DateTimeFormatter.ISO_DATE_TIME); + public static final CustomDateTimeFormatter BASIC_ISO_DATE = new CustomDateTimeFormatter(DateTimeFormatter.BASIC_ISO_DATE); + + public CustomDateTimeFormatter withLocale(Locale locale) { + return new CustomDateTimeFormatter(getFormatter().withLocale(locale)); + } + + public CustomDateTimeFormatter withZone(ZoneId zoneId) { + return new CustomDateTimeFormatter(getFormatter().withZone(zoneId)); + } + + public CustomDateTimeFormatter withChronology(Chronology chrono) { + return new CustomDateTimeFormatter(getFormatter().withChronology(chrono)); + } + + public String format(TemporalAccessor temporal) { + return this.getFormatter().format(temporal); + } + + public TemporalAccessor parse(CharSequence text, ParsePosition position) { + return this.getFormatter().parse(text, position); + } + + public TemporalAccessor parse(CharSequence text) { + return this.getFormatter().parse(text); + } + + public T parse(CharSequence text, TemporalQuery query) { + return this.getFormatter().parse(text, query); + } + + public ZoneId getZone() { + return this.getFormatter().getZone(); + } + + public Locale getLocale() { + return this.getFormatter().getLocale(); + } + + public TemporalAccessor parse(String input) { + return formatter.parse(input); + } + + public DateTimeFormatter getFormatter() { + return formatter; + } + + public Format toFormat() { + return getFormatter().toFormat(); + } + + public Object parseObject(String text, ParsePosition pos) { + return getFormatter().toFormat().parseObject(text, pos); + } +} 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 e74ab687b903b..72e0b03e13305 100644 --- a/server/src/main/java/org/opensearch/common/time/DateFormatters.java +++ b/server/src/main/java/org/opensearch/common/time/DateFormatters.java @@ -1299,6 +1299,41 @@ public class DateFormatters { .withResolverStyle(ResolverStyle.STRICT) ); + /** + * Returns RFC 3339 a popular ISO 8601 profile compatible date time formatter and parser. + * This is not fully compatible to the existing spec, its more linient and closely follows w3c note on datetime + */ + + public static final DateFormatter RFC3339_DATE_FORMATTER = new JavaDateFormatter( + "rfc3339_date_time", + new CustomDateTimeFormatter(STRICT_DATE_OPTIONAL_TIME_PRINTER), + new RFC3339Parser( + new DateTimeFormatterBuilder().append(DATE_FORMATTER) + .optionalStart() + .appendLiteral('T') + .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() + .optionalStart() + .appendOffsetId() + .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) @@ -2152,6 +2187,8 @@ static DateFormatter forPattern(String input) { return STRICT_YEAR_MONTH; } else if (FormatNames.STRICT_YEAR_MONTH_DAY.matches(input)) { return STRICT_YEAR_MONTH_DAY; + } else if (FormatNames.RFC3339_DATE_TIME.matches(input)) { + return RFC3339_DATE_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 new file mode 100644 index 0000000000000..a8420b0f4a4c4 --- /dev/null +++ b/server/src/main/java/org/opensearch/common/time/DateTime.java @@ -0,0 +1,225 @@ +/* + * 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.time.DateTimeException; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.Year; +import java.time.ZoneOffset; +import java.time.temporal.ChronoField; +import java.util.Objects; +import java.util.Optional; + +/** + * Container class for parsed date/date-time data. + */ +public class DateTime { + private final int year; + private final int month; + private final int day; + private final int hour; + private final int minute; + private final int second; + private final int nano; + private final ZoneOffset offset; + private final int fractionDigits; + + public DateTime( + final int year, + final int month, + final int day, + final int hour, + final int minute, + final int second, + final int nano, + final ZoneOffset offset, + final int fractionDigits + ) { + this.year = year; + this.month = assertSize(month, 1, 12, ChronoField.MONTH_OF_YEAR); + this.day = assertSize(day, 1, 31, ChronoField.DAY_OF_MONTH); + this.hour = assertSize(hour, 0, 23, ChronoField.HOUR_OF_DAY); + this.minute = assertSize(minute, 0, 59, ChronoField.MINUTE_OF_HOUR); + this.second = assertSize(second, 0, 60, ChronoField.SECOND_OF_MINUTE); + this.nano = assertSize(nano, 0, 999_999_999, ChronoField.NANO_OF_SECOND); + this.offset = offset; + this.fractionDigits = fractionDigits; + } + + /** + * Create a new instance with second granularity from the input parameters + */ + public static DateTime of(int year, int month, int day, int hour, int minute, int second, ZoneOffset offset) { + return new DateTime(year, month, day, hour, minute, second, 0, offset, 0); + } + + /** + * Create a new instance with nanosecond granularity from the input parameters + */ + public static DateTime of( + int year, + int month, + int day, + int hour, + int minute, + int second, + int nanos, + ZoneOffset offset, + final int fractionDigits + ) { + return new DateTime(year, month, day, hour, minute, second, nanos, offset, fractionDigits); + } + + /** + * Create a new instance with year granularity from the input parameters + */ + public static DateTime ofYear(int year) { + return new DateTime(year, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC, 0); + } + + /** + * Create a new instance with year-month granularity from the input parameters + */ + public static DateTime ofYearMonth(int years, int months) { + return new DateTime(years, months, 1, 0, 0, 0, 0, ZoneOffset.UTC, 0); + } + + /** + * Create a new instance with day granularity from the input parameters + */ + public static DateTime ofDate(int years, int months, int days) { + return new DateTime(years, months, days, 0, 0, 0, 0, ZoneOffset.UTC, 0); + } + + /** + * Create a new instance with minute granularity from the input parameters + */ + public static DateTime of(int years, int months, int days, int hours, int minute, ZoneOffset offset) { + return new DateTime(years, months, days, hours, minute, 0, 0, offset, 0); + } + + public static DateTime of(int years, int months, int days, int hours, int minute) { + return new DateTime(years, months, days, hours, minute, 0, 0, ZoneOffset.UTC, 0); + } + + private int assertSize(int value, int min, int max, ChronoField field) { + if (value > max) { + throw new DateTimeException("Field " + field.name() + " out of bounds. Expected " + min + "-" + max + ", got " + value); + } + return value; + } + + public int getYear() { + return year; + } + + public int getMonth() { + return month; + } + + public int getDayOfMonth() { + return day; + } + + public int getHour() { + return hour; + } + + public int getMinute() { + return minute; + } + + public int getSecond() { + return second; + } + + public int getNano() { + return nano; + } + + /** + * Returns the time offset, if available + * + * @return the time offset, if available + */ + public Optional getOffset() { + return Optional.ofNullable(offset); + } + + /** + * Creates a {@link Year} discarding any higher granularity fields + * + * @return the {@link Year} + */ + public Year toYear() { + return Year.of(year); + } + + /** + * Creates an {@link OffsetDateTime} + * + * @return the {@link OffsetDateTime} + */ + public OffsetDateTime toOffsetDatetime() { + if (offset != null) { + return OffsetDateTime.of(year, month, day, hour, minute, second, nano, offset); + } + throw new DateTimeException("No zone offset information found"); + } + + /** + * Creates a {@link LocalDate}, discarding any higher granularity fields + * + * @return the {@link LocalDate} + */ + public LocalDate toLocalDate() { + return LocalDate.of(year, month, day); + } + + /** + * Return the number of significant fraction digits in the second. + * + * @return The number of significant fraction digits + */ + public int getFractionDigits() { + return fractionDigits; + } + + /** + * * @hidden + */ + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + DateTime dateTime = (DateTime) o; + return year == dateTime.year + && month == dateTime.month + && day == dateTime.day + && hour == dateTime.hour + && minute == dateTime.minute + && second == dateTime.second + && nano == dateTime.nano + && fractionDigits == dateTime.fractionDigits + && Objects.equals(offset, dateTime.offset); + } + + /** + * @hidden + */ + @Override + public int hashCode() { + return Objects.hash(year, month, day, hour, minute, second, nano, offset, fractionDigits); + } +} 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 ba0a8fcf4a17a..2df75bb27c97f 100644 --- a/server/src/main/java/org/opensearch/common/time/FormatNames.java +++ b/server/src/main/java/org/opensearch/common/time/FormatNames.java @@ -44,6 +44,7 @@ */ public enum FormatNames { ISO8601(null, "iso8601"), + RFC3339_DATE_TIME(null, "rfc3339"), 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/JavaDateFormatter.java b/server/src/main/java/org/opensearch/common/time/JavaDateFormatter.java index f711b14aeb928..072e8c8fd643c 100644 --- a/server/src/main/java/org/opensearch/common/time/JavaDateFormatter.java +++ b/server/src/main/java/org/opensearch/common/time/JavaDateFormatter.java @@ -52,7 +52,6 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; -import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.BiConsumer; import java.util.stream.Collectors; @@ -70,11 +69,11 @@ class JavaDateFormatter implements DateFormatter { private final String format; private final String printFormat; - private final DateTimeFormatter printer; - private final List parsers; + private final CustomDateTimeFormatter printer; + private final List parsers; private final JavaDateFormatter roundupParser; private final Boolean canCacheLastParsedFormatter; - private volatile DateTimeFormatter lastParsedformatter = null; + private volatile CustomDateTimeFormatter lastParsedformatter = null; /** * A round up formatter @@ -83,11 +82,11 @@ class JavaDateFormatter implements DateFormatter { */ static class RoundUpFormatter extends JavaDateFormatter { - RoundUpFormatter(String format, List roundUpParsers) { + RoundUpFormatter(String format, List roundUpParsers) { super(format, firstFrom(roundUpParsers), null, roundUpParsers); } - private static DateTimeFormatter firstFrom(List roundUpParsers) { + private static CustomDateTimeFormatter firstFrom(List roundUpParsers) { return roundUpParsers.get(0); } @@ -101,14 +100,18 @@ JavaDateFormatter getRoundupParser() { JavaDateFormatter( String format, String printFormat, - DateTimeFormatter printer, + CustomDateTimeFormatter printer, Boolean canCacheLastParsedFormatter, - DateTimeFormatter... parsers + CustomDateTimeFormatter... parsers ) { this(format, printFormat, printer, ROUND_UP_BASE_FIELDS, canCacheLastParsedFormatter, parsers); } JavaDateFormatter(String format, DateTimeFormatter printer, DateTimeFormatter... parsers) { + this(format, format, wrapFormatter(printer), false, wrapAllFormatters(parsers)); + } + + JavaDateFormatter(String format, CustomDateTimeFormatter printer, CustomDateTimeFormatter... parsers) { this(format, format, printer, false, parsers); } @@ -127,19 +130,19 @@ JavaDateFormatter getRoundupParser() { JavaDateFormatter( String format, String printFormat, - DateTimeFormatter printer, + CustomDateTimeFormatter printer, BiConsumer roundupParserConsumer, Boolean canCacheLastParsedFormatter, - DateTimeFormatter... parsers + CustomDateTimeFormatter... parsers ) { if (printer == null) { throw new IllegalArgumentException("printer may not be null"); } - long distinctZones = Arrays.stream(parsers).map(DateTimeFormatter::getZone).distinct().count(); + long distinctZones = Arrays.stream(parsers).map(CustomDateTimeFormatter::getZone).distinct().count(); if (distinctZones > 1) { throw new IllegalArgumentException("formatters must have the same time zone"); } - long distinctLocales = Arrays.stream(parsers).map(DateTimeFormatter::getLocale).distinct().count(); + long distinctLocales = Arrays.stream(parsers).map(CustomDateTimeFormatter::getLocale).distinct().count(); if (distinctLocales > 1) { throw new IllegalArgumentException("formatters must have the same locale"); } @@ -154,7 +157,7 @@ JavaDateFormatter getRoundupParser() { this.parsers = Arrays.asList(parsers); } List roundUp = createRoundUpParser(format, roundupParserConsumer); - this.roundupParser = new RoundUpFormatter(format, roundUp); + this.roundupParser = new RoundUpFormatter(format, wrapAllFormatters(roundUp)); } JavaDateFormatter( @@ -163,7 +166,7 @@ JavaDateFormatter getRoundupParser() { BiConsumer roundupParserConsumer, DateTimeFormatter... parsers ) { - this(format, format, printer, roundupParserConsumer, false, parsers); + this(format, format, wrapFormatter(printer), roundupParserConsumer, false, wrapAllFormatters(parsers)); } /** @@ -181,7 +184,8 @@ private List createRoundUpParser( ) { if (format.contains("||") == false) { List roundUpParsers = new ArrayList<>(); - for (DateTimeFormatter parser : this.parsers) { + for (CustomDateTimeFormatter customparser : this.parsers) { + DateTimeFormatter parser = customparser.getFormatter(); DateTimeFormatterBuilder builder = new DateTimeFormatterBuilder(); builder.append(parser); roundupParserConsumer.accept(builder, parser); @@ -201,12 +205,12 @@ public static DateFormatter combined( assert formatters.size() > 0; assert printFormatter != null; - List parsers = new ArrayList<>(formatters.size()); - List roundUpParsers = new ArrayList<>(formatters.size()); + List parsers = new ArrayList<>(formatters.size()); + List roundUpParsers = new ArrayList<>(formatters.size()); assert printFormatter instanceof JavaDateFormatter; JavaDateFormatter javaPrintFormatter = (JavaDateFormatter) printFormatter; - DateTimeFormatter printer = javaPrintFormatter.getPrinter(); + CustomDateTimeFormatter printer = javaPrintFormatter.getPrinter(); for (DateFormatter formatter : formatters) { assert formatter instanceof JavaDateFormatter; JavaDateFormatter javaDateFormatter = (JavaDateFormatter) formatter; @@ -227,9 +231,9 @@ public static DateFormatter combined( private JavaDateFormatter( String format, String printFormat, - DateTimeFormatter printer, - List roundUpParsers, - List parsers, + CustomDateTimeFormatter printer, + List roundUpParsers, + List parsers, Boolean canCacheLastParsedFormatter ) { this.format = format; @@ -245,6 +249,15 @@ private JavaDateFormatter( DateTimeFormatter printer, List roundUpParsers, List parsers + ) { + this(format, format, wrapFormatter(printer), wrapAllFormatters(roundUpParsers), wrapAllFormatters(parsers), false); + } + + private JavaDateFormatter( + String format, + CustomDateTimeFormatter printer, + List roundUpParsers, + List parsers ) { this(format, format, printer, roundUpParsers, parsers, false); } @@ -253,7 +266,7 @@ JavaDateFormatter getRoundupParser() { return roundupParser; } - DateTimeFormatter getPrinter() { + CustomDateTimeFormatter getPrinter() { return printer; } @@ -289,14 +302,14 @@ private TemporalAccessor doParse(String input) { Object object = null; if (canCacheLastParsedFormatter && lastParsedformatter != null) { ParsePosition pos = new ParsePosition(0); - object = lastParsedformatter.toFormat().parseObject(input, pos); + object = lastParsedformatter.parseObject(input, pos); if (parsingSucceeded(object, input, pos)) { return (TemporalAccessor) object; } } - for (DateTimeFormatter formatter : parsers) { + for (CustomDateTimeFormatter formatter : parsers) { ParsePosition pos = new ParsePosition(0); - object = formatter.toFormat().parseObject(input, pos); + object = formatter.parseObject(input, pos); if (parsingSucceeded(object, input, pos)) { lastParsedformatter = formatter; return (TemporalAccessor) object; @@ -312,16 +325,28 @@ private boolean parsingSucceeded(Object object, String input, ParsePosition pos) return object != null && pos.getIndex() == input.length(); } + private static CustomDateTimeFormatter wrapFormatter(DateTimeFormatter formatter) { + return new CustomDateTimeFormatter(formatter); + } + + private static CustomDateTimeFormatter[] wrapAllFormatters(DateTimeFormatter... formatters) { + return Arrays.stream(formatters).map(JavaDateFormatter::wrapFormatter).toArray(CustomDateTimeFormatter[]::new); + } + + private static List wrapAllFormatters(List formatters) { + return formatters.stream().map(JavaDateFormatter::wrapFormatter).collect(Collectors.toList()); + } + @Override public DateFormatter withZone(ZoneId zoneId) { // shortcurt to not create new objects unnecessarily if (zoneId.equals(zone())) { return this; } - List parsers = new CopyOnWriteArrayList<>( + List parsers = new ArrayList<>( this.parsers.stream().map(p -> p.withZone(zoneId)).collect(Collectors.toList()) ); - List roundUpParsers = this.roundupParser.getParsers() + List roundUpParsers = this.roundupParser.getParsers() .stream() .map(p -> p.withZone(zoneId)) .collect(Collectors.toList()); @@ -334,10 +359,10 @@ public DateFormatter withLocale(Locale locale) { if (locale.equals(locale())) { return this; } - List parsers = new CopyOnWriteArrayList<>( + List parsers = new ArrayList<>( this.parsers.stream().map(p -> p.withLocale(locale)).collect(Collectors.toList()) ); - List roundUpParsers = this.roundupParser.getParsers() + List roundUpParsers = this.roundupParser.getParsers() .stream() .map(p -> p.withLocale(locale)) .collect(Collectors.toList()); @@ -396,7 +421,7 @@ public String toString() { return String.format(Locale.ROOT, "format[%s] locale[%s]", format, locale()); } - Collection getParsers() { + Collection getParsers() { return parsers; } } diff --git a/server/src/main/java/org/opensearch/common/time/RFC3339Parser.java b/server/src/main/java/org/opensearch/common/time/RFC3339Parser.java new file mode 100644 index 0000000000000..793d167fcb87c --- /dev/null +++ b/server/src/main/java/org/opensearch/common/time/RFC3339Parser.java @@ -0,0 +1,396 @@ +/* + * 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.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; +import java.util.Arrays; +import java.util.Locale; + +public final class RFC3339Parser extends CustomDateTimeFormatter { + 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; + + public RFC3339Parser(String pattern) { + super(pattern); + } + + public RFC3339Parser(String pattern, ZoneId zone) { + super(pattern); + this.zone = zone; + } + + public RFC3339Parser(DateTimeFormatter formatter) { + super(formatter); + } + + public RFC3339Parser(DateTimeFormatter formatter, ZoneId zone) { + super(formatter); + this.zone = zone; + } + + public static RFC3339Parser ofPattern(String pattern) { + return new RFC3339Parser(pattern); + } + + @Override + public CustomDateTimeFormatter withZone(ZoneId zoneId) { + return new RFC3339Parser(getFormatter().withZone(zoneId), zoneId); + } + + @Override + public CustomDateTimeFormatter withLocale(Locale locale) { + return new RFC3339Parser(getFormatter().withLocale(locale)); + } + + @Override + public Object parseObject(String text, ParsePosition pos) { + return parse(text, pos); + } + + @Override + public TemporalAccessor parse(final String dateTime) { + OffsetDateTime parsedDatetime = parse(dateTime, new ParsePosition(0)).toOffsetDatetime(); + return zone == null ? parsedDatetime : parsedDatetime.atZoneSameInstant(zone); + } + + public DateTime parse(String date, ParsePosition pos) { + if (date == null) { + throw new NullPointerException("date cannot be null"); + } + + final int len = date.length(); + final char[] chars = date.toCharArray(); + + // Date portion + + // YEAR + final int years = getYear(chars, pos); + if (4 == len) { + return DateTime.ofYear(years); + } + + // MONTH + consumeChar(chars, pos, DATE_SEPARATOR); + final int months = getMonth(chars, pos); + if (7 == len) { + return DateTime.ofYearMonth(years, months); + } + + // DAY + consumeChar(chars, pos, 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); + final int hours = getHour(chars, pos); + + // MINUTES + consumeChar(chars, pos, TIME_SEPARATOR); + final int minutes = getMinute(chars, pos); + if (16 == len) { + return DateTime.of(years, months, days, hours, minutes); + } + + // SECONDS or TIMEZONE + 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 DateTimeException("Unexpected end of expression at position " + strNum.length + ": '" + new String(strNum) + "'"); + } + + int result = 0; + for (int i = start; i < end; i++) { + final char c = strNum[i]; + if (isDigit(c) == false) { + pos.setErrorIndex(i); + throw new DateTimeException("Character " + c + " is not a digit"); + } + 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); + } + + private static int getMinute(final char[] chars, ParsePosition pos) { + return 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) { + raiseDateTimeException(chars, "Unexpected end of input"); + } + + if (chars[offset] != expected) { + throw new DateTimeException("Expected character " + expected + " at position " + (offset + 1) + " '" + new String(chars) + "'"); + } + pos.setIndex(offset + 1); + } + + private static void consumeNextChar(char[] chars, ParsePosition pos) { + int offset = pos.getIndex(); + if (isValidOffset(chars, offset) == false) { + raiseDateTimeException(chars, "Unexpected end of input"); + } + pos.setIndex(offset + 1); + } + + private static boolean checkPositionContains(char[] chars, ParsePosition pos, char... expected) { + int offset = pos.getIndex(); + if (offset >= chars.length) { + raiseDateTimeException(chars, "Unexpected end of input"); + } + + 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) { + raiseDateTimeException(chars, "Unexpected end of input"); + } + + boolean found = false; + for (char e : expected) { + if (chars[offset] == e) { + found = true; + pos.setIndex(offset + 1); + break; + } + } + if (!found) { + throw new DateTimeException( + "Expected character " + Arrays.toString(expected) + " at position " + (offset + 1) + " '" + new String(chars) + "'" + ); + } + } + + private static void assertNoMoreChars(char[] chars, ParsePosition pos) { + if (chars.length > pos.getIndex()) { + throw new DateTimeException("Trailing junk data after position " + (pos.getIndex() + 1)); + } + } + + 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); + return ZoneOffset.UTC; + } + + if (left != 6) { + throw new DateTimeException("Invalid timezone offset: " + new String(chars, offset, left)); + } + + final char sign = chars[offset]; + consumeNextChar(chars, pos); + int hours = getHour(chars, pos); + int minutes = getMinute(chars, pos); + if (sign == MINUS) { + if (hours == 0 && minutes == 0) { + throw new DateTimeException("Unknown 'Local Offset Convention' date-time not allowed"); + } + hours = -hours; + minutes = -minutes; + } else if (sign != PLUS) { + throw new DateTimeException("Invalid character starting at position " + offset + 1); + } + + return ZoneOffset.ofHoursMinutes(hours, minutes); + } + + 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); + return handleSeconds(year, month, day, hour, minute, chars, pos); + + case PLUS: + case MINUS: + case ZULU_UPPER: + case ZULU_LOWER: + final ZoneOffset zoneOffset = parseTimezone(chars, pos); + return DateTime.of(year, month, day, hour, minute, zoneOffset); + } + throw new DateTimeException("Unexpected character " + " at position " + pos.getIndex()); + } + + private static int getMonth(final char[] chars, ParsePosition pos) { + return readInt(chars, pos, 2); + } + + private static int getYear(final char[] chars, ParsePosition pos) { + return readInt(chars, pos, 4); + } + + private static int getSeconds(final char[] chars, ParsePosition pos) { + return 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); + switch (len) { + case 0: + throw new DateTimeException("Must have at least 1 fraction digit"); + 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; + } + } + + 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); + 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); + } + + ZoneOffset offset = null; + int fractions = 0; + int fractionDigits = 0; + if (remaining == 1 && checkPositionContains(chars, pos, ZULU_LOWER, ZULU_UPPER)) { + 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)) { + // We have fractional seconds; + consumeNextChar(chars, pos); + ParsePosition initPosition = new ParsePosition(pos.getIndex()); + consumeDigits(chars, pos); + if (pos.getErrorIndex() == -1) { + // We have an end of fractions + final int len = pos.getIndex() - initPosition.getIndex(); + fractions = getFractions(chars, initPosition, len); + fractionDigits = len; + offset = parseTimezone(chars, pos); + } else { + raiseDateTimeException(chars, "No timezone information"); + } + } else if (remaining >= 1 && checkPositionContains(chars, pos, PLUS, MINUS)) { + // No fractional sections + offset = parseTimezone(chars, pos); + } else { + raiseDateTimeException(chars, "Unexpected character at position " + (pos.getIndex())); + } + + return fractionDigits > 0 + ? DateTime.of(year, month, day, hour, minute, seconds, fractions, offset, fractionDigits) + : DateTime.of(year, month, day, hour, minute, seconds, offset); + } + + private static void raiseDateTimeException(char[] chars, String message) { + throw new DateTimeException(message + ": " + new String(chars)); + } +} 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 681daf1755890..40237ca9ba585 100644 --- a/server/src/test/java/org/opensearch/common/time/DateFormattersTests.java +++ b/server/src/test/java/org/opensearch/common/time/DateFormattersTests.java @@ -461,6 +461,48 @@ public void testIso8601Parsing() { formatter.format(formatter.parse("2018-05-15T17:14:56,123456789+01:00")); } + public void testRFC339Parsing() { + DateFormatter formatter = DateFormatters.forPattern("rfc3339"); + + // timezone not allowed with just date + formatter.format(formatter.parse("2018-05-15")); + + formatter.format(formatter.parse("2018-05-15T17:14")); + formatter.format(formatter.parse("2018-05-15T17:14Z")); + formatter.format(formatter.parse("2018-05-15T17:14-01:00")); + + formatter.format(formatter.parse("2018-05-15T17:14:56")); + formatter.format(formatter.parse("2018-05-15T17:14:56Z")); + formatter.format(formatter.parse("2018-05-15T17:14:56+01:00")); + + // milliseconds can be separated using comma or decimal point + formatter.format(formatter.parse("2018-05-15T17:14:56.123")); + formatter.format(formatter.parse("2018-05-15T17:14:56.123Z")); + formatter.format(formatter.parse("2018-05-15T17:14:56.123-0100")); + formatter.format(formatter.parse("2018-05-15T17:14:56.123-01:00")); + formatter.format(formatter.parse("2018-05-15T17:14:56,123")); + formatter.format(formatter.parse("2018-05-15T17:14:56,123Z")); + formatter.format(formatter.parse("2018-05-15T17:14:56,123+01:00")); + + // 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-15T17:14:56.123456Z")); + formatter.format(formatter.parse("2018-05-15T17:14:56.123456+0100")); + formatter.format(formatter.parse("2018-05-15T17:14:56.123456+01:00")); + formatter.format(formatter.parse("2018-05-15T17:14:56,123456")); + formatter.format(formatter.parse("2018-05-15T17:14:56,123456Z")); + formatter.format(formatter.parse("2018-05-15T17:14:56,123456-01:00")); + + // 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-15T17:14:56.123456789Z")); + formatter.format(formatter.parse("2018-05-15T17:14:56.123456789-0100")); + formatter.format(formatter.parse("2018-05-15T17:14:56.123456789-01:00")); + formatter.format(formatter.parse("2018-05-15T17:14:56,123456789")); + formatter.format(formatter.parse("2018-05-15T17:14:56,123456789Z")); + formatter.format(formatter.parse("2018-05-15T17:14:56,123456789+01:00")); + } + 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