From 280482c7a3ef90c55ec48618cc2b0f55b306371d Mon Sep 17 00:00:00 2001 From: Chloe Date: Wed, 6 Jan 2021 16:52:25 -0600 Subject: [PATCH 1/2] Support filter clause in aggregations (#960) * support filter clause in aggregation * added unit test * added comparison test cases * added user doc * address comments * added unit test for optimization * addressed comments --- .../sql/analysis/ExpressionAnalyzer.java | 7 +- .../sql/ast/dsl/AstDSL.java | 5 ++ .../sql/ast/expression/AggregateFunction.java | 15 ++++ .../expression/aggregation/Aggregator.java | 37 ++++++++- .../expression/aggregation/AvgAggregator.java | 10 +-- .../aggregation/CountAggregator.java | 8 +- .../expression/aggregation/MaxAggregator.java | 8 +- .../expression/aggregation/MinAggregator.java | 8 +- .../aggregation/NamedAggregator.java | 5 +- .../expression/aggregation/SumAggregator.java | 10 +-- .../sql/analysis/ExpressionAnalyzerTest.java | 14 ++++ .../aggregation/AvgAggregatorTest.java | 7 ++ .../aggregation/CountAggregatorTest.java | 7 ++ .../aggregation/MaxAggregatorTest.java | 7 ++ .../aggregation/MinAggregatorTest.java | 7 ++ .../aggregation/SumAggregatorTest.java | 7 ++ docs/user/dql/aggregations.rst | 39 +++++++++ ...lasticsearchAggregationResponseParser.java | 8 ++ .../dsl/MetricAggregationBuilder.java | 41 +++++++-- .../ElasticsearchLogicOptimizerTest.java | 56 +++++++++++++ .../response/AggregationResponseUtils.java | 4 + ...icsearchAggregationResponseParserTest.java | 59 +++++++++++++ .../AggregationQueryBuilderTest.java | 83 ++++++++++++++++++- .../resources/correctness/queries/filter.txt | 6 ++ sql/src/main/antlr/OpenDistroSQLParser.g4 | 5 ++ .../sql/sql/parser/AstExpressionBuilder.java | 12 +++ .../parser/context/QuerySpecification.java | 9 ++ .../sql/parser/AstExpressionBuilderTest.java | 9 ++ .../context/QuerySpecificationTest.java | 12 +++ 29 files changed, 457 insertions(+), 48 deletions(-) create mode 100644 integ-test/src/test/resources/correctness/queries/filter.txt diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionAnalyzer.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionAnalyzer.java index 06d90608f6..a7ab7e9702 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionAnalyzer.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionAnalyzer.java @@ -145,9 +145,12 @@ public Expression visitAggregateFunction(AggregateFunction node, AnalysisContext Optional builtinFunctionName = BuiltinFunctionName.of(node.getFuncName()); if (builtinFunctionName.isPresent()) { Expression arg = node.getField().accept(this, context); - return (Aggregator) - repository.compile( + Aggregator aggregator = (Aggregator) repository.compile( builtinFunctionName.get().getName(), Collections.singletonList(arg)); + if (node.getCondition() != null) { + aggregator.condition(analyze(node.getCondition(), context)); + } + return aggregator; } else { throw new SemanticCheckException("Unsupported aggregation function " + node.getFuncName()); } diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/ast/dsl/AstDSL.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/ast/dsl/AstDSL.java index ae5ae3ab55..2c53b5aa0c 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/ast/dsl/AstDSL.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/ast/dsl/AstDSL.java @@ -194,6 +194,11 @@ public static UnresolvedExpression aggregate( return new AggregateFunction(func, field, Arrays.asList(args)); } + public static UnresolvedExpression filteredAggregate( + String func, UnresolvedExpression field, UnresolvedExpression condition) { + return new AggregateFunction(func, field, condition); + } + public static Function function(String funcName, UnresolvedExpression... funcArgs) { return new Function(funcName, Arrays.asList(funcArgs)); } diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/ast/expression/AggregateFunction.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/ast/expression/AggregateFunction.java index ee8fdaf3b7..5def9fcc89 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/ast/expression/AggregateFunction.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/ast/expression/AggregateFunction.java @@ -34,6 +34,7 @@ public class AggregateFunction extends UnresolvedExpression { private final String funcName; private final UnresolvedExpression field; private final List argList; + private UnresolvedExpression condition; /** * Constructor. @@ -46,6 +47,20 @@ public AggregateFunction(String funcName, UnresolvedExpression field) { this.argList = Collections.emptyList(); } + /** + * Constructor. + * @param funcName function name. + * @param field {@link UnresolvedExpression}. + * @param condition condition in aggregation filter. + */ + public AggregateFunction(String funcName, UnresolvedExpression field, + UnresolvedExpression condition) { + this.funcName = funcName; + this.field = field; + this.argList = Collections.emptyList(); + this.condition = condition; + } + @Override public List getChild() { return Collections.singletonList(field); diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/Aggregator.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/Aggregator.java index c55f81f09e..ce9dd937f3 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/Aggregator.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/Aggregator.java @@ -17,6 +17,7 @@ import com.amazon.opendistroforelasticsearch.sql.analysis.ExpressionAnalyzer; import com.amazon.opendistroforelasticsearch.sql.data.model.ExprValue; +import com.amazon.opendistroforelasticsearch.sql.data.model.ExprValueUtils; import com.amazon.opendistroforelasticsearch.sql.data.type.ExprCoreType; import com.amazon.opendistroforelasticsearch.sql.data.type.ExprType; import com.amazon.opendistroforelasticsearch.sql.exception.ExpressionEvaluationException; @@ -30,6 +31,8 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; /** * Aggregator which will iterate on the {@link BindingTuple}s to aggregate the result. @@ -46,6 +49,10 @@ public abstract class Aggregator @Getter private final List arguments; protected final ExprCoreType returnType; + @Setter + @Getter + @Accessors(fluent = true) + protected Expression condition; /** * Create an {@link AggregationState} which will be used for aggregation. @@ -53,13 +60,29 @@ public abstract class Aggregator public abstract S create(); /** - * Iterate on the {@link BindingTuple}. + * Iterate on {@link ExprValue}. + * @param value {@link ExprValue} + * @param state {@link AggregationState} + * @return {@link AggregationState} + */ + protected abstract S iterate(ExprValue value, S state); + + /** + * Let the aggregator iterate on the {@link BindingTuple} + * To filter out ExprValues that are missing, null or cannot satisfy {@link #condition} + * Before the specific aggregator iterating ExprValue in the tuple. * * @param tuple {@link BindingTuple} * @param state {@link AggregationState} * @return {@link AggregationState} */ - public abstract S iterate(BindingTuple tuple, S state); + public S iterate(BindingTuple tuple, S state) { + ExprValue value = getArguments().get(0).valueOf(tuple); + if (value.isNull() || value.isMissing() || !conditionValue(tuple)) { + return state; + } + return iterate(value, state); + } @Override public ExprValue valueOf(Environment valueEnv) { @@ -77,4 +100,14 @@ public T accept(ExpressionNodeVisitor visitor, C context) { return visitor.visitAggregator(this, context); } + /** + * Util method to get value of condition in aggregation filter. + */ + public boolean conditionValue(BindingTuple tuple) { + if (condition == null) { + return true; + } + return ExprValueUtils.getBooleanValue(condition.valueOf(tuple)); + } + } diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/AvgAggregator.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/AvgAggregator.java index 917141f8aa..0e1d07c790 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/AvgAggregator.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/AvgAggregator.java @@ -43,13 +43,9 @@ public AvgState create() { } @Override - public AvgState iterate(BindingTuple tuple, AvgState state) { - Expression expression = getArguments().get(0); - ExprValue value = expression.valueOf(tuple); - if (!(value.isNull() || value.isMissing())) { - state.count++; - state.total += ExprValueUtils.getDoubleValue(value); - } + protected AvgState iterate(ExprValue value, AvgState state) { + state.count++; + state.total += ExprValueUtils.getDoubleValue(value); return state; } diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/CountAggregator.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/CountAggregator.java index 0e641ea8e8..596f3ae0b2 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/CountAggregator.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/CountAggregator.java @@ -39,12 +39,8 @@ public CountAggregator.CountState create() { } @Override - public CountState iterate(BindingTuple tuple, CountState state) { - Expression expression = getArguments().get(0); - ExprValue value = expression.valueOf(tuple); - if (!(value.isNull() || value.isMissing())) { - state.count++; - } + protected CountState iterate(ExprValue value, CountState state) { + state.count++; return state; } diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/MaxAggregator.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/MaxAggregator.java index 2800b40fce..4a4fce7896 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/MaxAggregator.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/MaxAggregator.java @@ -37,12 +37,8 @@ public MaxState create() { } @Override - public MaxState iterate(BindingTuple tuple, MaxState state) { - Expression expression = getArguments().get(0); - ExprValue value = expression.valueOf(tuple); - if (!(value.isNull() || value.isMissing())) { - state.max(value); - } + protected MaxState iterate(ExprValue value, MaxState state) { + state.max(value); return state; } diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/MinAggregator.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/MinAggregator.java index 7149b51eca..e03e75dcb7 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/MinAggregator.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/MinAggregator.java @@ -42,12 +42,8 @@ public MinState create() { } @Override - public MinState iterate(BindingTuple tuple, MinState state) { - Expression expression = getArguments().get(0); - ExprValue value = expression.valueOf(tuple); - if (!(value.isNull() || value.isMissing())) { - state.min(value); - } + protected MinState iterate(ExprValue value, MinState state) { + state.min(value); return state; } diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/NamedAggregator.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/NamedAggregator.java index c21beba10d..9d92d4f2e5 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/NamedAggregator.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/NamedAggregator.java @@ -17,6 +17,7 @@ package com.amazon.opendistroforelasticsearch.sql.expression.aggregation; +import com.amazon.opendistroforelasticsearch.sql.data.model.ExprValue; import com.amazon.opendistroforelasticsearch.sql.expression.ExpressionNodeVisitor; import com.amazon.opendistroforelasticsearch.sql.storage.bindingtuple.BindingTuple; import com.google.common.base.Strings; @@ -63,8 +64,8 @@ public AggregationState create() { } @Override - public AggregationState iterate(BindingTuple tuple, AggregationState state) { - return delegated.iterate(tuple, state); + protected AggregationState iterate(ExprValue value, AggregationState state) { + return delegated.iterate(value, state); } /** diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/SumAggregator.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/SumAggregator.java index 32d85ab92c..f3cd990257 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/SumAggregator.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/SumAggregator.java @@ -53,13 +53,9 @@ public SumState create() { } @Override - public SumState iterate(BindingTuple tuple, SumState state) { - Expression expression = getArguments().get(0); - ExprValue value = expression.valueOf(tuple); - if (!(value.isNull() || value.isMissing())) { - state.isEmptyCollection = false; - state.add(value); - } + protected SumState iterate(ExprValue value, SumState state) { + state.isEmptyCollection = false; + state.add(value); return state; } diff --git a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionAnalyzerTest.java b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionAnalyzerTest.java index ce40c26721..87cfd118e6 100644 --- a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionAnalyzerTest.java +++ b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionAnalyzerTest.java @@ -16,6 +16,10 @@ package com.amazon.opendistroforelasticsearch.sql.analysis; import static com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL.field; +import static com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL.filteredAggregate; +import static com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL.function; +import static com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL.intLiteral; +import static com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL.qualifiedName; import static com.amazon.opendistroforelasticsearch.sql.data.model.ExprValueUtils.LITERAL_TRUE; import static com.amazon.opendistroforelasticsearch.sql.data.model.ExprValueUtils.integerValue; import static com.amazon.opendistroforelasticsearch.sql.data.type.ExprCoreType.BOOLEAN; @@ -256,6 +260,16 @@ public void undefined_aggregation_function() { assertEquals("Unsupported aggregation function ESTDC_ERROR", exception.getMessage()); } + @Test + public void aggregation_filter() { + assertAnalyzeEqual( + dsl.avg(DSL.ref("integer_value", INTEGER)) + .condition(dsl.greater(DSL.ref("integer_value", INTEGER), DSL.literal(1))), + AstDSL.filteredAggregate("avg", qualifiedName("integer_value"), + function(">", qualifiedName("integer_value"), intLiteral(1))) + ); + } + protected Expression analyze(UnresolvedExpression unresolvedExpression) { return expressionAnalyzer.analyze(unresolvedExpression, analysisContext); } diff --git a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/AvgAggregatorTest.java b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/AvgAggregatorTest.java index 5c261b267c..f0ae64971a 100644 --- a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/AvgAggregatorTest.java +++ b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/AvgAggregatorTest.java @@ -43,6 +43,13 @@ public void avg_arithmetic_expression() { assertEquals(25.0, result.value()); } + @Test + public void filtered_avg() { + ExprValue result = aggregation(dsl.avg(DSL.ref("integer_value", INTEGER)) + .condition(dsl.greater(DSL.ref("integer_value", INTEGER), DSL.literal(1))), tuples); + assertEquals(3.0, result.value()); + } + @Test public void avg_with_missing() { ExprValue result = diff --git a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/CountAggregatorTest.java b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/CountAggregatorTest.java index 1190cc01df..a4a0fb1e81 100644 --- a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/CountAggregatorTest.java +++ b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/CountAggregatorTest.java @@ -112,6 +112,13 @@ public void count_array_field_expression() { assertEquals(1, result.value()); } + @Test + public void filtered_count() { + ExprValue result = aggregation(dsl.count(DSL.ref("integer_value", INTEGER)) + .condition(dsl.greater(DSL.ref("integer_value", INTEGER), DSL.literal(1))), tuples); + assertEquals(3, result.value()); + } + @Test public void count_with_missing() { ExprValue result = aggregation(dsl.count(DSL.ref("integer_value", INTEGER)), diff --git a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/MaxAggregatorTest.java b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/MaxAggregatorTest.java index 37a8291f65..f5e1db7ba5 100644 --- a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/MaxAggregatorTest.java +++ b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/MaxAggregatorTest.java @@ -101,6 +101,13 @@ public void test_max_arithmetic_expression() { assertEquals(4, result.value()); } + @Test + public void filtered_max() { + ExprValue result = aggregation(dsl.max(DSL.ref("integer_value", INTEGER)) + .condition(dsl.less(DSL.ref("integer_value", INTEGER), DSL.literal(4))), tuples); + assertEquals(3, result.value()); + } + @Test public void test_max_null() { ExprValue result = diff --git a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/MinAggregatorTest.java b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/MinAggregatorTest.java index 925d406aac..c203b69c10 100644 --- a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/MinAggregatorTest.java +++ b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/MinAggregatorTest.java @@ -101,6 +101,13 @@ public void test_min_arithmetic_expression() { assertEquals(1, result.value()); } + @Test + public void filtered_min() { + ExprValue result = aggregation(dsl.min(DSL.ref("integer_value", INTEGER)) + .condition(dsl.greater(DSL.ref("integer_value", INTEGER), DSL.literal(1))), tuples); + assertEquals(2, result.value()); + } + @Test public void test_min_null() { ExprValue result = diff --git a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/SumAggregatorTest.java b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/SumAggregatorTest.java index 8ba8b01b9e..af53ed57d7 100644 --- a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/SumAggregatorTest.java +++ b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/aggregation/SumAggregatorTest.java @@ -82,6 +82,13 @@ public void sum_string_field_expression() { assertEquals("unexpected type [STRING] in sum aggregation", exception.getMessage()); } + @Test + public void filtered_sum() { + ExprValue result = aggregation(dsl.sum(DSL.ref("integer_value", INTEGER)) + .condition(dsl.greater(DSL.ref("integer_value", INTEGER), DSL.literal(1))), tuples); + assertEquals(9, result.value()); + } + @Test public void sum_with_missing() { ExprValue result = diff --git a/docs/user/dql/aggregations.rst b/docs/user/dql/aggregations.rst index 7068c5d8ac..67f150298a 100644 --- a/docs/user/dql/aggregations.rst +++ b/docs/user/dql/aggregations.rst @@ -195,3 +195,42 @@ Additionally, a ``HAVING`` clause can work without ``GROUP BY`` clause. This is | Total of age > 100 | +------------------------+ + +FILTER Clause +============= + +Description +----------- + +A ``FILTER`` clause can set specific condition for the current aggregation bucket, following the syntax ``aggregation_function(expr) FILTER(WHERE condition_expr)``. If a filter is specified, then only the input rows for which the condition in the filter clause evaluates to true are fed to the aggregate function; other rows are discarded. The aggregation with filter clause can be use in ``SELECT`` clause only. + +FILTER with GROUP BY +-------------------- + +The group by aggregation with ``FILTER`` clause can set different conditions for each aggregation bucket. Here is an example to use ``FILTER`` in group by aggregation:: + + od> SELECT avg(age) FILTER(WHERE balance > 10000) AS filtered, gender FROM accounts GROUP BY gender + fetched rows / total rows = 2/2 + +------------+----------+ + | filtered | gender | + |------------+----------| + | 28.0 | F | + | 32.0 | M | + +------------+----------+ + +FILTER without GROUP BY +----------------------- + +The ``FILTER`` clause can be used in aggregation functions without GROUP BY as well. For example:: + + od> SELECT + ... count(*) AS unfiltered, + ... count(*) FILTER(WHERE age > 34) AS filtered + ... FROM accounts + fetched rows / total rows = 1/1 + +--------------+------------+ + | unfiltered | filtered | + |--------------+------------| + | 4 | 1 | + +--------------+------------+ + diff --git a/elasticsearch/src/main/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/response/ElasticsearchAggregationResponseParser.java b/elasticsearch/src/main/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/response/ElasticsearchAggregationResponseParser.java index 05220e5798..94e95806b3 100644 --- a/elasticsearch/src/main/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/response/ElasticsearchAggregationResponseParser.java +++ b/elasticsearch/src/main/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/response/ElasticsearchAggregationResponseParser.java @@ -27,6 +27,7 @@ import org.elasticsearch.search.aggregations.Aggregation; import org.elasticsearch.search.aggregations.Aggregations; import org.elasticsearch.search.aggregations.bucket.composite.CompositeAggregation; +import org.elasticsearch.search.aggregations.bucket.filter.Filter; import org.elasticsearch.search.aggregations.metrics.NumericMetricsAggregation; /** @@ -82,6 +83,13 @@ private static Map parseInternal(Aggregation aggregation) { resultMap.put( aggregation.getName(), handleNanValue(((NumericMetricsAggregation.SingleValue) aggregation).value())); + } else if (aggregation instanceof Filter) { + // parse sub-aggregations for FilterAggregation response + List aggList = ((Filter) aggregation).getAggregations().asList(); + aggList.forEach(internalAgg -> { + Map intermediateMap = parseInternal(internalAgg); + resultMap.put(internalAgg.getName(), intermediateMap.get(internalAgg.getName())); + }); } else { throw new IllegalStateException("unsupported aggregation type " + aggregation.getType()); } diff --git a/elasticsearch/src/main/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/storage/script/aggregation/dsl/MetricAggregationBuilder.java b/elasticsearch/src/main/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/storage/script/aggregation/dsl/MetricAggregationBuilder.java index af89196137..0e19ae2fa9 100644 --- a/elasticsearch/src/main/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/storage/script/aggregation/dsl/MetricAggregationBuilder.java +++ b/elasticsearch/src/main/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/storage/script/aggregation/dsl/MetricAggregationBuilder.java @@ -19,6 +19,7 @@ import static com.amazon.opendistroforelasticsearch.sql.data.type.ExprCoreType.INTEGER; +import com.amazon.opendistroforelasticsearch.sql.elasticsearch.storage.script.filter.FilterQueryBuilder; import com.amazon.opendistroforelasticsearch.sql.elasticsearch.storage.serialization.ExpressionSerializer; import com.amazon.opendistroforelasticsearch.sql.expression.Expression; import com.amazon.opendistroforelasticsearch.sql.expression.ExpressionNodeVisitor; @@ -29,6 +30,7 @@ import org.elasticsearch.search.aggregations.AggregationBuilder; import org.elasticsearch.search.aggregations.AggregationBuilders; import org.elasticsearch.search.aggregations.AggregatorFactories; +import org.elasticsearch.search.aggregations.bucket.filter.FilterAggregationBuilder; import org.elasticsearch.search.aggregations.support.ValuesSourceAggregationBuilder; /** @@ -38,10 +40,12 @@ public class MetricAggregationBuilder extends ExpressionNodeVisitor { private final AggregationBuilderHelper> helper; + private final FilterQueryBuilder filterBuilder; public MetricAggregationBuilder( ExpressionSerializer serializer) { this.helper = new AggregationBuilderHelper<>(serializer); + this.filterBuilder = new FilterQueryBuilder(serializer); } /** @@ -62,28 +66,35 @@ public AggregatorFactories.Builder build(List aggregatorList) { public AggregationBuilder visitNamedAggregator(NamedAggregator node, Object context) { Expression expression = node.getArguments().get(0); + Expression condition = node.getDelegated().condition(); String name = node.getName(); switch (node.getFunctionName().getFunctionName()) { case "avg": - return make(AggregationBuilders.avg(name), expression); + return make(AggregationBuilders.avg(name), expression, condition, name); case "sum": - return make(AggregationBuilders.sum(name), expression); + return make(AggregationBuilders.sum(name), expression, condition, name); case "count": - return make(AggregationBuilders.count(name), replaceStarOrLiteral(expression)); + return make( + AggregationBuilders.count(name), replaceStarOrLiteral(expression), condition, name); case "min": - return make(AggregationBuilders.min(name), expression); + return make(AggregationBuilders.min(name), expression, condition, name); case "max": - return make(AggregationBuilders.max(name), expression); + return make(AggregationBuilders.max(name), expression, condition, name); default: throw new IllegalStateException( String.format("unsupported aggregator %s", node.getFunctionName().getFunctionName())); } } - private ValuesSourceAggregationBuilder make(ValuesSourceAggregationBuilder builder, - Expression expression) { - return helper.build(expression, builder::field, builder::script); + private AggregationBuilder make(ValuesSourceAggregationBuilder builder, + Expression expression, Expression condition, String name) { + ValuesSourceAggregationBuilder aggregationBuilder = + helper.build(expression, builder::field, builder::script); + if (condition != null) { + return makeFilterAggregation(aggregationBuilder, condition, name); + } + return aggregationBuilder; } /** @@ -102,4 +113,18 @@ private Expression replaceStarOrLiteral(Expression countArg) { return countArg; } + /** + * Make builder to build FilterAggregation for aggregations with filter in the bucket. + * @param subAggBuilder AggregationBuilder instance which the filter is applied to. + * @param condition Condition expression in the filter. + * @param name Name of the FilterAggregation instance to build. + * @return {@link FilterAggregationBuilder}. + */ + private FilterAggregationBuilder makeFilterAggregation(AggregationBuilder subAggBuilder, + Expression condition, String name) { + return AggregationBuilders + .filter(name, filterBuilder.build(condition)) + .subAggregation(subAggBuilder); + } + } diff --git a/elasticsearch/src/test/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/planner/logical/ElasticsearchLogicOptimizerTest.java b/elasticsearch/src/test/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/planner/logical/ElasticsearchLogicOptimizerTest.java index 6176c91c03..02cbf0951c 100644 --- a/elasticsearch/src/test/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/planner/logical/ElasticsearchLogicOptimizerTest.java +++ b/elasticsearch/src/test/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/planner/logical/ElasticsearchLogicOptimizerTest.java @@ -18,6 +18,7 @@ package com.amazon.opendistroforelasticsearch.sql.elasticsearch.planner.logical; import static com.amazon.opendistroforelasticsearch.sql.data.model.ExprValueUtils.integerValue; +import static com.amazon.opendistroforelasticsearch.sql.data.model.ExprValueUtils.longValue; import static com.amazon.opendistroforelasticsearch.sql.data.type.ExprCoreType.DOUBLE; import static com.amazon.opendistroforelasticsearch.sql.data.type.ExprCoreType.INTEGER; import static com.amazon.opendistroforelasticsearch.sql.data.type.ExprCoreType.LONG; @@ -521,6 +522,61 @@ void project_literal_no_push() { ); } + /** + * SELECT AVG(intV) FILTER(WHERE intV > 1) FROM schema GROUP BY stringV. + */ + @Test + void filter_aggregation_merge_relation() { + assertEquals( + project( + indexScanAgg("schema", ImmutableList.of(DSL.named("AVG(intV)", + dsl.avg(DSL.ref("intV", INTEGER)) + .condition(dsl.greater(DSL.ref("intV", INTEGER), DSL.literal(1))))), + ImmutableList.of(DSL.named("stringV", DSL.ref("stringV", STRING)))), + DSL.named("avg(intV) filter(where intV > 1)", DSL.ref("avg(intV)", DOUBLE))), + optimize( + project( + aggregation( + relation("schema"), + ImmutableList.of(DSL.named("AVG(intV)", + dsl.avg(DSL.ref("intV", INTEGER)) + .condition(dsl.greater(DSL.ref("intV", INTEGER), DSL.literal(1))))), + ImmutableList.of(DSL.named("stringV", DSL.ref("stringV", STRING)))), + DSL.named("avg(intV) filter(where intV > 1)", DSL.ref("avg(intV)", DOUBLE))) + ) + ); + } + + /** + * SELECT AVG(intV) FILTER(WHERE intV > 1) FROM schema WHERE longV < 1 GROUP BY stringV. + */ + @Test + void filter_aggregation_merge_filter_relation() { + assertEquals( + project( + indexScanAgg("schema", + dsl.less(DSL.ref("longV", LONG), DSL.literal(1)), + ImmutableList.of(DSL.named("avg(intV)", + dsl.avg(DSL.ref("intV", INTEGER)) + .condition(dsl.greater(DSL.ref("intV", INTEGER), DSL.literal(1))))), + ImmutableList.of(DSL.named("stringV", DSL.ref("stringV", STRING)))), + DSL.named("avg(intV) filter(where intV > 1)", DSL.ref("avg(intV)", DOUBLE))), + optimize( + project( + aggregation( + filter( + relation("schema"), + dsl.less(DSL.ref("longV", LONG), DSL.literal(1)) + ), + ImmutableList.of(DSL.named("avg(intV)", + dsl.avg(DSL.ref("intV", INTEGER)) + .condition(dsl.greater(DSL.ref("intV", INTEGER), DSL.literal(1))))), + ImmutableList.of(DSL.named("stringV", DSL.ref("stringV", STRING)))), + DSL.named("avg(intV) filter(where intV > 1)", DSL.ref("avg(intV)", DOUBLE))) + ) + ); + } + private LogicalPlan optimize(LogicalPlan plan) { final LogicalPlanOptimizer optimizer = ElasticsearchLogicalPlanOptimizerFactory.create(); final LogicalPlan optimize = optimizer.optimize(plan); diff --git a/elasticsearch/src/test/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/response/AggregationResponseUtils.java b/elasticsearch/src/test/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/response/AggregationResponseUtils.java index f75b618d23..e9929d9962 100644 --- a/elasticsearch/src/test/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/response/AggregationResponseUtils.java +++ b/elasticsearch/src/test/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/response/AggregationResponseUtils.java @@ -33,6 +33,8 @@ import org.elasticsearch.search.aggregations.Aggregations; import org.elasticsearch.search.aggregations.bucket.composite.CompositeAggregationBuilder; import org.elasticsearch.search.aggregations.bucket.composite.ParsedComposite; +import org.elasticsearch.search.aggregations.bucket.filter.FilterAggregationBuilder; +import org.elasticsearch.search.aggregations.bucket.filter.ParsedFilter; import org.elasticsearch.search.aggregations.bucket.histogram.DateHistogramAggregationBuilder; import org.elasticsearch.search.aggregations.bucket.histogram.ParsedDateHistogram; import org.elasticsearch.search.aggregations.bucket.terms.DoubleTerms; @@ -72,6 +74,8 @@ public class AggregationResponseUtils { (p, c) -> ParsedDateHistogram.fromXContent(p, (String) c)) .put(CompositeAggregationBuilder.NAME, (p, c) -> ParsedComposite.fromXContent(p, (String) c)) + .put(FilterAggregationBuilder.NAME, + (p, c) -> ParsedFilter.fromXContent(p, (String) c)) .build() .entrySet() .stream() diff --git a/elasticsearch/src/test/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/response/ElasticsearchAggregationResponseParserTest.java b/elasticsearch/src/test/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/response/ElasticsearchAggregationResponseParserTest.java index d71f98e80e..bc23803701 100644 --- a/elasticsearch/src/test/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/response/ElasticsearchAggregationResponseParserTest.java +++ b/elasticsearch/src/test/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/response/ElasticsearchAggregationResponseParserTest.java @@ -150,6 +150,65 @@ void nan_value_should_return_null() { assertNull(ElasticsearchAggregationResponseParser.handleNanValue(Double.NaN)); } + /** + * SELECT AVG(age) FILTER(WHERE age > 37) as filtered FROM accounts. + */ + @Test + void filter_aggregation_should_pass() { + String response = "{\n" + + " \"filter#filtered\" : {\n" + + " \"doc_count\" : 3,\n" + + " \"avg#filtered\" : {\n" + + " \"value\" : 37.0\n" + + " }\n" + + " }\n" + + " }"; + assertThat(parse(response), contains(entry("filtered", 37.0))); + } + + /** + * SELECT AVG(age) FILTER(WHERE age > 37) as filtered FROM accounts GROUP BY gender. + */ + @Test + void filter_aggregation_group_by_should_pass() { + String response = "{\n" + + " \"composite#composite_buckets\":{\n" + + " \"after_key\":{\n" + + " \"gender\":\"m\"\n" + + " },\n" + + " \"buckets\":[\n" + + " {\n" + + " \"key\":{\n" + + " \"gender\":\"f\"\n" + + " },\n" + + " \"doc_count\":3,\n" + + " \"filter#filter\":{\n" + + " \"doc_count\":1,\n" + + " \"avg#avg\":{\n" + + " \"value\":39.0\n" + + " }\n" + + " }\n" + + " },\n" + + " {\n" + + " \"key\":{\n" + + " \"gender\":\"m\"\n" + + " },\n" + + " \"doc_count\":4,\n" + + " \"filter#filter\":{\n" + + " \"doc_count\":2,\n" + + " \"avg#avg\":{\n" + + " \"value\":36.0\n" + + " }\n" + + " }\n" + + " }\n" + + " ]\n" + + " }\n" + + "}"; + assertThat(parse(response), containsInAnyOrder( + entry("gender", "f", "avg", 39.0), + entry("gender", "m", "avg", 36.0))); + } + public List> parse(String json) { return ElasticsearchAggregationResponseParser.parse(AggregationResponseUtils.fromJson(json)); } diff --git a/elasticsearch/src/test/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/storage/script/aggregation/AggregationQueryBuilderTest.java b/elasticsearch/src/test/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/storage/script/aggregation/AggregationQueryBuilderTest.java index f4a25d9c3d..e0b6aee998 100644 --- a/elasticsearch/src/test/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/storage/script/aggregation/AggregationQueryBuilderTest.java +++ b/elasticsearch/src/test/java/com/amazon/opendistroforelasticsearch/sql/elasticsearch/storage/script/aggregation/AggregationQueryBuilderTest.java @@ -25,6 +25,7 @@ import static com.amazon.opendistroforelasticsearch.sql.elasticsearch.utils.Utils.avg; import static com.amazon.opendistroforelasticsearch.sql.elasticsearch.utils.Utils.group; import static com.amazon.opendistroforelasticsearch.sql.elasticsearch.utils.Utils.sort; +import static com.amazon.opendistroforelasticsearch.sql.expression.DSL.literal; import static com.amazon.opendistroforelasticsearch.sql.expression.DSL.named; import static com.amazon.opendistroforelasticsearch.sql.expression.DSL.ref; import static org.hamcrest.MatcherAssert.assertThat; @@ -34,13 +35,11 @@ import static org.mockito.Mockito.doAnswer; import com.amazon.opendistroforelasticsearch.sql.ast.tree.Sort; -import com.amazon.opendistroforelasticsearch.sql.data.type.ExprCoreType; import com.amazon.opendistroforelasticsearch.sql.data.type.ExprType; import com.amazon.opendistroforelasticsearch.sql.elasticsearch.storage.serialization.ExpressionSerializer; import com.amazon.opendistroforelasticsearch.sql.expression.DSL; import com.amazon.opendistroforelasticsearch.sql.expression.Expression; import com.amazon.opendistroforelasticsearch.sql.expression.NamedExpression; -import com.amazon.opendistroforelasticsearch.sql.expression.aggregation.Aggregator; import com.amazon.opendistroforelasticsearch.sql.expression.aggregation.AvgAggregator; import com.amazon.opendistroforelasticsearch.sql.expression.aggregation.NamedAggregator; import com.amazon.opendistroforelasticsearch.sql.expression.config.ExpressionConfig; @@ -314,6 +313,86 @@ void should_build_aggregation_without_bucket() { Collections.emptyList())); } + @Test + void should_build_filter_aggregation() { + assertEquals( + "{\n" + + " \"avg(age) filter(where age > 34)\" : {\n" + + " \"filter\" : {\n" + + " \"range\" : {\n" + + " \"age\" : {\n" + + " \"from\" : 20,\n" + + " \"to\" : null,\n" + + " \"include_lower\" : false,\n" + + " \"include_upper\" : true,\n" + + " \"boost\" : 1.0\n" + + " }\n" + + " }\n" + + " },\n" + + " \"aggregations\" : {\n" + + " \"avg(age) filter(where age > 34)\" : {\n" + + " \"avg\" : {\n" + + " \"field\" : \"age\"\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + "}", + buildQuery( + Arrays.asList(named("avg(age) filter(where age > 34)", + new AvgAggregator(Arrays.asList(ref("age", INTEGER)), INTEGER) + .condition(dsl.greater(ref("age", INTEGER), literal(20))))), + Collections.emptyList())); + } + + @Test + void should_build_filter_aggregation_group_by() { + assertEquals( + "{\n" + + " \"composite_buckets\" : {\n" + + " \"composite\" : {\n" + + " \"size\" : 1000,\n" + + " \"sources\" : [ {\n" + + " \"gender\" : {\n" + + " \"terms\" : {\n" + + " \"field\" : \"gender\",\n" + + " \"missing_bucket\" : true,\n" + + " \"order\" : \"asc\"\n" + + " }\n" + + " }\n" + + " } ]\n" + + " },\n" + + " \"aggregations\" : {\n" + + " \"avg(age) filter(where age > 34)\" : {\n" + + " \"filter\" : {\n" + + " \"range\" : {\n" + + " \"age\" : {\n" + + " \"from\" : 20,\n" + + " \"to\" : null,\n" + + " \"include_lower\" : false,\n" + + " \"include_upper\" : true,\n" + + " \"boost\" : 1.0\n" + + " }\n" + + " }\n" + + " },\n" + + " \"aggregations\" : {\n" + + " \"avg(age) filter(where age > 34)\" : {\n" + + " \"avg\" : {\n" + + " \"field\" : \"age\"\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + "}", + buildQuery( + Arrays.asList(named("avg(age) filter(where age > 34)", + new AvgAggregator(Arrays.asList(ref("age", INTEGER)), INTEGER) + .condition(dsl.greater(ref("age", INTEGER), literal(20))))), + Arrays.asList(named(ref("gender", STRING))))); + } + @Test void should_build_type_mapping_without_bucket() { assertThat( diff --git a/integ-test/src/test/resources/correctness/queries/filter.txt b/integ-test/src/test/resources/correctness/queries/filter.txt new file mode 100644 index 0000000000..690defb8ae --- /dev/null +++ b/integ-test/src/test/resources/correctness/queries/filter.txt @@ -0,0 +1,6 @@ +SELECT AVG(AvgTicketPrice) FILTER(WHERE Carrier = 'Kibana Airlines') AS filtered FROM kibana_sample_data_flights +SELECT AVG(AvgTicketPrice) FILTER(WHERE Carrier = 'Kibana Airlines') AS filtered FROM kibana_sample_data_flights GROUP BY Origin ORDER BY Origin +SELECT AVG(AvgTicketPrice + 1) FILTER(WHERE Carrier = 'Kibana Airlines') AS filtered FROM kibana_sample_data_flights +SELECT AVG(AvgTicketPrice) FILTER(WHERE Carrier = 'Kibana Airlines') / 2 AS filtered FROM kibana_sample_data_flights +SELECT AVG(AvgTicketPrice) FILTER(WHERE ABS(AvgTicketPrice) < 10000) AS filtered FROM kibana_sample_data_flights +SELECT AVG(AvgTicketPrice) AS unfiltered, AVG(AvgTicketPrice) FILTER(WHERE Carrier = 'Kibana Airlines') AS filtered1, AVG(AvgTicketPrice) FILTER(WHERE Carrier = 'ES-Air') AS filtered2 FROM kibana_sample_data_flights WHERE DestWeather = 'Sunny' \ No newline at end of file diff --git a/sql/src/main/antlr/OpenDistroSQLParser.g4 b/sql/src/main/antlr/OpenDistroSQLParser.g4 index cf4df2f4fe..f645174397 100644 --- a/sql/src/main/antlr/OpenDistroSQLParser.g4 +++ b/sql/src/main/antlr/OpenDistroSQLParser.g4 @@ -285,6 +285,7 @@ functionCall | specificFunction #specificFunctionCall | windowFunction #windowFunctionCall | aggregateFunction #aggregateFunctionCall + | aggregateFunction (orderByClause)? filterClause #filteredAggregationFunctionCall ; scalarFunctionName @@ -323,6 +324,10 @@ aggregateFunction | COUNT LR_BRACKET STAR RR_BRACKET #countStarFunctionCall ; +filterClause + : FILTER LR_BRACKET WHERE expression RR_BRACKET + ; + aggregationFunctionName : AVG | COUNT | SUM | MIN | MAX ; diff --git a/sql/src/main/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/AstExpressionBuilder.java b/sql/src/main/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/AstExpressionBuilder.java index 197c1ce1b2..6942aab238 100644 --- a/sql/src/main/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/AstExpressionBuilder.java +++ b/sql/src/main/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/AstExpressionBuilder.java @@ -159,6 +159,13 @@ public UnresolvedExpression visitShowDescribePattern( } } + @Override + public UnresolvedExpression visitFilteredAggregationFunctionCall( + OpenDistroSQLParser.FilteredAggregationFunctionCallContext ctx) { + AggregateFunction agg = (AggregateFunction) visit(ctx.aggregateFunction()); + return new AggregateFunction(agg.getFuncName(), agg.getField(), visit(ctx.filterClause())); + } + @Override public UnresolvedExpression visitWindowFunction(WindowFunctionContext ctx) { OverClauseContext overClause = ctx.overClause(); @@ -202,6 +209,11 @@ public UnresolvedExpression visitCountStarFunctionCall(CountStarFunctionCallCont return new AggregateFunction("COUNT", AllFields.of()); } + @Override + public UnresolvedExpression visitFilterClause(OpenDistroSQLParser.FilterClauseContext ctx) { + return visit(ctx.expression()); + } + @Override public UnresolvedExpression visitIsNullPredicate(IsNullPredicateContext ctx) { return new Function( diff --git a/sql/src/main/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/context/QuerySpecification.java b/sql/src/main/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/context/QuerySpecification.java index f89bfba826..16b518db3d 100644 --- a/sql/src/main/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/context/QuerySpecification.java +++ b/sql/src/main/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/context/QuerySpecification.java @@ -16,6 +16,7 @@ package com.amazon.opendistroforelasticsearch.sql.sql.parser.context; +import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.FilteredAggregationFunctionCallContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.GroupByElementContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.OrderByElementContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.SelectClauseContext; @@ -229,6 +230,14 @@ public Void visitAggregateFunctionCall(AggregateFunctionCallContext ctx) { return super.visitAggregateFunctionCall(ctx); } + @Override + public Void visitFilteredAggregationFunctionCall(FilteredAggregationFunctionCallContext ctx) { + UnresolvedExpression aggregateFunction = visitAstExpression(ctx); + aggregators.add( + AstDSL.alias(getTextInQuery(ctx, queryString), aggregateFunction)); + return super.visitFilteredAggregationFunctionCall(ctx); + } + private boolean isDistinct(SelectSpecContext ctx) { return (ctx != null) && (ctx.DISTINCT() != null); } diff --git a/sql/src/test/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/AstExpressionBuilderTest.java b/sql/src/test/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/AstExpressionBuilderTest.java index c306625dbc..7ff33f8603 100644 --- a/sql/src/test/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/AstExpressionBuilderTest.java +++ b/sql/src/test/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/AstExpressionBuilderTest.java @@ -349,6 +349,15 @@ public void canCastValueAsString() { ); } + @Test + public void filteredAggregation() { + assertEquals( + AstDSL.filteredAggregate("avg", qualifiedName("age"), + function(">", qualifiedName("age"), intLiteral(20))), + buildExprAst("avg(age) filter(where age > 20)") + ); + } + private Node buildExprAst(String expr) { OpenDistroSQLLexer lexer = new OpenDistroSQLLexer(new CaseInsensitiveCharStream(expr)); OpenDistroSQLParser parser = new OpenDistroSQLParser(new CommonTokenStream(lexer)); diff --git a/sql/src/test/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/context/QuerySpecificationTest.java b/sql/src/test/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/context/QuerySpecificationTest.java index 5e417bf44a..af24e5c132 100644 --- a/sql/src/test/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/context/QuerySpecificationTest.java +++ b/sql/src/test/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/context/QuerySpecificationTest.java @@ -18,7 +18,9 @@ import static com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL.aggregate; import static com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL.alias; +import static com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL.filteredAggregate; import static com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL.function; +import static com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL.intLiteral; import static com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL.qualifiedName; import static com.amazon.opendistroforelasticsearch.sql.ast.tree.Sort.NullOrder; import static com.amazon.opendistroforelasticsearch.sql.ast.tree.Sort.SortOrder; @@ -133,6 +135,16 @@ void should_skip_sort_items_in_window_function() { ).getOrderByOptions().size()); } + @Test + void can_collect_filtered_aggregation() { + assertEquals( + ImmutableSet.of(alias("AVG(age) FILTER(WHERE age > 20)", + filteredAggregate("AVG", qualifiedName("age"), + function(">", qualifiedName("age"), intLiteral(20))))), + collect("SELECT AVG(age) FILTER(WHERE age > 20) FROM test").getAggregators() + ); + } + private QuerySpecification collect(String query) { QuerySpecification querySpec = new QuerySpecification(); querySpec.collect(parse(query), query); From 9a08770195b11b3404972062fd335043cb1499fb Mon Sep 17 00:00:00 2001 From: Chen Dai <46505291+dai-chen@users.noreply.github.com> Date: Thu, 7 Jan 2021 11:13:57 -0800 Subject: [PATCH 2/2] Support aggregate window functions (#946) * Change grammar and AST builder * Add tostring and getchild for window function AST node * Add aggregate window function class * Add peer window frame and refactor * Name window function and optimize it when analysis * Refactor peer frame by peeking iterator * Comparison test * Add doctest * Add more comparison tests * Add java doc * Rename and fix broken UT * Add more IT * Add design doc * Prepare PR * Fix no sort key bug * Change README --- README.md | 2 +- .../sql/analysis/ExpressionAnalyzer.java | 11 +- .../ExpressionReferenceOptimizer.java | 11 +- .../analysis/SelectExpressionAnalyzer.java | 10 +- .../analysis/WindowExpressionAnalyzer.java | 26 +- .../sql/ast/dsl/AstDSL.java | 2 +- .../sql/ast/expression/WindowFunction.java | 10 +- .../window/WindowFunctionExpression.java | 39 +++ .../aggregation/AggregateWindowFunction.java | 78 ++++++ .../CurrentRowWindowFrame.java} | 53 ++-- .../window/frame/PeerRowsWindowFrame.java | 157 +++++++++++ .../expression/window/frame/WindowFrame.java | 25 +- .../window/ranking/DenseRankFunction.java | 4 +- .../window/ranking/RankFunction.java | 4 +- .../window/ranking/RankingWindowFunction.java | 28 +- .../window/ranking/RowNumberFunction.java | 4 +- .../sql/planner/logical/LogicalPlanDSL.java | 2 +- .../sql/planner/logical/LogicalWindow.java | 6 +- .../sql/planner/physical/PhysicalPlanDSL.java | 2 +- .../sql/planner/physical/WindowOperator.java | 44 +-- .../sql/analysis/AnalyzerTest.java | 6 +- .../sql/analysis/ExpressionAnalyzerTest.java | 45 ++- .../ExpressionReferenceOptimizerTest.java | 6 +- .../SelectExpressionAnalyzerTest.java | 3 +- .../WindowExpressionAnalyzerTest.java | 21 +- .../sql/executor/ExplainTest.java | 2 +- ...st.java => CurrentRowWindowFrameTest.java} | 75 +++-- .../AggregateWindowFunctionTest.java | 80 ++++++ .../window/frame/PeerRowsWindowFrameTest.java | 264 ++++++++++++++++++ .../ranking/RankingWindowFunctionTest.java | 143 ++++++---- .../sql/planner/DefaultImplementorTest.java | 2 +- .../logical/LogicalPlanNodeVisitorTest.java | 2 +- .../physical/PhysicalPlanNodeVisitorTest.java | 2 +- .../planner/physical/WindowOperatorTest.java | 60 +++- docs/dev/AggregateWindowFunction.md | 94 +++++++ docs/dev/img/aggregate-window-functions.png | Bin 0 -> 89495 bytes docs/user/dql/window.rst | 111 ++++++++ docs/user/limitations/limitations.rst | 13 +- .../ElasticsearchExecutionProtectorTest.java | 2 +- .../resources/correctness/queries/window.txt | 19 ++ sql/src/main/antlr/OpenDistroSQLParser.g4 | 12 +- .../sql/sql/parser/AstExpressionBuilder.java | 56 ++-- .../parser/context/QuerySpecification.java | 4 +- .../sql/parser/AstExpressionBuilderTest.java | 12 + 44 files changed, 1308 insertions(+), 244 deletions(-) create mode 100644 core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/WindowFunctionExpression.java create mode 100644 core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/aggregation/AggregateWindowFunction.java rename core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/{CumulativeWindowFrame.java => frame/CurrentRowWindowFrame.java} (72%) create mode 100644 core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/frame/PeerRowsWindowFrame.java rename core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/window/{CumulativeWindowFrameTest.java => CurrentRowWindowFrameTest.java} (51%) create mode 100644 core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/window/aggregation/AggregateWindowFunctionTest.java create mode 100644 core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/window/frame/PeerRowsWindowFrameTest.java create mode 100644 docs/dev/AggregateWindowFunction.md create mode 100644 docs/dev/img/aggregate-window-functions.png diff --git a/README.md b/README.md index 622b943281..a1e0ba02ae 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Here is a documentation list with features only available in this improved SQL q * [Aggregations](./docs/user/dql/aggregations.rst): aggregation over expression and more other features * [Complex queries](./docs/user/dql/complex.rst) * Improvement on Subqueries in FROM clause -* [Window functions](./docs/user/dql/window.rst): ranking window function support +* [Window functions](./docs/user/dql/window.rst): ranking and aggregate window function support To avoid impact on your side, normally you won't see any difference in query response. If you want to check if and why your query falls back to be handled by old SQL engine, please explain your query and check Elasticsearch log for "Request is falling back to old SQL engine due to ...". diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionAnalyzer.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionAnalyzer.java index a7ab7e9702..4738d25b74 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionAnalyzer.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionAnalyzer.java @@ -44,12 +44,15 @@ import com.amazon.opendistroforelasticsearch.sql.expression.DSL; import com.amazon.opendistroforelasticsearch.sql.expression.Expression; import com.amazon.opendistroforelasticsearch.sql.expression.ReferenceExpression; +import com.amazon.opendistroforelasticsearch.sql.expression.aggregation.AggregationState; import com.amazon.opendistroforelasticsearch.sql.expression.aggregation.Aggregator; import com.amazon.opendistroforelasticsearch.sql.expression.conditional.cases.CaseClause; import com.amazon.opendistroforelasticsearch.sql.expression.conditional.cases.WhenClause; import com.amazon.opendistroforelasticsearch.sql.expression.function.BuiltinFunctionName; import com.amazon.opendistroforelasticsearch.sql.expression.function.BuiltinFunctionRepository; import com.amazon.opendistroforelasticsearch.sql.expression.function.FunctionName; +import com.amazon.opendistroforelasticsearch.sql.expression.window.aggregation.AggregateWindowFunction; +import com.amazon.opendistroforelasticsearch.sql.expression.window.ranking.RankingWindowFunction; import com.google.common.collect.ImmutableSet; import java.util.ArrayList; import java.util.Arrays; @@ -166,9 +169,15 @@ public Expression visitFunction(Function node, AnalysisContext context) { return (Expression) repository.compile(functionName, arguments); } + @SuppressWarnings("unchecked") @Override public Expression visitWindowFunction(WindowFunction node, AnalysisContext context) { - return visitFunction(node.getFunction(), context); + Expression expr = node.getFunction().accept(this, context); + // Wrap regular aggregator by aggregate window function to adapt window operator use + if (expr instanceof Aggregator) { + return new AggregateWindowFunction((Aggregator) expr); + } + return expr; } @Override diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionReferenceOptimizer.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionReferenceOptimizer.java index b98c7be53e..eb837dbd26 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionReferenceOptimizer.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionReferenceOptimizer.java @@ -20,6 +20,7 @@ import com.amazon.opendistroforelasticsearch.sql.expression.Expression; import com.amazon.opendistroforelasticsearch.sql.expression.ExpressionNodeVisitor; import com.amazon.opendistroforelasticsearch.sql.expression.FunctionExpression; +import com.amazon.opendistroforelasticsearch.sql.expression.NamedExpression; import com.amazon.opendistroforelasticsearch.sql.expression.ReferenceExpression; import com.amazon.opendistroforelasticsearch.sql.expression.aggregation.Aggregator; import com.amazon.opendistroforelasticsearch.sql.expression.conditional.cases.CaseClause; @@ -89,6 +90,14 @@ public Expression visitAggregator(Aggregator node, AnalysisContext context) { return expressionMap.getOrDefault(node, node); } + @Override + public Expression visitNamed(NamedExpression node, AnalysisContext context) { + if (expressionMap.containsKey(node)) { + return expressionMap.get(node); + } + return node.getDelegated().accept(this, context); + } + /** * Implement this because Case/When is not registered in function repository. */ @@ -145,7 +154,7 @@ public Void visitAggregation(LogicalAggregation plan, Void context) { public Void visitWindow(LogicalWindow plan, Void context) { Expression windowFunc = plan.getWindowFunction(); expressionMap.put(windowFunc, - new ReferenceExpression(windowFunc.toString(), windowFunc.type())); + new ReferenceExpression(((NamedExpression) windowFunc).getName(), windowFunc.type())); return visitNode(plan, context); } } diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/analysis/SelectExpressionAnalyzer.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/analysis/SelectExpressionAnalyzer.java index 8a07d847b8..a949e6fcf3 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/analysis/SelectExpressionAnalyzer.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/analysis/SelectExpressionAnalyzer.java @@ -91,7 +91,15 @@ public List visitAlias(Alias node, AnalysisContext context) { private Expression referenceIfSymbolDefined(Alias expr, AnalysisContext context) { UnresolvedExpression delegatedExpr = expr.getDelegated(); - return optimizer.optimize(delegatedExpr.accept(expressionAnalyzer, context), context); + + // Pass named expression because expression like window function loses full name + // (OVER clause) and thus depends on name in alias to be replaced correctly + return optimizer.optimize( + DSL.named( + expr.getName(), + delegatedExpr.accept(expressionAnalyzer, context), + expr.getAlias()), + context); } @Override diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/analysis/WindowExpressionAnalyzer.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/analysis/WindowExpressionAnalyzer.java index d5fbe1b19b..acec2adfd7 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/analysis/WindowExpressionAnalyzer.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/analysis/WindowExpressionAnalyzer.java @@ -27,6 +27,7 @@ import com.amazon.opendistroforelasticsearch.sql.ast.expression.WindowFunction; import com.amazon.opendistroforelasticsearch.sql.ast.tree.Sort.SortOption; import com.amazon.opendistroforelasticsearch.sql.expression.Expression; +import com.amazon.opendistroforelasticsearch.sql.expression.NamedExpression; import com.amazon.opendistroforelasticsearch.sql.expression.window.WindowDefinition; import com.amazon.opendistroforelasticsearch.sql.planner.logical.LogicalPlan; import com.amazon.opendistroforelasticsearch.sql.planner.logical.LogicalSort; @@ -68,19 +69,26 @@ public LogicalPlan analyze(UnresolvedExpression projectItem, AnalysisContext con @Override public LogicalPlan visitAlias(Alias node, AnalysisContext context) { - return node.getDelegated().accept(this, context); - } + if (!(node.getDelegated() instanceof WindowFunction)) { + return null; + } + + WindowFunction unresolved = (WindowFunction) node.getDelegated(); + Expression windowFunction = expressionAnalyzer.analyze(unresolved, context); + List partitionByList = analyzePartitionList(unresolved, context); + List> sortList = analyzeSortList(unresolved, context); - @Override - public LogicalPlan visitWindowFunction(WindowFunction node, AnalysisContext context) { - Expression windowFunction = expressionAnalyzer.analyze(node, context); - List partitionByList = analyzePartitionList(node, context); - List> sortList = analyzeSortList(node, context); WindowDefinition windowDefinition = new WindowDefinition(partitionByList, sortList); + NamedExpression namedWindowFunction = + new NamedExpression(node.getName(), windowFunction, node.getAlias()); + List> allSortItems = windowDefinition.getAllSortItems(); + if (allSortItems.isEmpty()) { + return new LogicalWindow(child, namedWindowFunction, windowDefinition); + } return new LogicalWindow( - new LogicalSort(child, windowDefinition.getAllSortItems()), - windowFunction, + new LogicalSort(child, allSortItems), + namedWindowFunction, windowDefinition); } diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/ast/dsl/AstDSL.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/ast/dsl/AstDSL.java index 2c53b5aa0c..dffcd4c0f1 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/ast/dsl/AstDSL.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/ast/dsl/AstDSL.java @@ -236,7 +236,7 @@ public When when(UnresolvedExpression condition, UnresolvedExpression result) { return new When(condition, result); } - public UnresolvedExpression window(Function function, + public UnresolvedExpression window(UnresolvedExpression function, List partitionByList, List> sortList) { return new WindowFunction(function, partitionByList, sortList); diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/ast/expression/WindowFunction.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/ast/expression/WindowFunction.java index 976be0c48f..c886ebe929 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/ast/expression/WindowFunction.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/ast/expression/WindowFunction.java @@ -19,7 +19,7 @@ import com.amazon.opendistroforelasticsearch.sql.ast.AbstractNodeVisitor; import com.amazon.opendistroforelasticsearch.sql.ast.Node; import com.amazon.opendistroforelasticsearch.sql.ast.tree.Sort.SortOption; -import java.util.Collections; +import com.google.common.collect.ImmutableList; import java.util.List; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; @@ -35,13 +35,17 @@ @ToString public class WindowFunction extends UnresolvedExpression { - private final Function function; + private final UnresolvedExpression function; private List partitionByList; private List> sortList; @Override public List getChild() { - return Collections.singletonList(function); + ImmutableList.Builder children = ImmutableList.builder(); + children.add(function); + children.addAll(partitionByList); + sortList.forEach(pair -> children.add(pair.getRight())); + return children.build(); } @Override diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/WindowFunctionExpression.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/WindowFunctionExpression.java new file mode 100644 index 0000000000..f22dcd9ba5 --- /dev/null +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/WindowFunctionExpression.java @@ -0,0 +1,39 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + * + */ + +package com.amazon.opendistroforelasticsearch.sql.expression.window; + +import com.amazon.opendistroforelasticsearch.sql.expression.Expression; +import com.amazon.opendistroforelasticsearch.sql.expression.window.frame.WindowFrame; + +/** + * Window function abstraction. + */ +public interface WindowFunctionExpression extends Expression { + + /** + * Create specific window frame based on window definition and what's current window function. + * For now two types of cumulative window frame is returned: + * 1. Ranking window functions: ignore frame definition and always operates on + * previous and current row. + * 2. Aggregate window functions: frame partition into peers and sliding window is not supported. + * + * @param definition window definition + * @return window frame + */ + WindowFrame createWindowFrame(WindowDefinition definition); + +} diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/aggregation/AggregateWindowFunction.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/aggregation/AggregateWindowFunction.java new file mode 100644 index 0000000000..8d04bf6039 --- /dev/null +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/aggregation/AggregateWindowFunction.java @@ -0,0 +1,78 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + * + */ + +package com.amazon.opendistroforelasticsearch.sql.expression.window.aggregation; + +import com.amazon.opendistroforelasticsearch.sql.data.model.ExprValue; +import com.amazon.opendistroforelasticsearch.sql.data.type.ExprType; +import com.amazon.opendistroforelasticsearch.sql.expression.Expression; +import com.amazon.opendistroforelasticsearch.sql.expression.ExpressionNodeVisitor; +import com.amazon.opendistroforelasticsearch.sql.expression.aggregation.AggregationState; +import com.amazon.opendistroforelasticsearch.sql.expression.aggregation.Aggregator; +import com.amazon.opendistroforelasticsearch.sql.expression.env.Environment; +import com.amazon.opendistroforelasticsearch.sql.expression.window.WindowDefinition; +import com.amazon.opendistroforelasticsearch.sql.expression.window.WindowFunctionExpression; +import com.amazon.opendistroforelasticsearch.sql.expression.window.frame.PeerRowsWindowFrame; +import com.amazon.opendistroforelasticsearch.sql.expression.window.frame.WindowFrame; +import java.util.List; +import lombok.EqualsAndHashCode; +import lombok.RequiredArgsConstructor; + +/** + * Aggregate function adapter that adapts Aggregator for window operator use. + */ +@EqualsAndHashCode +@RequiredArgsConstructor +public class AggregateWindowFunction implements WindowFunctionExpression { + + private final Aggregator aggregator; + private AggregationState state; + + @Override + public WindowFrame createWindowFrame(WindowDefinition definition) { + return new PeerRowsWindowFrame(definition); + } + + @Override + public ExprValue valueOf(Environment valueEnv) { + PeerRowsWindowFrame frame = (PeerRowsWindowFrame) valueEnv; + if (frame.isNewPartition()) { + state = aggregator.create(); + } + + List peers = frame.next(); + for (ExprValue peer : peers) { + state = aggregator.iterate(peer.bindingTuples(), state); + } + return state.result(); + } + + @Override + public ExprType type() { + return aggregator.type(); + } + + @Override + public T accept(ExpressionNodeVisitor visitor, C context) { + return aggregator.accept(visitor, context); + } + + @Override + public String toString() { + return aggregator.toString(); + } + +} diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/CumulativeWindowFrame.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/frame/CurrentRowWindowFrame.java similarity index 72% rename from core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/CumulativeWindowFrame.java rename to core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/frame/CurrentRowWindowFrame.java index 75a5b3605f..4a4d15e826 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/CumulativeWindowFrame.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/frame/CurrentRowWindowFrame.java @@ -14,13 +14,14 @@ * */ -package com.amazon.opendistroforelasticsearch.sql.expression.window; +package com.amazon.opendistroforelasticsearch.sql.expression.window.frame; -import com.amazon.opendistroforelasticsearch.sql.data.model.ExprTupleValue; import com.amazon.opendistroforelasticsearch.sql.data.model.ExprValue; import com.amazon.opendistroforelasticsearch.sql.expression.Expression; import com.amazon.opendistroforelasticsearch.sql.expression.env.Environment; -import com.amazon.opendistroforelasticsearch.sql.expression.window.frame.WindowFrame; +import com.amazon.opendistroforelasticsearch.sql.expression.window.WindowDefinition; +import com.google.common.collect.PeekingIterator; +import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @@ -30,22 +31,21 @@ import lombok.ToString; /** - * Cumulative window frame that accumulates data row incrementally as window operator iterates - * input rows. Conceptually, cumulative window frame should hold all seen rows till next partition. + * Conceptually, cumulative window frame should hold all seen rows till next partition. * This class is actually an optimized version that only hold previous and current row. This is * efficient and sufficient for ranking and aggregate window function support for now, though need * to add "real" cumulative frame implementation in future as needed. */ @EqualsAndHashCode -@Getter @RequiredArgsConstructor @ToString -public class CumulativeWindowFrame implements WindowFrame { +public class CurrentRowWindowFrame implements WindowFrame { + @Getter private final WindowDefinition windowDefinition; - private ExprTupleValue previous; - private ExprTupleValue current; + private ExprValue previous; + private ExprValue current; @Override public boolean isNewPartition() { @@ -61,30 +61,39 @@ public boolean isNewPartition() { } @Override - public int currentIndex() { - // Current row index is always 1 since only 2 rows maintained - return 1; + public void load(PeekingIterator it) { + previous = current; + current = it.next(); } @Override - public void add(ExprTupleValue row) { - previous = current; - current = row; + public ExprValue current() { + return current; } - @Override - public ExprTupleValue get(int index) { - if (index != 0 && index != 1) { - throw new IndexOutOfBoundsException("Index is out of boundary of window frame: " + index); - } - return (index == 0) ? previous : current; + public ExprValue previous() { + return previous; } - private List resolve(List expressions, ExprTupleValue row) { + private List resolve(List expressions, ExprValue row) { Environment valueEnv = row.bindingTuples(); return expressions.stream() .map(expr -> expr.valueOf(valueEnv)) .collect(Collectors.toList()); } + /** + * Current row window frame won't pre-fetch any row ahead. + * So always return false as nothing "cached" in frame. + */ + @Override + public boolean hasNext() { + return false; + } + + @Override + public List next() { + return Collections.emptyList(); + } + } diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/frame/PeerRowsWindowFrame.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/frame/PeerRowsWindowFrame.java new file mode 100644 index 0000000000..7ba29ca014 --- /dev/null +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/frame/PeerRowsWindowFrame.java @@ -0,0 +1,157 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + * + */ + +package com.amazon.opendistroforelasticsearch.sql.expression.window.frame; + +import com.amazon.opendistroforelasticsearch.sql.data.model.ExprValue; +import com.amazon.opendistroforelasticsearch.sql.expression.Expression; +import com.amazon.opendistroforelasticsearch.sql.expression.env.Environment; +import com.amazon.opendistroforelasticsearch.sql.expression.window.WindowDefinition; +import com.google.common.collect.PeekingIterator; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.tuple.Pair; + +/** + * Window frame that only keep peers (tuples with same value of fields specified in sort list + * in window definition). See PeerWindowFrameTest for details about how this window frame + * interacts with window operator and window function. + */ +@RequiredArgsConstructor +public class PeerRowsWindowFrame implements WindowFrame { + + private final WindowDefinition windowDefinition; + + /** + * All peer rows (peer means rows in a partition that share same sort key + * based on sort list in window definition. + */ + private final List peers = new ArrayList<>(); + + /** + * Which row in the peer is currently being enriched by window function. + */ + private int position; + + /** + * Does row at current position represents a new partition. + */ + private boolean isNewPartition = true; + + /** + * If any more pre-fetched rows not returned to window operator yet. + */ + @Override + public boolean hasNext() { + return position < peers.size(); + } + + /** + * Move position and clear new partition flag. + * Note that because all peer rows have same result from window function, + * this is only returned at first time to change window function state. + * Afterwards, empty list is returned to avoid changes until next peer loaded. + * + * @return all rows for the peer + */ + @Override + public List next() { + isNewPartition = false; + if (position++ == 0) { + return peers; + } + return Collections.emptyList(); + } + + /** + * Current row at the position. Because rows are pre-fetched here, + * window operator needs to get them from here too. + * @return row at current position that being enriched by window function + */ + @Override + public ExprValue current() { + return peers.get(position); + } + + /** + * Preload all peer rows if last peer rows done. Note that when no more data in peeking iterator, + * there must be rows in frame (hasNext()=true), so no need to check it.hasNext() in this method. + * Load until: + * 1. Different peer found (row with different sort key) + * 2. Or new partition (row with different partition key) + * 3. Or no more rows + * @param it rows iterator + */ + @Override + public void load(PeekingIterator it) { + if (hasNext()) { + return; + } + + // Reset state: reset new partition before clearing peers + isNewPartition = !isSamePartition(it.peek()); + position = 0; + peers.clear(); + + while (it.hasNext()) { + ExprValue next = it.peek(); + if (peers.isEmpty()) { + peers.add(it.next()); + } else if (isSamePartition(next) && isPeer(next)) { + peers.add(it.next()); + } else { + break; + } + } + } + + @Override + public boolean isNewPartition() { + return isNewPartition; + } + + private boolean isPeer(ExprValue next) { + List sortFields = + windowDefinition.getSortList() + .stream() + .map(Pair::getRight) + .collect(Collectors.toList()); + + ExprValue last = peers.get(peers.size() - 1); + return resolve(sortFields, last).equals(resolve(sortFields, next)); + } + + private boolean isSamePartition(ExprValue next) { + if (peers.isEmpty()) { + return false; + } + + List partitionByList = windowDefinition.getPartitionByList(); + ExprValue last = peers.get(peers.size() - 1); + return resolve(partitionByList, last).equals(resolve(partitionByList, next)); + } + + private List resolve(List expressions, ExprValue row) { + Environment valueEnv = row.bindingTuples(); + return expressions.stream() + .map(expr -> expr.valueOf(valueEnv)) + .collect(Collectors.toList()); + } + +} diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/frame/WindowFrame.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/frame/WindowFrame.java index 4920598f69..fcc36e15fc 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/frame/WindowFrame.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/frame/WindowFrame.java @@ -16,10 +16,12 @@ package com.amazon.opendistroforelasticsearch.sql.expression.window.frame; -import com.amazon.opendistroforelasticsearch.sql.data.model.ExprTupleValue; import com.amazon.opendistroforelasticsearch.sql.data.model.ExprValue; import com.amazon.opendistroforelasticsearch.sql.expression.Expression; import com.amazon.opendistroforelasticsearch.sql.expression.env.Environment; +import com.google.common.collect.PeekingIterator; +import java.util.Iterator; +import java.util.List; /** * Window frame that represents a subset of a window which is all data accessible to @@ -30,11 +32,11 @@ * Note that which type of window frame is used is determined by both window function itself * and frame definition in a window definition. */ -public interface WindowFrame extends Environment { +public interface WindowFrame extends Environment, Iterator> { @Override default ExprValue resolve(Expression var) { - return var.valueOf(get(currentIndex()).bindingTuples()); + return var.valueOf(current().bindingTuples()); } /** @@ -44,22 +46,15 @@ default ExprValue resolve(Expression var) { boolean isNewPartition(); /** - * Get current row index in the frame. - * @return index + * Load one or more rows as window function calculation needed. + * @param iterator peeking iterator that can peek next element without moving iterator */ - int currentIndex(); + void load(PeekingIterator iterator); /** - * Add a row to the window frame. - * @param row data row - */ - void add(ExprTupleValue row); - - /** - * Get a data rows within the frame by offset. - * @param index index starting from 0 to upper boundary + * Get current data row for giving window operator chance to get rows preloaded into frame. * @return data row */ - ExprTupleValue get(int index); + ExprValue current(); } diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/ranking/DenseRankFunction.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/ranking/DenseRankFunction.java index 0eb0941fa7..bea3fa3a4e 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/ranking/DenseRankFunction.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/ranking/DenseRankFunction.java @@ -17,7 +17,7 @@ package com.amazon.opendistroforelasticsearch.sql.expression.window.ranking; import com.amazon.opendistroforelasticsearch.sql.expression.function.BuiltinFunctionName; -import com.amazon.opendistroforelasticsearch.sql.expression.window.CumulativeWindowFrame; +import com.amazon.opendistroforelasticsearch.sql.expression.window.frame.CurrentRowWindowFrame; /** * Dense rank window function that assigns a rank number to each row similarly as @@ -30,7 +30,7 @@ public DenseRankFunction() { } @Override - protected int rank(CumulativeWindowFrame frame) { + protected int rank(CurrentRowWindowFrame frame) { if (frame.isNewPartition()) { rank = 1; } else { diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/ranking/RankFunction.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/ranking/RankFunction.java index 2569c2ca16..eb2c45299f 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/ranking/RankFunction.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/ranking/RankFunction.java @@ -17,7 +17,7 @@ package com.amazon.opendistroforelasticsearch.sql.expression.window.ranking; import com.amazon.opendistroforelasticsearch.sql.expression.function.BuiltinFunctionName; -import com.amazon.opendistroforelasticsearch.sql.expression.window.CumulativeWindowFrame; +import com.amazon.opendistroforelasticsearch.sql.expression.window.frame.CurrentRowWindowFrame; /** * Rank window function that assigns a rank number to each row based on sort items @@ -36,7 +36,7 @@ public RankFunction() { } @Override - protected int rank(CumulativeWindowFrame frame) { + protected int rank(CurrentRowWindowFrame frame) { if (frame.isNewPartition()) { total = 1; rank = 1; diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/ranking/RankingWindowFunction.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/ranking/RankingWindowFunction.java index bb5419c105..0be473b7e3 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/ranking/RankingWindowFunction.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/ranking/RankingWindowFunction.java @@ -26,7 +26,9 @@ import com.amazon.opendistroforelasticsearch.sql.expression.FunctionExpression; import com.amazon.opendistroforelasticsearch.sql.expression.env.Environment; import com.amazon.opendistroforelasticsearch.sql.expression.function.FunctionName; -import com.amazon.opendistroforelasticsearch.sql.expression.window.CumulativeWindowFrame; +import com.amazon.opendistroforelasticsearch.sql.expression.window.WindowDefinition; +import com.amazon.opendistroforelasticsearch.sql.expression.window.WindowFunctionExpression; +import com.amazon.opendistroforelasticsearch.sql.expression.window.frame.CurrentRowWindowFrame; import com.amazon.opendistroforelasticsearch.sql.expression.window.frame.WindowFrame; import com.amazon.opendistroforelasticsearch.sql.storage.bindingtuple.BindingTuple; import java.util.List; @@ -37,7 +39,8 @@ * Ranking window function base class that captures same info across different ranking functions, * such as same return type (integer), same argument list (no arg). */ -public abstract class RankingWindowFunction extends FunctionExpression { +public abstract class RankingWindowFunction extends FunctionExpression + implements WindowFunctionExpression { /** * Current rank number assigned. @@ -53,9 +56,14 @@ public ExprType type() { return ExprCoreType.INTEGER; } + @Override + public WindowFrame createWindowFrame(WindowDefinition definition) { + return new CurrentRowWindowFrame(definition); + } + @Override public ExprValue valueOf(Environment valueEnv) { - return new ExprIntegerValue(rank((CumulativeWindowFrame) valueEnv)); + return new ExprIntegerValue(rank((CurrentRowWindowFrame) valueEnv)); } /** @@ -63,14 +71,14 @@ public ExprValue valueOf(Environment valueEnv) { * @param frame window frame * @return rank number */ - protected abstract int rank(CumulativeWindowFrame frame); + protected abstract int rank(CurrentRowWindowFrame frame); /** * Check sort field to see if current value is different from previous. * @param frame window frame * @return true if different, false if same or no sort list defined */ - protected boolean isSortFieldValueDifferent(CumulativeWindowFrame frame) { + protected boolean isSortFieldValueDifferent(CurrentRowWindowFrame frame) { if (isSortItemsNotDefined(frame)) { return false; } @@ -81,17 +89,17 @@ protected boolean isSortFieldValueDifferent(CumulativeWindowFrame frame) { .map(Pair::getRight) .collect(Collectors.toList()); - List previous = resolve(frame, sortItems, frame.currentIndex() - 1); - List current = resolve(frame, sortItems, frame.currentIndex()); + List previous = resolve(frame, sortItems, frame.previous()); + List current = resolve(frame, sortItems, frame.current()); return !current.equals(previous); } - private boolean isSortItemsNotDefined(CumulativeWindowFrame frame) { + private boolean isSortItemsNotDefined(CurrentRowWindowFrame frame) { return frame.getWindowDefinition().getSortList().isEmpty(); } - private List resolve(WindowFrame frame, List expressions, int index) { - BindingTuple valueEnv = frame.get(index).bindingTuples(); + private List resolve(WindowFrame frame, List expressions, ExprValue row) { + BindingTuple valueEnv = row.bindingTuples(); return expressions.stream() .map(expr -> expr.valueOf(valueEnv)) .collect(Collectors.toList()); diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/ranking/RowNumberFunction.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/ranking/RowNumberFunction.java index e11d071ffc..bb5abaa525 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/ranking/RowNumberFunction.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/expression/window/ranking/RowNumberFunction.java @@ -17,7 +17,7 @@ package com.amazon.opendistroforelasticsearch.sql.expression.window.ranking; import com.amazon.opendistroforelasticsearch.sql.expression.function.BuiltinFunctionName; -import com.amazon.opendistroforelasticsearch.sql.expression.window.CumulativeWindowFrame; +import com.amazon.opendistroforelasticsearch.sql.expression.window.frame.CurrentRowWindowFrame; /** * Row number window function that assigns row number starting from 1 to each row in a partition. @@ -29,7 +29,7 @@ public RowNumberFunction() { } @Override - protected int rank(CumulativeWindowFrame frame) { + protected int rank(CurrentRowWindowFrame frame) { if (frame.isNewPartition()) { rank = 1; } diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/planner/logical/LogicalPlanDSL.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/planner/logical/LogicalPlanDSL.java index 9f2cd274f2..f3be1955b8 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/planner/logical/LogicalPlanDSL.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/planner/logical/LogicalPlanDSL.java @@ -59,7 +59,7 @@ public static LogicalPlan project(LogicalPlan input, NamedExpression... fields) } public LogicalPlan window(LogicalPlan input, - Expression windowFunction, + NamedExpression windowFunction, WindowDefinition windowDefinition) { return new LogicalWindow(input, windowFunction, windowDefinition); } diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/planner/logical/LogicalWindow.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/planner/logical/LogicalWindow.java index 664f12686d..aa7a04c7c4 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/planner/logical/LogicalWindow.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/planner/logical/LogicalWindow.java @@ -16,7 +16,7 @@ package com.amazon.opendistroforelasticsearch.sql.planner.logical; -import com.amazon.opendistroforelasticsearch.sql.expression.Expression; +import com.amazon.opendistroforelasticsearch.sql.expression.NamedExpression; import com.amazon.opendistroforelasticsearch.sql.expression.window.WindowDefinition; import java.util.Collections; import lombok.EqualsAndHashCode; @@ -32,7 +32,7 @@ @Getter @ToString public class LogicalWindow extends LogicalPlan { - private final Expression windowFunction; + private final NamedExpression windowFunction; private final WindowDefinition windowDefinition; /** @@ -40,7 +40,7 @@ public class LogicalWindow extends LogicalPlan { */ public LogicalWindow( LogicalPlan child, - Expression windowFunction, + NamedExpression windowFunction, WindowDefinition windowDefinition) { super(Collections.singletonList(child)); this.windowFunction = windowFunction; diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/planner/physical/PhysicalPlanDSL.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/planner/physical/PhysicalPlanDSL.java index eb5442b5f9..f0a0a5be8b 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/planner/physical/PhysicalPlanDSL.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/planner/physical/PhysicalPlanDSL.java @@ -83,7 +83,7 @@ public static DedupeOperator dedupe( } public WindowOperator window(PhysicalPlan input, - Expression windowFunction, + NamedExpression windowFunction, WindowDefinition windowDefinition) { return new WindowOperator(input, windowFunction, windowDefinition); } diff --git a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/planner/physical/WindowOperator.java b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/planner/physical/WindowOperator.java index 92730d95b3..1286307564 100644 --- a/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/planner/physical/WindowOperator.java +++ b/core/src/main/java/com/amazon/opendistroforelasticsearch/sql/planner/physical/WindowOperator.java @@ -18,11 +18,13 @@ import com.amazon.opendistroforelasticsearch.sql.data.model.ExprTupleValue; import com.amazon.opendistroforelasticsearch.sql.data.model.ExprValue; -import com.amazon.opendistroforelasticsearch.sql.expression.Expression; -import com.amazon.opendistroforelasticsearch.sql.expression.window.CumulativeWindowFrame; +import com.amazon.opendistroforelasticsearch.sql.expression.NamedExpression; import com.amazon.opendistroforelasticsearch.sql.expression.window.WindowDefinition; +import com.amazon.opendistroforelasticsearch.sql.expression.window.WindowFunctionExpression; import com.amazon.opendistroforelasticsearch.sql.expression.window.frame.WindowFrame; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterators; +import com.google.common.collect.PeekingIterator; import java.util.Collections; import java.util.List; import lombok.EqualsAndHashCode; @@ -39,7 +41,7 @@ public class WindowOperator extends PhysicalPlan { private final PhysicalPlan input; @Getter - private final Expression windowFunction; + private final NamedExpression windowFunction; @Getter private final WindowDefinition windowDefinition; @@ -48,6 +50,15 @@ public class WindowOperator extends PhysicalPlan { @ToString.Exclude private final WindowFrame windowFrame; + /** + * Peeking iterator that can peek next element which is required + * by window frame such as peer frame to prefetch all rows related + * to same peer (of same sorting key). + */ + @EqualsAndHashCode.Exclude + @ToString.Exclude + private final PeekingIterator peekingIterator; + /** * Initialize window operator. * @param input child operator @@ -55,12 +66,13 @@ public class WindowOperator extends PhysicalPlan { * @param windowDefinition window definition */ public WindowOperator(PhysicalPlan input, - Expression windowFunction, + NamedExpression windowFunction, WindowDefinition windowDefinition) { this.input = input; this.windowFunction = windowFunction; this.windowDefinition = windowDefinition; this.windowFrame = createWindowFrame(); + this.peekingIterator = Iterators.peekingIterator(input); } @Override @@ -75,30 +87,18 @@ public List getChild() { @Override public boolean hasNext() { - return input.hasNext(); + return peekingIterator.hasNext() || windowFrame.hasNext(); } @Override public ExprValue next() { - loadRowsIntoWindowFrame(); + windowFrame.load(peekingIterator); return enrichCurrentRowByWindowFunctionResult(); } - /** - * For now cumulative window frame is returned always. When frame definition is supported: - * 1. Ranking window functions: ignore frame definition and always operates on entire window. - * 2. Aggregate window functions: operates on cumulative or sliding window based on definition. - */ private WindowFrame createWindowFrame() { - return new CumulativeWindowFrame(windowDefinition); - } - - /** - * For now always load next row into window frame. In future, how/how many rows loaded - * should be based on window frame type. - */ - private void loadRowsIntoWindowFrame() { - windowFrame.add((ExprTupleValue) input.next()); + return ((WindowFunctionExpression) windowFunction.getDelegated()) + .createWindowFrame(windowDefinition); } private ExprValue enrichCurrentRowByWindowFunctionResult() { @@ -109,13 +109,13 @@ private ExprValue enrichCurrentRowByWindowFunctionResult() { } private void preserveAllOriginalColumns(ImmutableMap.Builder mapBuilder) { - ExprTupleValue inputValue = windowFrame.get(windowFrame.currentIndex()); + ExprValue inputValue = windowFrame.current(); inputValue.tupleValue().forEach(mapBuilder::put); } private void addWindowFunctionResultColumn(ImmutableMap.Builder mapBuilder) { ExprValue exprValue = windowFunction.valueOf(windowFrame); - mapBuilder.put(windowFunction.toString(), exprValue); + mapBuilder.put(windowFunction.getName(), exprValue); } } diff --git a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/AnalyzerTest.java b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/AnalyzerTest.java index b8cd8ede2f..4c97c83bdd 100644 --- a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/AnalyzerTest.java +++ b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/AnalyzerTest.java @@ -370,13 +370,15 @@ public void window_function() { LogicalPlanDSL.relation("test"), ImmutablePair.of(DEFAULT_ASC, DSL.ref("string_value", STRING)), ImmutablePair.of(DEFAULT_ASC, DSL.ref("integer_value", INTEGER))), - dsl.rowNumber(), + DSL.named("window_function", dsl.rowNumber()), new WindowDefinition( ImmutableList.of(DSL.ref("string_value", STRING)), ImmutableList.of( ImmutablePair.of(DEFAULT_ASC, DSL.ref("integer_value", INTEGER))))), DSL.named("string_value", DSL.ref("string_value", STRING)), - DSL.named("window_function", DSL.ref("row_number()", INTEGER))), + // Alias name "window_function" is used as internal symbol name to connect + // project item and window operator output + DSL.named("window_function", DSL.ref("window_function", INTEGER))), AstDSL.project( AstDSL.relation("test"), AstDSL.alias("string_value", AstDSL.qualifiedName("string_value")), diff --git a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionAnalyzerTest.java b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionAnalyzerTest.java index 87cfd118e6..c3ecc4c8be 100644 --- a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionAnalyzerTest.java +++ b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionAnalyzerTest.java @@ -16,7 +16,6 @@ package com.amazon.opendistroforelasticsearch.sql.analysis; import static com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL.field; -import static com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL.filteredAggregate; import static com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL.function; import static com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL.intLiteral; import static com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL.qualifiedName; @@ -25,6 +24,7 @@ import static com.amazon.opendistroforelasticsearch.sql.data.type.ExprCoreType.BOOLEAN; import static com.amazon.opendistroforelasticsearch.sql.data.type.ExprCoreType.INTEGER; import static com.amazon.opendistroforelasticsearch.sql.data.type.ExprCoreType.STRUCT; +import static java.util.Collections.emptyList; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -39,11 +39,8 @@ import com.amazon.opendistroforelasticsearch.sql.exception.SemanticCheckException; import com.amazon.opendistroforelasticsearch.sql.expression.DSL; import com.amazon.opendistroforelasticsearch.sql.expression.Expression; -import com.amazon.opendistroforelasticsearch.sql.expression.LiteralExpression; import com.amazon.opendistroforelasticsearch.sql.expression.config.ExpressionConfig; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; +import com.amazon.opendistroforelasticsearch.sql.expression.window.aggregation.AggregateWindowFunction; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.context.annotation.Configuration; @@ -99,7 +96,7 @@ public void not() { public void qualified_name() { assertAnalyzeEqual( DSL.ref("integer_value", INTEGER), - AstDSL.qualifiedName("integer_value") + qualifiedName("integer_value") ); } @@ -115,7 +112,7 @@ public void case_value() { dsl.equal(DSL.ref("integer_value", INTEGER), DSL.literal(50)), DSL.literal("Fifty"))), AstDSL.caseWhen( - AstDSL.qualifiedName("integer_value"), + qualifiedName("integer_value"), AstDSL.stringLiteral("Default value"), AstDSL.when(AstDSL.intLiteral(30), AstDSL.stringLiteral("Thirty")), AstDSL.when(AstDSL.intLiteral(50), AstDSL.stringLiteral("Fifty")))); @@ -136,11 +133,11 @@ public void case_conditions() { null, AstDSL.when( AstDSL.function(">", - AstDSL.qualifiedName("integer_value"), + qualifiedName("integer_value"), AstDSL.intLiteral(50)), AstDSL.stringLiteral("Fifty")), AstDSL.when( AstDSL.function(">", - AstDSL.qualifiedName("integer_value"), + qualifiedName("integer_value"), AstDSL.intLiteral(30)), AstDSL.stringLiteral("Thirty")))); } @@ -158,7 +155,7 @@ public void castAnalyzer() { @Test public void case_with_default_result_type_different() { UnresolvedExpression caseWhen = AstDSL.caseWhen( - AstDSL.qualifiedName("integer_value"), + qualifiedName("integer_value"), AstDSL.intLiteral(60), AstDSL.when(AstDSL.intLiteral(30), AstDSL.stringLiteral("Thirty")), AstDSL.when(AstDSL.intLiteral(50), AstDSL.stringLiteral("Fifty"))); @@ -170,19 +167,37 @@ public void case_with_default_result_type_different() { exception.getMessage()); } + @Test + public void scalar_window_function() { + assertAnalyzeEqual( + dsl.rank(), + AstDSL.window(AstDSL.function("rank"), emptyList(), emptyList())); + } + + @SuppressWarnings("unchecked") + @Test + public void aggregate_window_function() { + assertAnalyzeEqual( + new AggregateWindowFunction(dsl.avg(DSL.ref("integer_value", INTEGER))), + AstDSL.window( + AstDSL.aggregate("avg", qualifiedName("integer_value")), + emptyList(), + emptyList())); + } + @Test public void qualified_name_with_qualifier() { analysisContext.push(); analysisContext.peek().define(new Symbol(Namespace.INDEX_NAME, "index_alias"), STRUCT); assertAnalyzeEqual( DSL.ref("integer_value", INTEGER), - AstDSL.qualifiedName("index_alias", "integer_value") + qualifiedName("index_alias", "integer_value") ); analysisContext.peek().define(new Symbol(Namespace.FIELD_NAME, "nested_field"), STRUCT); SyntaxCheckException exception = assertThrows(SyntaxCheckException.class, - () -> analyze(AstDSL.qualifiedName("nested_field", "integer_value"))); + () -> analyze(qualifiedName("nested_field", "integer_value"))); assertEquals( "The qualifier [nested_field] of qualified name [nested_field.integer_value] " + "must be an index name or its alias", @@ -217,7 +232,7 @@ public void case_clause() { AstDSL.nullLiteral(), AstDSL.when( AstDSL.function("=", - AstDSL.qualifiedName("integer_value"), + qualifiedName("integer_value"), AstDSL.intLiteral(30)), AstDSL.stringLiteral("test")))); } @@ -226,7 +241,7 @@ public void case_clause() { public void skip_struct_data_type() { SyntaxCheckException exception = assertThrows(SyntaxCheckException.class, - () -> analyze(AstDSL.qualifiedName("struct_value"))); + () -> analyze(qualifiedName("struct_value"))); assertEquals( "Identifier [struct_value] of type [STRUCT] is not supported yet", exception.getMessage() @@ -237,7 +252,7 @@ public void skip_struct_data_type() { public void skip_array_data_type() { SyntaxCheckException exception = assertThrows(SyntaxCheckException.class, - () -> analyze(AstDSL.qualifiedName("array_value"))); + () -> analyze(qualifiedName("array_value"))); assertEquals( "Identifier [array_value] of type [ARRAY] is not supported yet", exception.getMessage() diff --git a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionReferenceOptimizerTest.java b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionReferenceOptimizerTest.java index 7b39cf82f6..902408043d 100644 --- a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionReferenceOptimizerTest.java +++ b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/ExpressionReferenceOptimizerTest.java @@ -149,9 +149,9 @@ void window_expression_should_be_replaced() { LogicalPlanDSL.window( LogicalPlanDSL.window( LogicalPlanDSL.relation("test"), - dsl.rank(), + DSL.named(dsl.rank()), new WindowDefinition(emptyList(), emptyList())), - dsl.denseRank(), + DSL.named(dsl.denseRank()), new WindowDefinition(emptyList(), emptyList())); assertEquals( @@ -169,7 +169,7 @@ Expression optimize(Expression expression) { Expression optimize(Expression expression, LogicalPlan logicalPlan) { final ExpressionReferenceOptimizer optimizer = new ExpressionReferenceOptimizer(functionRepository, logicalPlan); - return optimizer.optimize(expression, new AnalysisContext()); + return optimizer.optimize(DSL.named(expression), new AnalysisContext()); } LogicalPlan logicalPlan() { diff --git a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/SelectExpressionAnalyzerTest.java b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/SelectExpressionAnalyzerTest.java index f0fe2db2a5..08558520f0 100644 --- a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/SelectExpressionAnalyzerTest.java +++ b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/SelectExpressionAnalyzerTest.java @@ -112,7 +112,8 @@ public void field_name_in_expression_with_qualifier() { } protected List analyze(UnresolvedExpression unresolvedExpression) { - doAnswer(returnsFirstArg()).when(optimizer).optimize(any(), any()); + doAnswer(invocation -> ((NamedExpression) invocation.getArgument(0)) + .getDelegated()).when(optimizer).optimize(any(), any()); return new SelectExpressionAnalyzer(expressionAnalyzer) .analyze(Arrays.asList(unresolvedExpression), analysisContext, optimizer); diff --git a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/WindowExpressionAnalyzerTest.java b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/WindowExpressionAnalyzerTest.java index 3292690b9a..ddc324cb9a 100644 --- a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/WindowExpressionAnalyzerTest.java +++ b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/analysis/WindowExpressionAnalyzerTest.java @@ -73,7 +73,7 @@ void should_wrap_child_with_window_and_sort_operator_if_project_item_windowed() LogicalPlanDSL.relation("test"), ImmutablePair.of(DEFAULT_ASC, DSL.ref("string_value", STRING)), ImmutablePair.of(DEFAULT_DESC, DSL.ref("integer_value", INTEGER))), - dsl.rowNumber(), + DSL.named("row_number", dsl.rowNumber()), new WindowDefinition( ImmutableList.of(DSL.ref("string_value", STRING)), ImmutableList.of( @@ -89,6 +89,25 @@ void should_wrap_child_with_window_and_sort_operator_if_project_item_windowed() analysisContext)); } + @Test + void should_not_generate_sort_operator_if_no_partition_by_and_order_by_list() { + assertEquals( + LogicalPlanDSL.window( + LogicalPlanDSL.relation("test"), + DSL.named("row_number", dsl.rowNumber()), + new WindowDefinition( + ImmutableList.of(), + ImmutableList.of())), + analyzer.analyze( + AstDSL.alias( + "row_number", + AstDSL.window( + AstDSL.function("row_number"), + ImmutableList.of(), + ImmutableList.of())), + analysisContext)); + } + @Test void should_return_original_child_if_project_item_not_windowed() { assertEquals( diff --git a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/executor/ExplainTest.java b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/executor/ExplainTest.java index dd63d53e69..3314feae18 100644 --- a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/executor/ExplainTest.java +++ b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/executor/ExplainTest.java @@ -168,7 +168,7 @@ void can_explain_window() { List> sortList = ImmutableList.of( ImmutablePair.of(DEFAULT_ASC, ref("age", INTEGER))); - PhysicalPlan plan = window(tableScan, dsl.rank(), + PhysicalPlan plan = window(tableScan, named(dsl.rank()), new WindowDefinition(partitionByList, sortList)); assertEquals( diff --git a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/window/CumulativeWindowFrameTest.java b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/window/CurrentRowWindowFrameTest.java similarity index 51% rename from core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/window/CumulativeWindowFrameTest.java rename to core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/window/CurrentRowWindowFrameTest.java index 62659710ae..64d271dec8 100644 --- a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/window/CumulativeWindowFrameTest.java +++ b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/window/CurrentRowWindowFrameTest.java @@ -21,61 +21,88 @@ import static com.amazon.opendistroforelasticsearch.sql.data.type.ExprCoreType.STRING; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import com.amazon.opendistroforelasticsearch.sql.data.model.ExprIntegerValue; import com.amazon.opendistroforelasticsearch.sql.data.model.ExprStringValue; import com.amazon.opendistroforelasticsearch.sql.data.model.ExprTupleValue; +import com.amazon.opendistroforelasticsearch.sql.data.model.ExprValue; import com.amazon.opendistroforelasticsearch.sql.expression.DSL; -import com.amazon.opendistroforelasticsearch.sql.expression.window.frame.WindowFrame; +import com.amazon.opendistroforelasticsearch.sql.expression.window.frame.CurrentRowWindowFrame; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterators; +import com.google.common.collect.PeekingIterator; import org.apache.commons.lang3.tuple.ImmutablePair; import org.junit.jupiter.api.Test; -class CumulativeWindowFrameTest { +class CurrentRowWindowFrameTest { - private final WindowDefinition windowDefinition = new WindowDefinition( - ImmutableList.of(DSL.ref("state", STRING)), - ImmutableList.of(ImmutablePair.of(DEFAULT_ASC, DSL.ref("age", INTEGER)))); + private final CurrentRowWindowFrame windowFrame = new CurrentRowWindowFrame( + new WindowDefinition( + ImmutableList.of(DSL.ref("state", STRING)), + ImmutableList.of(ImmutablePair.of(DEFAULT_ASC, DSL.ref("age", INTEGER))))); - private final WindowFrame windowFrame = new CumulativeWindowFrame(windowDefinition); + @Test + void test_iterator_methods() { + assertFalse(windowFrame.hasNext()); + assertTrue(windowFrame.next().isEmpty()); + } @Test void should_return_new_partition_if_partition_by_field_value_changed() { - ExprTupleValue tuple1 = ExprTupleValue.fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), - "age", new ExprIntegerValue(20))); - windowFrame.add(tuple1); + PeekingIterator iterator = Iterators.peekingIterator( + Iterators.forArray( + ExprTupleValue.fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("WA"), + "age", new ExprIntegerValue(20))), + ExprTupleValue.fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("WA"), + "age", new ExprIntegerValue(30))), + ExprTupleValue.fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("CA"), + "age", new ExprIntegerValue(18))))); + + windowFrame.load(iterator); assertTrue(windowFrame.isNewPartition()); - ExprTupleValue tuple2 = ExprTupleValue.fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), - "age", new ExprIntegerValue(30))); - windowFrame.add(tuple2); + windowFrame.load(iterator); assertFalse(windowFrame.isNewPartition()); - ExprTupleValue tuple3 = ExprTupleValue.fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("CA"), - "age", new ExprIntegerValue(18))); - windowFrame.add(tuple3); + windowFrame.load(iterator); assertTrue(windowFrame.isNewPartition()); } @Test void can_resolve_single_expression_value() { - windowFrame.add(ExprTupleValue.fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), - "age", new ExprIntegerValue(20)))); + windowFrame.load(Iterators.peekingIterator( + Iterators.singletonIterator( + ExprTupleValue.fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("WA"), + "age", new ExprIntegerValue(20)))))); assertEquals( new ExprIntegerValue(20), windowFrame.resolve(DSL.ref("age", INTEGER))); } @Test - void should_throw_exception_if_access_row_out_of_boundary() { - assertThrows(IndexOutOfBoundsException.class, () -> windowFrame.get(2)); + void can_return_previous_and_current_row() { + ExprValue row1 = ExprTupleValue.fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("WA"), + "age", new ExprIntegerValue(20))); + ExprValue row2 = ExprTupleValue.fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("WA"), + "age", new ExprIntegerValue(30))); + PeekingIterator iterator = Iterators.peekingIterator(Iterators.forArray(row1, row2)); + + windowFrame.load(iterator); + assertNull(windowFrame.previous()); + assertEquals(row1, windowFrame.current()); + + windowFrame.load(iterator); + assertEquals(row1, windowFrame.previous()); + assertEquals(row2, windowFrame.current()); } } \ No newline at end of file diff --git a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/window/aggregation/AggregateWindowFunctionTest.java b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/window/aggregation/AggregateWindowFunctionTest.java new file mode 100644 index 0000000000..df1eb7c25e --- /dev/null +++ b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/window/aggregation/AggregateWindowFunctionTest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + * + */ + +package com.amazon.opendistroforelasticsearch.sql.expression.window.aggregation; + +import static com.amazon.opendistroforelasticsearch.sql.data.model.ExprTupleValue.fromExprValueMap; +import static com.amazon.opendistroforelasticsearch.sql.data.type.ExprCoreType.INTEGER; +import static com.amazon.opendistroforelasticsearch.sql.data.type.ExprCoreType.LONG; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.amazon.opendistroforelasticsearch.sql.data.model.ExprIntegerValue; +import com.amazon.opendistroforelasticsearch.sql.expression.DSL; +import com.amazon.opendistroforelasticsearch.sql.expression.ExpressionTestBase; +import com.amazon.opendistroforelasticsearch.sql.expression.aggregation.Aggregator; +import com.amazon.opendistroforelasticsearch.sql.expression.window.frame.PeerRowsWindowFrame; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Aggregate window function test collection. + */ +@SuppressWarnings("unchecked") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@ExtendWith(MockitoExtension.class) +class AggregateWindowFunctionTest extends ExpressionTestBase { + + @SuppressWarnings("rawtypes") + @Test + void test_delegated_methods() { + Aggregator aggregator = mock(Aggregator.class); + when(aggregator.type()).thenReturn(LONG); + when(aggregator.accept(any(), any())).thenReturn(123); + when(aggregator.toString()).thenReturn("avg(age)"); + + AggregateWindowFunction windowFunction = new AggregateWindowFunction(aggregator); + assertEquals(LONG, windowFunction.type()); + assertEquals(123, (Integer) windowFunction.accept(null, null)); + assertEquals("avg(age)", windowFunction.toString()); + } + + @Test + void should_accumulate_all_peer_values_and_not_reset_state_if_same_partition() { + PeerRowsWindowFrame windowFrame = mock(PeerRowsWindowFrame.class); + AggregateWindowFunction windowFunction = + new AggregateWindowFunction(dsl.sum(DSL.ref("age", INTEGER))); + + when(windowFrame.isNewPartition()).thenReturn(true); + when(windowFrame.next()).thenReturn(ImmutableList.of( + fromExprValueMap(ImmutableMap.of("age", new ExprIntegerValue(10))), + fromExprValueMap(ImmutableMap.of("age", new ExprIntegerValue(20))))); + assertEquals(new ExprIntegerValue(30), windowFunction.valueOf(windowFrame)); + + when(windowFrame.isNewPartition()).thenReturn(false); + when(windowFrame.next()).thenReturn(ImmutableList.of( + fromExprValueMap(ImmutableMap.of("age", new ExprIntegerValue(30))))); + assertEquals(new ExprIntegerValue(60), windowFunction.valueOf(windowFrame)); + } + +} diff --git a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/window/frame/PeerRowsWindowFrameTest.java b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/window/frame/PeerRowsWindowFrameTest.java new file mode 100644 index 0000000000..a95ba5f029 --- /dev/null +++ b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/window/frame/PeerRowsWindowFrameTest.java @@ -0,0 +1,264 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + * + */ + +package com.amazon.opendistroforelasticsearch.sql.expression.window.frame; + +import static com.amazon.opendistroforelasticsearch.sql.ast.tree.Sort.SortOption.DEFAULT_ASC; +import static com.amazon.opendistroforelasticsearch.sql.data.model.ExprTupleValue.fromExprValueMap; +import static com.amazon.opendistroforelasticsearch.sql.data.type.ExprCoreType.INTEGER; +import static com.amazon.opendistroforelasticsearch.sql.data.type.ExprCoreType.STRING; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.amazon.opendistroforelasticsearch.sql.data.model.ExprIntegerValue; +import com.amazon.opendistroforelasticsearch.sql.data.model.ExprStringValue; +import com.amazon.opendistroforelasticsearch.sql.data.model.ExprValue; +import com.amazon.opendistroforelasticsearch.sql.expression.DSL; +import com.amazon.opendistroforelasticsearch.sql.expression.window.WindowDefinition; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterators; +import com.google.common.collect.PeekingIterator; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@ExtendWith(MockitoExtension.class) +class PeerRowsWindowFrameTest { + + private final PeerRowsWindowFrame windowFrame = new PeerRowsWindowFrame( + new WindowDefinition( + ImmutableList.of(DSL.ref("state", STRING)), + ImmutableList.of(Pair.of(DEFAULT_ASC, DSL.ref("age", INTEGER))))); + + @Test + void test_single_row() { + PeekingIterator tuples = Iterators.peekingIterator( + Iterators.singletonIterator(tuple("WA", 10, 100))); + windowFrame.load(tuples); + assertTrue(windowFrame.isNewPartition()); + assertEquals(ImmutableList.of(tuple("WA", 10, 100)), windowFrame.next()); + } + + @Test + void test_single_partition_with_no_more_rows_after_peers() { + PeekingIterator tuples = Iterators.peekingIterator( + Iterators.forArray( + tuple("WA", 10, 100), + tuple("WA", 20, 200), + tuple("WA", 20, 50))); + + // Here we simulate how WindowFrame interacts with WindowOperator which calls load() + // and WindowFunction which calls isNewPartition() and move() + windowFrame.load(tuples); + assertTrue(windowFrame.isNewPartition()); + assertEquals(ImmutableList.of(tuple("WA", 10, 100)), windowFrame.next()); + + windowFrame.load(tuples); + assertFalse(windowFrame.isNewPartition()); + assertEquals( + ImmutableList.of(tuple("WA", 20, 200), tuple("WA", 20, 50)), + windowFrame.next()); + + windowFrame.load(tuples); + assertFalse(windowFrame.isNewPartition()); + assertEquals(ImmutableList.of(), windowFrame.next()); + } + + @Test + void test_single_partition_with_more_rows_after_peers() { + PeekingIterator tuples = Iterators.peekingIterator( + Iterators.forArray( + tuple("WA", 10, 100), + tuple("WA", 20, 200), + tuple("WA", 20, 50), + tuple("WA", 35, 150))); + + windowFrame.load(tuples); + assertTrue(windowFrame.isNewPartition()); + assertEquals( + ImmutableList.of( + tuple("WA", 10, 100)), + windowFrame.next()); + + windowFrame.load(tuples); + assertFalse(windowFrame.isNewPartition()); + assertEquals( + ImmutableList.of( + tuple("WA", 20, 200), + tuple("WA", 20, 50)), + windowFrame.next()); + + windowFrame.load(tuples); + assertFalse(windowFrame.isNewPartition()); + assertEquals( + ImmutableList.of(), + windowFrame.next()); + + windowFrame.load(tuples); + assertFalse(windowFrame.isNewPartition()); + assertEquals( + ImmutableList.of( + tuple("WA", 35, 150)), + windowFrame.next()); + } + + @Test + void test_two_partitions_with_all_same_peers_in_second_partition() { + PeekingIterator tuples = Iterators.peekingIterator( + Iterators.forArray( + tuple("WA", 10, 100), + tuple("CA", 18, 150), + tuple("CA", 18, 100))); + + windowFrame.load(tuples); + assertTrue(windowFrame.isNewPartition()); + assertEquals( + ImmutableList.of( + tuple("WA", 10, 100)), + windowFrame.next()); + + windowFrame.load(tuples); + assertTrue(windowFrame.isNewPartition()); + assertEquals( + ImmutableList.of( + tuple("CA", 18, 150), + tuple("CA", 18, 100)), + windowFrame.next()); + + windowFrame.load(tuples); + assertFalse(windowFrame.isNewPartition()); + assertEquals( + ImmutableList.of(), + windowFrame.next()); + } + + @Test + void test_two_partitions_with_single_row_in_each_partition() { + PeekingIterator tuples = Iterators.peekingIterator( + Iterators.forArray( + tuple("WA", 10, 100), + tuple("CA", 30, 200))); + + windowFrame.load(tuples); + assertTrue(windowFrame.isNewPartition()); + assertEquals( + ImmutableList.of( + tuple("WA", 10, 100)), + windowFrame.next()); + + windowFrame.load(tuples); + assertTrue(windowFrame.isNewPartition()); + assertEquals( + ImmutableList.of( + tuple("CA", 30, 200)), + windowFrame.next()); + } + + @Test + void test_window_definition_with_no_partition_by() { + PeerRowsWindowFrame windowFrame = new PeerRowsWindowFrame( + new WindowDefinition( + ImmutableList.of(), + ImmutableList.of(Pair.of(DEFAULT_ASC, DSL.ref("age", INTEGER))))); + + PeekingIterator tuples = Iterators.peekingIterator( + Iterators.forArray( + tuple("WA", 10, 100), + tuple("CA", 30, 200))); + + windowFrame.load(tuples); + assertTrue(windowFrame.isNewPartition()); + assertEquals( + ImmutableList.of( + tuple("WA", 10, 100)), + windowFrame.next()); + + windowFrame.load(tuples); + assertFalse(windowFrame.isNewPartition()); + assertEquals( + ImmutableList.of( + tuple("CA", 30, 200)), + windowFrame.next()); + } + + @Test + void test_window_definition_with_no_order_by() { + PeerRowsWindowFrame windowFrame = new PeerRowsWindowFrame( + new WindowDefinition( + ImmutableList.of(DSL.ref("state", STRING)), + ImmutableList.of())); + + PeekingIterator tuples = Iterators.peekingIterator( + Iterators.forArray( + tuple("WA", 10, 100), + tuple("CA", 30, 200))); + + windowFrame.load(tuples); + assertTrue(windowFrame.isNewPartition()); + assertEquals( + ImmutableList.of( + tuple("WA", 10, 100)), + windowFrame.next()); + + windowFrame.load(tuples); + assertTrue(windowFrame.isNewPartition()); + assertEquals( + ImmutableList.of( + tuple("CA", 30, 200)), + windowFrame.next()); + } + + @Test + void test_window_definition_with_no_partition_by_and_order_by() { + PeerRowsWindowFrame windowFrame = new PeerRowsWindowFrame( + new WindowDefinition( + ImmutableList.of(), + ImmutableList.of())); + + PeekingIterator tuples = Iterators.peekingIterator( + Iterators.forArray( + tuple("WA", 10, 100), + tuple("CA", 30, 200))); + + windowFrame.load(tuples); + assertTrue(windowFrame.isNewPartition()); + assertEquals( + ImmutableList.of( + tuple("WA", 10, 100), + tuple("CA", 30, 200)), + windowFrame.next()); + + windowFrame.load(tuples); + assertFalse(windowFrame.isNewPartition()); + assertEquals( + ImmutableList.of(), + windowFrame.next()); + } + + private ExprValue tuple(String state, int age, int balance) { + return fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue(state), + "age", new ExprIntegerValue(age), + "balance", new ExprIntegerValue(balance))); + } + +} diff --git a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/window/ranking/RankingWindowFunctionTest.java b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/window/ranking/RankingWindowFunctionTest.java index ada077ca09..83c79c3dc5 100644 --- a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/window/ranking/RankingWindowFunctionTest.java +++ b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/expression/window/ranking/RankingWindowFunctionTest.java @@ -24,13 +24,17 @@ import com.amazon.opendistroforelasticsearch.sql.data.model.ExprIntegerValue; import com.amazon.opendistroforelasticsearch.sql.data.model.ExprStringValue; +import com.amazon.opendistroforelasticsearch.sql.data.model.ExprValue; import com.amazon.opendistroforelasticsearch.sql.expression.DSL; import com.amazon.opendistroforelasticsearch.sql.expression.ExpressionTestBase; -import com.amazon.opendistroforelasticsearch.sql.expression.window.CumulativeWindowFrame; import com.amazon.opendistroforelasticsearch.sql.expression.window.WindowDefinition; +import com.amazon.opendistroforelasticsearch.sql.expression.window.frame.CurrentRowWindowFrame; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterators; +import com.google.common.collect.PeekingIterator; import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Test; @@ -44,22 +48,54 @@ @ExtendWith(MockitoExtension.class) class RankingWindowFunctionTest extends ExpressionTestBase { - private final CumulativeWindowFrame windowFrame1 = new CumulativeWindowFrame( + private final CurrentRowWindowFrame windowFrame1 = new CurrentRowWindowFrame( new WindowDefinition( ImmutableList.of(DSL.ref("state", STRING)), ImmutableList.of(Pair.of(DEFAULT_ASC, DSL.ref("age", INTEGER))))); - private final CumulativeWindowFrame windowFrame2 = new CumulativeWindowFrame( + private final CurrentRowWindowFrame windowFrame2 = new CurrentRowWindowFrame( new WindowDefinition( ImmutableList.of(DSL.ref("state", STRING)), ImmutableList.of())); // No sort items defined + private PeekingIterator iterator1; + private PeekingIterator iterator2; + + @BeforeEach + void set_up() { + iterator1 = Iterators.peekingIterator(Iterators.forArray( + fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(30))), + fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(30))), + fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(40))), + fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("CA"), "age", new ExprIntegerValue(20))))); + + iterator2 = Iterators.peekingIterator(Iterators.forArray( + fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(30))), + fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(30))), + fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(50))), + fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(55))), + fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("CA"), "age", new ExprIntegerValue(15))))); + } + @Test void test_value_of() { - windowFrame1.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(30)))); + PeekingIterator iterator = Iterators.peekingIterator( + Iterators.singletonIterator( + fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(30))))); RankingWindowFunction rowNumber = dsl.rowNumber(); + + windowFrame1.load(iterator); assertEquals(new ExprIntegerValue(1), rowNumber.valueOf(windowFrame1)); } @@ -67,20 +103,16 @@ void test_value_of() { void test_row_number() { RankingWindowFunction rowNumber = dsl.rowNumber(); - windowFrame1.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(30)))); + windowFrame1.load(iterator1); assertEquals(1, rowNumber.rank(windowFrame1)); - windowFrame1.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(30)))); + windowFrame1.load(iterator1); assertEquals(2, rowNumber.rank(windowFrame1)); - windowFrame1.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(40)))); + windowFrame1.load(iterator1); assertEquals(3, rowNumber.rank(windowFrame1)); - windowFrame1.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("CA"), "age", new ExprIntegerValue(20)))); + windowFrame1.load(iterator1); assertEquals(1, rowNumber.rank(windowFrame1)); } @@ -88,24 +120,19 @@ void test_row_number() { void test_rank() { RankingWindowFunction rank = dsl.rank(); - windowFrame1.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(30)))); + windowFrame1.load(iterator2); assertEquals(1, rank.rank(windowFrame1)); - windowFrame1.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(30)))); + windowFrame1.load(iterator2); assertEquals(1, rank.rank(windowFrame1)); - windowFrame1.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(50)))); + windowFrame1.load(iterator2); assertEquals(3, rank.rank(windowFrame1)); - windowFrame1.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(55)))); + windowFrame1.load(iterator2); assertEquals(4, rank.rank(windowFrame1)); - windowFrame1.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("CA"), "age", new ExprIntegerValue(15)))); + windowFrame1.load(iterator2); assertEquals(1, rank.rank(windowFrame1)); } @@ -113,24 +140,19 @@ void test_rank() { void test_dense_rank() { RankingWindowFunction denseRank = dsl.denseRank(); - windowFrame1.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(30)))); + windowFrame1.load(iterator2); assertEquals(1, denseRank.rank(windowFrame1)); - windowFrame1.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(30)))); + windowFrame1.load(iterator2); assertEquals(1, denseRank.rank(windowFrame1)); - windowFrame1.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(50)))); + windowFrame1.load(iterator2); assertEquals(2, denseRank.rank(windowFrame1)); - windowFrame1.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(55)))); + windowFrame1.load(iterator2); assertEquals(3, denseRank.rank(windowFrame1)); - windowFrame1.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("CA"), "age", new ExprIntegerValue(15)))); + windowFrame1.load(iterator2); assertEquals(1, denseRank.rank(windowFrame1)); } @@ -138,45 +160,49 @@ void test_dense_rank() { void row_number_should_work_if_no_sort_items_defined() { RankingWindowFunction rowNumber = dsl.rowNumber(); - windowFrame2.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(30)))); + windowFrame2.load(iterator1); assertEquals(1, rowNumber.rank(windowFrame2)); - windowFrame2.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(30)))); + windowFrame2.load(iterator1); assertEquals(2, rowNumber.rank(windowFrame2)); - windowFrame2.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(40)))); + windowFrame2.load(iterator1); assertEquals(3, rowNumber.rank(windowFrame2)); - windowFrame2.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("CA"), "age", new ExprIntegerValue(20)))); + windowFrame2.load(iterator1); assertEquals(1, rowNumber.rank(windowFrame2)); } @Test void rank_should_always_return_1_if_no_sort_items_defined() { + PeekingIterator iterator = Iterators.peekingIterator( + Iterators.forArray( + fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(30))), + fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(30))), + fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(50))), + fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(55))), + fromExprValueMap(ImmutableMap.of( + "state", new ExprStringValue("CA"), "age", new ExprIntegerValue(15))))); + RankingWindowFunction rank = dsl.rank(); - windowFrame2.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(30)))); + windowFrame2.load(iterator); assertEquals(1, rank.rank(windowFrame2)); - windowFrame2.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(30)))); + windowFrame2.load(iterator); assertEquals(1, rank.rank(windowFrame2)); - windowFrame2.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(50)))); + windowFrame2.load(iterator); assertEquals(1, rank.rank(windowFrame2)); - windowFrame2.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(55)))); + windowFrame2.load(iterator); assertEquals(1, rank.rank(windowFrame2)); - windowFrame2.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("CA"), "age", new ExprIntegerValue(15)))); + windowFrame2.load(iterator); assertEquals(1, rank.rank(windowFrame2)); } @@ -184,24 +210,19 @@ void rank_should_always_return_1_if_no_sort_items_defined() { void dense_rank_should_always_return_1_if_no_sort_items_defined() { RankingWindowFunction denseRank = dsl.denseRank(); - windowFrame2.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(30)))); + windowFrame2.load(iterator2); assertEquals(1, denseRank.rank(windowFrame2)); - windowFrame2.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(30)))); + windowFrame2.load(iterator2); assertEquals(1, denseRank.rank(windowFrame2)); - windowFrame2.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(50)))); + windowFrame2.load(iterator2); assertEquals(1, denseRank.rank(windowFrame2)); - windowFrame2.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("WA"), "age", new ExprIntegerValue(55)))); + windowFrame2.load(iterator2); assertEquals(1, denseRank.rank(windowFrame2)); - windowFrame2.add(fromExprValueMap(ImmutableMap.of( - "state", new ExprStringValue("CA"), "age", new ExprIntegerValue(15)))); + windowFrame2.load(iterator2); assertEquals(1, denseRank.rank(windowFrame2)); } diff --git a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/planner/DefaultImplementorTest.java b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/planner/DefaultImplementorTest.java index 7fdcc6f3e4..04ee791325 100644 --- a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/planner/DefaultImplementorTest.java +++ b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/planner/DefaultImplementorTest.java @@ -178,7 +178,7 @@ public void visitRelationShouldThrowException() { @SuppressWarnings({"rawtypes", "unchecked"}) @Test public void visitWindowOperatorShouldReturnPhysicalWindowOperator() { - Expression windowFunction = new RowNumberFunction(); + NamedExpression windowFunction = named(new RowNumberFunction()); WindowDefinition windowDefinition = new WindowDefinition( Collections.singletonList(ref("state", STRING)), Collections.singletonList( diff --git a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/planner/logical/LogicalPlanNodeVisitorTest.java b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/planner/logical/LogicalPlanNodeVisitorTest.java index e7a7ed590e..9b9863a5cc 100644 --- a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/planner/logical/LogicalPlanNodeVisitorTest.java +++ b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/planner/logical/LogicalPlanNodeVisitorTest.java @@ -114,7 +114,7 @@ public void testAbstractPlanNodeVisitorShouldReturnNull() { assertNull(dedup.accept(new LogicalPlanNodeVisitor() { }, null)); - LogicalPlan window = LogicalPlanDSL.window(relation, expression, new WindowDefinition( + LogicalPlan window = LogicalPlanDSL.window(relation, named(expression), new WindowDefinition( ImmutableList.of(ref), ImmutableList.of(Pair.of(SortOption.DEFAULT_ASC, expression)))); assertNull(window.accept(new LogicalPlanNodeVisitor() { }, null)); diff --git a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/planner/physical/PhysicalPlanNodeVisitorTest.java b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/planner/physical/PhysicalPlanNodeVisitorTest.java index 34399abaf2..f97c551afe 100644 --- a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/planner/physical/PhysicalPlanNodeVisitorTest.java +++ b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/planner/physical/PhysicalPlanNodeVisitorTest.java @@ -118,7 +118,7 @@ public void test_PhysicalPlanVisitor_should_return_null() { assertNull(project.accept(new PhysicalPlanNodeVisitor() { }, null)); - PhysicalPlan window = PhysicalPlanDSL.window(plan, dsl.rowNumber(), + PhysicalPlan window = PhysicalPlanDSL.window(plan, named(dsl.rowNumber()), new WindowDefinition(emptyList(), emptyList())); assertNull(window.accept(new PhysicalPlanNodeVisitor() { }, null)); diff --git a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/planner/physical/WindowOperatorTest.java b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/planner/physical/WindowOperatorTest.java index 9063b0d1e2..61f0f0a9ae 100644 --- a/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/planner/physical/WindowOperatorTest.java +++ b/core/src/test/java/com/amazon/opendistroforelasticsearch/sql/planner/physical/WindowOperatorTest.java @@ -26,9 +26,11 @@ import com.amazon.opendistroforelasticsearch.sql.ast.tree.Sort.SortOption; import com.amazon.opendistroforelasticsearch.sql.data.model.ExprValueUtils; +import com.amazon.opendistroforelasticsearch.sql.expression.DSL; import com.amazon.opendistroforelasticsearch.sql.expression.Expression; -import com.amazon.opendistroforelasticsearch.sql.expression.FunctionExpression; +import com.amazon.opendistroforelasticsearch.sql.expression.NamedExpression; import com.amazon.opendistroforelasticsearch.sql.expression.window.WindowDefinition; +import com.amazon.opendistroforelasticsearch.sql.expression.window.aggregation.AggregateWindowFunction; import com.google.common.collect.ImmutableMap; import java.util.ArrayList; import java.util.List; @@ -47,7 +49,7 @@ class WindowOperatorTest extends PhysicalPlanTestBase { @Test - void test() { + void test_ranking_window_function() { window(dsl.rank()) .partitionBy(ref("action", STRING)) .sortBy(DEFAULT_ASC, ref("response", INTEGER)) @@ -69,18 +71,68 @@ void test() { .done(); } - private WindowOperatorAssertion window(FunctionExpression windowFunction) { + @SuppressWarnings("unchecked") + @Test + void test_aggregate_window_function() { + window(new AggregateWindowFunction(dsl.sum(ref("response", INTEGER)))) + .partitionBy(ref("action", STRING)) + .sortBy(DEFAULT_ASC, ref("response", INTEGER)) + .expectNext(ImmutableMap.of( + "ip", "209.160.24.63", "action", "GET", "response", 200, "referer", "www.amazon.com", + "sum(response)", 400)) + .expectNext(ImmutableMap.of( + "ip", "112.111.162.4", "action", "GET", "response", 200, "referer", "www.amazon.com", + "sum(response)", 400)) + .expectNext(ImmutableMap.of( + "ip", "209.160.24.63", "action", "GET", "response", 404, "referer", "www.amazon.com", + "sum(response)", 804)) + .expectNext(ImmutableMap.of( + "ip", "74.125.19.106", "action", "POST", "response", 200, "referer", "www.google.com", + "sum(response)", 200)) + .expectNext(ImmutableMap.of( + "ip", "74.125.19.106", "action", "POST", "response", 500, + "sum(response)", 700)) + .done(); + } + + @SuppressWarnings("unchecked") + @Test + void test_aggregate_window_function_without_sort_key() { + window(new AggregateWindowFunction(dsl.sum(ref("response", INTEGER)))) + .expectNext(ImmutableMap.of( + "ip", "209.160.24.63", "action", "GET", "response", 200, "referer", "www.amazon.com", + "sum(response)", 1504)) + .expectNext(ImmutableMap.of( + "ip", "74.125.19.106", "action", "POST", "response", 500, + "sum(response)", 1504)) + .expectNext(ImmutableMap.of( + "ip", "74.125.19.106", "action", "POST", "response", 200, "referer", "www.google.com", + "sum(response)", 1504)) + .expectNext(ImmutableMap.of( + "ip", "112.111.162.4", "action", "GET", "response", 200, "referer", "www.amazon.com", + "sum(response)", 1504)) + .expectNext(ImmutableMap.of( + "ip", "209.160.24.63", "action", "GET", "response", 404, "referer", "www.amazon.com", + "sum(response)", 1504)) + .done(); + } + + private WindowOperatorAssertion window(Expression windowFunction) { return new WindowOperatorAssertion(windowFunction); } @RequiredArgsConstructor private static class WindowOperatorAssertion { - private final Expression windowFunction; + private final NamedExpression windowFunction; private final List partitionByList = new ArrayList<>(); private final List> sortList = new ArrayList<>(); private WindowOperator windowOperator; + private WindowOperatorAssertion(Expression windowFunction) { + this.windowFunction = DSL.named(windowFunction); + } + WindowOperatorAssertion partitionBy(Expression expr) { partitionByList.add(expr); return this; diff --git a/docs/dev/AggregateWindowFunction.md b/docs/dev/AggregateWindowFunction.md new file mode 100644 index 0000000000..53c01c27cb --- /dev/null +++ b/docs/dev/AggregateWindowFunction.md @@ -0,0 +1,94 @@ +# SQL Aggregate Window Functions + +## 1.Overview + +To support aggregate window functions, the following two problems need to be addressed: + +1. How to make existing aggregate functions work as window function +2. How to handle duplicate sort key (field values in ORDER BY, will elaborate shortly) + +For the first problem, a wrapper class AggregateWindowFunction is created. In particular, it extends Expression interface and reuse existing aggregate functions to calculate result based on window frame. **Now let’s examine in details how to address the second problem**. + + +## 2.Problem Statement + +First let’s check why it’s a problem to the window frame and ranking window function framework introduced earlier. In the following example, `age` as sort key in `ORDER BY age` is unique, so the running total is accumulated on each row incrementally. + +``` +mysql> SELECT + -> ROW_NUMBER() OVER () AS "no.", + -> state, age, balance, + -> SUM(balance) OVER (PARTITION BY state ORDER BY age) AS "running total" + -> FROM accounts + -> ORDER BY state DESC, age; ++-----+-------+------+---------+---------------+ +| no. | state | age | balance | running total | ++-----+-------+------+---------+---------------+ +| 1 | WA | 10 | 100 | 100 | +| 2 | WA | 20 | 200 | 300 | +| 3 | WA | 25 | 50 | 350 | +| 4 | WA | 35 | 150 | 500 | +| 5 | CA | 18 | 150 | 150 | +| 6 | CA | 25 | 100 | 250 | +| 7 | CA | 30 | 200 | 450 | ++-----+-------+------+---------+---------------+ +``` + +However, problem arises when the sort key has duplicate values. For example, the 2nd and 3rd row (called peers) in ‘WA’ partition has same value. Same for the 5th and 6th row in the ‘CA’ partition. In this case, the running total would be the same for peer rows. This looks strange at first sight, though the reason is **the fact that which row is current is defined by sort key. That’s why the existing window frame and function implementation only based on and access to current row won’t work.** + +``` +mysql> SELECT + -> ROW_NUMBER() OVER () AS "no.", + -> state, age, balance, + -> SUM(balance) OVER (PARTITION BY state ORDER BY age) AS "running total" + -> FROM accounts + -> ORDER BY state DESC, age; ++-----+-------+------+---------+---------------+ +| no. | state | age | balance | running total | ++-----+-------+------+---------+---------------+ +| 1 | WA | 10 | 100 | 100 | +| 2 | WA | 20 | 200 | 350 | +| 3 | WA | 20 | 50 | 350 | +| 4 | WA | 35 | 150 | 500 | +| 5 | CA | 18 | 150 | 250 | +| 6 | CA | 18 | 100 | 250 | +| 7 | CA | 30 | 200 | 450 | ++-----+-------+------+---------+---------------+ +``` + +## 3.Solution + +### 3.1 How It Works + +By the examples above, we should be able to understand what an aggregate window function does conceptually. To implement, first we need to figure out how aggregate window functions work from iterative thinking. Let’s review the previous example and imagine a query engine behind it doing the calculation. + +``` ++-----+-------+------+---------+---------------+ +| no. | state | age | balance | running total | ++-----+-------+------+---------+---------------+ <- initial state +| 1 | WA | 10 | 100 | 100 | <- load 100, return 100 as sum +| 2 | WA | 20 | 200 | 350 | <- load 200 and 50, return 350 +| 3 | WA | 20 | 50 | 350 | <- load nothing, return 350 again +| 4 | WA | 35 | 150 | 500 | <- load 150, return 500 +| 5 | CA | 18 | 150 | 250 | <- new partition, reset and load 100 and 150 +| 6 | CA | 18 | 100 | 250 | <- load nothing, return 250 again +| 7 | CA | 30 | 200 | 450 | <- load 200, return 450 ++-----+-------+------+---------+---------------+ <- no more data, done +``` + +### 3.2 Design + +To explain the design in more intuitive way, formal sequence diagram in UML is not present here. Instead the following informal diagram illustrates how `WindowOperator`, `PeerRowsWindowFrame` and `AggregateWindowFunction` component work together as a whole to implement the same logic in last section. + +![High Level Design](img/aggregate-window-functions.png) + +### 3.3 Performance + +For time complexity, aggregate window functions are same as ranking functions which only scan input linearly. However, as for space complexity, there seems no way to avoid this memory consumption. Because more rows needs to be pre-fetched for calculation and meanwhile window operator need access to each previous row, one by one, as output. + +In the worst case, all input data will be pulled out into window frame if: + +1. Single partition due to no PARTITION BY clause +2. (and) All values of ORDER BY fields are exactly the same + +In this case, circuit breaker needs to be enabled to protect window operator from consuming large memory. diff --git a/docs/dev/img/aggregate-window-functions.png b/docs/dev/img/aggregate-window-functions.png new file mode 100644 index 0000000000000000000000000000000000000000..9132280e60133afa5369b320d30176054468e5dc GIT binary patch literal 89495 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

5?{mcdC+l*rH0D8do-RK;HJ;+sX}TZq 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�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

4HD%{6uo7#ovhImkv|KYw-u zbm~iWuRvG9yjSnFY1sj*t~R-w;eek!f8(0${Z=q~j9@e>xm%5}`Y9wiX3utGP>?fg zu{4+S-grmHSlQ?mKzXi9jcHa6b`Z^~pfIw7BrwwmbJ6*X#wt&;Nj=tZbuXN94V3bS z@-v~#%xyVDpFkAx?xP_3#^?Ub>m+G(n?#K?kd-DI!m-Zxor=U^Csu4b6%i@YUc#k~ zdljS{1=)CJav`$)Xg-C?eqrYcJ%wC|pB+Lvz$Tn0*L+sjwE>sRmz-6)rxjF7+D;Qp zZRW)k7X^_Y|DBhc5f@rVPI6cs7S@y;iGkQi za91`oW(~`29SQC_necpx=+yuaU*OG`4Y7wvd|ODUrw5PTBwvfZNm`yi;9yN8U5g;uK z4$FmwFCnIFTXuJ?#)!E^Wg5R4O&YcMH`!)&p~H8%{)p$Ubk7?HbryjlNKU|~o0CN| z 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 literal 0 HcmV?d00001 diff --git a/docs/user/dql/window.rst b/docs/user/dql/window.rst index 004e3a42df..ba105903e6 100644 --- a/docs/user/dql/window.rst +++ b/docs/user/dql/window.rst @@ -36,6 +36,117 @@ The syntax of a window function is as follows in which both ``PARTITION BY`` and ) +Aggregate Functions +=================== + +Aggregate functions are window functions that operates on a cumulative window frame to calculate an aggregated result. How cumulative data in the window frame being aggregated is exactly same as how regular aggregate functions work. So aggregate window functions can be used to perform running calculation easily, for example running average or running sum. Note that if ``PARTITION BY`` clause present and specified column value(s) changed, the state of aggregate function will be reset. + +COUNT +----- + +Here is an example for ``COUNT`` function:: + + od> SELECT + ... gender, balance, + ... COUNT(balance) OVER( + ... PARTITION BY gender ORDER BY balance + ... ) AS cnt + ... FROM accounts; + fetched rows / total rows = 4/4 + +----------+-----------+-------+ + | gender | balance | cnt | + |----------+-----------+-------| + | F | 32838 | 1 | + | M | 4180 | 1 | + | M | 5686 | 2 | + | M | 39225 | 3 | + +----------+-----------+-------+ + +MIN +--- + +Here is an example for ``MIN`` function:: + + od> SELECT + ... gender, balance, + ... MIN(balance) OVER( + ... PARTITION BY gender ORDER BY balance + ... ) AS cnt + ... FROM accounts; + fetched rows / total rows = 4/4 + +----------+-----------+-------+ + | gender | balance | cnt | + |----------+-----------+-------| + | F | 32838 | 32838 | + | M | 4180 | 4180 | + | M | 5686 | 4180 | + | M | 39225 | 4180 | + +----------+-----------+-------+ + +MAX +--- + +Here is an example for ``MAX`` function:: + + od> SELECT + ... gender, balance, + ... MAX(balance) OVER( + ... PARTITION BY gender ORDER BY balance + ... ) AS cnt + ... FROM accounts; + fetched rows / total rows = 4/4 + +----------+-----------+-------+ + | gender | balance | cnt | + |----------+-----------+-------| + | F | 32838 | 32838 | + | M | 4180 | 4180 | + | M | 5686 | 5686 | + | M | 39225 | 39225 | + +----------+-----------+-------+ + +AVG +--- + +Here is an example for ``AVG`` function:: + + od> SELECT + ... gender, balance, + ... AVG(balance) OVER( + ... PARTITION BY gender ORDER BY balance + ... ) AS cnt + ... FROM accounts; + fetched rows / total rows = 4/4 + +----------+-----------+--------------------+ + | gender | balance | cnt | + |----------+-----------+--------------------| + | F | 32838 | 32838.0 | + | M | 4180 | 4180.0 | + | M | 5686 | 4933.0 | + | M | 39225 | 16363.666666666666 | + +----------+-----------+--------------------+ + +SUM +--- + +Here is an example for ``SUM`` function:: + + od> SELECT + ... gender, balance, + ... SUM(balance) OVER( + ... PARTITION BY gender ORDER BY balance + ... ) AS cnt + ... FROM accounts; + fetched rows / total rows = 4/4 + +----------+-----------+-------+ + | gender | balance | cnt | + |----------+-----------+-------| + | F | 32838 | 32838 | + | M | 4180 | 4180 | + | M | 5686 | 9866 | + | M | 39225 | 49091 | + +----------+-----------+-------+ + + Ranking Functions ================= diff --git a/docs/user/limitations/limitations.rst b/docs/user/limitations/limitations.rst index cd39a1472a..bffa9a0c97 100644 --- a/docs/user/limitations/limitations.rst +++ b/docs/user/limitations/limitations.rst @@ -63,13 +63,24 @@ Here's a link to the Github issue - `Issue 110 sortItem = ImmutablePair.of(DEFAULT_ASC, DSL.ref("age", INTEGER)); WindowDefinition windowDefinition = diff --git a/integ-test/src/test/resources/correctness/queries/window.txt b/integ-test/src/test/resources/correctness/queries/window.txt index 53e682b0f0..8a1191d938 100644 --- a/integ-test/src/test/resources/correctness/queries/window.txt +++ b/integ-test/src/test/resources/correctness/queries/window.txt @@ -4,10 +4,29 @@ SELECT DistanceMiles, DENSE_RANK() OVER (ORDER BY DistanceMiles) AS rnk FROM kib SELECT DistanceMiles, ROW_NUMBER() OVER (ORDER BY DistanceMiles DESC) AS num FROM kibana_sample_data_flights SELECT DistanceMiles, RANK() OVER (ORDER BY DistanceMiles DESC) AS rnk FROM kibana_sample_data_flights SELECT DistanceMiles, DENSE_RANK() OVER (ORDER BY DistanceMiles DESC) AS rnk FROM kibana_sample_data_flights +SELECT DistanceMiles, COUNT(DistanceMiles) OVER () AS num FROM kibana_sample_data_flights +SELECT DistanceMiles, SUM(DistanceMiles) OVER () AS num FROM kibana_sample_data_flights +SELECT DistanceMiles, AVG(DistanceMiles) OVER () AS num FROM kibana_sample_data_flights +SELECT DistanceMiles, MAX(DistanceMiles) OVER () AS num FROM kibana_sample_data_flights +SELECT DistanceMiles, MIN(DistanceMiles) OVER () AS num FROM kibana_sample_data_flights +SELECT FlightDelayMin, DistanceMiles, SUM(DistanceMiles) OVER (ORDER BY FlightDelayMin) AS num FROM kibana_sample_data_flights +SELECT FlightDelayMin, DistanceMiles, AVG(DistanceMiles) OVER (ORDER BY FlightDelayMin) AS num FROM kibana_sample_data_flights +SELECT FlightDelayMin, DistanceMiles, MAX(DistanceMiles) OVER (ORDER BY FlightDelayMin) AS num FROM kibana_sample_data_flights +SELECT FlightDelayMin, DistanceMiles, MIN(DistanceMiles) OVER (ORDER BY FlightDelayMin) AS num FROM kibana_sample_data_flights SELECT user, RANK() OVER (ORDER BY user) AS rnk FROM kibana_sample_data_ecommerce SELECT user, DENSE_RANK() OVER (ORDER BY user) AS rnk FROM kibana_sample_data_ecommerce +SELECT user, COUNT(day_of_week_i) OVER (ORDER BY user) AS cnt FROM kibana_sample_data_ecommerce +SELECT user, SUM(day_of_week_i) OVER (ORDER BY user) AS num FROM kibana_sample_data_ecommerce +SELECT user, AVG(day_of_week_i) OVER (ORDER BY user) AS num FROM kibana_sample_data_ecommerce +SELECT user, MAX(day_of_week_i) OVER (ORDER BY user) AS num FROM kibana_sample_data_ecommerce +SELECT user, MIN(day_of_week_i) OVER (ORDER BY user) AS num FROM kibana_sample_data_ecommerce SELECT user, RANK() OVER (ORDER BY user DESC) AS rnk FROM kibana_sample_data_ecommerce SELECT user, DENSE_RANK() OVER (ORDER BY user DESC) AS rnk FROM kibana_sample_data_ecommerce +SELECT user, COUNT(day_of_week_i) OVER (PARTITION BY user ORDER BY order_id) AS cnt FROM kibana_sample_data_ecommerce +SELECT user, SUM(day_of_week_i) OVER (PARTITION BY user ORDER BY order_id) AS num FROM kibana_sample_data_ecommerce +SELECT user, AVG(day_of_week_i) OVER (PARTITION BY user ORDER BY order_id) AS num FROM kibana_sample_data_ecommerce +SELECT user, MAX(day_of_week_i) OVER (PARTITION BY user ORDER BY order_id) AS num FROM kibana_sample_data_ecommerce +SELECT user, MIN(day_of_week_i) OVER (PARTITION BY user ORDER BY order_id) AS num FROM kibana_sample_data_ecommerce SELECT customer_gender, user, ROW_NUMBER() OVER (PARTITION BY customer_gender ORDER BY user) AS num FROM kibana_sample_data_ecommerce SELECT customer_gender, user, RANK() OVER (PARTITION BY customer_gender ORDER BY user) AS num FROM kibana_sample_data_ecommerce SELECT customer_gender, user, DENSE_RANK() OVER (PARTITION BY customer_gender ORDER BY user) AS num FROM kibana_sample_data_ecommerce diff --git a/sql/src/main/antlr/OpenDistroSQLParser.g4 b/sql/src/main/antlr/OpenDistroSQLParser.g4 index f645174397..84ab26279f 100644 --- a/sql/src/main/antlr/OpenDistroSQLParser.g4 +++ b/sql/src/main/antlr/OpenDistroSQLParser.g4 @@ -155,12 +155,14 @@ limitClause ; // Window Function's Details -windowFunction - : function=rankingWindowFunction overClause +windowFunctionClause + : function=windowFunction overClause ; -rankingWindowFunction - : functionName=(ROW_NUMBER | RANK | DENSE_RANK) LR_BRACKET RR_BRACKET +windowFunction + : functionName=(ROW_NUMBER | RANK | DENSE_RANK) + LR_BRACKET functionArgs? RR_BRACKET #scalarWindowFunction + | aggregateFunction #aggregateWindowFunction ; overClause @@ -283,7 +285,7 @@ nullNotnull functionCall : scalarFunctionName LR_BRACKET functionArgs? RR_BRACKET #scalarFunctionCall | specificFunction #specificFunctionCall - | windowFunction #windowFunctionCall + | windowFunctionClause #windowFunctionCall | aggregateFunction #aggregateFunctionCall | aggregateFunction (orderByClause)? filterClause #filteredAggregationFunctionCall ; diff --git a/sql/src/main/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/AstExpressionBuilder.java b/sql/src/main/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/AstExpressionBuilder.java index 6942aab238..84e58d9535 100644 --- a/sql/src/main/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/AstExpressionBuilder.java +++ b/sql/src/main/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/AstExpressionBuilder.java @@ -27,7 +27,10 @@ import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.BooleanContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.CaseFuncAlternativeContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.CaseFunctionCallContext; +import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.ColumnFilterContext; +import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.ConvertedDataTypeContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.CountStarFunctionCallContext; +import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.DataTypeFunctionCallContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.DateLiteralContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.IsNullPredicateContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.LikePredicateContext; @@ -36,16 +39,19 @@ import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.NullLiteralContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.OverClauseContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.QualifiedNameContext; -import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.RankingWindowFunctionContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.RegexpPredicateContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.RegularAggregateFunctionCallContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.ScalarFunctionCallContext; +import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.ScalarWindowFunctionContext; +import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.ShowDescribePatternContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.SignedDecimalContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.SignedRealContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.StringContext; +import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.StringLiteralContext; +import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.TableFilterContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.TimeLiteralContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.TimestampLiteralContext; -import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.WindowFunctionContext; +import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.WindowFunctionClauseContext; import static com.amazon.opendistroforelasticsearch.sql.sql.parser.ParserUtils.createSortOption; import com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL; @@ -68,6 +74,7 @@ import com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser; import com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.AndExpressionContext; import com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.ColumnNameContext; +import com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.FunctionArgsContext; import com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.IdentContext; import com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.IntervalLiteralContext; import com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.NestedExpressionAtomContext; @@ -122,28 +129,18 @@ public UnresolvedExpression visitNestedExpressionAtom(NestedExpressionAtomContex @Override public UnresolvedExpression visitScalarFunctionCall(ScalarFunctionCallContext ctx) { - if (ctx.functionArgs() == null) { - return new Function(ctx.scalarFunctionName().getText(), Collections.emptyList()); - } - return new Function( - ctx.scalarFunctionName().getText(), - ctx.functionArgs() - .functionArg() - .stream() - .map(this::visitFunctionArg) - .collect(Collectors.toList()) - ); + return visitFunction(ctx.scalarFunctionName().getText(), ctx.functionArgs()); } @Override - public UnresolvedExpression visitTableFilter(OpenDistroSQLParser.TableFilterContext ctx) { + public UnresolvedExpression visitTableFilter(TableFilterContext ctx) { return new Function( LIKE.getName().getFunctionName(), Arrays.asList(qualifiedName("TABLE_NAME"), visit(ctx.showDescribePattern()))); } @Override - public UnresolvedExpression visitColumnFilter(OpenDistroSQLParser.ColumnFilterContext ctx) { + public UnresolvedExpression visitColumnFilter(ColumnFilterContext ctx) { return new Function( LIKE.getName().getFunctionName(), Arrays.asList(qualifiedName("COLUMN_NAME"), visit(ctx.showDescribePattern()))); @@ -151,7 +148,7 @@ public UnresolvedExpression visitColumnFilter(OpenDistroSQLParser.ColumnFilterCo @Override public UnresolvedExpression visitShowDescribePattern( - OpenDistroSQLParser.ShowDescribePatternContext ctx) { + ShowDescribePatternContext ctx) { if (ctx.compatibleID() != null) { return stringLiteral(ctx.compatibleID().getText()); } else { @@ -167,7 +164,7 @@ public UnresolvedExpression visitFilteredAggregationFunctionCall( } @Override - public UnresolvedExpression visitWindowFunction(WindowFunctionContext ctx) { + public UnresolvedExpression visitWindowFunctionClause(WindowFunctionClauseContext ctx) { OverClauseContext overClause = ctx.overClause(); List partitionByList = Collections.emptyList(); @@ -188,12 +185,12 @@ public UnresolvedExpression visitWindowFunction(WindowFunctionContext ctx) { createSortOption(item), visit(item.expression()))) .collect(Collectors.toList()); } - return new WindowFunction((Function) visit(ctx.function), partitionByList, sortList); + return new WindowFunction(visit(ctx.function), partitionByList, sortList); } @Override - public UnresolvedExpression visitRankingWindowFunction(RankingWindowFunctionContext ctx) { - return new Function(ctx.functionName.getText(), Collections.emptyList()); + public UnresolvedExpression visitScalarWindowFunction(ScalarWindowFunctionContext ctx) { + return visitFunction(ctx.functionName.getText(), ctx.functionArgs()); } @Override @@ -272,7 +269,7 @@ public UnresolvedExpression visitBoolean(BooleanContext ctx) { } @Override - public UnresolvedExpression visitStringLiteral(OpenDistroSQLParser.StringLiteralContext ctx) { + public UnresolvedExpression visitStringLiteral(StringLiteralContext ctx) { return AstDSL.stringLiteral(StringUtils.unquoteText(ctx.getText())); } @@ -332,16 +329,29 @@ public UnresolvedExpression visitCaseFuncAlternative(CaseFuncAlternativeContext @Override public UnresolvedExpression visitDataTypeFunctionCall( - OpenDistroSQLParser.DataTypeFunctionCallContext ctx) { + DataTypeFunctionCallContext ctx) { return new Cast(visit(ctx.expression()), visit(ctx.convertedDataType())); } @Override public UnresolvedExpression visitConvertedDataType( - OpenDistroSQLParser.ConvertedDataTypeContext ctx) { + ConvertedDataTypeContext ctx) { return AstDSL.stringLiteral(ctx.getText()); } + private Function visitFunction(String functionName, FunctionArgsContext args) { + if (args == null) { + return new Function(functionName, Collections.emptyList()); + } + return new Function( + functionName, + args.functionArg() + .stream() + .map(this::visitFunctionArg) + .collect(Collectors.toList()) + ); + } + private QualifiedName visitIdentifiers(List identifiers) { return new QualifiedName( identifiers.stream() diff --git a/sql/src/main/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/context/QuerySpecification.java b/sql/src/main/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/context/QuerySpecification.java index 16b518db3d..0349b2fa51 100644 --- a/sql/src/main/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/context/QuerySpecification.java +++ b/sql/src/main/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/context/QuerySpecification.java @@ -22,7 +22,7 @@ import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.SelectClauseContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.SelectElementContext; import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.SubqueryAsRelationContext; -import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.WindowFunctionContext; +import static com.amazon.opendistroforelasticsearch.sql.sql.antlr.parser.OpenDistroSQLParser.WindowFunctionClauseContext; import static com.amazon.opendistroforelasticsearch.sql.sql.parser.ParserUtils.createSortOption; import static com.amazon.opendistroforelasticsearch.sql.sql.parser.ParserUtils.getTextInQuery; @@ -183,7 +183,7 @@ public Void visitSubqueryAsRelation(SubqueryAsRelationContext ctx) { } @Override - public Void visitWindowFunction(WindowFunctionContext ctx) { + public Void visitWindowFunctionClause(WindowFunctionClauseContext ctx) { // skip collecting sort items in window functions return null; } diff --git a/sql/src/test/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/AstExpressionBuilderTest.java b/sql/src/test/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/AstExpressionBuilderTest.java index 7ff33f8603..901a62c5b8 100644 --- a/sql/src/test/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/AstExpressionBuilderTest.java +++ b/sql/src/test/java/com/amazon/opendistroforelasticsearch/sql/sql/parser/AstExpressionBuilderTest.java @@ -16,6 +16,7 @@ package com.amazon.opendistroforelasticsearch.sql.sql.parser; +import static com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL.aggregate; import static com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL.and; import static com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL.booleanLiteral; import static com.amazon.opendistroforelasticsearch.sql.ast.dsl.AstDSL.caseWhen; @@ -294,6 +295,17 @@ public void canBuildWindowFunctionWithoutOrderBy() { buildExprAst("RANK() OVER (PARTITION BY state)")); } + @Test + public void canBuildAggregateWindowFunction() { + assertEquals( + window( + aggregate("AVG", qualifiedName("age")), + ImmutableList.of(qualifiedName("state")), + ImmutableList.of(ImmutablePair.of( + new SortOption(null, null), qualifiedName("age")))), + buildExprAst("AVG(age) OVER (PARTITION BY state ORDER BY age)")); + } + @Test public void canBuildCaseConditionStatement() { assertEquals(