This is an automated email from the ASF dual-hosted git repository.
mihaibudiu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/calcite.git
The following commit(s) were added to refs/heads/main by this push:
new 4681d4d16a [CALCITE-7864] RANGE window frames should handle NULL ORDER
BY keys
4681d4d16a is described below
commit 4681d4d16af74e9da5f2b5105e9e07b1836ff000
Author: Letian Jiang <[email protected]>
AuthorDate: Sat Oct 17 17:41:46 2026 +0800
[CALCITE-7864] RANGE window frames should handle NULL ORDER BY keys
Handle nullable ORDER BY keys in offset RANGE boundaries and add 28
regression cases. Verify expected results against PostgreSQL 14.24.
---
.../adapter/enumerable/EnumerableWindow.java | 11 ++++
.../adapter/enumerable/EnumerableWindowTest.java | 66 ++++++++++++++++++++++
2 files changed, 77 insertions(+)
diff --git
a/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableWindow.java
b/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableWindow.java
index e57d3dd9e8..8602364bff 100644
---
a/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableWindow.java
+++
b/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableWindow.java
@@ -1004,6 +1004,7 @@ private static Expression
translateBound(RexToLixTranslator translator,
Expression val =
translator.translate(new RexInputRef(orderKey, keyType),
desiredKeyType);
if (!bound.isCurrentRow()) {
+ final Expression key = val;
RexNode node = bound.getOffset();
Expression offs = translator.translate(node);
// TODO: support date + interval somehow
@@ -1012,6 +1013,16 @@ private static Expression
translateBound(RexToLixTranslator translator,
} else {
val = Expressions.subtract(val, offs);
}
+ if (keyType.isNullable()) {
+ // A null key's finite RANGE bounds are the edges of its peer group.
+ // Keep the offset arithmetic inside the non-null branch, and box its
+ // result so that the conditional expression does not unbox null.
+ final Type boundType = Primitive.box(val.getType());
+ val =
+ Expressions.condition(Expressions.equal(key,
Expressions.constant(null)),
+ Expressions.constant(null, boundType),
+ EnumUtils.convert(val, boundType));
+ }
}
return Expressions.call(
(lower
diff --git
a/core/src/test/java/org/apache/calcite/adapter/enumerable/EnumerableWindowTest.java
b/core/src/test/java/org/apache/calcite/adapter/enumerable/EnumerableWindowTest.java
index 2af3dd3730..592f8aa09f 100644
---
a/core/src/test/java/org/apache/calcite/adapter/enumerable/EnumerableWindowTest.java
+++
b/core/src/test/java/org/apache/calcite/adapter/enumerable/EnumerableWindowTest.java
@@ -31,10 +31,15 @@
import org.apache.calcite.sql.type.BasicSqlType;
import org.apache.calcite.sql.type.SqlTypeFactoryImpl;
import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.calcite.test.CalciteAssert;
import org.apache.calcite.test.MockRelOptPlanner;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -47,6 +52,67 @@
* Test for {@link org.apache.calcite.adapter.enumerable.EnumerableWindow}.
*/
public class EnumerableWindowTest {
+ @ParameterizedTest
+ @MethodSource("rangeNullableOrderKeyCases")
+ void testRangeNullableOrderKey(String type, String nulls, String frame,
+ String sum1, String sum2, String sum4, String sumNull) {
+ CalciteAssert.that()
+ .query("select k, sum(v) over (order by k nulls " + nulls
+ + " range between " + frame + ") as s\n"
+ + "from (values (cast(1 as " + type + "), 10), (2, 20), (4, 40),\n"
+ + " (cast(null as " + type + "), 100), (null, 200)) as t(k, v)")
+ .explainContains("EnumerableWindow")
+ .returnsUnordered("K=1; S=" + sum1,
+ "K=2; S=" + sum2,
+ "K=4; S=" + sum4,
+ "K=null; S=" + sumNull,
+ "K=null; S=" + sumNull);
+ }
+
+ private static List<Arguments> rangeNullableOrderKeyCases() {
+ // Expected results for all 28 cases were verified on PostgreSQL 14.24
+ // by executing the same SQL and comparing all five result rows,
+ // including duplicate null-key rows, without regard to row order.
+ final List<Arguments> cases = new ArrayList<>();
+ for (String type : new String[] {"integer", "bigint"}) {
+ for (String nulls : new String[] {"first", "last"}) {
+ // Cover PRECEDING and FOLLOWING for both the lower and upper bounds.
+ cases.add(
+ Arguments.of(type, nulls, "1 preceding and current row",
+ "10", "30", "40", "300"));
+ cases.add(
+ Arguments.of(type, nulls, "current row and 1 following",
+ "30", "20", "40", "300"));
+ cases.add(
+ Arguments.of(type, nulls, "1 preceding and 1 following",
+ "30", "30", "40", "300"));
+ cases.add(
+ Arguments.of(type, nulls, "2 preceding and 1 preceding",
+ "null", "10", "20", "300"));
+ cases.add(
+ Arguments.of(type, nulls, "1 following and 2 following",
+ "20", "40", "null", "300"));
+ // An unbounded end can include non-null keys in a null key's frame.
+ if (nulls.equals("first")) {
+ cases.add(
+ Arguments.of(type, nulls, "unbounded preceding and 1 following",
+ "330", "330", "370", "300"));
+ cases.add(
+ Arguments.of(type, nulls, "1 preceding and unbounded following",
+ "70", "70", "40", "370"));
+ } else {
+ cases.add(
+ Arguments.of(type, nulls, "unbounded preceding and 1 following",
+ "30", "30", "70", "370"));
+ cases.add(
+ Arguments.of(type, nulls, "1 preceding and unbounded following",
+ "370", "370", "340", "300"));
+ }
+ }
+ }
+ return cases;
+ }
+
@Test void testCopyWithConstants() {
final MockRelOptPlanner planner = new MockRelOptPlanner(Contexts.empty());
final RelDataTypeFactory typeFactory =