diff --git a/ppl-spark-integration/src/main/java/org/opensearch/sql/expression/function/TimeUtils.java b/ppl-spark-integration/src/main/java/org/opensearch/sql/expression/function/TimeUtils.java new file mode 100644 index 000000000..d3a17081f --- /dev/null +++ b/ppl-spark-integration/src/main/java/org/opensearch/sql/expression/function/TimeUtils.java @@ -0,0 +1,224 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.expression.function; + +import lombok.experimental.UtilityClass; + +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.Period; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@UtilityClass +public class TimeUtils { + + private static final String now = "now"; + private static final String negativeSign = "-"; + + // Pattern for relative date time string + private static final String patternStringOffset = "(?[+-])(?\\d+)?(?\\w+)"; + private static final String patternStringSnap = "[@](?\\w+)"; + private static final String patternStringRelative = String.format("(?%s)?(?%s)?", patternStringOffset, patternStringSnap); + + private static final Pattern pattern = Pattern.compile(patternStringRelative); + + // Time unit constants + private static final Set secondUnits = Set.of("s", "sec", "secs", "second", "seconds"); + private static final Set minuteUnits = Set.of("m", "min", "mins", "minute", "minutes"); + private static final Set hourUnits = Set.of("h", "hr", "hrs", "hour", "hours"); + private static final Set dayUnits = Set.of("d", "day", "days"); + private static final Set weekUnits = Set.of("w", "wk", "wks", "week", "weeks"); + private static final Set monthUnits = Set.of("mon", "month", "months"); + private static final Set quarterUnits = Set.of("q", "qtr", "qtrs", "quarter", "quarters"); + private static final Set yearUnits = Set.of("y", "yr", "yrs", "year", "years"); + + // Map from time unit constants to the corresponding duration. + private static final Duration secondDuration = Duration.ofSeconds(1); + private static final Duration minuteDuration = Duration.ofMinutes(1); + private static final Duration hourDuration = Duration.ofHours(1); + + private static final Map durationForTimeUnit = Map.ofEntries( + Map.entry("s", secondDuration), + Map.entry("sec", secondDuration), + Map.entry("secs", secondDuration), + Map.entry("second", secondDuration), + Map.entry("seconds", secondDuration), + + Map.entry("m", minuteDuration), + Map.entry("min", minuteDuration), + Map.entry("mins", minuteDuration), + Map.entry("minute", minuteDuration), + Map.entry("minutes", minuteDuration), + + Map.entry("h", hourDuration), + Map.entry("hr", hourDuration), + Map.entry("hrs", hourDuration), + Map.entry("hour", hourDuration), + Map.entry("hours", hourDuration)); + + // Map from time unit constants to the corresponding period. + private static final Period periodDay = Period.ofDays(1); + private static final Period periodWeek = Period.ofWeeks(1); + private static final Period periodMonth = Period.ofMonths(1); + private static final Period periodQuarter = Period.ofMonths(3); + private static final Period periodYear = Period.ofYears(1); + + private static final Map periodForTimeUnit = Map.ofEntries( + Map.entry("d", periodDay), + Map.entry("day", periodDay), + Map.entry("days", periodDay), + + Map.entry("w", periodWeek), + Map.entry("wk", periodWeek), + Map.entry("wks", periodWeek), + Map.entry("week", periodWeek), + Map.entry("weeks", periodWeek), + + Map.entry("mon", periodMonth), + Map.entry("month", periodMonth), + Map.entry("months", periodMonth), + + Map.entry("q", periodQuarter), + Map.entry("qtr", periodQuarter), + Map.entry("qtrs", periodQuarter), + Map.entry("quarter", periodQuarter), + Map.entry("quarters", periodQuarter), + + Map.entry("y", periodYear), + Map.entry("yr", periodYear), + Map.entry("yrs", periodYear), + Map.entry("year", periodYear), + Map.entry("years", periodYear)); + + // Maps from day of the week unit constants to the corresponding day of the week. + private static final Map daysOfWeekForUnit = Map.ofEntries( + Map.entry("w0", DayOfWeek.SUNDAY), + Map.entry("w7", DayOfWeek.SUNDAY), + Map.entry("w1", DayOfWeek.MONDAY), + Map.entry("w2", DayOfWeek.TUESDAY), + Map.entry("w3", DayOfWeek.WEDNESDAY), + Map.entry("w4", DayOfWeek.THURSDAY), + Map.entry("w5", DayOfWeek.FRIDAY), + Map.entry("w6", DayOfWeek.SATURDAY)); + + static final int DAYS_PER_WEEK = 7; + static final int MONTHS_PER_QUARTER = 3; + + /** + * Returns the {@link LocalDateTime} corresponding to the given relative date time string and date time. + * Throws {@link RuntimeException} if the relative date time string is not supported. + */ + public static LocalDateTime getRelativeDateTime(String relativeDateTimeString, LocalDateTime dateTime) { + + if (relativeDateTimeString.equals(now)) { + return dateTime; + } + + Matcher matcher = pattern.matcher(relativeDateTimeString); + if (!matcher.matches()) { + String message = String.format("The relative date time '%s' is not supported.", relativeDateTimeString); + throw new RuntimeException(message); + } + + LocalDateTime relativeDateTime = dateTime; + + if (matcher.group("offset") != null) { + relativeDateTime = applyOffset( + relativeDateTime, + matcher.group("offsetSign"), + matcher.group("offsetValue"), + matcher.group("offsetUnit") + ); + } + + if (matcher.group("snap") != null) { + relativeDateTime = applySnap( + relativeDateTime, + matcher.group("snapUnit") + ); + } + + return relativeDateTime; + } + + /** + * Applies the offset specified by the offset sign, value, and unit to the given date time, and returns the result. + */ + private LocalDateTime applyOffset(LocalDateTime dateTime, String offsetSignString, String offsetValueString, String offsetUnitString) { + int offsetValue = Optional.ofNullable(offsetValueString).map(Integer::parseInt).orElse(1); + if (offsetSignString.equals(negativeSign)) { + offsetValue *= -1; + } + + /* {@link Duration} and {@link Period} must be handled separately because, even + though they both inherit from {@link java.time.temporal.TemporalAmount}, they + define separate 'multipliedBy' methods. */ + + if (durationForTimeUnit.containsKey(offsetUnitString)) { + final Duration offsetDuration = durationForTimeUnit.get(offsetUnitString).multipliedBy(offsetValue); + return dateTime.plus(offsetDuration); + } + + if (periodForTimeUnit.containsKey(offsetUnitString)) { + final Period offsetPeriod = periodForTimeUnit.get(offsetUnitString).multipliedBy(offsetValue); + return dateTime.plus(offsetPeriod); + } + + final String message = String.format("The relative date time unit '%s' is not supported.", offsetUnitString); + throw new RuntimeException(message); + } + + /** + * Snaps the given date time to the start of the previous time period specified by the given snap unit, and returns the result. + */ + private LocalDateTime applySnap(LocalDateTime dateTime, String snapUnit) { + + if (secondUnits.contains(snapUnit)) { + return dateTime.truncatedTo(ChronoUnit.SECONDS); + } else if (minuteUnits.contains(snapUnit)) { + return dateTime.truncatedTo(ChronoUnit.MINUTES); + } else if (hourUnits.contains(snapUnit)) { + return dateTime.truncatedTo(ChronoUnit.HOURS); + } else if (dayUnits.contains(snapUnit)) { + return dateTime.truncatedTo(ChronoUnit.DAYS); + } else if (weekUnits.contains(snapUnit)) { + return applySnapToDay(dateTime, DayOfWeek.SUNDAY); + } else if (monthUnits.contains(snapUnit)) { + return dateTime.truncatedTo(ChronoUnit.DAYS).withDayOfMonth(1); + } else if (quarterUnits.contains(snapUnit)) { + int monthsToSnap = (dateTime.getMonthValue() - 1) % MONTHS_PER_QUARTER; + return dateTime.truncatedTo(ChronoUnit.DAYS).withDayOfMonth(1).minusMonths(monthsToSnap); + } else if (yearUnits.contains(snapUnit)) { + return dateTime.truncatedTo(ChronoUnit.DAYS).withDayOfYear(1); + } else if (daysOfWeekForUnit.containsKey(snapUnit)) { + return applySnapToDay(dateTime, daysOfWeekForUnit.get(snapUnit)); + } + + final String message = String.format("The relative date time unit '%s' is not supported.", snapUnit); + throw new RuntimeException(message); + } + + /** + * Snaps the given date time to the start of the previous specified day of the week, and returns the result. + */ + private LocalDateTime applySnapToDay(LocalDateTime dateTime, DayOfWeek snapDay) { + LocalDateTime snapped = dateTime.truncatedTo(ChronoUnit.DAYS); + + DayOfWeek day = dateTime.getDayOfWeek(); + if (day.equals(snapDay)) { + return snapped; + } + + int daysToSnap = DAYS_PER_WEEK - snapDay.getValue() + day.getValue(); + return snapped.minusDays(daysToSnap); + } +} diff --git a/ppl-spark-integration/src/test/java/org/opensearch/sql/expression/function/TimeUtilsTest.java b/ppl-spark-integration/src/test/java/org/opensearch/sql/expression/function/TimeUtilsTest.java new file mode 100644 index 000000000..a3ddd3024 --- /dev/null +++ b/ppl-spark-integration/src/test/java/org/opensearch/sql/expression/function/TimeUtilsTest.java @@ -0,0 +1,169 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.expression.function; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import java.time.LocalDateTime; + +import org.junit.Test; + +public class TimeUtilsTest { + + // Monday, Jan 03, 2000 @ 01:01:01.100 + private final LocalDateTime dateTime = LocalDateTime.parse("2000-01-03T01:01:01.100"); + + @Test + public void testRelative() { + testValid("now", "2000-01-03T01:01:01.100"); + testValid("-60m", "2000-01-03T00:01:01.100"); + testValid("-h", "2000-01-03T00:01:01.100"); + testValid("+2wk", "2000-01-17T01:01:01.100"); + testValid("-1h@h", "2000-01-03T00:00"); + testValid("@d", "2000-01-03T00:00"); + + testInvalid("INVALID", "The relative date time 'INVALID' is not supported."); + } + + @Test + public void testRelativeOffsetSign() { + testValid("+h", "2000-01-03T02:01:01.100"); + testValid("-h", "2000-01-03T00:01:01.100"); + + testInvalid("~h", "The relative date time '~h' is not supported."); + } + + @Test + public void testRelativeOffsetValue() { + testValid("+h", "2000-01-03T02:01:01.100"); + testValid("+0h", "2000-01-03T01:01:01.100"); + testValid("+1h", "2000-01-03T02:01:01.100"); + testValid("+12h", "2000-01-03T13:01:01.100"); + + testInvalid("+1.1h", "The relative date time '+1.1h' is not supported."); + } + + @Test + public void testRelativeOffsetUnit() { + testValid("+s", "2000-01-03T01:01:02.1"); + testValid("+sec", "2000-01-03T01:01:02.1"); + testValid("+secs", "2000-01-03T01:01:02.1"); + testValid("+second", "2000-01-03T01:01:02.1"); + testValid("+seconds", "2000-01-03T01:01:02.1"); + + testValid("+m", "2000-01-03T01:02:01.100"); + testValid("+min", "2000-01-03T01:02:01.100"); + testValid("+mins", "2000-01-03T01:02:01.100"); + testValid("+minute", "2000-01-03T01:02:01.100"); + testValid("+minutes", "2000-01-03T01:02:01.100"); + + testValid("+h", "2000-01-03T02:01:01.100"); + testValid("+hr", "2000-01-03T02:01:01.100"); + testValid("+hrs", "2000-01-03T02:01:01.100"); + testValid("+hour", "2000-01-03T02:01:01.100"); + testValid("+hours", "2000-01-03T02:01:01.100"); + + testValid("+d", "2000-01-04T01:01:01.100"); + testValid("+day", "2000-01-04T01:01:01.100"); + testValid("+days", "2000-01-04T01:01:01.100"); + + testValid("+w", "2000-01-10T01:01:01.100"); + testValid("+wk", "2000-01-10T01:01:01.100"); + testValid("+wks", "2000-01-10T01:01:01.100"); + testValid("+week", "2000-01-10T01:01:01.100"); + testValid("+weeks", "2000-01-10T01:01:01.100"); + + testValid("+mon", "2000-02-03T01:01:01.100"); + testValid("+month", "2000-02-03T01:01:01.100"); + testValid("+months", "2000-02-03T01:01:01.100"); + + testValid("+q", "2000-04-03T01:01:01.100"); + testValid("+qtr", "2000-04-03T01:01:01.100"); + testValid("+qtrs", "2000-04-03T01:01:01.100"); + testValid("+quarter", "2000-04-03T01:01:01.100"); + testValid("+quarters", "2000-04-03T01:01:01.100"); + + testValid("+y", "2001-01-03T01:01:01.100"); + testValid("+yr", "2001-01-03T01:01:01.100"); + testValid("+yrs", "2001-01-03T01:01:01.100"); + testValid("+year", "2001-01-03T01:01:01.100"); + testValid("+years", "2001-01-03T01:01:01.100"); + + testInvalid("+1INVALID", "The relative date time unit 'INVALID' is not supported."); + } + + @Test + public void testRelativeSnap() { + testValid("@s", "2000-01-03T01:01:01"); + testValid("@sec", "2000-01-03T01:01:01"); + testValid("@secs", "2000-01-03T01:01:01"); + testValid("@second", "2000-01-03T01:01:01"); + testValid("@seconds", "2000-01-03T01:01:01"); + + testValid("@m", "2000-01-03T01:01"); + testValid("@min", "2000-01-03T01:01"); + testValid("@mins", "2000-01-03T01:01"); + testValid("@minute", "2000-01-03T01:01"); + testValid("@minutes", "2000-01-03T01:01"); + + testValid("@h", "2000-01-03T01:00"); + testValid("@hr", "2000-01-03T01:00"); + testValid("@hrs", "2000-01-03T01:00"); + testValid("@hour", "2000-01-03T01:00"); + testValid("@hours", "2000-01-03T01:00"); + + testValid("@d", "2000-01-03T00:00"); + testValid("@day", "2000-01-03T00:00"); + testValid("@days", "2000-01-03T00:00"); + + testValid("@w", "2000-01-02T00:00"); + testValid("@wk", "2000-01-02T00:00"); + testValid("@wks", "2000-01-02T00:00"); + testValid("@week", "2000-01-02T00:00"); + testValid("@weeks", "2000-01-02T00:00"); + + testValid("@mon", "2000-01-01T00:00"); + testValid("@month", "2000-01-01T00:00"); + testValid("@months", "2000-01-01T00:00"); + + testValid("@q", "2000-01-01T00:00"); + testValid("@qtr", "2000-01-01T00:00"); + testValid("@qtrs", "2000-01-01T00:00"); + testValid("@quarter", "2000-01-01T00:00"); + testValid("@quarters", "2000-01-01T00:00"); + + testValid("@y", "2000-01-01T00:00"); + testValid("@yr", "2000-01-01T00:00"); + testValid("@yrs", "2000-01-01T00:00"); + testValid("@year", "2000-01-01T00:00"); + testValid("@years", "2000-01-01T00:00"); + + testValid("@w0", "2000-01-02T00:00"); + testValid("@w1", "2000-01-03T00:00"); + testValid("@w2", "1999-12-28T00:00"); + testValid("@w3", "1999-12-29T00:00"); + testValid("@w4", "1999-12-30T00:00"); + testValid("@w5", "1999-12-31T00:00"); + testValid("@w6", "2000-01-01T00:00"); + testValid("@w7", "2000-01-02T00:00"); + + testInvalid("@INVALID", "The relative date time unit 'INVALID' is not supported."); + } + + private void testValid(String relativeDateTimeString, String expectedDateTimeString) { + String testMessage = String.format("\"%s\"", relativeDateTimeString); + LocalDateTime expectedDateTime = LocalDateTime.parse(expectedDateTimeString); + LocalDateTime actualDateTime = TimeUtils.getRelativeDateTime(relativeDateTimeString, dateTime); + assertEquals(testMessage, expectedDateTime, actualDateTime); + } + + private void testInvalid(String relativeDateTimeString, String expectedExceptionMessage) { + String testMessage = String.format("\"%s\"", relativeDateTimeString); + String actualExceptionMessage = assertThrows(testMessage, RuntimeException.class, () -> TimeUtils.getRelativeDateTime(relativeDateTimeString, dateTime)).getMessage(); + assertEquals(expectedExceptionMessage, actualExceptionMessage); + } +}