This is an automated email from the ASF dual-hosted git repository.
yashmayya pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new 371582a0d09 Add support for window function EXCLUDE clause (#18482)
371582a0d09 is described below
commit 371582a0d0958f704eba2573a2ff9b1ec4124aa3
Author: Yash Mayya <[email protected]>
AuthorDate: Thu Jul 16 15:03:05 2026 -0700
Add support for window function EXCLUDE clause (#18482)
---
pinot-common/src/main/proto/plan.proto | 10 +
.../query/planner/explain/PlanNodeMerger.java | 3 +
.../planner/logical/PlanNodeToRelConverter.java | 17 +-
.../planner/logical/RelToPlanNodeConverter.java | 19 +-
.../physical/v2/PRelToPlanNodeConverter.java | 4 +-
.../pinot/query/planner/plannode/WindowNode.java | 29 ++-
.../query/planner/serde/PlanNodeDeserializer.java | 18 +-
.../query/planner/serde/PlanNodeSerializer.java | 16 ++
.../apache/pinot/query/QueryCompilationTest.java | 6 -
.../runtime/operator/WindowAggregateOperator.java | 3 +-
.../query/runtime/operator/window/WindowFrame.java | 19 +-
.../runtime/operator/window/WindowFunction.java | 131 +++++++++++
.../window/aggregate/AggregateWindowFunction.java | 213 +++++++++++++++++-
.../SortedMultisetMinMaxWindowValueAggregator.java | 86 +++++++
.../aggregate/WindowValueAggregatorFactory.java | 17 +-
.../window/value/FirstValueWindowFunction.java | 37 ++++
.../window/value/LastValueWindowFunction.java | 37 ++++
.../operator/WindowAggregateOperatorTest.java | 2 +-
.../aggregate/WindowValueAggregatorTest.java | 51 +++++
.../test/resources/queries/WindowFunctions.json | 246 +++++++++++++++++++++
20 files changed, 932 insertions(+), 32 deletions(-)
diff --git a/pinot-common/src/main/proto/plan.proto
b/pinot-common/src/main/proto/plan.proto
index 8c2917d0968..9b529d37185 100644
--- a/pinot-common/src/main/proto/plan.proto
+++ b/pinot-common/src/main/proto/plan.proto
@@ -248,6 +248,15 @@ enum WindowFrameType {
RANGE = 1;
}
+// SQL standard `<window exclusion>` clause. Names are wire-stable;
renumbering breaks mixed-version brokers/servers.
+// `EXCLUDE_NO_OTHERS = 0` so that nodes serialized before this field existed
deserialize to the default behavior.
+enum WindowExclusion {
+ EXCLUDE_NO_OTHERS = 0;
+ EXCLUDE_CURRENT_ROW = 1;
+ EXCLUDE_GROUP = 2;
+ EXCLUDE_TIES = 3;
+}
+
message WindowNode {
repeated int32 keys = 1;
repeated Collation collations = 2;
@@ -256,6 +265,7 @@ message WindowNode {
int32 lowerBound = 5;
int32 upperBound = 6;
repeated Literal constants = 7;
+ WindowExclusion exclude = 8;
}
// A node that doesn't carry semantic information.
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/explain/PlanNodeMerger.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/explain/PlanNodeMerger.java
index b9af3463ea0..3169601eae9 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/explain/PlanNodeMerger.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/explain/PlanNodeMerger.java
@@ -435,6 +435,9 @@ class PlanNodeMerger {
if (node.getUpperBound() != otherNode.getUpperBound()) {
return null;
}
+ if (node.getExclude() != otherNode.getExclude()) {
+ return null;
+ }
if (!node.getConstants().equals(otherNode.getConstants())) {
return null;
}
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/PlanNodeToRelConverter.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/PlanNodeToRelConverter.java
index eb9b2228a63..40be0807ee0 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/PlanNodeToRelConverter.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/PlanNodeToRelConverter.java
@@ -376,7 +376,7 @@ public final class PlanNodeToRelConverter {
Window.Group group =
new Window.Group(keys, isRow,
getWindowBound(node.getLowerBound()), getWindowBound(node.getUpperBound()),
- RexWindowExclusion.EXCLUDE_NO_OTHER, orderKeys, aggCalls);
+ toRexWindowExclusion(node.getExclude()), orderKeys, aggCalls);
List<RexLiteral> constants =
node.getConstants().stream().map(constant ->
RexExpressionUtils.toRexLiteral(_builder, constant))
@@ -394,6 +394,21 @@ public final class PlanNodeToRelConverter {
return null;
}
+ private static RexWindowExclusion
toRexWindowExclusion(WindowNode.WindowExclusion exclude) {
+ switch (exclude) {
+ case NO_OTHERS:
+ return RexWindowExclusion.EXCLUDE_NO_OTHER;
+ case CURRENT_ROW:
+ return RexWindowExclusion.EXCLUDE_CURRENT_ROW;
+ case GROUP:
+ return RexWindowExclusion.EXCLUDE_GROUP;
+ case TIES:
+ return RexWindowExclusion.EXCLUDE_TIES;
+ default:
+ throw new IllegalStateException("Unsupported WindowExclusion: " +
exclude);
+ }
+ }
+
private RexWindowBound getWindowBound(int bound) {
if (bound == Integer.MIN_VALUE) {
return RexWindowBounds.UNBOUNDED_PRECEDING;
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/RelToPlanNodeConverter.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/RelToPlanNodeConverter.java
index 66271d5c006..880ce9120c4 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/RelToPlanNodeConverter.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/RelToPlanNodeConverter.java
@@ -641,18 +641,12 @@ public final class RelToPlanNodeConverter {
convertInputs(node.getInputs()), literalRows);
}
- /**
- * TODO: Add support for exclude clauses ({@link
org.apache.calcite.rex.RexWindowExclusion})
- */
private WindowNode convertLogicalWindow(LogicalWindow node) {
// Only a single Window Group should exist per WindowNode.
Preconditions.checkState(node.groups.size() == 1, "Only a single window
group is allowed, got: %s",
node.groups.size());
Window.Group windowGroup = node.groups.get(0);
- Preconditions.checkState(windowGroup.exclude ==
RexWindowExclusion.EXCLUDE_NO_OTHER,
- "EXCLUDE clauses for window functions are not currently supported");
-
int numAggregates = windowGroup.aggCalls.size();
List<RexExpression.FunctionCall> aggCalls = new ArrayList<>(numAggregates);
for (int i = 0; i < numAggregates; i++) {
@@ -696,7 +690,18 @@ public final class RelToPlanNodeConverter {
}
return new WindowNode(DEFAULT_STAGE_ID, toDataSchema(node.getRowType()),
NodeHint.fromRelHints(node.getHints()),
convertInputs(node.getInputs()), windowGroup.keys.asList(),
windowGroup.orderKeys.getFieldCollations(),
- aggCalls, windowFrameType, lowerBound, upperBound, constants);
+ aggCalls, windowFrameType, lowerBound, upperBound,
fromRexWindowExclusion(windowGroup.exclude), constants);
+ }
+
+ public static WindowNode.WindowExclusion
fromRexWindowExclusion(RexWindowExclusion exclude) {
+ if (exclude == RexWindowExclusion.EXCLUDE_CURRENT_ROW) {
+ return WindowNode.WindowExclusion.CURRENT_ROW;
+ } else if (exclude == RexWindowExclusion.EXCLUDE_GROUP) {
+ return WindowNode.WindowExclusion.GROUP;
+ } else if (exclude == RexWindowExclusion.EXCLUDE_TIES) {
+ return WindowNode.WindowExclusion.TIES;
+ }
+ return WindowNode.WindowExclusion.NO_OTHERS;
}
private SortNode convertLogicalSort(LogicalSort node) {
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/v2/PRelToPlanNodeConverter.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/v2/PRelToPlanNodeConverter.java
index 8704529bad9..3b28fc220f1 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/v2/PRelToPlanNodeConverter.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/physical/v2/PRelToPlanNodeConverter.java
@@ -47,6 +47,7 @@ import org.apache.pinot.common.utils.DataSchema;
import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
import org.apache.pinot.common.utils.DatabaseUtils;
import org.apache.pinot.common.utils.request.RequestUtils;
+import org.apache.pinot.query.planner.logical.RelToPlanNodeConverter;
import org.apache.pinot.query.planner.logical.RexExpression;
import org.apache.pinot.query.planner.logical.RexExpressionUtils;
import org.apache.pinot.query.planner.physical.v2.nodes.PhysicalAggregate;
@@ -188,7 +189,8 @@ public class PRelToPlanNodeConverter {
}
return new WindowNode(DEFAULT_STAGE_ID, toDataSchema(node.getRowType()),
NodeHint.fromRelHints(node.getHints()),
new ArrayList<>(), windowGroup.keys.asList(),
windowGroup.orderKeys.getFieldCollations(),
- aggCalls, windowFrameType, lowerBound, upperBound, constants);
+ aggCalls, windowFrameType, lowerBound, upperBound,
+ RelToPlanNodeConverter.fromRexWindowExclusion(windowGroup.exclude),
constants);
}
public static SortNode convertSort(Sort node) {
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/plannode/WindowNode.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/plannode/WindowNode.java
index 6263c277adf..bd91330b67e 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/plannode/WindowNode.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/plannode/WindowNode.java
@@ -35,11 +35,12 @@ public class WindowNode extends BasePlanNode {
// Integer.MAX_VALUE represents UNBOUNDED FOLLOWING which is only allowed
for the upper bound (ensured by Calcite).
private final int _lowerBound;
private final int _upperBound;
+ private final WindowExclusion _exclude;
private final List<RexExpression.Literal> _constants;
public WindowNode(int stageId, DataSchema dataSchema, NodeHint nodeHint,
List<PlanNode> inputs, List<Integer> keys,
List<RelFieldCollation> collations, List<RexExpression.FunctionCall>
aggCalls, WindowFrameType windowFrameType,
- int lowerBound, int upperBound, List<RexExpression.Literal> constants) {
+ int lowerBound, int upperBound, WindowExclusion exclude,
List<RexExpression.Literal> constants) {
super(stageId, dataSchema, nodeHint, inputs);
_keys = keys;
_collations = collations;
@@ -47,6 +48,7 @@ public class WindowNode extends BasePlanNode {
_windowFrameType = windowFrameType;
_lowerBound = lowerBound;
_upperBound = upperBound;
+ _exclude = exclude;
_constants = constants;
}
@@ -74,6 +76,10 @@ public class WindowNode extends BasePlanNode {
return _upperBound;
}
+ public WindowExclusion getExclude() {
+ return _exclude;
+ }
+
public List<RexExpression.Literal> getConstants() {
return _constants;
}
@@ -91,7 +97,7 @@ public class WindowNode extends BasePlanNode {
@Override
public PlanNode withInputs(List<PlanNode> inputs) {
return new WindowNode(_stageId, _dataSchema, _nodeHint, inputs, _keys,
_collations, _aggCalls, _windowFrameType,
- _lowerBound, _upperBound, _constants);
+ _lowerBound, _upperBound, _exclude, _constants);
}
@Override
@@ -108,13 +114,14 @@ public class WindowNode extends BasePlanNode {
WindowNode that = (WindowNode) o;
return _lowerBound == that._lowerBound && _upperBound == that._upperBound
&& Objects.equals(_aggCalls,
that._aggCalls) && Objects.equals(_keys, that._keys) &&
Objects.equals(_collations, that._collations)
- && _windowFrameType == that._windowFrameType &&
Objects.equals(_constants, that._constants);
+ && _windowFrameType == that._windowFrameType && _exclude ==
that._exclude
+ && Objects.equals(_constants, that._constants);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), _aggCalls, _keys, _collations,
_windowFrameType, _lowerBound, _upperBound,
- _constants);
+ _exclude, _constants);
}
/**
@@ -125,4 +132,18 @@ public class WindowNode extends BasePlanNode {
public enum WindowFrameType {
ROWS, RANGE
}
+
+ /**
+ * Enum to denote the frame exclusion option (SQL standard {@code EXCLUDE}
clause).
+ * {@link #NO_OTHERS} is the default and means no rows are excluded.
+ * {@link #CURRENT_ROW} excludes only the current row from the frame.
+ * {@link #GROUP} excludes the current row and all its ordering peers.
+ * {@link #TIES} excludes the ordering peers of the current row but keeps
the current row.
+ *
+ * <p>The constant names are part of the wire protocol via {@code
Plan.WindowExclusion} and must remain stable across
+ * mixed-version brokers and servers.
+ */
+ public enum WindowExclusion {
+ NO_OTHERS, CURRENT_ROW, GROUP, TIES
+ }
}
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/serde/PlanNodeDeserializer.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/serde/PlanNodeDeserializer.java
index c91e46d8f5f..434dba7243d 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/serde/PlanNodeDeserializer.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/serde/PlanNodeDeserializer.java
@@ -213,7 +213,8 @@ public class PlanNodeDeserializer {
extractInputs(protoNode), protoWindowNode.getKeysList(),
convertCollations(protoWindowNode.getCollationsList()),
convertFunctionCalls(protoWindowNode.getAggCallsList()),
convertWindowFrameType(protoWindowNode.getWindowFrameType()),
protoWindowNode.getLowerBound(),
- protoWindowNode.getUpperBound(),
convertLiterals(protoWindowNode.getConstantsList()));
+ protoWindowNode.getUpperBound(),
convertWindowExclusion(protoWindowNode.getExclude()),
+ convertLiterals(protoWindowNode.getConstantsList()));
}
private static ExplainedNode deserializeExplainedNode(Plan.PlanNode
protoNode) {
@@ -504,4 +505,19 @@ public class PlanNodeDeserializer {
throw new IllegalStateException("Unsupported WindowFrameType: " +
windowFrameType);
}
}
+
+ private static WindowNode.WindowExclusion
convertWindowExclusion(Plan.WindowExclusion exclude) {
+ switch (exclude) {
+ case EXCLUDE_NO_OTHERS:
+ return WindowNode.WindowExclusion.NO_OTHERS;
+ case EXCLUDE_CURRENT_ROW:
+ return WindowNode.WindowExclusion.CURRENT_ROW;
+ case EXCLUDE_GROUP:
+ return WindowNode.WindowExclusion.GROUP;
+ case EXCLUDE_TIES:
+ return WindowNode.WindowExclusion.TIES;
+ default:
+ throw new IllegalStateException("Unsupported WindowExclusion: " +
exclude);
+ }
+ }
}
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/serde/PlanNodeSerializer.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/serde/PlanNodeSerializer.java
index f2283ed09f5..bd056d2af28 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/serde/PlanNodeSerializer.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/serde/PlanNodeSerializer.java
@@ -261,6 +261,7 @@ public class PlanNodeSerializer {
.setWindowFrameType(convertWindowFrameType(node.getWindowFrameType()))
.setLowerBound(node.getLowerBound())
.setUpperBound(node.getUpperBound())
+ .setExclude(convertWindowExclusion(node.getExclude()))
.addAllConstants(convertLiterals(node.getConstants()))
.build();
builder.setWindowNode(windowNode);
@@ -479,5 +480,20 @@ public class PlanNodeSerializer {
throw new IllegalStateException("Unsupported WindowFrameType: " +
windowFrameType);
}
}
+
+ private static Plan.WindowExclusion
convertWindowExclusion(WindowNode.WindowExclusion exclude) {
+ switch (exclude) {
+ case NO_OTHERS:
+ return Plan.WindowExclusion.EXCLUDE_NO_OTHERS;
+ case CURRENT_ROW:
+ return Plan.WindowExclusion.EXCLUDE_CURRENT_ROW;
+ case GROUP:
+ return Plan.WindowExclusion.EXCLUDE_GROUP;
+ case TIES:
+ return Plan.WindowExclusion.EXCLUDE_TIES;
+ default:
+ throw new IllegalStateException("Unsupported WindowExclusion: " +
exclude);
+ }
+ }
}
}
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
b/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
index 1512b0e5558..123bb5cafb3 100644
---
a/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
@@ -807,12 +807,6 @@ public class QueryCompilationTest extends
QueryEnvironmentTestBase {
+ "CURRENT ROW) FROM a";
e = expectThrows(RuntimeException.class, () ->
_queryEnvironment.planQuery(ntileQueryWithNoArg));
assertTrue(e.getMessage().contains("expecting 1 argument"));
-
- String excludeCurrentRowQuery =
- "SELECT col1, col2, SUM(col3) OVER (PARTITION BY col1 ORDER BY col2
ROWS BETWEEN UNBOUNDED PRECEDING AND "
- + "CURRENT ROW EXCLUDE CURRENT ROW) FROM a";
- e = expectThrows(RuntimeException.class, () ->
_queryEnvironment.planQuery(excludeCurrentRowQuery));
- assertTrue(e.getMessage().contains("EXCLUDE clauses for window functions
are not currently supported"));
}
@Test
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/WindowAggregateOperator.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/WindowAggregateOperator.java
index f7583d1b426..a923f62f17e 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/WindowAggregateOperator.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/WindowAggregateOperator.java
@@ -123,7 +123,8 @@ public class WindowAggregateOperator extends
MultiStageOperator {
for (int i = 0; i < numKeys; i++) {
_keys[i] = keys.get(i);
}
- WindowFrame windowFrame = new WindowFrame(node.getWindowFrameType(),
node.getLowerBound(), node.getUpperBound());
+ WindowFrame windowFrame =
+ new WindowFrame(node.getWindowFrameType(), node.getLowerBound(),
node.getUpperBound(), node.getExclude());
Preconditions.checkState(
windowFrame.isRowType() || ((windowFrame.isUnboundedPreceding() ||
windowFrame.isLowerBoundCurrentRow()) && (
windowFrame.isUnboundedFollowing() ||
windowFrame.isUpperBoundCurrentRow())),
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/WindowFrame.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/WindowFrame.java
index f1d8a7a902f..2a110defc69 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/WindowFrame.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/WindowFrame.java
@@ -23,7 +23,8 @@ import org.apache.pinot.query.planner.plannode.WindowNode;
/**
* Defines the window frame to be used for a window function. The 'lowerBound'
and 'upperBound' indicate the frame
- * boundaries to be used. The frame can be of two types: ROWS or RANGE.
+ * boundaries to be used. The frame can be of two types: ROWS or RANGE.
Optionally an {@code EXCLUDE} clause may
+ * specify a subset of rows around the current row to be excluded from the
frame.
*/
public class WindowFrame {
// Enum to denote the FRAME type, can be either ROWS or RANGE types
@@ -33,11 +34,14 @@ public class WindowFrame {
// Integer.MAX_VALUE represents UNBOUNDED FOLLOWING which is only allowed
for the upper bound (ensured by Calcite).
private final int _lowerBound;
private final int _upperBound;
+ private final WindowNode.WindowExclusion _exclude;
- public WindowFrame(WindowNode.WindowFrameType type, int lowerBound, int
upperBound) {
+ public WindowFrame(WindowNode.WindowFrameType type, int lowerBound, int
upperBound,
+ WindowNode.WindowExclusion exclude) {
_type = type;
_lowerBound = lowerBound;
_upperBound = upperBound;
+ _exclude = exclude;
}
public boolean isUnboundedPreceding() {
@@ -68,8 +72,17 @@ public class WindowFrame {
return _upperBound;
}
+ public WindowNode.WindowExclusion getExclude() {
+ return _exclude;
+ }
+
+ public boolean isExcludeNoOthers() {
+ return _exclude == WindowNode.WindowExclusion.NO_OTHERS;
+ }
+
@Override
public String toString() {
- return "WindowFrame{" + "type=" + _type + ", lowerBound=" + _lowerBound +
", upperBound=" + _upperBound + '}';
+ return "WindowFrame{type=" + _type + ", lowerBound=" + _lowerBound + ",
upperBound=" + _upperBound + ", exclude="
+ + _exclude + '}';
}
}
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/WindowFunction.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/WindowFunction.java
index 19aabcfe9dd..032189c89c9 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/WindowFunction.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/WindowFunction.java
@@ -19,9 +19,11 @@
package org.apache.pinot.query.runtime.operator.window;
import java.util.List;
+import java.util.Objects;
import org.apache.calcite.rel.RelFieldCollation;
import org.apache.pinot.common.utils.DataSchema;
import org.apache.pinot.query.planner.logical.RexExpression;
+import org.apache.pinot.query.planner.plannode.WindowNode;
import org.apache.pinot.query.runtime.operator.WindowAggregateOperator;
import org.apache.pinot.query.runtime.operator.utils.AggregationUtils;
@@ -66,4 +68,133 @@ public abstract class WindowFunction extends
AggregationUtils.Accumulator {
protected Object extractValueFromRow(Object[] row) {
return _inputRef == -1 ? _literal : (row == null ? null : row[_inputRef]);
}
+
+ /**
+ * Returns whether peer-group information is required to apply the given
EXCLUDE clause. {@code CURRENT_ROW} only
+ * touches the current row, so callers can skip the O(n) peer-boundary
computation when the frame doesn't otherwise
+ * depend on peer bounds (i.e. ROWS frames).
+ */
+ protected static boolean needsPeerBoundaries(WindowNode.WindowExclusion
exclude) {
+ return exclude == WindowNode.WindowExclusion.GROUP || exclude ==
WindowNode.WindowExclusion.TIES;
+ }
+
+ /**
+ * Fills {@code peerStart} and {@code peerEnd} with the inclusive bounds of
each row's peer group based on the
+ * ORDER BY keys. Rows are peers iff they share the same ORDER BY values.
With no ORDER BY clause every row is a peer
+ * of every other row in the partition.
+ */
+ protected void computePeerBoundaries(List<Object[]> rows, int[] peerStart,
int[] peerEnd) {
+ int numRows = rows.size();
+ if (_orderKeys.length == 0) {
+ for (int i = 0; i < numRows; i++) {
+ peerStart[i] = 0;
+ peerEnd[i] = numRows - 1;
+ }
+ return;
+ }
+ int groupStart = 0;
+ for (int i = 1; i <= numRows; i++) {
+ if (i == numRows || !samePeerKey(rows.get(i - 1), rows.get(i))) {
+ int peerLast = i - 1;
+ for (int j = groupStart; j < i; j++) {
+ peerStart[j] = groupStart;
+ peerEnd[j] = peerLast;
+ }
+ groupStart = i;
+ }
+ }
+ }
+
+ private boolean samePeerKey(Object[] a, Object[] b) {
+ for (int k : _orderKeys) {
+ if (!Objects.equals(a[k], b[k])) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Returns the first index in {@code [fs, fe]} that is not excluded by the
SQL EXCLUDE clause for the current row
+ * {@code i} whose peer group is {@code [pStart, pEnd]}. Returns {@code -1}
if no such index exists.
+ */
+ protected static int firstNonExcluded(int fs, int fe, int i, int pStart, int
pEnd,
+ WindowNode.WindowExclusion exclude) {
+ if (fs > fe) {
+ return -1;
+ }
+ switch (exclude) {
+ case CURRENT_ROW:
+ return fs == i ? (fs + 1 > fe ? -1 : fs + 1) : fs;
+ case GROUP:
+ if (fs < pStart || fs > pEnd) {
+ return fs;
+ }
+ return pEnd + 1 > fe ? -1 : pEnd + 1;
+ case TIES:
+ if (fs == i || fs < pStart || fs > pEnd) {
+ return fs;
+ }
+ if (i >= fs && i <= fe) {
+ return i;
+ }
+ return pEnd + 1 > fe ? -1 : pEnd + 1;
+ default:
+ return fs;
+ }
+ }
+
+ /**
+ * Returns the inclusive lower index of the base frame for the row at index
{@code i} given its peer group
+ * {@code [pStart, pEnd]} and the total {@code numRows} in the partition.
+ */
+ protected int frameStartForRow(int i, int pStart, int numRows) {
+ if (_windowFrame.isRowType()) {
+ int lb = _windowFrame.getLowerBound();
+ return lb == Integer.MIN_VALUE ? 0 : Math.max(0, lb + i);
+ }
+ return _windowFrame.isUnboundedPreceding() ? 0 : pStart;
+ }
+
+ /**
+ * Returns the inclusive upper index of the base frame for the row at index
{@code i} given its peer group
+ * {@code [pStart, pEnd]} and the total {@code numRows} in the partition.
+ */
+ protected int frameEndForRow(int i, int pEnd, int numRows) {
+ if (_windowFrame.isRowType()) {
+ int ub = _windowFrame.getUpperBound();
+ return ub == Integer.MAX_VALUE ? numRows - 1 : Math.min(numRows - 1, ub
+ i);
+ }
+ return _windowFrame.isUnboundedFollowing() ? numRows - 1 : pEnd;
+ }
+
+ /**
+ * Returns the last index in {@code [fs, fe]} that is not excluded by the
SQL EXCLUDE clause for the current row
+ * {@code i} whose peer group is {@code [pStart, pEnd]}. Returns {@code -1}
if no such index exists.
+ */
+ protected static int lastNonExcluded(int fs, int fe, int i, int pStart, int
pEnd,
+ WindowNode.WindowExclusion exclude) {
+ if (fs > fe) {
+ return -1;
+ }
+ switch (exclude) {
+ case CURRENT_ROW:
+ return fe == i ? (fe - 1 < fs ? -1 : fe - 1) : fe;
+ case GROUP:
+ if (fe < pStart || fe > pEnd) {
+ return fe;
+ }
+ return pStart - 1 < fs ? -1 : pStart - 1;
+ case TIES:
+ if (fe == i || fe < pStart || fe > pEnd) {
+ return fe;
+ }
+ if (i >= fs && i <= fe) {
+ return i;
+ }
+ return pStart - 1 < fs ? -1 : pStart - 1;
+ default:
+ return fe;
+ }
+ }
}
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/aggregate/AggregateWindowFunction.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/aggregate/AggregateWindowFunction.java
index 2bb7d6adffa..b82be9c1be2 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/aggregate/AggregateWindowFunction.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/aggregate/AggregateWindowFunction.java
@@ -27,6 +27,7 @@ import org.apache.calcite.rel.RelFieldCollation;
import org.apache.pinot.common.utils.DataSchema;
import org.apache.pinot.core.data.table.Key;
import org.apache.pinot.query.planner.logical.RexExpression;
+import org.apache.pinot.query.planner.plannode.WindowNode;
import org.apache.pinot.query.runtime.operator.utils.AggregationUtils;
import org.apache.pinot.query.runtime.operator.window.WindowFrame;
import org.apache.pinot.query.runtime.operator.window.WindowFunction;
@@ -40,18 +41,21 @@ public class AggregateWindowFunction extends WindowFunction
{
List<RelFieldCollation> collations, WindowFrame windowFrame) {
super(aggCall, inputSchema, collations, windowFrame);
_functionName = aggCall.getFunctionName();
+ // Removal support is required for sliding ROWS frames and whenever an
EXCLUDE clause forces per-row corrections.
+ boolean nonDefaultExclude = !windowFrame.isExcludeNoOthers();
+ boolean supportRemoval = nonDefaultExclude || (windowFrame.isRowType() &&
!(
+ windowFrame.isUnboundedPreceding() &&
windowFrame.isUnboundedFollowing()));
_windowValueAggregator =
WindowValueAggregatorFactory.getWindowValueAggregator(_functionName, _dataType,
- windowFrame.isRowType() && !(_windowFrame.isUnboundedPreceding() &&
_windowFrame.isUnboundedFollowing()));
+ supportRemoval, nonDefaultExclude);
}
@Override
public final List<Object> processRows(List<Object[]> rows) {
_windowValueAggregator.clear();
- if (_windowFrame.isRowType()) {
- return processRowsWindow(rows);
- } else {
- return processRangeWindow(rows);
+ if (_windowFrame.isExcludeNoOthers()) {
+ return _windowFrame.isRowType() ? processRowsWindow(rows) :
processRangeWindow(rows);
}
+ return _windowFrame.isRowType() ? processRowsWindowWithExclude(rows) :
processRangeWindowWithExclude(rows);
}
/**
@@ -172,4 +176,203 @@ public class AggregateWindowFunction extends
WindowFunction {
throw new IllegalStateException("RANGE window frame with offset
PRECEDING / FOLLOWING is not supported");
}
}
+
+ /**
+ * ROWS frame with a non-default EXCLUDE clause. Loads the base frame into
the aggregator and removes / re-adds the
+ * excluded values per row. Peer-group boundaries are precomputed once per
partition.
+ */
+ private List<Object> processRowsWindowWithExclude(List<Object[]> rows) {
+ int numRows = rows.size();
+ WindowNode.WindowExclusion exclude = _windowFrame.getExclude();
+ int[] peerStart = null;
+ int[] peerEnd = null;
+ if (needsPeerBoundaries(exclude)) {
+ peerStart = new int[numRows];
+ peerEnd = new int[numRows];
+ computePeerBoundaries(rows, peerStart, peerEnd);
+ }
+
+ int lowerBound = _windowFrame.getLowerBound();
+ int upperBound = Math.min(_windowFrame.getUpperBound(), numRows - 1);
+
+ for (int i = Math.max(0, lowerBound); i <= upperBound; i++) {
+ _windowValueAggregator.addValue(extractValueFromRow(rows.get(i)));
+ }
+
+ List<Object> result = new ArrayList<>(numRows);
+ for (int i = 0; i < numRows; i++) {
+ if (lowerBound >= numRows) {
+ for (int j = i; j < numRows; j++) {
+ result.add(null);
+ }
+ return result;
+ }
+ int frameStart = Math.max(0, lowerBound);
+ int frameEnd = upperBound;
+ int pStart = peerStart != null ? peerStart[i] : i;
+ int pEnd = peerEnd != null ? peerEnd[i] : i;
+
+ applyExclude(rows, i, frameStart, frameEnd, pStart, pEnd, exclude, true);
+ result.add(_windowValueAggregator.getCurrentAggregatedValue());
+ applyExclude(rows, i, frameStart, frameEnd, pStart, pEnd, exclude,
false);
+
+ if (lowerBound >= 0) {
+
_windowValueAggregator.removeValue(extractValueFromRow(rows.get(lowerBound)));
+ }
+ lowerBound++;
+ if (upperBound < numRows - 1) {
+ upperBound++;
+ if (upperBound >= 0) {
+
_windowValueAggregator.addValue(extractValueFromRow(rows.get(upperBound)));
+ }
+ }
+ }
+ return result;
+ }
+
+ /**
+ * RANGE frame with a non-default EXCLUDE clause. The frame for each row is
determined by its peer group; we maintain
+ * the aggregator state corresponding to the base frame and apply per-row
EXCLUDE corrections.
+ */
+ private List<Object> processRangeWindowWithExclude(List<Object[]> rows) {
+ int numRows = rows.size();
+ int[] peerStart = new int[numRows];
+ int[] peerEnd = new int[numRows];
+ computePeerBoundaries(rows, peerStart, peerEnd);
+
+ boolean lowerCurrentRow = _windowFrame.isLowerBoundCurrentRow();
+ boolean upperCurrentRow = _windowFrame.isUpperBoundCurrentRow();
+ WindowNode.WindowExclusion exclude = _windowFrame.getExclude();
+ List<Object> result = new ArrayList<>(numRows);
+
+ if (_windowFrame.isUnboundedPreceding() &&
_windowFrame.isUnboundedFollowing()) {
+ // Frame = whole partition for every row
+ for (Object[] row : rows) {
+ _windowValueAggregator.addValue(extractValueFromRow(row));
+ }
+ for (int i = 0; i < numRows; i++) {
+ applyExclude(rows, i, 0, numRows - 1, peerStart[i], peerEnd[i],
exclude, true);
+ result.add(_windowValueAggregator.getCurrentAggregatedValue());
+ applyExclude(rows, i, 0, numRows - 1, peerStart[i], peerEnd[i],
exclude, false);
+ }
+ return result;
+ }
+
+ if (_windowFrame.isUnboundedPreceding() && upperCurrentRow) {
+ // Frame for row i = [0, peerEnd[i]]; aggregator is built peer-group by
peer-group
+ int loaded = 0;
+ int i = 0;
+ while (i < numRows) {
+ int end = peerEnd[i];
+ for (int j = loaded; j <= end; j++) {
+ _windowValueAggregator.addValue(extractValueFromRow(rows.get(j)));
+ }
+ loaded = end + 1;
+ while (i <= end) {
+ applyExclude(rows, i, 0, end, peerStart[i], peerEnd[i], exclude,
true);
+ result.add(_windowValueAggregator.getCurrentAggregatedValue());
+ applyExclude(rows, i, 0, end, peerStart[i], peerEnd[i], exclude,
false);
+ i++;
+ }
+ }
+ return result;
+ }
+
+ if (lowerCurrentRow && _windowFrame.isUnboundedFollowing()) {
+ // Frame for row i = [peerStart[i], numRows-1]; build up the aggregator
peer group by peer group, from the
+ // rightmost peer toward the leftmost. After adding peer g, the
aggregator contains [peerStart_g, numRows-1].
+ Object[] perRow = new Object[numRows];
+ int i = numRows - 1;
+ while (i >= 0) {
+ int start = peerStart[i];
+ for (int j = start; j <= i; j++) {
+ _windowValueAggregator.addValue(extractValueFromRow(rows.get(j)));
+ }
+ for (int j = start; j <= i; j++) {
+ applyExclude(rows, j, start, numRows - 1, peerStart[j], peerEnd[j],
exclude, true);
+ perRow[j] = _windowValueAggregator.getCurrentAggregatedValue();
+ applyExclude(rows, j, start, numRows - 1, peerStart[j], peerEnd[j],
exclude, false);
+ }
+ i = start - 1;
+ }
+ Collections.addAll(result, perRow);
+ return result;
+ }
+
+ if (lowerCurrentRow && upperCurrentRow) {
+ // Frame for row i = peer group of i; load each peer group separately
+ int i = 0;
+ while (i < numRows) {
+ int start = peerStart[i];
+ int end = peerEnd[i];
+ for (int j = start; j <= end; j++) {
+ _windowValueAggregator.addValue(extractValueFromRow(rows.get(j)));
+ }
+ while (i <= end) {
+ applyExclude(rows, i, start, end, start, end, exclude, true);
+ result.add(_windowValueAggregator.getCurrentAggregatedValue());
+ applyExclude(rows, i, start, end, start, end, exclude, false);
+ i++;
+ }
+ for (int j = start; j <= end; j++) {
+ _windowValueAggregator.removeValue(extractValueFromRow(rows.get(j)));
+ }
+ }
+ return result;
+ }
+
+ throw new IllegalStateException("RANGE window frame with offset PRECEDING
/ FOLLOWING is not supported");
+ }
+
+ /**
+ * Removes (when {@code remove} is true) or re-adds (otherwise) the rows in
the EXCLUDE set, restricted to the base
+ * frame {@code [frameStart, frameEnd]}. The exclude set is derived from the
current row {@code i} and its peer group
+ * {@code [pStart, pEnd]} as defined by SQL's EXCLUDE clause.
+ */
+ private void applyExclude(List<Object[]> rows, int i, int frameStart, int
frameEnd, int pStart, int pEnd,
+ WindowNode.WindowExclusion exclude, boolean remove) {
+ switch (exclude) {
+ case CURRENT_ROW:
+ if (i >= frameStart && i <= frameEnd) {
+ Object value = extractValueFromRow(rows.get(i));
+ if (remove) {
+ _windowValueAggregator.removeValue(value);
+ } else {
+ _windowValueAggregator.addValue(value);
+ }
+ }
+ break;
+ case GROUP: {
+ int from = Math.max(pStart, frameStart);
+ int to = Math.min(pEnd, frameEnd);
+ for (int j = from; j <= to; j++) {
+ Object value = extractValueFromRow(rows.get(j));
+ if (remove) {
+ _windowValueAggregator.removeValue(value);
+ } else {
+ _windowValueAggregator.addValue(value);
+ }
+ }
+ break;
+ }
+ case TIES: {
+ int from = Math.max(pStart, frameStart);
+ int to = Math.min(pEnd, frameEnd);
+ for (int j = from; j <= to; j++) {
+ if (j == i) {
+ continue;
+ }
+ Object value = extractValueFromRow(rows.get(j));
+ if (remove) {
+ _windowValueAggregator.removeValue(value);
+ } else {
+ _windowValueAggregator.addValue(value);
+ }
+ }
+ break;
+ }
+ default:
+ throw new IllegalStateException("Unsupported WindowExclusion: " +
exclude);
+ }
+ }
}
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/aggregate/SortedMultisetMinMaxWindowValueAggregator.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/aggregate/SortedMultisetMinMaxWindowValueAggregator.java
new file mode 100644
index 00000000000..7ecdb762b65
--- /dev/null
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/aggregate/SortedMultisetMinMaxWindowValueAggregator.java
@@ -0,0 +1,86 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License 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 org.apache.pinot.query.runtime.operator.window.aggregate;
+
+import java.util.TreeMap;
+import javax.annotation.Nullable;
+
+
+/**
+ * MIN / MAX window aggregator backed by a sorted multiset (a {@link TreeMap}
of value to occurrence count). Unlike the
+ * monotonic-deque aggregators used for sliding-window MIN / MAX, this
aggregator supports removal of arbitrary values
+ * in any order — necessary for window frames with a non-default {@code
EXCLUDE} clause. Add / remove / query are all
+ * {@code O(log K)} where {@code K} is the number of distinct values currently
in the window.
+ *
+ * <p>All non-null values added to a single instance must implement {@link
Comparable} and share a runtime type that is
+ * mutually comparable; mixing types (e.g. {@code Integer} and {@code Long})
is undefined. Callers obtain instances
+ * through {@link WindowValueAggregatorFactory}, which guarantees this because
window function values come from a single
+ * typed input column. {@code removeValue} for a value not currently present
is a no-op.
+ */
+public class SortedMultisetMinMaxWindowValueAggregator implements
WindowValueAggregator<Object> {
+
+ private final TreeMap<Object, Integer> _counts = new
TreeMap<>(SortedMultisetMinMaxWindowValueAggregator::compare);
+ private final boolean _isMin;
+
+ public SortedMultisetMinMaxWindowValueAggregator(boolean isMin) {
+ _isMin = isMin;
+ }
+
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ private static int compare(Object a, Object b) {
+ return ((Comparable) a).compareTo(b);
+ }
+
+ @Override
+ public void addValue(@Nullable Object value) {
+ if (value != null) {
+ _counts.merge(value, 1, Integer::sum);
+ }
+ }
+
+ @Override
+ public void removeValue(@Nullable Object value) {
+ if (value == null) {
+ return;
+ }
+ Integer count = _counts.get(value);
+ if (count == null) {
+ return;
+ }
+ if (count == 1) {
+ _counts.remove(value);
+ } else {
+ _counts.put(value, count - 1);
+ }
+ }
+
+ @Nullable
+ @Override
+ public Object getCurrentAggregatedValue() {
+ if (_counts.isEmpty()) {
+ return null;
+ }
+ return _isMin ? _counts.firstKey() : _counts.lastKey();
+ }
+
+ @Override
+ public void clear() {
+ _counts.clear();
+ }
+}
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/aggregate/WindowValueAggregatorFactory.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/aggregate/WindowValueAggregatorFactory.java
index 6ab348d5b25..4cc90bb6106 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/aggregate/WindowValueAggregatorFactory.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/aggregate/WindowValueAggregatorFactory.java
@@ -38,6 +38,17 @@ public class WindowValueAggregatorFactory {
*/
public static WindowValueAggregator<Object> getWindowValueAggregator(String
functionName,
DataSchema.ColumnDataType columnDataType, boolean supportRemoval) {
+ return getWindowValueAggregator(functionName, columnDataType,
supportRemoval, false);
+ }
+
+ /**
+ * Returns a window value aggregator for the given function/type. When
{@code arbitraryRemoval} is set, MIN / MAX
+ * aggregators that normally use a monotonic deque (which assumes values are
removed in arrival order) are replaced
+ * with a sorted-multiset implementation so callers may remove any value at
any time — required for window frames
+ * with a non-default {@code EXCLUDE} clause.
+ */
+ public static WindowValueAggregator<Object> getWindowValueAggregator(String
functionName,
+ DataSchema.ColumnDataType columnDataType, boolean supportRemoval,
boolean arbitraryRemoval) {
DataSchema.ColumnDataType storedType = columnDataType.getStoredType();
switch (functionName) {
// NOTE: Keep both 'SUM0' and '$SUM0' for backward compatibility where
'SUM0' is SqlKind and '$SUM0' is function
@@ -49,9 +60,11 @@ public class WindowValueAggregatorFactory {
case "AVG":
return new AvgWindowValueAggregator();
case "MIN":
- return createMinAggregator(storedType, supportRemoval);
+ return arbitraryRemoval ? new
SortedMultisetMinMaxWindowValueAggregator(true)
+ : createMinAggregator(storedType, supportRemoval);
case "MAX":
- return createMaxAggregator(storedType, supportRemoval);
+ return arbitraryRemoval ? new
SortedMultisetMinMaxWindowValueAggregator(false)
+ : createMaxAggregator(storedType, supportRemoval);
case "COUNT":
return new CountWindowValueAggregator();
case "BOOLAND":
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/value/FirstValueWindowFunction.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/value/FirstValueWindowFunction.java
index 7baebe8b41f..8ad0786fa61 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/value/FirstValueWindowFunction.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/value/FirstValueWindowFunction.java
@@ -29,6 +29,7 @@ import org.apache.pinot.common.collections.DualValueList;
import org.apache.pinot.common.utils.DataSchema;
import org.apache.pinot.core.data.table.Key;
import org.apache.pinot.query.planner.logical.RexExpression;
+import org.apache.pinot.query.planner.plannode.WindowNode;
import org.apache.pinot.query.runtime.operator.utils.AggregationUtils;
import org.apache.pinot.query.runtime.operator.window.WindowFrame;
@@ -42,6 +43,9 @@ public class FirstValueWindowFunction extends
ValueWindowFunction {
@Override
public List<Object> processRows(List<Object[]> rows) {
+ if (!_windowFrame.isExcludeNoOthers()) {
+ return processWithExclude(rows);
+ }
if (_windowFrame.isRowType()) {
if (_ignoreNulls) {
return processRowsWindowIgnoreNulls(rows);
@@ -57,6 +61,39 @@ public class FirstValueWindowFunction extends
ValueWindowFunction {
}
}
+ /**
+ * FIRST_VALUE for a non-default EXCLUDE clause. Computes the first
non-excluded index for each row in O(1) using
+ * the precomputed peer-group boundaries; if {@code IGNORE NULLS} is set,
advances past null values respecting the
+ * exclude rules.
+ */
+ private List<Object> processWithExclude(List<Object[]> rows) {
+ int numRows = rows.size();
+ WindowNode.WindowExclusion exclude = _windowFrame.getExclude();
+ int[] peerStart = null;
+ int[] peerEnd = null;
+ if (needsPeerBoundaries(exclude) || !_windowFrame.isRowType()) {
+ peerStart = new int[numRows];
+ peerEnd = new int[numRows];
+ computePeerBoundaries(rows, peerStart, peerEnd);
+ }
+
+ List<Object> result = new ArrayList<>(numRows);
+ for (int i = 0; i < numRows; i++) {
+ int pStart = peerStart != null ? peerStart[i] : i;
+ int pEnd = peerEnd != null ? peerEnd[i] : i;
+ int fs = frameStartForRow(i, pStart, numRows);
+ int fe = frameEndForRow(i, pEnd, numRows);
+ int idx = firstNonExcluded(fs, fe, i, pStart, pEnd, exclude);
+ if (_ignoreNulls) {
+ while (idx != -1 && extractValueFromRow(rows.get(idx)) == null) {
+ idx = firstNonExcluded(idx + 1, fe, i, pStart, pEnd, exclude);
+ }
+ }
+ result.add(idx == -1 ? null : extractValueFromRow(rows.get(idx)));
+ }
+ return result;
+ }
+
private List<Object> processRowsWindow(List<Object[]> rows) {
int numRows = rows.size();
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/value/LastValueWindowFunction.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/value/LastValueWindowFunction.java
index 8da5fa6fb13..900a0445671 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/value/LastValueWindowFunction.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/window/value/LastValueWindowFunction.java
@@ -29,6 +29,7 @@ import org.apache.pinot.common.collections.DualValueList;
import org.apache.pinot.common.utils.DataSchema;
import org.apache.pinot.core.data.table.Key;
import org.apache.pinot.query.planner.logical.RexExpression;
+import org.apache.pinot.query.planner.plannode.WindowNode;
import org.apache.pinot.query.runtime.operator.utils.AggregationUtils;
import org.apache.pinot.query.runtime.operator.window.WindowFrame;
@@ -42,6 +43,9 @@ public class LastValueWindowFunction extends
ValueWindowFunction {
@Override
public List<Object> processRows(List<Object[]> rows) {
+ if (!_windowFrame.isExcludeNoOthers()) {
+ return processWithExclude(rows);
+ }
if (_windowFrame.isRowType()) {
if (_ignoreNulls) {
return processRowsWindowIgnoreNulls(rows);
@@ -57,6 +61,39 @@ public class LastValueWindowFunction extends
ValueWindowFunction {
}
}
+ /**
+ * LAST_VALUE for a non-default EXCLUDE clause. Computes the last
non-excluded index for each row in O(1) using the
+ * precomputed peer-group boundaries; if {@code IGNORE NULLS} is set, scans
backward past null values respecting the
+ * exclude rules.
+ */
+ private List<Object> processWithExclude(List<Object[]> rows) {
+ int numRows = rows.size();
+ WindowNode.WindowExclusion exclude = _windowFrame.getExclude();
+ int[] peerStart = null;
+ int[] peerEnd = null;
+ if (needsPeerBoundaries(exclude) || !_windowFrame.isRowType()) {
+ peerStart = new int[numRows];
+ peerEnd = new int[numRows];
+ computePeerBoundaries(rows, peerStart, peerEnd);
+ }
+
+ List<Object> result = new ArrayList<>(numRows);
+ for (int i = 0; i < numRows; i++) {
+ int pStart = peerStart != null ? peerStart[i] : i;
+ int pEnd = peerEnd != null ? peerEnd[i] : i;
+ int fs = frameStartForRow(i, pStart, numRows);
+ int fe = frameEndForRow(i, pEnd, numRows);
+ int idx = lastNonExcluded(fs, fe, i, pStart, pEnd, exclude);
+ if (_ignoreNulls) {
+ while (idx != -1 && extractValueFromRow(rows.get(idx)) == null) {
+ idx = lastNonExcluded(fs, idx - 1, i, pStart, pEnd, exclude);
+ }
+ }
+ result.add(idx == -1 ? null : extractValueFromRow(rows.get(idx)));
+ }
+ return result;
+ }
+
private List<Object> processRowsWindow(List<Object[]> rows) {
int numRows = rows.size();
if (_windowFrame.isUnboundedFollowing() && _windowFrame.getLowerBound() <=
0) {
diff --git
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/WindowAggregateOperatorTest.java
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/WindowAggregateOperatorTest.java
index 82e97c4526b..61c335ea151 100644
---
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/WindowAggregateOperatorTest.java
+++
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/WindowAggregateOperatorTest.java
@@ -3062,7 +3062,7 @@ public class WindowAggregateOperatorTest {
MultiStageOperator input) {
return new WindowAggregateOperator(OperatorTestUtil.getTracingContext(),
input, inputSchema,
new WindowNode(-1, resultSchema, nodeHint, List.of(), keys,
collations, aggCalls, windowFrameType, lowerBound,
- upperBound, List.of()));
+ upperBound, WindowNode.WindowExclusion.NO_OTHERS, List.of()));
}
private WindowAggregateOperator getOperator(DataSchema inputSchema,
DataSchema resultSchema, List<Integer> keys,
diff --git
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/window/aggregate/WindowValueAggregatorTest.java
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/window/aggregate/WindowValueAggregatorTest.java
index 94d46d5d875..8dcce397c99 100644
---
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/window/aggregate/WindowValueAggregatorTest.java
+++
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/window/aggregate/WindowValueAggregatorTest.java
@@ -753,4 +753,55 @@ public class WindowValueAggregatorTest {
assertTrue(result instanceof Long);
assertEquals(result, largeVal);
}
+
+ // ========================
+ // SortedMultisetMinMaxWindowValueAggregator (used for MIN/MAX under EXCLUDE)
+ // ========================
+
+ @Test
+ public void testSortedMultisetMin() {
+ WindowValueAggregator<Object> agg = new
SortedMultisetMinMaxWindowValueAggregator(true);
+ assertNull(agg.getCurrentAggregatedValue());
+ agg.addValue(5);
+ agg.addValue(2);
+ agg.addValue(2);
+ agg.addValue(8);
+ assertEquals(agg.getCurrentAggregatedValue(), 2);
+ // Removing one occurrence of the duplicate min leaves the other
+ agg.removeValue(2);
+ assertEquals(agg.getCurrentAggregatedValue(), 2);
+ agg.removeValue(2);
+ assertEquals(agg.getCurrentAggregatedValue(), 5);
+ // Out-of-order removal still works (we remove the max next)
+ agg.removeValue(8);
+ assertEquals(agg.getCurrentAggregatedValue(), 5);
+ agg.removeValue(5);
+ assertNull(agg.getCurrentAggregatedValue());
+ }
+
+ @Test
+ public void testSortedMultisetMaxIgnoresNullsAndUnknownRemovals() {
+ WindowValueAggregator<Object> agg = new
SortedMultisetMinMaxWindowValueAggregator(false);
+ agg.addValue(null);
+ agg.addValue(7);
+ agg.addValue(3);
+ assertEquals(agg.getCurrentAggregatedValue(), 7);
+ // Removing a value never added is a no-op
+ agg.removeValue(99);
+ agg.removeValue(null);
+ assertEquals(agg.getCurrentAggregatedValue(), 7);
+ agg.clear();
+ assertNull(agg.getCurrentAggregatedValue());
+ }
+
+ @Test
+ public void testSortedMultisetWithBigDecimal() {
+ WindowValueAggregator<Object> agg = new
SortedMultisetMinMaxWindowValueAggregator(true);
+ agg.addValue(new BigDecimal("3.14"));
+ agg.addValue(new BigDecimal("2.71"));
+ agg.addValue(new BigDecimal("1.41"));
+ assertEquals(agg.getCurrentAggregatedValue(), new BigDecimal("1.41"));
+ agg.removeValue(new BigDecimal("1.41"));
+ assertEquals(agg.getCurrentAggregatedValue(), new BigDecimal("2.71"));
+ }
}
diff --git
a/pinot-query-runtime/src/test/resources/queries/WindowFunctions.json
b/pinot-query-runtime/src/test/resources/queries/WindowFunctions.json
index 76f15a6f104..e15346ae437 100644
--- a/pinot-query-runtime/src/test/resources/queries/WindowFunctions.json
+++ b/pinot-query-runtime/src/test/resources/queries/WindowFunctions.json
@@ -2378,6 +2378,252 @@
"outputs": [
["a", 5, 1, 1]
]
+ },
+ {
+ "description": "EXCLUDE CURRENT ROW on a bounded ROWS frame",
+ "sql": "SELECT string_col, int_col, double_col, SUM(int_col)
OVER(PARTITION BY string_col ORDER BY int_col, double_col ROWS BETWEEN 1
PRECEDING AND 1 FOLLOWING EXCLUDE CURRENT ROW) FROM {tbl} ORDER BY string_col,
int_col, double_col",
+ "keepOutputRowOrder": true,
+ "outputs": [
+ ["a", 2, 300.0, 2],
+ ["a", 2, 400.0, 44],
+ ["a", 42, 42.0, 44],
+ ["a", 42, 50.5, 84],
+ ["a", 42, 75.0, 42],
+ ["b", 3, 100.0, 100],
+ ["b", 100, 1.0, 3],
+ ["c", 2, 400.0, 3],
+ ["c", 3, 100.0, 103],
+ ["c", 101, 1.01, 153],
+ ["c", 150, 1.5, 101],
+ ["d", 42, 42.0, null],
+ ["e", 42, 42.0, 42],
+ ["e", 42, 50.5, 42],
+ ["g", 3, 100.0, null],
+ ["h", 150, 1.53, null]
+ ]
+ },
+ {
+ "description": "EXCLUDE GROUP on an unbounded RANGE frame",
+ "sql": "SELECT string_col, int_col, SUM(int_col) OVER(PARTITION BY
string_col ORDER BY int_col RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED
FOLLOWING EXCLUDE GROUP) FROM {tbl} ORDER BY string_col, int_col",
+ "outputs": [
+ ["a", 2, 126],
+ ["a", 2, 126],
+ ["a", 42, 4],
+ ["a", 42, 4],
+ ["a", 42, 4],
+ ["b", 3, 100],
+ ["b", 100, 3],
+ ["c", 2, 254],
+ ["c", 3, 253],
+ ["c", 101, 155],
+ ["c", 150, 106],
+ ["d", 42, null],
+ ["e", 42, null],
+ ["e", 42, null],
+ ["g", 3, null],
+ ["h", 150, null]
+ ]
+ },
+ {
+ "description": "EXCLUDE TIES on an unbounded RANGE frame",
+ "sql": "SELECT string_col, int_col, SUM(int_col) OVER(PARTITION BY
string_col ORDER BY int_col RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED
FOLLOWING EXCLUDE TIES) FROM {tbl} ORDER BY string_col, int_col",
+ "outputs": [
+ ["a", 2, 128],
+ ["a", 2, 128],
+ ["a", 42, 46],
+ ["a", 42, 46],
+ ["a", 42, 46],
+ ["b", 3, 103],
+ ["b", 100, 103],
+ ["c", 2, 256],
+ ["c", 3, 256],
+ ["c", 101, 256],
+ ["c", 150, 256],
+ ["d", 42, 42],
+ ["e", 42, 42],
+ ["e", 42, 42],
+ ["g", 3, 3],
+ ["h", 150, 150]
+ ]
+ },
+ {
+ "description": "EXCLUDE GROUP on RANGE UNBOUNDED PRECEDING to CURRENT
ROW",
+ "sql": "SELECT string_col, int_col, COUNT(*) OVER(PARTITION BY
string_col ORDER BY int_col RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
EXCLUDE GROUP) FROM {tbl} ORDER BY string_col, int_col",
+ "outputs": [
+ ["a", 2, 0],
+ ["a", 2, 0],
+ ["a", 42, 2],
+ ["a", 42, 2],
+ ["a", 42, 2],
+ ["b", 3, 0],
+ ["b", 100, 1],
+ ["c", 2, 0],
+ ["c", 3, 1],
+ ["c", 101, 2],
+ ["c", 150, 3],
+ ["d", 42, 0],
+ ["e", 42, 0],
+ ["e", 42, 0],
+ ["g", 3, 0],
+ ["h", 150, 0]
+ ]
+ },
+ {
+ "description": "EXCLUDE TIES on RANGE CURRENT ROW to UNBOUNDED
FOLLOWING",
+ "sql": "SELECT string_col, int_col, SUM(int_col) OVER(PARTITION BY
string_col ORDER BY int_col RANGE BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
EXCLUDE TIES) FROM {tbl} ORDER BY string_col, int_col",
+ "outputs": [
+ ["a", 2, 128],
+ ["a", 2, 128],
+ ["a", 42, 42],
+ ["a", 42, 42],
+ ["a", 42, 42],
+ ["b", 3, 103],
+ ["b", 100, 100],
+ ["c", 2, 256],
+ ["c", 3, 254],
+ ["c", 101, 251],
+ ["c", 150, 150],
+ ["d", 42, 42],
+ ["e", 42, 42],
+ ["e", 42, 42],
+ ["g", 3, 3],
+ ["h", 150, 150]
+ ]
+ },
+ {
+ "description": "EXCLUDE CURRENT ROW on RANGE CURRENT ROW to CURRENT
ROW (peer-group only)",
+ "sql": "SELECT string_col, int_col, COUNT(*) OVER(PARTITION BY
string_col ORDER BY int_col RANGE BETWEEN CURRENT ROW AND CURRENT ROW EXCLUDE
CURRENT ROW) FROM {tbl} ORDER BY string_col, int_col",
+ "outputs": [
+ ["a", 2, 1],
+ ["a", 2, 1],
+ ["a", 42, 2],
+ ["a", 42, 2],
+ ["a", 42, 2],
+ ["b", 3, 0],
+ ["b", 100, 0],
+ ["c", 2, 0],
+ ["c", 3, 0],
+ ["c", 101, 0],
+ ["c", 150, 0],
+ ["d", 42, 0],
+ ["e", 42, 1],
+ ["e", 42, 1],
+ ["g", 3, 0],
+ ["h", 150, 0]
+ ]
+ },
+ {
+ "description": "AVG with EXCLUDE CURRENT ROW on an unbounded ROWS
frame",
+ "sql": "SELECT string_col, int_col, double_col, AVG(int_col)
OVER(PARTITION BY string_col ORDER BY int_col, double_col ROWS BETWEEN
UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE CURRENT ROW) FROM {tbl}
ORDER BY string_col, int_col, double_col",
+ "keepOutputRowOrder": true,
+ "outputs": [
+ ["a", 2, 300.0, 32.0],
+ ["a", 2, 400.0, 32.0],
+ ["a", 42, 42.0, 22.0],
+ ["a", 42, 50.5, 22.0],
+ ["a", 42, 75.0, 22.0],
+ ["b", 3, 100.0, 100.0],
+ ["b", 100, 1.0, 3.0],
+ ["c", 2, 400.0, 84.66666666666667],
+ ["c", 3, 100.0, 84.33333333333333],
+ ["c", 101, 1.01, 51.666666666666664],
+ ["c", 150, 1.5, 35.333333333333336],
+ ["d", 42, 42.0, null],
+ ["e", 42, 42.0, 42.0],
+ ["e", 42, 50.5, 42.0],
+ ["g", 3, 100.0, null],
+ ["h", 150, 1.53, null]
+ ]
+ },
+ {
+ "description": "MIN with EXCLUDE GROUP on an unbounded ROWS frame",
+ "sql": "SELECT string_col, int_col, MIN(int_col) OVER(PARTITION BY
string_col ORDER BY int_col ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED
FOLLOWING EXCLUDE GROUP) FROM {tbl} ORDER BY string_col, int_col",
+ "outputs": [
+ ["a", 2, 42],
+ ["a", 2, 42],
+ ["a", 42, 2],
+ ["a", 42, 2],
+ ["a", 42, 2],
+ ["b", 3, 100],
+ ["b", 100, 3],
+ ["c", 2, 3],
+ ["c", 3, 2],
+ ["c", 101, 2],
+ ["c", 150, 2],
+ ["d", 42, null],
+ ["e", 42, null],
+ ["e", 42, null],
+ ["g", 3, null],
+ ["h", 150, null]
+ ]
+ },
+ {
+ "description": "FIRST_VALUE with EXCLUDE CURRENT ROW on an unbounded
ROWS frame",
+ "sql": "SELECT string_col, int_col, double_col, FIRST_VALUE(int_col)
OVER(PARTITION BY string_col ORDER BY int_col, double_col ROWS BETWEEN
UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING EXCLUDE CURRENT ROW) FROM {tbl}
ORDER BY string_col, int_col, double_col",
+ "keepOutputRowOrder": true,
+ "outputs": [
+ ["a", 2, 300.0, 2],
+ ["a", 2, 400.0, 2],
+ ["a", 42, 42.0, 2],
+ ["a", 42, 50.5, 2],
+ ["a", 42, 75.0, 2],
+ ["b", 3, 100.0, 100],
+ ["b", 100, 1.0, 3],
+ ["c", 2, 400.0, 3],
+ ["c", 3, 100.0, 2],
+ ["c", 101, 1.01, 2],
+ ["c", 150, 1.5, 2],
+ ["d", 42, 42.0, null],
+ ["e", 42, 42.0, 42],
+ ["e", 42, 50.5, 42],
+ ["g", 3, 100.0, null],
+ ["h", 150, 1.53, null]
+ ]
+ },
+ {
+ "description": "LAST_VALUE with EXCLUDE TIES on an unbounded ROWS
frame",
+ "sql": "SELECT string_col, int_col, LAST_VALUE(int_col) OVER(PARTITION
BY string_col ORDER BY int_col ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED
FOLLOWING EXCLUDE TIES) FROM {tbl} ORDER BY string_col, int_col",
+ "outputs": [
+ ["a", 2, 42],
+ ["a", 2, 42],
+ ["a", 42, 42],
+ ["a", 42, 42],
+ ["a", 42, 42],
+ ["b", 3, 100],
+ ["b", 100, 100],
+ ["c", 2, 150],
+ ["c", 3, 150],
+ ["c", 101, 150],
+ ["c", 150, 150],
+ ["d", 42, 42],
+ ["e", 42, 42],
+ ["e", 42, 42],
+ ["g", 3, 3],
+ ["h", 150, 150]
+ ]
+ },
+ {
+ "description": "EXCLUDE CURRENT ROW with PARTITION BY only (no ORDER
BY)",
+ "sql": "SELECT string_col, int_col, double_col, SUM(int_col)
OVER(PARTITION BY string_col ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED
FOLLOWING EXCLUDE CURRENT ROW) FROM {tbl} ORDER BY string_col, int_col,
double_col",
+ "keepOutputRowOrder": true,
+ "outputs": [
+ ["a", 2, 300.0, 128],
+ ["a", 2, 400.0, 128],
+ ["a", 42, 42.0, 88],
+ ["a", 42, 50.5, 88],
+ ["a", 42, 75.0, 88],
+ ["b", 3, 100.0, 100],
+ ["b", 100, 1.0, 3],
+ ["c", 2, 400.0, 254],
+ ["c", 3, 100.0, 253],
+ ["c", 101, 1.01, 155],
+ ["c", 150, 1.5, 106],
+ ["d", 42, 42.0, null],
+ ["e", 42, 42.0, 42],
+ ["e", 42, 50.5, 42],
+ ["g", 3, 100.0, null],
+ ["h", 150, 1.53, null]
+ ]
}
]
},
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]