This is an automated email from the ASF dual-hosted git repository.

Jackie-Jiang 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 e57f94bce52 Honour the null handling option in EXPR_MIN/EXPR_MAX and 
TIMESERIESAGGREGATE (#19357)
e57f94bce52 is described below

commit e57f94bce52d252410bc94a6fcca0509ba187b6b
Author: Xiaotian (Jackie) Jiang <[email protected]>
AuthorDate: Tue Aug 25 14:04:03 2026 -0700

    Honour the null handling option in EXPR_MIN/EXPR_MAX and 
TIMESERIESAGGREGATE (#19357)
---
 .../function/AggregationFunctionFactory.java       |   6 +-
 .../ParentExprMinMaxAggregationFunction.java       | 121 ++++++++++---
 .../function/TimeSeriesAggregationFunction.java    | 139 ++++++++------
 .../AggregationFunctionNullContractTest.java       |   9 +-
 .../function/ExprMinMaxNullHandlingTest.java       | 201 +++++++++++++++++++++
 .../function/StoredTypeDispatchTest.java           |   4 +-
 .../TimeSeriesAggregationNullHandlingTest.java     | 143 +++++++++++++++
 7 files changed, 534 insertions(+), 89 deletions(-)

diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AggregationFunctionFactory.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AggregationFunctionFactory.java
index 4c4165edbdb..fdc93df43e8 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AggregationFunctionFactory.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AggregationFunctionFactory.java
@@ -499,9 +499,9 @@ public class AggregationFunctionFactory {
             return new 
AvgValueIntegerTupleSketchAggregationFunction(arguments, 
IntegerSummary.Mode.Sum,
                 nullHandlingEnabled);
           case PINOTPARENTAGGEXPRMAX:
-            return new ParentExprMinMaxAggregationFunction(arguments, true);
+            return new ParentExprMinMaxAggregationFunction(arguments, true, 
nullHandlingEnabled);
           case PINOTPARENTAGGEXPRMIN:
-            return new ParentExprMinMaxAggregationFunction(arguments, false);
+            return new ParentExprMinMaxAggregationFunction(arguments, false, 
nullHandlingEnabled);
           case PINOTCHILDAGGEXPRMAX:
             return new ChildExprMinMaxAggregationFunction(arguments, true);
           case PINOTCHILDAGGEXPRMIN:
@@ -535,7 +535,7 @@ public class AggregationFunctionFactory {
           case DISTINCTCOUNTRAWULL:
             return new DistinctCountRawULLAggregationFunction(arguments, 
nullHandlingEnabled);
           case TIMESERIESAGGREGATE:
-            return new TimeSeriesAggregationFunction(arguments);
+            return new TimeSeriesAggregationFunction(arguments, 
nullHandlingEnabled);
           default:
             throw new IllegalArgumentException("Unsupported aggregation 
function type: " + functionType);
         }
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/ParentExprMinMaxAggregationFunction.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/ParentExprMinMaxAggregationFunction.java
index 957ac86de29..00c4aeb191c 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/ParentExprMinMaxAggregationFunction.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/ParentExprMinMaxAggregationFunction.java
@@ -26,6 +26,7 @@ import org.apache.pinot.common.CustomObject;
 import org.apache.pinot.common.request.context.ExpressionContext;
 import org.apache.pinot.common.utils.DataSchema;
 import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
+import org.apache.pinot.common.utils.RoaringBitmapUtils;
 import org.apache.pinot.core.common.BlockValSet;
 import org.apache.pinot.core.common.ObjectSerDeUtils;
 import org.apache.pinot.core.query.aggregation.AggregationResultHolder;
@@ -36,6 +37,7 @@ import 
org.apache.pinot.core.query.aggregation.utils.exprminmax.ExprMinMaxMeasur
 import 
org.apache.pinot.core.query.aggregation.utils.exprminmax.ExprMinMaxObject;
 import 
org.apache.pinot.core.query.aggregation.utils.exprminmax.ExprMinMaxProjectionValSetWrapper;
 import org.apache.pinot.segment.spi.AggregationFunctionType;
+import org.roaringbitmap.RoaringBitmap;
 
 
 public class ParentExprMinMaxAggregationFunction extends 
ParentAggregationFunction<ExprMinMaxObject, ExprMinMaxObject> {
@@ -54,6 +56,7 @@ public class ParentExprMinMaxAggregationFunction extends 
ParentAggregationFuncti
   private final int _numMeasuringColumns;
   // number of columns that we project based on the min/max value
   private final int _numProjectionColumns;
+  private final boolean _nullHandlingEnabled;
 
   // The following variable need to be initialized
 
@@ -68,10 +71,12 @@ public class ParentExprMinMaxAggregationFunction extends 
ParentAggregationFuncti
   // If the schemas are initialized
   private final ThreadLocal<Boolean> _schemaInitialized = 
ThreadLocal.withInitial(() -> false);
 
-  public ParentExprMinMaxAggregationFunction(List<ExpressionContext> 
arguments, boolean isMax) {
+  public ParentExprMinMaxAggregationFunction(List<ExpressionContext> 
arguments, boolean isMax,
+      boolean nullHandlingEnabled) {
 
     super(arguments);
     _isMax = isMax;
+    _nullHandlingEnabled = nullHandlingEnabled;
     _functionIdContext = arguments.get(0);
 
     _numMeasuringColumnContext = arguments.get(1);
@@ -110,33 +115,91 @@ public class ParentExprMinMaxAggregationFunction extends 
ParentAggregationFuncti
   @Override
   public void aggregate(int length, AggregationResultHolder 
aggregationResultHolder,
       Map<ExpressionContext, BlockValSet> blockValSetMap) {
+    initializeWithNewDataBlocks(blockValSetMap);
 
-    ExprMinMaxObject exprMinMaxObject = aggregationResultHolder.getResult();
-
-    if (exprMinMaxObject == null) {
-      initializeWithNewDataBlocks(blockValSetMap);
-      exprMinMaxObject = new ExprMinMaxObject(_measuringColumnSchema.get(), 
_projectionColumnSchema.get());
+    ExprMinMaxObject result = aggregationResultHolder.getResult();
+    if (result == null) {
+      result = new ExprMinMaxObject(_measuringColumnSchema.get(), 
_projectionColumnSchema.get());
+      aggregationResultHolder.setValue(result);
     }
 
+    ExprMinMaxObject exprMinMaxObject = result;
     List<Integer> rowIds = new ArrayList<>();
-    for (int i = 0; i < length; i++) {
-      int compareResult = 
exprMinMaxObject.compareAndSetKey(_exprMinMaxWrapperMeasuringColumnSets.get(), 
i, _isMax);
-      if (compareResult == 0) {
-        // same key, add the rowId to the list
-        rowIds.add(i);
-      } else if (compareResult > 0) {
-        // new key is set, clear the list and add the new rowId
-        rowIds.clear();
-        rowIds.add(i);
+    // Whether this block replaced the extremum key.
+    boolean[] keyReplaced = {false};
+    forEachNotNullMeasuring(length, blockValSetMap, (from, to) -> {
+      for (int i = from; i < to; i++) {
+        int compareResult = 
exprMinMaxObject.compareAndSetKey(_exprMinMaxWrapperMeasuringColumnSets.get(), 
i, _isMax);
+        if (compareResult == 0) {
+          // same key, add the rowId to the list
+          rowIds.add(i);
+        } else if (compareResult > 0) {
+          // new key is set, clear the list and add the new rowId
+          rowIds.clear();
+          rowIds.add(i);
+          keyReplaced[0] = true;
+        }
       }
+    });
+
+    // For all the rows that are associated with the extremum key, add the 
projection columns. The projection values
+    // are collected once here rather than per winning row, so that a row 
later beaten within this block never reads
+    // the projection columns at all.
+    //
+    // Only matters across blocks. rowIds is local, so within one block 
clearing it is enough and the object's value
+    // list is still empty; but the object outlives the call, and a block that 
replaces the key has to discard what an
+    // earlier block published under the old one. rowIds.clear() cannot reach 
those. Only setToNewVal clears them, so
+    // the first surviving row goes through it and the rows tying it are 
appended.
+    List<ExprMinMaxProjectionValSetWrapper> projectionColumnSets = 
_exprMinMaxWrapperProjectionColumnSets.get();
+    int firstToAppend = 0;
+    if (keyReplaced[0]) {
+      exprMinMaxObject.setToNewVal(projectionColumnSets, rowIds.get(0));
+      firstToAppend = 1;
     }
+    for (int i = firstToAppend; i < rowIds.size(); i++) {
+      exprMinMaxObject.addVal(projectionColumnSets, rowIds.get(i));
+    }
+  }
 
-    // for all the rows that are associated with the extremum key, add the 
projection columns
-    for (Integer rowId : rowIds) {
-      exprMinMaxObject.addVal(_exprMinMaxWrapperProjectionColumnSets.get(), 
rowId);
+  /// Runs `consumer` over the row ranges where every measuring column is 
non-null.
+  ///
+  /// The measuring columns form a single composite key, so a null in any one 
of them leaves the key undefined and the
+  /// row cannot take part in the comparison at all. The projection columns 
are only payload carried out of the winning
+  /// row, so a null there does not disqualify it. With the option disabled 
the whole block is one range, which is what
+  /// this function did unconditionally before.
+  private void forEachNotNullMeasuring(int length, Map<ExpressionContext, 
BlockValSet> blockValSetMap,
+      RoaringBitmapUtils.BatchConsumer consumer) {
+    RoaringBitmap nullBitmap = measuringNullBitmap(blockValSetMap);
+    if (nullBitmap == null) {
+      consumer.consume(0, length);
+      return;
     }
+    // Skip if the entire block is null
+    if (!nullBitmap.contains(0, length)) {
+      RoaringBitmapUtils.forEachUnset(length, nullBitmap.getIntIterator(), 
consumer);
+    }
+  }
 
-    aggregationResultHolder.setValue(exprMinMaxObject);
+  /// Returns the union of the measuring columns' null bitmaps, or `null` when 
no row is null.
+  @Nullable
+  private RoaringBitmap measuringNullBitmap(Map<ExpressionContext, 
BlockValSet> blockValSetMap) {
+    if (!_nullHandlingEnabled) {
+      return null;
+    }
+    RoaringBitmap merged = null;
+    for (ExpressionContext measuringColumn : _measuringColumns) {
+      RoaringBitmap nullBitmap = 
blockValSetMap.get(measuringColumn).getNullBitmap();
+      if (nullBitmap == null) {
+        continue;
+      }
+      // Copied before merging: the bitmap belongs to the block and must not 
be mutated
+      if (merged == null) {
+        merged = nullBitmap.clone();
+      } else {
+        merged.or(nullBitmap);
+      }
+    }
+    return merged;
   }
 
   // this method is called to initialize the schemas if they are not 
initialized
@@ -225,10 +288,12 @@ public class ParentExprMinMaxAggregationFunction extends 
ParentAggregationFuncti
   public void aggregateGroupBySV(int length, int[] groupKeyArray, 
GroupByResultHolder groupByResultHolder,
       Map<ExpressionContext, BlockValSet> blockValSetMap) {
     initializeWithNewDataBlocks(blockValSetMap);
-    for (int i = 0; i < length; i++) {
-      int groupKey = groupKeyArray[i];
-      updateGroupByResult(groupByResultHolder, i, groupKey);
-    }
+    forEachNotNullMeasuring(length, blockValSetMap, (from, to) -> {
+      for (int i = from; i < to; i++) {
+        int groupKey = groupKeyArray[i];
+        updateGroupByResult(groupByResultHolder, i, groupKey);
+      }
+    });
   }
 
   private void updateGroupByResult(GroupByResultHolder groupByResultHolder, 
int i, int groupKey) {
@@ -249,11 +314,13 @@ public class ParentExprMinMaxAggregationFunction extends 
ParentAggregationFuncti
   public void aggregateGroupByMV(int length, int[][] groupKeysArray, 
GroupByResultHolder groupByResultHolder,
       Map<ExpressionContext, BlockValSet> blockValSetMap) {
     initializeWithNewDataBlocks(blockValSetMap);
-    for (int i = 0; i < length; i++) {
-      for (int groupKey : groupKeysArray[i]) {
-        updateGroupByResult(groupByResultHolder, i, groupKey);
+    forEachNotNullMeasuring(length, blockValSetMap, (from, to) -> {
+      for (int i = from; i < to; i++) {
+        for (int groupKey : groupKeysArray[i]) {
+          updateGroupByResult(groupByResultHolder, i, groupKey);
+        }
       }
-    }
+    });
   }
 
   @Override
diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/TimeSeriesAggregationFunction.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/TimeSeriesAggregationFunction.java
index 1743256dfea..81ad63e0c75 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/TimeSeriesAggregationFunction.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/TimeSeriesAggregationFunction.java
@@ -33,6 +33,7 @@ import 
org.apache.pinot.common.request.context.ExpressionContext;
 import org.apache.pinot.common.request.context.FunctionContext;
 import org.apache.pinot.common.request.context.RequestContextUtils;
 import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.common.utils.RoaringBitmapUtils;
 import org.apache.pinot.core.common.BlockValSet;
 import org.apache.pinot.core.query.aggregation.AggregationResultHolder;
 import org.apache.pinot.core.query.aggregation.ObjectAggregationResultHolder;
@@ -82,6 +83,7 @@ public class TimeSeriesAggregationFunction implements 
AggregationFunction<BaseTi
   private final long _timeReferencePoint;
   private final long _timeOffset;
   private final long _timeBucketDivisor;
+  private final boolean _nullHandlingEnabled;
 
   /// Arguments are as shown below:
   ///
@@ -89,7 +91,7 @@ public class TimeSeriesAggregationFunction implements 
AggregationFunction<BaseTi
   /// timeSeriesAggregate("m3ql", "MIN", valueExpr, timeExpr, timeUnit, 
offsetSeconds, firstBucketValue,
   ///     bucketLenSeconds, numBuckets, "aggParam1=value1")
   /// ```
-  public TimeSeriesAggregationFunction(List<ExpressionContext> arguments) {
+  public TimeSeriesAggregationFunction(List<ExpressionContext> arguments, 
boolean nullHandlingEnabled) {
     // Initialize temporary variables.
     Preconditions.checkArgument(arguments.size() == 10, "Expected 10 arguments 
for time-series agg");
     String language = arguments.get(0).getLiteral().getStringValue();
@@ -112,6 +114,7 @@ public class TimeSeriesAggregationFunction implements 
AggregationFunction<BaseTi
     _timeReferencePoint = timeUnit.convert(Duration.ofSeconds(firstBucketValue 
- bucketWindowSeconds));
     _timeOffset = timeUnit.convert(Duration.ofSeconds(offsetSeconds));
     _timeBucketDivisor = timeUnit.convert(_timeBuckets.getBucketSize());
+    _nullHandlingEnabled = nullHandlingEnabled;
   }
 
   @Override
@@ -142,7 +145,7 @@ public class TimeSeriesAggregationFunction implements 
AggregationFunction<BaseTi
   @Override
   public void aggregate(int length, AggregationResultHolder 
aggregationResultHolder,
       Map<ExpressionContext, BlockValSet> blockValSetMap) {
-    final long[] timeValues = 
blockValSetMap.get(_timeExpression).getLongValuesSV();
+    BlockValSet timeBlockValSet = blockValSetMap.get(_timeExpression);
     BlockValSet valueBlockValSet = blockValSetMap.get(_valueExpression);
     switch (valueBlockValSet.getValueType().getStoredType()) {
       case INT:
@@ -150,10 +153,10 @@ public class TimeSeriesAggregationFunction implements 
AggregationFunction<BaseTi
       case FLOAT:
       case DOUBLE:
       case BIG_DECIMAL:
-        aggregateNumericValues(length, timeValues, aggregationResultHolder, 
valueBlockValSet);
+        aggregateNumericValues(length, timeBlockValSet, 
aggregationResultHolder, valueBlockValSet);
         break;
       case STRING:
-        aggregateStringValues(length, timeValues, aggregationResultHolder, 
valueBlockValSet);
+        aggregateStringValues(length, timeBlockValSet, 
aggregationResultHolder, valueBlockValSet);
         break;
       default:
         throw new UnsupportedOperationException(String.format("Unsupported 
type: %s in aggregate",
@@ -164,7 +167,7 @@ public class TimeSeriesAggregationFunction implements 
AggregationFunction<BaseTi
   @Override
   public void aggregateGroupBySV(int length, int[] groupKeyArray, 
GroupByResultHolder groupByResultHolder,
       Map<ExpressionContext, BlockValSet> blockValSetMap) {
-    final long[] timeValues = 
blockValSetMap.get(_timeExpression).getLongValuesSV();
+    BlockValSet timeBlockValSet = blockValSetMap.get(_timeExpression);
     BlockValSet valueBlockValSet = blockValSetMap.get(_valueExpression);
     switch (valueBlockValSet.getValueType().getStoredType()) {
       case INT:
@@ -172,10 +175,10 @@ public class TimeSeriesAggregationFunction implements 
AggregationFunction<BaseTi
       case FLOAT:
       case DOUBLE:
       case BIG_DECIMAL:
-        aggregateGroupByNumericValues(length, groupKeyArray, timeValues, 
groupByResultHolder, valueBlockValSet);
+        aggregateGroupByNumericValues(length, groupKeyArray, timeBlockValSet, 
groupByResultHolder, valueBlockValSet);
         break;
       case STRING:
-        aggregateGroupByStringValues(length, groupKeyArray, timeValues, 
groupByResultHolder, valueBlockValSet);
+        aggregateGroupByStringValues(length, groupKeyArray, timeBlockValSet, 
groupByResultHolder, valueBlockValSet);
         break;
       default:
         throw new UnsupportedOperationException(String.format("Unsupported 
type: %s in aggregate",
@@ -233,70 +236,96 @@ public class TimeSeriesAggregationFunction implements 
AggregationFunction<BaseTi
     return "TIME_SERIES";
   }
 
-  private void aggregateNumericValues(int length, long[] timeValues, 
AggregationResultHolder resultHolder,
+  /// Runs `consumer` over the row ranges where both the value and the 
timestamp are non-null.
+  ///
+  /// Both columns gate the row. A null value has nothing to add to its 
bucket, and a null timestamp leaves the point
+  /// with no bucket to land in, so either one makes the row uncountable 
rather than merely incomplete. With the option
+  /// disabled the whole block is one range, which is what this function did 
unconditionally before.
+  private void forEachNotNull(int length, BlockValSet valueBlockValSet, 
BlockValSet timeBlockValSet,
+      RoaringBitmapUtils.BatchConsumer consumer) {
+    RoaringBitmapUtils.forEachUnset(length,
+        
NullableSingleInputAggregationFunction.orNullIterator(_nullHandlingEnabled, 
valueBlockValSet, timeBlockValSet),
+        consumer);
+  }
+
+  /// Returns the holder's series builder, creating and storing one on first 
use.
+  ///
+  /// Called from inside a non-null range rather than before the loop, so that 
a block whose rows are all null leaves
+  /// the holder untouched and [#extractFinalResult] sees the `null` that 
means nothing was aggregated.
+  private BaseTimeSeriesBuilder 
getOrCreateSeriesBuilder(AggregationResultHolder resultHolder) {
+    BaseTimeSeriesBuilder seriesBuilder = resultHolder.getResult();
+    if (seriesBuilder == null) {
+      seriesBuilder = newSeriesBuilder();
+      resultHolder.setValue(seriesBuilder);
+    }
+    return seriesBuilder;
+  }
+
+  private BaseTimeSeriesBuilder newSeriesBuilder() {
+    return _factory.newTimeSeriesBuilder(_aggInfo, "TO_BE_REMOVED", 
_timeBuckets,
+        BaseTimeSeriesBuilder.UNINITIALISED_TAG_NAMES, 
BaseTimeSeriesBuilder.UNINITIALISED_TAG_VALUES);
+  }
+
+  private int timeIndex(long timeValue) {
+    return (int) (((timeValue + _timeOffset) - _timeReferencePoint - 1) / 
_timeBucketDivisor);
+  }
+
+  private void aggregateNumericValues(int length, BlockValSet timeBlockValSet, 
AggregationResultHolder resultHolder,
       BlockValSet blockValSet) {
+    long[] timeValues = timeBlockValSet.getLongValuesSV();
     double[] values = blockValSet.getDoubleValuesSV();
-    BaseTimeSeriesBuilder currentSeriesBuilder = resultHolder.getResult();
-    if (currentSeriesBuilder == null) {
-      currentSeriesBuilder = _factory.newTimeSeriesBuilder(_aggInfo, 
"TO_BE_REMOVED", _timeBuckets,
-          BaseTimeSeriesBuilder.UNINITIALISED_TAG_NAMES, 
BaseTimeSeriesBuilder.UNINITIALISED_TAG_VALUES);
-      resultHolder.setValue(currentSeriesBuilder);
-    }
-    int timeIndex;
-    for (int docIndex = 0; docIndex < length; docIndex++) {
-      timeIndex = (int) (((timeValues[docIndex] + _timeOffset) - 
_timeReferencePoint - 1) / _timeBucketDivisor);
-      currentSeriesBuilder.addValueAtIndex(timeIndex, values[docIndex], 
timeValues[docIndex]);
-    }
+    forEachNotNull(length, blockValSet, timeBlockValSet, (from, to) -> {
+      BaseTimeSeriesBuilder currentSeriesBuilder = 
getOrCreateSeriesBuilder(resultHolder);
+      for (int docIndex = from; docIndex < to; docIndex++) {
+        currentSeriesBuilder.addValueAtIndex(timeIndex(timeValues[docIndex]), 
values[docIndex], timeValues[docIndex]);
+      }
+    });
   }
 
-  private void aggregateStringValues(int length, long[] timeValues, 
AggregationResultHolder resultHolder,
+  private void aggregateStringValues(int length, BlockValSet timeBlockValSet, 
AggregationResultHolder resultHolder,
       BlockValSet blockValSet) {
+    long[] timeValues = timeBlockValSet.getLongValuesSV();
     String[] values = blockValSet.getStringValuesSV();
-    BaseTimeSeriesBuilder currentSeriesBuilder = resultHolder.getResult();
-    if (currentSeriesBuilder == null) {
-      currentSeriesBuilder = _factory.newTimeSeriesBuilder(_aggInfo, 
"TO_BE_REMOVED", _timeBuckets,
-          BaseTimeSeriesBuilder.UNINITIALISED_TAG_NAMES, 
BaseTimeSeriesBuilder.UNINITIALISED_TAG_VALUES);
-      resultHolder.setValue(currentSeriesBuilder);
-    }
-    int timeIndex;
-    for (int docIndex = 0; docIndex < length; docIndex++) {
-      timeIndex = (int) (((timeValues[docIndex] + _timeOffset) - 
_timeReferencePoint - 1) / _timeBucketDivisor);
-      currentSeriesBuilder.addValueAtIndex(timeIndex, values[docIndex], 
timeValues[docIndex]);
-    }
+    forEachNotNull(length, blockValSet, timeBlockValSet, (from, to) -> {
+      BaseTimeSeriesBuilder currentSeriesBuilder = 
getOrCreateSeriesBuilder(resultHolder);
+      for (int docIndex = from; docIndex < to; docIndex++) {
+        currentSeriesBuilder.addValueAtIndex(timeIndex(timeValues[docIndex]), 
values[docIndex], timeValues[docIndex]);
+      }
+    });
   }
 
-  private void aggregateGroupByNumericValues(int length, int[] groupKeyArray, 
long[] timeValues,
+  private void aggregateGroupByNumericValues(int length, int[] groupKeyArray, 
BlockValSet timeBlockValSet,
       GroupByResultHolder resultHolder, BlockValSet blockValSet) {
+    long[] timeValues = timeBlockValSet.getLongValuesSV();
     final double[] values = blockValSet.getDoubleValuesSV();
-    int timeIndex;
-    for (int docIndex = 0; docIndex < length; docIndex++) {
-      int groupId = groupKeyArray[docIndex];
-      BaseTimeSeriesBuilder currentSeriesBuilder = 
resultHolder.getResult(groupId);
-      if (currentSeriesBuilder == null) {
-        currentSeriesBuilder = _factory.newTimeSeriesBuilder(_aggInfo, 
"TO_BE_REMOVED", _timeBuckets,
-            BaseTimeSeriesBuilder.UNINITIALISED_TAG_NAMES, 
BaseTimeSeriesBuilder.UNINITIALISED_TAG_VALUES);
-        resultHolder.setValueForKey(groupId, currentSeriesBuilder);
+    forEachNotNull(length, blockValSet, timeBlockValSet, (from, to) -> {
+      for (int docIndex = from; docIndex < to; docIndex++) {
+        int groupId = groupKeyArray[docIndex];
+        BaseTimeSeriesBuilder currentSeriesBuilder = 
resultHolder.getResult(groupId);
+        if (currentSeriesBuilder == null) {
+          currentSeriesBuilder = newSeriesBuilder();
+          resultHolder.setValueForKey(groupId, currentSeriesBuilder);
+        }
+        currentSeriesBuilder.addValueAtIndex(timeIndex(timeValues[docIndex]), 
values[docIndex], timeValues[docIndex]);
       }
-      timeIndex = (int) (((timeValues[docIndex] + _timeOffset) - 
_timeReferencePoint - 1) / _timeBucketDivisor);
-      currentSeriesBuilder.addValueAtIndex(timeIndex, values[docIndex], 
timeValues[docIndex]);
-    }
+    });
   }
 
-  private void aggregateGroupByStringValues(int length, int[] groupKeyArray, 
long[] timeValues,
+  private void aggregateGroupByStringValues(int length, int[] groupKeyArray, 
BlockValSet timeBlockValSet,
       GroupByResultHolder resultHolder, BlockValSet blockValSet) {
+    long[] timeValues = timeBlockValSet.getLongValuesSV();
     final String[] values = blockValSet.getStringValuesSV();
-    int timeIndex;
-    for (int docIndex = 0; docIndex < length; docIndex++) {
-      int groupId = groupKeyArray[docIndex];
-      BaseTimeSeriesBuilder currentSeriesBuilder = 
resultHolder.getResult(groupId);
-      if (currentSeriesBuilder == null) {
-        currentSeriesBuilder = _factory.newTimeSeriesBuilder(_aggInfo, 
"TO_BE_REMOVED", _timeBuckets,
-            BaseTimeSeriesBuilder.UNINITIALISED_TAG_NAMES, 
BaseTimeSeriesBuilder.UNINITIALISED_TAG_VALUES);
-        resultHolder.setValueForKey(groupId, currentSeriesBuilder);
+    forEachNotNull(length, blockValSet, timeBlockValSet, (from, to) -> {
+      for (int docIndex = from; docIndex < to; docIndex++) {
+        int groupId = groupKeyArray[docIndex];
+        BaseTimeSeriesBuilder currentSeriesBuilder = 
resultHolder.getResult(groupId);
+        if (currentSeriesBuilder == null) {
+          currentSeriesBuilder = newSeriesBuilder();
+          resultHolder.setValueForKey(groupId, currentSeriesBuilder);
+        }
+        currentSeriesBuilder.addValueAtIndex(timeIndex(timeValues[docIndex]), 
values[docIndex], timeValues[docIndex]);
       }
-      timeIndex = (int) (((timeValues[docIndex] + _timeOffset) - 
_timeReferencePoint - 1) / _timeBucketDivisor);
-      currentSeriesBuilder.addValueAtIndex(timeIndex, values[docIndex], 
timeValues[docIndex]);
-    }
+    });
   }
 
   public static ExpressionContext create(String language, String 
valueExpressionStr, ExpressionContext timeExpression,
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/AggregationFunctionNullContractTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/AggregationFunctionNullContractTest.java
index 1a8df4c13da..1de4e8cfb44 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/AggregationFunctionNullContractTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/AggregationFunctionNullContractTest.java
@@ -97,8 +97,13 @@ public class AggregationFunctionNullContractTest {
   /// Types that cannot be constructed from a bare [FunctionContext] at all, 
and so cannot be checked here.
   ///
   /// - `EXPRMIN` / `EXPRMAX` are rejected by the factory outright; they are 
only legal in a selection without an alias,
-  ///   and are rewritten into the parent/child pair above before execution.
-  /// - `TIMESERIESAGGREGATE` needs time-series plan context that a bare 
function context cannot supply.
+  ///   and are rewritten into the parent/child pair above before execution. 
Covered by `ExprMinMaxNullHandlingTest`,
+  ///   which drives the parent directly.
+  /// - `TIMESERIESAGGREGATE` needs time-series plan context that a bare 
function context cannot supply. Covered by
+  ///   `TimeSeriesAggregationNullHandlingTest`.
+  ///
+  /// Being here means the contract is checked by the named test instead, not 
that it goes unchecked. A new entry
+  /// needs a test of its own before it is added.
   ///
   /// [#testEverySkippedTypeIsAccountedFor] pins this exactly, in both 
directions, so a newly added function cannot drop
   /// out of the contract unnoticed.
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/ExprMinMaxNullHandlingTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/ExprMinMaxNullHandlingTest.java
new file mode 100644
index 00000000000..508274ff870
--- /dev/null
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/ExprMinMaxNullHandlingTest.java
@@ -0,0 +1,201 @@
+/**
+ * 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.core.query.aggregation.function;
+
+import java.util.List;
+import java.util.Map;
+import org.apache.pinot.common.request.Literal;
+import org.apache.pinot.common.request.context.ExpressionContext;
+import org.apache.pinot.core.common.BlockValSet;
+import org.apache.pinot.core.common.SyntheticBlockValSets;
+import org.apache.pinot.core.query.aggregation.AggregationResultHolder;
+import org.apache.pinot.core.query.aggregation.groupby.GroupByResultHolder;
+import 
org.apache.pinot.core.query.aggregation.utils.exprminmax.ExprMinMaxObject;
+import org.roaringbitmap.RoaringBitmap;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+
+
+/// Null handling for `EXPR_MIN` and `EXPR_MAX`.
+///
+/// These cannot be reached through [AggregationFunctionNullContractTest]: the 
factory rejects `EXPRMIN` and `EXPRMAX`
+/// outright, since they are only legal in a selection without an alias and 
are rewritten into a parent/child pair
+/// before execution. So the parent is driven directly here.
+public class ExprMinMaxNullHandlingTest {
+  private static final ExpressionContext MEASURING = 
ExpressionContext.forIdentifier("measuring");
+  private static final ExpressionContext PROJECTION = 
ExpressionContext.forIdentifier("projection");
+
+  @Test
+  public void nullMeasuringValueDoesNotWinWhenNullHandlingEnabled() {
+    // Row 1 holds the smallest value but is null, so row 0 is the minimum
+    ExprMinMaxObject result = aggregate(false, true, nulls(1), new int[]{10, 
3, 20}, new int[]{100, 101, 102});
+
+    assertEquals(result.getExtremumKey()[0], 10);
+    assertEquals(result.getNumberOfRows(), 1);
+    assertEquals(result.getField(0, 0), 100);
+  }
+
+  @Test
+  public void nullMeasuringValueWinsWhenNullHandlingDisabled() {
+    ExprMinMaxObject result = aggregate(false, false, nulls(1), new int[]{10, 
3, 20}, new int[]{100, 101, 102});
+
+    assertEquals(result.getExtremumKey()[0], 3);
+    assertEquals(result.getField(0, 0), 101);
+  }
+
+  @Test
+  public void nullMeasuringValueDoesNotWinForMax() {
+    ExprMinMaxObject result = aggregate(true, true, nulls(2), new int[]{10, 3, 
20}, new int[]{100, 101, 102});
+
+    assertEquals(result.getExtremumKey()[0], 10);
+    assertEquals(result.getField(0, 0), 100);
+  }
+
+  @Test
+  public void everyRowNullMeasuresNothing() {
+    ExprMinMaxObject result = aggregate(false, true, nulls(0, 1, 2), new 
int[]{10, 3, 20}, new int[]{100, 101, 102});
+
+    assertEquals(result.getNumberOfRows(), 0);
+  }
+
+  @Test
+  public void tiedRowsAllProjectWhenTheirMeasuringValueIsNotNull() {
+    ExprMinMaxObject result = aggregate(false, true, nulls(), new int[]{10, 
10, 20}, new int[]{100, 101, 102});
+
+    assertEquals(result.getExtremumKey()[0], 10);
+    assertEquals(result.getNumberOfRows(), 2);
+    assertEquals(result.getField(0, 0), 100);
+    assertEquals(result.getField(1, 0), 101);
+  }
+
+  /// A second block must be read with its own values.
+  ///
+  /// The wrappers hold the block they were last bound to, so a function that 
only binds them while creating its
+  /// accumulator reads the first block's values at the second block's row 
offsets.
+  @Test
+  public void secondBlockIsReadWithItsOwnValues() {
+    ParentExprMinMaxAggregationFunction function = function(false, true);
+    AggregationResultHolder holder = function.createAggregationResultHolder();
+
+    function.aggregate(2, holder, block(nulls(), new int[]{10, 20}, new 
int[]{100, 101}));
+    function.aggregate(2, holder, block(nulls(), new int[]{3, 30}, new 
int[]{200, 201}));
+
+    ExprMinMaxObject result = function.extractAggregationResult(holder);
+    assertEquals(result.getExtremumKey()[0], 3);
+    assertEquals(result.getNumberOfRows(), 1);
+    assertEquals(result.getField(0, 0), 200);
+  }
+
+  /// A block that replaces the key must discard what the previous key 
projected, and still keep the rows that tie the
+  /// new key. `rowIds.clear()` cannot do the first part: it only drops 
candidates found within the current block.
+  @Test
+  public void 
aReplacedKeyDiscardsTheEarlierBlocksProjectionButKeepsItsOwnTies() {
+    ParentExprMinMaxAggregationFunction function = function(false, true);
+    AggregationResultHolder holder = function.createAggregationResultHolder();
+
+    function.aggregate(2, holder, block(nulls(), new int[]{10, 20}, new 
int[]{100, 101}));
+    function.aggregate(3, holder, block(nulls(), new int[]{3, 3, 30}, new 
int[]{200, 201, 202}));
+
+    ExprMinMaxObject result = function.extractAggregationResult(holder);
+    assertEquals(result.getExtremumKey()[0], 3);
+    assertEquals(result.getNumberOfRows(), 2);
+    assertEquals(result.getField(0, 0), 200);
+    assertEquals(result.getField(1, 0), 201);
+  }
+
+  @Test
+  public void nullMeasuringValueDoesNotWinInItsGroup() {
+    ParentExprMinMaxAggregationFunction function = function(false, true);
+    GroupByResultHolder holder = function.createGroupByResultHolder(2, 2);
+
+    // Rows 0 and 1 are group 0; row 1 holds the smaller value but is null
+    function.aggregateGroupBySV(4, new int[]{0, 0, 1, 1}, holder,
+        block(nulls(1), new int[]{10, 3, 20, 30}, new int[]{100, 101, 102, 
103}));
+
+    ExprMinMaxObject group0 = function.extractGroupByResult(holder, 0);
+    assertEquals(group0.getExtremumKey()[0], 10);
+    assertEquals(group0.getNumberOfRows(), 1);
+    assertEquals(group0.getField(0, 0), 100);
+
+    ExprMinMaxObject group1 = function.extractGroupByResult(holder, 1);
+    assertEquals(group1.getExtremumKey()[0], 20);
+    assertEquals(group1.getField(0, 0), 102);
+  }
+
+  @Test
+  public void nullMeasuringValueDoesNotWinInAnyOfItsGroups() {
+    ParentExprMinMaxAggregationFunction function = function(false, true);
+    GroupByResultHolder holder = function.createGroupByResultHolder(2, 2);
+
+    // Row 1 belongs to both groups and holds the smaller value, but is null
+    function.aggregateGroupByMV(2, new int[][]{{0, 1}, {0, 1}}, holder,
+        block(nulls(1), new int[]{10, 3}, new int[]{100, 101}));
+
+    assertEquals(function.extractGroupByResult(holder, 0).getExtremumKey()[0], 
10);
+    assertEquals(function.extractGroupByResult(holder, 0).getField(0, 0), 100);
+    assertEquals(function.extractGroupByResult(holder, 1).getExtremumKey()[0], 
10);
+    assertEquals(function.extractGroupByResult(holder, 1).getField(0, 0), 100);
+  }
+
+  /// The group-by path publishes each winning row as it is found rather than 
batching them, so a replaced key clears
+  /// the earlier block's projection through `setToNewVal` on its own. Pinned 
because the non-group-by path needed a
+  /// fix to reach the same behaviour.
+  @Test
+  public void aReplacedKeyInAGroupDiscardsTheEarlierBlocksProjection() {
+    ParentExprMinMaxAggregationFunction function = function(false, true);
+    GroupByResultHolder holder = function.createGroupByResultHolder(2, 2);
+
+    function.aggregateGroupBySV(2, new int[]{0, 0}, holder, block(nulls(), new 
int[]{10, 20}, new int[]{100, 101}));
+    function.aggregateGroupBySV(2, new int[]{0, 0}, holder, block(nulls(), new 
int[]{3, 30}, new int[]{200, 201}));
+
+    ExprMinMaxObject group0 = function.extractGroupByResult(holder, 0);
+    assertEquals(group0.getExtremumKey()[0], 3);
+    assertEquals(group0.getNumberOfRows(), 1);
+    assertEquals(group0.getField(0, 0), 200);
+  }
+
+  private static ExprMinMaxObject aggregate(boolean isMax, boolean 
nullHandlingEnabled, RoaringBitmap measuringNulls,
+      int[] measuring, int[] projection) {
+    ParentExprMinMaxAggregationFunction function = function(isMax, 
nullHandlingEnabled);
+    AggregationResultHolder holder = function.createAggregationResultHolder();
+    function.aggregate(measuring.length, holder, block(measuringNulls, 
measuring, projection));
+    return function.extractAggregationResult(holder);
+  }
+
+  private static ParentExprMinMaxAggregationFunction function(boolean isMax, 
boolean nullHandlingEnabled) {
+    return new ParentExprMinMaxAggregationFunction(List.of(
+        ExpressionContext.forLiteral(Literal.intValue(0)),
+        ExpressionContext.forLiteral(Literal.intValue(1)),
+        MEASURING,
+        PROJECTION), isMax, nullHandlingEnabled);
+  }
+
+  private static Map<ExpressionContext, BlockValSet> block(RoaringBitmap 
measuringNulls, int[] measuring,
+      int[] projection) {
+    return Map.of(
+        MEASURING, SyntheticBlockValSets.Int.create(measuringNulls, measuring),
+        PROJECTION, SyntheticBlockValSets.Int.create(null, projection)
+    );
+  }
+
+  private static RoaringBitmap nulls(int... indexes) {
+    return indexes.length == 0 ? null : RoaringBitmap.bitmapOf(indexes);
+  }
+}
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/StoredTypeDispatchTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/StoredTypeDispatchTest.java
index 751ec0ba897..6afec5210a8 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/StoredTypeDispatchTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/StoredTypeDispatchTest.java
@@ -226,7 +226,7 @@ public class StoredTypeDispatchTest {
         ExpressionContext.forLiteral(Literal.intValue(2)),
         ExpressionContext.forLiteral(Literal.stringValue("")));
 
-    TimeSeriesAggregationFunction function = new 
TimeSeriesAggregationFunction(arguments);
+    TimeSeriesAggregationFunction function = new 
TimeSeriesAggregationFunction(arguments, false);
     AggregationResultHolder holder = function.createAggregationResultHolder();
     function.aggregate(NUM_DOCS, holder, timeSeriesBlocks(valueBlock));
     return function.extractAggregationResult(holder);
@@ -242,7 +242,7 @@ public class StoredTypeDispatchTest {
         ExpressionContext.forLiteral(Literal.longValue(100)),
         ExpressionContext.forLiteral(Literal.longValue(10)),
         ExpressionContext.forLiteral(Literal.intValue(2)),
-        ExpressionContext.forLiteral(Literal.stringValue(""))));
+        ExpressionContext.forLiteral(Literal.stringValue(""))), false);
   }
 
   private static Map<ExpressionContext, BlockValSet> 
timeSeriesBlocks(BlockValSet valueBlock) {
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/TimeSeriesAggregationNullHandlingTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/TimeSeriesAggregationNullHandlingTest.java
new file mode 100644
index 00000000000..787febc0c81
--- /dev/null
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/TimeSeriesAggregationNullHandlingTest.java
@@ -0,0 +1,143 @@
+/**
+ * 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.core.query.aggregation.function;
+
+import java.util.List;
+import java.util.Map;
+import org.apache.pinot.common.request.Literal;
+import org.apache.pinot.common.request.context.ExpressionContext;
+import org.apache.pinot.core.common.BlockValSet;
+import org.apache.pinot.core.common.SyntheticBlockValSets;
+import org.apache.pinot.core.query.aggregation.AggregationResultHolder;
+import org.apache.pinot.core.query.aggregation.groupby.GroupByResultHolder;
+import org.apache.pinot.tsdb.spi.series.SimpleTimeSeriesBuilderFactory;
+import org.apache.pinot.tsdb.spi.series.TimeSeriesBuilderFactoryProvider;
+import org.roaringbitmap.RoaringBitmap;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNull;
+
+
+/// Null handling for `TIMESERIESAGGREGATE`.
+///
+/// This cannot be reached through [AggregationFunctionNullContractTest], 
which builds every function from a bare
+/// [org.apache.pinot.common.request.context.FunctionContext]; this one needs 
time-series plan context. It is not
+/// reachable from SQL either, but the time-series plan node passes the query 
options straight through, so a
+/// time-series query that sets `enableNullHandling` arrives here with the 
option on.
+///
+/// The rows are laid out as two buckets of two rows: timestamps `100, 100, 
110, 110` fall into bucket `0` and bucket
+/// `1` respectively, and the aggregation is a `SUM`. The bucket values are 
read off the series builder rather than
+/// through [TimeSeriesAggregationFunction#extractFinalResult], because that 
is the path the time-series engine takes
+/// and because a bucket no row landed in stays `null`, which the final-result 
conversion cannot represent.
+public class TimeSeriesAggregationNullHandlingTest {
+  private static final String LANGUAGE = 
"TimeSeriesAggregationNullHandlingTest";
+  private static final ExpressionContext TIME = 
ExpressionContext.forIdentifier("time");
+  private static final ExpressionContext VALUE = 
ExpressionContext.forIdentifier("value");
+  private static final long[] TIMESTAMPS = {100L, 100L, 110L, 110L};
+  private static final double[] VALUES = {1.0, 2.0, 3.0, 4.0};
+
+  @BeforeClass
+  public void setUp() {
+    TimeSeriesBuilderFactoryProvider.registerSeriesBuilderFactory(LANGUAGE, 
new SimpleTimeSeriesBuilderFactory());
+  }
+
+  @Test
+  public void nullValueIsNotAggregatedWhenNullHandlingEnabled() {
+    Double[] buckets = aggregate(true, RoaringBitmap.bitmapOf(0), null);
+
+    assertEquals(buckets[0].doubleValue(), 2.0);
+    assertEquals(buckets[1].doubleValue(), 7.0);
+  }
+
+  @Test
+  public void nullValueIsAggregatedWhenNullHandlingDisabled() {
+    Double[] buckets = aggregate(false, RoaringBitmap.bitmapOf(0), null);
+
+    assertEquals(buckets[0].doubleValue(), 3.0);
+    assertEquals(buckets[1].doubleValue(), 7.0);
+  }
+
+  /// A null timestamp leaves the point with no bucket to land in, so the row 
is skipped even though its value is
+  /// perfectly good.
+  @Test
+  public void nullTimestampSkipsTheRow() {
+    Double[] buckets = aggregate(true, null, RoaringBitmap.bitmapOf(2));
+
+    assertEquals(buckets[0].doubleValue(), 3.0);
+    assertEquals(buckets[1].doubleValue(), 4.0);
+  }
+
+  @Test
+  public void aBucketWhoseRowsAreAllNullIsNeverWrittenTo() {
+    Double[] buckets = aggregate(true, RoaringBitmap.bitmapOf(0, 1), null);
+
+    assertNull(buckets[0]);
+    assertEquals(buckets[1].doubleValue(), 7.0);
+  }
+
+  @Test
+  public void everyRowNullLeavesNothingAggregated() {
+    TimeSeriesAggregationFunction function = function(true);
+    AggregationResultHolder holder = function.createAggregationResultHolder();
+    function.aggregate(VALUES.length, holder, block(RoaringBitmap.bitmapOf(0, 
1, 2, 3), null));
+
+    assertNull(function.extractAggregationResult(holder), "the holder should 
never have been touched");
+  }
+
+  @Test
+  public void nullValueIsNotAggregatedIntoItsGroup() {
+    TimeSeriesAggregationFunction function = function(true);
+    GroupByResultHolder holder = function.createGroupByResultHolder(2, 2);
+    function.aggregateGroupBySV(VALUES.length, new int[]{0, 0, 1, 1}, holder,
+        block(RoaringBitmap.bitmapOf(0), null));
+
+    assertEquals(function.extractGroupByResult(holder, 
0).build().getDoubleValues()[0].doubleValue(), 2.0);
+    assertEquals(function.extractGroupByResult(holder, 
1).build().getDoubleValues()[1].doubleValue(), 7.0);
+  }
+
+  private static Double[] aggregate(boolean nullHandlingEnabled, RoaringBitmap 
valueNulls, RoaringBitmap timeNulls) {
+    TimeSeriesAggregationFunction function = function(nullHandlingEnabled);
+    AggregationResultHolder holder = function.createAggregationResultHolder();
+    function.aggregate(VALUES.length, holder, block(valueNulls, timeNulls));
+    return function.extractAggregationResult(holder).build().getDoubleValues();
+  }
+
+  private static TimeSeriesAggregationFunction function(boolean 
nullHandlingEnabled) {
+    return new TimeSeriesAggregationFunction(List.of(
+        ExpressionContext.forLiteral(Literal.stringValue(LANGUAGE)),
+        ExpressionContext.forLiteral(Literal.stringValue("SUM")),
+        VALUE,
+        TIME,
+        ExpressionContext.forLiteral(Literal.stringValue("SECONDS")),
+        ExpressionContext.forLiteral(Literal.longValue(0)),
+        ExpressionContext.forLiteral(Literal.longValue(100)),
+        ExpressionContext.forLiteral(Literal.longValue(10)),
+        ExpressionContext.forLiteral(Literal.intValue(2)),
+        ExpressionContext.forLiteral(Literal.stringValue(""))), 
nullHandlingEnabled);
+  }
+
+  private static Map<ExpressionContext, BlockValSet> block(RoaringBitmap 
valueNulls, RoaringBitmap timeNulls) {
+    return Map.of(
+        TIME, SyntheticBlockValSets.Long.create(timeNulls, TIMESTAMPS),
+        VALUE, SyntheticBlockValSets.Double.create(valueNulls, VALUES)
+    );
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to