Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions language-grammar/src/main/antlr4/OpenSearchSQLParser.g4
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ scalarFunctionName
bucketFunctionName
: HISTOGRAM
| DATE_HISTOGRAM
| RANGE
;

specificFunction
Expand Down
1 change: 1 addition & 0 deletions sql/src/main/antlr/OpenSearchSQLParser.g4
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,7 @@ scalarFunctionName
bucketFunctionName
: HISTOGRAM
| DATE_HISTOGRAM
| RANGE
;

specificFunction
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ public final class BucketFunctionRegistry {
private static final Map<String, BucketFunctionExpander> 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() {}

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>Lowering example for {@code range(price, 0, 100, 200)}:
*
* <pre>{@code
* CASE
* WHEN price >= 0 AND price < 100 THEN '0-100'
* WHEN price >= 100 AND price < 200 THEN '100-200'
* END
* }</pre>
*
* <p>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<UnresolvedExpression> args) {
if (NamedArguments.isNamedArguments(args)) {
throw new SemanticCheckException(
"range requires positional arguments: range(<column>, <bound1>, <bound2>, ...)");
}
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<Literal> 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<Literal> requireAscendingNumericBoundaries(List<UnresolvedExpression> raws) {
List<Literal> 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 '<p_i>-<p_{i+1}>' ... END} with
* one branch per consecutive boundary pair.
*/
private static Case buildCase(UnresolvedExpression field, List<Literal> boundaries) {
List<When> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,25 @@ void lookup_returns_DateHistogramExpander_for_DATE_HISTOGRAM() {
assertInstanceOf(DateHistogramExpander.class, expander.get());
}

@Test
void lookup_returns_RangeExpander_for_RANGE() {
Optional<BucketFunctionExpander> 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());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}