diff --git a/language-grammar/src/main/antlr4/OpenSearchSQLParser.g4 b/language-grammar/src/main/antlr4/OpenSearchSQLParser.g4 index 4a2ab35a89b..1c6b42edace 100644 --- a/language-grammar/src/main/antlr4/OpenSearchSQLParser.g4 +++ b/language-grammar/src/main/antlr4/OpenSearchSQLParser.g4 @@ -417,6 +417,7 @@ scalarFunctionName bucketFunctionName : HISTOGRAM | DATE_HISTOGRAM + | RANGE ; specificFunction diff --git a/sql/src/main/antlr/OpenSearchSQLParser.g4 b/sql/src/main/antlr/OpenSearchSQLParser.g4 index 8cf5a69a882..c81d8150ef3 100644 --- a/sql/src/main/antlr/OpenSearchSQLParser.g4 +++ b/sql/src/main/antlr/OpenSearchSQLParser.g4 @@ -449,6 +449,7 @@ scalarFunctionName bucketFunctionName : HISTOGRAM | DATE_HISTOGRAM + | RANGE ; specificFunction diff --git a/sql/src/main/java/org/opensearch/sql/sql/parser/bucket/BucketFunctionRegistry.java b/sql/src/main/java/org/opensearch/sql/sql/parser/bucket/BucketFunctionRegistry.java index e1471597689..1bc77b8640c 100644 --- a/sql/src/main/java/org/opensearch/sql/sql/parser/bucket/BucketFunctionRegistry.java +++ b/sql/src/main/java/org/opensearch/sql/sql/parser/bucket/BucketFunctionRegistry.java @@ -15,7 +15,8 @@ public final class BucketFunctionRegistry { private static final Map EXPANDERS = Map.of( HistogramExpander.FUNCTION_NAME, new HistogramExpander(), - DateHistogramExpander.FUNCTION_NAME, new DateHistogramExpander()); + DateHistogramExpander.FUNCTION_NAME, new DateHistogramExpander(), + RangeExpander.FUNCTION_NAME, new RangeExpander()); private BucketFunctionRegistry() {} diff --git a/sql/src/main/java/org/opensearch/sql/sql/parser/bucket/RangeExpander.java b/sql/src/main/java/org/opensearch/sql/sql/parser/bucket/RangeExpander.java new file mode 100644 index 00000000000..aab681f4d57 --- /dev/null +++ b/sql/src/main/java/org/opensearch/sql/sql/parser/bucket/RangeExpander.java @@ -0,0 +1,116 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.sql.parser.bucket; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.opensearch.sql.ast.dsl.AstDSL; +import org.opensearch.sql.ast.expression.And; +import org.opensearch.sql.ast.expression.Case; +import org.opensearch.sql.ast.expression.DataType; +import org.opensearch.sql.ast.expression.Function; +import org.opensearch.sql.ast.expression.Literal; +import org.opensearch.sql.ast.expression.QualifiedName; +import org.opensearch.sql.ast.expression.UnresolvedExpression; +import org.opensearch.sql.ast.expression.When; +import org.opensearch.sql.exception.SemanticCheckException; + +/** + * Lowers {@code range(field, p1, p2, ..., pN)} calls to a {@code CASE WHEN} chain producing one + * bucket label per row. Positional invocation only, numeric boundaries only, and N boundaries + * produce N-1 buckets — no synthetic outer-edge buckets, no modifier parameters. + * + *

Lowering example for {@code range(price, 0, 100, 200)}: + * + *

{@code
+ * CASE
+ *   WHEN price >= 0   AND price < 100 THEN '0-100'
+ *   WHEN price >= 100 AND price < 200 THEN '100-200'
+ * END
+ * }
+ * + *

Boundary semantics: {@code [from, to)} (closed lower, open upper). Rows whose field falls + * outside {@code [p1, pN)} produce {@code NULL}; downstream {@code GROUP BY} aggregation groups + * them into the NULL bucket. + */ +final class RangeExpander implements BucketFunctionExpander { + + static final String FUNCTION_NAME = "RANGE"; + + @Override + public UnresolvedExpression expand(List args) { + if (NamedArguments.isNamedArguments(args)) { + throw new SemanticCheckException( + "range requires positional arguments: range(, , , ...)"); + } + if (args.size() < 3) { + throw new SemanticCheckException( + "range requires a field and at least two boundaries; got " + args.size() + " arguments"); + } + UnresolvedExpression field = requireFieldRef(args.get(0)); + List boundaries = requireAscendingNumericBoundaries(args.subList(1, args.size())); + return buildCase(field, boundaries); + } + + private static UnresolvedExpression requireFieldRef(UnresolvedExpression expr) { + if (!(expr instanceof QualifiedName)) { + throw new SemanticCheckException("range field must be a column reference; got " + expr); + } + return expr; + } + + /** + * Validates that every boundary is a numeric literal and that the sequence is strictly ascending. + * Returns the typed list for the lowering step. + */ + private static List requireAscendingNumericBoundaries(List raws) { + List boundaries = new ArrayList<>(raws.size()); + Double previous = null; + for (UnresolvedExpression raw : raws) { + if (!(raw instanceof Literal lit) || !isNumeric(lit)) { + throw new SemanticCheckException("range boundaries must be numeric literals; got " + raw); + } + double current = ((Number) lit.getValue()).doubleValue(); + if (previous != null && current <= previous) { + throw new SemanticCheckException( + "range boundaries must be in strictly ascending order; got " + + previous + + " then " + + current); + } + previous = current; + boundaries.add(lit); + } + return boundaries; + } + + private static boolean isNumeric(Literal literal) { + DataType type = literal.getType(); + return type == DataType.INTEGER + || type == DataType.LONG + || type == DataType.FLOAT + || type == DataType.DOUBLE + || type == DataType.SHORT; + } + + /** + * Emits {@code CASE WHEN field >= p_i AND field < p_{i+1} THEN '-' ... END} with + * one branch per consecutive boundary pair. + */ + private static Case buildCase(UnresolvedExpression field, List boundaries) { + List whenClauses = new ArrayList<>(boundaries.size() - 1); + for (int i = 0; i < boundaries.size() - 1; i++) { + Literal from = boundaries.get(i); + Literal to = boundaries.get(i + 1); + UnresolvedExpression condition = + new And(new Function(">=", List.of(field, from)), new Function("<", List.of(field, to))); + String label = from.getValue() + "-" + to.getValue(); + whenClauses.add(new When(condition, AstDSL.stringLiteral(label))); + } + return new Case(null, whenClauses, Optional.empty()); + } +} diff --git a/sql/src/test/java/org/opensearch/sql/sql/parser/bucket/BucketFunctionRegistryTest.java b/sql/src/test/java/org/opensearch/sql/sql/parser/bucket/BucketFunctionRegistryTest.java index 9dc5acf2572..8445f11cb0a 100644 --- a/sql/src/test/java/org/opensearch/sql/sql/parser/bucket/BucketFunctionRegistryTest.java +++ b/sql/src/test/java/org/opensearch/sql/sql/parser/bucket/BucketFunctionRegistryTest.java @@ -31,17 +31,25 @@ void lookup_returns_DateHistogramExpander_for_DATE_HISTOGRAM() { assertInstanceOf(DateHistogramExpander.class, expander.get()); } + @Test + void lookup_returns_RangeExpander_for_RANGE() { + Optional expander = BucketFunctionRegistry.lookup("RANGE"); + assertTrue(expander.isPresent()); + assertInstanceOf(RangeExpander.class, expander.get()); + } + @Test void lookup_is_case_insensitive() { assertTrue(BucketFunctionRegistry.lookup("histogram").isPresent()); assertTrue(BucketFunctionRegistry.lookup("Histogram").isPresent()); assertTrue(BucketFunctionRegistry.lookup("date_histogram").isPresent()); assertTrue(BucketFunctionRegistry.lookup("Date_Histogram").isPresent()); + assertTrue(BucketFunctionRegistry.lookup("range").isPresent()); + assertTrue(BucketFunctionRegistry.lookup("Range").isPresent()); } @Test void lookup_returns_empty_for_unknown_function() { - assertFalse(BucketFunctionRegistry.lookup("range").isPresent()); assertFalse(BucketFunctionRegistry.lookup("SUM").isPresent()); assertFalse(BucketFunctionRegistry.lookup("FLOOR").isPresent()); } diff --git a/sql/src/test/java/org/opensearch/sql/sql/parser/bucket/RangeExpanderTest.java b/sql/src/test/java/org/opensearch/sql/sql/parser/bucket/RangeExpanderTest.java new file mode 100644 index 00000000000..94aa51b5037 --- /dev/null +++ b/sql/src/test/java/org/opensearch/sql/sql/parser/bucket/RangeExpanderTest.java @@ -0,0 +1,185 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.sql.parser.bucket; + +import static java.util.Collections.emptyList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.common.collect.ImmutableList; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.opensearch.sql.ast.dsl.AstDSL; +import org.opensearch.sql.ast.expression.AllFields; +import org.opensearch.sql.ast.expression.And; +import org.opensearch.sql.ast.expression.Case; +import org.opensearch.sql.ast.expression.Function; +import org.opensearch.sql.ast.expression.QualifiedName; +import org.opensearch.sql.ast.expression.UnresolvedExpression; +import org.opensearch.sql.ast.expression.When; +import org.opensearch.sql.ast.tree.UnresolvedPlan; +import org.opensearch.sql.exception.SemanticCheckException; +import org.opensearch.sql.sql.parser.AstBuilderTestBase; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class RangeExpanderTest extends AstBuilderTestBase { + + private final RangeExpander expander = new RangeExpander(); + + @Test + void rejects_named_argument_invocation_with_clear_message() { + SemanticCheckException ex = + assertThrows( + SemanticCheckException.class, + () -> + expander.expand( + List.of( + new Function( + "=", + List.of(AstDSL.stringLiteral("field"), AstDSL.stringLiteral("price"))), + new Function( + "=", + List.of( + AstDSL.stringLiteral("ranges"), AstDSL.stringLiteral("0,100")))))); + assertTrue(ex.getMessage().contains("positional")); + } + + @Test + void rejects_too_few_arguments() { + QualifiedName price = AstDSL.qualifiedName("price"); + assertThrows( + SemanticCheckException.class, () -> expander.expand(List.of(price, AstDSL.intLiteral(0)))); + } + + @Test + void rejects_non_column_field() { + assertThrows( + SemanticCheckException.class, + () -> + expander.expand( + List.of(AstDSL.intLiteral(42), AstDSL.intLiteral(0), AstDSL.intLiteral(100)))); + } + + @Test + void rejects_non_numeric_boundary() { + QualifiedName price = AstDSL.qualifiedName("price"); + assertThrows( + SemanticCheckException.class, + () -> expander.expand(List.of(price, AstDSL.intLiteral(0), AstDSL.stringLiteral("100")))); + } + + @Test + void rejects_non_ascending_boundaries() { + QualifiedName price = AstDSL.qualifiedName("price"); + SemanticCheckException ex = + assertThrows( + SemanticCheckException.class, + () -> + expander.expand( + List.of( + price, + AstDSL.intLiteral(100), + AstDSL.intLiteral(0), + AstDSL.intLiteral(200)))); + assertTrue(ex.getMessage().contains("ascending")); + } + + @Test + void rejects_equal_consecutive_boundaries() { + QualifiedName price = AstDSL.qualifiedName("price"); + assertThrows( + SemanticCheckException.class, + () -> expander.expand(List.of(price, AstDSL.intLiteral(100), AstDSL.intLiteral(100)))); + } + + @Test + void two_boundaries_lower_to_single_bucket_case() { + QualifiedName price = AstDSL.qualifiedName("price"); + + UnresolvedExpression result = + expander.expand(List.of(price, AstDSL.intLiteral(0), AstDSL.intLiteral(100))); + + Case expected = + new Case( + null, + List.of( + new When( + new And( + new Function(">=", List.of(price, AstDSL.intLiteral(0))), + new Function("<", List.of(price, AstDSL.intLiteral(100)))), + AstDSL.stringLiteral("0-100"))), + Optional.empty()); + assertEquals(expected, result); + } + + @Test + void three_boundaries_lower_to_two_bucket_case() { + QualifiedName price = AstDSL.qualifiedName("price"); + + UnresolvedExpression result = + expander.expand( + List.of(price, AstDSL.intLiteral(0), AstDSL.intLiteral(100), AstDSL.intLiteral(200))); + + Case expected = + new Case( + null, + List.of( + new When( + new And( + new Function(">=", List.of(price, AstDSL.intLiteral(0))), + new Function("<", List.of(price, AstDSL.intLiteral(100)))), + AstDSL.stringLiteral("0-100")), + new When( + new And( + new Function(">=", List.of(price, AstDSL.intLiteral(100))), + new Function("<", List.of(price, AstDSL.intLiteral(200)))), + AstDSL.stringLiteral("100-200"))), + Optional.empty()); + assertEquals(expected, result); + } + + @Test + void via_sql_lowers_to_case_chain() { + QualifiedName price = AstDSL.qualifiedName("price"); + Case bucket = + new Case( + null, + List.of( + new When( + new And( + new Function(">=", List.of(price, AstDSL.intLiteral(0))), + new Function("<", List.of(price, AstDSL.intLiteral(100)))), + AstDSL.stringLiteral("0-100")), + new When( + new And( + new Function(">=", List.of(price, AstDSL.intLiteral(100))), + new Function("<", List.of(price, AstDSL.intLiteral(200)))), + AstDSL.stringLiteral("100-200"))), + Optional.empty()); + + UnresolvedPlan result = + buildAST( + "SELECT range(price, 0, 100, 200), COUNT(*) FROM orders " + + "GROUP BY range(price, 0, 100, 200)"); + + assertEquals( + AstDSL.project( + AstDSL.agg( + AstDSL.relation("orders"), + ImmutableList.of( + AstDSL.alias("COUNT(*)", AstDSL.aggregate("COUNT", AllFields.of()))), + emptyList(), + ImmutableList.of(AstDSL.alias(bucket.toString(), bucket)), + emptyList()), + AstDSL.alias("range(price, 0, 100, 200)", bucket), + AstDSL.alias("COUNT(*)", AstDSL.aggregate("COUNT", AllFields.of()))), + result); + } +}