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

cwylie pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new 944dab401f1 fix bug causing wrong dimension merger order leading to 
exceptions or wrong data being used for a projection (#18380)
944dab401f1 is described below

commit 944dab401f13b7dce46a55a91a8016d9bf6f50be
Author: Clint Wylie <[email protected]>
AuthorDate: Fri Aug 8 03:53:55 2025 -0700

    fix bug causing wrong dimension merger order leading to exceptions or wrong 
data being used for a projection (#18380)
    
    changes:
    * fix accidental use of HashMap instead of LinkedHashMap causing projection 
column merger order to be unstable during segment merging
    * fix bug with overly restrictive projection filter matching
    * nicer `CursorFactoryProjectionTest`
---
 .../incremental/OnHeapAggregateProjection.java     |    3 +-
 .../druid/segment/projections/Projections.java     |   11 +-
 .../druid/segment/CursorFactoryProjectionTest.java | 1383 +++++++++-----------
 3 files changed, 659 insertions(+), 738 deletions(-)

diff --git 
a/processing/src/main/java/org/apache/druid/segment/incremental/OnHeapAggregateProjection.java
 
b/processing/src/main/java/org/apache/druid/segment/incremental/OnHeapAggregateProjection.java
index 5ac8f4a7174..2015c3cc4bb 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/incremental/OnHeapAggregateProjection.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/incremental/OnHeapAggregateProjection.java
@@ -52,7 +52,6 @@ import org.apache.druid.segment.column.ValueType;
 import javax.annotation.Nullable;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -103,7 +102,7 @@ public class OnHeapAggregateProjection implements 
IncrementalIndexRowSelector
     // always have its time-like column in the grouping columns list, so its 
position in this array specifies -1
     this.parentDimensionIndex = new 
int[projectionSpec.getGroupingColumns().size()];
     Arrays.fill(parentDimensionIndex, -1);
-    this.dimensionsMap = new HashMap<>();
+    this.dimensionsMap = new LinkedHashMap<>();
     this.columnFormats = new LinkedHashMap<>();
 
     initializeAndValidateDimensions(projectionSpec, getBaseTableDimensionDesc);
diff --git 
a/processing/src/main/java/org/apache/druid/segment/projections/Projections.java
 
b/processing/src/main/java/org/apache/druid/segment/projections/Projections.java
index f1971c05e7e..47258886017 100644
--- 
a/processing/src/main/java/org/apache/druid/segment/projections/Projections.java
+++ 
b/processing/src/main/java/org/apache/druid/segment/projections/Projections.java
@@ -20,6 +20,7 @@
 package org.apache.druid.segment.projections;
 
 import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
 import org.apache.druid.data.input.impl.AggregateProjectionSpec;
 import org.apache.druid.error.InvalidInput;
 import org.apache.druid.java.util.common.granularity.Granularities;
@@ -125,17 +126,18 @@ public class Projections
       return null;
     }
 
-    matchBuilder = matchFilter(projection, queryCursorBuildSpec, 
physicalColumnChecker, matchBuilder);
+
+    matchBuilder = matchGrouping(projection, queryCursorBuildSpec, 
physicalColumnChecker, matchBuilder);
     if (matchBuilder == null) {
       return null;
     }
 
-    matchBuilder = matchGrouping(projection, queryCursorBuildSpec, 
physicalColumnChecker, matchBuilder);
+    matchBuilder = matchAggregators(projection, queryCursorBuildSpec, 
matchBuilder);
     if (matchBuilder == null) {
       return null;
     }
 
-    matchBuilder = matchAggregators(projection, queryCursorBuildSpec, 
matchBuilder);
+    matchBuilder = matchFilter(projection, queryCursorBuildSpec, 
physicalColumnChecker, matchBuilder);
     if (matchBuilder == null) {
       return null;
     }
@@ -183,6 +185,7 @@ public class Projections
     if (projection.getFilter() != null) {
       final Filter queryFilter = queryCursorBuildSpec.getFilter();
       if (queryFilter != null) {
+        final Set<String> originalRequired = queryFilter.getRequiredColumns();
         // try to rewrite the query filter into a projection filter, if the 
rewrite is valid, we can proceed
         final Filter projectionFilter = 
projection.getFilter().toOptimizedFilter(false);
         final Map<String, String> filterRewrites = new HashMap<>();
@@ -204,9 +207,11 @@ public class Projections
         if (rewritten == ProjectionFilterMatch.INSTANCE) {
           // we can remove the whole thing since the query filter exactly 
matches the projection filter
           matchBuilder.rewriteFilter(null);
+          matchBuilder.addMatchedQueryColumns(originalRequired);
         } else {
           // otherwise, we partially rewrote the query filter to eliminate the 
projection filter since it is baked in
           matchBuilder.rewriteFilter(rewritten);
+          
matchBuilder.addMatchedQueryColumns(Sets.difference(originalRequired, 
rewritten.getRequiredColumns()));
         }
       } else {
         // projection has a filter, but the query doesn't, no good
diff --git 
a/processing/src/test/java/org/apache/druid/segment/CursorFactoryProjectionTest.java
 
b/processing/src/test/java/org/apache/druid/segment/CursorFactoryProjectionTest.java
index 555afa1ae81..7f9e82afb2e 100644
--- 
a/processing/src/test/java/org/apache/druid/segment/CursorFactoryProjectionTest.java
+++ 
b/processing/src/test/java/org/apache/druid/segment/CursorFactoryProjectionTest.java
@@ -43,8 +43,10 @@ import org.apache.druid.java.util.common.Pair;
 import org.apache.druid.java.util.common.granularity.Granularities;
 import org.apache.druid.java.util.common.guava.Sequence;
 import org.apache.druid.java.util.common.io.Closer;
+import org.apache.druid.query.DefaultQueryMetrics;
 import org.apache.druid.query.DruidProcessingConfig;
 import org.apache.druid.query.Druids;
+import org.apache.druid.query.Query;
 import org.apache.druid.query.QueryContexts;
 import org.apache.druid.query.Result;
 import org.apache.druid.query.aggregation.AggregatorFactory;
@@ -58,6 +60,7 @@ import org.apache.druid.query.expression.TestExprMacroTable;
 import org.apache.druid.query.filter.EqualityFilter;
 import org.apache.druid.query.groupby.GroupByQuery;
 import org.apache.druid.query.groupby.GroupByQueryConfig;
+import org.apache.druid.query.groupby.GroupByQueryMetrics;
 import org.apache.druid.query.groupby.GroupByResourcesReservationPool;
 import org.apache.druid.query.groupby.GroupByStatsProvider;
 import org.apache.druid.query.groupby.GroupingEngine;
@@ -67,6 +70,7 @@ import 
org.apache.druid.query.groupby.orderby.OrderByColumnSpec;
 import org.apache.druid.query.ordering.StringComparators;
 import org.apache.druid.query.timeseries.TimeseriesQuery;
 import org.apache.druid.query.timeseries.TimeseriesQueryEngine;
+import org.apache.druid.query.timeseries.TimeseriesQueryMetrics;
 import org.apache.druid.query.timeseries.TimeseriesResultValue;
 import org.apache.druid.segment.column.ColumnType;
 import org.apache.druid.segment.column.RowSignature;
@@ -82,15 +86,18 @@ import org.junit.Assert;
 import org.junit.Assume;
 import org.junit.Rule;
 import org.junit.Test;
+import org.junit.jupiter.api.Assertions;
 import org.junit.runner.RunWith;
 import org.junit.runners.Parameterized;
 
+import javax.annotation.Nullable;
 import java.io.File;
 import java.io.IOException;
 import java.nio.ByteBuffer;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -248,7 +255,7 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                              .build(),
       AggregateProjectionSpec.builder("missing_column")
                              .groupingColumns(new 
StringDimensionSchema("missing"))
-                             .aggregators(new LongSumAggregatorFactory("csum", 
"c"))
+                             .aggregators(new 
DoubleSumAggregatorFactory("dsum", "d"))
                              .build(),
       AggregateProjectionSpec.builder("json")
                              .groupingColumns(new AutoTypeColumnSchema("f", 
null))
@@ -265,6 +272,14 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                                  new LongSumAggregatorFactory("_c_sum", "c"),
                                  new DoubleSumAggregatorFactory("d", "d")
                              )
+                             .build(),
+      AggregateProjectionSpec.builder("a_concat_b_d_plus_f_sum_c")
+                             .virtualColumns(
+                                 new ExpressionVirtualColumn("__vc2", "d + e", 
ColumnType.LONG, TestExprMacroTable.INSTANCE),
+                                 new ExpressionVirtualColumn("__vc3", 
"concat(a, b)", ColumnType.STRING, TestExprMacroTable.INSTANCE)
+                             )
+                             .groupingColumns(new 
LongDimensionSchema("__vc2"), new StringDimensionSchema("__vc3"))
+                             .aggregators(new 
LongSumAggregatorFactory("sum_c", "c"))
                              .build()
   );
 
@@ -501,7 +516,6 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
   @Test
   public void testProjectionSelectionTwoDims()
   {
-    // this query can use the projection with 2 dims, which has 7 rows instead 
of the total of 8
     final GroupByQuery query =
         GroupByQuery.builder()
                     .setDataSource("test")
@@ -511,58 +525,29 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                     .addDimension("b")
                     .build();
 
-    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, null);
+    final ExpectedProjectionGroupBy queryMetrics = new 
ExpectedProjectionGroupBy("ab");
 
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(6, rowCount);
-    }
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
 
-    final Sequence<ResultRow> resultRows = groupingEngine.process(
+    assertCursorProjection(buildSpec, queryMetrics, 6);
+
+    testGroupBy(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        nonBlockingPool,
-        null
+        queryMetrics,
+        makeArrayResultSet(
+            new Object[]{"b", "bb"},
+            new Object[]{"a", "dd"},
+            new Object[]{"b", "aa"},
+            new Object[]{"a", "cc"},
+            new Object[]{"a", "bb"},
+            new Object[]{"a", "aa"}
+        )
     );
-
-    final List<ResultRow> results = resultRows.toList();
-    Assert.assertEquals(6, results.size());
-    if (projectionsCursorFactory instanceof QueryableIndexCursorFactory) {
-      if (autoSchema) {
-        Assert.assertArrayEquals(new Object[]{"b", "bb"}, 
results.get(0).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "dd"}, 
results.get(1).getArray());
-        Assert.assertArrayEquals(new Object[]{"b", "aa"}, 
results.get(2).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "cc"}, 
results.get(3).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "bb"}, 
results.get(4).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "aa"}, 
results.get(5).getArray());
-      } else {
-        Assert.assertArrayEquals(new Object[]{"a", "dd"}, 
results.get(0).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "aa"}, 
results.get(1).getArray());
-        Assert.assertArrayEquals(new Object[]{"b", "aa"}, 
results.get(2).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "cc"}, 
results.get(3).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "bb"}, 
results.get(4).getArray());
-        Assert.assertArrayEquals(new Object[]{"b", "bb"}, 
results.get(5).getArray());
-      }
-    } else {
-      Assert.assertArrayEquals(new Object[]{"a", "aa"}, 
results.get(0).getArray());
-      Assert.assertArrayEquals(new Object[]{"a", "bb"}, 
results.get(1).getArray());
-      Assert.assertArrayEquals(new Object[]{"a", "cc"}, 
results.get(2).getArray());
-      Assert.assertArrayEquals(new Object[]{"a", "dd"}, 
results.get(3).getArray());
-      Assert.assertArrayEquals(new Object[]{"b", "aa"}, 
results.get(4).getArray());
-      Assert.assertArrayEquals(new Object[]{"b", "bb"}, 
results.get(5).getArray());
-    }
   }
 
   @Test
   public void testProjectionSelectionTwoDimsVirtual()
   {
-    // this query can use the projection with 2 dims, which has 7 rows instead 
of the total of 8
     final GroupByQuery query =
         GroupByQuery.builder()
                     .setDataSource("test")
@@ -590,53 +575,23 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                     .setContext(ImmutableMap.of(QueryContexts.USE_PROJECTION, 
"abfoo"))
                     .build();
 
-    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, null);
+    final ExpectedProjectionGroupBy queryMetrics = new 
ExpectedProjectionGroupBy("abfoo");
 
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(6, rowCount);
-    }
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
+    assertCursorProjection(buildSpec, queryMetrics, 6);
 
-    final Sequence<ResultRow> resultRows = groupingEngine.process(
+    testGroupBy(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        nonBlockingPool,
-        null
+        queryMetrics,
+        makeArrayResultSet(
+            new Object[]{"b", "bbfoo"},
+            new Object[]{"a", "ddfoo"},
+            new Object[]{"b", "aafoo"},
+            new Object[]{"a", "ccfoo"},
+            new Object[]{"a", "bbfoo"},
+            new Object[]{"a", "aafoo"}
+        )
     );
-
-    final List<ResultRow> results = resultRows.toList();
-    Assert.assertEquals(6, results.size());
-    if (projectionsCursorFactory instanceof QueryableIndexCursorFactory) {
-      // testing ordering of stuff is kind of tricky at this level...
-      if (autoSchema) {
-        Assert.assertArrayEquals(new Object[]{"b", "bbfoo"}, 
results.get(0).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "ddfoo"}, 
results.get(1).getArray());
-        Assert.assertArrayEquals(new Object[]{"b", "aafoo"}, 
results.get(2).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "ccfoo"}, 
results.get(3).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "bbfoo"}, 
results.get(4).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "aafoo"}, 
results.get(5).getArray());
-      } else {
-        Assert.assertArrayEquals(new Object[]{"a", "ddfoo"}, 
results.get(0).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "aafoo"}, 
results.get(1).getArray());
-        Assert.assertArrayEquals(new Object[]{"b", "aafoo"}, 
results.get(2).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "ccfoo"}, 
results.get(3).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "bbfoo"}, 
results.get(4).getArray());
-        Assert.assertArrayEquals(new Object[]{"b", "bbfoo"}, 
results.get(5).getArray());
-      }
-    } else {
-      Assert.assertArrayEquals(new Object[]{"a", "aafoo"}, 
results.get(0).getArray());
-      Assert.assertArrayEquals(new Object[]{"a", "bbfoo"}, 
results.get(1).getArray());
-      Assert.assertArrayEquals(new Object[]{"a", "ccfoo"}, 
results.get(2).getArray());
-      Assert.assertArrayEquals(new Object[]{"a", "ddfoo"}, 
results.get(3).getArray());
-      Assert.assertArrayEquals(new Object[]{"b", "aafoo"}, 
results.get(4).getArray());
-      Assert.assertArrayEquals(new Object[]{"b", "bbfoo"}, 
results.get(5).getArray());
-    }
   }
 
   @Test
@@ -653,52 +608,23 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                     .addAggregator(new CountAggregatorFactory("count"))
                     .build();
 
-    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, null);
+    final ExpectedProjectionGroupBy queryMetrics = new 
ExpectedProjectionGroupBy(null);
 
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(8, rowCount);
-    }
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
+    assertCursorNoProjection(buildSpec, queryMetrics);
 
-    final Sequence<ResultRow> resultRows = groupingEngine.process(
+    testGroupBy(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        nonBlockingPool,
-        null
+        queryMetrics,
+        makeArrayResultSet(
+            new Object[]{"b", "aa", 2L},
+            new Object[]{"a", "cc", 1L},
+            new Object[]{"a", "bb", 1L},
+            new Object[]{"b", "bb", 1L},
+            new Object[]{"a", "dd", 1L},
+            new Object[]{"a", "aa", 2L}
+        )
     );
-
-    final List<ResultRow> results = resultRows.toList();
-    Assert.assertEquals(6, results.size());
-    if (projectionsCursorFactory instanceof QueryableIndexCursorFactory) {
-      if (autoSchema) {
-        Assert.assertArrayEquals(new Object[]{"b", "aa", 2L}, 
results.get(0).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "cc", 1L}, 
results.get(1).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "bb", 1L}, 
results.get(2).getArray());
-        Assert.assertArrayEquals(new Object[]{"b", "bb", 1L}, 
results.get(3).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "dd", 1L}, 
results.get(4).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "aa", 2L}, 
results.get(5).getArray());
-      } else {
-        Assert.assertArrayEquals(new Object[]{"a", "dd", 1L}, 
results.get(0).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "aa", 2L}, 
results.get(1).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "bb", 1L}, 
results.get(2).getArray());
-        Assert.assertArrayEquals(new Object[]{"b", "aa", 2L}, 
results.get(3).getArray());
-        Assert.assertArrayEquals(new Object[]{"a", "cc", 1L}, 
results.get(4).getArray());
-        Assert.assertArrayEquals(new Object[]{"b", "bb", 1L}, 
results.get(5).getArray());
-      }
-    } else {
-      Assert.assertArrayEquals(new Object[]{"a", "aa", 2L}, 
results.get(0).getArray());
-      Assert.assertArrayEquals(new Object[]{"a", "bb", 1L}, 
results.get(1).getArray());
-      Assert.assertArrayEquals(new Object[]{"a", "cc", 1L}, 
results.get(2).getArray());
-      Assert.assertArrayEquals(new Object[]{"b", "aa", 2L}, 
results.get(3).getArray());
-      Assert.assertArrayEquals(new Object[]{"b", "bb", 1L}, 
results.get(4).getArray());
-      Assert.assertArrayEquals(new Object[]{"a", "dd", 1L}, 
results.get(5).getArray());
-    }
   }
 
   @Test
@@ -739,40 +665,24 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                     .addAggregator(new LongLastAggregatorFactory("c_last", 
"c", null))
                     .setContext(ImmutableMap.of(QueryContexts.NO_PROJECTIONS, 
true))
                     .build();
-    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      // has to scan full 8 rows because context ensures projections not used
-      Assert.assertEquals(8, rowCount);
-    }
-    final Sequence<ResultRow> resultRows = groupingEngine.process(
+    final ExpectedProjectionGroupBy queryMetrics = new 
ExpectedProjectionGroupBy(null);
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
+
+    assertCursorNoProjection(buildSpec, queryMetrics);
+
+    testGroupBy(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        nonBlockingPool,
-        null
-    );
-    final List<ResultRow> results = resultRows.toList();
-    Assert.assertEquals(2, results.size());
-    Assert.assertArrayEquals(
-        new Object[]{"a", 7L, 
Pair.of(TIMESTAMP.plusHours(1).plusMinutes(1).getMillis(), 2L)},
-        results.get(0).getArray()
-    );
-    Assert.assertArrayEquals(
-        new Object[]{"b", 12L, Pair.of(TIMESTAMP.plusMinutes(10).getMillis(), 
5L)},
-        results.get(1).getArray()
+        queryMetrics,
+        List.of(
+            new Object[]{"a", 7L, 
Pair.of(TIMESTAMP.plusHours(1).plusMinutes(1).getMillis(), 2L)},
+            new Object[]{"b", 12L, 
Pair.of(TIMESTAMP.plusMinutes(10).getMillis(), 5L)}
+        )
     );
   }
 
   @Test
   public void testProjectionSingleDim()
   {
-    // test can use the single dimension projection
     final GroupByQuery query =
         GroupByQuery.builder()
                     .setDataSource("test")
@@ -782,76 +692,51 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                     .addAggregator(new LongSumAggregatorFactory("c_sum", "c"))
                     .addAggregator(new LongLastAggregatorFactory("c_last", 
"c", null))
                     .build();
-    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(3, rowCount);
-    }
-    final Sequence<ResultRow> resultRows = groupingEngine.process(
+    final ExpectedProjectionGroupBy queryMetrics =
+        new ExpectedProjectionGroupBy("a_hourly_c_sum_with_count_latest");
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
+
+    assertCursorProjection(buildSpec, queryMetrics, 3);
+
+    testGroupBy(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        nonBlockingPool,
-        null
-    );
-    final List<ResultRow> results = resultRows.toList();
-    Assert.assertEquals(2, results.size());
-    Assert.assertArrayEquals(
-        new Object[]{"a", 7L, 
Pair.of(TIMESTAMP.plusHours(1).plusMinutes(1).getMillis(), 2L)},
-        results.get(0).getArray()
-    );
-    Assert.assertArrayEquals(
-        new Object[]{"b", 12L, Pair.of(TIMESTAMP.plusMinutes(10).getMillis(), 
5L)},
-        results.get(1).getArray()
+        queryMetrics,
+        List.of(
+            new Object[]{"a", 7L, 
Pair.of(TIMESTAMP.plusHours(1).plusMinutes(1).getMillis(), 2L)},
+            new Object[]{"b", 12L, 
Pair.of(TIMESTAMP.plusMinutes(10).getMillis(), 5L)}
+        )
     );
   }
 
   @Test
   public void testProjectionSingleDimMissing()
   {
-    // test can use the single dimension projection
     final GroupByQuery query =
         GroupByQuery.builder()
                     .setDataSource("test")
                     .setGranularity(Granularities.ALL)
                     .setInterval(Intervals.ETERNITY)
                     .addDimension("missing")
-                    .addAggregator(new LongSumAggregatorFactory("c_sum", "c"))
+                    .addAggregator(new DoubleSumAggregatorFactory("d_sum", 
"d"))
                     .build();
-    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(1, rowCount);
-    }
-    final Sequence<ResultRow> resultRows = groupingEngine.process(
+    final ExpectedProjectionGroupBy queryMetrics =
+        new ExpectedProjectionGroupBy("missing_column");
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
+
+    assertCursorProjection(buildSpec, queryMetrics, 1);
+
+    testGroupBy(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        nonBlockingPool,
-        null
-    );
-    final List<ResultRow> results = resultRows.toList();
-    Assert.assertEquals(1, results.size());
-    Assert.assertArrayEquals(
-        new Object[]{null, 19L},
-        results.get(0).getArray()
+        queryMetrics,
+        Collections.singletonList(
+            new Object[]{null, 20.8}
+        )
     );
   }
 
   @Test
   public void testProjectionSingleDimFilter()
   {
-    // test can use the single dimension projection
     final GroupByQuery query =
         GroupByQuery.builder()
                     .setDataSource("test")
@@ -862,35 +747,24 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                     .addAggregator(new LongSumAggregatorFactory("c_sum", "c"))
                     .addAggregator(new LongLastAggregatorFactory("c_last", 
"c", null))
                     .build();
-    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(2, rowCount);
-    }
-    final Sequence<ResultRow> resultRows = groupingEngine.process(
+    final ExpectedProjectionGroupBy queryMetrics =
+        new ExpectedProjectionGroupBy("a_hourly_c_sum_with_count_latest");
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
+
+    assertCursorProjection(buildSpec, queryMetrics, 2);
+
+    testGroupBy(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        nonBlockingPool,
-        null
-    );
-    final List<ResultRow> results = resultRows.toList();
-    Assert.assertEquals(1, results.size());
-    Assert.assertArrayEquals(
-        new Object[]{"a", 7L, 
Pair.of(TIMESTAMP.plusHours(1).plusMinutes(1).getMillis(), 2L)},
-        results.get(0).getArray()
+        queryMetrics,
+        Collections.singletonList(
+            new Object[]{"a", 7L, 
Pair.of(TIMESTAMP.plusHours(1).plusMinutes(1).getMillis(), 2L)}
+        )
     );
   }
 
   @Test
   public void testProjectionSingleDimCount()
   {
-    // test can use the single dimension projection
     final GroupByQuery query =
         GroupByQuery.builder()
                     .setDataSource("test")
@@ -900,33 +774,25 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                     .addAggregator(new LongSumAggregatorFactory("c_sum", "c"))
                     .addAggregator(new CountAggregatorFactory("cnt"))
                     .build();
-    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(3, rowCount);
-    }
-    final Sequence<ResultRow> resultRows = groupingEngine.process(
+    final ExpectedProjectionGroupBy queryMetrics =
+        new ExpectedProjectionGroupBy("a_hourly_c_sum_with_count_latest");
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
+
+    assertCursorProjection(buildSpec, queryMetrics, 3);
+
+    testGroupBy(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        nonBlockingPool,
-        null
+        queryMetrics,
+        List.of(
+            new Object[]{"a", 7L, 5L},
+            new Object[]{"b", 12L, 3L}
+        )
     );
-    final List<ResultRow> results = resultRows.toList();
-    Assert.assertEquals(2, results.size());
-    Assert.assertArrayEquals(new Object[]{"a", 7L, 5L}, 
results.get(0).getArray());
-    Assert.assertArrayEquals(new Object[]{"b", 12L, 3L}, 
results.get(1).getArray());
   }
 
   @Test
   public void testProjectionSingleDimMultipleSameAggs()
   {
-    // test can use the single dimension projection
     final GroupByQuery query =
         GroupByQuery.builder()
                     .setDataSource("test")
@@ -936,27 +802,20 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                     .addAggregator(new LongSumAggregatorFactory("c_sum", "c"))
                     .addAggregator(new LongSumAggregatorFactory("c_sum_2", 
"c"))
                     .build();
-    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(3, rowCount);
-    }
-    final Sequence<ResultRow> resultRows = groupingEngine.process(
+    final ExpectedProjectionGroupBy queryMetrics =
+        new ExpectedProjectionGroupBy("a_hourly_c_sum_with_count_latest");
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
+
+    assertCursorProjection(buildSpec, queryMetrics, 3);
+
+    testGroupBy(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        nonBlockingPool,
-        null
+        queryMetrics,
+        List.of(
+            new Object[]{"a", 7L, 7L},
+            new Object[]{"b", 12L, 12L}
+        )
     );
-    final List<ResultRow> results = resultRows.toList();
-    Assert.assertEquals(2, results.size());
-    Assert.assertArrayEquals(new Object[]{"a", 7L, 7L}, 
results.get(0).getArray());
-    Assert.assertArrayEquals(new Object[]{"b", 12L, 12L}, 
results.get(1).getArray());
   }
 
   @Test
@@ -967,6 +826,7 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                     .setDataSource("test")
                     .setInterval(Intervals.ETERNITY)
                     .addAggregator(new LongSumAggregatorFactory("c_sum", "c"));
+    final ExpectedProjectionGroupBy queryMetrics = new 
ExpectedProjectionGroupBy(null);
     if (segmentSortedByTime) {
       queryBuilder.addDimension("a")
                   .setGranularity(Granularities.MINUTE);
@@ -979,73 +839,21 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                   .setGranularity(Granularities.ALL);
     }
     final GroupByQuery query = queryBuilder.build();
-    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(8, rowCount);
-    }
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
 
-    final Sequence<ResultRow> resultRows = groupingEngine.process(
+    assertCursorNoProjection(buildSpec, queryMetrics);
+
+    Set<Object[]> resultsInNoParticularOrder = makeArrayResultSet();
+    resultsInNoParticularOrder.addAll(
+        ROWS.stream()
+            .map(x -> new Object[]{x.getTimestamp().getMillis(), 
x.getRaw("a"), x.getRaw("c")})
+            .collect(Collectors.toList())
+    );
+    testGroupBy(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        nonBlockingPool,
-        null
+        queryMetrics,
+        resultsInNoParticularOrder
     );
-
-    final List<ResultRow> results = resultRows.toList();
-    Assert.assertEquals(8, results.size());
-
-    if (!segmentSortedByTime && projectionsCursorFactory instanceof 
QueryableIndexCursorFactory) {
-      // this sorts funny when not time ordered
-      Set<Object[]> resultsInNoParticularOrder = makeArrayResultSet();
-      resultsInNoParticularOrder.addAll(
-          ROWS.stream()
-              .map(x -> new Object[]{x.getTimestamp().getMillis(), 
x.getRaw("a"), x.getRaw("c")})
-              .collect(Collectors.toList())
-      );
-      for (ResultRow row : results) {
-        Assert.assertTrue(resultsInNoParticularOrder.contains(row.getArray()));
-      }
-    } else {
-      Assert.assertArrayEquals(
-          new Object[]{ROWS.get(0).getTimestamp().getMillis(), "a", 1L},
-          results.get(0).getArray()
-      );
-      Assert.assertArrayEquals(
-          new Object[]{ROWS.get(1).getTimestamp().getMillis(), "a", 1L},
-          results.get(1).getArray()
-      );
-      Assert.assertArrayEquals(
-          new Object[]{ROWS.get(2).getTimestamp().getMillis(), "a", 2L},
-          results.get(2).getArray()
-      );
-      Assert.assertArrayEquals(
-          new Object[]{ROWS.get(3).getTimestamp().getMillis(), "b", 3L},
-          results.get(3).getArray()
-      );
-      Assert.assertArrayEquals(
-          new Object[]{ROWS.get(4).getTimestamp().getMillis(), "b", 4L},
-          results.get(4).getArray()
-      );
-      Assert.assertArrayEquals(
-          new Object[]{ROWS.get(5).getTimestamp().getMillis(), "b", 5L},
-          results.get(5).getArray()
-      );
-      Assert.assertArrayEquals(
-          new Object[]{ROWS.get(6).getTimestamp().getMillis(), "a", 1L},
-          results.get(6).getArray()
-      );
-      Assert.assertArrayEquals(
-          new Object[]{ROWS.get(7).getTimestamp().getMillis(), "a", 2L},
-          results.get(7).getArray()
-      );
-    }
   }
 
   @Test
@@ -1056,6 +864,8 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                     .setDataSource("test")
                     .setInterval(Intervals.ETERNITY)
                     .addAggregator(new LongSumAggregatorFactory("c_sum", "c"));
+    final ExpectedProjectionGroupBy queryMetrics =
+        new ExpectedProjectionGroupBy("a_hourly_c_sum_with_count_latest");
     if (segmentSortedByTime) {
       queryBuilder.addDimension("a")
                   .setGranularity(Granularities.HOUR);
@@ -1068,41 +878,19 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                   
.setVirtualColumns(Granularities.toVirtualColumn(Granularities.HOUR, "__gran"));
     }
     final GroupByQuery query = queryBuilder.build();
-    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(3, rowCount);
-    }
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
 
-    final Sequence<ResultRow> resultRows = groupingEngine.process(
+    assertCursorProjection(buildSpec, queryMetrics, 3);
+
+    testGroupBy(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        nonBlockingPool,
-        null
+        queryMetrics,
+        makeArrayResultSet(
+            new Object[]{TIMESTAMP.getMillis(), "a", 4L},
+            new Object[]{TIMESTAMP.getMillis(), "b", 12L},
+            new Object[]{TIMESTAMP.plusHours(1).getMillis(), "a", 3L}
+        )
     );
-
-    final List<ResultRow> results = resultRows.toList();
-    Assert.assertEquals(3, results.size());
-    if (!segmentSortedByTime && projectionsCursorFactory instanceof 
QueryableIndexCursorFactory) {
-      Set<Object[]> resultsInNoParticularOrder = makeArrayResultSet(
-          new Object[]{TIMESTAMP.getMillis(), "a", 4L},
-          new Object[]{TIMESTAMP.getMillis(), "b", 12L},
-          new Object[]{TIMESTAMP.plusHours(1).getMillis(), "a", 3L}
-      );
-      for (ResultRow row : results) {
-        Assert.assertTrue(resultsInNoParticularOrder.contains(row.getArray()));
-      }
-    } else {
-      Assert.assertArrayEquals(new Object[]{TIMESTAMP.getMillis(), "a", 4L}, 
results.get(0).getArray());
-      Assert.assertArrayEquals(new Object[]{TIMESTAMP.getMillis(), "b", 12L}, 
results.get(1).getArray());
-      Assert.assertArrayEquals(new 
Object[]{TIMESTAMP.plusHours(1).getMillis(), "a", 3L}, 
results.get(2).getArray());
-    }
   }
 
   @Test
@@ -1113,6 +901,8 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                     .setDataSource("test")
                     .setInterval(Intervals.ETERNITY)
                     .addAggregator(new LongSumAggregatorFactory("c_sum", "c"));
+    final ExpectedProjectionGroupBy queryMetrics =
+        new ExpectedProjectionGroupBy(segmentSortedByTime ? null : 
"b_hourly_c_sum_non_time_ordered");
     if (segmentSortedByTime) {
       queryBuilder.addDimension("b")
                   .setGranularity(Granularities.HOUR);
@@ -1125,37 +915,21 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                   
.setVirtualColumns(Granularities.toVirtualColumn(Granularities.HOUR, "__gran"));
     }
     final GroupByQuery query = queryBuilder.build();
-    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(segmentSortedByTime ? 8 : 5, rowCount);
-    }
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
 
-    final Sequence<ResultRow> resultRows = groupingEngine.process(
-        query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        nonBlockingPool,
-        null
-    );
+    assertCursorProjection(buildSpec, queryMetrics, segmentSortedByTime ? 8 : 
5);
 
-    final List<ResultRow> results = resultRows.toList();
-    Assert.assertEquals(5, results.size());
-    Set<Object[]> resultsInNoParticularOrder = makeArrayResultSet(
-        new Object[]{TIMESTAMP.getMillis(), "aa", 8L},
-        new Object[]{TIMESTAMP.getMillis(), "bb", 6L},
-        new Object[]{TIMESTAMP.getMillis(), "cc", 2L},
-        new Object[]{TIMESTAMP.plusHours(1).getMillis(), "aa", 1L},
-        new Object[]{TIMESTAMP.plusHours(1).getMillis(), "dd", 2L}
+    testGroupBy(
+        query,
+        queryMetrics,
+        makeArrayResultSet(
+            new Object[]{TIMESTAMP.getMillis(), "aa", 8L},
+            new Object[]{TIMESTAMP.getMillis(), "bb", 6L},
+            new Object[]{TIMESTAMP.getMillis(), "cc", 2L},
+            new Object[]{TIMESTAMP.plusHours(1).getMillis(), "aa", 1L},
+            new Object[]{TIMESTAMP.plusHours(1).getMillis(), "dd", 2L}
+        )
     );
-    for (ResultRow row : results) {
-      Assert.assertTrue("missing row" + row.toString(), 
resultsInNoParticularOrder.contains(row.getArray()));
-    }
   }
 
 
@@ -1167,6 +941,8 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                     .setDataSource("test")
                     .setInterval(Intervals.ETERNITY)
                     .addAggregator(new LongSumAggregatorFactory("c_sum", "c"));
+    final ExpectedProjectionGroupBy queryMetrics =
+        new ExpectedProjectionGroupBy("a_hourly_c_sum_with_count_latest");
     if (segmentSortedByTime) {
       queryBuilder.addDimension("a")
                   .setGranularity(Granularities.DAY);
@@ -1179,45 +955,23 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                   
.setVirtualColumns(Granularities.toVirtualColumn(Granularities.DAY, "__gran"));
     }
     final GroupByQuery query = queryBuilder.build();
-    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(3, rowCount);
-    }
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
 
-    final Sequence<ResultRow> resultRows = groupingEngine.process(
+    assertCursorProjection(buildSpec, queryMetrics, 3);
+
+    testGroupBy(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        nonBlockingPool,
-        null
+        queryMetrics,
+        makeArrayResultSet(
+            new Object[]{TIMESTAMP.getMillis(), "a", 7L},
+            new Object[]{TIMESTAMP.getMillis(), "b", 12L}
+        )
     );
-
-    final List<ResultRow> results = resultRows.toList();
-    Assert.assertEquals(2, results.size());
-    if (!segmentSortedByTime && projectionsCursorFactory instanceof 
QueryableIndexCursorFactory) {
-      Set<Object[]> resultsInNoParticularOrder = makeArrayResultSet(
-          new Object[]{TIMESTAMP.getMillis(), "a", 7L},
-          new Object[]{TIMESTAMP.getMillis(), "b", 12L}
-      );
-      for (ResultRow row : results) {
-        Assert.assertTrue(resultsInNoParticularOrder.contains(row.getArray()));
-      }
-    } else {
-      Assert.assertArrayEquals(new Object[]{TIMESTAMP.getMillis(), "a", 7L}, 
results.get(0).getArray());
-      Assert.assertArrayEquals(new Object[]{TIMESTAMP.getMillis(), "b", 12L}, 
results.get(1).getArray());
-    }
   }
 
   @Test
   public void testProjectionSelectionMissingAggregatorButHasAggregatorInput()
   {
-    // e is present as a column on the projection, but since its an aggregate 
projection we cannot use it
     final GroupByQuery query =
         GroupByQuery.builder()
                     .setDataSource("test")
@@ -1227,37 +981,26 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                     .addAggregator(new LongSumAggregatorFactory("c_sum", "c"))
                     .addAggregator(new FloatSumAggregatorFactory("e_sum", "e"))
                     .build();
-    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(8, rowCount);
-    }
+    final ExpectedProjectionGroupBy queryMetrics = new 
ExpectedProjectionGroupBy(null);
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
 
-    final Sequence<ResultRow> resultRows = groupingEngine.process(
+    assertCursorNoProjection(buildSpec, queryMetrics);
+
+    testGroupBy(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        nonBlockingPool,
-        null
+        queryMetrics,
+        List.of(
+            new Object[]{"aa", 9L, 8.8f},
+            new Object[]{"bb", 6L, 6.6f},
+            new Object[]{"cc", 2L, 2.2f},
+            new Object[]{"dd", 2L, 2.2f}
+        )
     );
-
-    final List<ResultRow> results = resultRows.toList();
-    Assert.assertEquals(4, results.size());
-    Assert.assertArrayEquals(new Object[]{"aa", 9L, 8.8f}, 
results.get(0).getArray());
-    Assert.assertArrayEquals(new Object[]{"bb", 6L, 6.6f}, 
results.get(1).getArray());
-    Assert.assertArrayEquals(new Object[]{"cc", 2L, 2.2f}, 
results.get(2).getArray());
-    Assert.assertArrayEquals(new Object[]{"dd", 2L, 2.2f}, 
results.get(3).getArray());
   }
 
   @Test
   public void testProjectionSelectionMissingAggregatorAndAggregatorInput()
   {
-    // since d isn't present on the smaller projection, cant use it, but can 
still use the larger projection
     final GroupByQuery query =
         GroupByQuery.builder()
                     .setDataSource("test")
@@ -1267,35 +1010,25 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                     .addAggregator(new LongSumAggregatorFactory("c_sum", "c"))
                     .addAggregator(new DoubleSumAggregatorFactory("d_sum", 
"d"))
                     .build();
-    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(7, rowCount);
-    }
+    final ExpectedProjectionGroupBy queryMetrics =
+        new ExpectedProjectionGroupBy("ab_hourly_cd_sum");
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
 
-    final Sequence<ResultRow> resultRows = groupingEngine.process(
+    assertCursorProjection(buildSpec, queryMetrics, 7);
+
+    testGroupBy(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        nonBlockingPool,
-        null
+        queryMetrics,
+        List.of(
+            new Object[]{"a", 7L, 7.6000000000000005},
+            new Object[]{"b", 12L, 13.2}
+        )
     );
-
-    final List<ResultRow> results = resultRows.toList();
-    Assert.assertEquals(2, results.size());
-    Assert.assertArrayEquals(new Object[]{"a", 7L, 7.6000000000000005}, 
results.get(0).getArray());
-    Assert.assertArrayEquals(new Object[]{"b", 12L, 13.2}, 
results.get(1).getArray());
   }
 
   @Test
   public void testProjectionSelectionMissingColumnOnBaseTableToo()
   {
-    // since d isn't present on the smaller projection, cant use it, but can 
still use the larger projection
     final GroupByQuery query =
         GroupByQuery.builder()
                     .setDataSource("test")
@@ -1306,29 +1039,20 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                     .addAggregator(new LongSumAggregatorFactory("c_sum", "c"))
                     .addAggregator(new DoubleSumAggregatorFactory("d_sum", 
"d"))
                     .build();
-    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(7, rowCount);
-    }
+    final ExpectedProjectionGroupBy queryMetrics =
+        new ExpectedProjectionGroupBy("ab_hourly_cd_sum");
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
 
-    final Sequence<ResultRow> resultRows = groupingEngine.process(
+    assertCursorProjection(buildSpec, queryMetrics, 7);
+
+    testGroupBy(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        nonBlockingPool,
-        null
+        queryMetrics,
+        List.of(
+            new Object[]{"a", null, 7L, 7.6000000000000005},
+            new Object[]{"b", null, 12L, 13.2}
+        )
     );
-
-    final List<ResultRow> results = resultRows.toList();
-    Assert.assertEquals(2, results.size());
-    Assert.assertArrayEquals(new Object[]{"a", null, 7L, 7.6000000000000005}, 
results.get(0).getArray());
-    Assert.assertArrayEquals(new Object[]{"b", null, 12L, 13.2}, 
results.get(1).getArray());
   }
 
   @Test
@@ -1342,28 +1066,19 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                                         
.context(ImmutableMap.of(QueryContexts.USE_PROJECTION, "b_c_sum"))
                                         .build();
 
-    final CursorBuildSpec buildSpec = 
TimeseriesQueryEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(4, rowCount);
-    }
+    final ExpectedProjectionTimeseries queryMetrics =
+        new ExpectedProjectionTimeseries("b_c_sum");
+    final CursorBuildSpec buildSpec = 
TimeseriesQueryEngine.makeCursorBuildSpec(query, queryMetrics);
 
-    final Sequence<Result<TimeseriesResultValue>> resultRows = 
timeseriesEngine.process(
+    assertCursorProjection(buildSpec, queryMetrics, 4);
+
+    testTimeseries(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        null
+        queryMetrics,
+        Collections.singletonList(
+            new Object[]{TIMESTAMP, 19L}
+        )
     );
-
-    final List<Result<TimeseriesResultValue>> results = resultRows.toList();
-    Assert.assertEquals(1, results.size());
-    final RowSignature querySignature = 
query.getResultRowSignature(RowSignature.Finalization.YES);
-    Assert.assertArrayEquals(new Object[]{TIMESTAMP, 19L}, 
getResultArray(results.get(0), querySignature));
   }
 
   @Test
@@ -1379,28 +1094,19 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                                         
.context(ImmutableMap.of(QueryContexts.NO_PROJECTIONS, true))
                                         .build();
 
-    final CursorBuildSpec buildSpec = 
TimeseriesQueryEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(8, rowCount);
-    }
+    final ExpectedProjectionTimeseries queryMetrics =
+        new ExpectedProjectionTimeseries(null);
+    final CursorBuildSpec buildSpec = 
TimeseriesQueryEngine.makeCursorBuildSpec(query, queryMetrics);
 
-    final Sequence<Result<TimeseriesResultValue>> resultRows = 
timeseriesEngine.process(
+    assertCursorNoProjection(buildSpec, queryMetrics);
+
+    testTimeseries(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        null
+        queryMetrics,
+        Collections.singletonList(
+            new Object[]{TIMESTAMP, 19L}
+        )
     );
-
-    final List<Result<TimeseriesResultValue>> results = resultRows.toList();
-    Assert.assertEquals(1, results.size());
-    final RowSignature querySignature = 
query.getResultRowSignature(RowSignature.Finalization.YES);
-    Assert.assertArrayEquals(new Object[]{TIMESTAMP, 19L}, 
getResultArray(results.get(0), querySignature));
   }
 
   @Test
@@ -1434,29 +1140,20 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                                         .aggregators(new 
LongSumAggregatorFactory("c_sum", "c"))
                                         .build();
 
-    final CursorBuildSpec buildSpec = 
TimeseriesQueryEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(3, rowCount);
-    }
+    final ExpectedProjectionTimeseries queryMetrics =
+        new ExpectedProjectionTimeseries("a_hourly_c_sum_with_count_latest");
+    final CursorBuildSpec buildSpec = 
TimeseriesQueryEngine.makeCursorBuildSpec(query, queryMetrics);
 
-    final Sequence<Result<TimeseriesResultValue>> resultRows = 
timeseriesEngine.process(
+    assertCursorProjection(buildSpec, queryMetrics, 3);
+
+    testTimeseries(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        null
+        queryMetrics,
+        List.of(
+            new Object[]{TIMESTAMP, 16L},
+            new Object[]{TIMESTAMP.plusHours(1), 3L}
+        )
     );
-
-    final List<Result<TimeseriesResultValue>> results = resultRows.toList();
-    Assert.assertEquals(2, results.size());
-    final RowSignature querySignature = 
query.getResultRowSignature(RowSignature.Finalization.YES);
-    Assert.assertArrayEquals(new Object[]{TIMESTAMP, 16L}, 
getResultArray(results.get(0), querySignature));
-    Assert.assertArrayEquals(new Object[]{TIMESTAMP.plusHours(1), 3L}, 
getResultArray(results.get(1), querySignature));
   }
 
   @Test
@@ -1470,28 +1167,19 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                                         
.context(ImmutableMap.of(QueryContexts.USE_PROJECTION, "c_sum_daily"))
                                         .build();
 
-    final CursorBuildSpec buildSpec = 
TimeseriesQueryEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(1, rowCount);
-    }
+    final ExpectedProjectionTimeseries queryMetrics =
+        new ExpectedProjectionTimeseries("c_sum_daily");
+    final CursorBuildSpec buildSpec = 
TimeseriesQueryEngine.makeCursorBuildSpec(query, queryMetrics);
 
-    final Sequence<Result<TimeseriesResultValue>> resultRows = 
timeseriesEngine.process(
+    assertCursorProjection(buildSpec, queryMetrics, 1);
+
+    testTimeseries(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        null
+        queryMetrics,
+        Collections.singletonList(
+            new Object[]{TIMESTAMP, 19L}
+        )
     );
-
-    final List<Result<TimeseriesResultValue>> results = resultRows.toList();
-    Assert.assertEquals(1, results.size());
-    final RowSignature querySignature = 
query.getResultRowSignature(RowSignature.Finalization.YES);
-    Assert.assertArrayEquals(new Object[]{TIMESTAMP, 19L}, 
getResultArray(results.get(0), querySignature));
   }
 
   @Test
@@ -1505,28 +1193,19 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                                         
.context(ImmutableMap.of(QueryContexts.USE_PROJECTION, "c_sum"))
                                         .build();
 
-    final CursorBuildSpec buildSpec = 
TimeseriesQueryEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(1, rowCount);
-    }
+    final ExpectedProjectionTimeseries queryMetrics =
+        new ExpectedProjectionTimeseries("c_sum");
+    final CursorBuildSpec buildSpec = 
TimeseriesQueryEngine.makeCursorBuildSpec(query, queryMetrics);
 
-    final Sequence<Result<TimeseriesResultValue>> resultRows = 
timeseriesEngine.process(
+    assertCursorProjection(buildSpec, queryMetrics, 1);
+
+    testTimeseries(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        null
+        queryMetrics,
+        Collections.singletonList(
+            new Object[]{TIMESTAMP, 19L}
+        )
     );
-
-    final List<Result<TimeseriesResultValue>> results = resultRows.toList();
-    Assert.assertEquals(1, results.size());
-    final RowSignature querySignature = 
query.getResultRowSignature(RowSignature.Finalization.YES);
-    Assert.assertArrayEquals(new Object[]{TIMESTAMP, 19L}, 
getResultArray(results.get(0), querySignature));
   }
 
   @Test
@@ -1541,41 +1220,31 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                                         
.context(ImmutableMap.of(TimeseriesQuery.SKIP_EMPTY_BUCKETS, true))
                                         .build();
 
-    final CursorBuildSpec buildSpec = 
TimeseriesQueryEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(8, rowCount);
-    }
+    final ExpectedProjectionTimeseries queryMetrics =
+        new ExpectedProjectionTimeseries(null);
+    final CursorBuildSpec buildSpec = 
TimeseriesQueryEngine.makeCursorBuildSpec(query, queryMetrics);
 
-    final Sequence<Result<TimeseriesResultValue>> resultRows = 
timeseriesEngine.process(
+    assertCursorNoProjection(buildSpec, queryMetrics);
+
+    testTimeseries(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        null
+        queryMetrics,
+        List.of(
+            new Object[]{TIMESTAMP, 1L},
+            new Object[]{TIMESTAMP.plusMinutes(2), 1L},
+            new Object[]{TIMESTAMP.plusMinutes(4), 2L},
+            new Object[]{TIMESTAMP.plusMinutes(6), 3L},
+            new Object[]{TIMESTAMP.plusMinutes(8), 4L},
+            new Object[]{TIMESTAMP.plusMinutes(10), 5L},
+            new Object[]{TIMESTAMP.plusHours(1), 1L},
+            new Object[]{TIMESTAMP.plusHours(1).plusMinutes(1), 2L}
+        )
     );
-
-    final List<Result<TimeseriesResultValue>> results = resultRows.toList();
-    Assert.assertEquals(8, results.size());
-    final RowSignature querySignature = 
query.getResultRowSignature(RowSignature.Finalization.YES);
-    Assert.assertArrayEquals(new Object[]{TIMESTAMP, 1L}, 
getResultArray(results.get(0), querySignature));
-    Assert.assertArrayEquals(new Object[]{TIMESTAMP.plusMinutes(2), 1L}, 
getResultArray(results.get(1), querySignature));
-    Assert.assertArrayEquals(new Object[]{TIMESTAMP.plusMinutes(4), 2L}, 
getResultArray(results.get(2), querySignature));
-    Assert.assertArrayEquals(new Object[]{TIMESTAMP.plusMinutes(6), 3L}, 
getResultArray(results.get(3), querySignature));
-    Assert.assertArrayEquals(new Object[]{TIMESTAMP.plusMinutes(8), 4L}, 
getResultArray(results.get(4), querySignature));
-    Assert.assertArrayEquals(new Object[]{TIMESTAMP.plusMinutes(10), 5L}, 
getResultArray(results.get(5), querySignature));
-    Assert.assertArrayEquals(new Object[]{TIMESTAMP.plusHours(1), 1L}, 
getResultArray(results.get(6), querySignature));
-    Assert.assertArrayEquals(new 
Object[]{TIMESTAMP.plusHours(1).plusMinutes(1), 2L}, 
getResultArray(results.get(7), querySignature));
   }
 
   @Test
   public void testProjectionSingleDimRollupTable()
   {
-    // test can use the single dimension projection
     final GroupByQuery query =
         GroupByQuery.builder()
                     .setDataSource("test")
@@ -1584,32 +1253,22 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                     .addDimension("a")
                     .addAggregator(new LongSumAggregatorFactory("c_sum", 
"sum_c"))
                     .build();
-    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
rollupProjectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(3, rowCount);
-    }
-    final Sequence<ResultRow> resultRows = groupingEngine.process(
-        query,
+
+    final ExpectedProjectionGroupBy queryMetrics =
+        new ExpectedProjectionGroupBy("a_hourly_c_sum_with_count");
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
+
+    assertCursorProjection(rollupProjectionsCursorFactory, buildSpec, 
queryMetrics, 3);
+
+    testGroupBy(
         rollupProjectionsCursorFactory,
         rollupProjectionsTimeBoundaryInspector,
-        nonBlockingPool,
-        null
-    );
-    final List<ResultRow> results = resultRows.toList();
-    Assert.assertEquals(2, results.size());
-    Assert.assertArrayEquals(
-        new Object[]{"a", 7L},
-        results.get(0).getArray()
-    );
-    Assert.assertArrayEquals(
-        new Object[]{"b", 12L},
-        results.get(1).getArray()
+        query,
+        queryMetrics,
+        List.of(
+            new Object[]{"a", 7L},
+            new Object[]{"b", 12L}
+        )
     );
   }
 
@@ -1625,52 +1284,29 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                     .setVirtualColumns(new ExpressionVirtualColumn("v0", 
"concat(a, 'foo')", ColumnType.STRING, TestExprMacroTable.INSTANCE))
                     .addAggregator(new LongSumAggregatorFactory("c_sum", 
"sum_c"))
                     .build();
-    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
rollupProjectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(2, rowCount);
-    }
-    final Sequence<ResultRow> resultRows = groupingEngine.process(
-        query,
+
+    final ExpectedProjectionGroupBy queryMetrics =
+        new ExpectedProjectionGroupBy("afoo");
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
+
+    assertCursorProjection(rollupProjectionsCursorFactory, buildSpec, 
queryMetrics, 2);
+
+    testGroupBy(
         rollupProjectionsCursorFactory,
         rollupProjectionsTimeBoundaryInspector,
-        nonBlockingPool,
-        null
+        query,
+        queryMetrics,
+        makeArrayResultSet(
+            new Object[]{"afoo", 7L},
+            new Object[]{"bfoo", 12L}
+        )
     );
-    final List<ResultRow> results = resultRows.toList();
-    Assert.assertEquals(2, results.size());
-    if (!(projectionsCursorFactory instanceof QueryableIndexCursorFactory) || 
!autoSchema) {
-      Assert.assertArrayEquals(
-          new Object[]{"afoo", 7L},
-          results.get(0).getArray()
-      );
-      Assert.assertArrayEquals(
-          new Object[]{"bfoo", 12L},
-          results.get(1).getArray()
-      );
-    } else {
-      // inconsistent ordering since query isn't ordering
-      Assert.assertArrayEquals(
-          new Object[]{"bfoo", 12L},
-          results.get(0).getArray()
-      );
-      Assert.assertArrayEquals(
-          new Object[]{"afoo", 7L},
-          results.get(1).getArray()
-      );
-    }
   }
 
   @Test
   public void testProjectionJson()
   {
-    // test can use the single dimension projection
-    final GroupByQuery.Builder bob =
+    final GroupByQuery query =
         GroupByQuery.builder()
                     .setDataSource("test")
                     .setGranularity(Granularities.ALL)
@@ -1685,63 +1321,29 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                         )
                     )
                     .addDimension("v0")
-                    .addAggregator(new LongSumAggregatorFactory("c_sum", "c"));
-
-    final GroupByQuery query = bob.build();
-    final GroupByQuery queryNoProjection = 
bob.setContext(Map.of(QueryContexts.NO_PROJECTIONS, true)).build();
-
-    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(6, rowCount);
-    }
+                    .addAggregator(new LongSumAggregatorFactory("c_sum", "c"))
+                    .build();
 
-    final CursorBuildSpec buildSpecNoProjection = 
GroupingEngine.makeCursorBuildSpec(queryNoProjection, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpecNoProjection)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(8, rowCount);
-    }
+    final ExpectedProjectionGroupBy queryMetrics =
+        new ExpectedProjectionGroupBy("json");
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
 
-    final Sequence<ResultRow> resultRows = groupingEngine.process(
-        query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        nonBlockingPool,
-        null
-    );
-    final List<ResultRow> results = resultRows.toList();
-    Assert.assertEquals(2, results.size());
-    Assert.assertArrayEquals(new Object[]{"a", 7L}, results.get(0).getArray());
-    Assert.assertArrayEquals(new Object[]{"b", 12L}, 
results.get(1).getArray());
+    assertCursorProjection(buildSpec, queryMetrics, 6);
 
-    final Sequence<ResultRow> resultRowsNoProjection = groupingEngine.process(
+    testGroupBy(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
-        nonBlockingPool,
-        null
+        queryMetrics,
+        List.of(
+            new Object[]{"a", 7L},
+            new Object[]{"b", 12L}
+        )
     );
-    final List<ResultRow> resultsNoProjection = 
resultRowsNoProjection.toList();
-    Assert.assertEquals(2, resultsNoProjection.size());
-    Assert.assertArrayEquals(new Object[]{"a", 7L}, 
resultsNoProjection.get(0).getArray());
-    Assert.assertArrayEquals(new Object[]{"b", 12L}, 
resultsNoProjection.get(1).getArray());
   }
 
 
   @Test
   public void testProjectionFilter()
   {
-    // since d isn't present on the smaller projection, cant use it, but can 
still use the larger projection
     final GroupByQuery query =
         GroupByQuery.builder()
                     .setDataSource("test")
@@ -1751,30 +1353,253 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
                     .setDimFilter(new EqualityFilter("b", ColumnType.STRING, 
"aa", null))
                     .addAggregator(new LongSumAggregatorFactory("c_sum", "c"))
                     .addAggregator(new DoubleSumAggregatorFactory("d_sum", 
"d"))
+                    .setContext(Map.of(QueryContexts.USE_PROJECTION, 
"a_filter_b_aaonly_hourly_cd_sum"))
                     .build();
-    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, null);
-    try (final CursorHolder cursorHolder = 
projectionsCursorFactory.makeCursorHolder(buildSpec)) {
-      final Cursor cursor = cursorHolder.asCursor();
-      int rowCount = 0;
-      while (!cursor.isDone()) {
-        rowCount++;
-        cursor.advance();
-      }
-      Assert.assertEquals(3, rowCount);
-    }
+    final ExpectedProjectionGroupBy queryMetrics =
+        new ExpectedProjectionGroupBy("a_filter_b_aaonly_hourly_cd_sum");
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
+
+    assertCursorProjection(buildSpec, queryMetrics, 3);
+
+    testGroupBy(
+        query,
+        queryMetrics,
+        List.of(
+            new Object[]{"a", 2L, 2.1},
+            new Object[]{"b", 7L, 7.7}
+        )
+    );
+  }
+
+  @Test
+  public void testProjectionSelectionTwoVirtual()
+  {
+    final GroupByQuery query =
+        GroupByQuery.builder()
+                    .setDataSource("test")
+                    .setGranularity(Granularities.ALL)
+                    .setInterval(Intervals.ETERNITY)
+                    .addDimension("v0")
+                    .addDimension(DefaultDimensionSpec.of("v1", 
ColumnType.LONG))
+                    .setVirtualColumns(
+                        new ExpressionVirtualColumn(
+                            "v0",
+                            "concat(a, \"b\")",
+                            ColumnType.STRING,
+                            TestExprMacroTable.INSTANCE
+                        ),
+                        new ExpressionVirtualColumn(
+                            "v1",
+                            "d + e",
+                            ColumnType.LONG,
+                            TestExprMacroTable.INSTANCE
+                        )
+                    )
+                    .build();
+
+    final ExpectedProjectionGroupBy queryMetrics =
+        new ExpectedProjectionGroupBy("a_concat_b_d_plus_f_sum_c");
+    final CursorBuildSpec buildSpec = 
GroupingEngine.makeCursorBuildSpec(query, queryMetrics);
+
+    assertCursorProjection(buildSpec, queryMetrics, 8);
+
+    testGroupBy(
+        query,
+        queryMetrics,
+        makeArrayResultSet(
+            new Object[]{"aaa", null},
+            new Object[]{"aaa", 2L},
+            new Object[]{"abb", 2L},
+            new Object[]{"acc", 4L},
+            new Object[]{"add", 4L},
+            new Object[]{"baa", 8L},
+            new Object[]{"baa", 6L},
+            new Object[]{"baa", 8L},
+            new Object[]{"bbb", 11L}
+        )
+    );
+  }
+
+  private void testGroupBy(GroupByQuery query, ExpectedProjectionGroupBy 
queryMetrics, List<Object[]> expectedResults)
+  {
+    testGroupBy(projectionsCursorFactory, projectionsTimeBoundaryInspector, 
query, queryMetrics, expectedResults);
+  }
 
+  private void testGroupBy(
+      CursorFactory cursorFactory,
+      TimeBoundaryInspector timeBoundaryInspector,
+      GroupByQuery query,
+      ExpectedProjectionGroupBy queryMetrics,
+      List<Object[]> expectedResults
+  )
+  {
     final Sequence<ResultRow> resultRows = groupingEngine.process(
         query,
-        projectionsCursorFactory,
-        projectionsTimeBoundaryInspector,
+        cursorFactory,
+        timeBoundaryInspector,
+        nonBlockingPool,
+        queryMetrics
+    );
+
+    queryMetrics.assertProjection();
+
+    final List<ResultRow> results = resultRows.toList();
+    assertGroupByResults(expectedResults, results);
+
+    final Sequence<ResultRow> resultRowsNoProjection = groupingEngine.process(
+        query.withOverriddenContext(Map.of(QueryContexts.NO_PROJECTIONS, 
true)),
+        cursorFactory,
+        timeBoundaryInspector,
         nonBlockingPool,
         null
     );
 
+    final List<ResultRow> resultsNoProjection = 
resultRowsNoProjection.toList();
+    assertGroupByResults(expectedResults, resultsNoProjection);
+  }
+
+  private void testGroupBy(GroupByQuery query, ExpectedProjectionGroupBy 
queryMetrics, Set<Object[]> expectedResults)
+  {
+    testGroupBy(projectionsCursorFactory, projectionsTimeBoundaryInspector, 
query, queryMetrics, expectedResults);
+  }
+
+  private void testGroupBy(
+      CursorFactory cursorFactory,
+      TimeBoundaryInspector timeBoundaryInspector,
+      GroupByQuery query,
+      ExpectedProjectionGroupBy queryMetrics,
+      Set<Object[]> expectedResults
+  )
+  {
+    final Sequence<ResultRow> resultRows = groupingEngine.process(
+        query,
+        cursorFactory,
+        timeBoundaryInspector,
+        nonBlockingPool,
+        queryMetrics
+    );
+
+    queryMetrics.assertProjection();
+
     final List<ResultRow> results = resultRows.toList();
-    Assert.assertEquals(2, results.size());
-    Assert.assertArrayEquals(new Object[]{"a", 2L, 2.1}, 
results.get(0).getArray());
-    Assert.assertArrayEquals(new Object[]{"b", 7L, 7.7}, 
results.get(1).getArray());
+    assertGroupByResultsAnyOrder(expectedResults, results);
+
+    final Sequence<ResultRow> resultRowsNoProjection = groupingEngine.process(
+        query.withOverriddenContext(Map.of(QueryContexts.NO_PROJECTIONS, 
true)),
+        cursorFactory,
+        timeBoundaryInspector,
+        nonBlockingPool,
+        null
+    );
+
+    final List<ResultRow> resultsNoProjection = 
resultRowsNoProjection.toList();
+    assertGroupByResultsAnyOrder(expectedResults, resultsNoProjection);
+  }
+
+  private void testTimeseries(
+      TimeseriesQuery query,
+      ExpectedProjectionTimeseries queryMetrics,
+      List<Object[]> expectedResults
+  )
+  {
+    testTimeseries(projectionsCursorFactory, projectionsTimeBoundaryInspector, 
query, queryMetrics, expectedResults);
+  }
+
+  private void testTimeseries(
+      CursorFactory cursorFactory,
+      TimeBoundaryInspector timeBoundaryInspector,
+      TimeseriesQuery query,
+      ExpectedProjectionTimeseries queryMetrics,
+      List<Object[]> expectedResults
+  )
+  {
+    final Sequence<Result<TimeseriesResultValue>> resultRows = 
timeseriesEngine.process(
+        query,
+        cursorFactory,
+        timeBoundaryInspector,
+        queryMetrics
+    );
+
+    queryMetrics.assertProjection();
+
+    final List<Result<TimeseriesResultValue>> results = resultRows.toList();
+    
assertTimeseriesResults(query.getResultRowSignature(RowSignature.Finalization.YES),
 expectedResults, results);
+
+    Assume.assumeTrue(segmentSortedByTime);
+    final Sequence<Result<TimeseriesResultValue>> resultRowsNoProjection = 
timeseriesEngine.process(
+        query.withOverriddenContext(Map.of(QueryContexts.NO_PROJECTIONS, 
true)),
+        cursorFactory,
+        timeBoundaryInspector,
+        queryMetrics
+    );
+
+    final List<Result<TimeseriesResultValue>> resultsNoProjection = 
resultRowsNoProjection.toList();
+    assertTimeseriesResults(
+        query.getResultRowSignature(RowSignature.Finalization.YES),
+        expectedResults,
+        resultsNoProjection
+    );
+  }
+
+  private void assertGroupByResults(List<Object[]> expected, List<ResultRow> 
actual)
+  {
+    Assertions.assertEquals(expected.size(), actual.size());
+    for (int i = 0; i < expected.size(); i++) {
+      Assertions.assertArrayEquals(expected.get(i), actual.get(i).getArray());
+    }
+  }
+
+  private void assertGroupByResultsAnyOrder(Set<Object[]> expected, 
List<ResultRow> actual)
+  {
+    Assertions.assertEquals(expected.size(), actual.size());
+    for (ResultRow row : actual) {
+      Assertions.assertTrue(expected.contains(row.getArray()), "missing row:" 
+ Arrays.deepToString(row.getArray()));
+    }
+  }
+
+  private void assertTimeseriesResults(
+      RowSignature querySignature,
+      List<Object[]> expected,
+      List<Result<TimeseriesResultValue>> actual
+  )
+  {
+    Assertions.assertEquals(expected.size(), actual.size());
+    for (int i = 0; i < expected.size(); i++) {
+      Assertions.assertArrayEquals(expected.get(i), 
getResultArray(actual.get(i), querySignature));
+    }
+  }
+
+  private void assertCursorNoProjection(CursorBuildSpec buildSpec, 
ExpectedProjectionQueryMetrics<?> queryMetrics)
+  {
+    assertCursorProjection(buildSpec, queryMetrics, 8);
+  }
+
+  private void assertCursorProjection(
+      CursorBuildSpec buildSpec,
+      ExpectedProjectionQueryMetrics<?> queryMetrics,
+      int expectedRowCount
+  )
+  {
+    assertCursorProjection(projectionsCursorFactory, buildSpec, queryMetrics, 
expectedRowCount);
+  }
+
+  private void assertCursorProjection(
+      CursorFactory cursorFactory,
+      CursorBuildSpec buildSpec,
+      ExpectedProjectionQueryMetrics<?> queryMetrics,
+      int expectedRowCount
+  )
+  {
+    try (final CursorHolder cursorHolder = 
cursorFactory.makeCursorHolder(buildSpec)) {
+      queryMetrics.assertProjection();
+      final Cursor cursor = cursorHolder.asCursor();
+      int rowCount = 0;
+      while (!cursor.isDone()) {
+        rowCount++;
+        cursor.advance();
+      }
+      Assert.assertEquals(expectedRowCount, rowCount);
+    }
   }
 
   private static IndexBuilder makeBuilder(DimensionsSpec dimensionsSpec, 
boolean autoSchema, boolean writeNullColumns)
@@ -1822,7 +1647,7 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
           @Override
           public int hashCode(Object[] o)
           {
-            return Arrays.hashCode(o);
+            return Arrays.deepHashCode(o);
           }
 
           @Override
@@ -1854,4 +1679,96 @@ public class CursorFactoryProjectionTest extends 
InitializedNullHandlingTest
     }
     return rowArray;
   }
+
+  private static class ExpectedProjectionQueryMetrics<T extends Query<?>> 
extends DefaultQueryMetrics<T>
+  {
+    @Nullable
+    private final String expectedProjection;
+    @Nullable
+    private String capturedProjection;
+
+    private ExpectedProjectionQueryMetrics(@Nullable String expectedProjection)
+    {
+      this.expectedProjection = expectedProjection;
+    }
+
+    @Override
+    public void projection(String projection)
+    {
+      capturedProjection = projection;
+    }
+
+    void assertProjection()
+    {
+      Assertions.assertEquals(expectedProjection, capturedProjection);
+      capturedProjection = null;
+    }
+  }
+
+  private static class ExpectedProjectionGroupBy extends 
ExpectedProjectionQueryMetrics<GroupByQuery>
+      implements GroupByQueryMetrics
+  {
+    private ExpectedProjectionGroupBy(@Nullable String expectedProjection)
+    {
+      super(expectedProjection);
+    }
+
+    @Override
+    public void numDimensions(GroupByQuery query)
+    {
+
+    }
+
+    @Override
+    public void numMetrics(GroupByQuery query)
+    {
+
+    }
+
+    @Override
+    public void numComplexMetrics(GroupByQuery query)
+    {
+
+    }
+
+    @Override
+    public void granularity(GroupByQuery query)
+    {
+
+    }
+  }
+
+  private static class ExpectedProjectionTimeseries extends 
ExpectedProjectionQueryMetrics<TimeseriesQuery>
+      implements TimeseriesQueryMetrics
+  {
+
+    private ExpectedProjectionTimeseries(@Nullable String expectedProjection)
+    {
+      super(expectedProjection);
+    }
+
+    @Override
+    public void limit(TimeseriesQuery query)
+    {
+
+    }
+
+    @Override
+    public void numMetrics(TimeseriesQuery query)
+    {
+
+    }
+
+    @Override
+    public void numComplexMetrics(TimeseriesQuery query)
+    {
+
+    }
+
+    @Override
+    public void granularity(TimeseriesQuery query)
+    {
+
+    }
+  }
 }


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

Reply via email to