diff --git a/core/src/main/java/org/opensearch/sql/expression/DSL.java b/core/src/main/java/org/opensearch/sql/expression/DSL.java index 616f431283..024148ffd4 100644 --- a/core/src/main/java/org/opensearch/sql/expression/DSL.java +++ b/core/src/main/java/org/opensearch/sql/expression/DSL.java @@ -453,6 +453,11 @@ public static FunctionExpression year(Expression... expressions) { return compile(FunctionProperties.None, BuiltinFunctionName.YEAR, expressions); } + public static FunctionExpression yearweek( + FunctionProperties functionProperties, Expression... expressions) { + return compile(functionProperties, BuiltinFunctionName.YEARWEEK, expressions); + } + public static FunctionExpression divide(Expression... expressions) { return compile(FunctionProperties.None, BuiltinFunctionName.DIVIDE, expressions); } diff --git a/core/src/main/java/org/opensearch/sql/expression/datetime/DateTimeFunction.java b/core/src/main/java/org/opensearch/sql/expression/datetime/DateTimeFunction.java index fc8cdc93ef..667936e93f 100644 --- a/core/src/main/java/org/opensearch/sql/expression/datetime/DateTimeFunction.java +++ b/core/src/main/java/org/opensearch/sql/expression/datetime/DateTimeFunction.java @@ -196,6 +196,7 @@ public void register(BuiltinFunctionRepository repository) { repository.register(week(BuiltinFunctionName.WEEKOFYEAR)); repository.register(week(BuiltinFunctionName.WEEK_OF_YEAR)); repository.register(year()); + repository.register(yearweek()); } /** @@ -900,6 +901,30 @@ private DefaultFunctionResolver year() { ); } + /** + * YEARWEEK(DATE[,mode]). return the week number for date. + */ + private DefaultFunctionResolver yearweek() { + return define(BuiltinFunctionName.YEARWEEK.getName(), + implWithProperties(nullMissingHandlingWithProperties((functionProperties, arg) + -> yearweekToday( + DEFAULT_WEEK_OF_YEAR_MODE, + functionProperties.getQueryStartClock())), INTEGER, TIME), + impl(nullMissingHandling(DateTimeFunction::exprYearweekWithoutMode), INTEGER, DATE), + impl(nullMissingHandling(DateTimeFunction::exprYearweekWithoutMode), INTEGER, DATETIME), + impl(nullMissingHandling(DateTimeFunction::exprYearweekWithoutMode), INTEGER, TIMESTAMP), + impl(nullMissingHandling(DateTimeFunction::exprYearweekWithoutMode), INTEGER, STRING), + implWithProperties(nullMissingHandlingWithProperties((functionProperties, time, modeArg) + -> yearweekToday( + modeArg, + functionProperties.getQueryStartClock())), INTEGER, TIME, INTEGER), + impl(nullMissingHandling(DateTimeFunction::exprYearweek), INTEGER, DATE, INTEGER), + impl(nullMissingHandling(DateTimeFunction::exprYearweek), INTEGER, DATETIME, INTEGER), + impl(nullMissingHandling(DateTimeFunction::exprYearweek), INTEGER, TIMESTAMP, INTEGER), + impl(nullMissingHandling(DateTimeFunction::exprYearweek), INTEGER, STRING, INTEGER) + ); + } + /** * Formats date according to format specifier. First argument is date, second is format. * Detailed supported signatures: @@ -1731,6 +1756,52 @@ private ExprValue exprYear(ExprValue date) { return new ExprIntegerValue(date.dateValue().getYear()); } + /** + * Helper function to extract the yearweek output from a given date. + * + * @param date is a LocalDate input argument. + * @param mode is an integer containing the mode used to parse the LocalDate. + * @return is a long containing the formatted output for the yearweek function. + */ + private ExprIntegerValue extractYearweek(LocalDate date, int mode) { + // Needed to align with MySQL. Due to how modes for this function work. + // See description of modes here ... + // https://dev.mysql.com/doc/refman/8.0/en/date-and-time-functions.html#function_week + int modeJava = CalendarLookup.getWeekNumber(mode, date) != 0 ? mode : + mode <= 4 ? 2 : + 7; + + int formatted = CalendarLookup.getYearNumber(modeJava, date) * 100 + + CalendarLookup.getWeekNumber(modeJava, date); + + return new ExprIntegerValue(formatted); + } + + /** + * Yearweek for date implementation for ExprValue. + * + * @param date ExprValue of Date/Datetime/Time/Timestamp/String type. + * @param mode ExprValue of Integer type. + */ + private ExprValue exprYearweek(ExprValue date, ExprValue mode) { + return extractYearweek(date.dateValue(), mode.integerValue()); + } + + /** + * Yearweek for date implementation for ExprValue. + * When mode is not specified default value mode 0 is used. + * + * @param date ExprValue of Date/Datetime/Time/Timestamp/String type. + * @return ExprValue. + */ + private ExprValue exprYearweekWithoutMode(ExprValue date) { + return exprYearweek(date, new ExprIntegerValue(0)); + } + + private ExprValue yearweekToday(ExprValue mode, Clock clock) { + return extractYearweek(LocalDateTime.now(clock).toLocalDate(), mode.integerValue()); + } + private ExprValue monthOfYearToday(Clock clock) { return new ExprIntegerValue(LocalDateTime.now(clock).getMonthValue()); } diff --git a/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java b/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java index ec4a7bc140..f5c06e51b9 100644 --- a/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java +++ b/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java @@ -112,6 +112,7 @@ public enum BuiltinFunctionName { WEEKOFYEAR(FunctionName.of("weekofyear")), WEEK_OF_YEAR(FunctionName.of("week_of_year")), YEAR(FunctionName.of("year")), + YEARWEEK(FunctionName.of("yearweek")), // `now`-like functions NOW(FunctionName.of("now")), CURDATE(FunctionName.of("curdate")), diff --git a/core/src/test/java/org/opensearch/sql/expression/datetime/YearweekTest.java b/core/src/test/java/org/opensearch/sql/expression/datetime/YearweekTest.java new file mode 100644 index 0000000000..7517c5e8bf --- /dev/null +++ b/core/src/test/java/org/opensearch/sql/expression/datetime/YearweekTest.java @@ -0,0 +1,171 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + + +package org.opensearch.sql.expression.datetime; + +import static java.time.temporal.ChronoField.ALIGNED_WEEK_OF_YEAR; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.opensearch.sql.data.model.ExprValueUtils.integerValue; +import static org.opensearch.sql.data.type.ExprCoreType.INTEGER; + +import java.time.LocalDate; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.opensearch.sql.data.model.ExprDateValue; +import org.opensearch.sql.data.model.ExprDatetimeValue; +import org.opensearch.sql.data.model.ExprTimeValue; +import org.opensearch.sql.data.model.ExprValue; +import org.opensearch.sql.exception.SemanticCheckException; +import org.opensearch.sql.expression.DSL; +import org.opensearch.sql.expression.Expression; +import org.opensearch.sql.expression.ExpressionTestBase; +import org.opensearch.sql.expression.FunctionExpression; + +class YearweekTest extends ExpressionTestBase { + + private void yearweekQuery(String date, int mode, int expectedResult) { + FunctionExpression expression = DSL + .yearweek( + functionProperties, + DSL.literal(new ExprDateValue(date)), DSL.literal(mode)); + assertAll( + () -> assertEquals(INTEGER, expression.type()), + () -> assertEquals( + String.format("yearweek(DATE '%s', %d)", date, mode), expression.toString()), + () -> assertEquals(integerValue(expectedResult), eval(expression)) + ); + } + + private static Stream getTestDataForYearweek() { + //Test the behavior of different modes passed into the 'yearweek' function + return Stream.of( + Arguments.of("2019-01-05", 0, 201852), + Arguments.of("2019-01-05", 1, 201901), + Arguments.of("2019-01-05", 2, 201852), + Arguments.of("2019-01-05", 3, 201901), + Arguments.of("2019-01-05", 4, 201901), + Arguments.of("2019-01-05", 5, 201853), + Arguments.of("2019-01-05", 6, 201901), + Arguments.of("2019-01-05", 7, 201853), + Arguments.of("2019-01-06", 0, 201901), + Arguments.of("2019-01-06", 1, 201901), + Arguments.of("2019-01-06", 2, 201901), + Arguments.of("2019-01-06", 3, 201901), + Arguments.of("2019-01-06", 4, 201902), + Arguments.of("2019-01-06", 5, 201853), + Arguments.of("2019-01-06", 6, 201902), + Arguments.of("2019-01-06", 7, 201853), + Arguments.of("2019-01-07", 0, 201901), + Arguments.of("2019-01-07", 1, 201902), + Arguments.of("2019-01-07", 2, 201901), + Arguments.of("2019-01-07", 3, 201902), + Arguments.of("2019-01-07", 4, 201902), + Arguments.of("2019-01-07", 5, 201901), + Arguments.of("2019-01-07", 6, 201902), + Arguments.of("2019-01-07", 7, 201901), + Arguments.of("2000-01-01", 0, 199952), + Arguments.of("2000-01-01", 2, 199952), + Arguments.of("1999-12-31", 0, 199952), + Arguments.of("1999-01-01", 0, 199852), + Arguments.of("1999-01-01", 1, 199852), + Arguments.of("1999-01-01", 4, 199852), + Arguments.of("1999-01-01", 5, 199852), + Arguments.of("1999-01-01", 6, 199852) + ); + } + + @ParameterizedTest(name = "{0} | {1}") + @MethodSource("getTestDataForYearweek") + public void testYearweak(String date, int mode, int expected) { + yearweekQuery(date, mode, expected); + } + + @Test + public void testYearweekWithoutMode() { + LocalDate date = LocalDate.of(2019,1,05); + + FunctionExpression expression = DSL + .yearweek( + functionProperties, + DSL.literal(new ExprDateValue(date)), DSL.literal(0)); + + FunctionExpression expressionWithoutMode = DSL + .yearweek( + functionProperties, + DSL.literal(new ExprDateValue(date))); + + assertEquals(eval(expression), eval(expressionWithoutMode)); + } + + @Test + public void testYearweekWithTimeType() { + int week = LocalDate.now(functionProperties.getQueryStartClock()).get(ALIGNED_WEEK_OF_YEAR); + int year = LocalDate.now(functionProperties.getQueryStartClock()).getYear(); + int expected = Integer.parseInt(String.format("%d%02d", year, week)); + + FunctionExpression expression = DSL + .yearweek( + functionProperties, + DSL.literal(new ExprTimeValue("10:11:12")), DSL.literal(0)); + + FunctionExpression expressionWithoutMode = DSL + .yearweek( + functionProperties, + DSL.literal(new ExprTimeValue("10:11:12"))); + + assertAll( + () -> assertEquals(expected, eval(expression).integerValue()), + () -> assertEquals(expected, eval(expressionWithoutMode).integerValue()) + ); + } + + @Test + public void testInvalidYearWeek() { + assertAll( + //test invalid month + () -> assertThrows( + SemanticCheckException.class, + () -> yearweekQuery("2019-13-05 01:02:03", 0, 0)), + //test invalid day + () -> assertThrows( + SemanticCheckException.class, + () -> yearweekQuery("2019-01-50 01:02:03", 0, 0)), + //test invalid leap year + () -> assertThrows( + SemanticCheckException.class, + () -> yearweekQuery("2019-02-29 01:02:03", 0, 0)) + ); + } + + @Test + public void yearweekModeInUnsupportedFormat() { + FunctionExpression expression1 = DSL + .yearweek( + functionProperties, + DSL.literal(new ExprDatetimeValue("2019-01-05 10:11:12")), DSL.literal(8)); + SemanticCheckException exception = + assertThrows(SemanticCheckException.class, () -> eval(expression1)); + assertEquals("mode:8 is invalid, please use mode value between 0-7", + exception.getMessage()); + + FunctionExpression expression2 = DSL + .yearweek( + functionProperties, + DSL.literal(new ExprDatetimeValue("2019-01-05 10:11:12")), DSL.literal(-1)); + exception = assertThrows(SemanticCheckException.class, () -> eval(expression2)); + assertEquals("mode:-1 is invalid, please use mode value between 0-7", + exception.getMessage()); + } + + private ExprValue eval(Expression expression) { + return expression.valueOf(); + } +} diff --git a/docs/user/dql/functions.rst b/docs/user/dql/functions.rst index 5d5a3e1f96..31f320042d 100644 --- a/docs/user/dql/functions.rst +++ b/docs/user/dql/functions.rst @@ -2698,6 +2698,28 @@ Example:: +----------------------------+ +YEARWEEK +-------- + +Description +>>>>>>>>>>> + +Usage: yearweek(date) returns the year and week for date as an integer. It accepts and optional mode arguments aligned with those available for the `WEEK`_ function. + +Argument type: STRING/DATE/DATETIME/TIME/TIMESTAMP + +Return type: INTEGER + +Example:: + + os> SELECT YEARWEEK('2020-08-26'), YEARWEEK('2019-01-05', 0) + fetched rows / total rows = 1/1 + +--------------------------+-----------------------------+ + | YEARWEEK('2020-08-26') | YEARWEEK('2019-01-05', 0) | + |--------------------------+-----------------------------| + | 202034 | 201852 | + +--------------------------+-----------------------------+ + String Functions ================ diff --git a/integ-test/src/test/java/org/opensearch/sql/sql/DateTimeFunctionIT.java b/integ-test/src/test/java/org/opensearch/sql/sql/DateTimeFunctionIT.java index 4254641524..03f1e4e0ad 100644 --- a/integ-test/src/test/java/org/opensearch/sql/sql/DateTimeFunctionIT.java +++ b/integ-test/src/test/java/org/opensearch/sql/sql/DateTimeFunctionIT.java @@ -985,6 +985,14 @@ public void testWeekAlternateSyntaxesReturnTheSameResults() throws IOException { compareWeekResults("CAST(datetime0 AS timestamp)", TEST_INDEX_CALCS); } + @Test + public void testYearweek() throws IOException { + JSONObject result = executeQuery( + String.format("SELECT yearweek(time0), yearweek(time0, 4) FROM %s LIMIT 2", TEST_INDEX_CALCS)); + + verifyDataRows(result, rows(189952, 189952), rows(189953, 190001)); + } + void verifyDateFormat(String date, String type, String format, String formatted) throws IOException { String query = String.format("date_format(%s('%s'), '%s')", type, date, format); JSONObject result = executeQuery("select " + query); diff --git a/sql/src/main/antlr/OpenSearchSQLLexer.g4 b/sql/src/main/antlr/OpenSearchSQLLexer.g4 index 25f23a7bd6..7bb37a7796 100644 --- a/sql/src/main/antlr/OpenSearchSQLLexer.g4 +++ b/sql/src/main/antlr/OpenSearchSQLLexer.g4 @@ -340,6 +340,7 @@ STRCMP: 'STRCMP'; // DATE AND TIME FUNCTIONS ADDDATE: 'ADDDATE'; +YEARWEEK: 'YEARWEEK'; // RELEVANCE FUNCTIONS AND PARAMETERS ALLOW_LEADING_WILDCARD: 'ALLOW_LEADING_WILDCARD'; diff --git a/sql/src/main/antlr/OpenSearchSQLParser.g4 b/sql/src/main/antlr/OpenSearchSQLParser.g4 index e5efeabba0..12fb8cc534 100644 --- a/sql/src/main/antlr/OpenSearchSQLParser.g4 +++ b/sql/src/main/antlr/OpenSearchSQLParser.g4 @@ -482,6 +482,7 @@ dateTimeFunctionName | WEEK_OF_YEAR | WEEKOFYEAR | YEAR + | YEARWEEK ; textFunctionName diff --git a/sql/src/test/java/org/opensearch/sql/sql/antlr/SQLSyntaxParserTest.java b/sql/src/test/java/org/opensearch/sql/sql/antlr/SQLSyntaxParserTest.java index 0b8e64d0bb..22b48ef3cd 100644 --- a/sql/src/test/java/org/opensearch/sql/sql/antlr/SQLSyntaxParserTest.java +++ b/sql/src/test/java/org/opensearch/sql/sql/antlr/SQLSyntaxParserTest.java @@ -542,6 +542,12 @@ public void can_parse_wildcard_query_relevance_function() { + "boost=1.5, case_insensitive=true, rewrite=\"scoring_boolean\")")); } + @Test + public void can_parse_yearweek_function() { + assertNotNull(parser.parse("SELECT yearweek('1987-01-01')")); + assertNotNull(parser.parse("SELECT yearweek('1987-01-01', 1)")); + } + @Test public void describe_request_accepts_only_quoted_string_literals() { assertAll(