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 902d3864bed Fix wrong results from MinMax selection order-by combine 
under null handling (#19724)
902d3864bed is described below

commit 902d3864bed591d7820c3409f2ab03dca02968c0
Author: rohit <[email protected]>
AuthorDate: Wed Oct 7 03:12:52 2026 +0530

    Fix wrong results from MinMax selection order-by combine under null 
handling (#19724)
---
 ...xValueBasedSelectionOrderByCombineOperator.java |  49 +++++--
 .../combine/SelectionCombineOperatorTest.java      | 148 ++++++++++++++++++++-
 .../pinot/queries/NullQueriesFluentTest.java       | 137 +++++++++++++++++++
 3 files changed, 321 insertions(+), 13 deletions(-)

diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/combine/MinMaxValueBasedSelectionOrderByCombineOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/combine/MinMaxValueBasedSelectionOrderByCombineOperator.java
index 86ae288aca1..b349ed7ef6e 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/combine/MinMaxValueBasedSelectionOrderByCombineOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/combine/MinMaxValueBasedSelectionOrderByCombineOperator.java
@@ -35,6 +35,7 @@ import 
org.apache.pinot.core.operator.blocks.results.MetadataResultsBlock;
 import org.apache.pinot.core.operator.blocks.results.SelectionResultsBlock;
 import org.apache.pinot.core.query.request.context.QueryContext;
 import org.apache.pinot.core.query.selection.SelectionOperatorUtils;
+import org.apache.pinot.segment.spi.datasource.DataSource;
 import org.apache.pinot.segment.spi.datasource.DataSourceMetadata;
 import org.apache.pinot.spi.data.Schema;
 import org.apache.pinot.spi.exception.QueryErrorCode;
@@ -51,6 +52,14 @@ import org.slf4j.LoggerFactory;
 /// - 1. Sort all the segments by the column min/max value
 /// - 2. Keep processing segments until we get enough documents to fulfill the 
LIMIT and OFFSET requirement
 /// - 3. Skip processing the segments that cannot add values to the final 
result
+///
+/// With null handling enabled, the column min/max value cannot bound the null 
rows: a null is stored as the column's
+/// default null value, and the metadata has no null semantics. Null rows are 
handled as follows:
+/// - When nulls sort first (the default for descending order, or explicit 
`NULLS FIRST`), a segment that may hold null
+///   rows (it has a null value vector) is treated like a segment without 
min/max value: it is processed before the
+///   other segments and never skipped. Segments without a null value vector 
are skipped as usual.
+/// - When nulls sort last, null rows cannot beat a non-null boundary value, 
so segment skipping applies as usual.
+/// - A null row value is never used as the boundary value; the previous 
boundary value is kept instead.
 @SuppressWarnings({"rawtypes", "unchecked"})
 public class MinMaxValueBasedSelectionOrderByCombineOperator
     extends BaseSingleBlockCombineOperator<SelectionResultsBlock> {
@@ -81,10 +90,13 @@ public class MinMaxValueBasedSelectionOrderByCombineOperator
     OrderByExpressionContext firstOrderByExpression = 
orderByExpressions.get(0);
     assert firstOrderByExpression.getExpression().getType() == 
ExpressionContext.Type.IDENTIFIER;
     String firstOrderByColumn = 
firstOrderByExpression.getExpression().getIdentifier();
+    // isNullsLast() is false for DESC even when null handling is disabled, so 
the null handling check is required
+    boolean nullsRankFirst = queryContext.isNullHandlingEnabled() && 
!firstOrderByExpression.isNullsLast();
 
     _minMaxValueContexts = new ArrayList<>(_numOperators);
     for (Operator<BaseResultsBlock> operator : _operators) {
-      _minMaxValueContexts.add(new MinMaxValueContext(operator, 
firstOrderByColumn, queryContext.getSchema()));
+      _minMaxValueContexts.add(
+          new MinMaxValueContext(operator, firstOrderByColumn, 
queryContext.getSchema(), nullsRankFirst));
     }
     if (firstOrderByExpression.isAsc()) {
       // For ascending order, sort on column min value in ascending order
@@ -210,9 +222,11 @@ public class 
MinMaxValueBasedSelectionOrderByCombineOperator
       List<Object[]> rows = resultsBlock.getRows();
       assert rows != null;
       int numRows = rows.size();
-      if (numRows >= _numRowsToKeep) {
-        // Segment result has enough rows, update the boundary value
-        Comparable segmentBoundaryValue = (Comparable) rows.get(numRows - 
1)[0];
+      // Update the boundary value if the segment result has enough rows
+      // NOTE: A null row value cannot be compared with the segment min/max 
value, so it is never used as the boundary
+      //       value. The previous boundary value is kept, which is still 
valid.
+      Comparable segmentBoundaryValue = numRows >= _numRowsToKeep ? 
(Comparable) rows.get(numRows - 1)[0] : null;
+      if (segmentBoundaryValue != null) {
         if (boundaryValue == null) {
           boundaryValue = segmentBoundaryValue;
         } else {
@@ -273,9 +287,14 @@ public class 
MinMaxValueBasedSelectionOrderByCombineOperator
       numBlocksMerged++;
 
       // Update the boundary value if enough rows are collected
+      // NOTE: A null row value is never used as the boundary value; the 
previous one is kept. A null global boundary
+      //       would only fall back to the thread boundary, so this preserves 
segment skipping across threads.
       List<Object[]> rows = mergedBlock.getRows();
       if (rows.size() == _numRowsToKeep) {
-        _globalBoundaryValue.set((Comparable) rows.get(_numRowsToKeep - 1)[0]);
+        Comparable boundaryValue = (Comparable) rows.get(_numRowsToKeep - 
1)[0];
+        if (boundaryValue != null) {
+          _globalBoundaryValue.set(boundaryValue);
+        }
       }
     }
     return mergedBlock;
@@ -303,12 +322,22 @@ public class 
MinMaxValueBasedSelectionOrderByCombineOperator
     final Comparable _minValue;
     final Comparable _maxValue;
 
-    MinMaxValueContext(Operator<BaseResultsBlock> operator, String column, 
Schema schema) {
+    MinMaxValueContext(Operator<BaseResultsBlock> operator, String column, 
Schema schema,
+        boolean nullsRankFirst) {
       _operator = operator;
-      DataSourceMetadata dataSourceMetadata =
-          operator.getIndexSegment().getDataSource(column, 
schema).getDataSourceMetadata();
-      _minValue = dataSourceMetadata.getMinValue();
-      _maxValue = dataSourceMetadata.getMaxValue();
+      DataSource dataSource = operator.getIndexSegment().getDataSource(column, 
schema);
+      // When nulls sort first, min/max value cannot bound a segment that may 
hold null rows. Leave them unset so that
+      // the segment is processed first and never skipped.
+      // NOTE: Immutable segments only have a null value vector when they 
contain nulls. Mutable segments always have
+      //       one when null handling is enabled for the column, so they are 
conservatively never skipped.
+      if (nullsRankFirst && dataSource.getNullValueVector() != null) {
+        _minValue = null;
+        _maxValue = null;
+      } else {
+        DataSourceMetadata dataSourceMetadata = 
dataSource.getDataSourceMetadata();
+        _minValue = dataSourceMetadata.getMinValue();
+        _maxValue = dataSourceMetadata.getMaxValue();
+      }
     }
   }
 }
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/combine/SelectionCombineOperatorTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/combine/SelectionCombineOperatorTest.java
index 845b7a283d7..e5aa65bc7e4 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/combine/SelectionCombineOperatorTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/combine/SelectionCombineOperatorTest.java
@@ -21,7 +21,9 @@ package org.apache.pinot.core.operator.combine;
 import java.io.File;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
+import java.util.Set;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import org.apache.commons.io.FileUtils;
@@ -35,6 +37,8 @@ import 
org.apache.pinot.core.query.request.context.QueryContext;
 import 
org.apache.pinot.core.query.request.context.utils.QueryContextConverterUtils;
 import org.apache.pinot.core.util.QueryMultiThreadingUtils;
 import 
org.apache.pinot.segment.local.indexsegment.immutable.ImmutableSegmentLoader;
+import org.apache.pinot.segment.local.indexsegment.mutable.MutableSegmentImpl;
+import 
org.apache.pinot.segment.local.indexsegment.mutable.MutableSegmentImplTestUtils;
 import 
org.apache.pinot.segment.local.segment.creator.impl.SegmentIndexCreationDriverImpl;
 import org.apache.pinot.segment.local.segment.readers.GenericRowRecordReader;
 import org.apache.pinot.segment.spi.IndexSegment;
@@ -58,7 +62,8 @@ import static org.testng.Assert.assertNotNull;
 import static org.testng.Assert.assertTrue;
 
 
-/// Test for [SelectionOnlyCombineOperator] and 
[SelectionOrderByCombineOperator].
+/// Test for [SelectionOnlyCombineOperator], [SelectionOrderByCombineOperator] 
and
+/// [MinMaxValueBasedSelectionOrderByCombineOperator].
 public class SelectionCombineOperatorTest {
   private static final File TEMP_DIR = new File(FileUtils.getTempDirectory(), 
"SelectionCombineOperatorTest");
   private static final String RAW_TABLE_NAME = "testTable";
@@ -77,7 +82,20 @@ public class SelectionCombineOperatorTest {
   private static final PlanMaker PLAN_MAKER = new InstancePlanMakerImplV2();
   private static final ExecutorService EXECUTOR = 
Executors.newCachedThreadPool();
 
+  // Segments for the min/max value based segment skipping tests under null 
handling
+  private static final TableConfig NULL_HANDLING_TABLE_CONFIG =
+      new 
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).setNullHandlingEnabled(true).build();
+  private static final Schema COLUMN_BASED_NULL_HANDLING_SCHEMA =
+      new Schema.SchemaBuilder().setEnableColumnBasedNullHandling(true)
+          .addDimensionField(INT_COLUMN, FieldSpec.DataType.INT, fieldSpec -> 
fieldSpec.setNullable(false)).build();
+  private static final String NULL_HANDLING_OPTIONS = "SET 
enableNullHandling=true; ";
+
   private List<IndexSegment> _indexSegments;
+  private IndexSegment _highSegment;
+  private IndexSegment _lowSegment;
+  private IndexSegment _lowSegmentWithNulls;
+  private IndexSegment _highNonNullableSegment;
+  private IndexSegment _lowNonNullableSegment;
 
   @BeforeClass
   public void setUp()
@@ -87,6 +105,34 @@ public class SelectionCombineOperatorTest {
     for (int i = 0; i < NUM_SEGMENTS; i++) {
       _indexSegments.add(createOfflineSegment(i));
     }
+    _highSegment = createSegment("highSegment", NULL_HANDLING_TABLE_CONFIG, 
SCHEMA, 100, 101, 102);
+    _lowSegment = createSegment("lowSegment", NULL_HANDLING_TABLE_CONFIG, 
SCHEMA, 5, 6, 7);
+    _lowSegmentWithNulls = createSegment("lowSegmentWithNulls", 
NULL_HANDLING_TABLE_CONFIG, SCHEMA, 5, null, null);
+    _highNonNullableSegment =
+        createSegment("highNonNullableSegment", TABLE_CONFIG, 
COLUMN_BASED_NULL_HANDLING_SCHEMA, 100, 101, 102);
+    _lowNonNullableSegment =
+        createSegment("lowNonNullableSegment", TABLE_CONFIG, 
COLUMN_BASED_NULL_HANDLING_SCHEMA, 5, 6, 7);
+  }
+
+  private IndexSegment createSegment(String segmentName, TableConfig 
tableConfig, Schema schema, Integer... values)
+      throws Exception {
+    List<GenericRow> records = new ArrayList<>(values.length);
+    for (Integer value : values) {
+      GenericRow record = new GenericRow();
+      record.putValue(INT_COLUMN, value);
+      records.add(record);
+    }
+
+    SegmentGeneratorConfig segmentGeneratorConfig = new 
SegmentGeneratorConfig(tableConfig, schema);
+    segmentGeneratorConfig.setTableName(RAW_TABLE_NAME);
+    segmentGeneratorConfig.setSegmentName(segmentName);
+    segmentGeneratorConfig.setOutDir(TEMP_DIR.getPath());
+
+    SegmentIndexCreationDriverImpl driver = new 
SegmentIndexCreationDriverImpl();
+    driver.init(segmentGeneratorConfig, new GenericRowRecordReader(records));
+    driver.build();
+
+    return ImmutableSegmentLoader.load(new File(TEMP_DIR, segmentName), 
ReadMode.mmap);
   }
 
   private IndexSegment createOfflineSegment(int index)
@@ -304,10 +350,102 @@ public class SelectionCombineOperatorTest {
     assertEquals(combineResult.getNumTotalDocs(), NUM_SEGMENTS * 
NUM_RECORDS_PER_SEGMENT);
   }
 
+  /// Under null handling, a segment without nulls is still skipped when its 
max cannot beat the boundary, even though
+  /// nulls sort first under `DESC`.
+  @Test
+  public void 
selectionOrderByMinMaxSkipsSegmentWithoutNullsUnderNullHandling() {
+    SelectionResultsBlock combineResult =
+        getSingleThreadCombineResult(NULL_HANDLING_OPTIONS + "SELECT * FROM 
testTable ORDER BY intColumn DESC LIMIT 3",
+            List.of(_highSegment, _lowSegment));
+    assertEquals(getIntColumnValues(combineResult), Arrays.asList(102, 101, 
100));
+    assertEquals(combineResult.getNumSegmentsProcessed(), 2);
+    assertEquals(combineResult.getNumSegmentsMatched(), 1);
+  }
+
+  /// When nulls sort last, a segment with nulls is still skipped when its max 
cannot beat the boundary: its nulls rank
+  /// below any non-null boundary.
+  @Test
+  public void selectionOrderByMinMaxSkipsSegmentWithNullsWhenNullsSortLast() {
+    SelectionResultsBlock combineResult = getSingleThreadCombineResult(
+        NULL_HANDLING_OPTIONS + "SELECT * FROM testTable ORDER BY intColumn 
DESC NULLS LAST LIMIT 3",
+        List.of(_highSegment, _lowSegmentWithNulls));
+    assertEquals(getIntColumnValues(combineResult), Arrays.asList(102, 101, 
100));
+    assertEquals(combineResult.getNumSegmentsProcessed(), 2);
+    assertEquals(combineResult.getNumSegmentsMatched(), 1);
+  }
+
+  /// When nulls sort first, a segment with nulls must not be skipped: the 
segment min/max does not account for its
+  /// null rows.
+  @Test
+  public void 
selectionOrderByMinMaxProcessesSegmentWithNullsWhenNullsSortFirst() {
+    SelectionResultsBlock combineResult =
+        getSingleThreadCombineResult(NULL_HANDLING_OPTIONS + "SELECT * FROM 
testTable ORDER BY intColumn DESC LIMIT 3",
+            List.of(_highSegment, _lowSegmentWithNulls));
+    assertEquals(getIntColumnValues(combineResult), Arrays.asList(null, null, 
102));
+    assertEquals(combineResult.getNumSegmentsProcessed(), 2);
+    assertEquals(combineResult.getNumSegmentsMatched(), 2);
+  }
+
+  /// A consuming segment always has a null value vector for a nullable 
column, so it is never skipped when nulls sort
+  /// first, even if it currently holds no null rows.
+  @Test
+  public void selectionOrderByMinMaxProcessesMutableSegmentWhenNullsSortFirst()
+      throws IOException {
+    MutableSegmentImpl mutableSegment =
+        MutableSegmentImplTestUtils.createMutableSegmentImpl(SCHEMA, Set.of(), 
Set.of(), Set.of(), false, true);
+    try {
+      for (Integer value : new Integer[]{5, 6, 7}) {
+        GenericRow row = new GenericRow();
+        row.putValue(INT_COLUMN, value);
+        mutableSegment.index(row, null);
+      }
+      SelectionResultsBlock combineResult = getSingleThreadCombineResult(
+          NULL_HANDLING_OPTIONS + "SELECT * FROM testTable ORDER BY intColumn 
DESC LIMIT 3",
+          List.of(_highSegment, mutableSegment));
+      assertEquals(getIntColumnValues(combineResult), Arrays.asList(102, 101, 
100));
+      assertEquals(combineResult.getNumSegmentsProcessed(), 2);
+      assertEquals(combineResult.getNumSegmentsMatched(), 2);
+    } finally {
+      mutableSegment.destroy();
+    }
+  }
+
+  /// With column-based null handling, a non-nullable column has no nulls, so 
segment skipping still applies.
+  @Test
+  public void selectionOrderByMinMaxSkipsSegmentForNonNullableColumn() {
+    SelectionResultsBlock combineResult =
+        getSingleThreadCombineResult(NULL_HANDLING_OPTIONS + "SELECT * FROM 
testTable ORDER BY intColumn DESC LIMIT 3",
+            List.of(_highNonNullableSegment, _lowNonNullableSegment));
+    assertEquals(getIntColumnValues(combineResult), Arrays.asList(102, 101, 
100));
+    assertEquals(combineResult.getNumSegmentsProcessed(), 2);
+    assertEquals(combineResult.getNumSegmentsMatched(), 1);
+  }
+
+  private static List<Integer> getIntColumnValues(SelectionResultsBlock 
combineResult) {
+    List<Integer> values = new ArrayList<>();
+    for (Object[] row : combineResult.getRows()) {
+      values.add((Integer) row[0]);
+    }
+    return values;
+  }
+
   private SelectionResultsBlock getCombineResult(@Language("sql") String 
query) {
+    return getCombineResult(QueryContextConverterUtils.getQueryContext(query), 
_indexSegments);
+  }
+
+  /// Runs the query with a single thread, which processes the segments in 
min/max order and makes the segment skipping
+  /// deterministic. `SET maxExecutionThreads` would not apply here: the 
server plan maker is what copies it into the
+  /// query context.
+  private SelectionResultsBlock getSingleThreadCombineResult(@Language("sql") 
String query,
+      List<IndexSegment> indexSegments) {
     QueryContext queryContext = 
QueryContextConverterUtils.getQueryContext(query);
-    List<PlanNode> planNodes = new ArrayList<>(NUM_SEGMENTS);
-    for (IndexSegment indexSegment : _indexSegments) {
+    queryContext.setMaxExecutionThreads(1);
+    return getCombineResult(queryContext, indexSegments);
+  }
+
+  private SelectionResultsBlock getCombineResult(QueryContext queryContext, 
List<IndexSegment> indexSegments) {
+    List<PlanNode> planNodes = new ArrayList<>(indexSegments.size());
+    for (IndexSegment indexSegment : indexSegments) {
       planNodes.add(PLAN_MAKER.makeSegmentPlanNode(new 
SegmentContext(indexSegment), queryContext));
     }
     queryContext.setEndTimeMs(System.currentTimeMillis() + 
Server.DEFAULT_QUERY_EXECUTOR_TIMEOUT_MS);
@@ -321,6 +459,10 @@ public class SelectionCombineOperatorTest {
     for (IndexSegment indexSegment : _indexSegments) {
       indexSegment.destroy();
     }
+    for (IndexSegment indexSegment : List.of(_highSegment, _lowSegment, 
_lowSegmentWithNulls, _highNonNullableSegment,
+        _lowNonNullableSegment)) {
+      indexSegment.destroy();
+    }
     FileUtils.deleteDirectory(TEMP_DIR);
   }
 }
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/queries/NullQueriesFluentTest.java 
b/pinot-core/src/test/java/org/apache/pinot/queries/NullQueriesFluentTest.java
index 85e80ab92db..eee751662bd 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/queries/NullQueriesFluentTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/queries/NullQueriesFluentTest.java
@@ -22,6 +22,7 @@ import java.io.File;
 import java.io.IOException;
 import java.io.UncheckedIOException;
 import java.nio.file.Files;
+import java.util.Map;
 import org.apache.commons.io.FileUtils;
 import org.apache.pinot.spi.config.table.FieldConfig;
 import org.apache.pinot.spi.config.table.TableConfig;
@@ -49,6 +50,39 @@ public class NullQueriesFluentTest {
       .addSingleValueDimension("stringCol", FieldSpec.DataType.STRING)
       .build();
 
+  // Selection ORDER BY on an identifier with a LIMIT plans 
MinMaxValueBasedSelectionOrderByCombineOperator, which skips
+  // segments whose min/max cannot beat the current boundary. The tests below 
put two segments on one instance so the
+  // skip can happen. With a single instance, BaseQueriesTest serves it as two 
identical servers, so the broker merges
+  // two copies of the combine result before applying the LIMIT. The comments 
on each assertion give the combine
+  // (per-server) result the expectation is derived from.
+  private static final TableConfig NULL_HANDLING_TABLE_CONFIG = new 
TableConfigBuilder(TableType.OFFLINE)
+      .setTableName("testTable")
+      .setNullHandlingEnabled(true)
+      .build();
+
+  private static final TableConfig PLAIN_TABLE_CONFIG = new 
TableConfigBuilder(TableType.OFFLINE)
+      .setTableName("testTable")
+      .build();
+
+  private static final Schema INT_SCHEMA = new Schema.SchemaBuilder()
+      .setSchemaName("testTable")
+      .addSingleValueDimension("intCol", FieldSpec.DataType.INT)
+      .build();
+
+  private static final Schema INT_CUSTOM_DEFAULT_NULL_SCHEMA = new 
Schema.SchemaBuilder()
+      .setSchemaName("testTable")
+      .addSingleValueDimension("intCol", FieldSpec.DataType.INT, 1000)
+      .build();
+
+  private static final Schema INT_COLUMN_BASED_SCHEMA = new 
Schema.SchemaBuilder()
+      .setSchemaName("testTable")
+      .setEnableColumnBasedNullHandling(true)
+      .addDimensionField("intCol", FieldSpec.DataType.INT, fieldSpec -> 
fieldSpec.setNullable(true))
+      .build();
+
+  // One thread processes the segments in min/max order, which makes the 
segment skip deterministic
+  private static final Map<String, String> SINGLE_THREAD = 
Map.of("maxExecutionThreads", "1");
+
   private File _baseDir;
 
   @BeforeClass
@@ -109,4 +143,107 @@ public class NullQueriesFluentTest {
             new Object[]{"2025-09-23 17:38:00.0"}
         );
   }
+
+  /// The segment with nulls has max 5, below the boundary 100 set by the 
first segment, but its nulls sort first.
+  @Test
+  public void testMinMaxCombineOrderByDescKeepsNullsFirst() {
+    givenHighAndLowWithNullsSegments(INT_SCHEMA, NULL_HANDLING_TABLE_CONFIG)
+        // Combine result: [null, null, 104, 103, 102]; the two copies hold 4 
nulls, so LIMIT 5 keeps one 104
+        .whenQuery("select intCol from testTable order by intCol desc limit 5")
+        .thenResultIs(new Object[]{null}, new Object[]{null}, new 
Object[]{null}, new Object[]{null},
+            new Object[]{104})
+        // Combine result: [null, null, 104, 103, 102]
+        .whenQuery("select intCol from testTable order by intCol desc nulls 
first limit 5")
+        .thenResultIs(new Object[]{null}, new Object[]{null}, new 
Object[]{null}, new Object[]{null},
+            new Object[]{104});
+  }
+
+  @Test
+  public void 
testMinMaxCombineOrderByDescKeepsNullsFirstColumnBasedNullHandling() {
+    givenHighAndLowWithNullsSegments(INT_COLUMN_BASED_SCHEMA, 
PLAIN_TABLE_CONFIG)
+        // Combine result: [null, null, 104, 103, 102]
+        .whenQuery("select intCol from testTable order by intCol desc limit 5")
+        .thenResultIs(new Object[]{null}, new Object[]{null}, new 
Object[]{null}, new Object[]{null},
+            new Object[]{104});
+  }
+
+  /// Orders where nulls sort last are not affected by the segment skip.
+  @Test
+  public void testMinMaxCombineNullsLast() {
+    givenHighAndLowWithNullsSegments(INT_SCHEMA, NULL_HANDLING_TABLE_CONFIG)
+        // Combine result: [104, 103, 102]
+        .whenQuery("select intCol from testTable order by intCol desc nulls 
last limit 3")
+        .thenResultIs(new Object[]{104}, new Object[]{104}, new Object[]{103})
+        // Combine result: [5, 100, 101]
+        .whenQuery("select intCol from testTable order by intCol limit 3")
+        .thenResultIs(new Object[]{5}, new Object[]{5}, new Object[]{100})
+        // Combine result: [5, 100, 101]
+        .whenQuery("select intCol from testTable order by intCol asc nulls 
last limit 3")
+        .thenResultIs(new Object[]{5}, new Object[]{5}, new Object[]{100});
+  }
+
+  /// The stored default null value (1000) gives the segment with nulls min 
500, above the boundary 3 set by the first
+  /// segment, but its nulls sort first.
+  @Test
+  public void 
testMinMaxCombineOrderByAscNullsFirstWithCustomDefaultNullValue() {
+    FluentQueryTest.withBaseDir(_baseDir)
+        .withExtraQueryOptions(SINGLE_THREAD)
+        .withNullHandling(true)
+        .givenTable(INT_CUSTOM_DEFAULT_NULL_SCHEMA, NULL_HANDLING_TABLE_CONFIG)
+        .onFirstInstance(
+            new Object[]{1},
+            new Object[]{2},
+            new Object[]{3}
+        ).andSegment(
+            new Object[]{500},
+            new Object[]{null},
+            new Object[]{null}
+        )
+        // Combine result: [null, null, 1]
+        .whenQuery("select intCol from testTable order by intCol asc nulls 
first limit 3")
+        .thenResultIs(new Object[]{null}, new Object[]{null}, new 
Object[]{null});
+  }
+
+  /// The segment with nulls (max 95) is not skipped by the boundary 80, and 
the last of its top 3 rows is null, which
+  /// must not be used as a boundary value.
+  @Test
+  public void testMinMaxCombineNullBoundaryValue() {
+    FluentQueryTest.withBaseDir(_baseDir)
+        .withExtraQueryOptions(SINGLE_THREAD)
+        .withNullHandling(true)
+        .givenTable(INT_SCHEMA, NULL_HANDLING_TABLE_CONFIG)
+        .onFirstInstance(
+            new Object[]{100},
+            new Object[]{90},
+            new Object[]{80}
+        ).andSegment(
+            new Object[]{95},
+            new Object[]{null},
+            new Object[]{null}
+        )
+        // Combine result: [100, 95, 90]
+        .whenQuery("select intCol from testTable order by intCol desc nulls 
last limit 3")
+        .thenResultIs(new Object[]{100}, new Object[]{100}, new Object[]{95})
+        // Combine result: [null, null, 100]
+        .whenQuery("select intCol from testTable order by intCol desc limit 3")
+        .thenResultIs(new Object[]{null}, new Object[]{null}, new 
Object[]{null});
+  }
+
+  private FluentQueryTest.OnFirstInstance 
givenHighAndLowWithNullsSegments(Schema schema, TableConfig tableConfig) {
+    return FluentQueryTest.withBaseDir(_baseDir)
+        .withExtraQueryOptions(SINGLE_THREAD)
+        .withNullHandling(true)
+        .givenTable(schema, tableConfig)
+        .onFirstInstance(
+            new Object[]{100},
+            new Object[]{101},
+            new Object[]{102},
+            new Object[]{103},
+            new Object[]{104}
+        ).andSegment(
+            new Object[]{5},
+            new Object[]{null},
+            new Object[]{null}
+        );
+  }
 }


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

Reply via email to