You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pinot.apache.org by ja...@apache.org on 2026/09/02 16:24:09 UTC

(pinot) branch master updated: Support null handling in dictionary-based and multi-value group key generation (#19390)

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 46a07c77cfd Support null handling in dictionary-based and multi-value group key generation (#19390)
46a07c77cfd is described below

commit 46a07c77cfd199ab0013611d16435b95fc0e030b
Author: Xiaotian (Jackie) Jiang <17...@users.noreply.github.com>
AuthorDate: Wed Sep 2 09:24:01 2026 -0700

    Support null handling in dictionary-based and multi-value group key generation (#19390)
---
 .../groupby/DefaultGroupByExecutor.java            |   29 +-
 .../groupby/DictionaryBasedGroupKeyGenerator.java  |  136 +-
 .../groupby/GroupingSetsGroupKeyGenerator.java     |   17 +
 .../NoDictionaryMultiColumnGroupKeyGenerator.java  |  212 ++-
 .../NoDictionarySingleColumnGroupKeyGenerator.java |  222 ++--
 .../DictionaryBasedGroupKeyGeneratorTest.java      |  123 +-
 .../queries/NullHandlingEnabledQueriesTest.java    | 1370 +++++++++++---------
 7 files changed, 1203 insertions(+), 906 deletions(-)

diff --git a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/DefaultGroupByExecutor.java b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/DefaultGroupByExecutor.java
index 5bad8a3fc48..f4349c1171a 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/DefaultGroupByExecutor.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/DefaultGroupByExecutor.java
@@ -41,10 +41,14 @@ import org.apache.pinot.core.query.request.context.QueryContext;
 
 
 /// This class implements group by aggregation.
-/// It is optimized for performance, and uses the best possible algorithm/data-structure
-/// for a given query based on the following parameters:
+/// It is optimized for performance, and uses the best possible algorithm/data-structure for a given query based on the
+/// following parameters:
+/// - Whether all group-by columns are dictionary encoded.
 /// - Maximum number of group keys possible.
 /// - Single/Multi valued columns.
+///
+/// Null handling does not affect the choice: every group key generator gives a null a group of its own when it is
+/// enabled.
 @SuppressWarnings({"rawtypes", "unchecked"})
 public class DefaultGroupByExecutor implements GroupByExecutor {
   // Thread local (reusable) array for single-valued group keys
@@ -68,11 +72,6 @@ public class DefaultGroupByExecutor implements GroupByExecutor {
     this(queryContext, queryContext.getAggregationFunctions(), groupByExpressions, projectOperator, null);
   }
 
-  public DefaultGroupByExecutor(QueryContext queryContext, AggregationFunction[] aggregationFunctions,
-      ExpressionContext[] groupByExpressions, BaseProjectOperator<?> projectOperator) {
-    this(queryContext, aggregationFunctions, groupByExpressions, projectOperator, null);
-  }
-
   public DefaultGroupByExecutor(QueryContext queryContext, AggregationFunction[] aggregationFunctions,
       ExpressionContext[] groupByExpressions, BaseProjectOperator<?> projectOperator,
       @Nullable GroupKeyGenerator groupKeyGenerator) {
@@ -90,8 +89,8 @@ public class DefaultGroupByExecutor implements GroupByExecutor {
       // isDictionaryEncoded() flag rather than gating on dictionary nullness alone.
       hasNoDictionaryGroupByExpression |= !columnContext.isDictionaryEncoded();
     }
-    /// Grouping-set queries expand each row into one group per grouping set, so they always use the
-    /// multi-value (int[][]) executor path even though the union group-by columns are single-valued.
+    // Grouping-set queries expand each row into one group per grouping set, so they always use the
+    // multi-value (int[][]) executor path even though the union group-by columns are single-valued.
     boolean groupingSets = queryContext.isGroupingSets();
     _hasMVGroupByExpression = hasMVGroupByExpression || groupingSets;
 
@@ -105,12 +104,14 @@ public class DefaultGroupByExecutor implements GroupByExecutor {
     if (groupKeyGenerator != null) {
       _groupKeyGenerator = groupKeyGenerator;
     } else if (groupingSets) {
-      _groupKeyGenerator = new GroupingSetsGroupKeyGenerator(projectOperator, groupByExpressions,
-          queryContext.getGroupingSets(), numGroupsLimit, _nullHandlingEnabled);
+      _groupKeyGenerator =
+          new GroupingSetsGroupKeyGenerator(projectOperator, groupByExpressions, queryContext.getGroupingSets(),
+              numGroupsLimit, _nullHandlingEnabled);
     } else {
-      if (hasNoDictionaryGroupByExpression || _nullHandlingEnabled) {
+      // Null handling does not steer this choice: every generator below gives a null an id of its own, so the
+      // encoding of the group-by columns decides on its own which one to use.
+      if (hasNoDictionaryGroupByExpression) {
         if (groupByExpressions.length == 1) {
-          // TODO(nhejazi): support MV and dictionary based when null handling is enabled.
           _groupKeyGenerator =
               new NoDictionarySingleColumnGroupKeyGenerator(projectOperator, groupByExpressions[0], numGroupsLimit,
                   _nullHandlingEnabled, groupByExpressionSizesFromPredicates);
@@ -121,7 +122,7 @@ public class DefaultGroupByExecutor implements GroupByExecutor {
         }
       } else {
         _groupKeyGenerator = new DictionaryBasedGroupKeyGenerator(projectOperator, groupByExpressions, numGroupsLimit,
-            maxInitialResultHolderCapacity, groupByExpressionSizesFromPredicates);
+            maxInitialResultHolderCapacity, _nullHandlingEnabled, groupByExpressionSizesFromPredicates);
       }
     }
 
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/DictionaryBasedGroupKeyGenerator.java b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/DictionaryBasedGroupKeyGenerator.java
index 22c3229cb8c..823f11ce971 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/DictionaryBasedGroupKeyGenerator.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/DictionaryBasedGroupKeyGenerator.java
@@ -37,7 +37,10 @@ import org.apache.pinot.core.common.BlockValSet;
 import org.apache.pinot.core.operator.BaseProjectOperator;
 import org.apache.pinot.core.operator.ColumnContext;
 import org.apache.pinot.core.operator.blocks.ValueBlock;
+import org.apache.pinot.segment.spi.datasource.DataSource;
 import org.apache.pinot.segment.spi.index.reader.Dictionary;
+import org.roaringbitmap.PeekableIntIterator;
+import org.roaringbitmap.RoaringBitmap;
 
 
 /// Class for generating group keys (groupId-stringKey pair) for a given list of dictionary encoded group-by columns.
@@ -46,6 +49,12 @@ import org.apache.pinot.segment.spi.index.reader.Dictionary;
 ///
 /// The raw key is generated from the dictionary ids of the group-by columns.
 ///
+/// With null handling enabled, a column that can produce a null reserves one further dictionary id, one past its
+/// last real id, to stand for it, so its cardinality counts one more than the dictionary holds. A null then composes
+/// into the raw key like any other value and is read back out as SQL `NULL`. A column read from a segment that tracks
+/// no nulls reserves nothing, and neither does any column when null handling is disabled, which leaves the
+/// cardinalities, the raw key arithmetic and the block reads exactly as they are without this.
+///
 /// - If the maximum number of possible group keys is less than a threshold (10K), directly use the raw key as the
 ///   group id. (ARRAY_BASED)
 /// - If the maximum number of possible group keys is larger than the threshold, but still fit into integer, generate
@@ -83,12 +92,22 @@ public class DictionaryBasedGroupKeyGenerator implements GroupKeyGenerator {
   private final int[] _cardinalities;
   private final boolean[] _isSingleValueColumn;
   private final Dictionary[] _dictionaries;
-
+  // Dictionary id standing for a null value in each group-by column, or -1 for a column that produces none. Reserving
+  // the id one past the column's last real id is what keeps nulls apart from the column's default null value, which is
+  // the value a null row is physically stored as. Null when no column reserves one at all.
+  @Nullable
+  private final int[] _nullDictIds;
   // The first dimension is the index of group-by column
   // Reusable buffer for single-value column dictionary ids
   private final int[][] _singleValueDictIds;
+  // Reusable buffers holding a block's dictionary ids with the null rows rewritten, filled in on the first block of
+  // a column that actually contains nulls, and null whenever no column reserves a null dictionary id
+  @Nullable
+  private final int[][] _nullReplacedSingleValueDictIds;
   // Reusable buffer for multi-value column dictionary ids
   private final int[][][] _multiValueDictIds;
+  @Nullable
+  private final int[][][] _nullReplacedMultiValueDictIds;
 
   private final Object[][] _internedDictionaryValues;
 
@@ -97,7 +116,7 @@ public class DictionaryBasedGroupKeyGenerator implements GroupKeyGenerator {
 
   public DictionaryBasedGroupKeyGenerator(BaseProjectOperator<?> projectOperator,
       ExpressionContext[] groupByExpressions, int numGroupsLimit, int arrayBasedThreshold,
-      @Nullable Map<ExpressionContext, Integer> groupByExpressionSizesFromPredicates) {
+      boolean nullHandlingEnabled, @Nullable Map<ExpressionContext, Integer> groupByExpressionSizesFromPredicates) {
     _groupByExpressions = groupByExpressions;
     _numGroupByExpressions = groupByExpressions.length;
 
@@ -106,6 +125,8 @@ public class DictionaryBasedGroupKeyGenerator implements GroupKeyGenerator {
     _dictionaries = new Dictionary[_numGroupByExpressions];
     _singleValueDictIds = new int[_numGroupByExpressions][];
     _multiValueDictIds = new int[_numGroupByExpressions][][];
+    int[] nullDictIds = nullHandlingEnabled ? new int[_numGroupByExpressions] : null;
+    boolean anyNullDictId = false;
     // no need to intern dictionary values when there is only one group by expression because
     // only one call will be made to the dictionary to extract each raw value.
     _internedDictionaryValues = _numGroupByExpressions > 1 ? new Object[_numGroupByExpressions][] : null;
@@ -118,6 +139,24 @@ public class DictionaryBasedGroupKeyGenerator implements GroupKeyGenerator {
       _dictionaries[i] = columnContext.getDictionary();
       assert _dictionaries[i] != null;
       int cardinality = _dictionaries[i].length();
+      if (nullDictIds != null) {
+        // Reserve an id unless the column is known to produce no null. A column read straight from a segment says so
+        // through its null value vector, which is the same vector a query reads nulls from. A transform exposes no
+        // data source to consult and can still hand out a null bitmap of its own, so it always reserves one.
+        //
+        // This decides nullability once, from one project operator, while a shared generator can be handed blocks
+        // from others: FilteredGroupByOperator builds a single generator for every filtered aggregation. Those
+        // operators must therefore agree on which group-by columns can produce a null. They do today, because a
+        // star-tree is only chosen for a null handling query when no column it touches holds a null.
+        DataSource dataSource = columnContext.getDataSource();
+        boolean tracksNulls = dataSource == null || dataSource.getNullValueVector() != null;
+        if (tracksNulls) {
+          nullDictIds[i] = cardinality++;
+          anyNullDictId = true;
+        } else {
+          nullDictIds[i] = -1;
+        }
+      }
       _cardinalities[i] = cardinality;
       cardinalityMap.put(groupByExpression, cardinality);
       if (_internedDictionaryValues != null && cardinality < MAX_DICTIONARY_INTERN_TABLE_SIZE) {
@@ -132,6 +171,15 @@ public class DictionaryBasedGroupKeyGenerator implements GroupKeyGenerator {
       }
       _isSingleValueColumn[i] = columnContext.isSingleValue();
     }
+    if (anyNullDictId) {
+      _nullDictIds = nullDictIds;
+      _nullReplacedSingleValueDictIds = new int[_numGroupByExpressions][];
+      _nullReplacedMultiValueDictIds = new int[_numGroupByExpressions][][];
+    } else {
+      _nullDictIds = null;
+      _nullReplacedSingleValueDictIds = null;
+      _nullReplacedMultiValueDictIds = null;
+    }
     if (groupByExpressionSizesFromPredicates != null) {
       Pair<Boolean, Long> optimizedCardinality = getOptimizedGroupByCardinality(groupByExpressionSizesFromPredicates,
           cardinalityMap);
@@ -216,25 +264,89 @@ public class DictionaryBasedGroupKeyGenerator implements GroupKeyGenerator {
   @Override
   public void generateKeysForBlock(ValueBlock valueBlock, int[] groupKeys) {
     // Fetch dictionary ids in the given block for all group-by columns
+    int numDocs = valueBlock.getNumDocs();
     for (int i = 0; i < _numGroupByExpressions; i++) {
-      BlockValSet blockValueSet = valueBlock.getBlockValueSet(_groupByExpressions[i]);
-      _singleValueDictIds[i] = blockValueSet.getDictionaryIdsSV();
+      _singleValueDictIds[i] = getSingleValueDictIds(valueBlock.getBlockValueSet(_groupByExpressions[i]), i, numDocs);
     }
-    _rawKeyHolder.processSingleValue(valueBlock.getNumDocs(), groupKeys);
+    _rawKeyHolder.processSingleValue(numDocs, groupKeys);
   }
 
   @Override
   public void generateKeysForBlock(ValueBlock valueBlock, int[][] groupKeys) {
     // Fetch dictionary ids in the given block for all group-by columns
+    int numDocs = valueBlock.getNumDocs();
     for (int i = 0; i < _numGroupByExpressions; i++) {
       BlockValSet blockValueSet = valueBlock.getBlockValueSet(_groupByExpressions[i]);
       if (_isSingleValueColumn[i]) {
-        _singleValueDictIds[i] = blockValueSet.getDictionaryIdsSV();
+        _singleValueDictIds[i] = getSingleValueDictIds(blockValueSet, i, numDocs);
       } else {
-        _multiValueDictIds[i] = blockValueSet.getDictionaryIdsMV();
+        _multiValueDictIds[i] = getMultiValueDictIds(blockValueSet, i, numDocs);
       }
     }
-    _rawKeyHolder.processMultiValue(valueBlock.getNumDocs(), groupKeys);
+    _rawKeyHolder.processMultiValue(numDocs, groupKeys);
+  }
+
+  /// Returns the block's single-value dictionary ids, with every null row moved onto the column's reserved null
+  /// dictionary id so that nulls form one group of their own.
+  ///
+  /// The block owns the array it hands out and shares it with everything else reading that column in this block, so
+  /// the ids are copied before being rewritten rather than replaced in place. Returns the block's own array untouched
+  /// when the column reserves no null dictionary id or the block holds no null.
+  private int[] getSingleValueDictIds(BlockValSet blockValSet, int index, int numDocs) {
+    int[] dictIds = blockValSet.getDictionaryIdsSV();
+    if (_nullDictIds == null || _nullDictIds[index] < 0) {
+      return dictIds;
+    }
+    int nullDictId = _nullDictIds[index];
+    RoaringBitmap nullBitmap = blockValSet.getNullBitmap();
+    if (nullBitmap == null || nullBitmap.isEmpty()) {
+      return dictIds;
+    }
+    assert _nullReplacedSingleValueDictIds != null;
+    int[] nullReplacedDictIds = _nullReplacedSingleValueDictIds[index];
+    if (nullReplacedDictIds == null || nullReplacedDictIds.length < numDocs) {
+      nullReplacedDictIds = new int[numDocs];
+      _nullReplacedSingleValueDictIds[index] = nullReplacedDictIds;
+    }
+    System.arraycopy(dictIds, 0, nullReplacedDictIds, 0, numDocs);
+    PeekableIntIterator nullIterator = nullBitmap.getIntIterator();
+    while (nullIterator.hasNext()) {
+      nullReplacedDictIds[nullIterator.next()] = nullDictId;
+    }
+    return nullReplacedDictIds;
+  }
+
+  /// Returns the block's multi-value dictionary ids, with every null row replaced by a single entry holding the
+  /// column's reserved null dictionary id, so the row contributes one null group instead of being read as the
+  /// column's default null value.
+  ///
+  /// Only the outer array is copied: the rows that are not null are handed on pointing at the arrays the block
+  /// already gave out. Returns the block's own array untouched when the column reserves no null dictionary id or the
+  /// block holds no null.
+  private int[][] getMultiValueDictIds(BlockValSet blockValSet, int index, int numDocs) {
+    int[][] dictIds = blockValSet.getDictionaryIdsMV();
+    if (_nullDictIds == null || _nullDictIds[index] < 0) {
+      return dictIds;
+    }
+    int nullDictId = _nullDictIds[index];
+    RoaringBitmap nullBitmap = blockValSet.getNullBitmap();
+    if (nullBitmap == null || nullBitmap.isEmpty()) {
+      return dictIds;
+    }
+    assert _nullReplacedMultiValueDictIds != null;
+    int[][] nullReplacedDictIds = _nullReplacedMultiValueDictIds[index];
+    if (nullReplacedDictIds == null || nullReplacedDictIds.length < numDocs) {
+      nullReplacedDictIds = new int[numDocs][];
+      _nullReplacedMultiValueDictIds[index] = nullReplacedDictIds;
+    }
+    System.arraycopy(dictIds, 0, nullReplacedDictIds, 0, numDocs);
+    PeekableIntIterator nullIterator = nullBitmap.getIntIterator();
+    while (nullIterator.hasNext()) {
+      // A fresh array per row rather than one shared between them: a raw key holder writes its group ids back over
+      // the array it is handed for a row whose only group-by column is this one
+      nullReplacedDictIds[nullIterator.next()] = new int[]{nullDictId};
+    }
+    return nullReplacedDictIds;
   }
 
   @Override
@@ -589,7 +701,9 @@ public class DictionaryBasedGroupKeyGenerator implements GroupKeyGenerator {
   private Object[] getKeys(int rawKey) {
     // Specialize single group-by column case
     if (_numGroupByExpressions == 1) {
-      return new Object[]{_dictionaries[0].getInternal(rawKey)};
+      return new Object[]{
+          _nullDictIds == null || rawKey != _nullDictIds[0] ? _dictionaries[0].getInternal(rawKey) : null
+      };
     } else {
       Object[] groupKeys = new Object[_numGroupByExpressions];
       for (int i = 0; i < _numGroupByExpressions; i++) {
@@ -601,7 +715,11 @@ public class DictionaryBasedGroupKeyGenerator implements GroupKeyGenerator {
     }
   }
 
+  @Nullable
   private Object getRawValue(int dictionaryIndex, int dictId) {
+    if (_nullDictIds != null && dictId == _nullDictIds[dictionaryIndex]) {
+      return null;
+    }
     Dictionary dictionary = _dictionaries[dictionaryIndex];
     Object[] table = _internedDictionaryValues[dictionaryIndex];
     if (table == null) {
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/GroupingSetsGroupKeyGenerator.java b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/GroupingSetsGroupKeyGenerator.java
index b01db55b01f..680e958ab4c 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/GroupingSetsGroupKeyGenerator.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/GroupingSetsGroupKeyGenerator.java
@@ -34,6 +34,7 @@ import org.apache.pinot.core.query.aggregation.groupby.utils.ValueToIdMapFactory
 import org.apache.pinot.spi.data.FieldSpec.DataType;
 import org.apache.pinot.spi.utils.ByteArray;
 import org.apache.pinot.spi.utils.FixedIntArray;
+import org.roaringbitmap.PeekableIntIterator;
 import org.roaringbitmap.RoaringBitmap;
 
 
@@ -86,6 +87,8 @@ public class GroupingSetsGroupKeyGenerator implements GroupKeyGenerator {
   /// Reusable single-element id list for a rolled-up (non-participating) column on the MV path. Per-instance
   /// (segment-confined, never shared across query threads) and treated as read-only: expandGroupIds copies its
   /// element into the composite key and never mutates it.
+  /// Stands for a NULL key component, whether because the grouping set excludes the column or because the row's
+  /// value is NULL. Shared by every row and set: the key expansion reads it and never writes to it.
   private final int[] _nullComponent = {ID_FOR_NULL};
 
   public GroupingSetsGroupKeyGenerator(BaseProjectOperator<?> projectOperator,
@@ -281,6 +284,17 @@ public class GroupingSetsGroupKeyGenerator implements GroupKeyGenerator {
         throw new IllegalArgumentException(
             "Illegal multi-value data type for grouping-sets group key generator: " + _storedTypes[col]);
     }
+    if (_nullHandlingEnabled) {
+      // A null row's values were resolved above and are discarded here. Only the composed key decides a group, so the
+      // ids they took in the on-the-fly dictionary go unused rather than becoming groups of their own.
+      RoaringBitmap nullBitmap = blockValSet.getNullBitmap();
+      if (nullBitmap != null && !nullBitmap.isEmpty()) {
+        PeekableIntIterator nullIterator = nullBitmap.getIntIterator();
+        while (nullIterator.hasNext()) {
+          ids[nullIterator.next()] = _nullComponent;
+        }
+      }
+    }
     return ids;
   }
 
@@ -291,6 +305,9 @@ public class GroupingSetsGroupKeyGenerator implements GroupKeyGenerator {
     BlockValSet blockValSet = valueBlock.getBlockValueSet(_groupByExpressions[col]);
     ValueToIdMap dictionary = _onTheFlyDictionaries[col];
     RoaringBitmap nullBitmap = _nullHandlingEnabled ? blockValSet.getNullBitmap() : null;
+    if (nullBitmap != null && nullBitmap.isEmpty()) {
+      nullBitmap = null;
+    }
     switch (_storedTypes[col]) {
       case INT:
         int[] intValues = blockValSet.getIntValuesSV();
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/NoDictionaryMultiColumnGroupKeyGenerator.java b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/NoDictionaryMultiColumnGroupKeyGenerator.java
index a10de9f47a5..1b6cf547d43 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/NoDictionaryMultiColumnGroupKeyGenerator.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/NoDictionaryMultiColumnGroupKeyGenerator.java
@@ -25,6 +25,7 @@ import it.unimi.dsi.fastutil.objects.ObjectIterator;
 import java.util.Arrays;
 import java.util.Iterator;
 import java.util.Map;
+import javax.annotation.Nullable;
 import org.apache.pinot.common.request.context.ExpressionContext;
 import org.apache.pinot.core.common.BlockValSet;
 import org.apache.pinot.core.operator.BaseProjectOperator;
@@ -36,18 +37,23 @@ import org.apache.pinot.segment.spi.index.reader.Dictionary;
 import org.apache.pinot.spi.data.FieldSpec.DataType;
 import org.apache.pinot.spi.utils.ByteArray;
 import org.apache.pinot.spi.utils.FixedIntArray;
+import org.roaringbitmap.PeekableIntIterator;
 import org.roaringbitmap.RoaringBitmap;
 
 
-/// Implementation of [GroupKeyGenerator] interface using actual value based
-/// group keys, instead of dictionary ids. This implementation is used for group-by key
-/// generation when one or more of the group-by columns do not have dictionary.
+/// Implementation of [GroupKeyGenerator] interface using actual value based group keys, instead of dictionary ids.
+/// This implementation is used for group-by key generation when one or more of the group-by columns do not have
+/// dictionary.
 ///
-/// TODO:
-/// 1. Add support for multi-valued group-by columns.
-/// 2. Add support for trimming group-by results.
+/// With null handling enabled, a null row's column contributes a reserved id to the composed key instead of the id of
+/// its stored value, so nulls form groups of their own and are read back as SQL `NULL`. Both the single-value and the
+/// multi-value key paths recognize nulls. Nullness is read from the block's null bitmap rather than from the id, so a
+/// dictionary-encoded column beside a raw one still composes its own dictionary ids instead of paying for an
+/// on-the-fly dictionary.
 public class NoDictionaryMultiColumnGroupKeyGenerator implements GroupKeyGenerator {
   private static final int ID_FOR_NULL = INVALID_ID - 1;
+  /// Shared by every null row and column: the key composition reads these arrays and never writes to them.
+  private static final int[] NULL_KEY_COMPONENT = {ID_FOR_NULL};
 
   private final ExpressionContext[] _groupByExpressions;
   private final int _numGroupByExpressions;
@@ -79,8 +85,7 @@ public class NoDictionaryMultiColumnGroupKeyGenerator implements GroupKeyGenerat
       // Take the dict-id path only when the forward index is dict-encoded. A column with EncodingType.RAW +
       // dictionaryIndex exposes a Dictionary but BlockValSet#getDictionaryIdsSV throws on its RAW forward
       // index — fall back to an on-the-fly dictionary on raw values for that case.
-      Dictionary dictionary = _nullHandlingEnabled || !columnContext.isDictionaryEncoded() ? null
-          : columnContext.getDictionary();
+      Dictionary dictionary = columnContext.isDictionaryEncoded() ? columnContext.getDictionary() : null;
       if (dictionary != null) {
         _dictionaries[i] = dictionary;
       } else {
@@ -148,125 +153,59 @@ public class NoDictionaryMultiColumnGroupKeyGenerator implements GroupKeyGenerat
         }
       }
     }
-    int[] keyValues = new int[_numGroupByExpressions];
-    // note that we are mutating its backing array for memory efficiency
-    FixedIntArray flyweightKey = new FixedIntArray(keyValues);
+    // All null when null handling is disabled, so the per-column check below never matches. The check stays inside
+    // the row loop rather than forking the loop per mode: every iteration pays for a hash-map operation, which dwarfs
+    // a predicted branch.
+    RoaringBitmap[] nullBitmaps = new RoaringBitmap[_numGroupByExpressions];
     if (_nullHandlingEnabled) {
-      RoaringBitmap[] nullBitmaps = new RoaringBitmap[_numGroupByExpressions];
       for (int i = 0; i < _numGroupByExpressions; i++) {
-        nullBitmaps[i] = valueBlock.getBlockValueSet(_groupByExpressions[i]).getNullBitmap();
+        RoaringBitmap nullBitmap = valueBlock.getBlockValueSet(_groupByExpressions[i]).getNullBitmap();
+        // Normalize an empty bitmap to null so the per-row check is a plain null comparison
+        nullBitmaps[i] = nullBitmap != null && !nullBitmap.isEmpty() ? nullBitmap : null;
       }
-      for (int row = 0; row < numDocs; row++) {
-        int numGroups = _groupKeyMap.size();
-        boolean hasInvalidKeyValue = false;
-        if (numGroups < _numGroupsLimit) {
-          for (int col = 0; col < _numGroupByExpressions; col++) {
-            if (nullBitmaps[col] != null && nullBitmaps[col].contains(row)) {
-              keyValues[col] = ID_FOR_NULL;
-            } else {
-              Object columnValues = values[col];
-              ValueToIdMap onTheFlyDictionary = _onTheFlyDictionaries[col];
-              int keyValue;
-              if (columnValues instanceof int[]) {
-                keyValue = onTheFlyDictionary.put(((int[]) columnValues)[row]);
-              } else if (columnValues instanceof long[]) {
-                keyValue = onTheFlyDictionary.put(((long[]) columnValues)[row]);
-              } else if (columnValues instanceof float[]) {
-                keyValue = onTheFlyDictionary.put(((float[]) columnValues)[row]);
-              } else if (columnValues instanceof double[]) {
-                keyValue = onTheFlyDictionary.put(((double[]) columnValues)[row]);
-              } else if (columnValues instanceof byte[][]) {
-                keyValue = onTheFlyDictionary.put(new ByteArray(((byte[][]) columnValues)[row]));
-              } else {
-                keyValue = onTheFlyDictionary.put(((Object[]) columnValues)[row]);
-              }
-              keyValues[col] = keyValue;
-            }
-          }
-        } else {
-          for (int col = 0; col < _numGroupByExpressions; col++) {
-            if (nullBitmaps[col] != null && nullBitmaps[col].contains(row)) {
-              keyValues[col] = ID_FOR_NULL;
-            } else {
-              Object columnValues = values[col];
-              ValueToIdMap onTheFlyDictionary = _onTheFlyDictionaries[col];
-              int keyValue;
-              if (columnValues instanceof int[]) {
-                keyValue = onTheFlyDictionary.getId(((int[]) columnValues)[row]);
-              } else if (columnValues instanceof long[]) {
-                keyValue = onTheFlyDictionary.getId(((long[]) columnValues)[row]);
-              } else if (columnValues instanceof float[]) {
-                keyValue = onTheFlyDictionary.getId(((float[]) columnValues)[row]);
-              } else if (columnValues instanceof double[]) {
-                keyValue = onTheFlyDictionary.getId(((double[]) columnValues)[row]);
-              } else if (columnValues instanceof byte[][]) {
-                keyValue = onTheFlyDictionary.getId(new ByteArray(((byte[][]) columnValues)[row]));
-              } else {
-                keyValue = onTheFlyDictionary.getId(((Object[]) columnValues)[row]);
-              }
-              if (keyValue == INVALID_ID) {
-                hasInvalidKeyValue = true;
-                break;
-              }
-            }
-          }
-        }
-        if (hasInvalidKeyValue) {
-          groupKeys[row] = INVALID_ID;
-        } else {
-          int groupId = getGroupIdForKey(flyweightKey);
-          if (groupId == numGroups) {
-            // When a new group is added, create a new FixedIntArray
-            keyValues = new int[_numGroupByExpressions];
-            flyweightKey = new FixedIntArray(keyValues);
-          }
-          groupKeys[row] = groupId;
-        }
-      }
-    } else {
-      for (int row = 0; row < numDocs; row++) {
-        int numGroups = _groupKeyMap.size();
-        boolean hasInvalidKeyValue = false;
-        if (numGroups < _numGroupsLimit) {
-          for (int col = 0; col < _numGroupByExpressions; col++) {
+    }
+    int[] keyValues = new int[_numGroupByExpressions];
+    // note that we are mutating its backing array for memory efficiency
+    FixedIntArray flyweightKey = new FixedIntArray(keyValues);
+    for (int row = 0; row < numDocs; row++) {
+      int numGroups = _groupKeyMap.size();
+      boolean hasInvalidKeyValue = false;
+      if (numGroups < _numGroupsLimit) {
+        for (int col = 0; col < _numGroupByExpressions; col++) {
+          if (isNull(nullBitmaps[col], row)) {
+            keyValues[col] = ID_FOR_NULL;
+          } else {
             Object columnValues = values[col];
             ValueToIdMap onTheFlyDictionary = _onTheFlyDictionaries[col];
-            int keyValue;
-            if (columnValues instanceof int[]) {
-              int columnValue = ((int[]) columnValues)[row];
-              keyValue = onTheFlyDictionary != null ? onTheFlyDictionary.put(columnValue) : columnValue;
-            } else if (columnValues instanceof long[]) {
-              keyValue = onTheFlyDictionary.put(((long[]) columnValues)[row]);
-            } else if (columnValues instanceof float[]) {
-              keyValue = onTheFlyDictionary.put(((float[]) columnValues)[row]);
-            } else if (columnValues instanceof double[]) {
-              keyValue = onTheFlyDictionary.put(((double[]) columnValues)[row]);
-            } else if (columnValues instanceof byte[][]) {
-              keyValue = onTheFlyDictionary.put(new ByteArray(((byte[][]) columnValues)[row]));
-            } else {
-              keyValue = onTheFlyDictionary.put(((Object[]) columnValues)[row]);
-            }
-            keyValues[col] = keyValue;
+            keyValues[col] = switch (columnValues) {
+              // A dictionary-encoded column has no on-the-fly dictionary: its values are dictionary ids already, used
+              // as key values directly
+              case int[] ints -> onTheFlyDictionary != null ? onTheFlyDictionary.put(ints[row]) : ints[row];
+              case long[] longs -> onTheFlyDictionary.put(longs[row]);
+              case float[] floats -> onTheFlyDictionary.put(floats[row]);
+              case double[] doubles -> onTheFlyDictionary.put(doubles[row]);
+              case byte[][] bytes -> onTheFlyDictionary.put(new ByteArray(bytes[row]));
+              default -> onTheFlyDictionary.put(((Object[]) columnValues)[row]);
+            };
           }
-        } else {
-          for (int col = 0; col < _numGroupByExpressions; col++) {
+        }
+      } else {
+        for (int col = 0; col < _numGroupByExpressions; col++) {
+          if (isNull(nullBitmaps[col], row)) {
+            keyValues[col] = ID_FOR_NULL;
+          } else {
             Object columnValues = values[col];
             ValueToIdMap onTheFlyDictionary = _onTheFlyDictionaries[col];
-            int keyValue;
-            if (columnValues instanceof int[]) {
-              int columnValue = ((int[]) columnValues)[row];
-              keyValue = onTheFlyDictionary != null ? onTheFlyDictionary.getId(columnValue) : columnValue;
-            } else if (columnValues instanceof long[]) {
-              keyValue = onTheFlyDictionary.getId(((long[]) columnValues)[row]);
-            } else if (columnValues instanceof float[]) {
-              keyValue = onTheFlyDictionary.getId(((float[]) columnValues)[row]);
-            } else if (columnValues instanceof double[]) {
-              keyValue = onTheFlyDictionary.getId(((double[]) columnValues)[row]);
-            } else if (columnValues instanceof byte[][]) {
-              keyValue = onTheFlyDictionary.getId(new ByteArray(((byte[][]) columnValues)[row]));
-            } else {
-              keyValue = onTheFlyDictionary.getId(((Object[]) columnValues)[row]);
-            }
+            int keyValue = switch (columnValues) {
+              // A dictionary-encoded column has no on-the-fly dictionary: its values are dictionary ids already, used
+              // as key values directly
+              case int[] ints -> onTheFlyDictionary != null ? onTheFlyDictionary.getId(ints[row]) : ints[row];
+              case long[] longs -> onTheFlyDictionary.getId(longs[row]);
+              case float[] floats -> onTheFlyDictionary.getId(floats[row]);
+              case double[] doubles -> onTheFlyDictionary.getId(doubles[row]);
+              case byte[][] bytes -> onTheFlyDictionary.getId(new ByteArray(bytes[row]));
+              default -> onTheFlyDictionary.getId(((Object[]) columnValues)[row]);
+            };
             if (keyValue == INVALID_ID) {
               hasInvalidKeyValue = true;
               break;
@@ -274,21 +213,25 @@ public class NoDictionaryMultiColumnGroupKeyGenerator implements GroupKeyGenerat
             keyValues[col] = keyValue;
           }
         }
-        if (hasInvalidKeyValue) {
-          groupKeys[row] = INVALID_ID;
-        } else {
-          int groupId = getGroupIdForKey(flyweightKey);
-          if (groupId == numGroups) {
-            // When a new group is added, create a new FixedIntArray
-            keyValues = new int[_numGroupByExpressions];
-            flyweightKey = new FixedIntArray(keyValues);
-          }
-          groupKeys[row] = groupId;
+      }
+      if (hasInvalidKeyValue) {
+        groupKeys[row] = INVALID_ID;
+      } else {
+        int groupId = getGroupIdForKey(flyweightKey);
+        if (groupId == numGroups) {
+          // When a new group is added, create a new FixedIntArray
+          keyValues = new int[_numGroupByExpressions];
+          flyweightKey = new FixedIntArray(keyValues);
         }
+        groupKeys[row] = groupId;
       }
     }
   }
 
+  private static boolean isNull(@Nullable RoaringBitmap nullBitmap, int row) {
+    return nullBitmap != null && nullBitmap.contains(row);
+  }
+
   @Override
   public void generateKeysForBlock(ValueBlock valueBlock, int[][] groupKeys) {
     int numDocs = valueBlock.getNumDocs();
@@ -414,6 +357,17 @@ public class NoDictionaryMultiColumnGroupKeyGenerator implements GroupKeyGenerat
           }
         }
       }
+      if (_nullHandlingEnabled) {
+        // A null row's own value was resolved above and is discarded here. Only the composed key decides a group, so
+        // the id it took in the on-the-fly dictionary goes unused rather than becoming a group of its own.
+        RoaringBitmap nullBitmap = blockValSet.getNullBitmap();
+        if (nullBitmap != null && !nullBitmap.isEmpty()) {
+          PeekableIntIterator nullIterator = nullBitmap.getIntIterator();
+          while (nullIterator.hasNext()) {
+            keys[nullIterator.next()][i] = NULL_KEY_COMPONENT;
+          }
+        }
+      }
     }
     for (int i = 0; i < numDocs; i++) {
       groupKeys[i] = getGroupIdsForKeys(keys[i]);
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/NoDictionarySingleColumnGroupKeyGenerator.java b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/NoDictionarySingleColumnGroupKeyGenerator.java
index d6b0bfcce93..98f83c5f846 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/NoDictionarySingleColumnGroupKeyGenerator.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/groupby/NoDictionarySingleColumnGroupKeyGenerator.java
@@ -30,7 +30,6 @@ import it.unimi.dsi.fastutil.objects.Object2IntMap;
 import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap;
 import it.unimi.dsi.fastutil.objects.ObjectIterator;
 import java.math.BigDecimal;
-import java.util.Arrays;
 import java.util.Iterator;
 import java.util.Map;
 import javax.annotation.Nullable;
@@ -44,22 +43,24 @@ import org.apache.pinot.spi.utils.ByteArray;
 import org.roaringbitmap.RoaringBitmap;
 
 
-/// Implementation of [GroupKeyGenerator] interface for single group by column,
-/// in absence of dictionary for the group by column.
+/// Implementation of [GroupKeyGenerator] interface for single group by column, in absence of dictionary for the group
+/// by column.
+///
+/// With null handling enabled, a null row forms a group of its own instead of joining the group of the column's default
+/// null value, which is what a null row is physically stored as. The object-keyed maps hold the null key directly,
+/// while the primitive-keyed maps cannot, so the null group id is tracked beside them. Both the single-value and the
+/// multi-value key paths recognize nulls.
 @SuppressWarnings({"rawtypes", "unchecked"})
 public class NoDictionarySingleColumnGroupKeyGenerator implements GroupKeyGenerator {
   private final ExpressionContext _groupByExpression;
   private final DataType _storedType;
   private final Map _groupKeyMap;
   private final int _globalGroupIdUpperBound;
-  // TODO(nhejazi): Most of the logic between _nullHandlingEnabled=true/false is not sharable, so consider making a
-  //  base implementation, and 2 derived classes, one for null enabled, one for disabled.
   private final boolean _nullHandlingEnabled;
-
-  private Integer _groupIdForNullValue = null;
   private final boolean _isSingleValueExpression;
 
-  private int _numGroups = 0;
+  private Integer _groupIdForNullValue;
+  private int _numGroups;
 
   public NoDictionarySingleColumnGroupKeyGenerator(BaseProjectOperator<?> projectOperator,
       ExpressionContext groupByExpression, int numGroupsLimit, boolean nullHandlingEnabled,
@@ -86,56 +87,53 @@ public class NoDictionarySingleColumnGroupKeyGenerator implements GroupKeyGenera
   @Override
   public void generateKeysForBlock(ValueBlock valueBlock, int[] groupKeys) {
     BlockValSet blockValSet = valueBlock.getBlockValueSet(_groupByExpression);
-    if (_nullHandlingEnabled) {
-      RoaringBitmap nullBitmap = blockValSet.getNullBitmap();
-      if (nullBitmap != null && !nullBitmap.isEmpty()) {
-        generateKeysForBlockNullHandlingEnabled(valueBlock, groupKeys, nullBitmap);
-        return;
-      }
-    }
     int numDocs = valueBlock.getNumDocs();
+    RoaringBitmap nullBitmap = getNullBitmap(blockValSet);
+    // Resolved once rather than per row, and only when the block holds a null, so that reading a block without one
+    // never records a null group
+    int nullKey = nullBitmap != null ? getKeyForNull() : INVALID_ID;
 
     switch (_storedType) {
       case INT:
         int[] intValues = blockValSet.getIntValuesSV();
         for (int i = 0; i < numDocs; i++) {
-          groupKeys[i] = getKeyForValue(intValues[i]);
+          groupKeys[i] = isNull(nullBitmap, i) ? nullKey : getKeyForValue(intValues[i]);
         }
         break;
       case LONG:
         long[] longValues = blockValSet.getLongValuesSV();
         for (int i = 0; i < numDocs; i++) {
-          groupKeys[i] = getKeyForValue(longValues[i]);
+          groupKeys[i] = isNull(nullBitmap, i) ? nullKey : getKeyForValue(longValues[i]);
         }
         break;
       case FLOAT:
         float[] floatValues = blockValSet.getFloatValuesSV();
         for (int i = 0; i < numDocs; i++) {
-          groupKeys[i] = getKeyForValue(floatValues[i]);
+          groupKeys[i] = isNull(nullBitmap, i) ? nullKey : getKeyForValue(floatValues[i]);
         }
         break;
       case DOUBLE:
         double[] doubleValues = blockValSet.getDoubleValuesSV();
         for (int i = 0; i < numDocs; i++) {
-          groupKeys[i] = getKeyForValue(doubleValues[i]);
+          groupKeys[i] = isNull(nullBitmap, i) ? nullKey : getKeyForValue(doubleValues[i]);
         }
         break;
       case BIG_DECIMAL:
         BigDecimal[] bigDecimalValues = blockValSet.getBigDecimalValuesSV();
         for (int i = 0; i < numDocs; i++) {
-          groupKeys[i] = getKeyForValue(bigDecimalValues[i]);
+          groupKeys[i] = isNull(nullBitmap, i) ? nullKey : getKeyForValue(bigDecimalValues[i]);
         }
         break;
       case STRING:
         String[] stringValues = blockValSet.getStringValuesSV();
         for (int i = 0; i < numDocs; i++) {
-          groupKeys[i] = getKeyForValue(stringValues[i]);
+          groupKeys[i] = isNull(nullBitmap, i) ? nullKey : getKeyForValue(stringValues[i]);
         }
         break;
       case BYTES:
         byte[][] bytesValues = blockValSet.getBytesValuesSV();
         for (int i = 0; i < numDocs; i++) {
-          groupKeys[i] = getKeyForValue(new ByteArray(bytesValues[i]));
+          groupKeys[i] = isNull(nullBitmap, i) ? nullKey : getKeyForValue(new ByteArray(bytesValues[i]));
         }
         break;
       default:
@@ -143,86 +141,14 @@ public class NoDictionarySingleColumnGroupKeyGenerator implements GroupKeyGenera
     }
   }
 
-  public void generateKeysForBlockNullHandlingEnabled(ValueBlock valueBlock, int[] groupKeys,
-      RoaringBitmap nullBitmap) {
-    assert nullBitmap != null;
-    BlockValSet blockValSet = valueBlock.getBlockValueSet(_groupByExpression);
-    int numDocs = valueBlock.getNumDocs();
-
-    switch (_storedType) {
-      case INT:
-        int[] intValues = blockValSet.getIntValuesSV();
-        if (nullBitmap.getCardinality() < numDocs) {
-          for (int i = 0; i < numDocs; i++) {
-            groupKeys[i] = nullBitmap.contains(i) ? getKeyForNullValue() : getKeyForValue(intValues[i]);
-          }
-        } else if (numDocs > 0) {
-          Arrays.fill(groupKeys, 0, numDocs, getKeyForNullValue());
-        }
-        break;
-      case LONG:
-        long[] longValues = blockValSet.getLongValuesSV();
-        if (nullBitmap.getCardinality() < numDocs) {
-          for (int i = 0; i < numDocs; i++) {
-            groupKeys[i] = nullBitmap.contains(i) ? getKeyForNullValue() : getKeyForValue(longValues[i]);
-          }
-        } else if (numDocs > 0) {
-          Arrays.fill(groupKeys, 0, numDocs, getKeyForNullValue());
-        }
-        break;
-      case FLOAT:
-        float[] floatValues = blockValSet.getFloatValuesSV();
-        if (nullBitmap.getCardinality() < numDocs) {
-          for (int i = 0; i < numDocs; i++) {
-            groupKeys[i] = nullBitmap.contains(i) ? getKeyForNullValue() : getKeyForValue(floatValues[i]);
-          }
-        } else if (numDocs > 0) {
-          Arrays.fill(groupKeys, 0, numDocs, getKeyForNullValue());
-        }
-        break;
-      case DOUBLE:
-        double[] doubleValues = blockValSet.getDoubleValuesSV();
-        if (nullBitmap.getCardinality() < numDocs) {
-          for (int i = 0; i < numDocs; i++) {
-            groupKeys[i] = nullBitmap.contains(i) ? getKeyForNullValue() : getKeyForValue(doubleValues[i]);
-          }
-        } else if (numDocs > 0) {
-          Arrays.fill(groupKeys, 0, numDocs, getKeyForNullValue());
-        }
-        break;
-      case BIG_DECIMAL:
-        BigDecimal[] bigDecimalValues = blockValSet.getBigDecimalValuesSV();
-        if (nullBitmap.getCardinality() < numDocs) {
-          for (int i = 0; i < numDocs; i++) {
-            groupKeys[i] = getKeyForValue(nullBitmap.contains(i) ? null : bigDecimalValues[i]);
-          }
-        } else if (numDocs > 0) {
-          Arrays.fill(groupKeys, 0, numDocs, getKeyForValue((BigDecimal) null));
-        }
-        break;
-      case STRING:
-        String[] stringValues = blockValSet.getStringValuesSV();
-        if (nullBitmap.getCardinality() < numDocs) {
-          for (int i = 0; i < numDocs; i++) {
-            groupKeys[i] = getKeyForValue(nullBitmap.contains(i) ? null : stringValues[i]);
-          }
-        } else if (numDocs > 0) {
-          Arrays.fill(groupKeys, 0, numDocs, getKeyForValue((String) null));
-        }
-        break;
-      case BYTES:
-        byte[][] bytesValues = blockValSet.getBytesValuesSV();
-        if (nullBitmap.getCardinality() < numDocs) {
-          for (int i = 0; i < numDocs; i++) {
-            groupKeys[i] = getKeyForValue(nullBitmap.contains(i) ? null : new ByteArray(bytesValues[i]));
-          }
-        } else if (numDocs > 0) {
-          Arrays.fill(groupKeys, 0, numDocs, getKeyForValue((ByteArray) null));
-        }
-        break;
-      default:
-        throw new IllegalArgumentException("Illegal data type for no-dictionary key generator: " + _storedType);
+  /// Returns the block's null bitmap, or `null` when null handling is disabled or no row in the block is null.
+  @Nullable
+  private RoaringBitmap getNullBitmap(BlockValSet blockValSet) {
+    if (!_nullHandlingEnabled) {
+      return null;
     }
+    RoaringBitmap nullBitmap = blockValSet.getNullBitmap();
+    return nullBitmap != null && !nullBitmap.isEmpty() ? nullBitmap : null;
   }
 
   /// Helper method to create the group-key map, depending on the data type.
@@ -269,42 +195,70 @@ public class NoDictionarySingleColumnGroupKeyGenerator implements GroupKeyGenera
   public void generateKeysForBlock(ValueBlock valueBlock, int[][] groupKeys) {
     int numDocs = valueBlock.getNumDocs();
     BlockValSet blockValSet = valueBlock.getBlockValueSet(_groupByExpression);
+    RoaringBitmap nullBitmap = getNullBitmap(blockValSet);
+    // Resolved once rather than per row, and only when the block holds a null, so that reading a block without one
+    // never records a null group
+    int nullKey = nullBitmap != null ? getKeyForNull() : INVALID_ID;
 
     if (_isSingleValueExpression) {
       switch (_storedType) {
         case INT:
           int[] intValues = blockValSet.getIntValuesSV();
           for (int i = 0; i < numDocs; i++) {
+            if (isNull(nullBitmap, i)) {
+              groupKeys[i] = new int[]{nullKey};
+              continue;
+            }
             groupKeys[i] = new int[]{getKeyForValue(intValues[i])};
           }
           break;
         case LONG:
           long[] longValues = blockValSet.getLongValuesSV();
           for (int i = 0; i < numDocs; i++) {
+            if (isNull(nullBitmap, i)) {
+              groupKeys[i] = new int[]{nullKey};
+              continue;
+            }
             groupKeys[i] = new int[]{getKeyForValue(longValues[i])};
           }
           break;
         case FLOAT:
           float[] floatValues = blockValSet.getFloatValuesSV();
           for (int i = 0; i < numDocs; i++) {
+            if (isNull(nullBitmap, i)) {
+              groupKeys[i] = new int[]{nullKey};
+              continue;
+            }
             groupKeys[i] = new int[]{getKeyForValue(floatValues[i])};
           }
           break;
         case DOUBLE:
           double[] doubleValues = blockValSet.getDoubleValuesSV();
           for (int i = 0; i < numDocs; i++) {
+            if (isNull(nullBitmap, i)) {
+              groupKeys[i] = new int[]{nullKey};
+              continue;
+            }
             groupKeys[i] = new int[]{getKeyForValue(doubleValues[i])};
           }
           break;
         case STRING:
           String[] stringValues = blockValSet.getStringValuesSV();
           for (int i = 0; i < numDocs; i++) {
+            if (isNull(nullBitmap, i)) {
+              groupKeys[i] = new int[]{nullKey};
+              continue;
+            }
             groupKeys[i] = new int[]{getKeyForValue(stringValues[i])};
           }
           break;
         case BYTES:
           byte[][] byteValues = blockValSet.getBytesValuesSV();
           for (int i = 0; i < numDocs; i++) {
+            if (isNull(nullBitmap, i)) {
+              groupKeys[i] = new int[]{nullKey};
+              continue;
+            }
             groupKeys[i] = new int[]{getKeyForValue(new ByteArray(byteValues[i]))};
           }
           break;
@@ -316,6 +270,10 @@ public class NoDictionarySingleColumnGroupKeyGenerator implements GroupKeyGenera
         case INT:
           int[][] intValues = blockValSet.getIntValuesMV();
           for (int i = 0; i < numDocs; i++) {
+            if (isNull(nullBitmap, i)) {
+              groupKeys[i] = new int[]{nullKey};
+              continue;
+            }
             int mvSize = intValues[i].length;
             int[] mvKeys = new int[mvSize];
             for (int j = 0; j < mvSize; j++) {
@@ -327,6 +285,10 @@ public class NoDictionarySingleColumnGroupKeyGenerator implements GroupKeyGenera
         case LONG:
           long[][] longValues = blockValSet.getLongValuesMV();
           for (int i = 0; i < numDocs; i++) {
+            if (isNull(nullBitmap, i)) {
+              groupKeys[i] = new int[]{nullKey};
+              continue;
+            }
             int mvSize = longValues[i].length;
             int[] mvKeys = new int[mvSize];
             for (int j = 0; j < mvSize; j++) {
@@ -338,6 +300,10 @@ public class NoDictionarySingleColumnGroupKeyGenerator implements GroupKeyGenera
         case FLOAT:
           float[][] floatValues = blockValSet.getFloatValuesMV();
           for (int i = 0; i < numDocs; i++) {
+            if (isNull(nullBitmap, i)) {
+              groupKeys[i] = new int[]{nullKey};
+              continue;
+            }
             int mvSize = floatValues[i].length;
             int[] mvKeys = new int[mvSize];
             for (int j = 0; j < mvSize; j++) {
@@ -349,6 +315,10 @@ public class NoDictionarySingleColumnGroupKeyGenerator implements GroupKeyGenera
         case DOUBLE:
           double[][] doubleValues = blockValSet.getDoubleValuesMV();
           for (int i = 0; i < numDocs; i++) {
+            if (isNull(nullBitmap, i)) {
+              groupKeys[i] = new int[]{nullKey};
+              continue;
+            }
             int mvSize = doubleValues[i].length;
             int[] mvKeys = new int[mvSize];
             for (int j = 0; j < mvSize; j++) {
@@ -360,6 +330,10 @@ public class NoDictionarySingleColumnGroupKeyGenerator implements GroupKeyGenera
         case STRING:
           String[][] stringValues = blockValSet.getStringValuesMV();
           for (int i = 0; i < numDocs; i++) {
+            if (isNull(nullBitmap, i)) {
+              groupKeys[i] = new int[]{nullKey};
+              continue;
+            }
             int mvSize = stringValues[i].length;
             int[] mvKeys = new int[mvSize];
             for (int j = 0; j < mvSize; j++) {
@@ -374,29 +348,38 @@ public class NoDictionarySingleColumnGroupKeyGenerator implements GroupKeyGenera
     }
   }
 
+  /// Returns the group id of a null value. The primitive maps cannot hold a null key so it is tracked beside them,
+  /// while the object maps hold it as a key of their own.
+  private int getKeyForNull() {
+    return switch (_storedType) {
+      case BIG_DECIMAL -> getKeyForValue((BigDecimal) null);
+      case STRING -> getKeyForValue((String) null);
+      case BYTES -> getKeyForValue((ByteArray) null);
+      default -> getKeyForNullValue();
+    };
+  }
+
+  private static boolean isNull(@Nullable RoaringBitmap nullBitmap, int row) {
+    return nullBitmap != null && nullBitmap.contains(row);
+  }
+
+  /// The group id of a null value is handed out beside [#_groupKeyMap] rather than stored in it, because the
+  /// primitive-keyed maps cannot hold a null key, so the group count has to come from the id counter itself.
   @Override
   public int getCurrentGroupKeyUpperBound() {
-    return _groupKeyMap.size();
+    return _numGroups;
   }
 
   @Override
   public Iterator<GroupKey> getGroupKeys() {
-    switch (_storedType) {
-      case INT:
-        return new IntGroupKeyIterator((Int2IntOpenHashMap) _groupKeyMap, _groupIdForNullValue);
-      case LONG:
-        return new LongGroupKeyIterator((Long2IntOpenHashMap) _groupKeyMap, _groupIdForNullValue);
-      case FLOAT:
-        return new FloatGroupKeyIterator((Float2IntOpenHashMap) _groupKeyMap, _groupIdForNullValue);
-      case DOUBLE:
-        return new DoubleGroupKeyIterator((Double2IntOpenHashMap) _groupKeyMap, _groupIdForNullValue);
-      case BIG_DECIMAL:
-      case STRING:
-      case BYTES:
-        return new ObjectGroupKeyIterator((Object2IntOpenHashMap) _groupKeyMap);
-      default:
-        throw new IllegalStateException();
-    }
+    return switch (_storedType) {
+      case INT -> new IntGroupKeyIterator((Int2IntOpenHashMap) _groupKeyMap, _groupIdForNullValue);
+      case LONG -> new LongGroupKeyIterator((Long2IntOpenHashMap) _groupKeyMap, _groupIdForNullValue);
+      case FLOAT -> new FloatGroupKeyIterator((Float2IntOpenHashMap) _groupKeyMap, _groupIdForNullValue);
+      case DOUBLE -> new DoubleGroupKeyIterator((Double2IntOpenHashMap) _groupKeyMap, _groupIdForNullValue);
+      case BIG_DECIMAL, STRING, BYTES -> new ObjectGroupKeyIterator((Object2IntOpenHashMap) _groupKeyMap);
+      default -> throw new IllegalStateException();
+    };
   }
 
   private int getKeyForNullValue() {
@@ -412,7 +395,7 @@ public class NoDictionarySingleColumnGroupKeyGenerator implements GroupKeyGenera
 
   @Override
   public int getNumKeys() {
-    return _groupKeyMap.size();
+    return _numGroups;
   }
 
   private int getKeyForValue(int value) {
@@ -503,7 +486,6 @@ public class NoDictionarySingleColumnGroupKeyGenerator implements GroupKeyGenera
 
     @Override
     public GroupKey next() {
-      // TODO(nhejazi): revisit to avoid adding overhead to the regular case where null handling is not enabled.
       if (_groupKeyForNullValue != null) {
         _groupKey._groupId = _groupKeyForNullValue;
         _groupKey._keys = new Object[]{null};
diff --git a/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/groupby/DictionaryBasedGroupKeyGeneratorTest.java b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/groupby/DictionaryBasedGroupKeyGeneratorTest.java
index 5b460b22a4e..f77711f320e 100644
--- a/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/groupby/DictionaryBasedGroupKeyGeneratorTest.java
+++ b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/groupby/DictionaryBasedGroupKeyGeneratorTest.java
@@ -58,6 +58,7 @@ import org.testng.annotations.Test;
 
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNull;
 import static org.testng.Assert.assertTrue;
 
 
@@ -74,6 +75,8 @@ public class DictionaryBasedGroupKeyGeneratorTest {
   private static final int[][] MV_GROUP_KEY_BUFFER = new int[NUM_GROUPS][];
   private static final String FILTER_COLUMN = "docId";
   private static final String[] SV_COLUMNS = {"s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9", "s10"};
+  /// Null in every row, so the only dictionary id it contributes to a group key is the reserved null id.
+  private static final String NULL_COLUMN = "n1";
   private static final String[] MV_COLUMNS = {"m1", "m2"};
 
   private final long _randomSeed = System.currentTimeMillis();
@@ -95,6 +98,7 @@ public class DictionaryBasedGroupKeyGeneratorTest {
     for (int i = 0; i < UNIQUE_ROWS; i++) {
       GenericRow row = new GenericRow();
       row.putValue(FILTER_COLUMN, i);
+      row.putValue(NULL_COLUMN, null);
       for (String svColumn : SV_COLUMNS) {
         row.putValue(svColumn, value);
         value += 1 + _random.nextInt(MAX_STEP_LENGTH);
@@ -117,6 +121,7 @@ public class DictionaryBasedGroupKeyGeneratorTest {
     // Create an index segment with the random values
     Schema schema = new Schema();
     schema.addField(new DimensionFieldSpec(FILTER_COLUMN, DataType.INT, true));
+    schema.addField(new DimensionFieldSpec(NULL_COLUMN, DataType.INT, true));
     for (String singleValueColumn : SV_COLUMNS) {
       schema.addField(new DimensionFieldSpec(singleValueColumn, DataType.INT, true));
     }
@@ -128,6 +133,8 @@ public class DictionaryBasedGroupKeyGeneratorTest {
 
     SegmentGeneratorConfig config = new SegmentGeneratorConfig(tableConfig, schema);
     config.setOutDir(INDEX_DIR_PATH);
+    // Writes the null value vector that a generator reads a column's nullability from
+    config.setDefaultNullHandlingEnabled(true);
     config.setSegmentName(SEGMENT_NAME);
 
     SegmentIndexCreationDriverImpl driver = new SegmentIndexCreationDriverImpl();
@@ -140,11 +147,12 @@ public class DictionaryBasedGroupKeyGeneratorTest {
     int docId2 = docId1 + 1 + _random.nextInt(50);
     // NOTE: put all columns into group-by so that transform operator has expressions for all columns
     String query =
-        String.format("SELECT COUNT(*) FROM testTable WHERE %s IN (%d, %d) GROUP BY %s, %s", FILTER_COLUMN, docId1,
-            docId2, StringUtils.join(SV_COLUMNS, ", "), StringUtils.join(MV_COLUMNS, ", "));
+        String.format("SELECT COUNT(*) FROM testTable WHERE %s IN (%d, %d) GROUP BY %s, %s, %s", FILTER_COLUMN,
+            docId1, docId2, NULL_COLUMN, StringUtils.join(SV_COLUMNS, ", "), StringUtils.join(MV_COLUMNS, ", "));
     QueryContext queryContext = QueryContextConverterUtils.getQueryContext(query);
 
     List<ExpressionContext> expressions = new ArrayList<>();
+    expressions.add(ExpressionContext.forIdentifier(NULL_COLUMN));
     for (String column : SV_COLUMNS) {
       expressions.add(ExpressionContext.forIdentifier(column));
     }
@@ -166,7 +174,7 @@ public class DictionaryBasedGroupKeyGeneratorTest {
     DictionaryBasedGroupKeyGenerator dictionaryBasedGroupKeyGenerator =
         new DictionaryBasedGroupKeyGenerator(_projectOperator, getExpressions(groupByColumns),
             Server.DEFAULT_QUERY_EXECUTOR_NUM_GROUPS_LIMIT,
-            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, null);
+            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, false, null);
     assertEquals(dictionaryBasedGroupKeyGenerator.getGlobalGroupKeyUpperBound(), UNIQUE_ROWS, _errorMessage);
     assertEquals(dictionaryBasedGroupKeyGenerator.getCurrentGroupKeyUpperBound(), UNIQUE_ROWS, _errorMessage);
 
@@ -175,6 +183,8 @@ public class DictionaryBasedGroupKeyGeneratorTest {
     assertEquals(dictionaryBasedGroupKeyGenerator.getCurrentGroupKeyUpperBound(), UNIQUE_ROWS, _errorMessage);
     compareSingleValueBuffer();
     testGetGroupKeys(dictionaryBasedGroupKeyGenerator.getGroupKeys(), 2);
+
+    dictionaryBasedGroupKeyGenerator.close();
   }
 
   @Test
@@ -186,7 +196,7 @@ public class DictionaryBasedGroupKeyGeneratorTest {
     DictionaryBasedGroupKeyGenerator dictionaryBasedGroupKeyGenerator =
         new DictionaryBasedGroupKeyGenerator(_projectOperator, getExpressions(groupByColumns),
             Server.DEFAULT_QUERY_EXECUTOR_NUM_GROUPS_LIMIT,
-            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, null);
+            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, false, null);
     assertEquals(dictionaryBasedGroupKeyGenerator.getGlobalGroupKeyUpperBound(),
         Server.DEFAULT_QUERY_EXECUTOR_NUM_GROUPS_LIMIT, _errorMessage);
     assertEquals(dictionaryBasedGroupKeyGenerator.getCurrentGroupKeyUpperBound(), 0, _errorMessage);
@@ -211,7 +221,7 @@ public class DictionaryBasedGroupKeyGeneratorTest {
     DictionaryBasedGroupKeyGenerator dictionaryBasedGroupKeyGenerator =
         new DictionaryBasedGroupKeyGenerator(_projectOperator, getExpressions(groupByColumns),
             Server.DEFAULT_QUERY_EXECUTOR_NUM_GROUPS_LIMIT,
-            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, null);
+            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, false, null);
     assertEquals(dictionaryBasedGroupKeyGenerator.getGlobalGroupKeyUpperBound(),
         Server.DEFAULT_QUERY_EXECUTOR_NUM_GROUPS_LIMIT, _errorMessage);
     assertEquals(dictionaryBasedGroupKeyGenerator.getCurrentGroupKeyUpperBound(), 0, _errorMessage);
@@ -236,7 +246,7 @@ public class DictionaryBasedGroupKeyGeneratorTest {
     DictionaryBasedGroupKeyGenerator dictionaryBasedGroupKeyGenerator =
         new DictionaryBasedGroupKeyGenerator(_projectOperator, getExpressions(groupByColumns),
             Server.DEFAULT_QUERY_EXECUTOR_NUM_GROUPS_LIMIT,
-            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, null);
+            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, false, null);
     assertEquals(dictionaryBasedGroupKeyGenerator.getGlobalGroupKeyUpperBound(),
         Server.DEFAULT_QUERY_EXECUTOR_NUM_GROUPS_LIMIT, _errorMessage);
     assertEquals(dictionaryBasedGroupKeyGenerator.getCurrentGroupKeyUpperBound(), 0, _errorMessage);
@@ -264,6 +274,77 @@ public class DictionaryBasedGroupKeyGeneratorTest {
     }
   }
 
+  /// A column that can hold a null reserves one dictionary id beyond the ones its dictionary holds, which is what
+  /// lets a null take a group of its own. Every row of [#NULL_COLUMN] is null, so its dictionary holds the single
+  /// default null value and the reserved id brings the column's cardinality to two.
+  @Test
+  public void testArrayBasedNullHandling() {
+    String[] groupByColumns = {NULL_COLUMN};
+
+    DictionaryBasedGroupKeyGenerator dictionaryBasedGroupKeyGenerator =
+        new DictionaryBasedGroupKeyGenerator(_projectOperator, getExpressions(groupByColumns),
+            Server.DEFAULT_QUERY_EXECUTOR_NUM_GROUPS_LIMIT,
+            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, true, null);
+    assertEquals(dictionaryBasedGroupKeyGenerator.getGlobalGroupKeyUpperBound(), 2, _errorMessage);
+
+    dictionaryBasedGroupKeyGenerator.generateKeysForBlock(_valueBlock, SV_GROUP_KEY_BUFFER);
+    assertNullGroupKeys(dictionaryBasedGroupKeyGenerator.getGroupKeys(), 1);
+    dictionaryBasedGroupKeyGenerator.close();
+  }
+
+  /// The reserved id has to survive being folded into a long raw key alongside other columns' ids.
+  @Test
+  public void testLongMapBasedNullHandling() {
+    // Cardinality product larger than Integer.MAX_VALUE but smaller than Long.MAX_VALUE
+    String[] groupByColumns = {NULL_COLUMN, "s1", "s2", "s3", "s4", "s5"};
+
+    DictionaryBasedGroupKeyGenerator dictionaryBasedGroupKeyGenerator =
+        new DictionaryBasedGroupKeyGenerator(_projectOperator, getExpressions(groupByColumns),
+            Server.DEFAULT_QUERY_EXECUTOR_NUM_GROUPS_LIMIT,
+            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, true, null);
+    assertEquals(dictionaryBasedGroupKeyGenerator.getGlobalGroupKeyUpperBound(),
+        Server.DEFAULT_QUERY_EXECUTOR_NUM_GROUPS_LIMIT, _errorMessage);
+
+    dictionaryBasedGroupKeyGenerator.generateKeysForBlock(_valueBlock, SV_GROUP_KEY_BUFFER);
+    assertNullGroupKeys(dictionaryBasedGroupKeyGenerator.getGroupKeys(), 2);
+    dictionaryBasedGroupKeyGenerator.close();
+    assertEquals(DictionaryBasedGroupKeyGenerator.THREAD_LOCAL_LONG_MAP.get().size(), 0);
+  }
+
+  /// The same, for the holder that keeps the ids as an array because their product does not fit into a long.
+  @Test
+  public void testArrayMapBasedNullHandling() {
+    // Cardinality product larger than Long.MAX_VALUE
+    String[] groupByColumns = {NULL_COLUMN, "s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9", "s10"};
+
+    DictionaryBasedGroupKeyGenerator dictionaryBasedGroupKeyGenerator =
+        new DictionaryBasedGroupKeyGenerator(_projectOperator, getExpressions(groupByColumns),
+            Server.DEFAULT_QUERY_EXECUTOR_NUM_GROUPS_LIMIT,
+            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, true, null);
+    assertEquals(dictionaryBasedGroupKeyGenerator.getGlobalGroupKeyUpperBound(),
+        Server.DEFAULT_QUERY_EXECUTOR_NUM_GROUPS_LIMIT, _errorMessage);
+
+    dictionaryBasedGroupKeyGenerator.generateKeysForBlock(_valueBlock, SV_GROUP_KEY_BUFFER);
+    assertNullGroupKeys(dictionaryBasedGroupKeyGenerator.getGroupKeys(), 2);
+    dictionaryBasedGroupKeyGenerator.close();
+    assertEquals(DictionaryBasedGroupKeyGenerator.THREAD_LOCAL_INT_ARRAY_MAP.get().size(), 0);
+  }
+
+  /// Asserts that the generator produced the given number of distinct keys and that every one of them reads the
+  /// all-null column back as SQL NULL rather than as the value stored for a null row.
+  private void assertNullGroupKeys(Iterator<GroupKeyGenerator.GroupKey> groupKeyIterator, int numUniqueKeys) {
+    int count = 0;
+    Set<List<Object>> groupKeySet = new HashSet<>();
+    while (groupKeyIterator.hasNext()) {
+      count++;
+      GroupKeyGenerator.GroupKey groupKey = groupKeyIterator.next();
+      assertNull(groupKey._keys[0], _errorMessage);
+      groupKeySet.add(Arrays.asList(groupKey._keys));
+    }
+    assertEquals(count, numUniqueKeys, _errorMessage);
+    assertEquals(groupKeySet.size(), numUniqueKeys, _errorMessage);
+  }
+
   @Test
   public void testArrayBasedMultiValue() {
     // Cardinality product (100 - 1,000) smaller than arrayBasedThreshold
@@ -273,7 +354,7 @@ public class DictionaryBasedGroupKeyGeneratorTest {
     DictionaryBasedGroupKeyGenerator dictionaryBasedGroupKeyGenerator =
         new DictionaryBasedGroupKeyGenerator(_projectOperator, getExpressions(groupByColumns),
             Server.DEFAULT_QUERY_EXECUTOR_NUM_GROUPS_LIMIT,
-            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, null);
+            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, false, null);
     int groupKeyUpperBound = dictionaryBasedGroupKeyGenerator.getGlobalGroupKeyUpperBound();
     assertEquals(dictionaryBasedGroupKeyGenerator.getCurrentGroupKeyUpperBound(), groupKeyUpperBound, _errorMessage);
 
@@ -294,7 +375,7 @@ public class DictionaryBasedGroupKeyGeneratorTest {
     DictionaryBasedGroupKeyGenerator dictionaryBasedGroupKeyGenerator =
         new DictionaryBasedGroupKeyGenerator(_projectOperator, getExpressions(groupByColumns),
             Server.DEFAULT_QUERY_EXECUTOR_NUM_GROUPS_LIMIT,
-            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, null);
+            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, false, null);
     assertEquals(dictionaryBasedGroupKeyGenerator.getGlobalGroupKeyUpperBound(),
         Server.DEFAULT_QUERY_EXECUTOR_NUM_GROUPS_LIMIT, _errorMessage);
     assertEquals(dictionaryBasedGroupKeyGenerator.getCurrentGroupKeyUpperBound(), 0, _errorMessage);
@@ -321,7 +402,7 @@ public class DictionaryBasedGroupKeyGeneratorTest {
     DictionaryBasedGroupKeyGenerator dictionaryBasedGroupKeyGenerator =
         new DictionaryBasedGroupKeyGenerator(_projectOperator, getExpressions(groupByColumns),
             Server.DEFAULT_QUERY_EXECUTOR_NUM_GROUPS_LIMIT,
-            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, null);
+            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, false, null);
     assertEquals(dictionaryBasedGroupKeyGenerator.getGlobalGroupKeyUpperBound(),
         Server.DEFAULT_QUERY_EXECUTOR_NUM_GROUPS_LIMIT, _errorMessage);
     assertEquals(dictionaryBasedGroupKeyGenerator.getCurrentGroupKeyUpperBound(), 0, _errorMessage);
@@ -347,7 +428,7 @@ public class DictionaryBasedGroupKeyGeneratorTest {
     DictionaryBasedGroupKeyGenerator dictionaryBasedGroupKeyGenerator =
         new DictionaryBasedGroupKeyGenerator(_projectOperator, getExpressions(groupByColumns),
             Server.DEFAULT_QUERY_EXECUTOR_NUM_GROUPS_LIMIT,
-            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, null);
+            Server.DEFAULT_QUERY_EXECUTOR_MAX_INITIAL_RESULT_HOLDER_CAPACITY, false, null);
     assertEquals(dictionaryBasedGroupKeyGenerator.getGlobalGroupKeyUpperBound(),
         Server.DEFAULT_QUERY_EXECUTOR_NUM_GROUPS_LIMIT, _errorMessage);
     assertEquals(dictionaryBasedGroupKeyGenerator.getCurrentGroupKeyUpperBound(), 0, _errorMessage);
@@ -371,7 +452,7 @@ public class DictionaryBasedGroupKeyGeneratorTest {
     // NOTE: arrayBasedThreshold must be smaller or equal to numGroupsLimit
     DictionaryBasedGroupKeyGenerator dictionaryBasedGroupKeyGenerator =
         new DictionaryBasedGroupKeyGenerator(_projectOperator, getExpressions(groupByColumns), numGroupsLimit,
-            numGroupsLimit, null);
+            numGroupsLimit, false, null);
     assertEquals(dictionaryBasedGroupKeyGenerator.getGlobalGroupKeyUpperBound(), numGroupsLimit, _errorMessage);
     assertEquals(dictionaryBasedGroupKeyGenerator.getCurrentGroupKeyUpperBound(), 0, _errorMessage);
 
@@ -441,6 +522,26 @@ public class DictionaryBasedGroupKeyGeneratorTest {
     assertEquals(groupKeySet.size(), numUniqueKeys, _errorMessage);
   }
 
+  /// A map that grew past the caching threshold is rebuilt at its initial capacity instead of being kept as an
+  /// oversized cleared table, and must come back fully usable with group ids starting over.
+  @Test
+  public void testIntGroupIdMapClearAndTrim() {
+    DictionaryBasedGroupKeyGenerator.IntGroupIdMap map = new DictionaryBasedGroupKeyGenerator.IntGroupIdMap();
+    // Above the caching threshold of 0.75 * 2^20 entries
+    int numEntries = 800_000;
+    for (int i = 0; i < numEntries; i++) {
+      assertEquals(map.getGroupId(i, Integer.MAX_VALUE), i);
+    }
+    assertEquals(map.size(), numEntries);
+
+    map.clearAndTrim();
+
+    assertEquals(map.size(), 0);
+    assertEquals(map.getGroupId(123, Integer.MAX_VALUE), 0);
+    assertEquals(map.getGroupId(123, Integer.MAX_VALUE), 0);
+    assertEquals(map.size(), 1);
+  }
+
   @Test
   public void testMapDefaultValue() {
     assertEquals(DictionaryBasedGroupKeyGenerator.THREAD_LOCAL_LONG_MAP.get().defaultReturnValue(),
diff --git a/pinot-core/src/test/java/org/apache/pinot/queries/NullHandlingEnabledQueriesTest.java b/pinot-core/src/test/java/org/apache/pinot/queries/NullHandlingEnabledQueriesTest.java
index 5a37f159b62..0afe4ef6ad3 100644
--- a/pinot-core/src/test/java/org/apache/pinot/queries/NullHandlingEnabledQueriesTest.java
+++ b/pinot-core/src/test/java/org/apache/pinot/queries/NullHandlingEnabledQueriesTest.java
@@ -23,8 +23,8 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
+import javax.annotation.Nullable;
 import org.apache.commons.io.FileUtils;
-import org.apache.pinot.common.response.broker.BrokerResponseNative;
 import org.apache.pinot.common.response.broker.ResultTable;
 import org.apache.pinot.core.plan.DocIdSetPlanNode;
 import org.apache.pinot.segment.local.indexsegment.immutable.ImmutableSegmentLoader;
@@ -36,6 +36,7 @@ import org.apache.pinot.segment.spi.creator.SegmentGeneratorConfig;
 import org.apache.pinot.spi.config.table.TableConfig;
 import org.apache.pinot.spi.config.table.TableType;
 import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
 import org.apache.pinot.spi.data.Schema;
 import org.apache.pinot.spi.data.readers.GenericRow;
 import org.apache.pinot.spi.utils.ReadMode;
@@ -44,10 +45,7 @@ import org.testng.annotations.AfterMethod;
 import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
-import static org.testng.Assert.assertEquals;
-import static org.testng.Assert.assertNotNull;
-import static org.testng.Assert.assertNull;
-import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.*;
 
 
 public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
@@ -56,10 +54,22 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
   private static final String SEGMENT_NAME = "testSegment";
   private static final String COLUMN1 = "column1";
   private static final String COLUMN2 = "column2";
+  private static final TableConfig TABLE_CONFIG =
+      new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
+  private static final TableConfig TABLE_CONFIG_WITH_RAW_COLUMN =
+      new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
+          .setNoDictionaryColumns(List.of(COLUMN1))
+          .build();
+  private static final TableConfig TABLE_CONFIG_WITH_SORTED_COLUMN =
+      new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).setSortedColumn(COLUMN1).build();
+  private static final TableConfig TABLE_CONFIG_WITH_INVERTED_INDEX_COLUMN =
+      new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
+          .setInvertedIndexColumns(List.of(COLUMN1))
+          .build();
   private static final int NUM_OF_SEGMENT_COPIES = 4;
-  private final List<GenericRow> _rows = new ArrayList<>();
   private static final Map<String, String> QUERY_OPTIONS = Map.of("enableNullHandling", "true");
 
+  private final List<GenericRow> _rows = new ArrayList<>();
   private IndexSegment _indexSegment;
   private List<IndexSegment> _indexSegments;
 
@@ -113,6 +123,22 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     _rows.add(row);
   }
 
+  private static Schema.SchemaBuilder schemaBuilder() {
+    return new Schema.SchemaBuilder().setSchemaName(RAW_TABLE_NAME);
+  }
+
+  /// Returns the result rows of the given query, executed with null handling enabled.
+  private List<Object[]> getRows(String query) {
+    return getRows(query, QUERY_OPTIONS);
+  }
+
+  private List<Object[]> getRows(String query, @Nullable Map<String, String> queryOptions) {
+    //noinspection LanguageMismatch
+    ResultTable resultTable = getBrokerResponse(query, queryOptions).getResultTable();
+    assertNotNull(resultTable, "Failed to get result table for query: " + query);
+    return resultTable.getRows();
+  }
+
   @DataProvider(name = "BooleanAssertionFunctions")
   public static Object[][] getBooleanAssertionFunctionsParameters() {
     return new Object[][]{
@@ -136,15 +162,12 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
       throws Exception {
     initializeRows();
     insertRow(data);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.BOOLEAN).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.BOOLEAN).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT %s(%s) FROM testTable LIMIT 1", function, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getRows().get(0)[0], queryResult);
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.get(0)[0], queryResult);
   }
 
   @Test
@@ -157,22 +180,314 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRow(1);
     insertRow(2);
     insertRow(2);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT %s, COUNT(*) FROM testTable GROUP BY %s ORDER BY 1 DESC NULLS LAST", COLUMN1, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 3);
     assertEquals(rows.get(0), new Object[]{2, (long) 2 * NUM_OF_SEGMENT_COPIES});
     assertEquals(rows.get(1), new Object[]{1, (long) NUM_OF_SEGMENT_COPIES});
     assertEquals(rows.get(2), new Object[]{null, (long) 3 * NUM_OF_SEGMENT_COPIES});
   }
 
+  /// A row holding the column's default null value must not join the null group.
+  ///
+  /// Ingestion stores that default in the forward index for a null row, so both share a dictionary id and only the
+  /// null vector tells them apart. Grouping therefore has to move nulls onto an id of their own.
+  @Test
+  public void testGroupByKeepsNullApartFromTheDefaultNullValue()
+      throws Exception {
+    initializeRows();
+    insertRow(1);
+    insertRow(null);
+    insertRow(FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_INT);
+    insertRow(null);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
+    String query =
+        String.format("SELECT %s, COUNT(*) FROM testTable GROUP BY %s ORDER BY 1 NULLS LAST", COLUMN1, COLUMN1);
+
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 3);
+    assertEquals(rows.get(0),
+        new Object[]{FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_INT, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(1), new Object[]{1, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(2), new Object[]{null, (long) 2 * NUM_OF_SEGMENT_COPIES});
+  }
+
+  /// A multi-value row expands into one group per value it holds, so a row whose column is null contributes a single
+  /// null group rather than reading as the column's default null value, which is what a null row is stored as.
+  @Test
+  public void testGroupByMultiValueColumnHoldingNulls()
+      throws Exception {
+    initializeRows();
+    insertRow(new Object[]{1, 2});
+    insertRow(new Object[]{2, 3});
+    insertRow(null);
+    insertRow(null);
+    Schema schema = schemaBuilder().addMultiValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
+    String query =
+        String.format("SELECT %s, COUNT(*) FROM testTable GROUP BY %s ORDER BY 1 NULLS LAST", COLUMN1, COLUMN1);
+
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 4);
+    assertEquals(rows.get(0), new Object[]{1, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(1), new Object[]{2, (long) 2 * NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(2), new Object[]{3, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(3), new Object[]{null, (long) 2 * NUM_OF_SEGMENT_COPIES});
+  }
+
+  /// A column with no dictionary resolves group keys through an on-the-fly dictionary rather than dictionary ids,
+  /// and its null rows still hold the column's default null value, so the same null-vs-default separation applies.
+  @Test
+  public void testGroupByRawColumnKeepsNullApartFromTheDefaultNullValue()
+      throws Exception {
+    initializeRows();
+    insertRow(1);
+    insertRow(null);
+    insertRow(FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_INT);
+    insertRow(null);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG_WITH_RAW_COLUMN, schema);
+    String query =
+        String.format("SELECT %s, COUNT(*) FROM testTable GROUP BY %s ORDER BY 1 NULLS LAST", COLUMN1, COLUMN1);
+
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 3);
+    assertEquals(rows.get(0),
+        new Object[]{FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_INT, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(1), new Object[]{1, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(2), new Object[]{null, (long) 2 * NUM_OF_SEGMENT_COPIES});
+  }
+
+  /// A STRING group key is held in an object-keyed map, which stores the null group under a null key instead of
+  /// tracking it beside the map the way the primitive-keyed maps do.
+  @Test
+  public void testGroupByRawStringColumnHoldingNulls()
+      throws Exception {
+    initializeRows();
+    insertRow("a");
+    insertRow("b");
+    insertRow(null);
+    insertRow(null);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.STRING).build();
+    setUpSegments(TABLE_CONFIG_WITH_RAW_COLUMN, schema);
+    String query =
+        String.format("SELECT %s, COUNT(*) FROM testTable GROUP BY %s ORDER BY 1 NULLS LAST", COLUMN1, COLUMN1);
+
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 3);
+    assertEquals(rows.get(0), new Object[]{"a", (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(1), new Object[]{"b", (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(2), new Object[]{null, (long) 2 * NUM_OF_SEGMENT_COPIES});
+  }
+
+  /// A dictionary-backed transform must keep a null apart from the column's default null value, exactly as reading
+  /// the column directly does.
+  ///
+  /// A transform hands out its own null bitmap but exposes no data source to inspect, so nullability cannot be ruled
+  /// out for it the way it can for a column read straight from a segment.
+  @Test
+  public void testGroupByDictionaryBackedTransformKeepsNullApartFromTheDefaultNullValue()
+      throws Exception {
+    initializeRows();
+    insertRow(new Object[]{1});
+    insertRow(null);
+    insertRow(new Object[]{FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_INT});
+    Schema schema = schemaBuilder().addMultiValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
+    String transform = String.format("filterMv(%s, 'v <= 1')", COLUMN1);
+    String query = String.format("SELECT %s, COUNT(*) FROM testTable GROUP BY %s ORDER BY 1 NULLS LAST", transform,
+        transform);
+
+    List<Object[]> rows = getRows(query);
+
+    assertEquals(rows.size(), 3);
+    assertEquals(rows.get(0)[0], FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_INT);
+    assertEquals(rows.get(1)[0], 1);
+    assertNull(rows.get(2)[0], "The null row must not join the group of the column's default null value");
+  }
+
+  /// Grouping sets resolve each column's values to ids of their own, and the multi-value resolver has to recognize a
+  /// null row there too, so it forms its own group instead of joining the column's default null value.
+  @Test
+  public void testGroupingSetsOverMultiValueColumnHoldingNulls()
+      throws Exception {
+    initializeRows();
+    insertRow(new Object[]{1});
+    insertRow(null);
+    insertRow(new Object[]{FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_INT});
+    Schema schema = schemaBuilder().addMultiValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
+    String query = String.format(
+        "SELECT %s, COUNT(*) FROM testTable GROUP BY GROUPING SETS ((%s)) ORDER BY 1 NULLS LAST", COLUMN1, COLUMN1);
+
+    List<Object[]> rows = getRows(query);
+
+    assertEquals(rows.size(), 3);
+    assertEquals(rows.get(0), new Object[]{FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_INT,
+        (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(1), new Object[]{1, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(2), new Object[]{null, (long) NUM_OF_SEGMENT_COPIES});
+  }
+
+  /// The group id of a null value is handed out beside the group key map, so a generator that counted only the map
+  /// would under-report its groups and let a group id run past the result holder it sized.
+  @Test
+  public void testNullGroupIsCountedWhenSizingTheResultHolder()
+      throws Exception {
+    initializeRows();
+    insertRow(new Object[]{1});
+    insertRow(new Object[]{2});
+    insertRow(null);
+    Schema schema = schemaBuilder().addMultiValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG_WITH_RAW_COLUMN, schema);
+    String query =
+        String.format("SELECT %s, COUNT(*) FROM testTable GROUP BY %s ORDER BY 1 NULLS LAST", COLUMN1, COLUMN1);
+    // Sized so that the null group is the first id past the holder's initial capacity
+    Map<String, String> queryOptions =
+        Map.of("enableNullHandling", "true", "maxInitialResultHolderCapacity", "2");
+
+    List<Object[]> rows = getRows(query, queryOptions);
+
+    assertEquals(rows.size(), 3);
+    assertEquals(rows.get(0), new Object[]{1, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(1), new Object[]{2, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(2), new Object[]{null, (long) NUM_OF_SEGMENT_COPIES});
+  }
+
+  /// One column without a dictionary sends both columns down the on-the-fly path, where a null contributes a reserved
+  /// id to the composed key, in any combination across the two columns.
+  @Test
+  public void testGroupByRawAndDictionaryColumnsHoldingNulls()
+      throws Exception {
+    initializeRows();
+    insertRowWithTwoColumns(1, 10);
+    insertRowWithTwoColumns(1, null);
+    insertRowWithTwoColumns(null, 10);
+    insertRowWithTwoColumns(null, null);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG_WITH_RAW_COLUMN, schema);
+    String query =
+        String.format("SELECT %s, %s, COUNT(*) FROM testTable GROUP BY %s, %s ORDER BY 1 NULLS LAST, 2 NULLS LAST",
+            COLUMN1, COLUMN2, COLUMN1, COLUMN2);
+
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 4);
+    assertEquals(rows.get(0), new Object[]{1, 10, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(1), new Object[]{1, null, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(2), new Object[]{null, 10, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(3), new Object[]{null, null, (long) NUM_OF_SEGMENT_COPIES});
+  }
+
+  /// A column with no dictionary is grouped through an on-the-fly one, and a multi-value column of that kind takes
+  /// the path that reads whole rows at a time, which has to consult the null bitmap just as the others do.
+  @Test
+  public void testGroupByRawMultiValueColumnHoldingNulls()
+      throws Exception {
+    initializeRows();
+    insertRow(new Object[]{1, 2});
+    insertRow(new Object[]{2, 3});
+    insertRow(null);
+    insertRow(null);
+    Schema schema = schemaBuilder().addMultiValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG_WITH_RAW_COLUMN, schema);
+    String query =
+        String.format("SELECT %s, COUNT(*) FROM testTable GROUP BY %s ORDER BY 1 NULLS LAST", COLUMN1, COLUMN1);
+
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 4);
+    assertEquals(rows.get(0), new Object[]{1, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(1), new Object[]{2, (long) 2 * NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(2), new Object[]{3, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(3), new Object[]{null, (long) 2 * NUM_OF_SEGMENT_COPIES});
+  }
+
+  /// Once the group limit is reached, a row of an existing group must still land in that group. The at-limit path
+  /// resolves each column's key value without being allowed to create one, and has to compose the looked-up values
+  /// rather than whatever the key buffer held before.
+  @Test
+  public void testGroupLimitStillRoutesRowsToTheirExistingGroup()
+      throws Exception {
+    initializeRows();
+    insertRowWithTwoColumns(1, 10);
+    insertRowWithTwoColumns(2, 20);
+    insertRowWithTwoColumns(2, 20);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG_WITH_RAW_COLUMN, schema);
+    String query =
+        String.format("SELECT %s, %s, COUNT(*) FROM testTable GROUP BY %s, %s ORDER BY 1", COLUMN1, COLUMN2, COLUMN1,
+            COLUMN2);
+    Map<String, String> queryOptions = Map.of("enableNullHandling", "true", "numGroupsLimit", "2");
+
+    List<Object[]> rows = getRows(query, queryOptions);
+    assertEquals(rows.size(), 2);
+    assertEquals(rows.get(0), new Object[]{1, 10, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(1), new Object[]{2, 20, (long) 2 * NUM_OF_SEGMENT_COPIES});
+  }
+
+  /// One column without a dictionary sends every column down the on-the-fly path, so a null has to be recognized
+  /// there for the dictionary-encoded column beside it as well.
+  @Test
+  public void testGroupByRawMultiValueColumnBesideADictionaryColumn()
+      throws Exception {
+    initializeRows();
+    insertRowWithTwoColumns(new Object[]{1, 2}, 10);
+    insertRowWithTwoColumns(null, 10);
+    insertRowWithTwoColumns(new Object[]{1}, null);
+    Schema schema = schemaBuilder()
+        .addMultiValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG_WITH_RAW_COLUMN, schema);
+    String query =
+        String.format("SELECT %s, %s, COUNT(*) FROM testTable GROUP BY %s, %s ORDER BY 1 NULLS LAST, 2 NULLS LAST",
+            COLUMN1, COLUMN2, COLUMN1, COLUMN2);
+
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 4);
+    assertEquals(rows.get(0), new Object[]{1, 10, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(1), new Object[]{1, null, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(2), new Object[]{2, 10, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(3), new Object[]{null, 10, (long) NUM_OF_SEGMENT_COPIES});
+  }
+
+  /// Several columns compose their dictionary ids into a single key using each column's cardinality, so the id
+  /// reserved for null has to be counted in every one of them for the composition to stay unambiguous.
+  @Test
+  public void testGroupByTwoColumnsHoldingNulls()
+      throws Exception {
+    initializeRows();
+    insertRowWithTwoColumns(1, 10);
+    insertRowWithTwoColumns(1, null);
+    insertRowWithTwoColumns(null, 10);
+    insertRowWithTwoColumns(null, null);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
+    String query =
+        String.format("SELECT %s, %s, COUNT(*) FROM testTable GROUP BY %s, %s ORDER BY 1 NULLS LAST, 2 NULLS LAST",
+            COLUMN1, COLUMN2, COLUMN1, COLUMN2);
+
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 4);
+    assertEquals(rows.get(0), new Object[]{1, 10, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(1), new Object[]{1, null, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(2), new Object[]{null, 10, (long) NUM_OF_SEGMENT_COPIES});
+    assertEquals(rows.get(3), new Object[]{null, null, (long) NUM_OF_SEGMENT_COPIES});
+  }
+
   @Test
   public void testHavingFilterIsNull()
       throws Exception {
@@ -180,18 +495,16 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(1, 1);
     insertRowWithTwoColumns(null, 1);
     insertRowWithTwoColumns(null, 1);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT %s, COUNT(%s) FROM testTable GROUP BY %s HAVING %s IS NULL LIMIT 100", COLUMN1, COLUMN2,
             COLUMN1, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 1);
     assertEquals(rows.get(0), new Object[]{null, (long) 2 * NUM_OF_SEGMENT_COPIES});
   }
@@ -203,18 +516,16 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(1, 1);
     insertRowWithTwoColumns(null, 1);
     insertRowWithTwoColumns(null, 1);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT %s, COUNT(%s) FROM testTable GROUP BY %s HAVING %s IS NOT NULL LIMIT 100", COLUMN1,
             COLUMN2, COLUMN1, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 1);
     assertEquals(rows.get(0), new Object[]{1, (long) NUM_OF_SEGMENT_COPIES});
   }
@@ -226,18 +537,16 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(true, 1);
     insertRowWithTwoColumns(null, 1);
     insertRowWithTwoColumns(null, 1);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.BOOLEAN)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.BOOLEAN)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT %s, COUNT(%s) FROM testTable GROUP BY %s HAVING (NOT %s) IS NULL LIMIT 100", COLUMN1,
             COLUMN2, COLUMN1, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 1);
     assertEquals(rows.get(0), new Object[]{null, (long) 2 * NUM_OF_SEGMENT_COPIES});
   }
@@ -249,18 +558,16 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(true, 1);
     insertRowWithTwoColumns(null, 1);
     insertRowWithTwoColumns(null, 1);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.BOOLEAN)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.BOOLEAN)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT %s, COUNT(%s) FROM testTable GROUP BY %s HAVING NOT (%s IS NULL) LIMIT 100", COLUMN1,
             COLUMN2, COLUMN1, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 1);
     assertEquals(rows.get(0), new Object[]{true, (long) NUM_OF_SEGMENT_COPIES});
   }
@@ -272,18 +579,16 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(1, 1);
     insertRowWithTwoColumns(null, 1);
     insertRowWithTwoColumns(null, 1);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format(
         "SELECT %s, COUNT(%s) FROM testTable GROUP BY %s HAVING (%s IS NULL) AND (COUNT(%s) is NOT NULL) LIMIT 100",
         COLUMN1, COLUMN2, COLUMN1, COLUMN1, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 1);
     assertEquals(rows.get(0), new Object[]{null, (long) 2 * NUM_OF_SEGMENT_COPIES});
   }
@@ -295,18 +600,16 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(1, 1);
     insertRowWithTwoColumns(null, 1);
     insertRowWithTwoColumns(null, 1);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format(
         "SELECT %s, COUNT(%s) FROM testTable GROUP BY %s HAVING (%s IS NULL) OR (COUNT(%s) is NULL) LIMIT 100", COLUMN1,
         COLUMN2, COLUMN1, COLUMN1, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 1);
     assertEquals(rows.get(0), new Object[]{null, (long) 2 * NUM_OF_SEGMENT_COPIES});
   }
@@ -318,16 +621,13 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     initializeRows();
     insertRow(1);
     insertRow(null);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT DISTINCT %s FROM testTable ORDER BY %s NULLS FIRST", COLUMN1, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertNull(resultTable.getRows().get(0)[0]);
-    assertNotNull(resultTable.getRows().get(1)[0]);
+    List<Object[]> rows = getRows(query);
+    assertNull(rows.get(0)[0]);
+    assertNotNull(rows.get(1)[0]);
   }
 
   @Test
@@ -337,16 +637,13 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     initializeRows();
     insertRow(1);
     insertRow(null);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT DISTINCT %s FROM testTable ORDER BY %s NULLS LAST", COLUMN1, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertNotNull(resultTable.getRows().get(0)[0]);
-    assertNull(resultTable.getRows().get(1)[0]);
+    List<Object[]> rows = getRows(query);
+    assertNotNull(rows.get(0)[0]);
+    assertNull(rows.get(1)[0]);
   }
 
   @Test
@@ -356,15 +653,12 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     initializeRows();
     insertRow(Integer.MIN_VALUE);
     insertRow(null);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT DISTINCT %s FROM testTable", COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getRows().size(), 2);
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 2);
   }
 
   @Test
@@ -377,16 +671,15 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(null, 1);
     insertRowWithTwoColumns(null, 2);
     insertRowWithTwoColumns(null, null);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT DISTINCT %s,%s FROM testTable", COLUMN1, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getRows().size(), 4);
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 4);
   }
 
   @Test
@@ -398,21 +691,20 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(null, 2);
     insertRowWithTwoColumns(1, 1);
     insertRowWithTwoColumns(null, null);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT DISTINCT %s,%s FROM testTable ORDER BY %s,%s", COLUMN1, COLUMN2, COLUMN1, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getRows().size(), 4);
-    assertEquals(resultTable.getRows().get(0), new Object[]{1, 1});
-    assertEquals(resultTable.getRows().get(1), new Object[]{null, 1});
-    assertEquals(resultTable.getRows().get(2), new Object[]{null, 2});
-    assertEquals(resultTable.getRows().get(3), new Object[]{null, null});
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 4);
+    assertEquals(rows.get(0), new Object[]{1, 1});
+    assertEquals(rows.get(1), new Object[]{null, 1});
+    assertEquals(rows.get(2), new Object[]{null, 2});
+    assertEquals(rows.get(3), new Object[]{null, null});
   }
 
   @Test
@@ -424,52 +716,48 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(null, 2);
     insertRowWithTwoColumns(1, 1);
     insertRowWithTwoColumns(null, null);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT DISTINCT %s,%s FROM testTable ORDER BY %s NULLS FIRST, %s DESC NULLS LAST", COLUMN1,
             COLUMN2, COLUMN1, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getRows().size(), 4);
-    assertEquals(resultTable.getRows().get(0), new Object[]{null, 2});
-    assertEquals(resultTable.getRows().get(1), new Object[]{null, 1});
-    assertEquals(resultTable.getRows().get(2), new Object[]{null, null});
-    assertEquals(resultTable.getRows().get(3), new Object[]{1, 1});
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 4);
+    assertEquals(rows.get(0), new Object[]{null, 2});
+    assertEquals(rows.get(1), new Object[]{null, 1});
+    assertEquals(rows.get(2), new Object[]{null, null});
+    assertEquals(rows.get(3), new Object[]{1, 1});
   }
 
   @DataProvider(name = "NumberTypes")
   public static Object[][] getPrimitiveDataTypes() {
     return new Object[][]{
-        {FieldSpec.DataType.INT}, {FieldSpec.DataType.LONG}, {FieldSpec.DataType.DOUBLE}, {FieldSpec.DataType.FLOAT}
+        {DataType.INT}, {DataType.LONG}, {DataType.DOUBLE}, {DataType.FLOAT}
     };
   }
 
   @Test(dataProvider = "NumberTypes")
-  public void testSelectDistinctWithLimit(FieldSpec.DataType dataType)
+  public void testSelectDistinctWithLimit(DataType dataType)
       throws Exception {
     initializeRows();
     insertRow(null);
     insertRow(1);
     insertRow(2);
     insertRow(3);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, dataType).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, dataType).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT DISTINCT %s FROM testTable ORDER BY %s LIMIT 3", COLUMN1, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getRows().size(), 3);
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 3);
   }
 
   @Test(dataProvider = "NumberTypes")
-  public void testSelectDistinctOrderByWithLimit(FieldSpec.DataType dataType)
+  public void testSelectDistinctOrderByWithLimit(DataType dataType)
       throws Exception {
     double delta = 0.01;
     initializeRows();
@@ -477,232 +765,203 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRow(1);
     insertRow(2);
     insertRow(3);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, dataType).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, dataType).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT DISTINCT %s FROM testTable ORDER BY %s LIMIT 3", COLUMN1, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getRows().size(), 3);
-    assertTrue(Math.abs(((Number) resultTable.getRows().get(0)[0]).doubleValue() - 1.0) < delta);
-    assertTrue(Math.abs(((Number) resultTable.getRows().get(1)[0]).doubleValue() - 2.0) < delta);
-    assertTrue(Math.abs(((Number) resultTable.getRows().get(2)[0]).doubleValue() - 3.0) < delta);
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 3);
+    assertTrue(Math.abs(((Number) rows.get(0)[0]).doubleValue() - 1.0) < delta);
+    assertTrue(Math.abs(((Number) rows.get(1)[0]).doubleValue() - 2.0) < delta);
+    assertTrue(Math.abs(((Number) rows.get(2)[0]).doubleValue() - 3.0) < delta);
   }
 
   @DataProvider(name = "ObjectTypes")
   public static Object[][] getObjectDataTypes() {
     return new Object[][]{
-        {FieldSpec.DataType.STRING, "a"}, {
-        FieldSpec.DataType.BIG_DECIMAL, 1
-      }, {
-        FieldSpec.DataType.BYTES, "a string".getBytes()
-      }
+        {DataType.STRING, "a"},
+        {DataType.BIG_DECIMAL, 1},
+        {DataType.BYTES, "a string".getBytes()}
     };
   }
 
   @Test(dataProvider = "ObjectTypes")
-  public void testObjectSingleColumnDistinctOrderByNullsFirst(FieldSpec.DataType dataType, Object value)
+  public void testObjectSingleColumnDistinctOrderByNullsFirst(DataType dataType, Object value)
       throws Exception {
     initializeRows();
     insertRow(null);
     insertRow(value);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, dataType).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, dataType).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT DISTINCT %s FROM testTable ORDER BY %s NULLS FIRST LIMIT 1", COLUMN1, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getRows().size(), 1);
-    assertNull(resultTable.getRows().get(0)[0]);
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 1);
+    assertNull(rows.get(0)[0]);
   }
 
   @Test(dataProvider = "ObjectTypes")
-  public void testObjectSingleColumnDistinctOrderByNullsLast(FieldSpec.DataType dataType, Object value)
+  public void testObjectSingleColumnDistinctOrderByNullsLast(DataType dataType, Object value)
       throws Exception {
     initializeRows();
     insertRow(null);
     insertRow(value);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, dataType).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, dataType).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT DISTINCT %s FROM testTable ORDER BY %s NULLS LAST LIMIT 1", COLUMN1, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getRows().size(), 1);
-    assertNotNull(resultTable.getRows().get(0)[0]);
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 1);
+    assertNotNull(rows.get(0)[0]);
   }
 
   @Test(dataProvider = "NumberTypes")
-  public void testDistinctCountDictNumberTypes(FieldSpec.DataType dataType)
+  public void testDistinctCountDictNumberTypes(DataType dataType)
       throws Exception {
     initializeRows();
     insertRow(null);
     insertRow(1);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, dataType).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, dataType).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT DISTINCTCOUNT(%s) FROM testTable", COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getRows().size(), 1);
-    assertEquals(resultTable.getRows().get(0)[0], 1);
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 1);
+    assertEquals(rows.get(0)[0], 1);
   }
 
   @Test(dataProvider = "NumberTypes")
-  public void testDistinctCountNonDictNumberTypes(FieldSpec.DataType dataType)
+  public void testDistinctCountNonDictNumberTypes(DataType dataType)
       throws Exception {
     initializeRows();
     insertRow(null);
     insertRow(1);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
-        .setNoDictionaryColumns(List.of(COLUMN1)).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, dataType).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, dataType).build();
+    setUpSegments(TABLE_CONFIG_WITH_RAW_COLUMN, schema);
     String query = String.format("SELECT DISTINCTCOUNT(%s) FROM testTable", COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getRows().size(), 1);
-    assertEquals(resultTable.getRows().get(0)[0], 1);
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 1);
+    assertEquals(rows.get(0)[0], 1);
   }
 
   @Test(dataProvider = "NumberTypes")
-  public void testGroupByDistinctCountDictNumberTypes(FieldSpec.DataType dataType)
+  public void testGroupByDistinctCountDictNumberTypes(DataType dataType)
       throws Exception {
     initializeRows();
     insertRowWithTwoColumns(null, "key");
     insertRowWithTwoColumns(1, "key");
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, dataType)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.STRING).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, dataType)
+        .addSingleValueDimension(COLUMN2, DataType.STRING)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT DISTINCTCOUNT(%s), %s FROM testTable GROUP BY %s", COLUMN1, COLUMN2, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getRows().size(), 1);
-    assertEquals(resultTable.getRows().get(0), new Object[]{1, "key"});
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 1);
+    assertEquals(rows.get(0), new Object[]{1, "key"});
   }
 
   @Test(dataProvider = "NumberTypes")
-  public void testGroupByDistinctCountNonDictNumberTypes(FieldSpec.DataType dataType)
+  public void testGroupByDistinctCountNonDictNumberTypes(DataType dataType)
       throws Exception {
     initializeRows();
     insertRowWithTwoColumns(null, "key");
     insertRowWithTwoColumns(1, "key");
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
-        .setNoDictionaryColumns(List.of(COLUMN1)).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, dataType)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.STRING).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, dataType)
+        .addSingleValueDimension(COLUMN2, DataType.STRING)
+        .build();
+    setUpSegments(TABLE_CONFIG_WITH_RAW_COLUMN, schema);
     String query = String.format("SELECT DISTINCTCOUNT(%s), %s FROM testTable GROUP BY %s", COLUMN1, COLUMN2, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getRows().size(), 1);
-    assertEquals(resultTable.getRows().get(0), new Object[]{1, "key"});
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 1);
+    assertEquals(rows.get(0), new Object[]{1, "key"});
   }
 
   @Test(dataProvider = "NumberTypes")
-  public void testGroupByMvDistinctCountNonDictNumberTypes(FieldSpec.DataType dataType)
+  public void testGroupByMvDistinctCountNonDictNumberTypes(DataType dataType)
       throws Exception {
     initializeRows();
     insertRowWithTwoColumns(null, new String[]{"key1", "key2"});
     insertRowWithTwoColumns(1, new String[]{"key1", "key2"});
     insertRowWithTwoColumns(2, new String[]{"key2"});
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
-        .setNoDictionaryColumns(List.of(COLUMN1)).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, dataType)
-        .addMultiValueDimension(COLUMN2, FieldSpec.DataType.STRING).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, dataType)
+        .addMultiValueDimension(COLUMN2, DataType.STRING)
+        .build();
+    setUpSegments(TABLE_CONFIG_WITH_RAW_COLUMN, schema);
     String query =
         String.format("SELECT DISTINCTCOUNT(%s), %s FROM testTable GROUP BY %s ORDER BY %s", COLUMN1, COLUMN2, COLUMN2,
             COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getRows().get(0), new Object[]{1, "key1"});
-    assertEquals(resultTable.getRows().get(1), new Object[]{2, "key2"});
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.get(0), new Object[]{1, "key1"});
+    assertEquals(rows.get(1), new Object[]{2, "key2"});
   }
 
   @Test(dataProvider = "NumberTypes")
-  public void testGroupByMvDistinctCountDictNumberTypes(FieldSpec.DataType dataType)
+  public void testGroupByMvDistinctCountDictNumberTypes(DataType dataType)
       throws Exception {
     initializeRows();
     insertRowWithTwoColumns(null, new String[]{"key1", "key2"});
     insertRowWithTwoColumns(1, new String[]{"key1", "key2"});
     insertRowWithTwoColumns(2, new String[]{"key2"});
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, dataType)
-        .addMultiValueDimension(COLUMN2, FieldSpec.DataType.STRING).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, dataType)
+        .addMultiValueDimension(COLUMN2, DataType.STRING)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT DISTINCTCOUNT(%s), %s FROM testTable GROUP BY %s ORDER BY %s", COLUMN1, COLUMN2, COLUMN2,
             COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getRows().get(0), new Object[]{1, "key1"});
-    assertEquals(resultTable.getRows().get(1), new Object[]{2, "key2"});
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.get(0), new Object[]{1, "key1"});
+    assertEquals(rows.get(1), new Object[]{2, "key2"});
   }
 
   @DataProvider(name = "DistinctCountObjectTypes")
   public static Object[][] getDistinctAggregationObjectTypes() {
     return new Object[][]{
-        {FieldSpec.DataType.STRING, "a"}, {
-        FieldSpec.DataType.BYTES, "a string".getBytes()
-      }
+        {DataType.STRING, "a"},
+        {DataType.BYTES, "a string".getBytes()}
     };
   }
 
   @Test(dataProvider = "DistinctCountObjectTypes")
-  public void testObjectDistinctCountObjectTypes(FieldSpec.DataType dataType, Object value)
+  public void testObjectDistinctCountObjectTypes(DataType dataType, Object value)
       throws Exception {
     initializeRows();
     insertRow(null);
     insertRow(value);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, dataType).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, dataType).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT DISTINCTCOUNT(%s) FROM testTable", COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getRows().size(), 1);
-    assertEquals(resultTable.getRows().get(0)[0], 1);
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 1);
+    assertEquals(rows.get(0)[0], 1);
   }
 
   @Test(dataProvider = "DistinctCountObjectTypes")
-  public void testGroupByDistinctCountObjectTypes(FieldSpec.DataType dataType, Object value)
+  public void testGroupByDistinctCountObjectTypes(DataType dataType, Object value)
       throws Exception {
     initializeRows();
     insertRowWithTwoColumns(null, "key");
     insertRowWithTwoColumns(value, "key");
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, dataType)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.STRING).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, dataType)
+        .addSingleValueDimension(COLUMN2, DataType.STRING)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT DISTINCTCOUNT(%s), %s FROM testTable GROUP BY %s", COLUMN1, COLUMN2, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getRows().size(), 1);
-    assertEquals(resultTable.getRows().get(0), new Object[]{1, "key"});
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 1);
+    assertEquals(rows.get(0), new Object[]{1, "key"});
   }
 
   @Test
@@ -713,16 +972,13 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRow(1);
     insertRow(2);
     insertRow(2);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT DISTINCTSUM(%s) FROM testTable", COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getRows().size(), 1);
-    assertEquals(resultTable.getRows().get(0)[0], (double) 3);
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 1);
+    assertEquals(rows.get(0)[0], (double) 3);
   }
 
   @Test
@@ -733,16 +989,13 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRow(1);
     insertRow(2);
     insertRow(2);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT DISTINCTAVG(%s) FROM testTable", COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getRows().size(), 1);
-    assertEquals(resultTable.getRows().get(0)[0], 1.5);
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), 1);
+    assertEquals(rows.get(0)[0], 1.5);
   }
 
   @Test
@@ -750,16 +1003,13 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
       throws Exception {
     initializeRows();
     insertRow(null);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT (CASE WHEN %s IS NULL THEN 1 END) FROM testTable", COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getRows().size(), NUM_OF_SEGMENT_COPIES);
-    assertEquals(resultTable.getRows().get(0)[0], 1);
+    List<Object[]> rows = getRows(query);
+    assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES);
+    assertEquals(rows.get(0)[0], 1);
   }
 
   private boolean contains(List<Object[]> rows, Object[] target) {
@@ -781,18 +1031,16 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(1, 1);
     insertRowWithTwoColumns(1, null);
     insertRowWithTwoColumns(1, Integer.MIN_VALUE);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT count(*), %s, %s FROM testTable GROUP BY %s, %s", COLUMN1, COLUMN2, COLUMN1,
             COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 5);
     assertTrue(contains(rows, new Object[]{(long) NUM_OF_SEGMENT_COPIES, null, null}));
     assertTrue(contains(rows, new Object[]{(long) 2 * NUM_OF_SEGMENT_COPIES, null, 1}));
@@ -811,18 +1059,16 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(1, 1);
     insertRowWithTwoColumns(1, null);
     insertRowWithTwoColumns(1, Integer.MIN_VALUE);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT count(*), %s, %s FROM testTable GROUP BY %s, %s LIMIT 3", COLUMN1, COLUMN2, COLUMN1,
             COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 3);
   }
 
@@ -835,17 +1081,13 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRow(1);
     insertRow(2);
     insertRow(3);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT count(*), %s FROM testTable GROUP BY %s ORDER BY %s ASC NULLS LAST", COLUMN1, COLUMN1,
             COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 4);
     assertEquals(rows.get(0), new Object[]{(long) 2 * NUM_OF_SEGMENT_COPIES, 1});
     assertEquals(rows.get(1), new Object[]{(long) NUM_OF_SEGMENT_COPIES, 2});
@@ -862,17 +1104,13 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRow(1);
     insertRow(2);
     insertRow(3);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT count(*), %s FROM testTable GROUP BY %s ORDER BY %s DESC NULLS FIRST LIMIT 3", COLUMN1,
             COLUMN1, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 3);
     assertEquals(rows.get(0), new Object[]{(long) NUM_OF_SEGMENT_COPIES, null});
     assertEquals(rows.get(1), new Object[]{(long) NUM_OF_SEGMENT_COPIES, 3});
@@ -884,16 +1122,12 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
       throws Exception {
     initializeRows();
     insertRow(null);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT (CASE WHEN %s = -2147483648 THEN 1 ELSE 2 END) + 0 FROM testTable", COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.get(0), new Object[]{(double) 2});
   }
 
@@ -904,17 +1138,12 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRow(null);
     insertRow(false);
     insertRow(true);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
-        .setInvertedIndexColumns(List.of(COLUMN1)).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.BOOLEAN).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.BOOLEAN).build();
+    setUpSegments(TABLE_CONFIG_WITH_INVERTED_INDEX_COLUMN, schema);
     String query =
         String.format("SELECT * FROM testTable WHERE %s = false", COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES);
     assertEquals(rows.get(0), new Object[]{false});
   }
@@ -926,17 +1155,12 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRow(null);
     insertRow(false);
     insertRow(true);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
-        .setSortedColumn(COLUMN1).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.BOOLEAN).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.BOOLEAN).build();
+    setUpSegments(TABLE_CONFIG_WITH_SORTED_COLUMN, schema);
     String query =
         String.format("SELECT * FROM testTable WHERE %s = false", COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES);
     assertEquals(rows.get(0), new Object[]{false});
   }
@@ -947,16 +1171,11 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     initializeRows();
     insertRow(-1);
     insertRow(null);
-    TableConfig tableConfig =
-        new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).setSortedColumn(COLUMN1).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG_WITH_SORTED_COLUMN, schema);
     String query = String.format("SELECT * FROM testTable WHERE %s < 0", COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES);
     assertEquals(rows.get(0), new Object[]{-1});
   }
@@ -967,16 +1186,11 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     initializeRows();
     insertRow(null);
     insertRow(Integer.MIN_VALUE);
-    TableConfig tableConfig =
-        new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).setSortedColumn(COLUMN1).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG_WITH_SORTED_COLUMN, schema);
     String query = String.format("SELECT * FROM testTable WHERE %s = %d", COLUMN1, Integer.MIN_VALUE);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES);
     assertEquals(rows.get(0), new Object[]{Integer.MIN_VALUE});
   }
@@ -992,18 +1206,16 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(-1, 1);
     insertRowWithTwoColumns(1, null);
     insertRowWithTwoColumns(null, -1);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT %s, %s FROM testTable WHERE OR(%s > 0, %s < 0) LIMIT 100", COLUMN1, COLUMN2, COLUMN1,
             COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES * 3);
   }
 
@@ -1014,15 +1226,11 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRow(null);
     insertRow(-1);
     insertRow(1);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT %s FROM testTable WHERE NOT(%s = 1) LIMIT 100", COLUMN1, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES);
     assertEquals(rows.get(0), new Object[]{-1});
   }
@@ -1038,18 +1246,16 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(-1, 1);
     insertRowWithTwoColumns(1, null);
     insertRowWithTwoColumns(null, -1);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT %s, %s FROM testTable WHERE NOT(AND(%s > 0, %s < 0)) LIMIT 100", COLUMN1, COLUMN2,
             COLUMN1, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 3 * NUM_OF_SEGMENT_COPIES);
   }
 
@@ -1064,18 +1270,16 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(-1, 1);
     insertRowWithTwoColumns(1, null);
     insertRowWithTwoColumns(null, -1);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT %s, %s FROM testTable WHERE NOT(OR(%s > 0, %s < 0)) LIMIT 100", COLUMN1, COLUMN2, COLUMN1,
             COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES);
     assertEquals(rows.get(0), new Object[]{-1, 1});
   }
@@ -1086,16 +1290,11 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     initializeRows();
     insertRow(false);
     insertRow(true);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
-        .setInvertedIndexColumns(List.of(COLUMN1)).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.BOOLEAN).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.BOOLEAN).build();
+    setUpSegments(TABLE_CONFIG_WITH_INVERTED_INDEX_COLUMN, schema);
     String query = String.format("SELECT * FROM testTable WHERE NOT(%s = false)", COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES);
     assertEquals(rows.get(0), new Object[]{true});
   }
@@ -1108,15 +1307,11 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRow(Integer.MIN_VALUE);
     insertRow(1);
     insertRow(-1);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT %s FROM testTable WHERE add(%s, 0) < 0", COLUMN1, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES * 2);
   }
 
@@ -1128,15 +1323,11 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRow(Integer.MIN_VALUE);
     insertRow(1);
     insertRow(-1);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT %s FROM testTable WHERE NOT(add(%s, 0) > 0)", COLUMN1, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES * 2);
   }
 
@@ -1152,18 +1343,16 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(-1, null);
     insertRowWithTwoColumns(null, -1);
     insertRowWithTwoColumns(1, 1);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT %s, %s FROM testTable WHERE GREATEST(%s, %s) < 0 LIMIT 100", COLUMN1, COLUMN2, COLUMN1,
             COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES * 2);
   }
 
@@ -1175,17 +1364,15 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
       insertRowWithTwoColumns(null, i);
     }
     insertRowWithTwoColumns(1, DocIdSetPlanNode.MAX_DOC_PER_CALL);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT %s, %s FROM testTable WHERE add(%s, 0) > 0 LIMIT 10", COLUMN1, COLUMN2, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES);
     assertEquals(rows.get(0), new Object[]{1, DocIdSetPlanNode.MAX_DOC_PER_CALL});
   }
@@ -1198,17 +1385,15 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(1, null);
     insertRowWithTwoColumns(-1, 1);
     insertRowWithTwoColumns(Integer.MIN_VALUE, null);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT %s FROM testTable WHERE NOT(add(%s, 0) > 0) AND %s IS NULL", COLUMN1, COLUMN1, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES);
     assertEquals(rows.get(0), new Object[]{Integer.MIN_VALUE});
   }
@@ -1218,15 +1403,11 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
       throws Exception {
     initializeRows();
     insertRow(new Integer[]{1, 2, 3});
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addMultiValueDimension(COLUMN1, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addMultiValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT * FROM testTable WHERE NOT(VALUEIN(%s, 2, 3) > 2) LIMIT 100", COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query, null);
     assertEquals(rows.size(), 0);
   }
 
@@ -1237,17 +1418,15 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(null, 1);
     insertRowWithTwoColumns(1, 2);
     insertRowWithTwoColumns(-1, 3);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT %s, %s FROM testTable WHERE ADD(%s, 0) IS NULL LIMIT 100", COLUMN1, COLUMN2, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES);
     assertEquals(rows.get(0), new Object[]{null, 1});
   }
@@ -1259,17 +1438,15 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(null, 1);
     insertRowWithTwoColumns(null, 2);
     insertRowWithTwoColumns(1, 3);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT %s, %s FROM testTable WHERE ADD(%s, 0) IS NOT NULL LIMIT 100", COLUMN1, COLUMN2, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES);
     assertEquals(rows.get(0), new Object[]{1, 3});
   }
@@ -1281,18 +1458,16 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(null, 1);
     insertRowWithTwoColumns(null, 2);
     insertRowWithTwoColumns(1, 3);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT %s, %s FROM testTable WHERE NOT(ADD(%s, 0) IS NULL) LIMIT 100", COLUMN1, COLUMN2,
             COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES);
     assertEquals(rows.get(0), new Object[]{1, 3});
   }
@@ -1303,18 +1478,16 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     initializeRows();
     insertRowWithTwoColumns(null, 1);
     insertRowWithTwoColumns(2, 3);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT %s, %s FROM testTable WHERE NOT(ADD(%s, 0) IS NOT NULL) LIMIT 100", COLUMN1, COLUMN2,
             COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES);
     assertEquals(rows.get(0), new Object[]{null, 1});
   }
@@ -1326,17 +1499,15 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(true, 1);
     insertRowWithTwoColumns(null, 2);
     insertRowWithTwoColumns(false, 3);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.BOOLEAN)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.BOOLEAN)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT %s, %s FROM testTable WHERE (NOT %s) IS NULL LIMIT 100", COLUMN1, COLUMN2, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES);
     assertEquals(rows.get(0), new Object[]{null, 2});
   }
@@ -1349,18 +1520,16 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(1, null);
     insertRowWithTwoColumns(-1, 1);
     insertRowWithTwoColumns(null, null);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT %s, %s FROM testTable WHERE (add(%s, 0) IS NULL) AND (%s IS NULL)", COLUMN1, COLUMN2,
             COLUMN1, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES);
     assertEquals(rows.get(0), new Object[]{null, null});
   }
@@ -1371,17 +1540,15 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     initializeRows();
     insertRowWithTwoColumns(new Integer[]{1, 2, 3}, 1);
     insertRowWithTwoColumns(new Integer[]{2, 3, 4}, null);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addMultiValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addMultiValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT * FROM testTable WHERE (VALUEIN(%s, 2, 3) IN (2, 3)) AND (%s = 1)", COLUMN1, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES);
     assertEquals(rows.get(0), new Object[]{new Integer[]{1, 2, 3}, 1});
   }
@@ -1391,15 +1558,11 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
       throws Exception {
     initializeRows();
     insertRow(new Integer[]{1, 2, 3});
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addMultiValueDimension(COLUMN1, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addMultiValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT * FROM testTable WHERE (VALUEIN(%s, 2, 3) IS NULL)", COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 0);
   }
 
@@ -1408,15 +1571,11 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
       throws Exception {
     initializeRows();
     insertRow(new Integer[]{1, 2, 3});
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addMultiValueDimension(COLUMN1, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addMultiValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT * FROM testTable WHERE (VALUEIN(%s, 2, 3) IS NOT NULL)", COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES);
     assertEquals(rows.get(0), new Object[]{new Integer[]{1, 2, 3}});
   }
@@ -1427,15 +1586,11 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     initializeRows();
     insertRow("abc");
     insertRow(null);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.STRING).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.STRING).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT STARTSWITH(%s, NULL) FROM testTable", COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 2 * NUM_OF_SEGMENT_COPIES);
     for (int i = 0; i < 2 * NUM_OF_SEGMENT_COPIES; i++) {
       assertEquals(rows.get(i), new Object[]{null});
@@ -1447,35 +1602,27 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
       throws Exception {
     initializeRows();
     insertRow(1);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.INT).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT between(%s, NULL, 2) FROM testTable", COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), NUM_OF_SEGMENT_COPIES);
     assertEquals(rows.get(0), new Object[]{null});
   }
 
   @Test(dataProvider = "NumberTypes")
-  public void testStddevPop(FieldSpec.DataType dataType)
+  public void testStddevPop(DataType dataType)
       throws Exception {
     initializeRows();
     insertRow(null);
     insertRow(1);
     insertRow(2);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, dataType).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, dataType).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT STDDEV_POP(%s) FROM testTable", COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 1);
     assertEquals(rows.get(0)[0], 0.5);
   }
@@ -1489,15 +1636,14 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     insertRowWithTwoColumns(null, 20);
     insertRowWithTwoColumns(3, null);
     insertRowWithTwoColumns(4, 40);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT COVAR_POP(%s, %s) FROM testTable", COLUMN1, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    List<Object[]> rows = brokerResponse.getResultTable().getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 1);
     // Only rows 0 and 3 contribute: mean(xy) - mean(x)mean(y) = 85 - 2.5 * 25
     assertEquals(rows.get(0)[0], 22.5);
@@ -1509,15 +1655,14 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     initializeRows();
     insertRowWithTwoColumns(null, 10);
     insertRowWithTwoColumns(null, 20);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT COVAR_POP(%s, %s) FROM testTable", COLUMN1, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    List<Object[]> rows = brokerResponse.getResultTable().getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 1);
     assertNull(rows.get(0)[0]);
   }
@@ -1529,59 +1674,54 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
     initializeRows();
     insertRowWithTwoColumns(null, 10);
     insertRowWithTwoColumns(null, 20);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.INT).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.INT)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT COVAR_POP(%s, %s) FROM testTable", COLUMN1, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query);
-
-    List<Object[]> rows = brokerResponse.getResultTable().getRows();
+    List<Object[]> rows = getRows(query, null);
     assertEquals(rows.size(), 1);
     assertEquals(rows.get(0)[0], 0.0);
   }
 
   @Test(dataProvider = "NumberTypes")
-  public void testGroupByStddevPop(FieldSpec.DataType dataType)
+  public void testGroupByStddevPop(DataType dataType)
       throws Exception {
     initializeRows();
     insertRowWithTwoColumns(null, "key");
     insertRowWithTwoColumns(1, "key");
     insertRowWithTwoColumns(2, "key");
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, dataType)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.STRING).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, dataType)
+        .addSingleValueDimension(COLUMN2, DataType.STRING)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT STDDEV_POP(%s), %s FROM testTable GROUP BY %s", COLUMN1, COLUMN2, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 1);
     assertEquals(rows.get(0), new Object[]{0.5, "key"});
   }
 
   @Test(dataProvider = "NumberTypes")
-  public void testGroupByMvStddevPop(FieldSpec.DataType dataType)
+  public void testGroupByMvStddevPop(DataType dataType)
       throws Exception {
     initializeRows();
     insertRowWithTwoColumns(null, new String[]{"key1", "key2"});
     insertRowWithTwoColumns(1, new String[]{"key1", "key2"});
     insertRowWithTwoColumns(2, new String[]{"key1"});
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, dataType)
-        .addMultiValueDimension(COLUMN2, FieldSpec.DataType.STRING).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, dataType)
+        .addMultiValueDimension(COLUMN2, DataType.STRING)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT STDDEV_POP(%s), %s FROM testTable GROUP BY %s ORDER BY %s", COLUMN1, COLUMN2, COLUMN2,
             COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 2);
     assertEquals(rows.get(0), new Object[]{0.5, "key1"});
     assertEquals(rows.get(1), new Object[]{0.0, "key2"});
@@ -1592,20 +1732,18 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
       throws Exception {
     initializeRows();
     insertRowWithTwoColumns(null, "key1");
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.INT)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.STRING).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.INT)
+        .addSingleValueDimension(COLUMN2, DataType.STRING)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query =
         String.format("SELECT STDDEV_POP(%s), %s FROM testTable GROUP BY %s ORDER BY %s", COLUMN1, COLUMN2, COLUMN2,
             COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 1);
-    assertEquals(rows.get(0)[0], null);
+    assertNull(rows.get(0)[0]);
   }
 
   @Test
@@ -1613,17 +1751,13 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
       throws Exception {
     initializeRows();
     insertRow(null);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.DOUBLE).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.DOUBLE).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT STDDEV_POP(%s) FROM testTable", COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query);
     assertEquals(rows.size(), 1);
-    assertEquals(rows.get(0)[0], null);
+    assertNull(rows.get(0)[0]);
   }
 
   @Test
@@ -1631,15 +1765,11 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
       throws Exception {
     initializeRows();
     insertRow(1);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.DOUBLE).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder().addSingleValueDimension(COLUMN1, DataType.DOUBLE).build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT STDDEV_POP(%s) FROM testTable WHERE %s != 1", COLUMN1, COLUMN1);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query);
-
-    ResultTable resultTable = brokerResponse.getResultTable();
-    List<Object[]> rows = resultTable.getRows();
+    List<Object[]> rows = getRows(query, null);
     assertEquals(rows.size(), 1);
     assertEquals(rows.get(0)[0], Double.NEGATIVE_INFINITY);
   }
@@ -1649,15 +1779,14 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
       throws Exception {
     initializeRows();
     insertRowWithTwoColumns(true, null);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.BOOLEAN)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.BOOLEAN).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.BOOLEAN)
+        .addSingleValueDimension(COLUMN2, DataType.BOOLEAN)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT AND(%s, %s) FROM testTable LIMIT 1", COLUMN1, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    assertEquals(brokerResponse.getResultTable().getRows().get(0)[0], null);
+    assertNull(getRows(query).get(0)[0]);
   }
 
   @Test
@@ -1665,15 +1794,14 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
       throws Exception {
     initializeRows();
     insertRowWithTwoColumns(false, null);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.BOOLEAN)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.BOOLEAN).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.BOOLEAN)
+        .addSingleValueDimension(COLUMN2, DataType.BOOLEAN)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT AND(%s, %s) FROM testTable LIMIT 1", COLUMN1, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    assertEquals(brokerResponse.getResultTable().getRows().get(0)[0], false);
+    assertFalse((boolean) getRows(query).get(0)[0]);
   }
 
   @Test
@@ -1681,15 +1809,14 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
       throws Exception {
     initializeRows();
     insertRowWithTwoColumns(null, null);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.BOOLEAN)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.BOOLEAN).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.BOOLEAN)
+        .addSingleValueDimension(COLUMN2, DataType.BOOLEAN)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT AND(%s, %s) FROM testTable LIMIT 1", COLUMN1, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    assertEquals(brokerResponse.getResultTable().getRows().get(0)[0], null);
+    assertNull(getRows(query).get(0)[0]);
   }
 
   @Test
@@ -1697,15 +1824,14 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
       throws Exception {
     initializeRows();
     insertRowWithTwoColumns(true, null);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.BOOLEAN)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.BOOLEAN).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.BOOLEAN)
+        .addSingleValueDimension(COLUMN2, DataType.BOOLEAN)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT OR(%s, %s) FROM testTable LIMIT 1", COLUMN1, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    assertEquals(brokerResponse.getResultTable().getRows().get(0)[0], true);
+    assertTrue((boolean) getRows(query).get(0)[0]);
   }
 
   @Test
@@ -1713,15 +1839,14 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
       throws Exception {
     initializeRows();
     insertRowWithTwoColumns(false, null);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.BOOLEAN)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.BOOLEAN).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.BOOLEAN)
+        .addSingleValueDimension(COLUMN2, DataType.BOOLEAN)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT OR(%s, %s) FROM testTable LIMIT 1", COLUMN1, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    assertEquals(brokerResponse.getResultTable().getRows().get(0)[0], null);
+    assertNull(getRows(query).get(0)[0]);
   }
 
   @Test
@@ -1729,15 +1854,14 @@ public class NullHandlingEnabledQueriesTest extends BaseQueriesTest {
       throws Exception {
     initializeRows();
     insertRowWithTwoColumns(null, null);
-    TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
-    Schema schema = new Schema.SchemaBuilder().addSingleValueDimension(COLUMN1, FieldSpec.DataType.BOOLEAN)
-        .addSingleValueDimension(COLUMN2, FieldSpec.DataType.BOOLEAN).build();
-    setUpSegments(tableConfig, schema);
+    Schema schema = schemaBuilder()
+        .addSingleValueDimension(COLUMN1, DataType.BOOLEAN)
+        .addSingleValueDimension(COLUMN2, DataType.BOOLEAN)
+        .build();
+    setUpSegments(TABLE_CONFIG, schema);
     String query = String.format("SELECT OR(%s, %s) FROM testTable LIMIT 1", COLUMN1, COLUMN2);
 
-    BrokerResponseNative brokerResponse = getBrokerResponse(query, QUERY_OPTIONS);
-
-    assertEquals(brokerResponse.getResultTable().getRows().get(0)[0], null);
+    assertNull(getRows(query).get(0)[0]);
   }
 
   @AfterMethod


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org