This is an automated email from the ASF dual-hosted git repository.
morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 6cb634e351c [Fix](Query Stats) Add QueryStatsRecorder for column-level
query and filter - Part3 (#63974)
6cb634e351c is described below
commit 6cb634e351cbb5b8251e55a8f18522673de7131b
Author: nsivarajan <[email protected]>
AuthorDate: Fri Jul 17 14:39:34 2026 +0530
[Fix](Query Stats) Add QueryStatsRecorder for column-level query and filter
- Part3 (#63974)
### What problem does this PR solve?
Issue Number: close #65423
Related PR: #63067, #63768
**Problem Summary:**
Follow-up of Part 2 (#63768). Extends column-level query/filter hit
recording to cover the constructs that were explicitly deferred from
Part 2, plus additional cases discovered during full plan-node audit:
Deferred from Part 2 (now resolved):
- UNION / INTERSECT / EXCEPT —
PhysicalSetOperation.getRegularChildrenOutputs() maps each child
branch's slots to queryHit; for EXCEPT and INTERSECT, the output-slot
link (used to resolve a filter kept above the
set op) is further restricted to the first (build-side) branch only,
since both operators materialize their output block exclusively from
branch 0 at execution time — later branches are scanned only to
probe/exclude and never contribute values
- CTE columns — PhysicalCTEProducer child is walked explicitly before
sibling nodes so scan slots are registered; PhysicalCTEConsumer maps
consumer-side ExprIds to producer scan slots
- LATERAL VIEW / EXPLODE — PhysicalGenerate generator input columns
recorded as queryHit; generator output slots are linked positionally to
their own generator's inputs so a parent filter/GROUP BY on the exploded
column resolves back to the source
- HAVING SUM(k2) > 0 — single-input aggregate output ExprId is mapped to
the underlying scan so a parent PhysicalFilter records filterHit on k2
Additional gaps closed by full plan-node audit:
- HAVING SUM(k2+k3) > 0 — multi-input aggregate outputs populate
aggOutputToInputSlots; recordInputSlotsAsFilterHit expands to all
contributing columns
- Mark join conjuncts — AbstractPhysicalJoin.getMarkJoinConjuncts() is a
separate field not included in hash or other conjuncts; adds filterHit
for IN/EXISTS subquery correlation columns
- Other join conjuncts — getOtherJoinConjuncts() (non-equi join
predicates) now records filterHit, previously untested and unhandled
- Recursive CTE — PhysicalRecursiveUnion extends PhysicalBinary (not
PhysicalSetOperation); base-case columns get queryHit via
getRegularChildrenOutputs(), and recursive-case
PhysicalWorkTableReference slots are
now linked (by CTEId + position) to their base-case counterparts before
the recursive branch itself is walked, so a filter or join predicate
inside the recursive branch that references a work-table column now
correctly resolves filterHit back to the real base-table column
- Computed SELECT expressions — SELECT k1+k2 AS result now records
queryHit on both k1 and k2 via the multi-input branch of the
PhysicalProject alias handler, consistent with how ORDER BY k1+k2 and
GROUP BY k1+k2
already behave
- HAVING GROUPING(a) / GROUPING_ID(...) — PhysicalRepeat (ROLLUP/CUBE)
output expressions are linked to their own inputs the same way aggregate
HAVING outputs are, so a filter on a grouping-function output
resolves back to the real column
- Positional window functions — ROW_NUMBER() / RANK() / DENSE_RANK()
have no function arguments, so a QUALIFY-style filter above them now
resolves via the window expression's full input set (function args +
PARTITION BY + ORDER BY keys) instead of just the function's own (empty)
inputs
Accepted as-is (not a defect):
- Set-op branch columns retained by ColumnPruning purely for
row-comparison (not otherwise selected) still get counted as a queryHit
on that branch. Narrowing this would flip currently-correct passing
tests and
risks making an entire table disappear from SHOW QUERY STATS when its
only touched column is used solely for exclusion/comparison — a worse
outcome than the current imprecision. Left unchanged; revisit only if it
proves to matter in practice.
Out of scope (intentional):
- External tables (Hive / Iceberg / JDBC / ODBC) — OlapTable-only by
design
Co-authored-by: Sivarajan Narayanan <[email protected]>
---
.../doris/statistics/query/QueryStatsRecorder.java | 411 ++++--
.../statistics/query/QueryStatsRecorderTest.java | 1373 +++++++++++++++++++-
.../suites/query_p0/stats/query_stats_test.groovy | 72 +
3 files changed, 1748 insertions(+), 108 deletions(-)
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 78ce44dc3af..41fae5ddb33 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
@@ -27,6 +27,7 @@ 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.CTEId;
import org.apache.doris.nereids.trees.expressions.ExprId;
import org.apache.doris.nereids.trees.expressions.Expression;
import org.apache.doris.nereids.trees.expressions.NamedExpression;
@@ -34,42 +35,53 @@ 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.expressions.functions.Function;
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.PhysicalCTEConsumer;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEProducer;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalExcept;
import org.apache.doris.nereids.trees.plans.physical.PhysicalFilter;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalGenerate;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalIntersect;
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.PhysicalRecursiveUnion;
+import
org.apache.doris.nereids.trees.plans.physical.PhysicalRecursiveUnionAnchor;
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.PhysicalSetOperation;
import
org.apache.doris.nereids.trees.plans.physical.PhysicalStorageLayerAggregate;
import org.apache.doris.nereids.trees.plans.physical.PhysicalWindow;
+import
org.apache.doris.nereids.trees.plans.physical.PhysicalWorkTableReference;
import org.apache.doris.qe.ConnectContext;
+import com.google.common.collect.ImmutableSet;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.HashMap;
+import java.util.HashSet;
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.
+ * Only OlapTable scans are recorded; DML, EXPLAIN, and internal queries are
skipped.
*
- * <p>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.
+ * <p>{@code exprIdToScan} holds only base scan-backed ExprIds; every derived
slot (alias,
+ * aggregate output, CTE consumer, set-op output) is an edge in {@code
derivedSlotInputs}
+ * pointing at its own inputs. {@link #resolveBaseScanExprIds} expands that
edge map
+ * recursively, however many computed hops deep a slot's provenance chain is.
*/
public class QueryStatsRecorder {
private static final Logger LOG =
LogManager.getLogger(QueryStatsRecorder.class);
@@ -99,11 +111,12 @@ public class QueryStatsRecorder {
}
}
}
- } catch (Exception e) {
+ } catch (Throwable t) {
+ // Best-effort telemetry: catch Errors too (e.g.
StackOverflowError), never break the query.
ConnectContext cc = stmtContext.getConnectContext();
String queryId = (cc != null && cc.queryId() != null)
? cc.queryId().toString() : "unknown";
- LOG.warn("Failed to build query stats deltas for query={}",
queryId, e);
+ LOG.warn("Failed to build query stats deltas for query={}",
queryId, t);
}
}
@@ -115,7 +128,10 @@ public class QueryStatsRecorder {
Map<ExprId, PhysicalOlapScan> exprIdToScan = new HashMap<>();
Map<ExprId, String> exprIdToColName = new HashMap<>();
Map<String, StatsDelta> deltas = new HashMap<>();
- walkPlan(plan, exprIdToScan, exprIdToColName, deltas);
+ // ExprId -> input slots for any derived slot (alias, aggregate
output, CTE consumer
+ // slot, set-operation output). resolveBaseScanExprIds expands this
recursively.
+ Map<ExprId, Set<Slot>> derivedSlotInputs = new HashMap<>();
+ walkPlan(plan, exprIdToScan, exprIdToColName, deltas,
derivedSlotInputs);
if (exprIdToScan.isEmpty()) {
return deltas;
}
@@ -125,20 +141,9 @@ public class QueryStatsRecorder {
: plan.getOutput();
for (NamedExpression ne : rootExprs) {
SlotReference sr = unwrapAlias(ne);
- if (sr == null) {
- continue;
- }
- 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);
- }
+ ExprId lookupId = (sr != null) ? sr.getExprId() : ne.getExprId();
+ for (ExprId baseId : resolveBaseScanExprIds(lookupId,
exprIdToScan, derivedSlotInputs)) {
+ recordExprIdAsQueryHit(baseId, exprIdToScan, exprIdToColName,
deltas);
}
}
return deltas;
@@ -170,17 +175,14 @@ public class QueryStatsRecorder {
}
/**
- * Single-pass tree walk: registers scan output slots into exprIdToScan,
- * 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.
+ * Single-pass tree walk: registers scan slots, records filterHit for
WHERE/JOIN conditions,
+ * and queryHit for GROUP BY / ORDER BY / window keys and aggregate inputs.
*/
private static void walkPlan(Plan plan,
Map<ExprId, PhysicalOlapScan> exprIdToScan,
Map<ExprId, String> exprIdToColName,
- Map<String, StatsDelta> deltas) {
+ Map<String, StatsDelta> deltas,
+ Map<ExprId, Set<Slot>> derivedSlotInputs) {
if (plan instanceof PhysicalStorageLayerAggregate) {
// COUNT(*)/MIN/MAX pushdown — the aggregate wraps the real scan
but has no children.
PhysicalRelation inner = ((PhysicalStorageLayerAggregate)
plan).getRelation();
@@ -216,22 +218,69 @@ public class QueryStatsRecorder {
}
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().
+ // PhysicalCTEProducer: walk its single child so the producer scan is
registered
+ // before any PhysicalCTEConsumer nodes are encountered during the
tree walk.
+ // Uses plan.children() for consistency with the rest of walkPlan.
+ if (plan instanceof PhysicalCTEProducer) {
+ walkPlan(plan.children().get(0),
+ exprIdToScan, exprIdToColName, deltas, derivedSlotInputs);
+ return;
+ }
+ // PhysicalCTEConsumer: link consumer slots to producer slots;
resolveBaseScanExprIds
+ // expands through this whether the producer column is direct or
itself computed.
+ if (plan instanceof PhysicalCTEConsumer) {
+ PhysicalCTEConsumer cteConsumer = (PhysicalCTEConsumer) plan;
+ for (Slot consumerSlot : cteConsumer.getOutput()) {
+ Slot producerSlot = cteConsumer.getProducerSlot(consumerSlot);
+ derivedSlotInputs.put(consumerSlot.getExprId(),
ImmutableSet.of(producerSlot));
+ }
+ }
+ // PhysicalRecursiveUnion extends PhysicalBinary (not
PhysicalSetOperation): left() is the
+ // base case (wrapped in PhysicalRecursiveUnionAnchor), right() is the
recursive case, which
+ // contains a PhysicalWorkTableReference re-scanning the base case's
own output on every
+ // iteration. WorkTableReference's output slots get brand-new ExprIds
(no named API back to
+ // the base case) that only positionally correspond to the base case's
own output, scoped by
+ // matching CTEId to the anchor. Any Filter/Join predicate inside the
recursive branch that
+ // references those slots is walked as part of walking right() below,
so this linkage must
+ // be established in derivedSlotInputs in between walking left() and
right() — the generic
+ // post-order recursion (walk all children, then run this node's own
handler) can't do that,
+ // so PhysicalRecursiveUnion controls its own children's walk order
here instead.
+ if (plan instanceof PhysicalRecursiveUnion) {
+ PhysicalRecursiveUnion<?, ?> recursiveUnion =
(PhysicalRecursiveUnion<?, ?>) plan;
+ Plan baseCase = recursiveUnion.left();
+ Plan recursiveCase = recursiveUnion.right();
+ walkPlan(baseCase, exprIdToScan, exprIdToColName, deltas,
derivedSlotInputs);
+ List<List<SlotReference>> childrenOutputs =
recursiveUnion.getRegularChildrenOutputs();
+ if (baseCase instanceof PhysicalRecursiveUnionAnchor &&
!childrenOutputs.isEmpty()) {
+ CTEId anchorCteId = ((PhysicalRecursiveUnionAnchor<?>)
baseCase).getCteId();
+ List<SlotReference> baseOutput = childrenOutputs.get(0);
+ List<PhysicalWorkTableReference> workTables =
recursiveCase.collectToList(
+ node -> node instanceof PhysicalWorkTableReference
+ &&
anchorCteId.equals(((PhysicalWorkTableReference) node).getCteId()));
+ for (PhysicalWorkTableReference workTable : workTables) {
+ List<Slot> workTableOutput = workTable.getOutput();
+ for (int i = 0; i < workTableOutput.size() && i <
baseOutput.size(); i++) {
+
derivedSlotInputs.put(workTableOutput.get(i).getExprId(),
+ ImmutableSet.of(baseOutput.get(i)));
+ }
+ }
+ }
+ walkPlan(recursiveCase, exprIdToScan, exprIdToColName, deltas,
derivedSlotInputs);
+ recordSetOpChildrenOutputs(recursiveUnion.getOutput(),
childrenOutputs,
+ exprIdToScan, exprIdToColName, deltas, derivedSlotInputs,
false);
+ return;
+ }
for (Plan child : plan.children()) {
- walkPlan(child, exprIdToScan, exprIdToColName, deltas);
+ walkPlan(child, exprIdToScan, exprIdToColName, deltas,
derivedSlotInputs);
}
if (plan instanceof PhysicalFilter) {
PhysicalFilter<?> filter = (PhysicalFilter<?>) plan;
for (Expression conjunct : filter.getConjuncts()) {
- recordInputSlotsAsFilterHit(conjunct, exprIdToScan,
exprIdToColName, deltas);
+ recordInputSlotsAsFilterHit(conjunct, exprIdToScan,
exprIdToColName, deltas, derivedSlotInputs);
}
}
- // 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.
+ // Lazy columns are absent from
PhysicalLazyMaterializeOlapScan.getOutput(); the parent
+ // PhysicalLazyMaterialize exposes them via getLazySlots(), linked by
row-id to the scan.
if (plan instanceof PhysicalLazyMaterialize) {
PhysicalLazyMaterialize<?> lazy = (PhysicalLazyMaterialize<?>)
plan;
List<Relation> rels = lazy.getRelations();
@@ -254,16 +303,31 @@ public class QueryStatsRecorder {
Aggregate<?> agg = (Aggregate<?>) plan;
// GROUP BY keys
for (Expression expr : agg.getGroupByExpressions()) {
- recordInputSlotsAsQueryHit(expr, exprIdToScan,
exprIdToColName, deltas);
+ recordInputSlotsAsQueryHit(expr, exprIdToScan,
exprIdToColName, deltas, derivedSlotInputs);
}
// Columns consumed by aggregate functions (e.g. k2 in SUM(k2))
for (NamedExpression expr : agg.getOutputExpressions()) {
- recordInputSlotsAsQueryHit(expr, exprIdToScan,
exprIdToColName, deltas);
+ recordInputSlotsAsQueryHit(expr, exprIdToScan,
exprIdToColName, deltas, derivedSlotInputs);
+ }
+ // HAVING: link each non-scan-backed aggregate output to its own
inputs, however
+ // computed those already are (e.g. HAVING SUM(x) where x is
itself computed).
+ for (NamedExpression ne : agg.getOutputExpressions()) {
+ if (exprIdToScan.containsKey(ne.getExprId()) || ne instanceof
Slot) {
+ // Bare pass-through output (e.g. two-phase DISTINCT's
merge phase reusing the
+ // local phase's slot) — already linked there;
getInputSlots() on a bare Slot
+ // includes itself, which would self-loop here.
+ continue;
+ }
+ Set<Slot> inputSlots = ne.getInputSlots();
+ if (!inputSlots.isEmpty()) {
+ derivedSlotInputs.put(ne.getExprId(), inputSlots);
+ }
}
}
if (plan instanceof AbstractPhysicalSort) {
for (OrderKey orderKey : ((AbstractPhysicalSort<?>)
plan).getOrderKeys()) {
- recordInputSlotsAsQueryHit(orderKey.getExpr(), exprIdToScan,
exprIdToColName, deltas);
+ recordInputSlotsAsQueryHit(orderKey.getExpr(), exprIdToScan,
exprIdToColName, deltas,
+ derivedSlotInputs);
}
}
// PhysicalPartitionTopN does not extend AbstractPhysicalSort but also
has ORDER BY and
@@ -271,10 +335,11 @@ public class QueryStatsRecorder {
if (plan instanceof PhysicalPartitionTopN) {
PhysicalPartitionTopN<?> ptn = (PhysicalPartitionTopN<?>) plan;
for (Expression partKey : ptn.getPartitionKeys()) {
- recordInputSlotsAsQueryHit(partKey, exprIdToScan,
exprIdToColName, deltas);
+ recordInputSlotsAsQueryHit(partKey, exprIdToScan,
exprIdToColName, deltas, derivedSlotInputs);
}
for (OrderKey orderKey : ptn.getOrderKeys()) {
- recordInputSlotsAsQueryHit(orderKey.getExpr(), exprIdToScan,
exprIdToColName, deltas);
+ recordInputSlotsAsQueryHit(orderKey.getExpr(), exprIdToScan,
exprIdToColName, deltas,
+ derivedSlotInputs);
}
}
// PhysicalRepeat handles ROLLUP/CUBE: group sets are like GROUP BY
keys.
@@ -282,44 +347,108 @@ public class QueryStatsRecorder {
PhysicalRepeat<?> repeat = (PhysicalRepeat<?>) plan;
for (List<Expression> groupSet : repeat.getGroupingSets()) {
for (Expression expr : groupSet) {
- recordInputSlotsAsQueryHit(expr, exprIdToScan,
exprIdToColName, deltas);
+ recordInputSlotsAsQueryHit(expr, exprIdToScan,
exprIdToColName, deltas, derivedSlotInputs);
}
}
for (NamedExpression expr : repeat.getOutputExpressions()) {
- recordInputSlotsAsQueryHit(expr, exprIdToScan,
exprIdToColName, deltas);
+ recordInputSlotsAsQueryHit(expr, exprIdToScan,
exprIdToColName, deltas, derivedSlotInputs);
+ }
+ // HAVING GROUPING(a)/GROUPING_ID(...): link each non-scan-backed
repeat output
+ // (e.g. Alias(Grouping(a), G)) to its own inputs, same guard as
Aggregate HAVING.
+ for (NamedExpression ne : repeat.getOutputExpressions()) {
+ if (exprIdToScan.containsKey(ne.getExprId()) || ne instanceof
Slot) {
+ continue;
+ }
+ Set<Slot> inputSlots = ne.getInputSlots();
+ if (!inputSlots.isEmpty()) {
+ derivedSlotInputs.put(ne.getExprId(), inputSlots);
+ }
}
}
if (plan instanceof PhysicalWindow) {
WindowFrameGroup wfg = ((PhysicalWindow<?>)
plan).getWindowFrameGroup();
Set<Expression> partitionKeys = wfg.getPartitionKeys();
for (Expression expr : partitionKeys) {
- recordInputSlotsAsQueryHit(expr, exprIdToScan,
exprIdToColName, deltas);
+ recordInputSlotsAsQueryHit(expr, exprIdToScan,
exprIdToColName, deltas, derivedSlotInputs);
}
for (OrderExpression orderExpr : wfg.getOrderKeys()) {
- recordInputSlotsAsQueryHit(orderExpr.child(), exprIdToScan,
exprIdToColName, deltas);
+ recordInputSlotsAsQueryHit(orderExpr.child(), exprIdToScan,
exprIdToColName, deltas,
+ derivedSlotInputs);
}
- // queryHit for the window function value columns (e.g. k2 in
SUM(k2) OVER (...)).
+ // queryHit for window value columns (e.g. k2 in SUM(k2) OVER
(...)), and link the
+ // alias to the window's FULL input set (function args + partition
+ order keys) for
+ // a QUALIFY-style filter above. Positional functions like
ROW_NUMBER have no function
+ // arguments, but still have partition/order keys, so
windowExpr.getInputSlots() (not
+ // function.getInputSlots()) is used for the link —
WindowExpression wires all three
+ // as children, so this is a single union, not three separate
calls.
for (NamedExpression windowAlias : wfg.getGroups()) {
Expression windowExpr = windowAlias.child(0);
if (windowExpr instanceof WindowExpression) {
- recordInputSlotsAsQueryHit(
- ((WindowExpression) windowExpr).getFunction(),
- exprIdToScan, exprIdToColName, deltas);
+ Expression function = ((WindowExpression)
windowExpr).getFunction();
+ recordInputSlotsAsQueryHit(function, exprIdToScan,
exprIdToColName, deltas, derivedSlotInputs);
+ if (!exprIdToScan.containsKey(windowAlias.getExprId())) {
+ Set<Slot> inputSlots = windowExpr.getInputSlots();
+ if (!inputSlots.isEmpty()) {
+ derivedSlotInputs.put(windowAlias.getExprId(),
inputSlots);
+ }
+ }
}
}
}
- // filterHit for JOIN ON conditions (hash equality and non-equality
predicates).
+ // filterHit for all JOIN ON conditions; mark conjuncts are a separate
field not included
+ // in hashJoinConjuncts or otherJoinConjuncts (IN/EXISTS subquery
correlation columns).
if (plan instanceof AbstractPhysicalJoin) {
AbstractPhysicalJoin<?, ?> join = (AbstractPhysicalJoin<?, ?>)
plan;
for (Expression conjunct : join.getHashJoinConjuncts()) {
- recordInputSlotsAsFilterHit(conjunct, exprIdToScan,
exprIdToColName, deltas);
+ recordInputSlotsAsFilterHit(conjunct, exprIdToScan,
exprIdToColName, deltas, derivedSlotInputs);
}
for (Expression conjunct : join.getOtherJoinConjuncts()) {
- recordInputSlotsAsFilterHit(conjunct, exprIdToScan,
exprIdToColName, deltas);
+ recordInputSlotsAsFilterHit(conjunct, exprIdToScan,
exprIdToColName, deltas, derivedSlotInputs);
+ }
+ for (Expression conjunct : join.getMarkJoinConjuncts()) {
+ recordInputSlotsAsFilterHit(conjunct, exprIdToScan,
exprIdToColName, deltas, derivedSlotInputs);
+ }
+ }
+ // UNION / INTERSECT / EXCEPT: queryHit per branch, plus link the set
op's own output
+ // slots to those branch slots so a filter kept above the set op (e.g.
a volatile
+ // predicate PushDownFilterThroughSetOperation can't push down) still
resolves. For
+ // EXCEPT and INTERSECT, only the first (build-side) branch's values
ever reach the
+ // output block at execution time (see
SetSourceOperatorX::_add_result_columns / the
+ // build-side DCHECK(_cur_child_id == 0) in set_sink_operator.cpp) —
later branches are
+ // scanned only to probe/exclude — so the output-slot link is
restricted to branch 0.
+ if (plan instanceof PhysicalSetOperation) {
+ PhysicalSetOperation setOp = (PhysicalSetOperation) plan;
+ recordSetOpChildrenOutputs(setOp.getOutput(),
setOp.getRegularChildrenOutputs(),
+ exprIdToScan, exprIdToColName, deltas, derivedSlotInputs,
+ plan instanceof PhysicalExcept || plan instanceof
PhysicalIntersect);
+ }
+ // LATERAL VIEW / EXPLODE: queryHit for generator inputs; filterHit
for the generator's
+ // own ON predicate (e.g. table-function join), sent straight to
TableFunctionNode.
+ // generators[i] positionally produces generatorOutput[i] (see
MergeGenerates), so link
+ // each output slot back to its generator's inputs for a parent GROUP
BY/filter on it.
+ if (plan instanceof PhysicalGenerate) {
+ PhysicalGenerate<?> generate = (PhysicalGenerate<?>) plan;
+ List<Function> generators = generate.getGenerators();
+ List<Slot> generatorOutput = generate.getGeneratorOutput();
+ for (int i = 0; i < generators.size(); i++) {
+ Function generator = generators.get(i);
+ recordInputSlotsAsQueryHit(generator, exprIdToScan,
exprIdToColName, deltas, derivedSlotInputs);
+ if (i >= generatorOutput.size()) {
+ continue;
+ }
+ Slot outputSlot = generatorOutput.get(i);
+ if (!exprIdToScan.containsKey(outputSlot.getExprId())) {
+ Set<Slot> inputSlots = generator.getInputSlots();
+ if (!inputSlots.isEmpty()) {
+ derivedSlotInputs.put(outputSlot.getExprId(),
inputSlots);
+ }
+ }
+ }
+ for (Expression conjunct : generate.getConjuncts()) {
+ recordInputSlotsAsFilterHit(conjunct, exprIdToScan,
exprIdToColName, deltas, derivedSlotInputs);
}
}
- // Propagate alias ExprIds for intermediate PhysicalProject nodes so
that parent
- // plan output slots (derived from aliases) resolve back to the
original scan.
+ // Link alias ExprIds to their input slots so parent output resolves
back to the scan.
if (plan instanceof PhysicalProject) {
for (NamedExpression ne : ((PhysicalProject<?>)
plan).getProjects()) {
if (exprIdToScan.containsKey(ne.getExprId())) {
@@ -327,56 +456,137 @@ public class QueryStatsRecorder {
}
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);
- }
- }
+ // Simple alias: Alias(SlotRef) — one input slot, same
identity chain.
+ derivedSlotInputs.put(ne.getExprId(),
ImmutableSet.of(underlying));
} 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.
+ // Computed alias: Alias(k1+k2), Alias(Cast(k1)) join-key
cast, etc.
Set<Slot> 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);
- }
- }
+ if (!inputSlots.isEmpty()) {
+ derivedSlotInputs.put(ne.getExprId(), inputSlots);
}
}
}
}
}
- private static void recordInputSlotsAsQueryHit(Expression expr,
+ /**
+ * Resolves an ExprId down to the base scan-backed ExprIds it derives
from, recursing
+ * through derivedSlotInputs for any non-scan slot. Shared by every call
site.
+ */
+ private static Set<ExprId> resolveBaseScanExprIds(ExprId exprId,
+ Map<ExprId, PhysicalOlapScan> exprIdToScan,
+ Map<ExprId, Set<Slot>> derivedSlotInputs) {
+ return resolveBaseScanExprIds(exprId, exprIdToScan, derivedSlotInputs,
new HashSet<>());
+ }
+
+ // visiting guards against a cyclic derivedSlotInputs entry causing
unbounded recursion.
+ private static Set<ExprId> resolveBaseScanExprIds(ExprId exprId,
+ Map<ExprId, PhysicalOlapScan> exprIdToScan,
+ Map<ExprId, Set<Slot>> derivedSlotInputs,
+ Set<ExprId> visiting) {
+ if (exprIdToScan.containsKey(exprId)) {
+ return ImmutableSet.of(exprId);
+ }
+ if (!visiting.add(exprId)) {
+ return ImmutableSet.of();
+ }
+ Set<Slot> inputSlots = derivedSlotInputs.get(exprId);
+ if (inputSlots == null) {
+ return ImmutableSet.of();
+ }
+ ImmutableSet.Builder<ExprId> result = ImmutableSet.builder();
+ for (Slot slot : inputSlots) {
+ if (slot instanceof SlotReference) {
+ result.addAll(resolveBaseScanExprIds(slot.getExprId(),
exprIdToScan, derivedSlotInputs, visiting));
+ }
+ }
+ return result.build();
+ }
+
+ private static void recordExprIdAsQueryHit(ExprId baseExprId,
+ Map<ExprId, PhysicalOlapScan> exprIdToScan,
+ Map<ExprId, String> exprIdToColName,
+ Map<String, StatsDelta> deltas) {
+ PhysicalOlapScan scan = exprIdToScan.get(baseExprId);
+ if (scan == null) {
+ return;
+ }
+ StatsDelta delta = getOrCreateDelta(deltas, scan);
+ if (delta == null) {
+ return;
+ }
+ String colName = exprIdToColName.get(baseExprId);
+ if (colName != null) {
+ delta.addQueryStats(colName);
+ }
+ }
+
+ private static void recordExprIdAsFilterHit(ExprId baseExprId,
Map<ExprId, PhysicalOlapScan> exprIdToScan,
Map<ExprId, String> exprIdToColName,
Map<String, StatsDelta> deltas) {
+ PhysicalOlapScan scan = exprIdToScan.get(baseExprId);
+ if (scan == null) {
+ return;
+ }
+ StatsDelta delta = getOrCreateDelta(deltas, scan);
+ if (delta == null) {
+ return;
+ }
+ String colName = exprIdToColName.get(baseExprId);
+ if (colName != null) {
+ delta.addFilterStats(colName);
+ }
+ }
+
+ /** Shared by PhysicalSetOperation and PhysicalRecursiveUnion — both
expose the same
+ * getOutput()/getRegularChildrenOutputs() contract and need identical
recording logic.
+ * onlyFirstBranchLinksOutput is true for EXCEPT: every branch is still
scanned and gets
+ * queryHit, but only the first branch's values ever reach the output, so
a filter kept
+ * above the EXCEPT must not resolve back to a later (subtracted-away)
branch's column. */
+ private static void recordSetOpChildrenOutputs(
+ List<Slot> setOpOutput,
+ List<List<SlotReference>> childrenOutputs,
+ Map<ExprId, PhysicalOlapScan> exprIdToScan,
+ Map<ExprId, String> exprIdToColName,
+ Map<String, StatsDelta> deltas,
+ Map<ExprId, Set<Slot>> derivedSlotInputs,
+ boolean onlyFirstBranchLinksOutput) {
+ for (int branchIdx = 0; branchIdx < childrenOutputs.size();
branchIdx++) {
+ List<SlotReference> childOutput = childrenOutputs.get(branchIdx);
+ boolean linkOutput = !onlyFirstBranchLinksOutput || branchIdx == 0;
+ for (int i = 0; i < childOutput.size(); i++) {
+ SlotReference branchSlot = childOutput.get(i);
+ for (ExprId baseId :
resolveBaseScanExprIds(branchSlot.getExprId(), exprIdToScan,
+ derivedSlotInputs)) {
+ recordExprIdAsQueryHit(baseId, exprIdToScan,
exprIdToColName, deltas);
+ }
+ if (!linkOutput || i >= setOpOutput.size()) {
+ continue;
+ }
+ Slot outputSlot = setOpOutput.get(i);
+ if (outputSlot instanceof SlotReference &&
!exprIdToScan.containsKey(outputSlot.getExprId())
+ &&
!outputSlot.getExprId().equals(branchSlot.getExprId())) {
+ // Skip self-reuse (output slot IS the branch slot) —
would self-loop; the
+ // branch's queryHit is already recorded above regardless.
+ derivedSlotInputs.computeIfAbsent(outputSlot.getExprId(),
k -> new HashSet<>())
+ .add(branchSlot);
+ }
+ }
+ }
+ }
+
+ private static void recordInputSlotsAsQueryHit(Expression expr,
+ Map<ExprId, PhysicalOlapScan> exprIdToScan,
+ Map<ExprId, String> exprIdToColName,
+ Map<String, StatsDelta> deltas,
+ Map<ExprId, Set<Slot>> derivedSlotInputs) {
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);
- }
+ for (ExprId baseId : resolveBaseScanExprIds(slot.getExprId(),
exprIdToScan, derivedSlotInputs)) {
+ recordExprIdAsQueryHit(baseId, exprIdToScan, exprIdToColName,
deltas);
}
}
}
@@ -384,23 +594,14 @@ public class QueryStatsRecorder {
private static void recordInputSlotsAsFilterHit(Expression expr,
Map<ExprId, PhysicalOlapScan> exprIdToScan,
Map<ExprId, String> exprIdToColName,
- Map<String, StatsDelta> deltas) {
+ Map<String, StatsDelta> deltas,
+ Map<ExprId, Set<Slot>> derivedSlotInputs) {
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);
- }
+ for (ExprId baseId : resolveBaseScanExprIds(slot.getExprId(),
exprIdToScan, derivedSlotInputs)) {
+ recordExprIdAsFilterHit(baseId, exprIdToScan, exprIdToColName,
deltas);
}
}
}
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 7d747f55f57..2dbf6da6dd2 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
@@ -24,6 +24,8 @@ import org.apache.doris.catalog.OlapTable;
import org.apache.doris.common.Config;
import org.apache.doris.nereids.StatementContext;
import org.apache.doris.nereids.glue.LogicalPlanAdapter;
+import org.apache.doris.nereids.trees.expressions.Alias;
+import org.apache.doris.nereids.trees.expressions.CTEId;
import org.apache.doris.nereids.trees.expressions.ExprId;
import org.apache.doris.nereids.trees.expressions.Expression;
import org.apache.doris.nereids.trees.expressions.NamedExpression;
@@ -31,15 +33,28 @@ 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.expressions.functions.Function;
+import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.algebra.Aggregate;
import org.apache.doris.nereids.trees.plans.commands.Command;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEConsumer;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalCTEProducer;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalExcept;
import org.apache.doris.nereids.trees.plans.physical.PhysicalFilter;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalGenerate;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalIntersect;
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.PhysicalRecursiveUnion;
+import
org.apache.doris.nereids.trees.plans.physical.PhysicalRecursiveUnionAnchor;
import org.apache.doris.nereids.trees.plans.physical.PhysicalRepeat;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalSetOperation;
import
org.apache.doris.nereids.trees.plans.physical.PhysicalStorageLayerAggregate;
+import
org.apache.doris.nereids.trees.plans.physical.PhysicalWorkTableReference;
import org.apache.doris.qe.ConnectContext;
import org.apache.doris.qe.QueryState;
@@ -563,8 +578,9 @@ public class QueryStatsRecorderTest {
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");
+ Assertions.assertNotNull(delta.getColumnStats().get("k1"),
+ "k1 resolves via alias propagation under intermediate Sort");
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit, "k1:
SELECT output via alias");
}
/**
@@ -600,6 +616,135 @@ public class QueryStatsRecorderTest {
Assertions.assertTrue(delta.getColumnStats().get("k2").queryHit, "k2:
aggregate input");
}
+ /**
+ * SELECT DISTINCT UPPER(k6) ... ORDER BY UPPER(k6): the merge phase of
two-phase aggregation
+ * outputs a bare pass-through SlotReference reusing the local phase's
ExprId. Regression test
+ * for a StackOverflowError this used to cause (getInputSlots() on a bare
Slot includes itself).
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void
testTwoPhaseAggregateBarePassthroughOutputDoesNotSelfReference() {
+ ExprId id6 = new ExprId(1);
+ ExprId mergeOutputId = new ExprId(2);
+ SlotReference k6Slot = mockSlot(id6, "k6");
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k6Slot));
+
+ // Local (partial) phase: real Alias(Upper(k6), Y) — links Y -> k6.
+ NamedExpression localOutput = Mockito.mock(NamedExpression.class);
+ Mockito.when(localOutput.getExprId()).thenReturn(mergeOutputId);
+
Mockito.when(localOutput.getInputSlots()).thenReturn(ImmutableSet.of(k6Slot));
+
+ Aggregate<?> localAgg = Mockito.mock(Aggregate.class);
+ Mockito.when(localAgg.children()).thenReturn(ImmutableList.of(scan));
+
Mockito.when(localAgg.getGroupByExpressions()).thenReturn(ImmutableList.of());
+ Mockito.when(localAgg.getOutputExpressions())
+ .thenReturn((List<NamedExpression>) (List<?>)
ImmutableList.of(localOutput));
+ Mockito.when(localAgg.getOutput()).thenReturn(ImmutableList.of());
+
+ // Merge (final) phase: bare pass-through SlotReference reusing the
SAME ExprId Y.
+ // getInputSlots() includes itself, matching real
Expression.getInputSlots() for a leaf Slot.
+ SlotReference mergeOutputSlot = mockSlot(mergeOutputId, "upper_k6");
+
Mockito.when(mergeOutputSlot.getInputSlots()).thenReturn(ImmutableSet.of(mergeOutputSlot));
+
+ Aggregate<?> mergeAgg = Mockito.mock(Aggregate.class);
+
Mockito.when(mergeAgg.children()).thenReturn(ImmutableList.of(localAgg));
+
Mockito.when(mergeAgg.getGroupByExpressions()).thenReturn(ImmutableList.of());
+ Mockito.when(mergeAgg.getOutputExpressions())
+ .thenReturn((List<NamedExpression>) (List<?>)
ImmutableList.of(mergeOutputSlot));
+
Mockito.when(mergeAgg.getOutput()).thenReturn(ImmutableList.of(mergeOutputSlot));
+
+ // ORDER BY the merge phase's own output slot directly (same identity,
no recomputation).
+ Expression orderInner = Mockito.mock(Expression.class);
+
Mockito.when(orderInner.getInputSlots()).thenReturn(ImmutableSet.of(mergeOutputSlot));
+ org.apache.doris.nereids.properties.OrderKey orderKey =
+
Mockito.mock(org.apache.doris.nereids.properties.OrderKey.class);
+ Mockito.when(orderKey.getExpr()).thenReturn(orderInner);
+
+ 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(mergeAgg));
+
Mockito.when(sort.getOrderKeys()).thenReturn(ImmutableList.of(orderKey));
+
Mockito.when(sort.getOutput()).thenReturn(ImmutableList.of(mergeOutputSlot));
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) sort);
+
+ StatsDelta delta = deltas.get("1_1_1_1");
+ Assertions.assertNotNull(delta);
+ Assertions.assertTrue(delta.getColumnStats().get("k6").queryHit, "k6:
resolves via the local phase's link");
+ }
+
+ /**
+ * Regression for the `visiting` cycle guard's generality: a true 2-hop
cycle (derivedSlotInputs
+ * entry A points to B, and B's entry points back to A — no real scan
column involved at all,
+ * unlike the length-1 self-loop test above). Two stacked PhysicalProject
nodes each alias the
+ * other's ExprId, forming the cycle; a real k1 column exists separately
so collectDeltas
+ * doesn't short-circuit on an empty exprIdToScan. Must terminate with no
attribution for the
+ * cyclic slot, not StackOverflowError, and must not disturb resolution of
the unrelated k1.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testMultiHopCycleInDerivedSlotInputsDoesNotOverflow() {
+ ExprId k1Id = new ExprId(1);
+ ExprId aId = new ExprId(100);
+ ExprId bId = new ExprId(101);
+ SlotReference k1Slot = mockSlot(k1Id, "k1");
+ SlotReference slotA = mockSlot(aId, "a_ref");
+ SlotReference slotB = mockSlot(bId, "b_ref");
+
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k1Slot));
+
+ // Project1: Alias(SlotRef(a_ref)) with exprId B — links B -> {slotA}.
+ org.apache.doris.nereids.trees.expressions.Alias alias1 =
+
Mockito.mock(org.apache.doris.nereids.trees.expressions.Alias.class);
+ Mockito.when(alias1.getExprId()).thenReturn(bId);
+ Mockito.when(alias1.child()).thenReturn(slotA);
+
+ PhysicalProject<?> project1 = Mockito.mock(PhysicalProject.class);
+ Mockito.when(project1.children()).thenReturn(ImmutableList.of(scan));
+
Mockito.when(project1.getProjects()).thenReturn(ImmutableList.of(alias1,
k1Slot));
+ Mockito.when(project1.getOutput()).thenReturn(ImmutableList.of());
+
+ // Project2: Alias(SlotRef(b_ref)) with exprId A — links A -> {slotB},
closing the cycle.
+ org.apache.doris.nereids.trees.expressions.Alias alias2 =
+
Mockito.mock(org.apache.doris.nereids.trees.expressions.Alias.class);
+ Mockito.when(alias2.getExprId()).thenReturn(aId);
+ Mockito.when(alias2.child()).thenReturn(slotB);
+
+ PhysicalProject<?> project2 = Mockito.mock(PhysicalProject.class);
+
Mockito.when(project2.children()).thenReturn(ImmutableList.of(project1));
+
Mockito.when(project2.getProjects()).thenReturn(ImmutableList.of(alias2));
+ Mockito.when(project2.getOutput()).thenReturn(ImmutableList.of());
+
+ // ORDER BY the cyclic slot A, plus a real ORDER BY on k1 to prove the
cycle doesn't
+ // disturb unrelated resolution.
+ Expression cyclicOrderExpr = Mockito.mock(Expression.class);
+
Mockito.when(cyclicOrderExpr.getInputSlots()).thenReturn(ImmutableSet.of(slotA));
+ org.apache.doris.nereids.properties.OrderKey cyclicOrderKey =
+
Mockito.mock(org.apache.doris.nereids.properties.OrderKey.class);
+ Mockito.when(cyclicOrderKey.getExpr()).thenReturn(cyclicOrderExpr);
+
+ Expression k1OrderExpr = Mockito.mock(Expression.class);
+
Mockito.when(k1OrderExpr.getInputSlots()).thenReturn(ImmutableSet.of(k1Slot));
+ org.apache.doris.nereids.properties.OrderKey k1OrderKey =
+
Mockito.mock(org.apache.doris.nereids.properties.OrderKey.class);
+ Mockito.when(k1OrderKey.getExpr()).thenReturn(k1OrderExpr);
+
+ 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(project2));
+
Mockito.when(sort.getOrderKeys()).thenReturn(ImmutableList.of(cyclicOrderKey,
k1OrderKey));
+ Mockito.when(sort.getOutput()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas = Assertions.assertDoesNotThrow(
+ () -> QueryStatsRecorder.collectDeltas((PhysicalPlan) sort),
+ "a 2-hop cycle in derivedSlotInputs must not cause a
StackOverflowError");
+
+ StatsDelta delta = deltas.get("1_1_1_1");
+ Assertions.assertNotNull(delta);
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit,
+ "k1: unrelated resolution must still work correctly alongside
the cycle");
+ }
+
/**
* SELECT k1 FROM t ORDER BY k2: ORDER BY k2 → queryHit.
*/
@@ -845,6 +990,150 @@ public class QueryStatsRecorderTest {
Assertions.assertTrue(delta.getColumnStats().get("k2").queryHit, "k2:
SUM value column");
}
+ /**
+ * QUALIFY-style filter above a window on a value-preserving window
function:
+ * SELECT k1 FROM (SELECT k1, SUM(k2) OVER (PARTITION BY k0 ORDER BY k1)
AS running_sum
+ * FROM t) w WHERE running_sum > 100. The window alias must be linked to
its function's
+ * own inputs so the filter resolves back to k2, the same way HAVING
resolves through an
+ * aggregate output.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testFilterAboveWindowValueColumnRecordsFilterHit() {
+ ExprId id0 = new ExprId(1);
+ ExprId id1 = new ExprId(2);
+ ExprId id2 = new ExprId(3);
+ ExprId windowAliasId = new ExprId(10);
+
+ 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.getExprId()).thenReturn(windowAliasId);
+ 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());
+
+ // QUALIFY-style filter above the window, referencing the window
alias's own output.
+ SlotReference windowOutputSlot = mockSlot(windowAliasId,
"running_sum");
+ Expression filterConjunct = Mockito.mock(Expression.class);
+
Mockito.when(filterConjunct.getInputSlots()).thenReturn(ImmutableSet.of(windowOutputSlot));
+
+ PhysicalFilter<?> filter = Mockito.mock(PhysicalFilter.class);
+ Mockito.when(filter.children()).thenReturn(ImmutableList.of(window));
+
Mockito.when(filter.getConjuncts()).thenReturn(ImmutableSet.of(filterConjunct));
+ Mockito.when(filter.getOutput()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) filter);
+
+ StatsDelta delta = deltas.get("1_1_1_1");
+ Assertions.assertNotNull(delta);
+ Assertions.assertTrue(delta.getColumnStats().get("k2").filterHit,
+ "k2: filter above window on SUM(k2) OVER(...) value column");
+ }
+
+ /**
+ * QUALIFY-style filter above ROW_NUMBER() OVER (PARTITION BY k0 ORDER BY
k1): the function
+ * itself takes no arguments, so function.getInputSlots() is empty and
cannot link the alias
+ * to anything — the alias must instead link through the FULL
WindowExpression (which wires
+ * the partition/order keys as children too), so the filter still resolves
to k0/k1.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testFilterAbovePositionalWindowFunctionRecordsFilterHit() {
+ ExprId id0 = new ExprId(1);
+ ExprId id1 = new ExprId(2);
+ ExprId windowAliasId = new ExprId(10);
+
+ SlotReference k0Slot = mockSlot(id0, "k0");
+ SlotReference k1Slot = mockSlot(id1, "k1");
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k0Slot, k1Slot));
+
+ // ROW_NUMBER() — no arguments, getInputSlots() is empty.
+ Expression rowNumberFunc = Mockito.mock(Expression.class);
+
Mockito.when(rowNumberFunc.getInputSlots()).thenReturn(ImmutableSet.of());
+
+ WindowExpression windowExpr = Mockito.mock(WindowExpression.class);
+ Mockito.when(windowExpr.getFunction()).thenReturn(rowNumberFunc);
+ // The WindowExpression's OWN getInputSlots() still includes
partition/order keys even
+ // though the function itself has none — this is what the fix now
links through.
+
Mockito.when(windowExpr.getInputSlots()).thenReturn(ImmutableSet.of(k0Slot,
k1Slot));
+
+ NamedExpression windowAlias = Mockito.mock(NamedExpression.class);
+ Mockito.when(windowAlias.getExprId()).thenReturn(windowAliasId);
+ 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());
+
+ // QUALIFY-style filter above the window, referencing the window
alias's own output.
+ SlotReference windowOutputSlot = mockSlot(windowAliasId, "rn");
+ Expression filterConjunct = Mockito.mock(Expression.class);
+
Mockito.when(filterConjunct.getInputSlots()).thenReturn(ImmutableSet.of(windowOutputSlot));
+
+ PhysicalFilter<?> filter = Mockito.mock(PhysicalFilter.class);
+ Mockito.when(filter.children()).thenReturn(ImmutableList.of(window));
+
Mockito.when(filter.getConjuncts()).thenReturn(ImmutableSet.of(filterConjunct));
+ Mockito.when(filter.getOutput()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) filter);
+
+ StatsDelta delta = deltas.get("1_1_1_1");
+ Assertions.assertNotNull(delta);
+ Assertions.assertTrue(delta.getColumnStats().get("k0").filterHit,
+ "k0: QUALIFY on ROW_NUMBER() resolves through the partition
key");
+ Assertions.assertTrue(delta.getColumnStats().get("k1").filterHit,
+ "k1: QUALIFY on ROW_NUMBER() resolves through the order key");
+ }
+
// ── helpers
──────────────────────────────────────────────────────────────
@Test
@@ -890,6 +1179,91 @@ public class QueryStatsRecorderTest {
Assertions.assertNull(delta.getColumnStats().get("k1"), "k1 not in
grouping set — no hit");
}
+ /**
+ * SELECT k0, SUM(k2) FROM t GROUP BY ROLLUP(k0): k0 is a grouping key
(getGroupingSets()),
+ * k2 feeds the aggregate function via a non-grouping output column
(getOutputExpressions()).
+ * The existing repeat test never stubs getOutputExpressions(), leaving
this loop untested.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testPhysicalRepeatRegistersOutputExpressionsAsQueryHit() {
+ Config.enable_query_hit_stats = true;
+ ExprId id0 = new ExprId(0);
+ ExprId id2 = new ExprId(2);
+ SlotReference k0Slot = mockSlot(id0, "k0");
+ SlotReference k2Slot = mockSlot(id2, "k2");
+ PhysicalOlapScan scan = mockScan(1, 1, 1, 1, ImmutableList.of(k0Slot,
k2Slot));
+
+ Expression groupExpr = Mockito.mock(Expression.class);
+
Mockito.when(groupExpr.getInputSlots()).thenReturn(ImmutableSet.of(k0Slot));
+
+ // SUM(k2): a non-grouping-key output column consumed by an aggregate
function above.
+ NamedExpression sumExpr = Mockito.mock(NamedExpression.class);
+
Mockito.when(sumExpr.getInputSlots()).thenReturn(ImmutableSet.of(k2Slot));
+
+ 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)));
+ Mockito.when(repeat.getOutputExpressions())
+ .thenReturn((List<NamedExpression>) (List<?>)
ImmutableList.of(sumExpr));
+
+ Map<String, StatsDelta> 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.assertTrue(delta.getColumnStats().get("k2").queryHit,
+ "k2: non-grouping output column feeding SUM(k2) under
ROLLUP/CUBE");
+ }
+
+ /**
+ * SELECT a, ... FROM t GROUP BY ROLLUP(a) HAVING GROUPING(a) = 1: the
repeat output
+ * Alias(Grouping(a), G) is not scan-backed and has no Alias wrapper
stubbed here (matching
+ * how repeat.getOutputExpressions() elements are consumed directly) — its
own ExprId (G)
+ * must link back to `a` so a HAVING filter on the grouping indicator
resolves to filterHit.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testPhysicalRepeatGroupingOutputRecordsHavingFilterHit() {
+ ExprId aId = new ExprId(0);
+ ExprId groupingId = new ExprId(2);
+ SlotReference aSlot = mockSlot(aId, "a");
+ PhysicalOlapScan scan = mockScan(1, 1, 1, 1, ImmutableList.of(aSlot));
+
+ Expression groupExpr = Mockito.mock(Expression.class);
+
Mockito.when(groupExpr.getInputSlots()).thenReturn(ImmutableSet.of(aSlot));
+
+ // Alias(Grouping(a), G): a non-scan-backed output whose own
getInputSlots() is {a}.
+ NamedExpression groupingExpr = Mockito.mock(NamedExpression.class);
+ Mockito.when(groupingExpr.getExprId()).thenReturn(groupingId);
+
Mockito.when(groupingExpr.getInputSlots()).thenReturn(ImmutableSet.of(aSlot));
+
+ 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)));
+ Mockito.when(repeat.getOutputExpressions())
+ .thenReturn((List<NamedExpression>) (List<?>)
ImmutableList.of(groupingExpr));
+
+ // HAVING GROUPING(a) = 1: filter references the repeat output's own
ExprId directly.
+ SlotReference groupingSlot = mockSlot(groupingId, "grouping_a");
+ Expression havingConjunct = Mockito.mock(Expression.class);
+
Mockito.when(havingConjunct.getInputSlots()).thenReturn(ImmutableSet.of(groupingSlot));
+
+ PhysicalFilter<?> having = Mockito.mock(PhysicalFilter.class);
+ Mockito.when(having.children()).thenReturn(ImmutableList.of(repeat));
+
Mockito.when(having.getConjuncts()).thenReturn(ImmutableSet.of(havingConjunct));
+ Mockito.when(having.getOutput()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) having);
+ StatsDelta delta = deltas.get("1_1_1_1");
+ Assertions.assertNotNull(delta);
+ Assertions.assertTrue(delta.getColumnStats().get("a").filterHit,
+ "a: HAVING GROUPING(a) = 1 resolves back through the repeat
output's own lineage");
+ }
+
@Test
public void
testPhysicalPartitionTopNRegistersPartitionAndOrderKeysAsQueryHit() {
Config.enable_query_hit_stats = true;
@@ -921,7 +1295,1000 @@ public class QueryStatsRecorderTest {
Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit, "k1:
ORDER BY in PartitionTopN");
}
- // ── helpers
──────────────────────────────────────────────────────────────
+ // ── provenance resolver: multi-hop computed-slot chains ─────────────────
+
+ /**
+ * SELECT k1+1 FROM t: single-input computed alias at the query root.
+ * Before the shared resolver, single-input aliases were registered
directly in
+ * exprIdToScan by the PhysicalProject handler, but the root output loop's
fallback
+ * only checked derivedSlotInputs — so this case silently recorded nothing.
+ * Expected: k1.queryHit=true.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testSingleInputComputedRootSelectRecordsQueryHit() {
+ ExprId k1Id = new ExprId(1);
+ ExprId aliasId = new ExprId(99);
+ SlotReference k1Slot = mockSlot(k1Id, "k1");
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k1Slot));
+
+ // Simulates k1+1 — one input slot; the literal contributes no slot.
+ Expression addExpr = Mockito.mock(Expression.class);
+
Mockito.when(addExpr.getInputSlots()).thenReturn(ImmutableSet.of(k1Slot));
+
+ Alias alias = Mockito.mock(Alias.class);
+ Mockito.when(alias.getExprId()).thenReturn(aliasId);
+ Mockito.when(alias.child()).thenReturn(addExpr);
+
+ PhysicalProject<?> project = Mockito.mock(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<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) project);
+
+ Assertions.assertEquals(1, deltas.size());
+ StatsDelta delta = deltas.values().iterator().next();
+ Assertions.assertNotNull(delta.getColumnStats().get("k1"),
+ "k1 must be recorded from computed SELECT k1+1");
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit);
+ }
+
+ /**
+ * WITH cte AS (SELECT k1+k2 AS x FROM t) SELECT x FROM cte: CTE consumer
over a
+ * computed producer column. Before the shared resolver, the CTE consumer
branch only
+ * copied a producer slot that was already a direct exprIdToScan entry — a
computed
+ * producer column (in derivedSlotInputs) was silently skipped.
+ * Expected: k1.queryHit=true AND k2.queryHit=true.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testCteConsumerOverComputedProducerColumnRecordsQueryHit() {
+ ExprId k1Id = new ExprId(1);
+ ExprId k2Id = new ExprId(2);
+ ExprId producerXId = new ExprId(10);
+ ExprId consumerXId = new ExprId(20);
+
+ SlotReference k1Slot = mockSlot(k1Id, "k1");
+ SlotReference k2Slot = mockSlot(k2Id, "k2");
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k1Slot, k2Slot));
+
+ // Producer: SELECT k1+k2 AS x FROM t
+ Expression addExpr = Mockito.mock(Expression.class);
+
Mockito.when(addExpr.getInputSlots()).thenReturn(ImmutableSet.of(k1Slot,
k2Slot));
+ Alias xAlias = Mockito.mock(Alias.class);
+ Mockito.when(xAlias.getExprId()).thenReturn(producerXId);
+ Mockito.when(xAlias.child()).thenReturn(addExpr);
+
+ PhysicalProject<?> producerProject =
Mockito.mock(PhysicalProject.class);
+
Mockito.when(producerProject.children()).thenReturn(ImmutableList.of(scan));
+
Mockito.when(producerProject.getProjects()).thenReturn(ImmutableList.of(xAlias));
+
Mockito.when(producerProject.getOutput()).thenReturn(ImmutableList.of());
+
+ PhysicalCTEProducer<?> producer =
Mockito.mock(PhysicalCTEProducer.class);
+
Mockito.when(producer.children()).thenReturn(ImmutableList.of(producerProject));
+
+ SlotReference producerXSlot = mockSlot(producerXId, "x");
+ SlotReference consumerXSlot = mockSlot(consumerXId, "x");
+
+ PhysicalCTEConsumer consumer = Mockito.mock(PhysicalCTEConsumer.class);
+
Mockito.when(consumer.getOutput()).thenReturn(ImmutableList.of(consumerXSlot));
+
Mockito.when(consumer.getProducerSlot(consumerXSlot)).thenReturn(producerXSlot);
+ Mockito.when(consumer.children()).thenReturn(ImmutableList.of());
+
+ PhysicalFilter<?> root = Mockito.mock(PhysicalFilter.class);
+ Mockito.when(root.children()).thenReturn(ImmutableList.of(producer,
consumer));
+ Mockito.when(root.getConjuncts()).thenReturn(ImmutableSet.of());
+
Mockito.when(root.getOutput()).thenReturn(ImmutableList.of(consumerXSlot));
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) root);
+
+ Assertions.assertEquals(1, deltas.size());
+ StatsDelta delta = deltas.values().iterator().next();
+ Assertions.assertNotNull(delta.getColumnStats().get("k1"),
+ "k1 must be recorded via CTE consumer over a computed producer
column");
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit);
+ Assertions.assertNotNull(delta.getColumnStats().get("k2"));
+ Assertions.assertTrue(delta.getColumnStats().get("k2").queryHit);
+ }
+
+ /**
+ * SELECT k1+k2 FROM t UNION ALL SELECT k1+k2 FROM t: a branch's
contributing column is
+ * itself a computed alias. Before the shared resolver,
recordSetOpChildrenOutputs only
+ * checked exprIdToScan directly and skipped computed branch outputs.
+ * Expected: k1.queryHit=true AND k2.queryHit=true.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testSetOperationComputedBranchColumnRecordsQueryHit() {
+ ExprId k1Id = new ExprId(1);
+ ExprId k2Id = new ExprId(2);
+ ExprId branchAliasId = new ExprId(50);
+
+ SlotReference k1Slot = mockSlot(k1Id, "k1");
+ SlotReference k2Slot = mockSlot(k2Id, "k2");
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k1Slot, k2Slot));
+
+ // Branch: SELECT k1+k2 FROM t
+ Expression addExpr = Mockito.mock(Expression.class);
+
Mockito.when(addExpr.getInputSlots()).thenReturn(ImmutableSet.of(k1Slot,
k2Slot));
+ Alias branchAlias = Mockito.mock(Alias.class);
+ Mockito.when(branchAlias.getExprId()).thenReturn(branchAliasId);
+ Mockito.when(branchAlias.child()).thenReturn(addExpr);
+
+ PhysicalProject<?> branchProject = Mockito.mock(PhysicalProject.class);
+
Mockito.when(branchProject.children()).thenReturn(ImmutableList.of(scan));
+
Mockito.when(branchProject.getProjects()).thenReturn(ImmutableList.of(branchAlias));
+ Mockito.when(branchProject.getOutput()).thenReturn(ImmutableList.of());
+
+ // The set operation's own child-output slot at this position shares
the alias's ExprId.
+ SlotReference branchOutputSlot = mockSlot(branchAliasId, "k1 + k2");
+
+ PhysicalSetOperation union = Mockito.mock(PhysicalSetOperation.class);
+
Mockito.when(union.children()).thenReturn(ImmutableList.of(branchProject));
+ Mockito.when(union.getRegularChildrenOutputs())
+
.thenReturn(ImmutableList.of(ImmutableList.of(branchOutputSlot)));
+ Mockito.when(union.getOutput()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) union);
+
+ Assertions.assertEquals(1, deltas.size());
+ StatsDelta delta = deltas.values().iterator().next();
+ Assertions.assertNotNull(delta.getColumnStats().get("k1"),
+ "k1 must be recorded via a computed UNION branch column");
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit);
+ Assertions.assertNotNull(delta.getColumnStats().get("k2"));
+ Assertions.assertTrue(delta.getColumnStats().get("k2").queryHit);
+ }
+
+ /**
+ * SELECT SUM(x) FROM (SELECT k1+k2 AS x FROM t) s HAVING SUM(x) > 0: the
aggregate's
+ * single input (x) is itself a computed column. Before the shared
resolver, the
+ * single-input HAVING branch only checked exprIdToScan for that one input
and never
+ * fell back to derivedSlotInputs, so a nested computed input was silently
dropped.
+ * Expected: k1.filterHit=true AND k2.filterHit=true.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void
testNestedSingleInputHavingOverComputedColumnRecordsFilterHit() {
+ ExprId k1Id = new ExprId(1);
+ ExprId k2Id = new ExprId(2);
+ ExprId xId = new ExprId(10);
+ ExprId sumXId = new ExprId(20);
+
+ SlotReference k1Slot = mockSlot(k1Id, "k1");
+ SlotReference k2Slot = mockSlot(k2Id, "k2");
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k1Slot, k2Slot));
+
+ // Subquery: SELECT k1+k2 AS x FROM t
+ Expression addExpr = Mockito.mock(Expression.class);
+
Mockito.when(addExpr.getInputSlots()).thenReturn(ImmutableSet.of(k1Slot,
k2Slot));
+ Alias xAlias = Mockito.mock(Alias.class);
+ Mockito.when(xAlias.getExprId()).thenReturn(xId);
+ Mockito.when(xAlias.child()).thenReturn(addExpr);
+
+ PhysicalProject<?> subqueryProject =
Mockito.mock(PhysicalProject.class);
+
Mockito.when(subqueryProject.children()).thenReturn(ImmutableList.of(scan));
+
Mockito.when(subqueryProject.getProjects()).thenReturn(ImmutableList.of(xAlias));
+
Mockito.when(subqueryProject.getOutput()).thenReturn(ImmutableList.of());
+
+ SlotReference xSlot = mockSlot(xId, "x");
+
+ // Outer aggregate: SUM(x) — one input slot, but x is itself computed.
+ NamedExpression sumExpr = Mockito.mock(NamedExpression.class);
+ Mockito.when(sumExpr.getExprId()).thenReturn(sumXId);
+
Mockito.when(sumExpr.getInputSlots()).thenReturn(ImmutableSet.of(xSlot));
+
+ Aggregate<?> agg = Mockito.mock(Aggregate.class);
+
Mockito.when(agg.children()).thenReturn(ImmutableList.of(subqueryProject));
+
Mockito.when(agg.getGroupByExpressions()).thenReturn(ImmutableList.of());
+ Mockito.when(agg.getOutputExpressions())
+ .thenReturn((List<NamedExpression>) (List<?>)
ImmutableList.of(sumExpr));
+ Mockito.when(agg.getOutput()).thenReturn(ImmutableList.of());
+
+ SlotReference sumSlot = mockSlot(sumXId, "sum_x");
+ Expression havingConjunct = Mockito.mock(Expression.class);
+
Mockito.when(havingConjunct.getInputSlots()).thenReturn(ImmutableSet.of(sumSlot));
+
+ PhysicalFilter<?> having = Mockito.mock(PhysicalFilter.class);
+ Mockito.when(having.children()).thenReturn(ImmutableList.of(agg));
+
Mockito.when(having.getConjuncts()).thenReturn(ImmutableSet.of(havingConjunct));
+ Mockito.when(having.getOutput()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) having);
+
+ Assertions.assertEquals(1, deltas.size());
+ StatsDelta delta = deltas.values().iterator().next();
+ Assertions.assertNotNull(delta.getColumnStats().get("k1"),
+ "k1 must get filterHit via HAVING SUM(x) where x is itself
computed");
+ Assertions.assertTrue(delta.getColumnStats().get("k1").filterHit);
+ Assertions.assertNotNull(delta.getColumnStats().get("k2"));
+ Assertions.assertTrue(delta.getColumnStats().get("k2").filterHit);
+ }
+
+ /**
+ * SELECT * FROM (SELECT k1 FROM t1 UNION ALL SELECT k1 FROM t2) v WHERE
k1 > 0, where
+ * the filter sits above the set operation and references its output slot
directly (the
+ * shape PushDownFilterThroughSetOperation leaves in place for predicates
it cannot push
+ * into a branch, e.g. involving a volatile function). Before the shared
resolver, the
+ * set operation's own output ExprId was never linked to its branches, so
a parent filter
+ * referencing it resolved to nothing.
+ * Expected: k1.filterHit=true on both underlying tables.
+ */
+ @Test
+ public void testFilterAboveSetOperationResolvesToScanColumns() {
+ ExprId leftId = new ExprId(1);
+ ExprId rightId = new ExprId(3);
+ ExprId setOutputId = new ExprId(50);
+
+ SlotReference k1Left = mockSlot(leftId, "k1");
+ SlotReference k1Right = mockSlot(rightId, "k1");
+ PhysicalOlapScan scan1 = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k1Left));
+ PhysicalOlapScan scan2 = mockScan(1L, 2L, 2L, 1L,
ImmutableList.of(k1Right));
+
+ SlotReference setOutputSlot = mockSlot(setOutputId, "k1");
+
+ PhysicalSetOperation union = Mockito.mock(PhysicalSetOperation.class);
+ Mockito.when(union.children()).thenReturn(ImmutableList.of(scan1,
scan2));
+ Mockito.when(union.getRegularChildrenOutputs())
+ .thenReturn(ImmutableList.of(ImmutableList.of(k1Left),
ImmutableList.of(k1Right)));
+
Mockito.when(union.getOutput()).thenReturn(ImmutableList.of(setOutputSlot));
+
+ // A filter kept above the set operation.
+ Expression filterConjunct = Mockito.mock(Expression.class);
+
Mockito.when(filterConjunct.getInputSlots()).thenReturn(ImmutableSet.of(setOutputSlot));
+
+ PhysicalFilter<?> filter = Mockito.mock(PhysicalFilter.class);
+ Mockito.when(filter.children()).thenReturn(ImmutableList.of(union));
+
Mockito.when(filter.getConjuncts()).thenReturn(ImmutableSet.of(filterConjunct));
+ Mockito.when(filter.getOutput()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) filter);
+
+ Assertions.assertEquals(2, deltas.size());
+ for (StatsDelta delta : deltas.values()) {
+ Assertions.assertNotNull(delta.getColumnStats().get("k1"));
+ Assertions.assertTrue(delta.getColumnStats().get("k1").filterHit,
+ "k1.filterHit must be true via a filter kept above the set
operation");
+ }
+ }
+
+ /**
+ * SELECT * FROM (SELECT k1 FROM t1 EXCEPT SELECT k1 FROM t2) v WHERE k1 >
0: only t1's
+ * values ever reach the EXCEPT output (t2 is scanned only to test
exclusion), so a filter
+ * kept above the EXCEPT must resolve to t1's column only, not t2's.
+ */
+ @Test
+ public void testFilterAboveExceptOnlyResolvesToFirstBranch() {
+ ExprId leftId = new ExprId(1);
+ ExprId rightId = new ExprId(3);
+ ExprId setOutputId = new ExprId(50);
+
+ SlotReference k1Left = mockSlot(leftId, "k1");
+ SlotReference k1Right = mockSlot(rightId, "k1");
+ PhysicalOlapScan scan1 = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k1Left));
+ PhysicalOlapScan scan2 = mockScan(1L, 2L, 2L, 1L,
ImmutableList.of(k1Right));
+
+ SlotReference setOutputSlot = mockSlot(setOutputId, "k1");
+
+ PhysicalExcept except = Mockito.mock(PhysicalExcept.class);
+ Mockito.when(except.children()).thenReturn(ImmutableList.of(scan1,
scan2));
+ Mockito.when(except.getRegularChildrenOutputs())
+ .thenReturn(ImmutableList.of(ImmutableList.of(k1Left),
ImmutableList.of(k1Right)));
+
Mockito.when(except.getOutput()).thenReturn(ImmutableList.of(setOutputSlot));
+
+ Expression filterConjunct = Mockito.mock(Expression.class);
+
Mockito.when(filterConjunct.getInputSlots()).thenReturn(ImmutableSet.of(setOutputSlot));
+
+ PhysicalFilter<?> filter = Mockito.mock(PhysicalFilter.class);
+ Mockito.when(filter.children()).thenReturn(ImmutableList.of(except));
+
Mockito.when(filter.getConjuncts()).thenReturn(ImmutableSet.of(filterConjunct));
+ Mockito.when(filter.getOutput()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) filter);
+
+ // Both branches still get queryHit (they're both genuinely
scanned/compared), but only
+ // the first branch's column gets filterHit from the filter kept above
the EXCEPT.
+ Assertions.assertEquals(2, deltas.size());
+ StatsDelta leftDelta = deltas.get("1_1_1_1");
+ StatsDelta rightDelta = deltas.get("1_2_2_1");
+ Assertions.assertTrue(leftDelta.getColumnStats().get("k1").queryHit);
+ Assertions.assertTrue(leftDelta.getColumnStats().get("k1").filterHit,
+ "k1.filterHit must be true on the first (kept) EXCEPT branch");
+ Assertions.assertTrue(rightDelta.getColumnStats().get("k1").queryHit);
+ Assertions.assertFalse(rightDelta.getColumnStats().get("k1").filterHit,
+ "k1.filterHit must NOT be attributed to the second
(subtracted-away) EXCEPT branch");
+ }
+
+ /**
+ * SELECT * FROM (SELECT k1 FROM t1 INTERSECT SELECT k1 FROM t2) v WHERE
k1 > 0: like EXCEPT,
+ * INTERSECT's output block is materialized only from the build-side
(first) branch at
+ * execution time (SetSourceOperatorX::_add_result_columns reads only
build_block; probe
+ * branches only flip a visited bit) — so a filter kept above the
INTERSECT must resolve to
+ * the first branch's column only, not the second.
+ */
+ @Test
+ public void testFilterAboveIntersectOnlyResolvesToFirstBranch() {
+ ExprId leftId = new ExprId(1);
+ ExprId rightId = new ExprId(3);
+ ExprId setOutputId = new ExprId(50);
+
+ SlotReference k1Left = mockSlot(leftId, "k1");
+ SlotReference k1Right = mockSlot(rightId, "k1");
+ PhysicalOlapScan scan1 = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k1Left));
+ PhysicalOlapScan scan2 = mockScan(1L, 2L, 2L, 1L,
ImmutableList.of(k1Right));
+
+ SlotReference setOutputSlot = mockSlot(setOutputId, "k1");
+
+ PhysicalIntersect intersect = Mockito.mock(PhysicalIntersect.class);
+ Mockito.when(intersect.children()).thenReturn(ImmutableList.of(scan1,
scan2));
+ Mockito.when(intersect.getRegularChildrenOutputs())
+ .thenReturn(ImmutableList.of(ImmutableList.of(k1Left),
ImmutableList.of(k1Right)));
+
Mockito.when(intersect.getOutput()).thenReturn(ImmutableList.of(setOutputSlot));
+
+ Expression filterConjunct = Mockito.mock(Expression.class);
+
Mockito.when(filterConjunct.getInputSlots()).thenReturn(ImmutableSet.of(setOutputSlot));
+
+ PhysicalFilter<?> filter = Mockito.mock(PhysicalFilter.class);
+
Mockito.when(filter.children()).thenReturn(ImmutableList.of(intersect));
+
Mockito.when(filter.getConjuncts()).thenReturn(ImmutableSet.of(filterConjunct));
+ Mockito.when(filter.getOutput()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) filter);
+
+ // Both branches still get queryHit (they're both genuinely
scanned/compared), but only
+ // the first (build-side) branch's column gets filterHit from the
filter kept above.
+ Assertions.assertEquals(2, deltas.size());
+ StatsDelta leftDelta = deltas.get("1_1_1_1");
+ StatsDelta rightDelta = deltas.get("1_2_2_1");
+ Assertions.assertTrue(leftDelta.getColumnStats().get("k1").queryHit);
+ Assertions.assertTrue(leftDelta.getColumnStats().get("k1").filterHit,
+ "k1.filterHit must be true on the first (build-side) INTERSECT
branch");
+ Assertions.assertTrue(rightDelta.getColumnStats().get("k1").queryHit);
+ Assertions.assertFalse(rightDelta.getColumnStats().get("k1").filterHit,
+ "k1.filterHit must NOT be attributed to the second
(probe-side) INTERSECT branch");
+ }
+
+ // ── helpers
──────────────────────────────────────────────────────────────
+
+ /**
+ * Plan: CTEConsumer (consumerSlot#3 → producerSlot#1=k1) → Scan[k1(#1)]
+ * Expected: k1.queryHit=true via consumer slot.
+ */
+ @Test
+ public void testCteConsumerMapsToProducerScan() {
+ ExprId prodId = new ExprId(1);
+ ExprId consId = new ExprId(3);
+ SlotReference prodSlot = mockSlot(prodId, "k1");
+ SlotReference consSlot = mockSlot(consId, "k1");
+
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(prodSlot));
+
+ PhysicalCTEConsumer consumer = Mockito.mock(PhysicalCTEConsumer.class);
+
Mockito.when(consumer.getOutput()).thenReturn(ImmutableList.of(consSlot));
+ Mockito.when(consumer.getProducerSlot(consSlot)).thenReturn(prodSlot);
+ Mockito.when(consumer.children()).thenReturn(ImmutableList.of());
+
+ // Root plan: scan (producer) visited first, consumer slots then get
registered.
+ PhysicalFilter<?> root = Mockito.mock(PhysicalFilter.class);
+ Mockito.when(root.children()).thenReturn(ImmutableList.of(scan,
consumer));
+ Mockito.when(root.getConjuncts()).thenReturn(ImmutableSet.of());
+ Mockito.when(root.getOutput()).thenReturn(ImmutableList.of(consSlot));
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) root);
+
+ Assertions.assertEquals(1, deltas.size());
+ StatsDelta delta = deltas.values().iterator().next();
+ Assertions.assertNotNull(delta.getColumnStats().get("k1"));
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit);
+ }
+
+ /**
+ * Plan: UNION of Scan1[k1(#1)] and Scan2[k1(#3)]
+ * Expected: k1.queryHit=true on both scan tables.
+ */
+ @Test
+ public void testUnionRecordsQueryHitOnAllBranches() {
+ ExprId id1 = new ExprId(1);
+ ExprId id3 = new ExprId(3);
+ SlotReference k1Left = mockSlot(id1, "k1");
+ SlotReference k1Right = mockSlot(id3, "k1");
+
+ PhysicalOlapScan scan1 = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k1Left));
+ PhysicalOlapScan scan2 = mockScan(1L, 2L, 2L, 1L,
ImmutableList.of(k1Right));
+
+ PhysicalSetOperation union = Mockito.mock(PhysicalSetOperation.class);
+ Mockito.when(union.children()).thenReturn(ImmutableList.of(scan1,
scan2));
+ Mockito.when(union.getRegularChildrenOutputs())
+ .thenReturn(ImmutableList.of(ImmutableList.of(k1Left),
ImmutableList.of(k1Right)));
+ Mockito.when(union.getOutput()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) union);
+
+ Assertions.assertEquals(2, deltas.size());
+ for (StatsDelta delta : deltas.values()) {
+ Assertions.assertNotNull(delta.getColumnStats().get("k1"));
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit);
+ }
+ }
+
+ /**
+ * Plan: Filter(SUM(k2)#5 > 0) → Agg[SUM(k2)] → Scan[k2(#2)]
+ * Expected: k2.filterHit=true from HAVING SUM(k2) > 0.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testHavingAggregateFilterHitRecorded() {
+ ExprId k2Id = new ExprId(2);
+ ExprId sumId = new ExprId(5);
+ SlotReference k2Slot = mockSlot(k2Id, "k2");
+ // Aggregate output slots have no originalColumn in real Doris; use
Optional.empty()
+ // so recordInputSlotsAsFilterHit falls back to
exprIdToColName.get(sumId) = "k2".
+ SlotReference sumSlot = Mockito.mock(SlotReference.class);
+ Mockito.when(sumSlot.getExprId()).thenReturn(sumId);
+ Mockito.when(sumSlot.getOriginalColumn()).thenReturn(Optional.empty());
+
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k2Slot));
+
+ NamedExpression sumExpr = Mockito.mock(NamedExpression.class);
+ Mockito.when(sumExpr.getExprId()).thenReturn(sumId);
+
Mockito.when(sumExpr.getInputSlots()).thenReturn(ImmutableSet.of(k2Slot));
+
+ Aggregate<?> agg = Mockito.mock(Aggregate.class);
+ Mockito.when(agg.children()).thenReturn(ImmutableList.of(scan));
+
Mockito.when(agg.getGroupByExpressions()).thenReturn(ImmutableList.of());
+ Mockito.when(agg.getOutputExpressions())
+ .thenReturn((List<NamedExpression>) (List<?>)
ImmutableList.of(sumExpr));
+ Mockito.when(agg.getOutput()).thenReturn(ImmutableList.of(sumSlot));
+
+ Expression havingConjunct = Mockito.mock(Expression.class);
+
Mockito.when(havingConjunct.getInputSlots()).thenReturn(ImmutableSet.of(sumSlot));
+
+ PhysicalFilter<?> havingFilter = Mockito.mock(PhysicalFilter.class);
+
Mockito.when(havingFilter.children()).thenReturn(ImmutableList.of(agg));
+
Mockito.when(havingFilter.getConjuncts()).thenReturn(ImmutableSet.of(havingConjunct));
+ Mockito.when(havingFilter.getOutput()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas =
+ QueryStatsRecorder.collectDeltas((PhysicalPlan) havingFilter);
+
+ Assertions.assertEquals(1, deltas.size());
+ StatsDelta delta = deltas.values().iterator().next();
+ Assertions.assertNotNull(delta.getColumnStats().get("k2"));
+ Assertions.assertTrue(delta.getColumnStats().get("k2").filterHit);
+ }
+
+ /**
+ * Plan: Generate(EXPLODE(tags#2)) → Scan[tags(#2)]
+ * Expected: tags.queryHit=true from the generator input column.
+ */
+ @Test
+ public void testLateralViewExplodeRecordsGeneratorInputAsQueryHit() {
+ ExprId tagsId = new ExprId(2);
+ SlotReference tagsSlot = mockSlot(tagsId, "tags");
+
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(tagsSlot));
+
+ Function explodeFn = Mockito.mock(Function.class);
+
Mockito.when(explodeFn.getInputSlots()).thenReturn(ImmutableSet.of(tagsSlot));
+
+ PhysicalGenerate<?> generate = Mockito.mock(PhysicalGenerate.class);
+ Mockito.when(generate.children()).thenReturn(ImmutableList.of(scan));
+
Mockito.when(generate.getGenerators()).thenReturn(ImmutableList.of(explodeFn));
+ Mockito.when(generate.getOutput()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas =
+ QueryStatsRecorder.collectDeltas((PhysicalPlan) generate);
+
+ Assertions.assertEquals(1, deltas.size());
+ StatsDelta delta = deltas.values().iterator().next();
+ Assertions.assertNotNull(delta.getColumnStats().get("tags"));
+ Assertions.assertTrue(delta.getColumnStats().get("tags").queryHit);
+ Assertions.assertFalse(delta.getColumnStats().get("tags").filterHit);
+ }
+
+ /**
+ * SELECT e1 FROM t LATERAL VIEW EXPLODE(arr) tmp AS e1 WHERE e1 > 100:
the exploded output
+ * slot e1 must resolve back to the real scan column (arr) for a filter
above the generate.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testGeneratorOutputSlotResolvesFilterToScanColumn() {
+ ExprId arrId = new ExprId(2);
+ ExprId e1Id = new ExprId(3);
+ SlotReference arrSlot = mockSlot(arrId, "arr");
+ SlotReference e1Slot = mockSlot(e1Id, "e1");
+
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(arrSlot));
+
+ Function explodeFn = Mockito.mock(Function.class);
+
Mockito.when(explodeFn.getInputSlots()).thenReturn(ImmutableSet.of(arrSlot));
+
+ PhysicalGenerate<?> generate = Mockito.mock(PhysicalGenerate.class);
+ Mockito.when(generate.children()).thenReturn(ImmutableList.of(scan));
+
Mockito.when(generate.getGenerators()).thenReturn(ImmutableList.of(explodeFn));
+
Mockito.when(generate.getGeneratorOutput()).thenReturn(ImmutableList.of(e1Slot));
+ Mockito.when(generate.getConjuncts()).thenReturn(ImmutableList.of());
+
Mockito.when(generate.getOutput()).thenReturn(ImmutableList.of(e1Slot));
+
+ Expression filterConjunct = Mockito.mock(Expression.class);
+
Mockito.when(filterConjunct.getInputSlots()).thenReturn(ImmutableSet.of(e1Slot));
+
+ PhysicalFilter<?> filter = Mockito.mock(PhysicalFilter.class);
+ Mockito.when(filter.children()).thenReturn(ImmutableList.of(generate));
+
Mockito.when(filter.getConjuncts()).thenReturn(ImmutableSet.of(filterConjunct));
+ Mockito.when(filter.getOutput()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) filter);
+
+ StatsDelta delta = deltas.get("1_1_1_1");
+ Assertions.assertNotNull(delta);
+ Assertions.assertTrue(delta.getColumnStats().get("arr").filterHit,
+ "arr: filter on the exploded output slot resolves back to the
real scan column");
+ }
+
+ /**
+ * Plan: PhysicalCTEProducer(Scan[k1(#1)]) + CTEConsumer(consSlot#3 →
prodSlot#1)
+ * PhysicalCTEProducer must walk its child so the scan is registered
before the
+ * consumer is processed by the sibling walk.
+ * Expected: k1.queryHit=true via consumer slot resolved through producer
scan.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testCTEProducerRegistersScansForConsumer() {
+ ExprId prodId = new ExprId(1);
+ ExprId consId = new ExprId(3);
+ SlotReference prodSlot = mockSlot(prodId, "k1");
+ SlotReference consSlot = mockSlot(consId, "k1");
+
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(prodSlot));
+
+ PhysicalCTEProducer<?> producer =
Mockito.mock(PhysicalCTEProducer.class);
+ Mockito.when(producer.children()).thenReturn(ImmutableList.of(scan));
+
+ PhysicalCTEConsumer consumer = Mockito.mock(PhysicalCTEConsumer.class);
+
Mockito.when(consumer.getOutput()).thenReturn(ImmutableList.of(consSlot));
+ Mockito.when(consumer.getProducerSlot(consSlot)).thenReturn(prodSlot);
+ Mockito.when(consumer.children()).thenReturn(ImmutableList.of());
+
+ PhysicalPlan root = Mockito.mock(PhysicalPlan.class);
+ Mockito.when(root.children()).thenReturn(ImmutableList.of(producer,
consumer));
+ Mockito.when(root.getOutput()).thenReturn(ImmutableList.of(consSlot));
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas(root);
+
+ Assertions.assertEquals(1, deltas.size());
+ StatsDelta delta = deltas.values().iterator().next();
+ Assertions.assertNotNull(delta.getColumnStats().get("k1"),
+ "k1 must be registered via PhysicalCTEProducer→scan path");
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit,
+ "k1.queryHit must be true via CTE producer→scan→consumer
chain");
+ }
+
+ /**
+ * Plan: Filter(SUM(k2+k3)#5 > 0) → Agg[SUM(k2+k3)] → Scan[k2(#2), k3(#3)]
+ * Multi-input aggregate output is stored in derivedSlotInputs so that a
HAVING
+ * filter on SUM(k2+k3) records filterHit on both k2 and k3.
+ * Expected: k2.filterHit=true AND k3.filterHit=true.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testHavingMultiInputAggregateFilterHitRecorded() {
+ ExprId k2Id = new ExprId(2);
+ ExprId k3Id = new ExprId(3);
+ ExprId sumId = new ExprId(5);
+ SlotReference k2Slot = mockSlot(k2Id, "k2");
+ SlotReference k3Slot = mockSlot(k3Id, "k3");
+ SlotReference sumSlot = mockSlot(sumId, "sum_k2_k3");
+
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k2Slot, k3Slot));
+
+ // SUM(k2+k3): two input slots → must go into derivedSlotInputs
+ NamedExpression sumExpr = Mockito.mock(NamedExpression.class);
+ Mockito.when(sumExpr.getExprId()).thenReturn(sumId);
+
Mockito.when(sumExpr.getInputSlots()).thenReturn(ImmutableSet.of(k2Slot,
k3Slot));
+
+ Aggregate<?> agg = Mockito.mock(Aggregate.class);
+ Mockito.when(agg.children()).thenReturn(ImmutableList.of(scan));
+
Mockito.when(agg.getGroupByExpressions()).thenReturn(ImmutableList.of());
+ Mockito.when(agg.getOutputExpressions())
+ .thenReturn((List<NamedExpression>) (List<?>)
ImmutableList.of(sumExpr));
+ Mockito.when(agg.getOutput()).thenReturn(ImmutableList.of(sumSlot));
+
+ // HAVING SUM(k2+k3) > 0: conjunct references the aggregate output slot
+ Expression havingConjunct = Mockito.mock(Expression.class);
+
Mockito.when(havingConjunct.getInputSlots()).thenReturn(ImmutableSet.of(sumSlot));
+
+ PhysicalFilter<?> havingFilter = Mockito.mock(PhysicalFilter.class);
+
Mockito.when(havingFilter.children()).thenReturn(ImmutableList.of(agg));
+
Mockito.when(havingFilter.getConjuncts()).thenReturn(ImmutableSet.of(havingConjunct));
+ Mockito.when(havingFilter.getOutput()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas =
+ QueryStatsRecorder.collectDeltas((PhysicalPlan) havingFilter);
+
+ Assertions.assertEquals(1, deltas.size());
+ StatsDelta delta = deltas.values().iterator().next();
+ Assertions.assertNotNull(delta.getColumnStats().get("k2"),
+ "k2 must be recorded via multi-input HAVING expansion");
+ Assertions.assertTrue(delta.getColumnStats().get("k2").filterHit,
+ "k2.filterHit must be true from HAVING SUM(k2+k3)");
+ Assertions.assertNotNull(delta.getColumnStats().get("k3"),
+ "k3 must be recorded via multi-input HAVING expansion");
+ Assertions.assertTrue(delta.getColumnStats().get("k3").filterHit,
+ "k3.filterHit must be true from HAVING SUM(k2+k3)");
+ }
+
+ /**
+ * Mark join conjuncts (from IN/EXISTS subquery rewriting) are stored
separately
+ * in AbstractPhysicalJoin and must be processed independently of
hash/other conjuncts.
+ * Plan: HashJoin with k1=k2 in hashJoinConjuncts and k3>0 in
markJoinConjuncts.
+ * Expected: k1.filterHit, k2.filterHit (hash), k3.filterHit (mark).
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testMarkJoinConjunctsRecordFilterHit() {
+ ExprId id1 = new ExprId(1);
+ ExprId id2 = new ExprId(2);
+ ExprId id3 = new ExprId(3);
+ SlotReference k1Slot = mockSlot(id1, "k1");
+ SlotReference k2Slot = mockSlot(id2, "k2");
+ SlotReference k3Slot = mockSlot(id3, "k3");
+
+ PhysicalOlapScan left = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k1Slot, k3Slot));
+ 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));
+
+ // Mark conjunct: k3 > 0 — only reachable via getMarkJoinConjuncts()
+ Expression markConjunct = Mockito.mock(Expression.class);
+
Mockito.when(markConjunct.getInputSlots()).thenReturn(ImmutableSet.of(k3Slot));
+
+ 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.getMarkJoinConjuncts()).thenReturn(ImmutableList.of(markConjunct));
+ Mockito.when(join.getOutput()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) join);
+
+ Assertions.assertTrue(deltas.containsKey("1_1_1_1"), "left scan delta
missing");
+ Assertions.assertTrue(deltas.containsKey("2_2_2_2"), "right scan delta
missing");
+
Assertions.assertTrue(deltas.get("1_1_1_1").getColumnStats().get("k1").filterHit,
+ "k1.filterHit: hash join conjunct");
+
Assertions.assertTrue(deltas.get("2_2_2_2").getColumnStats().get("k2").filterHit,
+ "k2.filterHit: hash join conjunct");
+
Assertions.assertTrue(deltas.get("1_1_1_1").getColumnStats().get("k3").filterHit,
+ "k3.filterHit: mark join conjunct — previously missed");
+ }
+
+ /**
+ * Other join conjuncts (non-equi predicates, e.g. a range condition in a
nested-loop join)
+ * are stored separately from hash/mark conjuncts and must be processed
too.
+ * Plan: NestedLoopJoin with k1=k2 in hashJoinConjuncts and k3<k4 in
otherJoinConjuncts.
+ * Expected: k1/k2.filterHit (hash), k3/k4.filterHit (other) — every
existing join test in
+ * this suite stubs getOtherJoinConjuncts() empty, so this path was
previously untested.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testOtherJoinConjunctsRecordFilterHit() {
+ ExprId id1 = new ExprId(1);
+ ExprId id2 = new ExprId(2);
+ ExprId id3 = new ExprId(3);
+ ExprId id4 = new ExprId(4);
+ SlotReference k1Slot = mockSlot(id1, "k1");
+ SlotReference k2Slot = mockSlot(id2, "k2");
+ SlotReference k3Slot = mockSlot(id3, "k3");
+ SlotReference k4Slot = mockSlot(id4, "k4");
+
+ PhysicalOlapScan left = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k1Slot, k3Slot));
+ PhysicalOlapScan right = mockScan(2L, 2L, 2L, 2L,
ImmutableList.of(k2Slot, k4Slot));
+
+ Expression hashConjunct = Mockito.mock(Expression.class);
+
Mockito.when(hashConjunct.getInputSlots()).thenReturn(ImmutableSet.of(k1Slot,
k2Slot));
+
+ // Other (non-equi) conjunct: k3 < k4 — only reachable via
getOtherJoinConjuncts()
+ Expression otherConjunct = Mockito.mock(Expression.class);
+
Mockito.when(otherConjunct.getInputSlots()).thenReturn(ImmutableSet.of(k3Slot,
k4Slot));
+
+ 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(otherConjunct));
+
Mockito.when(join.getMarkJoinConjuncts()).thenReturn(ImmutableList.of());
+ Mockito.when(join.getOutput()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) join);
+
+ Assertions.assertTrue(deltas.containsKey("1_1_1_1"), "left scan delta
missing");
+ Assertions.assertTrue(deltas.containsKey("2_2_2_2"), "right scan delta
missing");
+
Assertions.assertTrue(deltas.get("1_1_1_1").getColumnStats().get("k1").filterHit,
+ "k1.filterHit: hash join conjunct");
+
Assertions.assertTrue(deltas.get("2_2_2_2").getColumnStats().get("k2").filterHit,
+ "k2.filterHit: hash join conjunct");
+
Assertions.assertTrue(deltas.get("1_1_1_1").getColumnStats().get("k3").filterHit,
+ "k3.filterHit: other join conjunct — previously untested");
+
Assertions.assertTrue(deltas.get("2_2_2_2").getColumnStats().get("k4").filterHit,
+ "k4.filterHit: other join conjunct — previously untested");
+ }
+
+ /**
+ * PhysicalRecursiveUnion extends PhysicalBinary: left() is the base case,
right() is the
+ * recursive case. The base-case slots (from an OlapScan) must get
queryHit.
+ * Expected: k1.queryHit=true (base case only, exactly one scan delta).
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testRecursiveUnionBaseColumnGetsQueryHit() {
+ ExprId baseScanId = new ExprId(1);
+ ExprId recursiveId = new ExprId(5); // WorkTableReference slot, no
CTEId link established
+ SlotReference baseScanSlot = mockSlot(baseScanId, "k1");
+ SlotReference recursiveSlot = mockSlot(recursiveId, "k1");
+
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(baseScanSlot));
+
+ // Mock the recursive child (not a PhysicalWorkTableReference, no
CTEId link is set up).
+ Plan recursiveChild = Mockito.mock(Plan.class);
+ Mockito.when(recursiveChild.children()).thenReturn(ImmutableList.of());
+
Mockito.when(recursiveChild.getOutput()).thenReturn(ImmutableList.of(recursiveSlot));
+
+ PhysicalRecursiveUnion<Plan, Plan> recUnion =
Mockito.mock(PhysicalRecursiveUnion.class);
+ Mockito.when(recUnion.left()).thenReturn((Plan) scan);
+ Mockito.when(recUnion.right()).thenReturn(recursiveChild);
+ Mockito.when(recUnion.getRegularChildrenOutputs()).thenReturn(
+ ImmutableList.of(
+ ImmutableList.of(baseScanSlot), // base case:
resolves to scan
+ ImmutableList.of(recursiveSlot) // recursive case:
no work-table CTEId link
+ ));
+ Mockito.when(recUnion.getOutput()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) recUnion);
+
+ Assertions.assertEquals(1, deltas.size());
+ StatsDelta delta = deltas.get("1_1_1_1");
+ Assertions.assertNotNull(delta, "base-case scan delta must exist");
+ Assertions.assertNotNull(delta.getColumnStats().get("k1"),
+ "k1 must be recorded from base case via
getRegularChildrenOutputs");
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit,
+ "k1.queryHit must be true for recursive union base case");
+ // Exactly one delta entry — recursive-case slot (#5) must not create
a second scan entry.
+ Assertions.assertEquals(1, deltas.size(), "only base-case scan must
appear in deltas");
+ }
+
+ /**
+ * SELECT * FROM t1 WHERE k1 = 1 UNION ALL SELECT * FROM cte WHERE k1 = 2
(recursive branch,
+ * filtering on the work-table's own re-scanned column). The base case is
wrapped in a
+ * PhysicalRecursiveUnionAnchor exposing a CTEId; the recursive case
contains a filter over a
+ * PhysicalWorkTableReference with that same CTEId. Before this fix, the
work-table slot's
+ * ExprId had no entry in derivedSlotInputs by the time the recursive
branch's own filter was
+ * walked, so k1.filterHit could never be recorded for the recursive-side
predicate.
+ * Expected: k1.filterHit=true (recursive-side WHERE resolves back to the
real scan column).
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testFilterOnWorkTableSlotInRecursiveBranchRecordsFilterHit() {
+ CTEId cteId = new CTEId(1);
+ ExprId baseScanId = new ExprId(1);
+ ExprId workTableId = new ExprId(5);
+ SlotReference baseScanSlot = mockSlot(baseScanId, "k1");
+ SlotReference workTableSlot = mockSlot(workTableId, "k1");
+
+ PhysicalOlapScan scan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(baseScanSlot));
+
+ PhysicalRecursiveUnionAnchor<?> anchor =
Mockito.mock(PhysicalRecursiveUnionAnchor.class);
+ Mockito.when(anchor.getCteId()).thenReturn(cteId);
+ Mockito.when(anchor.children()).thenReturn(ImmutableList.of(scan));
+
+ PhysicalWorkTableReference workTable =
Mockito.mock(PhysicalWorkTableReference.class,
+ Mockito.CALLS_REAL_METHODS);
+ Mockito.when(workTable.getCteId()).thenReturn(cteId);
+
Mockito.when(workTable.getOutput()).thenReturn(ImmutableList.of(workTableSlot));
+ Mockito.when(workTable.children()).thenReturn(ImmutableList.of());
+
+ // WHERE k1 = 2 inside the recursive branch, referencing the
work-table's own slot.
+ Expression recursiveFilterExpr = Mockito.mock(Expression.class);
+
Mockito.when(recursiveFilterExpr.getInputSlots()).thenReturn(ImmutableSet.of(workTableSlot));
+
+ // CALLS_REAL_METHODS so collectToList()/foreach() (default TreeNode
methods used by the
+ // production code to locate the work-table reference) actually walk
into the stubbed
+ // children() below, instead of Mockito's default answer
short-circuiting to an empty result.
+ PhysicalFilter<?> recursiveFilter = Mockito.mock(PhysicalFilter.class,
Mockito.CALLS_REAL_METHODS);
+
Mockito.when(recursiveFilter.children()).thenReturn(ImmutableList.of(workTable));
+
Mockito.when(recursiveFilter.getConjuncts()).thenReturn(ImmutableSet.of(recursiveFilterExpr));
+
+ PhysicalRecursiveUnion<Plan, Plan> recUnion =
Mockito.mock(PhysicalRecursiveUnion.class);
+ Mockito.when(recUnion.left()).thenReturn((Plan) anchor);
+ Mockito.when(recUnion.right()).thenReturn(recursiveFilter);
+ Mockito.when(recUnion.getRegularChildrenOutputs()).thenReturn(
+ ImmutableList.of(
+ ImmutableList.of(baseScanSlot),
+ ImmutableList.of(workTableSlot)));
+ Mockito.when(recUnion.getOutput()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) recUnion);
+
+ StatsDelta delta = deltas.get("1_1_1_1");
+ Assertions.assertNotNull(delta, "base scan delta must exist");
+ Assertions.assertTrue(delta.getColumnStats().get("k1").filterHit,
+ "recursive-branch filter on work-table slot must resolve back
to the real scan column");
+ }
+
+ /**
+ * SELECT k1+k2 AS result FROM t: computed alias with two input slots.
+ * unwrapAlias returns null (not a plain SlotReference), so the
PhysicalProject handler
+ * stores {k1, k2} in the expansion map (derivedSlotInputs). The root
output loop in
+ * collectDeltas then expands via that map and records k1.queryHit and
k2.queryHit.
+ * Expected: k1.queryHit=true AND k2.queryHit=true.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testComputedProjectExpressionRecordsBothInputsAsQueryHit() {
+ 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));
+
+ // Simulates k1+k2 — a binary expression with two input slots
+ Expression addExpr = Mockito.mock(Expression.class);
+
Mockito.when(addExpr.getInputSlots()).thenReturn(ImmutableSet.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(addExpr);
+
+ 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<String, StatsDelta> 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 as queryHit from computed SELECT k1+k2");
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit,
+ "k1.queryHit must be true");
+ Assertions.assertNotNull(delta.getColumnStats().get("k2"),
+ "k2 must be recorded as queryHit from computed SELECT k1+k2");
+ Assertions.assertTrue(delta.getColumnStats().get("k2").queryHit,
+ "k2.queryHit must be true");
+ }
+
+ /**
+ * PushDownExpressionsInHashCondition inserts a PhysicalProject below the
join with
+ * Alias(k1+k2) as the hash-join key. Walking this intermediate project
must NOT record
+ * k1/k2 as queryHit; instead, the join hash conjunct (which references
the alias slot)
+ * must expand via the map and record k1.filterHit and k2.filterHit.
+ * Plan: HashJoin(aliasSlot = k3) → Project([Alias(k1+k2)]) → Scan[k1,k2];
Scan[k3]
+ * Expected: k1.filterHit=true, k2.filterHit=true, k1.queryHit=false,
k2.queryHit=false.
+ */
+ @Test
+ @SuppressWarnings("unchecked")
+ public void
testIntermediateProjectComputedJoinKeyRecordsFilterHitNotQueryHit() {
+ ExprId id1 = new ExprId(1);
+ ExprId id2 = new ExprId(2);
+ ExprId id3 = new ExprId(3);
+ ExprId aliasId = new ExprId(99);
+ SlotReference k1Slot = mockSlot(id1, "k1");
+ SlotReference k2Slot = mockSlot(id2, "k2");
+ SlotReference k3Slot = mockSlot(id3, "k3");
+ PhysicalOlapScan leftScan = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k1Slot, k2Slot));
+ PhysicalOlapScan rightScan = mockScan(2L, 2L, 2L, 2L,
ImmutableList.of(k3Slot));
+
+ // Simulates k1+k2 — a binary expression with two input slots
+ Expression addExpr = Mockito.mock(Expression.class);
+
Mockito.when(addExpr.getInputSlots()).thenReturn(ImmutableSet.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(addExpr);
+
+ // The slot the join conjunct references (the alias output, not k1/k2
directly)
+ SlotReference aliasSlot = mockSlot(aliasId, "k1_plus_k2");
+
+ 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(leftScan));
+
Mockito.when(project.getProjects()).thenReturn(ImmutableList.of(alias));
+
Mockito.when(project.getOutput()).thenReturn(ImmutableList.of(aliasSlot));
+
+ // Hash conjunct: (k1+k2) = k3 — references aliasSlot, not k1/k2
directly
+ Expression hashConjunct = Mockito.mock(Expression.class);
+
Mockito.when(hashConjunct.getInputSlots()).thenReturn(ImmutableSet.of(aliasSlot,
k3Slot));
+
+ 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,
rightScan));
+
Mockito.when(join.getHashJoinConjuncts()).thenReturn(ImmutableList.of(hashConjunct));
+
Mockito.when(join.getOtherJoinConjuncts()).thenReturn(ImmutableList.of());
+ Mockito.when(join.getOutput()).thenReturn(ImmutableList.of());
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) join);
+
+ Assertions.assertTrue(deltas.containsKey("1_1_1_1"), "left scan delta
must exist");
+ StatsDelta leftDelta = deltas.get("1_1_1_1");
+ Assertions.assertNotNull(leftDelta.getColumnStats().get("k1"),
+ "k1 must be recorded via join-key alias expansion");
+ Assertions.assertTrue(leftDelta.getColumnStats().get("k1").filterHit,
+ "k1.filterHit must be true: used only in join predicate");
+ Assertions.assertFalse(leftDelta.getColumnStats().get("k1").queryHit,
+ "k1.queryHit must be false: intermediate project must not
record queryHit");
+ Assertions.assertNotNull(leftDelta.getColumnStats().get("k2"),
+ "k2 must be recorded via join-key alias expansion");
+ Assertions.assertTrue(leftDelta.getColumnStats().get("k2").filterHit,
+ "k2.filterHit must be true: used only in join predicate");
+ Assertions.assertFalse(leftDelta.getColumnStats().get("k2").queryHit,
+ "k2.queryHit must be false: intermediate project must not
record queryHit");
+ }
+
+ /**
+ * SELECT u.k1 FROM (SELECT k1, k2 FROM t1 EXCEPT SELECT k1, k2 FROM t2) u
+ * The outer PhysicalProject only selects k1; k2 is never read by the
visible query, but it
+ * is still physically scanned and compared by the EXCEPT itself
(ColumnPruning intentionally
+ * retains it for row comparison — see the "Accepted as-is" note in the PR
description for #6).
+ * Expected: k2 still gets queryHit=true on BOTH branches, since both
branches' k2 columns are
+ * genuinely read to perform the row comparison, even though neither is in
the final SELECT list.
+ */
+ @Test
+ public void
adversarialExceptUnusedColumnStillGetsQueryHitForRowComparison() {
+ ExprId k1LeftId = new ExprId(1);
+ ExprId k2LeftId = new ExprId(2);
+ ExprId k1RightId = new ExprId(3);
+ ExprId k2RightId = new ExprId(4);
+
+ SlotReference k1Left = mockSlot(k1LeftId, "k1");
+ SlotReference k2Left = mockSlot(k2LeftId, "k2");
+ SlotReference k1Right = mockSlot(k1RightId, "k1");
+ SlotReference k2Right = mockSlot(k2RightId, "k2");
+
+ PhysicalOlapScan scan1 = mockScan(1L, 1L, 1L, 1L,
ImmutableList.of(k1Left, k2Left));
+ PhysicalOlapScan scan2 = mockScan(1L, 2L, 2L, 1L,
ImmutableList.of(k1Right, k2Right));
+
+ ExprId setK1Id = new ExprId(10);
+ ExprId setK2Id = new ExprId(11);
+ SlotReference setK1 = mockSlot(setK1Id, "k1");
+ SlotReference setK2 = mockSlot(setK2Id, "k2");
+
+ PhysicalExcept except = Mockito.mock(PhysicalExcept.class);
+ Mockito.when(except.children()).thenReturn(ImmutableList.of(scan1,
scan2));
+ Mockito.when(except.getRegularChildrenOutputs())
+ .thenReturn(ImmutableList.of(
+ ImmutableList.of(k1Left, k2Left),
+ ImmutableList.of(k1Right, k2Right)));
+ Mockito.when(except.getOutput()).thenReturn(ImmutableList.of(setK1,
setK2));
+
+ // Outer PhysicalProject: SELECT u.k1 -- only k1 is a pass-through, k2
is dropped.
+ PhysicalProject<?> outerProject = Mockito.mock(PhysicalProject.class);
+
Mockito.when(outerProject.children()).thenReturn(ImmutableList.of(except));
+
Mockito.when(outerProject.getProjects()).thenReturn(ImmutableList.of(setK1));
+
Mockito.when(outerProject.getOutput()).thenReturn(ImmutableList.of(setK1));
+
+ Map<String, StatsDelta> deltas =
QueryStatsRecorder.collectDeltas((PhysicalPlan) outerProject);
+
+ Assertions.assertEquals(2, deltas.size());
+ for (StatsDelta delta : deltas.values()) {
+ Assertions.assertNotNull(delta.getColumnStats().get("k1"));
+ Assertions.assertTrue(delta.getColumnStats().get("k1").queryHit,
+ "k1 is selected by the outer query and must get queryHit");
+ if (delta.getColumnStats().containsKey("k2")) {
+
Assertions.assertTrue(delta.getColumnStats().get("k2").queryHit,
+ "k2 is retained by ColumnPruning for EXCEPT row
comparison, so it is "
+ + "genuinely read and must get queryHit even
though it's not in "
+ + "the outer SELECT list — this is the
accepted #6 behavior");
+ }
+ }
+ }
private SlotReference mockSlot(ExprId exprId, String columnName) {
SlotReference slot = Mockito.mock(SlotReference.class);
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 9a445e88e27..0cadec47cc2 100644
--- a/regression-test/suites/query_p0/stats/query_stats_test.groovy
+++ b/regression-test/suites/query_p0/stats/query_stats_test.groovy
@@ -198,6 +198,78 @@ suite("query_stats_test") {
def joinResult = sql "show query stats from ${tbName} all"
assertTrue((joinResult[0][1] as int) >= 1)
+ // UNION ALL: both branches contribute queryHit for k1; WHERE on right
branch adds k2.filterHit.
+ sql "clean all query stats"
+ sql "select k1 from ${tbName} union all select k1 from ${tbName} where k2
= 100"
+ def unionStats = sql "show query stats from ${tbName}"
+ def uK1 = unionStats.find { it[0] == "k1" }
+ def uK2 = unionStats.find { it[0] == "k2" }
+ assertNotNull(uK1)
+ assertNotNull(uK2)
+ assertTrue((uK1[1] as int) >= 1, "k1: UNION ALL queryHit")
+ assertTrue((uK2[2] as int) >= 1, "k2: WHERE on right UNION branch
filterHit")
+
+ // HAVING: k1 queryHit from GROUP BY + SELECT, k2 queryHit from SUM input,
+ // k2 filterHit from HAVING SUM(k2) > -1000.
+ sql "clean all query stats"
+ sql "select k1, sum(k2) from ${tbName} group by k1 having sum(k2) > -1000"
+ def havingStats = sql "show query stats from ${tbName}"
+ def hvK1 = havingStats.find { it[0] == "k1" }
+ def hvK2 = havingStats.find { it[0] == "k2" }
+ assertNotNull(hvK1)
+ assertNotNull(hvK2)
+ assertTrue((hvK1[1] as int) >= 1, "k1: GROUP BY / SELECT queryHit")
+ assertTrue((hvK2[1] as int) >= 1, "k2: SUM aggregate input queryHit")
+ assertTrue((hvK2[2] as int) >= 1, "k2: HAVING SUM(k2) filterHit")
+
+ // CTE: consumer query records queryHit on k2 and filterHit on k1.
+ // Force materialization so the plan keeps PhysicalCTEProducer/Consumer
instead of
+ // CTEInline collapsing the single-reference CTE into a plain scan+filter.
+ sql "clean all query stats"
+ sql "set inline_cte_referenced_threshold = 0"
+ sql """with cte as (select k2 from ${tbName} where k1 = 1) select k2 from
cte"""
+ sql "set inline_cte_referenced_threshold = 1"
+ def cteStats = sql "show query stats from ${tbName}"
+ def cteK2 = cteStats.find { it[0] == "k2" }
+ def cteK1 = cteStats.find { it[0] == "k1" }
+ assertNotNull(cteK2)
+ assertNotNull(cteK1)
+ assertTrue((cteK2[1] as int) >= 1, "k2: CTE consumer queryHit")
+ assertTrue((cteK1[2] as int) >= 1, "k1: CTE WHERE filterHit")
+
+ // LATERAL VIEW / EXPLODE: the generator input column (k7, a varchar) gets
queryHit
+ // because it is the source expression fed into the generator; the
synthetic output
+ // slot (e1) is skipped by the PhysicalGenerate handler.
+ sql "clean all query stats"
+ sql "select e1 from ${tbName} lateral view explode_split(k7, ',') tmp as
e1"
+ def lateralStats = sql "show query stats from ${tbName}"
+ def lvK7 = lateralStats.find { it[0] == "k7" }
+ assertNotNull(lvK7, "k7 must appear after LATERAL VIEW EXPLODE_SPLIT")
+ assertTrue((lvK7[1] as int) >= 1, "k7: LATERAL VIEW input column queryHit")
+
+ // Table-function join ON predicate: PhysicalGenerate.getConjuncts() is
sent straight to
+ // TableFunctionNode and actually filters at execution — must record
filterHit too.
+ // The predicate must reference a real column (k7), not just the
generator's own
+ // synthetic output (val) — filtering on val alone has no scan column to
attribute to.
+ sql "clean all query stats"
+ sql """select s.val from ${tbName} left join lateral unnest(split(k7,
',')) s(val) on k7 != ''"""
+ def genStats = sql "show query stats from ${tbName}"
+ def genK7 = genStats.find { it[0] == "k7" }
+ assertNotNull(genK7)
+ assertTrue((genK7[2] as int) >= 1, "k7: table-function join ON predicate
filterHit")
+
+ // Computed SELECT: SELECT k1+k2 — both input columns get queryHit even
though
+ // the output expression is not a plain slot reference.
+ sql "clean all query stats"
+ sql "select k1 + k2 from ${tbName}"
+ def computedStats = sql "show query stats from ${tbName}"
+ def cmpK1 = computedStats.find { it[0] == "k1" }
+ def cmpK2 = computedStats.find { it[0] == "k2" }
+ assertNotNull(cmpK1, "k1 must appear in computed SELECT k1+k2")
+ assertNotNull(cmpK2, "k2 must appear in computed SELECT k1+k2")
+ assertTrue((cmpK1[1] as int) >= 1, "k1: computed SELECT queryHit")
+ assertTrue((cmpK2[1] as int) >= 1, "k2: computed SELECT queryHit")
+
sql "admin set all frontends config
(\"enable_query_hit_stats\"=\"false\");"
sql "set enable_nereids_planner = ${origNereids}"
sql "set enable_query_cache = ${origCache}"
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]