diff --git a/fe/fe-core/src/main/java/org/apache/doris/statistics/query/QueryStatsRecorder.java b/fe/fe-core/src/main/java/org/apache/doris/statistics/query/QueryStatsRecorder.java
index 1b5b76a1bc5984..78ce44dc3aff76 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/statistics/query/QueryStatsRecorder.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/statistics/query/QueryStatsRecorder.java
@@ -24,39 +24,52 @@
import org.apache.doris.common.Config;
import org.apache.doris.nereids.StatementContext;
import org.apache.doris.nereids.glue.LogicalPlanAdapter;
+import org.apache.doris.nereids.properties.OrderKey;
+import org.apache.doris.nereids.rules.implementation.LogicalWindowToPhysicalWindow.WindowFrameGroup;
+import org.apache.doris.nereids.trees.expressions.Alias;
import org.apache.doris.nereids.trees.expressions.ExprId;
import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.OrderExpression;
import org.apache.doris.nereids.trees.expressions.Slot;
import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.expressions.WindowExpression;
import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.algebra.Aggregate;
+import org.apache.doris.nereids.trees.plans.algebra.Relation;
import org.apache.doris.nereids.trees.plans.commands.Command;
+import org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalJoin;
+import org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalSort;
import org.apache.doris.nereids.trees.plans.physical.PhysicalFilter;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalLazyMaterialize;
import org.apache.doris.nereids.trees.plans.physical.PhysicalLazyMaterializeOlapScan;
import org.apache.doris.nereids.trees.plans.physical.PhysicalOlapScan;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalPartitionTopN;
import org.apache.doris.nereids.trees.plans.physical.PhysicalPlan;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalProject;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalRelation;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalRepeat;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalStorageLayerAggregate;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalWindow;
import org.apache.doris.qe.ConnectContext;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
+import java.util.Set;
/**
* Records column-level query-hit and filter-hit statistics from the Nereids physical plan.
* Called once per query in NereidsPlanner after plan translation.
*
- *
Scope (Part 1):
- *
- * - queryHit: base SELECT columns whose ExprId flows straight through to the root
- * plan's output without rewriting. Columns hidden by an alias, an expression,
- * or an aggregate function are NOT recorded yet (Part 2).
- * - filterHit: columns referenced in WHERE predicate conjuncts.
- * - Only OlapTable scans are recorded; external tables (Hive, Iceberg, JDBC, …) are not.
- * - DML, EXPLAIN, and internal queries (e.g. auto-analyze) are skipped.
- * - Per query, each table's count is incremented at most once regardless of scan count.
- *
- * GROUP BY, ORDER BY, window, JOIN, and aliased/projected columns are deferred to Part 2.
+ * queryHit: SELECT output columns (alias-unwrapped), GROUP BY keys,
+ * ORDER BY keys, window PARTITION BY / ORDER BY keys, aggregate input columns.
+ * filterHit: WHERE predicate columns and JOIN ON conditions.
+ * Only OlapTable scans are recorded. DML, EXPLAIN, and internal queries are skipped.
+ * Per query each table's count is incremented at most once regardless of scan count.
*/
public class QueryStatsRecorder {
private static final Logger LOG = LogManager.getLogger(QueryStatsRecorder.class);
@@ -100,23 +113,32 @@ public static void record(PhysicalPlan plan, StatementContext stmtContext) {
*/
static Map collectDeltas(PhysicalPlan plan) {
Map exprIdToScan = new HashMap<>();
+ Map exprIdToColName = new HashMap<>();
Map deltas = new HashMap<>();
- walkPlan(plan, exprIdToScan, deltas);
+ walkPlan(plan, exprIdToScan, exprIdToColName, deltas);
if (exprIdToScan.isEmpty()) {
return deltas;
}
- for (Slot slot : plan.getOutput()) {
- if (!(slot instanceof SlotReference)) {
+ // queryHit: use getProjects() for PhysicalProject so Alias nodes are visible to unwrapAlias.
+ Iterable extends NamedExpression> rootExprs = (plan instanceof PhysicalProject)
+ ? ((PhysicalProject>) plan).getProjects()
+ : plan.getOutput();
+ for (NamedExpression ne : rootExprs) {
+ SlotReference sr = unwrapAlias(ne);
+ if (sr == null) {
continue;
}
- SlotReference sr = (SlotReference) slot;
PhysicalOlapScan sourceScan = exprIdToScan.get(sr.getExprId());
if (sourceScan == null) {
continue;
}
StatsDelta delta = getOrCreateDelta(deltas, sourceScan);
if (delta != null) {
- sr.getOriginalColumn().ifPresent(col -> delta.addQueryStats(col.getName()));
+ String colName = sr.getOriginalColumn().map(col -> col.getName())
+ .orElseGet(() -> exprIdToColName.get(sr.getExprId()));
+ if (colName != null) {
+ delta.addQueryStats(colName);
+ }
}
}
return deltas;
@@ -149,20 +171,38 @@ static boolean shouldRecord(StatementContext ctx) {
/**
* Single-pass tree walk: registers scan output slots into exprIdToScan,
- * and records filterHit for PhysicalFilter conjuncts.
- * Children are visited before the current node so scans are registered
- * before parent filters look them up.
+ * records filterHit for WHERE conjuncts, and records queryHit for
+ * GROUP BY / ORDER BY / window keys and aggregate input columns.
+ * Children are visited before the current node so scans are registered first.
* PhysicalLazyMaterializeOlapScan is checked before PhysicalOlapScan
* because it is a subclass; the inner scan's metadata must be used.
*/
private static void walkPlan(Plan plan,
Map exprIdToScan,
+ Map exprIdToColName,
Map deltas) {
+ if (plan instanceof PhysicalStorageLayerAggregate) {
+ // COUNT(*)/MIN/MAX pushdown — the aggregate wraps the real scan but has no children.
+ PhysicalRelation inner = ((PhysicalStorageLayerAggregate) plan).getRelation();
+ if (inner instanceof PhysicalOlapScan) {
+ PhysicalOlapScan scan = (PhysicalOlapScan) inner;
+ for (Slot slot : scan.getOutput()) {
+ exprIdToScan.put(slot.getExprId(), scan);
+ if (slot instanceof SlotReference) {
+ registerColName(exprIdToColName, slot.getExprId(), (SlotReference) slot);
+ }
+ }
+ }
+ return;
+ }
if (plan instanceof PhysicalLazyMaterializeOlapScan) {
PhysicalOlapScan inner =
((PhysicalLazyMaterializeOlapScan) plan).getScan();
for (Slot slot : plan.getOutput()) {
exprIdToScan.put(slot.getExprId(), inner);
+ if (slot instanceof SlotReference) {
+ registerColName(exprIdToColName, slot.getExprId(), (SlotReference) slot);
+ }
}
return;
}
@@ -170,33 +210,221 @@ private static void walkPlan(Plan plan,
PhysicalOlapScan scan = (PhysicalOlapScan) plan;
for (Slot slot : scan.getOutput()) {
exprIdToScan.put(slot.getExprId(), scan);
+ if (slot instanceof SlotReference) {
+ registerColName(exprIdToColName, slot.getExprId(), (SlotReference) slot);
+ }
}
return;
}
+ // TODO: PhysicalCTEConsumer slots use consumer-side ExprIds that differ from the producer
+ // scan's ExprIds, so CTE column stats are silently missed. Fix requires mapping consumer
+ // slots back to producer slots via StatementContext.getConsumerToProducerSlotMap().
for (Plan child : plan.children()) {
- walkPlan(child, exprIdToScan, deltas);
+ walkPlan(child, exprIdToScan, exprIdToColName, deltas);
}
if (plan instanceof PhysicalFilter) {
PhysicalFilter> filter = (PhysicalFilter>) plan;
for (Expression conjunct : filter.getConjuncts()) {
- conjunct.getInputSlots().forEach(slot -> {
- if (!(slot instanceof SlotReference)) {
- return;
+ recordInputSlotsAsFilterHit(conjunct, exprIdToScan, exprIdToColName, deltas);
+ }
+ }
+ // Lazy-materialized columns are not in PhysicalLazyMaterializeOlapScan.getOutput()
+ // (only operative slots + row-id are). The parent PhysicalLazyMaterialize exposes
+ // the lazy slots via getLazySlots(). Use each row-id — already registered by the
+ // child scan branch — to look up the source scan and register the lazy ExprIds.
+ if (plan instanceof PhysicalLazyMaterialize) {
+ PhysicalLazyMaterialize> lazy = (PhysicalLazyMaterialize>) plan;
+ List rels = lazy.getRelations();
+ List rowIds = lazy.getRowIds();
+ for (int i = 0; i < rels.size() && i < rowIds.size(); i++) {
+ PhysicalOlapScan sourceScan = exprIdToScan.get(rowIds.get(i).getExprId());
+ if (sourceScan == null) {
+ continue;
+ }
+ for (Slot lazySlot : lazy.getLazySlots(rels.get(i))) {
+ exprIdToScan.put(lazySlot.getExprId(), sourceScan);
+ if (lazySlot instanceof SlotReference) {
+ registerColName(exprIdToColName, lazySlot.getExprId(),
+ (SlotReference) lazySlot);
}
- SlotReference sr = (SlotReference) slot;
- PhysicalOlapScan sourceScan = exprIdToScan.get(sr.getExprId());
- if (sourceScan == null) {
- return;
+ }
+ }
+ }
+ if (plan instanceof Aggregate) {
+ Aggregate> agg = (Aggregate>) plan;
+ // GROUP BY keys
+ for (Expression expr : agg.getGroupByExpressions()) {
+ recordInputSlotsAsQueryHit(expr, exprIdToScan, exprIdToColName, deltas);
+ }
+ // Columns consumed by aggregate functions (e.g. k2 in SUM(k2))
+ for (NamedExpression expr : agg.getOutputExpressions()) {
+ recordInputSlotsAsQueryHit(expr, exprIdToScan, exprIdToColName, deltas);
+ }
+ }
+ if (plan instanceof AbstractPhysicalSort) {
+ for (OrderKey orderKey : ((AbstractPhysicalSort>) plan).getOrderKeys()) {
+ recordInputSlotsAsQueryHit(orderKey.getExpr(), exprIdToScan, exprIdToColName, deltas);
+ }
+ }
+ // PhysicalPartitionTopN does not extend AbstractPhysicalSort but also has ORDER BY and
+ // partition keys (used for row_number() / rank() per partition).
+ if (plan instanceof PhysicalPartitionTopN) {
+ PhysicalPartitionTopN> ptn = (PhysicalPartitionTopN>) plan;
+ for (Expression partKey : ptn.getPartitionKeys()) {
+ recordInputSlotsAsQueryHit(partKey, exprIdToScan, exprIdToColName, deltas);
+ }
+ for (OrderKey orderKey : ptn.getOrderKeys()) {
+ recordInputSlotsAsQueryHit(orderKey.getExpr(), exprIdToScan, exprIdToColName, deltas);
+ }
+ }
+ // PhysicalRepeat handles ROLLUP/CUBE: group sets are like GROUP BY keys.
+ if (plan instanceof PhysicalRepeat) {
+ PhysicalRepeat> repeat = (PhysicalRepeat>) plan;
+ for (List groupSet : repeat.getGroupingSets()) {
+ for (Expression expr : groupSet) {
+ recordInputSlotsAsQueryHit(expr, exprIdToScan, exprIdToColName, deltas);
+ }
+ }
+ for (NamedExpression expr : repeat.getOutputExpressions()) {
+ recordInputSlotsAsQueryHit(expr, exprIdToScan, exprIdToColName, deltas);
+ }
+ }
+ if (plan instanceof PhysicalWindow) {
+ WindowFrameGroup wfg = ((PhysicalWindow>) plan).getWindowFrameGroup();
+ Set partitionKeys = wfg.getPartitionKeys();
+ for (Expression expr : partitionKeys) {
+ recordInputSlotsAsQueryHit(expr, exprIdToScan, exprIdToColName, deltas);
+ }
+ for (OrderExpression orderExpr : wfg.getOrderKeys()) {
+ recordInputSlotsAsQueryHit(orderExpr.child(), exprIdToScan, exprIdToColName, deltas);
+ }
+ // queryHit for the window function value columns (e.g. k2 in SUM(k2) OVER (...)).
+ for (NamedExpression windowAlias : wfg.getGroups()) {
+ Expression windowExpr = windowAlias.child(0);
+ if (windowExpr instanceof WindowExpression) {
+ recordInputSlotsAsQueryHit(
+ ((WindowExpression) windowExpr).getFunction(),
+ exprIdToScan, exprIdToColName, deltas);
+ }
+ }
+ }
+ // filterHit for JOIN ON conditions (hash equality and non-equality predicates).
+ if (plan instanceof AbstractPhysicalJoin) {
+ AbstractPhysicalJoin, ?> join = (AbstractPhysicalJoin, ?>) plan;
+ for (Expression conjunct : join.getHashJoinConjuncts()) {
+ recordInputSlotsAsFilterHit(conjunct, exprIdToScan, exprIdToColName, deltas);
+ }
+ for (Expression conjunct : join.getOtherJoinConjuncts()) {
+ recordInputSlotsAsFilterHit(conjunct, exprIdToScan, exprIdToColName, deltas);
+ }
+ }
+ // Propagate alias ExprIds for intermediate PhysicalProject nodes so that parent
+ // plan output slots (derived from aliases) resolve back to the original scan.
+ if (plan instanceof PhysicalProject) {
+ for (NamedExpression ne : ((PhysicalProject>) plan).getProjects()) {
+ if (exprIdToScan.containsKey(ne.getExprId())) {
+ continue; // plain slot pass-through — already registered by child scan
+ }
+ SlotReference underlying = unwrapAlias(ne);
+ if (underlying != null && !underlying.getExprId().equals(ne.getExprId())) {
+ // Simple alias: Alias(SlotRef) — propagate scan and column name.
+ PhysicalOlapScan scan = exprIdToScan.get(underlying.getExprId());
+ if (scan != null) {
+ exprIdToScan.put(ne.getExprId(), scan);
+ String colName = exprIdToColName.get(underlying.getExprId());
+ if (colName != null) {
+ exprIdToColName.put(ne.getExprId(), colName);
+ }
}
- StatsDelta delta = getOrCreateDelta(deltas, sourceScan);
- if (delta != null) {
- sr.getOriginalColumn().ifPresent(col -> delta.addFilterStats(col.getName()));
+ } else if (underlying == null && ne instanceof Alias) {
+ // Complex alias: Alias(Cast(col), name) — created by
+ // PushDownExpressionsInHashCondition for type-mismatched join keys.
+ // If all input slots trace back to one scan, propagate to the alias ExprId.
+ Set inputSlots = ((Alias) ne).child().getInputSlots();
+ if (inputSlots.size() == 1) {
+ Slot inputSlot = inputSlots.iterator().next();
+ PhysicalOlapScan scan = exprIdToScan.get(inputSlot.getExprId());
+ if (scan != null) {
+ exprIdToScan.put(ne.getExprId(), scan);
+ String colName = exprIdToColName.get(inputSlot.getExprId());
+ if (colName != null) {
+ exprIdToColName.put(ne.getExprId(), colName);
+ }
+ }
}
- });
+ }
}
}
}
+ private static void recordInputSlotsAsQueryHit(Expression expr,
+ Map exprIdToScan,
+ Map exprIdToColName,
+ Map deltas) {
+ for (Slot slot : expr.getInputSlots()) {
+ if (!(slot instanceof SlotReference)) {
+ continue;
+ }
+ SlotReference sr = (SlotReference) slot;
+ PhysicalOlapScan sourceScan = exprIdToScan.get(sr.getExprId());
+ if (sourceScan == null) {
+ continue;
+ }
+ StatsDelta delta = getOrCreateDelta(deltas, sourceScan);
+ if (delta != null) {
+ String colName = sr.getOriginalColumn().map(col -> col.getName())
+ .orElseGet(() -> exprIdToColName.get(sr.getExprId()));
+ if (colName != null) {
+ delta.addQueryStats(colName);
+ }
+ }
+ }
+ }
+
+ private static void recordInputSlotsAsFilterHit(Expression expr,
+ Map exprIdToScan,
+ Map exprIdToColName,
+ Map deltas) {
+ for (Slot slot : expr.getInputSlots()) {
+ if (!(slot instanceof SlotReference)) {
+ continue;
+ }
+ SlotReference sr = (SlotReference) slot;
+ PhysicalOlapScan sourceScan = exprIdToScan.get(sr.getExprId());
+ if (sourceScan == null) {
+ continue;
+ }
+ StatsDelta delta = getOrCreateDelta(deltas, sourceScan);
+ if (delta != null) {
+ String colName = sr.getOriginalColumn().map(col -> col.getName())
+ .orElseGet(() -> exprIdToColName.get(sr.getExprId()));
+ if (colName != null) {
+ delta.addFilterStats(colName);
+ }
+ }
+ }
+ }
+
+ /** Registers a slot's column name into exprIdToColName for later fallback lookup. */
+ private static void registerColName(Map exprIdToColName,
+ ExprId exprId, SlotReference slot) {
+ String name = slot.getOriginalColumn().map(col -> col.getName()).orElse(slot.getName());
+ if (name != null) {
+ exprIdToColName.put(exprId, name);
+ }
+ }
+
+ /** Unwraps Alias chains to reach the underlying SlotReference; null for computed expressions. */
+ static SlotReference unwrapAlias(Expression expr) {
+ if (expr instanceof SlotReference) {
+ return (SlotReference) expr;
+ }
+ if (expr instanceof Alias) {
+ return unwrapAlias(((Alias) expr).child());
+ }
+ return null;
+ }
+
private static StatsDelta getOrCreateDelta(Map deltas,
PhysicalOlapScan scan) {
OlapTable t = scan.getTable();
diff --git a/fe/fe-core/src/main/java/org/apache/doris/statistics/query/QueryStatsUtil.java b/fe/fe-core/src/main/java/org/apache/doris/statistics/query/QueryStatsUtil.java
index a8cf306f271be7..af261430a5af6a 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/statistics/query/QueryStatsUtil.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/statistics/query/QueryStatsUtil.java
@@ -148,7 +148,10 @@ public static long getMergedReplicaStats(long replicaId) {
request.setType(TQueryStatsType.TABLET);
request.setReplicaId(replicaId);
for (TQueryStatsResult other : getStats(request)) {
- queryHits += other.getTabletStats().get(replicaId);
+ Long remoteCount = other.getTabletStats().get(replicaId);
+ if (remoteCount != null) {
+ queryHits += remoteCount;
+ }
}
return queryHits;
}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/statistics/query/QueryStatsRecorderTest.java b/fe/fe-core/src/test/java/org/apache/doris/statistics/query/QueryStatsRecorderTest.java
index a06a6eba4ad41f..7d747f55f572a4 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/statistics/query/QueryStatsRecorderTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/statistics/query/QueryStatsRecorderTest.java
@@ -26,13 +26,20 @@
import org.apache.doris.nereids.glue.LogicalPlanAdapter;
import org.apache.doris.nereids.trees.expressions.ExprId;
import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.OrderExpression;
import org.apache.doris.nereids.trees.expressions.Slot;
import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.expressions.WindowExpression;
import org.apache.doris.nereids.trees.plans.commands.Command;
import org.apache.doris.nereids.trees.plans.physical.PhysicalFilter;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalLazyMaterialize;
import org.apache.doris.nereids.trees.plans.physical.PhysicalLazyMaterializeOlapScan;
import org.apache.doris.nereids.trees.plans.physical.PhysicalOlapScan;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalPartitionTopN;
import org.apache.doris.nereids.trees.plans.physical.PhysicalPlan;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalRepeat;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalStorageLayerAggregate;
import org.apache.doris.qe.ConnectContext;
import org.apache.doris.qe.QueryState;
@@ -259,7 +266,63 @@ public void testDeferMaterializeScanUsesInnerScan() {
}
/**
- * Plan: Filter(k1=1) → Scan[k1(id1)], root output = [k1].
+ * TopN lazy materialization: operative slot (sort_col/id2) is in the lazy-scan output;
+ * lazy slot (cold_col/id3) is only in PhysicalLazyMaterialize's output.
+ * Expected: cold_col.queryHit=true via the PhysicalLazyMaterialize walk branch.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testLazyMaterializeQueryHitRecorded() {
+ ExprId idSort = new ExprId(1); // sort_col — operative, in lazy-scan output
+ ExprId idRowId = new ExprId(2); // row-id slot
+ ExprId idCold = new ExprId(3); // cold_col — lazy, NOT in lazy-scan output
+
+ SlotReference sortSlot = mockSlot(idSort, "sort_col");
+ SlotReference rowIdSlot = mockSlot(idRowId, "__row_id");
+ SlotReference coldSlot = mockSlot(idCold, "cold_col");
+
+ // Inner scan
+ PhysicalOlapScan inner = mockScan(1L, 1L, 1L, 1L,
+ ImmutableList.of(sortSlot, rowIdSlot));
+
+ // PhysicalLazyMaterializeOlapScan: output = [sort_col, row_id]
+ PhysicalLazyMaterializeOlapScan lazyScan =
+ Mockito.mock(PhysicalLazyMaterializeOlapScan.class);
+ Mockito.when(lazyScan.getScan()).thenReturn(inner);
+ Mockito.when(lazyScan.getOutput())
+ .thenReturn(ImmutableList.of(sortSlot, rowIdSlot));
+ Mockito.when(lazyScan.children()).thenReturn(ImmutableList.of());
+
+ // PhysicalLazyMaterialize: knows cold_col is lazy for this relation;
+ // uses rowIdSlot to link back to the relation.
+ org.apache.doris.nereids.trees.plans.algebra.Relation rel =
+ Mockito.mock(org.apache.doris.nereids.trees.plans.algebra.Relation.class);
+
+ PhysicalLazyMaterialize> lazyMat =
+ Mockito.mock(PhysicalLazyMaterialize.class);
+ Mockito.when(lazyMat.children()).thenReturn(ImmutableList.of(lazyScan));
+ Mockito.when(lazyMat.getRelations()).thenReturn(ImmutableList.of(rel));
+ Mockito.when(lazyMat.getRowIds()).thenReturn(ImmutableList.of(rowIdSlot));
+ Mockito.when(lazyMat.getLazySlots(rel)).thenReturn(ImmutableList.of(coldSlot));
+ // Root output: cold_col is selected
+ Mockito.when(lazyMat.getOutput()).thenReturn(ImmutableList.of(coldSlot));
+
+ Map deltas =
+ QueryStatsRecorder.collectDeltas((PhysicalPlan) lazyMat);
+
+ Assertions.assertEquals(1, deltas.size());
+ StatsDelta delta = deltas.get("1_1_1_1");
+ Assertions.assertNotNull(delta, "delta must exist for the inner scan");
+ Assertions.assertNotNull(delta.getColumnStats().get("cold_col"),
+ "cold_col must be recorded via lazy-materialize branch");
+ Assertions.assertTrue(delta.getColumnStats().get("cold_col").queryHit,
+ "cold_col.queryHit must be true");
+ // sort_col was only an operative slot, not in root output — no queryHit
+ Assertions.assertNull(delta.getColumnStats().get("sort_col"),
+ "sort_col must not appear (not in root output)");
+ }
+
+ /**
* k1 appears in both the WHERE predicate and the SELECT output.
* Expected: k1.queryHit=true AND k1.filterHit=true simultaneously.
*/
@@ -405,6 +468,459 @@ public void testNullTableInScanDoesNotCrash() {
Assertions.assertTrue(deltas.isEmpty(), "Null-table scan must not create a delta");
}
+ // ── Alias / GROUP BY / ORDER BY / Window ─────────────────────────────────
+
+ /**
+ * Unit test for unwrapAlias: Alias(k1) → k1SlotReference.
+ */
+ @Test
+ public void testUnwrapAliasReturnsUnderlyingSlot() {
+ ExprId id1 = new ExprId(1);
+ SlotReference k1Slot = mockSlot(id1, "k1");
+
+ org.apache.doris.nereids.trees.expressions.Alias alias =
+ Mockito.mock(org.apache.doris.nereids.trees.expressions.Alias.class);
+ Mockito.when(alias.child()).thenReturn(k1Slot);
+
+ SlotReference result = QueryStatsRecorder.unwrapAlias(alias);
+ Assertions.assertEquals(k1Slot, result);
+ }
+
+ /**
+ * SELECT k1 AS x FROM t: PhysicalProject.getProjects() exposes Alias so
+ * unwrapAlias resolves to k1 and records k1.queryHit.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testAliasUnwrappedForQueryHit() {
+ ExprId id1 = new ExprId(1);
+ SlotReference k1Slot = mockSlot(id1, "k1");
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L, ImmutableList.of(k1Slot));
+
+ org.apache.doris.nereids.trees.expressions.Alias alias =
+ Mockito.mock(org.apache.doris.nereids.trees.expressions.Alias.class);
+ Mockito.when(alias.getExprId()).thenReturn(new ExprId(99));
+ Mockito.when(alias.child()).thenReturn(k1Slot);
+
+ org.apache.doris.nereids.trees.plans.physical.PhysicalProject> project =
+ Mockito.mock(org.apache.doris.nereids.trees.plans.physical.PhysicalProject.class);
+ Mockito.when(project.children()).thenReturn(ImmutableList.of(scan));
+ Mockito.when(project.getProjects()).thenReturn(ImmutableList.of(alias));
+ Mockito.when(project.getOutput()).thenReturn(ImmutableList.of());
+
+ Map deltas = QueryStatsRecorder.collectDeltas(
+ (org.apache.doris.nereids.trees.plans.physical.PhysicalPlan) project);
+
+ Assertions.assertEquals(1, deltas.size());
+ StatsDelta delta = deltas.values().iterator().next();
+ Assertions.assertNotNull(delta.getColumnStats().get("k1"), "k1 must be recorded via alias unwrap");
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit);
+ }
+
+ /**
+ * SELECT k1 AS x FROM t ORDER BY k2: PhysicalProject is intermediate under PhysicalSort.
+ * walkPlan must propagate alias ExprId so parent's getOutput() resolves to k1.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testAliasUnwrappedForQueryHitIntermediateProject() {
+ ExprId id1 = new ExprId(1);
+ ExprId id2 = new ExprId(2);
+ ExprId aliasId = new ExprId(99);
+ SlotReference k1Slot = mockSlot(id1, "k1");
+ SlotReference k2Slot = mockSlot(id2, "k2");
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L, ImmutableList.of(k1Slot, k2Slot));
+
+ org.apache.doris.nereids.trees.expressions.Alias alias =
+ Mockito.mock(org.apache.doris.nereids.trees.expressions.Alias.class);
+ Mockito.when(alias.getExprId()).thenReturn(aliasId);
+ Mockito.when(alias.child()).thenReturn(k1Slot);
+
+ // x_slot — what PhysicalProject.getOutput() returns (alias's ExprId, not k1's)
+ SlotReference xSlot = mockSlot(aliasId, "x");
+
+ org.apache.doris.nereids.trees.plans.physical.PhysicalProject> project =
+ Mockito.mock(org.apache.doris.nereids.trees.plans.physical.PhysicalProject.class);
+ Mockito.when(project.children()).thenReturn(ImmutableList.of(scan));
+ Mockito.when(project.getProjects()).thenReturn(ImmutableList.of(alias));
+ Mockito.when(project.getOutput()).thenReturn(ImmutableList.of(xSlot));
+
+ Expression sortExpr = Mockito.mock(Expression.class);
+ Mockito.when(sortExpr.getInputSlots()).thenReturn(ImmutableSet.of(k2Slot));
+ org.apache.doris.nereids.properties.OrderKey orderKey =
+ Mockito.mock(org.apache.doris.nereids.properties.OrderKey.class);
+ Mockito.when(orderKey.getExpr()).thenReturn(sortExpr);
+
+ org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalSort> sort =
+ Mockito.mock(org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalSort.class);
+ Mockito.when(sort.children()).thenReturn(ImmutableList.of(project));
+ Mockito.when(sort.getOrderKeys()).thenReturn(ImmutableList.of(orderKey));
+ Mockito.when(sort.getOutput()).thenReturn(ImmutableList.of(xSlot));
+
+ Map deltas = QueryStatsRecorder.collectDeltas(
+ (org.apache.doris.nereids.trees.plans.physical.PhysicalPlan) sort);
+
+ StatsDelta delta = deltas.get("1_1_1_1");
+ Assertions.assertNotNull(delta);
+ Assertions.assertTrue(delta.getColumnStats().get("k2").queryHit, "k2: ORDER BY");
+ Assertions.assertNotNull(delta.getColumnStats().get("x"), "x slot resolves via alias propagation");
+ Assertions.assertTrue(delta.getColumnStats().get("x").queryHit, "x: SELECT output via alias");
+ }
+
+ /**
+ * SELECT k1, SUM(k2) FROM t GROUP BY k1: GROUP BY k1 → queryHit, SUM input k2 → queryHit.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testGroupByAndAggregateInputQueryHit() {
+ ExprId id1 = new ExprId(1);
+ ExprId id2 = new ExprId(2);
+ SlotReference k1Slot = mockSlot(id1, "k1");
+ SlotReference k2Slot = mockSlot(id2, "k2");
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L, ImmutableList.of(k1Slot, k2Slot));
+
+ Expression groupExpr = Mockito.mock(Expression.class);
+ Mockito.when(groupExpr.getInputSlots()).thenReturn(ImmutableSet.of(k1Slot));
+
+ NamedExpression aggExpr = Mockito.mock(NamedExpression.class);
+ Mockito.when(aggExpr.getInputSlots()).thenReturn(ImmutableSet.of(k2Slot));
+
+ org.apache.doris.nereids.trees.plans.physical.PhysicalHashAggregate> agg =
+ Mockito.mock(org.apache.doris.nereids.trees.plans.physical.PhysicalHashAggregate.class);
+ Mockito.when(agg.children()).thenReturn(ImmutableList.of(scan));
+ Mockito.when(agg.getGroupByExpressions()).thenReturn(ImmutableList.of(groupExpr));
+ Mockito.when(agg.getOutputExpressions()).thenReturn(ImmutableList.of(aggExpr));
+ Mockito.when(agg.getOutput()).thenReturn(ImmutableList.of(k1Slot));
+
+ Map deltas = QueryStatsRecorder.collectDeltas((PhysicalPlan) agg);
+
+ StatsDelta delta = deltas.get("1_1_1_1");
+ Assertions.assertNotNull(delta);
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit, "k1: GROUP BY key");
+ Assertions.assertTrue(delta.getColumnStats().get("k2").queryHit, "k2: aggregate input");
+ }
+
+ /**
+ * SELECT k1 FROM t ORDER BY k2: ORDER BY k2 → queryHit.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testOrderByQueryHit() {
+ ExprId id1 = new ExprId(1);
+ ExprId id2 = new ExprId(2);
+ SlotReference k1Slot = mockSlot(id1, "k1");
+ SlotReference k2Slot = mockSlot(id2, "k2");
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L, ImmutableList.of(k1Slot, k2Slot));
+
+ Expression sortExpr = Mockito.mock(Expression.class);
+ Mockito.when(sortExpr.getInputSlots()).thenReturn(ImmutableSet.of(k2Slot));
+
+ org.apache.doris.nereids.properties.OrderKey orderKey =
+ Mockito.mock(org.apache.doris.nereids.properties.OrderKey.class);
+ Mockito.when(orderKey.getExpr()).thenReturn(sortExpr);
+
+ org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalSort> sort =
+ Mockito.mock(org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalSort.class);
+ Mockito.when(sort.children()).thenReturn(ImmutableList.of(scan));
+ Mockito.when(sort.getOrderKeys()).thenReturn(ImmutableList.of(orderKey));
+ Mockito.when(sort.getOutput()).thenReturn(ImmutableList.of(k1Slot));
+
+ Map deltas = QueryStatsRecorder.collectDeltas((PhysicalPlan) sort);
+
+ StatsDelta delta = deltas.get("1_1_1_1");
+ Assertions.assertNotNull(delta);
+ Assertions.assertTrue(delta.getColumnStats().get("k2").queryHit, "k2: ORDER BY key");
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit, "k1: SELECT output");
+ }
+
+ /**
+ * Window PARTITION BY k0, ORDER BY k1: both → queryHit.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testWindowPartitionAndOrderQueryHit() {
+ ExprId id0 = new ExprId(1);
+ ExprId id1 = new ExprId(2);
+ SlotReference k0Slot = mockSlot(id0, "k0");
+ SlotReference k1Slot = mockSlot(id1, "k1");
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L, ImmutableList.of(k0Slot, k1Slot));
+
+ Expression partExpr = Mockito.mock(Expression.class);
+ Mockito.when(partExpr.getInputSlots()).thenReturn(ImmutableSet.of(k0Slot));
+
+ Expression orderExprInner = Mockito.mock(Expression.class);
+ Mockito.when(orderExprInner.getInputSlots()).thenReturn(ImmutableSet.of(k1Slot));
+
+ org.apache.doris.nereids.trees.expressions.OrderExpression orderExpr =
+ Mockito.mock(org.apache.doris.nereids.trees.expressions.OrderExpression.class);
+ Mockito.when(orderExpr.child()).thenReturn(orderExprInner);
+
+ org.apache.doris.nereids.rules.implementation.LogicalWindowToPhysicalWindow.WindowFrameGroup wfg =
+ Mockito.mock(
+ org.apache.doris.nereids.rules.implementation.LogicalWindowToPhysicalWindow.WindowFrameGroup.class);
+ Mockito.when(wfg.getPartitionKeys()).thenReturn(ImmutableSet.of(partExpr));
+ Mockito.when(wfg.getOrderKeys()).thenReturn(ImmutableList.of(orderExpr));
+
+ org.apache.doris.nereids.trees.plans.physical.PhysicalWindow> window =
+ Mockito.mock(org.apache.doris.nereids.trees.plans.physical.PhysicalWindow.class);
+ Mockito.when(window.children()).thenReturn(ImmutableList.of(scan));
+ Mockito.when(window.getWindowFrameGroup()).thenReturn(wfg);
+ Mockito.when(window.getOutput()).thenReturn(ImmutableList.of());
+
+ Map deltas = QueryStatsRecorder.collectDeltas((PhysicalPlan) window);
+
+ StatsDelta delta = deltas.get("1_1_1_1");
+ Assertions.assertNotNull(delta);
+ Assertions.assertTrue(delta.getColumnStats().get("k0").queryHit, "k0: PARTITION BY key");
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit, "k1: window ORDER BY key");
+ }
+
+ /**
+ * PhysicalBucketedHashAggregate implements Aggregate but does not extend PhysicalHashAggregate.
+ * The Aggregate interface check must cover it.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testBucketedAggregateGroupByQueryHit() {
+ ExprId id1 = new ExprId(1);
+ SlotReference k1Slot = mockSlot(id1, "k1");
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L, ImmutableList.of(k1Slot));
+
+ Expression groupExpr = Mockito.mock(Expression.class);
+ Mockito.when(groupExpr.getInputSlots()).thenReturn(ImmutableSet.of(k1Slot));
+
+ org.apache.doris.nereids.trees.plans.physical.PhysicalBucketedHashAggregate> agg =
+ Mockito.mock(org.apache.doris.nereids.trees.plans.physical.PhysicalBucketedHashAggregate.class);
+ Mockito.when(agg.children()).thenReturn(ImmutableList.of(scan));
+ Mockito.when(agg.getGroupByExpressions()).thenReturn(ImmutableList.of(groupExpr));
+ Mockito.when(agg.getOutputExpressions()).thenReturn(ImmutableList.of());
+ Mockito.when(agg.getOutput()).thenReturn(ImmutableList.of(k1Slot));
+
+ Map deltas = QueryStatsRecorder.collectDeltas((PhysicalPlan) agg);
+
+ StatsDelta delta = deltas.get("1_1_1_1");
+ Assertions.assertNotNull(delta, "bucketed aggregate must be recorded via Aggregate interface");
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit, "k1: GROUP BY in bucketed agg");
+ }
+
+ /**
+ * JOIN ON t1.k1 = t2.k2: both hash-join and other-join conjuncts → filterHit.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testJoinConditionFilterHit() {
+ ExprId id1 = new ExprId(1);
+ ExprId id2 = new ExprId(2);
+ SlotReference k1Slot = mockSlot(id1, "k1");
+ SlotReference k2Slot = mockSlot(id2, "k2");
+
+ PhysicalOlapScan left = mockScan(1L, 1L, 1L, 1L, ImmutableList.of(k1Slot));
+ PhysicalOlapScan right = mockScan(2L, 2L, 2L, 2L, ImmutableList.of(k2Slot));
+
+ Expression hashConjunct = Mockito.mock(Expression.class);
+ Mockito.when(hashConjunct.getInputSlots()).thenReturn(ImmutableSet.of(k1Slot, k2Slot));
+
+ org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalJoin, ?> join =
+ Mockito.mock(org.apache.doris.nereids.trees.plans.physical.PhysicalHashJoin.class);
+ Mockito.when(join.children()).thenReturn(ImmutableList.of(left, right));
+ Mockito.when(join.getHashJoinConjuncts()).thenReturn(ImmutableList.of(hashConjunct));
+ Mockito.when(join.getOtherJoinConjuncts()).thenReturn(ImmutableList.of());
+ Mockito.when(join.getOutput()).thenReturn(ImmutableList.of(k1Slot));
+
+ Map deltas = QueryStatsRecorder.collectDeltas((PhysicalPlan) join);
+
+ Assertions.assertTrue(deltas.containsKey("1_1_1_1"));
+ Assertions.assertTrue(deltas.containsKey("2_2_2_2"));
+ Assertions.assertTrue(deltas.get("1_1_1_1").getColumnStats().get("k1").filterHit,
+ "k1: join hash conjunct → filterHit");
+ Assertions.assertTrue(deltas.get("2_2_2_2").getColumnStats().get("k2").filterHit,
+ "k2: join hash conjunct → filterHit");
+ }
+
+ /**
+ * JOIN ON a.k1 = b.k2 (tinyint vs smallint): Nereids inserts Alias(Cast(k1)) in a
+ * PhysicalProject via PushDownExpressionsInHashCondition. The cast-alias ExprId must be
+ * propagated into exprIdToScan so that recordInputSlotsAsFilterHit can record k1.filterHit.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testCastAliasInJoinPropagatesFilterHit() {
+ ExprId k1Id = new ExprId(1);
+ ExprId castAliasId = new ExprId(99);
+
+ SlotReference k1Slot = mockSlot(k1Id, "k1");
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L, ImmutableList.of(k1Slot));
+
+ // Cast(k1) — not a SlotReference, so unwrapAlias returns null for the alias below
+ Expression castExpr = Mockito.mock(Expression.class);
+ Mockito.when(castExpr.getInputSlots()).thenReturn(ImmutableSet.of(k1Slot));
+
+ // Alias(Cast(k1)) with a fresh ExprId — this is what PushDownExpressionsInHashCondition
+ // creates; the hash conjunct references castAliasId, not k1Id
+ org.apache.doris.nereids.trees.expressions.Alias castAlias =
+ Mockito.mock(org.apache.doris.nereids.trees.expressions.Alias.class);
+ Mockito.when(castAlias.getExprId()).thenReturn(castAliasId);
+ Mockito.when(castAlias.child()).thenReturn(castExpr);
+
+ SlotReference castAliasSlot = mockSlot(castAliasId, "k1");
+ org.apache.doris.nereids.trees.plans.physical.PhysicalProject> project =
+ Mockito.mock(org.apache.doris.nereids.trees.plans.physical.PhysicalProject.class);
+ Mockito.when(project.children()).thenReturn(ImmutableList.of(scan));
+ Mockito.when(project.getProjects()).thenReturn(ImmutableList.of(castAlias));
+ Mockito.when(project.getOutput()).thenReturn(ImmutableList.of(castAliasSlot));
+
+ // Hash conjunct uses the cast-alias slot, not the original k1Slot
+ Expression hashConjunct = Mockito.mock(Expression.class);
+ Mockito.when(hashConjunct.getInputSlots()).thenReturn(ImmutableSet.of(castAliasSlot));
+
+ org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalJoin, ?> join =
+ Mockito.mock(org.apache.doris.nereids.trees.plans.physical.PhysicalHashJoin.class);
+ Mockito.when(join.children()).thenReturn(ImmutableList.of(project));
+ Mockito.when(join.getHashJoinConjuncts()).thenReturn(ImmutableList.of(hashConjunct));
+ Mockito.when(join.getOtherJoinConjuncts()).thenReturn(ImmutableList.of());
+ Mockito.when(join.getOutput()).thenReturn(ImmutableList.of());
+
+ Map deltas = QueryStatsRecorder.collectDeltas((PhysicalPlan) join);
+
+ Assertions.assertTrue(deltas.containsKey("1_1_1_1"), "scan delta must exist");
+ Assertions.assertNotNull(deltas.get("1_1_1_1").getColumnStats().get("k1"),
+ "k1 must be recorded via cast-alias propagation");
+ Assertions.assertTrue(deltas.get("1_1_1_1").getColumnStats().get("k1").filterHit,
+ "k1.filterHit must be set through Alias(Cast(k1)) chain");
+ }
+
+ /**
+ * SUM(k2) OVER (PARTITION BY k0 ORDER BY k1): k2 (value) → queryHit in addition to k0/k1.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testWindowFunctionValueColumnQueryHit() {
+ ExprId id0 = new ExprId(1);
+ ExprId id1 = new ExprId(2);
+ ExprId id2 = new ExprId(3);
+ SlotReference k0Slot = mockSlot(id0, "k0");
+ SlotReference k1Slot = mockSlot(id1, "k1");
+ SlotReference k2Slot = mockSlot(id2, "k2");
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
+ ImmutableList.of(k0Slot, k1Slot, k2Slot));
+
+ // Window function SUM(k2) — its input slots include k2
+ Expression sumFunc = Mockito.mock(Expression.class);
+ Mockito.when(sumFunc.getInputSlots()).thenReturn(ImmutableSet.of(k2Slot));
+
+ WindowExpression windowExpr = Mockito.mock(WindowExpression.class);
+ Mockito.when(windowExpr.getFunction()).thenReturn(sumFunc);
+ Mockito.when(windowExpr.getInputSlots()).thenReturn(ImmutableSet.of(k0Slot, k1Slot, k2Slot));
+
+ NamedExpression windowAlias = Mockito.mock(NamedExpression.class);
+ Mockito.when(windowAlias.child(0)).thenReturn(windowExpr);
+
+ Expression partExpr = Mockito.mock(Expression.class);
+ Mockito.when(partExpr.getInputSlots()).thenReturn(ImmutableSet.of(k0Slot));
+
+ OrderExpression orderExpr = Mockito.mock(OrderExpression.class);
+ Expression orderInner = Mockito.mock(Expression.class);
+ Mockito.when(orderInner.getInputSlots()).thenReturn(ImmutableSet.of(k1Slot));
+ Mockito.when(orderExpr.child()).thenReturn(orderInner);
+
+ org.apache.doris.nereids.rules.implementation.LogicalWindowToPhysicalWindow.WindowFrameGroup wfg =
+ Mockito.mock(
+ org.apache.doris.nereids.rules.implementation.LogicalWindowToPhysicalWindow.WindowFrameGroup.class);
+ Mockito.when(wfg.getPartitionKeys()).thenReturn(ImmutableSet.of(partExpr));
+ Mockito.when(wfg.getOrderKeys()).thenReturn(ImmutableList.of(orderExpr));
+ Mockito.when(wfg.getGroups()).thenReturn(ImmutableList.of(windowAlias));
+
+ org.apache.doris.nereids.trees.plans.physical.PhysicalWindow> window =
+ Mockito.mock(org.apache.doris.nereids.trees.plans.physical.PhysicalWindow.class);
+ Mockito.when(window.children()).thenReturn(ImmutableList.of(scan));
+ Mockito.when(window.getWindowFrameGroup()).thenReturn(wfg);
+ Mockito.when(window.getOutput()).thenReturn(ImmutableList.of());
+
+ Map deltas = QueryStatsRecorder.collectDeltas((PhysicalPlan) window);
+
+ StatsDelta delta = deltas.get("1_1_1_1");
+ Assertions.assertNotNull(delta);
+ Assertions.assertTrue(delta.getColumnStats().get("k0").queryHit, "k0: PARTITION BY");
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit, "k1: window ORDER BY");
+ Assertions.assertTrue(delta.getColumnStats().get("k2").queryHit, "k2: SUM value column");
+ }
+
+ // ── helpers ──────────────────────────────────────────────────────────────
+
+ @Test
+ public void testPhysicalStorageLayerAggregateRegistersQueryHit() {
+ Config.enable_query_hit_stats = true;
+ ExprId id0 = new ExprId(0);
+ SlotReference k0Slot = mockSlot(id0, "k0");
+ PhysicalOlapScan scan = mockScan(1, 1, 1, 1, ImmutableList.of(k0Slot));
+
+ PhysicalStorageLayerAggregate sla = Mockito.mock(PhysicalStorageLayerAggregate.class);
+ Mockito.when(sla.getRelation()).thenReturn(scan);
+ Mockito.when(sla.children()).thenReturn(ImmutableList.of());
+ Mockito.when(sla.getOutput()).thenReturn(ImmutableList.of(k0Slot));
+
+ Map deltas = QueryStatsRecorder.collectDeltas((PhysicalPlan) sla);
+ StatsDelta delta = deltas.get("1_1_1_1");
+ Assertions.assertNotNull(delta, "StorageLayerAggregate scan must produce a delta");
+ Assertions.assertTrue(delta.getColumnStats().get("k0").queryHit, "k0: COUNT(*)/agg pushdown");
+ }
+
+ @Test
+ public void testPhysicalRepeatRegistersGroupingExpressionsAsQueryHit() {
+ Config.enable_query_hit_stats = true;
+ ExprId id0 = new ExprId(0);
+ ExprId id1 = new ExprId(1);
+ SlotReference k0Slot = mockSlot(id0, "k0");
+ SlotReference k1Slot = mockSlot(id1, "k1");
+ PhysicalOlapScan scan = mockScan(1, 1, 1, 1, ImmutableList.of(k0Slot, k1Slot));
+
+ Expression groupExpr = Mockito.mock(Expression.class);
+ Mockito.when(groupExpr.getInputSlots()).thenReturn(ImmutableSet.of(k0Slot));
+
+ PhysicalRepeat> repeat = Mockito.mock(PhysicalRepeat.class);
+ Mockito.when(repeat.children()).thenReturn(ImmutableList.of(scan));
+ Mockito.when(repeat.getOutput()).thenReturn(ImmutableList.of());
+ Mockito.when(repeat.getGroupingSets())
+ .thenReturn(ImmutableList.of(ImmutableList.of(groupExpr)));
+
+ Map deltas = QueryStatsRecorder.collectDeltas((PhysicalPlan) repeat);
+ StatsDelta delta = deltas.get("1_1_1_1");
+ Assertions.assertNotNull(delta);
+ Assertions.assertTrue(delta.getColumnStats().get("k0").queryHit, "k0: ROLLUP grouping key");
+ Assertions.assertNull(delta.getColumnStats().get("k1"), "k1 not in grouping set — no hit");
+ }
+
+ @Test
+ public void testPhysicalPartitionTopNRegistersPartitionAndOrderKeysAsQueryHit() {
+ Config.enable_query_hit_stats = true;
+ ExprId id0 = new ExprId(0);
+ ExprId id1 = new ExprId(1);
+ SlotReference k0Slot = mockSlot(id0, "k0");
+ SlotReference k1Slot = mockSlot(id1, "k1");
+ PhysicalOlapScan scan = mockScan(1, 1, 1, 1, ImmutableList.of(k0Slot, k1Slot));
+
+ Expression partExpr = Mockito.mock(Expression.class);
+ Mockito.when(partExpr.getInputSlots()).thenReturn(ImmutableSet.of(k0Slot));
+
+ Expression orderInner = Mockito.mock(Expression.class);
+ Mockito.when(orderInner.getInputSlots()).thenReturn(ImmutableSet.of(k1Slot));
+ org.apache.doris.nereids.properties.OrderKey orderKey =
+ Mockito.mock(org.apache.doris.nereids.properties.OrderKey.class);
+ Mockito.when(orderKey.getExpr()).thenReturn(orderInner);
+
+ PhysicalPartitionTopN> ptn = Mockito.mock(PhysicalPartitionTopN.class);
+ Mockito.when(ptn.children()).thenReturn(ImmutableList.of(scan));
+ Mockito.when(ptn.getOutput()).thenReturn(ImmutableList.of());
+ Mockito.when(ptn.getPartitionKeys()).thenReturn(ImmutableList.of(partExpr));
+ Mockito.when(ptn.getOrderKeys()).thenReturn(ImmutableList.of(orderKey));
+
+ Map deltas = QueryStatsRecorder.collectDeltas((PhysicalPlan) ptn);
+ StatsDelta delta = deltas.get("1_1_1_1");
+ Assertions.assertNotNull(delta);
+ Assertions.assertTrue(delta.getColumnStats().get("k0").queryHit, "k0: PARTITION BY");
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit, "k1: ORDER BY in PartitionTopN");
+ }
+
// ── helpers ──────────────────────────────────────────────────────────────
private SlotReference mockSlot(ExprId exprId, String columnName) {
diff --git a/regression-test/suites/query_p0/stats/query_stats_test.groovy b/regression-test/suites/query_p0/stats/query_stats_test.groovy
index aa9b7e5a0b6fcb..bc6c16f7fc202e 100644
--- a/regression-test/suites/query_p0/stats/query_stats_test.groovy
+++ b/regression-test/suites/query_p0/stats/query_stats_test.groovy
@@ -135,7 +135,7 @@ suite("query_stats_test") {
assertEquals(0, row[2] as int)
}
- // Alias gap: k1.queryHit = 0 until Part 2 walks Project nodes.
+ // Alias: k1.queryHit >= 1 now that alias is unwrapped; k2.filterHit >= 1 from WHERE.
sql "clean all query stats"
sql "select k1 as x from ${tbName} where k2 = 1"
def aliasResult = sql "show query stats from ${tbName}"
@@ -143,14 +143,58 @@ suite("query_stats_test") {
def arK2 = aliasResult.find { it[0] == "k2" }
assertNotNull(arK1)
assertNotNull(arK2)
- assertEquals(0, arK1[1] as int)
+ assertTrue((arK1[1] as int) >= 1)
assertTrue((arK2[2] as int) >= 1)
+ // GROUP BY: k1 queryHit from GROUP BY key, k2 queryHit from SUM(k2) aggregate input.
+ sql "clean all query stats"
+ sql "select k1, sum(k2) from ${tbName} group by k1"
+ def gbResult = sql "show query stats from ${tbName}"
+ def gbK1 = gbResult.find { it[0] == "k1" }
+ def gbK2 = gbResult.find { it[0] == "k2" }
+ assertNotNull(gbK1)
+ assertNotNull(gbK2)
+ assertTrue((gbK1[1] as int) >= 1)
+ assertTrue((gbK2[1] as int) >= 1)
+
+ // ORDER BY: k3 queryHit from SELECT, k4 queryHit from ORDER BY.
+ sql "clean all query stats"
+ sql "select k3 from ${tbName} order by k4"
+ def obResult = sql "show query stats from ${tbName}"
+ def obK3 = obResult.find { it[0] == "k3" }
+ def obK4 = obResult.find { it[0] == "k4" }
+ assertNotNull(obK3)
+ assertNotNull(obK4)
+ assertTrue((obK3[1] as int) >= 1)
+ assertTrue((obK4[1] as int) >= 1)
+
+ // JOIN: k1 filterHit from ON condition (same-type columns avoid cast-alias edge cases).
+ sql "clean all query stats"
+ sql """select a.k3 from ${tbName} a join ${tbName} b on a.k1 = b.k1"""
+ def joinOnResult = sql "show query stats from ${tbName}"
+ def jK1 = joinOnResult.find { it[0] == "k1" }
+ assertNotNull(jK1)
+ assertTrue((jK1[2] as int) >= 1)
+
+ // Window value column: k2 queryHit from SUM(k2), k0 from PARTITION BY, k1 from ORDER BY.
+ sql "clean all query stats"
+ sql "select sum(k2) over (partition by k0 order by k1) from ${tbName}"
+ def winResult = sql "show query stats from ${tbName}"
+ def wK0 = winResult.find { it[0] == "k0" }
+ def wK1 = winResult.find { it[0] == "k1" }
+ def wK2 = winResult.find { it[0] == "k2" }
+ assertNotNull(wK0)
+ assertNotNull(wK1)
+ assertNotNull(wK2)
+ assertTrue((wK0[1] as int) >= 1)
+ assertTrue((wK1[1] as int) >= 1)
+ assertTrue((wK2[1] as int) >= 1)
+
// Self-join: StatsDelta dedup keeps table count = 1 per FE.
sql "clean all query stats"
sql "select a.k1 from ${tbName} a, ${tbName} b where a.k1 = b.k1"
def joinResult = sql "show query stats from ${tbName} all"
- assertTrue((joinResult[0][1] as int) >= 1)
+ assertEquals(1, joinResult[0][1] as int)
sql "admin set all frontends config (\"enable_query_hit_stats\"=\"false\");"
sql "set enable_nereids_planner = ${origNereids}"