diff --git a/integ-test/src/test/java/org/opensearch/sql/legacy/SQLIntegTestCase.java b/integ-test/src/test/java/org/opensearch/sql/legacy/SQLIntegTestCase.java index fc15c908c63..e0f4b53e3f6 100644 --- a/integ-test/src/test/java/org/opensearch/sql/legacy/SQLIntegTestCase.java +++ b/integ-test/src/test/java/org/opensearch/sql/legacy/SQLIntegTestCase.java @@ -994,7 +994,13 @@ public enum Index { "timewrap_test", "timewrap_test", "{\"mappings\":{\"properties\":{\"@timestamp\":{\"type\":\"date\"},\"host\":{\"type\":\"keyword\"},\"requests\":{\"type\":\"integer\"},\"errors\":{\"type\":\"integer\"}}}}", - "src/test/resources/timewrap_test.json"); + "src/test/resources/timewrap_test.json"), + DATE_HISTOGRAM_TEST( + "date_histogram_test", + "date_histogram_test", + "{\"mappings\":{\"properties\":{\"ts\":{\"type\":\"date\",\"format\":\"yyyy-MM-dd" + + " HH:mm:ss\"},\"category\":{\"type\":\"keyword\"},\"value\":{\"type\":\"integer\"}}}}", + "src/test/resources/date_histogram_test.json"); private final String name; private final String type; diff --git a/integ-test/src/test/java/org/opensearch/sql/sql/DateHistogramBucketFunctionIT.java b/integ-test/src/test/java/org/opensearch/sql/sql/DateHistogramBucketFunctionIT.java new file mode 100644 index 00000000000..76e154cf2e7 --- /dev/null +++ b/integ-test/src/test/java/org/opensearch/sql/sql/DateHistogramBucketFunctionIT.java @@ -0,0 +1,191 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.sql; + +import static org.opensearch.sql.util.Capability.LEGACY_ENGINE_FALLBACK; +import static org.opensearch.sql.util.MatcherUtils.rows; +import static org.opensearch.sql.util.MatcherUtils.verifyDataRows; +import static org.opensearch.sql.util.MatcherUtils.verifyDataRowsInOrder; + +import java.io.IOException; +import org.json.JSONObject; +import org.junit.Test; +import org.opensearch.sql.legacy.SQLIntegTestCase; +import org.opensearch.sql.util.RequiresCapability; + +/** + * Execution coverage for {@code date_histogram} and {@code histogram}. The expander unit tests + * assert the AST that gets built; these assert what comes back after analysis, planning and + * pushdown, against 72 documents on fixed timestamps: + * + *
+ *   00:00 x5 alpha   00:30 x7 beta    01:00 x11 alpha
+ *   01:45 x13 gamma  02:00 x17 beta   03:00 x19 alpha
+ * 
+ * + * so hourly grouping must yield 12/24/17/19 and half-hourly 5/7/11/13/17/19. + */ +public class DateHistogramBucketFunctionIT extends SQLIntegTestCase { + + private static final String IDX = "date_histogram_test"; + + @Override + protected void init() throws Exception { + super.init(); + loadIndex(Index.DATE_HISTOGRAM_TEST); + } + + /** The planner rejects {@code GROUP BY }, so the bucket is aliased in a subquery. */ + private static String bucketed(String bucketExpr) { + return "SELECT b, COUNT(*) FROM (SELECT " + + bucketExpr + + " AS b FROM " + + IDX + + ") sub GROUP BY b ORDER BY b"; + } + + @Test + public void hourlyBucketsCarryKeysAndCounts() throws IOException { + JSONObject response = executeQuery(bucketed("date_histogram('field'=ts, 'interval'='1h')")); + + verifyDataRowsInOrder( + response, + rows("2026-01-01 00:00:00", 12), + rows("2026-01-01 01:00:00", 24), + rows("2026-01-01 02:00:00", 17), + rows("2026-01-01 03:00:00", 19)); + } + + /** A sub-hour interval must split 00:00/00:30 and 01:00/01:45 rather than merge them. */ + @Test + public void halfHourlyBucketsSplitWithinTheHour() throws IOException { + JSONObject response = executeQuery(bucketed("date_histogram('field'=ts, 'interval'='30m')")); + + verifyDataRowsInOrder( + response, + rows("2026-01-01 00:00:00", 5), + rows("2026-01-01 00:30:00", 7), + rows("2026-01-01 01:00:00", 11), + rows("2026-01-01 01:30:00", 13), + rows("2026-01-01 02:00:00", 17), + rows("2026-01-01 03:00:00", 19)); + } + + @Test + public void dailyIntervalCollapsesEverythingIntoOneBucket() throws IOException { + JSONObject response = executeQuery(bucketed("date_histogram('field'=ts, 'interval'='1d')")); + + verifyDataRows(response, rows("2026-01-01 00:00:00", 72)); + } + + /** {@code fixed_interval} and {@code calendar_interval} are accepted as synonyms of interval. */ + @Test + public void intervalSynonymsProduceTheSameBuckets() throws IOException { + JSONObject viaFixed = + executeQuery(bucketed("date_histogram('field'=ts, 'fixed_interval'='1h')")); + JSONObject viaCalendar = + executeQuery(bucketed("date_histogram('field'=ts, 'calendar_interval'='1h')")); + + for (JSONObject response : new JSONObject[] {viaFixed, viaCalendar}) { + verifyDataRowsInOrder( + response, + rows("2026-01-01 00:00:00", 12), + rows("2026-01-01 01:00:00", 24), + rows("2026-01-01 02:00:00", 17), + rows("2026-01-01 03:00:00", 19)); + } + } + + /** + * The scan sits in its own derived table because the V2 engine cannot resolve the span's field + * otherwise when a second grouping key is present. + */ + @Test + public void bucketsCombineWithAnAdditionalGroupingKey() throws IOException { + JSONObject response = + executeQuery( + "SELECT b, c, COUNT(*) FROM (SELECT date_histogram('field'=ts, 'interval'='1h') AS b," + + " category AS c FROM (SELECT * FROM " + + IDX + + ") inner_scan) sub GROUP BY b, c ORDER BY b, c"); + + verifyDataRowsInOrder( + response, + rows("2026-01-01 00:00:00", "alpha", 5), + rows("2026-01-01 00:00:00", "beta", 7), + rows("2026-01-01 01:00:00", "alpha", 11), + rows("2026-01-01 01:00:00", "gamma", 13), + rows("2026-01-01 02:00:00", "beta", 17), + rows("2026-01-01 03:00:00", "alpha", 19)); + } + + @Test + public void bucketsRespectAWhereClause() throws IOException { + JSONObject response = + executeQuery( + "SELECT b, COUNT(*) FROM (SELECT date_histogram('field'=ts, 'interval'='1h') AS b FROM " + + IDX + + " WHERE category = 'alpha') sub GROUP BY b ORDER BY b"); + + verifyDataRowsInOrder( + response, + rows("2026-01-01 00:00:00", 5), + rows("2026-01-01 01:00:00", 11), + rows("2026-01-01 03:00:00", 19)); + } + + @Test + public void numericHistogramBucketsByInterval() throws IOException { + JSONObject response = + executeQuery( + "SELECT b, COUNT(*) FROM (SELECT histogram('field'=value, 'interval'=20) AS b FROM " + + IDX + + ") sub GROUP BY b ORDER BY b"); + + // value runs 1..72, so the 20-wide buckets hold 19, 20, 20 and 13 documents. + verifyDataRowsInOrder(response, rows(0, 19), rows(20, 20), rows(40, 20), rows(60, 13)); + } + + /** + * Only the legacy V1 engine understands this spelling, and it answered before these names entered + * the V2 grammar. The expander has to keep declining with SyntaxCheckException so it still does. + */ + @Test + @RequiresCapability(LEGACY_ENGINE_FALLBACK) + public void positionalCallReturnsHourlyBuckets() throws IOException { + JSONObject response = + executeQuery( + "SELECT COUNT(*) FROM " + IDX + " GROUP BY date_histogram(field='ts','interval'='1h')"); + + verifyDataRows(response, rows(12), rows(24), rows(17), rows(19)); + } + + /** + * `alias` has no lowering here but the legacy engine implements it, so the query still has to + * answer. CsvFormatResponseIT.dateHistogramTest has asserted this shape for years. + */ + @Test + @RequiresCapability(LEGACY_ENGINE_FALLBACK) + public void callWithAliasParameterReturnsHourlyBuckets() throws IOException { + JSONObject response = + executeQuery( + "SELECT COUNT(*) FROM " + + IDX + + " GROUP BY date_histogram('field'='ts','fixed_interval'='1h','alias'='hours')"); + + verifyDataRows(response, rows(12), rows(24), rows(17), rows(19)); + } + + @Test + @RequiresCapability(LEGACY_ENGINE_FALLBACK) + public void positionalNumericHistogramReturnsBuckets() throws IOException { + JSONObject response = + executeQuery( + "SELECT COUNT(*) FROM " + IDX + " GROUP BY histogram(field='value','interval'='20')"); + + verifyDataRows(response, rows(19), rows(20), rows(20), rows(13)); + } +} diff --git a/integ-test/src/test/java/org/opensearch/sql/util/Capability.java b/integ-test/src/test/java/org/opensearch/sql/util/Capability.java index 1d4067f1414..f5a5b784e38 100644 --- a/integ-test/src/test/java/org/opensearch/sql/util/Capability.java +++ b/integ-test/src/test/java/org/opensearch/sql/util/Capability.java @@ -541,6 +541,17 @@ public enum Capability { * FRONTEND: legacy method-query syntax (regexp_query/wildcard_query) is not in the Calcite * grammar. */ + /** + * FRONTEND: the legacy V1 engine answers call shapes the V2 grammar declines, but only on the + * default route. Requests reach it when RestSQLQueryAction catches a SyntaxCheckException; the + * analytics-engine route enters through RestUnifiedQueryAction, which has no such fallback. + */ + LEGACY_ENGINE_FALLBACK( + "A call shape only the legacy V1 engine understands (e.g. positional" + + " date_histogram(field=, ...), or an `alias` parameter) can't be answered on the" + + " analytics-engine route: reaching that engine depends on RestSQLQueryAction's" + + " SyntaxCheckException fallback, and the analytics route does not go through it."), + LEGACY_METHOD_QUERY( "Legacy method-query syntax (regexp_query/wildcard_query/query/matchquery) is not in the" + " Calcite grammar used by the analytics-engine route."), diff --git a/integ-test/src/test/resources/date_histogram_test.json b/integ-test/src/test/resources/date_histogram_test.json new file mode 100644 index 00000000000..2d43eca9da3 --- /dev/null +++ b/integ-test/src/test/resources/date_histogram_test.json @@ -0,0 +1,144 @@ +{"index":{}} +{"ts":"2026-01-01 00:00:00","category":"alpha","value":1} +{"index":{}} +{"ts":"2026-01-01 00:00:00","category":"alpha","value":2} +{"index":{}} +{"ts":"2026-01-01 00:00:00","category":"alpha","value":3} +{"index":{}} +{"ts":"2026-01-01 00:00:00","category":"alpha","value":4} +{"index":{}} +{"ts":"2026-01-01 00:00:00","category":"alpha","value":5} +{"index":{}} +{"ts":"2026-01-01 00:30:00","category":"beta","value":6} +{"index":{}} +{"ts":"2026-01-01 00:30:00","category":"beta","value":7} +{"index":{}} +{"ts":"2026-01-01 00:30:00","category":"beta","value":8} +{"index":{}} +{"ts":"2026-01-01 00:30:00","category":"beta","value":9} +{"index":{}} +{"ts":"2026-01-01 00:30:00","category":"beta","value":10} +{"index":{}} +{"ts":"2026-01-01 00:30:00","category":"beta","value":11} +{"index":{}} +{"ts":"2026-01-01 00:30:00","category":"beta","value":12} +{"index":{}} +{"ts":"2026-01-01 01:00:00","category":"alpha","value":13} +{"index":{}} +{"ts":"2026-01-01 01:00:00","category":"alpha","value":14} +{"index":{}} +{"ts":"2026-01-01 01:00:00","category":"alpha","value":15} +{"index":{}} +{"ts":"2026-01-01 01:00:00","category":"alpha","value":16} +{"index":{}} +{"ts":"2026-01-01 01:00:00","category":"alpha","value":17} +{"index":{}} +{"ts":"2026-01-01 01:00:00","category":"alpha","value":18} +{"index":{}} +{"ts":"2026-01-01 01:00:00","category":"alpha","value":19} +{"index":{}} +{"ts":"2026-01-01 01:00:00","category":"alpha","value":20} +{"index":{}} +{"ts":"2026-01-01 01:00:00","category":"alpha","value":21} +{"index":{}} +{"ts":"2026-01-01 01:00:00","category":"alpha","value":22} +{"index":{}} +{"ts":"2026-01-01 01:00:00","category":"alpha","value":23} +{"index":{}} +{"ts":"2026-01-01 01:45:00","category":"gamma","value":24} +{"index":{}} +{"ts":"2026-01-01 01:45:00","category":"gamma","value":25} +{"index":{}} +{"ts":"2026-01-01 01:45:00","category":"gamma","value":26} +{"index":{}} +{"ts":"2026-01-01 01:45:00","category":"gamma","value":27} +{"index":{}} +{"ts":"2026-01-01 01:45:00","category":"gamma","value":28} +{"index":{}} +{"ts":"2026-01-01 01:45:00","category":"gamma","value":29} +{"index":{}} +{"ts":"2026-01-01 01:45:00","category":"gamma","value":30} +{"index":{}} +{"ts":"2026-01-01 01:45:00","category":"gamma","value":31} +{"index":{}} +{"ts":"2026-01-01 01:45:00","category":"gamma","value":32} +{"index":{}} +{"ts":"2026-01-01 01:45:00","category":"gamma","value":33} +{"index":{}} +{"ts":"2026-01-01 01:45:00","category":"gamma","value":34} +{"index":{}} +{"ts":"2026-01-01 01:45:00","category":"gamma","value":35} +{"index":{}} +{"ts":"2026-01-01 01:45:00","category":"gamma","value":36} +{"index":{}} +{"ts":"2026-01-01 02:00:00","category":"beta","value":37} +{"index":{}} +{"ts":"2026-01-01 02:00:00","category":"beta","value":38} +{"index":{}} +{"ts":"2026-01-01 02:00:00","category":"beta","value":39} +{"index":{}} +{"ts":"2026-01-01 02:00:00","category":"beta","value":40} +{"index":{}} +{"ts":"2026-01-01 02:00:00","category":"beta","value":41} +{"index":{}} +{"ts":"2026-01-01 02:00:00","category":"beta","value":42} +{"index":{}} +{"ts":"2026-01-01 02:00:00","category":"beta","value":43} +{"index":{}} +{"ts":"2026-01-01 02:00:00","category":"beta","value":44} +{"index":{}} +{"ts":"2026-01-01 02:00:00","category":"beta","value":45} +{"index":{}} +{"ts":"2026-01-01 02:00:00","category":"beta","value":46} +{"index":{}} +{"ts":"2026-01-01 02:00:00","category":"beta","value":47} +{"index":{}} +{"ts":"2026-01-01 02:00:00","category":"beta","value":48} +{"index":{}} +{"ts":"2026-01-01 02:00:00","category":"beta","value":49} +{"index":{}} +{"ts":"2026-01-01 02:00:00","category":"beta","value":50} +{"index":{}} +{"ts":"2026-01-01 02:00:00","category":"beta","value":51} +{"index":{}} +{"ts":"2026-01-01 02:00:00","category":"beta","value":52} +{"index":{}} +{"ts":"2026-01-01 02:00:00","category":"beta","value":53} +{"index":{}} +{"ts":"2026-01-01 03:00:00","category":"alpha","value":54} +{"index":{}} +{"ts":"2026-01-01 03:00:00","category":"alpha","value":55} +{"index":{}} +{"ts":"2026-01-01 03:00:00","category":"alpha","value":56} +{"index":{}} +{"ts":"2026-01-01 03:00:00","category":"alpha","value":57} +{"index":{}} +{"ts":"2026-01-01 03:00:00","category":"alpha","value":58} +{"index":{}} +{"ts":"2026-01-01 03:00:00","category":"alpha","value":59} +{"index":{}} +{"ts":"2026-01-01 03:00:00","category":"alpha","value":60} +{"index":{}} +{"ts":"2026-01-01 03:00:00","category":"alpha","value":61} +{"index":{}} +{"ts":"2026-01-01 03:00:00","category":"alpha","value":62} +{"index":{}} +{"ts":"2026-01-01 03:00:00","category":"alpha","value":63} +{"index":{}} +{"ts":"2026-01-01 03:00:00","category":"alpha","value":64} +{"index":{}} +{"ts":"2026-01-01 03:00:00","category":"alpha","value":65} +{"index":{}} +{"ts":"2026-01-01 03:00:00","category":"alpha","value":66} +{"index":{}} +{"ts":"2026-01-01 03:00:00","category":"alpha","value":67} +{"index":{}} +{"ts":"2026-01-01 03:00:00","category":"alpha","value":68} +{"index":{}} +{"ts":"2026-01-01 03:00:00","category":"alpha","value":69} +{"index":{}} +{"ts":"2026-01-01 03:00:00","category":"alpha","value":70} +{"index":{}} +{"ts":"2026-01-01 03:00:00","category":"alpha","value":71} +{"index":{}} +{"ts":"2026-01-01 03:00:00","category":"alpha","value":72} diff --git a/language-grammar/src/main/antlr4/OpenSearchSQLParser.g4 b/language-grammar/src/main/antlr4/OpenSearchSQLParser.g4 index 5f7361160b3..5162e6d1e78 100644 --- a/language-grammar/src/main/antlr4/OpenSearchSQLParser.g4 +++ b/language-grammar/src/main/antlr4/OpenSearchSQLParser.g4 @@ -335,6 +335,7 @@ functionCall | extractFunction # extractFunctionCall | getFormatFunction # getFormatFunctionCall | timestampFunction # timestampFunctionCall + | bucketFunction # bucketFunctionCall ; timestampFunction @@ -396,6 +397,14 @@ highlightFunction : HIGHLIGHT LR_BRACKET relevanceField (COMMA highlightArg)* RR_BRACKET ; +bucketFunction + : bucketFunctionName LR_BRACKET bucketArg (COMMA bucketArg)* RR_BRACKET + ; + +bucketArg + : bucketArgName EQUAL_SYMBOL bucketArgValue + ; + positionFunction : POSITION LR_BRACKET functionArg IN functionArg RR_BRACKET ; @@ -413,6 +422,11 @@ scalarFunctionName | nestedFunctionName ; +bucketFunctionName + : HISTOGRAM + | DATE_HISTOGRAM + ; + specificFunction : CASE expression caseFuncAlternative+ (ELSE elseArg = functionArg)? END # caseFunctionCall | CASE caseFuncAlternative+ (ELSE elseArg = functionArg)? END # caseFunctionCall @@ -756,6 +770,10 @@ highlightArgName | HIGHLIGHT_PRE_TAGS ; +bucketArgName + : stringLiteral + ; + relevanceFieldAndWeight : field = relevanceField | field = relevanceField weight = relevanceFieldWeight @@ -780,6 +798,11 @@ relevanceArgValue | constant ; +bucketArgValue + : constant + | qualifiedName + ; + highlightArgValue : stringLiteral ; diff --git a/sql/src/main/antlr/OpenSearchSQLParser.g4 b/sql/src/main/antlr/OpenSearchSQLParser.g4 index 5b52b9d3387..fa0b5b91ea9 100644 --- a/sql/src/main/antlr/OpenSearchSQLParser.g4 +++ b/sql/src/main/antlr/OpenSearchSQLParser.g4 @@ -368,6 +368,7 @@ functionCall | extractFunction # extractFunctionCall | getFormatFunction # getFormatFunctionCall | timestampFunction # timestampFunctionCall + | bucketFunction # bucketFunctionCall ; timestampFunction @@ -429,6 +430,14 @@ highlightFunction : HIGHLIGHT LR_BRACKET relevanceField (COMMA highlightArg)* RR_BRACKET ; +bucketFunction + : bucketFunctionName LR_BRACKET bucketArg (COMMA bucketArg)* RR_BRACKET + ; + +bucketArg + : bucketArgName EQUAL_SYMBOL bucketArgValue + ; + positionFunction : POSITION LR_BRACKET functionArg IN functionArg RR_BRACKET ; @@ -446,6 +455,11 @@ scalarFunctionName | nestedFunctionName ; +bucketFunctionName + : HISTOGRAM + | DATE_HISTOGRAM + ; + specificFunction : CASE expression caseFuncAlternative+ (ELSE elseArg = functionArg)? END # caseFunctionCall | CASE caseFuncAlternative+ (ELSE elseArg = functionArg)? END # caseFunctionCall @@ -789,6 +803,10 @@ highlightArgName | HIGHLIGHT_PRE_TAGS ; +bucketArgName + : stringLiteral + ; + relevanceFieldAndWeight : field = relevanceField | field = relevanceField weight = relevanceFieldWeight @@ -813,6 +831,11 @@ relevanceArgValue | constant ; +bucketArgValue + : constant + | qualifiedName + ; + highlightArgValue : stringLiteral ; diff --git a/sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java b/sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java index e7510f31b7a..c65352335d4 100644 --- a/sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java +++ b/sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java @@ -20,6 +20,8 @@ import static org.opensearch.sql.sql.antlr.parser.OpenSearchSQLParser.BetweenPredicateContext; import static org.opensearch.sql.sql.antlr.parser.OpenSearchSQLParser.BinaryComparisonPredicateContext; import static org.opensearch.sql.sql.antlr.parser.OpenSearchSQLParser.BooleanContext; +import static org.opensearch.sql.sql.antlr.parser.OpenSearchSQLParser.BucketArgContext; +import static org.opensearch.sql.sql.antlr.parser.OpenSearchSQLParser.BucketFunctionCallContext; import static org.opensearch.sql.sql.antlr.parser.OpenSearchSQLParser.CaseFuncAlternativeContext; import static org.opensearch.sql.sql.antlr.parser.OpenSearchSQLParser.CaseFunctionCallContext; import static org.opensearch.sql.sql.antlr.parser.OpenSearchSQLParser.ColumnFilterContext; @@ -70,14 +72,18 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import java.time.ZoneOffset; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; +import java.util.stream.Stream; import org.antlr.v4.runtime.RuleContext; import org.antlr.v4.runtime.tree.ParseTree; import org.antlr.v4.runtime.tree.RuleNode; @@ -89,6 +95,7 @@ import org.opensearch.sql.common.antlr.AstBuildGuard; import org.opensearch.sql.common.antlr.SyntaxCheckException; import org.opensearch.sql.common.utils.StringUtils; +import org.opensearch.sql.exception.SemanticCheckException; import org.opensearch.sql.expression.function.BuiltinFunctionName; import org.opensearch.sql.sql.antlr.parser.OpenSearchSQLParser; import org.opensearch.sql.sql.antlr.parser.OpenSearchSQLParser.AlternateMultiMatchQueryContext; @@ -165,6 +172,130 @@ public UnresolvedExpression visitScalarFunctionCall(ScalarFunctionCallContext ct return buildFunction(ctx.scalarFunctionName().getText(), ctx.functionArgs().functionArg()); } + /** + * Lowers {@code histogram} and {@code date_histogram} to a {@link Span} over the bucketed field. + * The grammar admits only the {@code 'name'=value} form, so the positional spelling the legacy + * engine has always answered never reaches here -- it stays an unknown scalar function, and + * RestSQLQueryAction hands it back to that engine. + */ + @Override + public UnresolvedExpression visitBucketFunctionCall(BucketFunctionCallContext ctx) { + String functionName = + ctx.bucketFunction().bucketFunctionName().getText().toLowerCase(Locale.ROOT); + Map args = new LinkedHashMap<>(); + for (BucketArgContext arg : ctx.bucketFunction().bucketArg()) { + String name = StringUtils.unquoteText(arg.bucketArgName().getText()).toLowerCase(Locale.ROOT); + if (args.put(name, visit(arg.bucketArgValue())) != null) { + throw new SemanticCheckException("Duplicate parameter: " + name); + } + } + + UnresolvedExpression field = requireArg(args, "field", functionName); + UnresolvedExpression missing = args.remove("missing"); + Literal interval = intervalOf(args, functionName); + Literal format = stringArg(args, "format"); + Literal timeZone = stringArg(args, "time_zone"); + + // Anything left is a parameter with no lowering here. Some of them -- alias, min_doc_count, + // order -- are implemented by the legacy engine, so decline in the one way RestSQLQueryAction + // falls back on rather than failing the request outright. + if (!args.isEmpty()) { + throw new SyntaxCheckException( + functionName + " does not accept parameter: " + String.join(", ", args.keySet())); + } + + UnresolvedExpression bucketed = coalesceMissing(normalizeField(field), missing); + if (timeZone != null) { + bucketed = shiftByTimeZone(bucketed, timeZone); + } + Span span = AstDSL.spanFromSpanLengthLiteral(bucketed, interval); + return format == null ? span : new Function("date_format", List.of(span, format)); + } + + private static UnresolvedExpression requireArg( + Map args, String name, String functionName) { + UnresolvedExpression value = args.remove(name); + if (value == null) { + throw new SemanticCheckException(functionName + " requires " + name + " parameter"); + } + return value; + } + + /** + * {@code interval}, {@code fixed_interval} and {@code calendar_interval} are synonyms; exactly + * one must be present. The distinction between calendar and fixed intervals is not preserved. + */ + private static Literal intervalOf(Map args, String functionName) { + List supplied = + Stream.of("interval", "fixed_interval", "calendar_interval") + .map(key -> stringOrNumericArg(args, key)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + if (supplied.isEmpty()) { + throw new SemanticCheckException( + functionName + " requires one of: interval, fixed_interval, calendar_interval"); + } + if (supplied.size() > 1) { + throw new SemanticCheckException( + functionName + " accepts only one of: interval, fixed_interval, calendar_interval"); + } + return supplied.get(0); + } + + private static Literal stringArg(Map args, String name) { + UnresolvedExpression value = args.remove(name); + if (value == null) { + return null; + } + if (!(value instanceof Literal literal) || literal.getType() != DataType.STRING) { + throw new SemanticCheckException( + name + " must be a string literal (e.g. '1d', '15m'); got " + value); + } + return literal; + } + + private static Literal stringOrNumericArg(Map args, String name) { + UnresolvedExpression value = args.remove(name); + if (value == null) { + return null; + } + if (!(value instanceof Literal literal)) { + throw new SemanticCheckException(name + " must be a literal; got " + value); + } + return literal; + } + + /** A string literal naming a column is coerced so downstream sees a column reference. */ + private static UnresolvedExpression normalizeField(UnresolvedExpression field) { + if (field instanceof Literal literal && literal.getType() == DataType.STRING) { + return AstDSL.qualifiedName(literal.getValue().toString()); + } + return field; + } + + private static UnresolvedExpression coalesceMissing( + UnresolvedExpression field, UnresolvedExpression missing) { + return missing == null ? field : new Function("coalesce", List.of(field, missing)); + } + + /** + * Shifts the field by a {@link ZoneOffset} before bucketing. Validated here so an invalid offset + * is reported rather than surfacing as an arithmetic failure at execution. + */ + private static UnresolvedExpression shiftByTimeZone( + UnresolvedExpression field, Literal timeZone) { + String offset = timeZone.getValue().toString(); + int seconds; + try { + seconds = ZoneOffset.of(offset).getTotalSeconds(); + } catch (RuntimeException e) { + throw new SemanticCheckException( + "time_zone must be a valid offset like '+05:30' or 'Z'; got '" + offset + "'"); + } + return new Function( + "timestampadd", List.of(AstDSL.stringLiteral("SECOND"), AstDSL.intLiteral(seconds), field)); + } + @Override public UnresolvedExpression visitGetFormatFunctionCall(GetFormatFunctionCallContext ctx) { return new Function( diff --git a/sql/src/test/java/org/opensearch/sql/sql/parser/AstExpressionBuilderTest.java b/sql/src/test/java/org/opensearch/sql/sql/parser/AstExpressionBuilderTest.java index aba8023b07e..51f919001a7 100644 --- a/sql/src/test/java/org/opensearch/sql/sql/parser/AstExpressionBuilderTest.java +++ b/sql/src/test/java/org/opensearch/sql/sql/parser/AstExpressionBuilderTest.java @@ -47,12 +47,16 @@ import org.opensearch.sql.ast.expression.DataType; import org.opensearch.sql.ast.expression.Literal; import org.opensearch.sql.ast.expression.RelevanceFieldList; +import org.opensearch.sql.ast.expression.Span; +import org.opensearch.sql.ast.expression.SpanUnit; import org.opensearch.sql.ast.expression.WindowFrame; import org.opensearch.sql.ast.expression.WindowFunction; import org.opensearch.sql.ast.tree.Sort.SortOption; import org.opensearch.sql.common.antlr.AstBuildGuard; import org.opensearch.sql.common.antlr.CaseInsensitiveCharStream; import org.opensearch.sql.common.antlr.SyntaxAnalysisErrorListener; +import org.opensearch.sql.common.antlr.SyntaxCheckException; +import org.opensearch.sql.exception.SemanticCheckException; import org.opensearch.sql.sql.antlr.parser.OpenSearchSQLLexer; import org.opensearch.sql.sql.antlr.parser.OpenSearchSQLParser; @@ -860,6 +864,114 @@ private static String nest(int depth, String base, UnaryOperator wrap) { return expr; } + @Test + public void canBuildDateHistogramAsSpan() { + assertEquals( + new Span(qualifiedName("ts"), intLiteral(1), SpanUnit.H), + buildExprAst("date_histogram('field'=ts, 'interval'='1h')")); + } + + @Test + public void canBuildDateHistogramWithIntervalSynonyms() { + Span expected = new Span(qualifiedName("ts"), intLiteral(1), SpanUnit.D); + assertEquals(expected, buildExprAst("date_histogram('field'=ts, 'fixed_interval'='1d')")); + assertEquals(expected, buildExprAst("date_histogram('field'=ts, 'calendar_interval'='1d')")); + } + + @Test + public void canBuildDateHistogramWithStringFieldName() { + assertEquals( + new Span(qualifiedName("ts"), intLiteral(30), SpanUnit.m), + buildExprAst("date_histogram('field'='ts', 'interval'='30m')")); + } + + /** A numeric literal field is left alone rather than coerced to a column reference. */ + @Test + public void bucketFieldGivenNonStringLiteralIsPassedThrough() { + assertEquals( + new Span(intLiteral(1), intLiteral(10), SpanUnit.NONE), + buildExprAst("histogram('field'=1, 'interval'=10)")); + } + + @Test + public void canBuildNumericHistogramAsSpan() { + assertEquals( + new Span(qualifiedName("age"), intLiteral(10), SpanUnit.NONE), + buildExprAst("histogram('field'=age, 'interval'=10)")); + } + + @Test + public void canBuildDateHistogramWithMissing() { + assertEquals( + new Span( + function("coalesce", qualifiedName("ts"), stringLiteral("1970-01-01")), + intLiteral(1), + SpanUnit.H), + buildExprAst("date_histogram('field'=ts, 'interval'='1h', 'missing'='1970-01-01')")); + } + + @Test + public void canBuildDateHistogramWithTimeZoneShift() { + assertEquals( + new Span( + function( + "timestampadd", stringLiteral("SECOND"), intLiteral(19800), qualifiedName("ts")), + intLiteral(1), + SpanUnit.H), + buildExprAst("date_histogram('field'=ts, 'interval'='1h', 'time_zone'='+05:30')")); + } + + @Test + public void canBuildDateHistogramWithFormat() { + assertEquals( + function( + "date_format", + new Span(qualifiedName("ts"), intLiteral(1), SpanUnit.D), + stringLiteral("yyyy-MM-dd")), + buildExprAst("date_histogram('field'=ts, 'interval'='1d', 'format'='yyyy-MM-dd')")); + } + + /** + * A parameter with no lowering here has to raise SyntaxCheckException -- the one type + * RestSQLQueryAction falls back on -- because the legacy engine implements alias, min_doc_count + * and order, and has answered queries using them for years. + */ + @Test + public void unsupportedBucketParameterDefersToLegacyEngine() { + assertThrows( + SyntaxCheckException.class, + () -> buildExprAst("date_histogram('field'=ts, 'interval'='1d', 'alias'='days')")); + assertThrows( + SyntaxCheckException.class, + () -> buildExprAst("histogram('field'=age, 'interval'=10, 'min_doc_count'=1)")); + } + + /** A bad argument inside a shape we own must not fall back, so the caller sees this message. */ + @Test + public void badBucketArgumentIsReportedRatherThanDeferred() { + assertThrows( + SemanticCheckException.class, () -> buildExprAst("date_histogram('interval'='1d')")); + assertThrows(SemanticCheckException.class, () -> buildExprAst("date_histogram('field'=ts)")); + assertThrows( + SemanticCheckException.class, + () -> buildExprAst("date_histogram('field'=ts, 'interval'='1d', 'fixed_interval'='2d')")); + assertThrows( + SemanticCheckException.class, + () -> buildExprAst("date_histogram('field'=ts, 'interval'='1d', 'time_zone'='nope')")); + assertThrows( + SemanticCheckException.class, + () -> buildExprAst("date_histogram('field'=ts, 'interval'='1d', 'format'=7)")); + assertThrows( + SemanticCheckException.class, + () -> buildExprAst("date_histogram('field'=ts, 'interval'='1d', 'format'=other)")); + assertThrows( + SemanticCheckException.class, + () -> buildExprAst("date_histogram('field'=ts, 'interval'=ts)")); + assertThrows( + SemanticCheckException.class, + () -> buildExprAst("date_histogram('field'=ts, 'interval'='1d', 'interval'='2d')")); + } + private Node buildExprAst(String expr) { return buildExprAst(expr, astExprBuilder); }