This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.1 by this push:
new dfee70e8a3f branch-4.1: [fix](preagg) Enable pre-aggregation for
multi-argument aggregate functions with all-key distinct inputs #65846 (#66676)
dfee70e8a3f is described below
commit dfee70e8a3fa818d8cd2ecff19bda3b0627026c0
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu Aug 13 18:39:44 2026 +0800
branch-4.1: [fix](preagg) Enable pre-aggregation for multi-argument
aggregate functions with all-key distinct inputs #65846 (#66676)
Cherry-picked from #65846
Co-authored-by: starocean999 <[email protected]>
---
.../nereids/rules/rewrite/SetPreAggStatus.java | 384 +++++++--
.../nereids/rules/rewrite/SetPreAggStatusTest.java | 355 +++++++++
.../nereids_rules_p0/set_preagg/set_preagg.out | 148 ++++
.../nereids_rules_p0/set_preagg/set_preagg.groovy | 887 ++++++++++++++++++++-
4 files changed, 1716 insertions(+), 58 deletions(-)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/SetPreAggStatus.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/SetPreAggStatus.java
index aef5f660056..f81b6159200 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/SetPreAggStatus.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/SetPreAggStatus.java
@@ -21,13 +21,16 @@ import org.apache.doris.catalog.AggregateType;
import org.apache.doris.catalog.KeysType;
import org.apache.doris.catalog.MaterializedIndexMeta;
import org.apache.doris.common.Pair;
+import org.apache.doris.nereids.exceptions.AnalysisException;
import org.apache.doris.nereids.jobs.JobContext;
import org.apache.doris.nereids.trees.expressions.CaseWhen;
import org.apache.doris.nereids.trees.expressions.Cast;
import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.NamedExpression;
import org.apache.doris.nereids.trees.expressions.Slot;
import org.apache.doris.nereids.trees.expressions.SlotReference;
import org.apache.doris.nereids.trees.expressions.WhenClause;
+import
org.apache.doris.nereids.trees.expressions.functions.NoneMovableFunction;
import
org.apache.doris.nereids.trees.expressions.functions.agg.AggregateFunction;
import org.apache.doris.nereids.trees.expressions.functions.agg.BitmapUnion;
import
org.apache.doris.nereids.trees.expressions.functions.agg.BitmapUnionCount;
@@ -91,11 +94,67 @@ public class SetPreAggStatus extends
DefaultPlanRewriter<Stack<SetPreAggStatus.P
private List<Expression> groupingScalarFunctionExpresssions = new
ArrayList<>();
private Set<AggregateFunction> aggregateFunctions = new HashSet<>();
private Set<RelationId> olapScanIds = new HashSet<>();
+ private boolean hasUnresolvedExpression = false;
+
+ // Scans on the selected side of an ASOF join. ASOF emits at most one
+ // matching row per probe row (the index may pick any equal-time row),
so
+ // the selected side must stay storage-merged (pre-agg OFF): with
pre-agg
+ // ON, duplicate full keys remain as partial rows and a stale partial
can
+ // be picked instead of the merged value, changing MAX/MIN.
+ private Set<RelationId> asofSelectedSideRelationIds = new HashSet<>();
+
+ // Retained non-movable project expressions (e.g. assert_true). They
are
+ // kept by pruneOutputs even when unused, and must run on
storage-merged
+ // rows: a pre-agg ON scan would evaluate them on raw partial rows. Any
+ // scan whose value columns they reference must stay OFF.
+ private List<Expression> retainedNonMovableExpressions = new
ArrayList<>();
private Map<Slot, Expression> replaceMap = new HashMap<>();
- private void setReplaceMap(Map<Slot, Expression> replaceMap) {
- this.replaceMap = replaceMap;
+ private void setReplaceMap(Map<Slot, Expression> newReplaceMap) {
+ // merge instead of replace: sibling projects under a join share
one
+ // PreAggInfoContext, and a full replacement would lose mappings
from
+ // the sibling. merge keeps all entries; new entries shadow old
ones
+ // so a chain of projects still resolves correctly.
+ //
+ // Before merging, resolve the new aliases' producers through the
+ // existing replaceMap so that upper-layer aliases reference base
table
+ // columns directly instead of intermediate computed aliases.
+ // Resolve only the new entries into a small temp map against the
+ // unchanged old map, then putAll them to keep accumulation linear.
+ if (newReplaceMap.isEmpty()) {
+ return;
+ }
+ Map<Slot, Expression> resolved = new HashMap<>(
+ com.google.common.collect.Maps.newHashMapWithExpectedSize(
+ newReplaceMap.size()));
+ for (Map.Entry<Slot, Expression> entry : newReplaceMap.entrySet())
{
+ Expression resolvedProducer;
+ try {
+ resolvedProducer = ExpressionUtils.replace(
+ entry.getValue(), this.replaceMap);
+ } catch (AnalysisException e) {
+ if (e.getErrorCode() ==
AnalysisException.ErrorCode.EXPRESSION_EXCEEDS_LIMIT) {
+ // Eager composition hit the depth/width limit (e.g.
deep
+ // xN = x(N-1) + x(N-1) chains expand width
exponentially).
+ // Keep the raw producer so the query still plans, but
mark
+ // the expression unresolved: the raw producer still
+ // references intermediate aliases (not base columns),
so a
+ // later replace is top-down short-circuit and never
expands
+ // them. Without the flag, an aggregate over such an
alias
+ // would have an empty local slot intersection and be
+ // whitelisted as an other-table MAX/MIN, wrongly
turning
+ // this scan ON (e.g. a SUM-origin chain whose value
differs
+ // between raw and merged duplicate full keys).
+ resolvedProducer = entry.getValue();
+ hasUnresolvedExpression = true;
+ } else {
+ throw e;
+ }
+ }
+ resolved.put(entry.getKey(), resolvedProducer);
+ }
+ this.replaceMap.putAll(resolved);
}
private void addRelationId(RelationId id) {
@@ -104,18 +163,45 @@ public class SetPreAggStatus extends
DefaultPlanRewriter<Stack<SetPreAggStatus.P
private void addJoinInfo(LogicalJoin logicalJoin) {
joinConjuncts.addAll(logicalJoin.getExpressions());
- joinConjuncts =
Lists.newArrayList(ExpressionUtils.replace(joinConjuncts, replaceMap));
+ try {
+ joinConjuncts = Lists.newArrayList(
+ ExpressionUtils.replace(joinConjuncts, replaceMap));
+ } catch (AnalysisException e) {
+ if (e.getErrorCode() ==
AnalysisException.ErrorCode.EXPRESSION_EXCEEDS_LIMIT) {
+ hasUnresolvedExpression = true;
+ } else {
+ throw e;
+ }
+ }
}
private void addFilterConjuncts(List<Expression> conjuncts) {
filterConjuncts.addAll(conjuncts);
- filterConjuncts =
Lists.newArrayList(ExpressionUtils.replace(filterConjuncts, replaceMap));
+ try {
+ filterConjuncts = Lists.newArrayList(
+ ExpressionUtils.replace(filterConjuncts, replaceMap));
+ } catch (AnalysisException e) {
+ if (e.getErrorCode() ==
AnalysisException.ErrorCode.EXPRESSION_EXCEEDS_LIMIT) {
+ hasUnresolvedExpression = true;
+ } else {
+ throw e;
+ }
+ }
}
private void addGroupByExpresssions(List<Expression> expressions) {
groupByExpresssions.addAll(expressions);
groupByExpresssions.removeAll(groupingScalarFunctionExpresssions);
- groupByExpresssions =
Lists.newArrayList(ExpressionUtils.replace(groupByExpresssions, replaceMap));
+ try {
+ groupByExpresssions = Lists.newArrayList(
+ ExpressionUtils.replace(groupByExpresssions,
replaceMap));
+ } catch (AnalysisException e) {
+ if (e.getErrorCode() ==
AnalysisException.ErrorCode.EXPRESSION_EXCEEDS_LIMIT) {
+ hasUnresolvedExpression = true;
+ } else {
+ throw e;
+ }
+ }
}
private void addGroupingScalarFunctionExpresssions(List<Expression>
expressions) {
@@ -126,12 +212,33 @@ public class SetPreAggStatus extends
DefaultPlanRewriter<Stack<SetPreAggStatus.P
groupingScalarFunctionExpresssions.add(expression);
}
+ private void addRetainedNonMovableExpression(Expression expr) {
+ try {
+
retainedNonMovableExpressions.add(ExpressionUtils.replace(expr, replaceMap));
+ } catch (AnalysisException e) {
+ if (e.getErrorCode() ==
AnalysisException.ErrorCode.EXPRESSION_EXCEEDS_LIMIT) {
+ hasUnresolvedExpression = true;
+ } else {
+ throw e;
+ }
+ }
+ }
+
private void addAggregateFunctions(Set<AggregateFunction> functions) {
aggregateFunctions.addAll(functions);
Set<AggregateFunction> newAggregateFunctions = Sets.newHashSet();
for (AggregateFunction aggregateFunction : aggregateFunctions) {
- newAggregateFunctions
- .add((AggregateFunction)
ExpressionUtils.replace(aggregateFunction, replaceMap));
+ try {
+ newAggregateFunctions
+ .add((AggregateFunction) ExpressionUtils.replace(
+ aggregateFunction, replaceMap));
+ } catch (AnalysisException e) {
+ if (e.getErrorCode() ==
AnalysisException.ErrorCode.EXPRESSION_EXCEEDS_LIMIT) {
+ hasUnresolvedExpression = true;
+ } else {
+ throw e;
+ }
+ }
}
aggregateFunctions = newAggregateFunctions;
}
@@ -193,6 +300,19 @@ public class SetPreAggStatus extends
DefaultPlanRewriter<Stack<SetPreAggStatus.P
LogicalJoin plan = (LogicalJoin) super.visit(logicalJoin, context);
if (!context.empty() && context.peek() != null) {
context.peek().addJoinInfo(plan);
+ if (plan.getJoinType().isAsofJoin()) {
+ // ASOF join keeps at most one matching row per probe row, and
the
+ // ASOF index may pick any equal-time row. If the selected
side is
+ // pre-agg ON, duplicate full keys stay as partial rows and a
stale
+ // partial (e.g. an older v9=100) could be picked instead of
the
+ // storage-merged value (200), changing MAX/MIN. Force the
selected
+ // side OFF so storage merges its columns first.
+ Plan selectedSide = plan.getJoinType().isAsofLeftJoin() ?
plan.right() : plan.left();
+ for (LogicalOlapScan scan : selectedSide
+
.<LogicalOlapScan>collect(LogicalOlapScan.class::isInstance)) {
+
context.peek().asofSelectedSideRelationIds.add(scan.getRelationId());
+ }
+ }
}
return plan;
}
@@ -200,9 +320,18 @@ public class SetPreAggStatus extends
DefaultPlanRewriter<Stack<SetPreAggStatus.P
@Override
public Plan visitLogicalProject(LogicalProject<? extends Plan>
logicalProject,
Stack<PreAggInfoContext> context) {
- LogicalProject plan = (LogicalProject) super.visit(logicalProject,
context);
+ LogicalProject<?> plan = (LogicalProject) super.visit(logicalProject,
context);
if (!context.empty() && context.peek() != null) {
context.peek().setReplaceMap(plan.getAliasToProducer());
+ // Track retained non-movable project expressions (e.g.
assert_true):
+ // pruneOutputs keeps them even when unused, and they must run on
+ // storage-merged rows. If pre-agg turns a scan ON, these would be
+ // evaluated on raw partial rows, so keep affected scans OFF.
+ for (NamedExpression output : plan.getOutputs()) {
+ if (output.containsType(NoneMovableFunction.class)) {
+ context.peek().addRetainedNonMovableExpression(output);
+ }
+ }
}
return plan;
}
@@ -256,7 +385,15 @@ public class SetPreAggStatus extends
DefaultPlanRewriter<Stack<SetPreAggStatus.P
PreAggStatus preAggStatus = PreAggStatus.off("No valid
aggregate on scan.");
PreAggInfoContext preAggInfoContext =
context.get(olapScan.getRelationId());
if (preAggInfoContext != null) {
- preAggStatus = createPreAggStatus(olapScan,
preAggInfoContext);
+ if
(preAggInfoContext.asofSelectedSideRelationIds.contains(olapScan.getRelationId()))
{
+ // ASOF join's one-row selection does not commute with
+ // exposing this scan's partial (unmerged) rows:
storage
+ // must merge first so the picked row is the merged
value.
+ preAggStatus = PreAggStatus.off(
+ "can't turn preAgg on because the scan is the
selected side of an ASOF join");
+ } else {
+ preAggStatus = createPreAggStatus(olapScan,
preAggInfoContext);
+ }
}
return olapScan.withPreAggStatus(preAggStatus);
} else {
@@ -265,6 +402,9 @@ public class SetPreAggStatus extends
DefaultPlanRewriter<Stack<SetPreAggStatus.P
}
private PreAggStatus createPreAggStatus(LogicalOlapScan
logicalOlapScan, PreAggInfoContext context) {
+ if (context.hasUnresolvedExpression) {
+ return PreAggStatus.off("Expression exceeds limit, can't
determine preAgg status.");
+ }
List<Expression> filterConjuncts = context.filterConjuncts;
List<Expression> joinConjuncts = context.joinConjuncts;
Set<AggregateFunction> aggregateFuncs = context.aggregateFunctions;
@@ -294,6 +434,65 @@ public class SetPreAggStatus extends
DefaultPlanRewriter<Stack<SetPreAggStatus.P
return PreAggStatus.off(String.format("Join conjuncts %s
contains non-key column %s",
joinConjuncts, joinInputSlots));
}
+
+ // Retained non-movable project expressions (e.g. assert_true) must
+ // run on storage-merged rows: with pre-agg ON they would be
evaluated
+ // on raw partial rows. Keep any scan whose value columns they
read OFF.
+ //
+ // Slotless volatile retained outputs (e.g. assert_true(random() >
0.5))
+ // have no input slots, so the value-slot fence above misses them,
and
+ // the volatility checks below only cover agg/filter/join/grouping
+ // expressions. PREAGG ON would then evaluate the volatile
expression
+ // once per raw partial row instead of once per storage-merged row
—
+ // a different evaluation cardinality that can change its result —
so
+ // keep the scan OFF here as well.
+ for (Expression retained : context.retainedNonMovableExpressions) {
+ if (retained.containsVolatileExpression()) {
+ return PreAggStatus.off(String.format(
+ "retained non-movable expression %s contains
volatile expression",
+ retained));
+ }
+ if (!Sets.intersection(retained.getInputSlots(),
valueSlots).isEmpty()) {
+ return PreAggStatus.off(String.format(
+ "retained non-movable expression %s references
non-key column %s",
+ retained, valueSlots));
+ }
+ }
+
+ // Row-stability check: volatile expressions evaluated per partial
row
+ // produce different results than per merged logical row, even when
+ // their input slots are all key columns or empty. Check centrally
+ // before per-scan candidate filtering so the guard also covers
+ // other-table aggregates, slot-less filters, joins, and grouping.
+ for (AggregateFunction aggFunc : aggregateFuncs) {
+ if (aggFunc.containsVolatileExpression()) {
+ return PreAggStatus.off(
+ String.format("aggregate function %s contains
volatile expression",
+ aggFunc));
+ }
+ }
+ for (Expression conjunct : filterConjuncts) {
+ if (conjunct.containsVolatileExpression()) {
+ return PreAggStatus.off(
+ String.format("filter conjunct %s contains
volatile expression",
+ conjunct));
+ }
+ }
+ for (Expression conjunct : joinConjuncts) {
+ if (conjunct.containsVolatileExpression()) {
+ return PreAggStatus.off(
+ String.format("join conjunct %s contains volatile
expression",
+ conjunct));
+ }
+ }
+ for (Expression expr : groupingExprs) {
+ if (expr.containsVolatileExpression()) {
+ return PreAggStatus.off(
+ String.format("grouping expression %s contains
volatile expression",
+ expr));
+ }
+ }
+
Set<AggregateFunction> candidateAggFuncs = Sets.newHashSet();
for (AggregateFunction aggregateFunction : aggregateFuncs) {
if (!Sets.intersection(aggregateFunction.getInputSlots(),
outputSlots).isEmpty()) {
@@ -315,39 +514,60 @@ public class SetPreAggStatus extends
DefaultPlanRewriter<Stack<SetPreAggStatus.P
return !aggregateFuncs.isEmpty() || !groupingExprs.isEmpty() ?
PreAggStatus.on()
: PreAggStatus.off("No aggregate on scan.");
} else {
- return checkAggregateFunctions(candidateAggFuncs,
candidateGroupByInputSlots);
+ return checkAggregateFunctions(candidateAggFuncs,
candidateGroupByInputSlots, outputSlots);
}
}
private PreAggStatus checkAggregateFunctions(Set<AggregateFunction>
aggregateFuncs,
- Set<Slot> groupingExprsInputSlots) {
+ Set<Slot> groupingExprsInputSlots, Set<Slot> outputSlots) {
if (aggregateFuncs.isEmpty() && groupingExprsInputSlots.isEmpty())
{
return PreAggStatus.off("No aggregate on scan.");
}
PreAggStatus preAggStatus = PreAggStatus.on();
for (AggregateFunction aggFunc : aggregateFuncs) {
- if (aggFunc.children().isEmpty()) {
- preAggStatus = PreAggStatus.off(
- String.format("can't turn preAgg on for aggregate
function %s", aggFunc));
- } else if (aggFunc.children().size() == 1 && aggFunc.child(0)
instanceof Slot) {
- Slot aggSlot = (Slot) aggFunc.child(0);
- if (aggSlot instanceof SlotReference
- && ((SlotReference)
aggSlot).getOriginalColumn().isPresent()) {
- if (((SlotReference)
aggSlot).getOriginalColumn().get().isKey()) {
- preAggStatus =
OneKeySlotAggChecker.INSTANCE.check(aggFunc);
- } else {
+ // candidateAggFuncs only contains functions whose input slots
+ // intersect outputSlots (see createPreAggStatus), so aggSlots
is
+ // never empty and splitKeyValueSlots always inspects real
slots.
+ Set<Slot> aggSlots = Sets.intersection(
+ aggFunc.getInputSlots(), outputSlots);
+ Pair<Set<SlotReference>, Set<SlotReference>> splitSlots =
splitKeyValueSlots(aggSlots);
+ if (splitSlots.first.isEmpty()) {
+ // only value slots
+ Expression valueChild = aggFunc.child(0);
+ if (aggFunc.children().size() == 1 && valueChild
instanceof SlotReference) {
+ SlotReference slotRef = (SlotReference) valueChild;
+ if (slotRef.getOriginalColumn().isPresent()) {
preAggStatus =
OneValueSlotAggChecker.INSTANCE.check(aggFunc,
- ((SlotReference)
aggSlot).getOriginalColumn().get().getAggregationType());
+
slotRef.getOriginalColumn().get().getAggregationType());
+ } else {
+ preAggStatus = PreAggStatus.off(
+ String.format("can't turn preAgg on for
aggregate function %s", aggFunc));
}
+ } else if (aggFunc.children().size() == 1
+ && (valueChild instanceof If || valueChild
instanceof CaseWhen)) {
+ // IF/CaseWhen: the condition references only key
columns (foreign or
+ // local), stable across this scan's partial rows.
The return
+ // expressions reference local value columns validated
below.
+ // Reuse checkAggWithKeyAndValueSlots: the global
condition
+ // check (step 2) accepts foreign key columns.
+ preAggStatus = checkAggWithKeyAndValueSlots(aggFunc,
outputSlots);
} else {
preAggStatus = PreAggStatus.off(
- String.format("aggregate function %s use
unknown slot %s from scan",
- aggFunc, aggSlot));
+ String.format("can't turn preAgg on for
aggregate function %s", aggFunc));
}
+ } else if (splitSlots.second.isEmpty()) {
+ // only key slots
+ preAggStatus = KeySlotAggChecker.INSTANCE.check(aggFunc);
} else {
- Set<Slot> aggSlots = aggFunc.getInputSlots();
- Pair<Set<SlotReference>, Set<SlotReference>> splitSlots =
splitKeyValueSlots(aggSlots);
- preAggStatus = checkAggWithKeyAndValueSlots(aggFunc,
splitSlots.first, splitSlots.second);
+ // checkAggWithKeyAndValueSlots only inspects child(0) for
IF/CaseWhen patterns.
+ // For multi-argument aggregate functions, child(0)
inspection is insufficient
+ // as later arguments may contain value columns that are
not validated.
+ if (aggFunc.children().size() > 1) {
+ preAggStatus = PreAggStatus.off(
+ String.format("can't turn preAgg on for
aggregate function %s", aggFunc));
+ } else {
+ preAggStatus = checkAggWithKeyAndValueSlots(aggFunc,
outputSlots);
+ }
}
if (preAggStatus.isOff()) {
return preAggStatus;
@@ -371,41 +591,93 @@ public class SetPreAggStatus extends
DefaultPlanRewriter<Stack<SetPreAggStatus.P
return Pair.of(keySlots, valueSlots);
}
- private PreAggStatus checkAggWithKeyAndValueSlots(AggregateFunction
aggFunc,
- Set<SlotReference> keySlots, Set<SlotReference> valueSlots) {
+ private PreAggStatus checkAggWithKeyAndValueSlots(AggregateFunction
aggFunc, Set<Slot> outputSlots) {
Expression child = aggFunc.child(0);
List<Expression> conditionExps = new ArrayList<>();
List<Expression> returnExps = new ArrayList<>();
- // ignore cast
- while (child instanceof Cast) {
- if (!((Cast) child).getDataType().isNumericType()) {
- return PreAggStatus.off(String.format("%s is not numeric
CAST.", child.toSql()));
- }
- child = child.child(0);
- }
- // step 1: extract all condition exprs and return exprs
+ // No cast is peeled for MAX/MIN: DOUBLE/DECIMAL→FLOAT can
underflow
+ // to -0.0 and change the observable tie representative (signed
zero)
+ // under MAX/MIN, so even nondecreasing casts are not MAX/MIN
+ // homomorphisms. Any remaining cast is rejected below / by the
+ // checker, keeping pre-agg conservatively OFF.
+ // Reject remaining cast.
+ if (child instanceof Cast) {
+ return PreAggStatus.off(String.format("%s is not supported.",
child.toSql()));
+ }
+ // step 1: extract all condition exprs and return exprs.
+ // child is guaranteed to be Cast-free here (rejected above), but
+ // individual IF/CaseWhen return expressions may still have their
+ // own Cast wrappers. Only strip those for MAX/MIN: sum(cast(x))
+ // and cast(sum(x)) are not interchangeable due to overflow.
if (child instanceof If) {
conditionExps.add(child.child(0));
- returnExps.add(removeCast(child.child(1)));
- returnExps.add(removeCast(child.child(2)));
+ returnExps.add(child.child(1));
+ returnExps.add(child.child(2));
} else if (child instanceof CaseWhen) {
CaseWhen caseWhen = (CaseWhen) child;
// WHEN THEN
for (WhenClause whenClause : caseWhen.getWhenClauses()) {
conditionExps.add(whenClause.getOperand());
- returnExps.add(removeCast(whenClause.getResult()));
+ returnExps.add(whenClause.getResult());
}
// ELSE
-
returnExps.add(removeCast(caseWhen.getDefaultValue().orElse(new
NullLiteral())));
+ returnExps.add(caseWhen.getDefaultValue().orElse(new
NullLiteral()));
} else {
- // currently, only IF and CASE WHEN are supported
- returnExps.add(removeCast(child));
+ // Non-IF/CASE — conditionExps stays empty and returns OFF
below.
+ returnExps.add(child);
+ }
+
+ // step 1.5: ownership — every return expression must reference
only
+ // this scan's own columns. PREAGG ON exposes this scan's partial
+ // (unmerged) rows; under join fan-out a return that references a
+ // foreign value column would then be evaluated once per partial
row
+ // and double-counted. So a foreign slot (value or key) in any
return
+ // forces this scan OFF — never use a foreign column to justify ON.
+ //
+ // Exception: MAX/MIN are idempotent — max(x, x) = x — so
repeating a
+ // foreign value across partial rows cannot change the aggregate
+ // result. The fence is over-conservative for them: a foreign
return
+ // branch is safe once the condition is row-stable (step 2) and the
+ // return slot still matches the aggregate type (enforced by
+ // KeyAndValueSlotsAggChecker). Keep the fence for non-idempotent
+ // aggregates (SUM, COUNT, ...) where a repeated foreign value
would
+ // be double-counted.
+ //
+ // count(distinct ...) is also multiplicity-immune (DISTINCT
+ // deduplicates repeated foreign values), like MAX/MIN, but is
+ // deliberately not exempted: the exemption would be a no-op. A
safe
+ // foreign return for it is a key slot, and such aggregates never
+ // reach this fence — key-only local slots go through
+ // KeySlotAggChecker (isDistinct -> ON), empty local slots are
+ // whitelisted as other-table count-distinct. When this fence does
+ // fire for count(distinct), step 2 (a value column in the
+ // condition) or KeyAndValueSlotsAggChecker.visitCount (a value
+ // column in a return; only key/0/NULL returns are accepted)
+ // independently rejects it anyway.
+ if (!(aggFunc instanceof Max || aggFunc instanceof Min)) {
+ for (Expression returnExp : returnExps) {
+ if (returnExp instanceof SlotReference &&
!outputSlots.contains(returnExp)) {
+ return PreAggStatus.off(
+ String.format("return expression %s references
column not owned by this scan.",
+ returnExp.toSql()));
+ }
+ }
+ }
+ if (conditionExps.isEmpty()) {
+ return PreAggStatus.off(
+ String.format("can't turn preAgg on for aggregate
function %s", aggFunc));
}
- // step 2: check condition expressions
+ // step 2: check condition expressions — all condition inputs must
+ // be key columns (from any table), not value columns. A global
+ // splitKeyValueSlots check handles this correctly for both the
+ // mixed-path (called with local key/value sets) and the value-only
+ // path (foreign key conditions in IF/CaseWhen).
Set<Slot> inputSlots =
ExpressionUtils.getInputSlotSet(conditionExps);
- if (!keySlots.containsAll(inputSlots)) {
+ Pair<Set<SlotReference>, Set<SlotReference>> condSplit =
+ splitKeyValueSlots(inputSlots);
+ if (!condSplit.second.isEmpty()) {
return PreAggStatus
.off(String.format("some columns in condition %s is
not key.", conditionExps));
}
@@ -413,13 +685,6 @@ public class SetPreAggStatus extends
DefaultPlanRewriter<Stack<SetPreAggStatus.P
return KeyAndValueSlotsAggChecker.INSTANCE.check(aggFunc,
returnExps);
}
- private static Expression removeCast(Expression expression) {
- while (expression instanceof Cast) {
- expression = ((Cast) expression).child();
- }
- return expression;
- }
-
private static class OneValueSlotAggChecker
extends ExpressionVisitor<PreAggStatus, AggregateType> {
public static final OneValueSlotAggChecker INSTANCE = new
OneValueSlotAggChecker();
@@ -511,8 +776,8 @@ public class SetPreAggStatus extends
DefaultPlanRewriter<Stack<SetPreAggStatus.P
}
}
- private static class OneKeySlotAggChecker extends
ExpressionVisitor<PreAggStatus, Void> {
- public static final OneKeySlotAggChecker INSTANCE = new
OneKeySlotAggChecker();
+ private static class KeySlotAggChecker extends
ExpressionVisitor<PreAggStatus, Void> {
+ public static final KeySlotAggChecker INSTANCE = new
KeySlotAggChecker();
public PreAggStatus check(AggregateFunction aggFun) {
return aggFun.accept(INSTANCE, null);
@@ -566,6 +831,13 @@ public class SetPreAggStatus extends
DefaultPlanRewriter<Stack<SetPreAggStatus.P
@Override
public PreAggStatus visitSum(Sum sum, List<Expression>
returnValues) {
+ // DISTINCT breaks pre-agg: storage SUM merges duplicate full
keys
+ // first (e.g. two rowsets with v7=1 become 2), so
sum(DISTINCT ...)
+ // over the merged value differs from DISTINCT over the raw
rows.
+ // Reject like OneValueSlotAggChecker.visitSum does.
+ if (sum.isDistinct()) {
+ return PreAggStatus.off(String.format("%s is not
supported.", sum.toSql()));
+ }
for (Expression value : returnValues) {
if (!(isAggTypeMatched(value, AggregateType.SUM) ||
value.isZeroLiteral()
|| value.isNullLiteral())) {
@@ -579,7 +851,7 @@ public class SetPreAggStatus extends
DefaultPlanRewriter<Stack<SetPreAggStatus.P
public PreAggStatus visitMax(Max max, List<Expression>
returnValues) {
for (Expression value : returnValues) {
if (!(isAggTypeMatched(value, AggregateType.MAX) ||
isKeySlot(value)
- || value.isNullLiteral())) {
+ || value.isLiteral())) {
return PreAggStatus.off(String.format("%s is not
supported.", max.toSql()));
}
}
@@ -590,7 +862,7 @@ public class SetPreAggStatus extends
DefaultPlanRewriter<Stack<SetPreAggStatus.P
public PreAggStatus visitMin(Min min, List<Expression>
returnValues) {
for (Expression value : returnValues) {
if (!(isAggTypeMatched(value, AggregateType.MIN) ||
isKeySlot(value)
- || value.isNullLiteral())) {
+ || value.isLiteral())) {
return PreAggStatus.off(String.format("%s is not
supported.", min.toSql()));
}
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/SetPreAggStatusTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/SetPreAggStatusTest.java
new file mode 100644
index 00000000000..aa93a831d07
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/SetPreAggStatusTest.java
@@ -0,0 +1,355 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.nereids.rules.rewrite;
+
+import org.apache.doris.catalog.AggregateType;
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.Type;
+import org.apache.doris.nereids.trees.expressions.Add;
+import org.apache.doris.nereids.trees.expressions.ExprId;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.GreaterThan;
+import org.apache.doris.nereids.trees.expressions.Slot;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import
org.apache.doris.nereids.trees.expressions.functions.agg.AggregateFunction;
+import org.apache.doris.nereids.trees.expressions.functions.agg.Count;
+import org.apache.doris.nereids.trees.expressions.functions.agg.Max;
+import org.apache.doris.nereids.trees.expressions.functions.agg.Min;
+import org.apache.doris.nereids.trees.expressions.functions.agg.Sum;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.If;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.Random;
+import org.apache.doris.nereids.trees.expressions.literal.DoubleLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
+import org.apache.doris.nereids.trees.plans.PreAggStatus;
+import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan;
+import org.apache.doris.nereids.types.IntegerType;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.Set;
+
+/**
+ * Unit tests for the pre-agg dispatch logic in {@link SetPreAggStatus}.
+ *
+ * <p>The decision logic lives in the private static class SetOlapScanPreAgg
+ * (checkAggregateFunctions / checkAggWithKeyAndValueSlots /
createPreAggStatus),
+ * so we reach it via reflection, mirroring TabletSlidingWindowAccessStatsTest.
+ * The dispatch matrix covered here (pure-key multi-arg, mixed multi-arg,
+ * pure-value IF path, volatile guards, ownership fence) is otherwise only
+ * exercised by explain regression tests.
+ */
+class SetPreAggStatusTest {
+
+ private static final Method CHECK_AGG_FUNCS;
+ private static final Method CHECK_AGG_WITH_KEY_VALUE;
+ private static final Method CREATE_PRE_AGG_STATUS;
+ private static final Object SET_OLAP_SCAN_PRE_AGG_INSTANCE;
+
+ static {
+ try {
+ Class<?> clazz = Class.forName(
+
"org.apache.doris.nereids.rules.rewrite.SetPreAggStatus$SetOlapScanPreAgg");
+ CHECK_AGG_FUNCS = clazz.getDeclaredMethod(
+ "checkAggregateFunctions", Set.class, Set.class,
Set.class);
+ CHECK_AGG_FUNCS.setAccessible(true);
+ CHECK_AGG_WITH_KEY_VALUE = clazz.getDeclaredMethod(
+ "checkAggWithKeyAndValueSlots", AggregateFunction.class,
Set.class);
+ CHECK_AGG_WITH_KEY_VALUE.setAccessible(true);
+ CREATE_PRE_AGG_STATUS = clazz.getDeclaredMethod(
+ "createPreAggStatus", LogicalOlapScan.class,
SetPreAggStatus.PreAggInfoContext.class);
+ CREATE_PRE_AGG_STATUS.setAccessible(true);
+ Field instance = clazz.getDeclaredField("INSTANCE");
+ instance.setAccessible(true);
+ SET_OLAP_SCAN_PRE_AGG_INSTANCE = instance.get(null);
+ } catch (Exception e) {
+ throw new ExceptionInInitializerError(e);
+ }
+ }
+
+ private static int exprIdCounter = 0;
+
+ private static SlotReference keySlot(String name) {
+ return slot(name, new Column(name, Type.INT, true, AggregateType.NONE,
null, ""));
+ }
+
+ private static SlotReference valueSlot(String name, AggregateType
aggregateType) {
+ return slot(name, new Column(name, Type.INT, false, aggregateType,
null, ""));
+ }
+
+ private static SlotReference slot(String name, Column column) {
+ return new SlotReference(new ExprId(exprIdCounter++), name,
IntegerType.INSTANCE, true,
+ ImmutableList.of("t"), null, column, null, null);
+ }
+
+ private static PreAggStatus checkAggregateFunctions(
+ Set<AggregateFunction> aggregateFuncs, Set<Slot>
groupingExprsInputSlots, Set<Slot> outputSlots) {
+ try {
+ // checkAggregateFunctions is an instance method of
SetOlapScanPreAgg, so pass its INSTANCE
+ return (PreAggStatus)
CHECK_AGG_FUNCS.invoke(SET_OLAP_SCAN_PRE_AGG_INSTANCE,
+ aggregateFuncs, groupingExprsInputSlots, outputSlots);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static PreAggStatus checkAggWithKeyAndValueSlots(AggregateFunction
aggFunc, Set<Slot> outputSlots) {
+ try {
+ return (PreAggStatus)
CHECK_AGG_WITH_KEY_VALUE.invoke(SET_OLAP_SCAN_PRE_AGG_INSTANCE, aggFunc,
outputSlots);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static PreAggStatus createPreAggStatus(LogicalOlapScan scan,
SetPreAggStatus.PreAggInfoContext context) {
+ try {
+ return (PreAggStatus)
CREATE_PRE_AGG_STATUS.invoke(SET_OLAP_SCAN_PRE_AGG_INSTANCE, scan, context);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static Expression greaterThanZero(Slot slot) {
+ return new GreaterThan(slot, new IntegerLiteral(0));
+ }
+
+ private static Expression ifGreaterThanZero(Slot key, Expression thenExpr,
Expression elseExpr) {
+ return new If(greaterThanZero(key), thenExpr, elseExpr);
+ }
+
+ @Test
+ void testNoAggregateReturnsOff() {
+ SlotReference k = keySlot("k");
+ Assertions.assertTrue(checkAggregateFunctions(Collections.emptySet(),
Collections.emptySet(),
+ Sets.newHashSet(k)).isOff());
+ }
+
+ @Test
+ void testGroupingOnlyReturnsOn() {
+ SlotReference k = keySlot("k");
+ // aggregateFuncs empty but groupingExprsInputSlots non-empty -> loop
is a no-op, returns ON
+ Assertions.assertTrue(checkAggregateFunctions(Collections.emptySet(),
Sets.newHashSet(k),
+ Sets.newHashSet(k)).isOn());
+ }
+
+ @Test
+ void testPureKeySlotsDispatch() {
+ SlotReference k1 = keySlot("k1");
+ SlotReference k2 = keySlot("k2");
+ Set<Slot> output = Sets.newHashSet(k1, k2);
+
+ // max/min over key slots are allowed (KeySlotAggChecker)
+ Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new
Max(k1)), Collections.emptySet(), output)
+ .isOn());
+ Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new
Min(k1)), Collections.emptySet(), output)
+ .isOn());
+
+ // distinct aggregates over key slots are allowed (KeySlotAggChecker)
+ Assertions.assertTrue(
+ checkAggregateFunctions(Sets.newHashSet(new Count(true, k1)),
Collections.emptySet(), output).isOn());
+ // pure-key multi-argument count(distinct k1, k2)
+ Assertions.assertTrue(
+ checkAggregateFunctions(Sets.newHashSet(new Count(true, k1,
k2)), Collections.emptySet(), output)
+ .isOn());
+
+ // non-distinct, non-max/min aggregates over key slots are rejected
+ Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new
Sum(k1)), Collections.emptySet(), output)
+ .isOff());
+ Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new
Count(k1)), Collections.emptySet(), output)
+ .isOff());
+ }
+
+ @Test
+ void testMixedMultiArgRejected() {
+ SlotReference k = keySlot("k");
+ SlotReference v = valueSlot("v", AggregateType.SUM);
+ Set<Slot> output = Sets.newHashSet(k, v);
+
+ // multi-argument aggregate with mixed local key/value slots cannot
pre-agg
+ PreAggStatus status = checkAggregateFunctions(
+ Sets.newHashSet(new Count(true, k, v)),
Collections.emptySet(), output);
+ Assertions.assertTrue(status.isOff());
+ Assertions.assertTrue(status.getOffReason().contains("can't turn
preAgg on for aggregate function"));
+ }
+
+ @Test
+ void testPureValueSlotDispatch() {
+ SlotReference vSum = valueSlot("v", AggregateType.SUM);
+ SlotReference vMax = valueSlot("vmax", AggregateType.MAX);
+ Set<Slot> output = Sets.newHashSet(vSum);
+
+ // sum(v) with SUM-type value column (OneValueSlotAggChecker)
+ Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new
Sum(vSum)), Collections.emptySet(), output)
+ .isOn());
+ // max(v) with MAX-type value column
+ Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new
Max(vMax)), Collections.emptySet(),
+ Sets.newHashSet(vMax)).isOn());
+ // aggregation-type mismatch
+ Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new
Sum(vMax)), Collections.emptySet(),
+ Sets.newHashSet(vMax)).isOff());
+ // count over a bare value column is not pre-aggregable
(OneValueSlotAggChecker has no visitCount)
+ Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new
Count(vSum)), Collections.emptySet(),
+ output).isOff());
+ // non-slot, non-IF/CaseWhen child is rejected
+ Assertions.assertTrue(checkAggregateFunctions(
+ Sets.newHashSet(new Sum(new Add(vSum, new IntegerLiteral(1)))),
+ Collections.emptySet(), output).isOff());
+ }
+
+ @Test
+ void testIfCaseWhenValuePath() {
+ SlotReference k = keySlot("k");
+ SlotReference v = valueSlot("v", AggregateType.SUM);
+ SlotReference vMax = valueSlot("vmax", AggregateType.MAX);
+ SlotReference foreignV = valueSlot("fv", AggregateType.SUM);
+ SlotReference foreignVMax = valueSlot("fvmax", AggregateType.MAX);
+ Set<Slot> output = Sets.newHashSet(k, v);
+
+ // sum(if(k > 0, v, 0)): row-stable key condition + local SUM return
-> ON
+ Expression localIf = ifGreaterThanZero(k, v, new IntegerLiteral(0));
+ Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new
Sum(localIf)), Collections.emptySet(),
+ output).isOn());
+
+ // condition references a value column -> OFF (step 2)
+ Expression condOnValue = new If(new GreaterThan(v, new
IntegerLiteral(0)), v, new IntegerLiteral(0));
+ Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new
Sum(condOnValue)), Collections.emptySet(),
+ output).isOff());
+
+ // foreign value in a SUM return: local slots are pure keys, so this
goes through
+ // KeySlotAggChecker (sum is not distinct) -> OFF, never reaching the
ownership fence
+ Expression foreignSumIf = ifGreaterThanZero(k, foreignV, new
IntegerLiteral(0));
+ PreAggStatus foreignSum = checkAggregateFunctions(
+ Sets.newHashSet(new Sum(foreignSumIf)),
Collections.emptySet(), output);
+ Assertions.assertTrue(foreignSum.isOff());
+ Assertions.assertTrue(foreignSum.getOffReason().contains("is not
distinct"));
+
+ // MAX exemption: foreign MAX value return is allowed when local slots
are mixed
+ Expression mixedMaxIf = new If(greaterThanZero(k), foreignVMax, vMax);
+ Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new
Max(mixedMaxIf)), Collections.emptySet(),
+ Sets.newHashSet(k, vMax)).isOn());
+
+ // same mixed shape with SUM is rejected by the ownership fence
+ Expression mixedSumIf = new If(greaterThanZero(k), foreignV, v);
+ PreAggStatus mixedSum = checkAggregateFunctions(
+ Sets.newHashSet(new Sum(mixedSumIf)), Collections.emptySet(),
output);
+ Assertions.assertTrue(mixedSum.isOff());
+ Assertions.assertTrue(mixedSum.getOffReason().contains("references
column not owned by this scan"));
+ }
+
+ @Test
+ void testCountDistinctDispatch() {
+ SlotReference k = keySlot("k");
+ SlotReference v = valueSlot("v", AggregateType.SUM);
+ Set<Slot> output = Sets.newHashSet(k, v);
+
+ // count(distinct if(k > 0, k, 0)): local slots are all keys ->
KeySlotAggChecker -> ON
+ Expression cdKeyIf = ifGreaterThanZero(k, k, new IntegerLiteral(0));
+ Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new
Count(true, cdKeyIf)),
+ Collections.emptySet(), output).isOn());
+
+ // count(distinct if(k > 0, v, 0)): value column in return -> OFF
(visitCount accepts only key/0/NULL)
+ Expression cdValueIf = ifGreaterThanZero(k, v, new IntegerLiteral(0));
+ Assertions.assertTrue(checkAggregateFunctions(Sets.newHashSet(new
Count(true, cdValueIf)),
+ Collections.emptySet(), output).isOff());
+ }
+
+ @Test
+ void testCheckAggWithKeyAndValueSlots() {
+ SlotReference k = keySlot("k");
+ SlotReference k2 = keySlot("k2");
+ SlotReference v = valueSlot("v", AggregateType.SUM);
+ SlotReference foreignV = valueSlot("fv", AggregateType.SUM);
+ SlotReference foreignVMax = valueSlot("fvmax", AggregateType.MAX);
+ Set<Slot> output = Sets.newHashSet(k, v);
+
+ // ownership fence: foreign value in a SUM return
+ Assertions.assertTrue(checkAggWithKeyAndValueSlots(
+ new Sum(ifGreaterThanZero(k, foreignV, new
IntegerLiteral(0))), output).isOff());
+
+ // MAX exemption: foreign MAX value return is safe
+ Assertions.assertTrue(checkAggWithKeyAndValueSlots(
+ new Max(ifGreaterThanZero(k, foreignVMax, new
IntegerLiteral(0))), output).isOn());
+
+ // condition referencing a value column is rejected (step 2)
+ Assertions.assertTrue(checkAggWithKeyAndValueSlots(
+ new Sum(new If(new GreaterThan(v, new IntegerLiteral(0)), v,
new IntegerLiteral(0))), output).isOff());
+
+ // count(distinct) returns must be key/0/NULL: value return -> OFF,
key return -> ON
+ Assertions.assertTrue(checkAggWithKeyAndValueSlots(
+ new Count(true, ifGreaterThanZero(k, v, new
IntegerLiteral(0))), output).isOff());
+ Assertions.assertTrue(checkAggWithKeyAndValueSlots(
+ new Count(true, ifGreaterThanZero(k, k2, new
IntegerLiteral(0))),
+ Sets.newHashSet(k, k2, v)).isOn());
+ }
+
+ @Test
+ void testVolatileAggregateTurnsScanOff() throws Exception {
+ SlotReference k = keySlot("k");
+ PreAggStatus status = createPreAggStatus(mockScan(Sets.newHashSet(k)),
+ contextWithAggregateFunctions(Sets.newHashSet(new Sum(new
Random()))));
+ Assertions.assertTrue(status.isOff());
+ Assertions.assertTrue(status.getOffReason().contains("aggregate
function")
+ && status.getOffReason().contains("contains volatile
expression"));
+ }
+
+ @Test
+ void testVolatileFilterTurnsScanOff() throws Exception {
+ SlotReference k = keySlot("k");
+ SetPreAggStatus.PreAggInfoContext context = new
SetPreAggStatus.PreAggInfoContext();
+ Field filterField =
SetPreAggStatus.PreAggInfoContext.class.getDeclaredField("filterConjuncts");
+ filterField.setAccessible(true);
+ filterField.set(context,
+ Lists.newArrayList(new GreaterThan(new Random(), new
DoubleLiteral(0.5))));
+
+ PreAggStatus status = createPreAggStatus(mockScan(Sets.newHashSet(k)),
context);
+ Assertions.assertTrue(status.isOff());
+ Assertions.assertTrue(status.getOffReason().contains("filter conjunct")
+ && status.getOffReason().contains("contains volatile
expression"));
+ }
+
+ @Test
+ void testValidAggregateTurnsScanOn() throws Exception {
+ SlotReference k = keySlot("k");
+ SlotReference v = valueSlot("v", AggregateType.SUM);
+ PreAggStatus status = createPreAggStatus(mockScan(Sets.newHashSet(k,
v)),
+ contextWithAggregateFunctions(Sets.newHashSet(new Sum(v))));
+ Assertions.assertTrue(status.isOn());
+ }
+
+ private static LogicalOlapScan mockScan(Set<Slot> outputSlots) {
+ LogicalOlapScan scan = Mockito.mock(LogicalOlapScan.class);
+ Mockito.when(scan.getOutputSet()).thenReturn(outputSlots);
+ return scan;
+ }
+
+ private static SetPreAggStatus.PreAggInfoContext
contextWithAggregateFunctions(
+ Set<AggregateFunction> aggregateFunctions) throws Exception {
+ SetPreAggStatus.PreAggInfoContext context = new
SetPreAggStatus.PreAggInfoContext();
+ Field aggField =
SetPreAggStatus.PreAggInfoContext.class.getDeclaredField("aggregateFunctions");
+ aggField.setAccessible(true);
+ aggField.set(context, aggregateFunctions);
+ return context;
+ }
+}
diff --git a/regression-test/data/nereids_rules_p0/set_preagg/set_preagg.out
b/regression-test/data/nereids_rules_p0/set_preagg/set_preagg.out
new file mode 100644
index 00000000000..ff3fbe7f7b7
--- /dev/null
+++ b/regression-test/data/nereids_rules_p0/set_preagg/set_preagg.out
@@ -0,0 +1,148 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !q01 --
+0 0 40 7000
+1 1 30 8000
+
+-- !q02 --
+0 0 7000
+1 1 8000
+
+-- !q03 --
+0 0 0 7000 1
+1 1 1 8000 1
+
+-- !q04 --
+0 0 70 7000
+1 1 60 8000
+
+-- !q05 --
+0 0 3000 7000 1
+1 1 5000 8000 1
+
+-- !q06 --
+0 0 70 7000 1
+1 1 110 8000 1
+
+-- !q07 --
+0 0 70 7000
+1 1 110 8000
+
+-- !q08 --
+0 0 1 7000 70
+1 1 2 8000 110
+
+-- !q09 --
+0 0 3000 7000 1 1
+1 1 5000 8000 1 1
+
+-- !q10 --
+0 0 7000 1 1
+1 1 8000 1 1
+
+-- !q11 --
+0 0 40 7000 1 1
+1 1 60 8000 1 1
+
+-- !q12 --
+1 1 10 1000
+2 0 40 700
+
+-- !q13 --
+10
+
+-- !q14 --
+4
+
+-- !q15 --
+3
+
+-- !q16 --
+4
+
+-- !q17 --
+4
+
+-- !q18 --
+1
+
+-- !q19 --
+3
+
+-- !q20 --
+400
+
+-- !q21 --
+1000
+
+-- !q22 --
+100
+
+-- !q23 --
+900
+
+-- !q24 --
+1000
+
+-- !q25 --
+30
+
+-- !q26 --
+1000
+
+-- !q27 --
+1000
+
+-- !q28 --
+1000
+
+-- !q29 --
+\N
+
+-- !q30 --
+1000
+
+-- !q31 --
+0
+
+-- !q32 --
+1000
+
+-- !q33 --
+0
+
+-- !test_a --
+300
+
+-- !test_b --
+160
+
+-- !q34 --
+1000
+
+-- !q35 --
+7
+
+-- !q36 --
+false
+
+-- !q37 --
+300
+
+-- !q38 --
+600
+
+-- !q39 --
+100
+
+-- !test_c --
+20
+
+-- !test_f --
+20
+
+-- !test_d --
+1000
+
+-- !test_e --
+0
+
diff --git
a/regression-test/suites/nereids_rules_p0/set_preagg/set_preagg.groovy
b/regression-test/suites/nereids_rules_p0/set_preagg/set_preagg.groovy
index f47b2b40fc2..d278699d23f 100644
--- a/regression-test/suites/nereids_rules_p0/set_preagg/set_preagg.groovy
+++ b/regression-test/suites/nereids_rules_p0/set_preagg/set_preagg.groovy
@@ -16,6 +16,9 @@
// under the License.
suite("set_preagg") {
+ // preagg_t4 gets two loads of the same full key (1,1,1,1,1,1) to create
+ // duplicate full keys across rowsets: storage SUM merges v7 to 1+1=2 while
+ // pre-agg ON would expose the raw {1,1} rows (used by the DISTINCT SUM
test).
multi_sql """
set disable_nereids_rules='PRUNE_EMPTY_PARTITION';
set forbid_unknown_col_stats=false;
@@ -23,6 +26,15 @@ suite("set_preagg") {
drop table if exists preagg_t1;
drop table if exists preagg_t2;
drop table if exists preagg_t3;
+ drop table if exists preagg_t4;
+ drop table if exists preagg_t5;
+ drop table if exists preagg_f_l;
+ drop table if exists preagg_f_r;
+ drop table if exists preagg_asof_l;
+ drop table if exists preagg_asof_r;
+ drop table if exists preagg_g;
+ drop table if exists preagg_own_l;
+ drop table if exists preagg_own_r;
create table preagg_t1(
k1 int null,
@@ -67,8 +79,125 @@ suite("set_preagg") {
aggregate key (k1,k2,k3,k4,k5,k6)
distributed BY hash(k1) buckets 3
properties("replication_num" = "1");
+ create table preagg_t4(
+ k1 int null,
+ k2 int null,
+ k3 int null,
+ k4 int null,
+ k5 int null,
+ k6 int null,
+ v7 bigint SUM,
+ v9 bigint MAX
+ )
+ aggregate key (k1,k2,k3,k4,k5,k6)
+ distributed BY hash(k1) buckets 3
+ properties("replication_num" = "1");
+ create table preagg_t5(
+ k1 int null,
+ v double MAX
+ )
+ aggregate key (k1)
+ distributed BY hash(k1) buckets 1
+ properties("replication_num" = "1");
+ create table preagg_f_l(
+ k1 int null,
+ v9 bigint MAX,
+ v9m bigint MIN
+ )
+ aggregate key (k1)
+ distributed BY hash(k1) buckets 1
+ properties("replication_num" = "1");
+ create table preagg_f_r(
+ k1 int null,
+ v9 bigint MAX,
+ v9m bigint MIN
+ )
+ aggregate key (k1)
+ distributed BY hash(k1) buckets 1
+ properties("replication_num" = "1");
+ create table preagg_asof_l(
+ grp int null,
+ ts datetime null
+ )
+ aggregate key (grp, ts)
+ distributed BY hash(grp) buckets 1
+ properties("replication_num" = "1");
+ create table preagg_asof_r(
+ grp int null,
+ ts datetime null,
+ v7 bigint SUM
+ )
+ aggregate key (grp, ts)
+ distributed BY hash(grp) buckets 1
+ properties("replication_num" = "1");
+ create table preagg_g(
+ k1 int null,
+ v7 bigint SUM,
+ v9 bigint MAX
+ )
+ aggregate key (k1)
+ distributed BY hash(k1) buckets 1
+ properties("replication_num" = "1");
+ create table preagg_own_l(
+ k1 int null,
+ v7 bigint SUM
+ )
+ aggregate key (k1)
+ distributed BY hash(k1) buckets 1
+ properties("replication_num" = "1");
+ create table preagg_own_r(
+ k1 int null,
+ v7 bigint SUM
+ )
+ aggregate key (k1)
+ distributed BY hash(k1) buckets 1
+ properties("replication_num" = "1");
+
+ insert into preagg_t1 values
+ (1,1,1,1,1,1, 10, 100, 1000),
+ (1,1,1,1,1,2, 20, 200, 900),
+ (-1,0,0,0,0,0, 30, 300, 800),
+ (2,0,0,0,0,0, 40, 400, 700);
+ insert into preagg_t2 values
+ (1,1,1,1,1,1, 50, 500, 5000),
+ (1,1,1,1,1,2, 60, 600, 4000),
+ (2,0,0,0,0,0, 70, 700, 3000);
+ insert into preagg_t3 values
+ (1,1,1,1,1,1, 80, 800, 8000),
+ (2,0,0,0,0,0, 90, 900, 7000);
+ insert into preagg_t4 values (1,1,1,1,1,1, 1, 100);
+ insert into preagg_t4 values (1,1,1,1,1,1, 1, 200);
+ insert into preagg_t4 values (2,0,0,0,0,0, 5, 300);
+ insert into preagg_t5 values (1, -1e-300);
+ insert into preagg_t5 values (1, 0.0);
+ insert into preagg_f_l values (1, 100, 100);
+ insert into preagg_f_l values (-1, 50, 50);
+ insert into preagg_f_r values (1, 1000, 1000);
+ insert into preagg_f_r values (1, 2000, 2000);
+ insert into preagg_f_r values (-1, 500, 500);
+ insert into preagg_f_r values (-1, 600, 600);
+ insert into preagg_asof_l values (1,'2020-01-01
00:15:00'),(2,'2020-01-01 00:00:00');
+ insert into preagg_asof_r values (1,'2020-01-01 00:00:00',100);
+ insert into preagg_asof_r values (1,'2020-01-01 00:00:00',200);
+ insert into preagg_asof_r values (2,'2020-01-01 00:00:00',300);
+ insert into preagg_g values (1, -2, 10);
+ insert into preagg_g values (1, 3, 20);
+ insert into preagg_own_l values (1, 10);
+ insert into preagg_own_l values (1, 20);
+ insert into preagg_own_l values (0, 5);
+ insert into preagg_own_l values (0, 7);
+ insert into preagg_own_r values (1, 100);
+ insert into preagg_own_r values (1, 200);
+ insert into preagg_own_r values (0, 50);
+ insert into preagg_own_r values (0, 80);
"""
+ // preagg_own_l/preagg_own_r: full keys k1=1 and k1=0 are each loaded twice
+ // in separate rowsets, so PREAGG ON would expose duplicate full-key
partial
+ // rows. preagg_own_l's k1=0 row makes t.a = abs(0) = 0, exercising the
ELSE
+ // branch (r.v7) in test_b; preagg_own_r's repeated keys make its own
ON/OFF
+ // status observable through join fan-out as well.
+
explain {
sql("""
select preagg_t3.k2, t12.k2, sum(t12.v1), max(preagg_t3.v9)
@@ -89,6 +218,21 @@ suite("set_preagg") {
contains "(preagg_t2), PREAGGREGATION: ON"
contains "(preagg_t3), PREAGGREGATION: OFF. Reason: can't turn preAgg
on because aggregate function sum"
}
+ order_qt_q01 """
+ select preagg_t3.k2, t12.k2, sum(t12.v1), max(preagg_t3.v9)
+ from
+ (
+ select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4,
sum(ta1.t1_sum_v7) v1, sum(ta2.t2_sum_v7) v2
+ from
+ (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1
group by k1, k2, k3, k4, k5) as ta1
+ inner join
+ (select k1, k2, k3, k4, k5, sum(v7) t2_sum_v7 from preagg_t2
group by k1, k2, k3, k4, k5) as ta2
+ on ta1.k3 = ta2.k3
+ group by k1, k2, k3, k4
+ ) t12 inner join preagg_t3 on t12.k1 = preagg_t3.k1
+ group by preagg_t3.k2, t12.k2
+ order by 1, 2;
+ """
explain {
sql("""
@@ -108,6 +252,21 @@ suite("set_preagg") {
""")
notContains "PREAGGREGATION: OFF"
}
+ order_qt_q02 """
+ select preagg_t3.k2, t12.k2, max(preagg_t3.v9)
+ from
+ (
+ select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4,
max(ta1.t1_sum_v7) v1, sum(ta2.t2_sum_v7) v2
+ from
+ (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1
group by k1, k2, k3, k4, k5) as ta1
+ inner join
+ (select k1, k2, k3, k4, k5, sum(v7) t2_sum_v7 from preagg_t2
group by k1, k2, k3, k4, k5) as ta2
+ on ta1.k3 = ta2.k3
+ group by k1, k2, k3, k4
+ ) t12 inner join preagg_t3 on t12.k1 = preagg_t3.k1
+ group by preagg_t3.k2, t12.k2
+ order by 1, 2;
+ """
explain {
sql("""
@@ -129,6 +288,21 @@ suite("set_preagg") {
contains "(preagg_t2), PREAGGREGATION: ON"
contains "(preagg_t3), PREAGGREGATION: OFF. Reason: can't turn preAgg
on because aggregate function sum"
}
+ order_qt_q03 """
+ select preagg_t3.k2, t12.k2, max(t12.v2), max(preagg_t3.v9),
sum(t12.v3)
+ from
+ (
+ select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4,
max(ta1.t1_sum_v7) v1, max(ta2.k4) v2, count(distinct ta2.k5) v3
+ from
+ (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1
group by k1, k2, k3, k4, k5) as ta1
+ inner join
+ (select k1, k2, k3, k4, k5, v7 from preagg_t2) as ta2
+ on ta1.k3 = ta2.k3
+ group by k1, k2, k3, k4
+ ) t12 inner join preagg_t3 on t12.k1 = preagg_t3.k1
+ group by preagg_t3.k2, t12.k2
+ order by 1, 2;
+ """
explain {
sql("""
@@ -150,6 +324,21 @@ suite("set_preagg") {
contains "(preagg_t2), PREAGGREGATION: OFF. Reason: max(v7) is not
match agg mode SUM"
contains "(preagg_t3), PREAGGREGATION: OFF. Reason: can't turn preAgg
on because aggregate function sum"
}
+ order_qt_q04 """
+ select preagg_t3.k2, t12.k2, sum(t12.v2), max(preagg_t3.v9)
+ from
+ (
+ select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4,
max(ta1.t1_sum_v7) v1, max(ta2.v7) v2
+ from
+ (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1
group by k1, k2, k3, k4, k5) as ta1
+ inner join
+ (select k1, k2, k3, k4, k5, v7 from preagg_t2) as ta2
+ on ta1.k3 = ta2.k3
+ group by k1, k2, k3, k4
+ ) t12 inner join preagg_t3 on t12.k1 = preagg_t3.k1
+ group by preagg_t3.k2, t12.k2
+ order by 1, 2;
+ """
explain {
sql("""
@@ -192,6 +381,21 @@ suite("set_preagg") {
contains "(preagg_t2), PREAGGREGATION: ON"
contains "(preagg_t3), PREAGGREGATION: OFF. Reason: can't turn preAgg
on because aggregate function sum"
}
+ order_qt_q05 """
+ select preagg_t3.k2, t12.k2, max(t12.v2), max(preagg_t3.v9),
sum(t12.v3)
+ from
+ (
+ select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4, max(case when
ta2.k1 > 0 then ta2.v9 when ta2.k1 = 0 then null when ta2.k1 < 0 then ta2.v9
else null end) v2, count(distinct ta2.k5) v3
+ from
+ (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1
group by k1, k2, k3, k4, k5) as ta1
+ inner join
+ (select k1, k2, k3, k4, k5, v7, v8, v9 from preagg_t2) as ta2
+ on ta1.k3 = ta2.k3
+ group by k1, k2, k3, k4
+ ) t12 inner join preagg_t3 on t12.k1 = preagg_t3.k1
+ group by preagg_t3.k2, t12.k2
+ order by 1, 2;
+ """
explain {
sql("""
@@ -213,6 +417,21 @@ suite("set_preagg") {
contains "(preagg_t2), PREAGGREGATION: ON"
contains "(preagg_t3), PREAGGREGATION: OFF. Reason: can't turn preAgg
on because aggregate function sum"
}
+ order_qt_q06 """
+ select preagg_t3.k2, t12.k2, max(t12.v2), max(preagg_t3.v9),
sum(t12.v3)
+ from
+ (
+ select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4, sum(case when
ta2.k1 > 0 then ta2.v7 when ta2.k1 = 0 then 0 when ta2.k1 < 0 then ta2.v8 else
0 end) v2, count(distinct ta2.k5) v3
+ from
+ (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1
group by k1, k2, k3, k4, k5) as ta1
+ inner join
+ (select k1, k2, k3, k4, k5, v7, v8 from preagg_t2) as ta2
+ on ta1.k3 = ta2.k3
+ group by k1, k2, k3, k4
+ ) t12 inner join preagg_t3 on t12.k1 = preagg_t3.k1
+ group by preagg_t3.k2, t12.k2
+ order by 1, 2;
+ """
explain {
sql("""
@@ -232,7 +451,21 @@ suite("set_preagg") {
""")
notContains "PREAGGREGATION: OFF"
}
-
+ order_qt_q07 """
+ select preagg_t3.k2, t12.k2, sum(t12.v2), max(preagg_t3.v9)
+ from
+ (
+ select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4,
max(ta1.t1_sum_v7) v1, sum(ta2.v7) v2
+ from
+ (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1
group by k1, k2, k3, k4, k5) as ta1
+ inner join
+ (select k1, k2, k3, k4, k5, v7 from preagg_t2) as ta2
+ on ta1.k3 = ta2.k3
+ group by k1, k2, k3, k4
+ ) t12 inner join preagg_t3 on t12.k1 = preagg_t3.k1
+ group by preagg_t3.k2, t12.k2
+ order by 1, 2;
+ """
explain {
sql("""
select preagg_t3.k2, t12.k2, max(t12.v2), max(preagg_t3.v9),
sum(t12.v3)
@@ -250,9 +483,24 @@ suite("set_preagg") {
order by 1, 2;
""")
contains "(preagg_t1), PREAGGREGATION: ON"
- contains "(preagg_t2), PREAGGREGATION: OFF. Reason: count("
+ contains "(preagg_t2), PREAGGREGATION: OFF. Reason: count"
contains "(preagg_t3), PREAGGREGATION: OFF. Reason: can't turn preAgg
on because aggregate function sum"
}
+ order_qt_q08 """
+ select preagg_t3.k2, t12.k2, max(t12.v2), max(preagg_t3.v9),
sum(t12.v3)
+ from
+ (
+ select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4,
max(ta1.t1_sum_v7) v1, count(case when ta2.k1 > 0 then ta2.v7 when ta2.k1 = 0
then 0 when ta1.k1 < 0 then ta2.v8 else 0 end) v2, sum(ta2.v7) v3
+ from
+ (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1
group by k1, k2, k3, k4, k5) as ta1
+ left join
+ (select k1, k2, k3, k4, k5, v7, v8 from preagg_t2) as ta2
+ on ta1.k3 = ta2.k3
+ group by k1, k2, k3, k4
+ ) t12 inner join preagg_t3 on t12.k1 = preagg_t3.k1
+ group by preagg_t3.k2, t12.k2
+ order by 1, 2;
+ """
explain {
sql("""
@@ -273,6 +521,20 @@ suite("set_preagg") {
contains "(preagg_t2), PREAGGREGATION: ON"
contains "(preagg_t3), PREAGGREGATION: ON"
}
+ order_qt_q09 """
+ select preagg_t3.k2, t12.k2, max(t12.v2), max(preagg_t3.v9),
count(distinct t12.v3), count(distinct t12.k4) v3
+ from
+ (
+ select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4, ta1.t1_sum_v7
v1, ta2.v9 v2, ta2.k5 v3
+ from
+ (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1
group by k1, k2, k3, k4, k5) as ta1
+ inner join
+ (select k1, k2, k3, k4, k5, v9 from preagg_t2) as ta2
+ on ta1.k3 = ta2.k3
+ ) t12 right join preagg_t3 on t12.k1 = preagg_t3.k1
+ group by preagg_t3.k2, t12.k2
+ order by 1, 2;
+ """
explain {
sql("""
@@ -293,6 +555,20 @@ suite("set_preagg") {
contains "(preagg_t2), PREAGGREGATION: ON"
contains "(preagg_t3), PREAGGREGATION: ON"
}
+ order_qt_q10 """
+ select preagg_t3.k2, t12.k2, max(preagg_t3.v9), count(distinct
t12.v3), count(distinct t12.k4) v3
+ from
+ (
+ select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4, ta1.t1_sum_v7
v1, ta1.k5 v3
+ from
+ (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1
group by k1, k2, k3, k4, k5) as ta1
+ inner join
+ (select k1, k2, k3, k4, k5, v9 from preagg_t2) as ta2
+ on ta1.k3 = ta2.k3
+ ) t12 right join preagg_t3 on t12.k1 = preagg_t3.k1
+ group by preagg_t3.k2, t12.k2
+ order by 1, 2;
+ """
explain {
sql("""
@@ -313,6 +589,20 @@ suite("set_preagg") {
contains "(preagg_t2), PREAGGREGATION: OFF. Reason: can't turn preAgg
on because aggregate function sum"
contains "(preagg_t3), PREAGGREGATION: OFF"
}
+ order_qt_q11 """
+ select preagg_t3.k2, t12.k2, sum(t12.v1), max(preagg_t3.v9),
count(distinct t12.v3), count(distinct t12.k4) v3
+ from
+ (
+ select ta1.k1 k1, ta1.k2 k2, ta2.k1 k3, ta2.k2 k4, ta1.t1_sum_v7
v1, ta1.k5 v3
+ from
+ (select k1, k2, k3, k4, k5, sum(v7) t1_sum_v7 from preagg_t1
group by k1, k2, k3, k4, k5) as ta1
+ inner join
+ (select k1, k2, k3, k4, k5, v9 from preagg_t2) as ta2
+ on ta1.k3 = ta2.k3
+ ) t12 right join preagg_t3 on t12.k1 = preagg_t3.k1
+ group by preagg_t3.k2, t12.k2
+ order by 1, 2;
+ """
explain {
sql("""
@@ -328,6 +618,17 @@ suite("set_preagg") {
contains "(preagg_t1), PREAGGREGATION: OFF. Reason: No valid aggregate
on scan."
contains "(preagg_t1), PREAGGREGATION: ON"
}
+ order_qt_q12 """
+ select cw.k1, cw.k2, cw.v7, cw.v9
+ from preagg_t1 cw
+ inner join (
+ select k1, k2, max(v9) as v9
+ from preagg_t1
+ where k1 in (1, 2)
+ group by k1, k2
+ ) mw on cw.k1 = mw.k1 and cw.v9 = mw.v9
+ order by 1, 2, 3, 4;
+ """
// Aggregate over limited subquery: Limit between aggregate and scan goes
// through generic visitor path, which should block preagg collection
@@ -349,4 +650,586 @@ suite("set_preagg") {
select count(*) from (select * from numbers("number"="10")) t;
""")
}
+ order_qt_q13 """
+ select count(*) from (select * from numbers("number"="10")) t;
+ """
+
+ explain {
+ sql("""select count(distinct k6, v7) from preagg_t1;""")
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+ order_qt_q14 """
+ select count(distinct k6, v7) from preagg_t1;
+ """
+
+ explain {
+ sql("""select count(distinct k6, k5) from preagg_t1;""")
+ contains "(preagg_t1), PREAGGREGATION: ON"
+ }
+ order_qt_q15 """
+ select count(distinct k6, k5) from preagg_t1;
+ """
+
+ // Negative: count(DISTINCT IF(...), value_col) —
checkAggWithKeyAndValueSlots
+ // only inspects child(0) (the IF). Without a multi-arg guard, it would
+ // miss v7 in child(1) and incorrectly return ON.
+ explain {
+ sql("""
+ select count(distinct if(k6 > 0, k5, 0), v7) from preagg_t1;
+ """)
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+ order_qt_q16 """
+ select count(distinct if(k6 > 0, k5, 0), v7) from preagg_t1;
+ """
+
+ explain {
+ sql("""
+ select count(distinct case when k6 > 0 then k5 else 0 end, v7)
from preagg_t1;
+ """)
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+ order_qt_q17 """
+ select count(distinct case when k6 > 0 then k5 else 0 end, v7) from
preagg_t1;
+ """
+
+ // Negative: count(DISTINCT key + random()) — volatile in the expression
+ // argument. With pre-agg ON, random() would be evaluated per partial row
+ // instead of per merged logical row, changing the distinct count.
+ explain {
+ sql("""select count(distinct k6 + random()) from preagg_t1;""")
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+
+ // max/min(key + random()) have the same volatile concern.
+ explain {
+ sql("""select max(k6 + random()) from preagg_t1;""")
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+
+ explain {
+ sql("""select min(k6 + random()) from preagg_t1;""")
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+
+ // Volatile in an IF condition inside a mixed-key-value aggregate.
+ // The condition k6 + random() > 0 uses only key input slots but is
+ // volatile, so pre-agg must be OFF.
+ explain {
+ sql("""
+ select sum(if(k6 + random() > 0, v7, 0)) from preagg_t1;
+ """)
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+
+ // Positive: two-project join where both aliases resolve to key-only
+ // expressions. With merge+resolve, x = k5 + 1 resolves fully to base
+ // key columns, so pre-agg can be ON for both scans.
+ explain {
+ sql("""
+ select count(distinct a)
+ from (
+ select l.k1 + l.x as a
+ from (
+ select t1.k1, t1.k5 + 1 as x from preagg_t1 t1
+ ) l
+ inner join (
+ select abs(t2.k1) as rk from preagg_t2 t2
+ ) r on l.k1 = r.rk
+ ) t;
+ """)
+ contains "(preagg_t1), PREAGGREGATION: ON"
+ contains "(preagg_t2), PREAGGREGATION: ON"
+ }
+ order_qt_q18 """
+ select count(distinct a)
+ from (
+ select l.k1 + l.x as a
+ from (
+ select t1.k1, t1.k5 + 1 as x from preagg_t1 t1
+ ) l
+ inner join (
+ select abs(t2.k1) as rk from preagg_t2 t2
+ ) r on l.k1 = r.rk
+ ) t;
+ """
+
+ // Negative: two-project join; x carries v7 + 1 (a value column). Even
+ // with merge+resolve, the fully resolved expression contains v7 so
+ // pre-agg is correctly OFF.
+ explain {
+ sql("""
+ select count(distinct a)
+ from (
+ select l.k1 + l.x as a
+ from (
+ select t1.k1, t1.v7 + 1 as x from preagg_t1 t1
+ ) l
+ inner join (
+ select abs(t2.k1) as rk from preagg_t2 t2
+ ) r on l.k1 = r.rk
+ ) t;
+ """)
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+ order_qt_q19 """
+ select count(distinct a)
+ from (
+ select l.k1 + l.x as a
+ from (
+ select t1.k1, t1.v7 + 1 as x from preagg_t1 t1
+ ) l
+ inner join (
+ select abs(t2.k1) as rk from preagg_t2 t2
+ ) r on l.k1 = r.rk
+ ) t;
+ """
+
+ // Bypass 1: volatile in an other-table aggregate function. max(r.k1 +
random())
+ // is whitelisted as a duplicate-insensitive MAX for scan l; the candidate
set
+ // for l is empty, so without a central volatile check it returns ON before
+ // reaching the per-function guard. Both scans must be OFF.
+ explain {
+ sql("""
+ select max(r.k1 + random())
+ from preagg_t1 l
+ inner join preagg_t2 r on l.k1 = r.k1;
+ """)
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ notContains "(preagg_t2), PREAGGREGATION: ON"
+ }
+
+ // Bypass 2: volatile filter with no input slots. random() < 0.5 has an
+ // empty input-slot set, so the slot-based value-column check bypasses it.
+ // The central volatile guard must reject pre-agg on this scan.
+ explain {
+ sql("""
+ select sum(v7) from preagg_t1 where random() < 0.5;
+ """)
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+
+ // Foreign value column in mixed aggregate: sum(if(abs(l.k1) > 0, r.v7, 0))
+ // references l.k1 (local key) and r.v7 (foreign value). The mixed helper
+ // must not use r.v7's SUM type to justify pre-agg on l — r.v7 is not
+ // a column of l. l must be OFF; r can be ON.
+ explain {
+ sql("""
+ select sum(if(t.a > 0, r.v7, 0))
+ from (select abs(k1) as a from preagg_t1) t
+ inner join preagg_t2 r on t.a = r.k1;
+ """)
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ // r's local slots are only {r.v7} with IF; the value-only IF/CaseWhen
+ // handler validates conditions (foreign key t.a → safe) and returns
ON.
+ contains "(preagg_t2), PREAGGREGATION: ON"
+ }
+
+ // CASE WHEN symmetry of the foreign-key-condition / local-value-return
+ // pattern on r: conditions reference only foreign keys, return references
+ // r.v7, so r should be ON.
+ explain {
+ sql("""
+ select sum(case when t.a > 0 then r.v7 else 0 end)
+ from (select abs(k1) as a from preagg_t1) t
+ inner join preagg_t2 r on t.a = r.k1;
+ """)
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ contains "(preagg_t2), PREAGGREGATION: ON"
+ }
+ order_qt_q20 """
+ select sum(case when t.a > 0 then r.v7 else 0 end)
+ from (select abs(k1) as a from preagg_t1) t
+ inner join preagg_t2 r on t.a = r.k1;
+ """
+
+ // Negative: max(cast(v9 as double)) — no cast is peeled for MAX/MIN.
+ // DOUBLE/DECIMAL→FLOAT can underflow to -0.0 and change the observable
+ // tie representative (signed zero) under MAX/MIN, so even nondecreasing
+ // casts are not MAX/MIN homomorphisms. The checker sees a Cast and
+ // returns OFF.
+ explain {
+ sql("""select max(cast(v9 as double)) from preagg_t1;""")
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+ order_qt_q21 """select max(cast(v9 as double)) from preagg_t1;"""
+
+ // Negative: sum(cast(v7 as double)) — sum(cast(x)) and cast(sum(x)) are
+ // not interchangeable due to precision/overflow, so cast must NOT be
+ // unwrapped. OneValueSlotAggChecker sees a Cast, not a SlotReference,
+ // and correctly returns OFF.
+ explain {
+ sql("""select sum(cast(v7 as double)) from preagg_t1;""")
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+ order_qt_q22 """select sum(cast(v7 as double)) from preagg_t1;"""
+
+ // Negative: max(cast(v9 as string)) — non-numeric cast is never safe
+ // for MAX/MIN because string comparison differs from numeric comparison.
+ explain {
+ sql("""select max(cast(v9 as string)) from preagg_t1;""")
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+ order_qt_q23 """select max(cast(v9 as string)) from preagg_t1;"""
+
+ // Negative mixed-path IF with cast in return: max(if(k6 > 0, cast(v9 as
double), 0))
+ // — no cast is peeled for MAX/MIN, so the IF return stays wrapped in Cast
+ // and the checker returns OFF.
+ explain {
+ sql("""
+ select max(if(k6 > 0, cast(v9 as double), 0)) from preagg_t1;
+ """)
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+ order_qt_q24 """
+ select max(if(k6 > 0, cast(v9 as double), 0)) from preagg_t1;
+ """
+
+ // Negative mixed-path IF with cast in return:
+ // sum(if(k6 > 0, cast(v7 as double), 0)) — sum(cast(x)) is not
+ // interchangeable with cast(sum(x)), so the guard keeps the Cast
+ // wrapper and the checker returns OFF.
+ explain {
+ sql("""
+ select sum(if(k6 > 0, cast(v7 as double), 0)) from preagg_t1;
+ """)
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+ order_qt_q25 """
+ select sum(if(k6 > 0, cast(v7 as double), 0)) from preagg_t1;
+ """
+
+ // --- Cast regression tests ---
+ // No cast is peeled for MAX/MIN: DOUBLE/DECIMAL→FLOAT can underflow to
+ // -0.0 and change the observable tie representative (signed zero) under
+ // MAX/MIN, so even nondecreasing casts are not MAX/MIN homomorphisms.
+ // Any cast-wrapped aggregate is therefore conservatively OFF.
+
+ // Negative: BIGINT→DECIMAL(20,0) widening cast → OFF (no peeling).
+ explain {
+ sql("""select max(cast(v9 as decimal(20,0))) from preagg_t1;""")
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+ order_qt_q26 """select max(cast(v9 as decimal(20,0))) from preagg_t1;"""
+
+ // Negative: BIGINT→LARGEINT widening cast → OFF (no peeling).
+ explain {
+ sql("""select max(cast(v9 as largeint)) from preagg_t1;""")
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+ order_qt_q27 """select max(cast(v9 as largeint)) from preagg_t1;"""
+
+ // Negative: BIGINT→INT is narrowing (not injective, not float) → OFF.
+ explain {
+ sql("""select max(cast(v9 as int)) from preagg_t1;""")
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+ order_qt_q28 """select max(cast(v9 as int)) from preagg_t1;"""
+
+ // Negative: BIGINT→TINYINT is narrowing (not injective, not float) → OFF.
+ explain {
+ sql("""select max(cast(v9 as tinyint)) from preagg_t1;""")
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+ order_qt_q29 """select max(cast(v9 as tinyint)) from preagg_t1;"""
+
+ // Negative mixed-path IF with widening cast: no peeling → OFF.
+ explain {
+ sql("""
+ select max(if(k6 > 0, cast(v9 as decimal(20,0)), 0)) from
preagg_t1;
+ """)
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+ order_qt_q30 """
+ select max(if(k6 > 0, cast(v9 as decimal(20,0)), 0)) from preagg_t1;
+ """
+
+ // Mixed-path IF with unsafe cast: max(if(..., cast(v9 as tinyint), 0))
+ // return cast is narrowing non-injective → not peeled → checker OFF.
+ explain {
+ sql("""
+ select max(if(k6 > 0, cast(v9 as tinyint), 0)) from preagg_t1;
+ """)
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+ order_qt_q31 """
+ select max(if(k6 > 0, cast(v9 as tinyint), 0)) from preagg_t1;
+ """
+
+ // Negative: CASE WHEN with widening cast → OFF (no peeling).
+ explain {
+ sql("""
+ select max(case when k6 > 0 then cast(v9 as decimal(20,0)) else 0
end) from preagg_t1;
+ """)
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+ order_qt_q32 """
+ select max(case when k6 > 0 then cast(v9 as decimal(20,0)) else 0 end)
from preagg_t1;
+ """
+
+ // CASE WHEN with unsafe narrowing cast → OFF.
+ explain {
+ sql("""
+ select max(case when k6 > 0 then cast(v9 as tinyint) else 0 end)
from preagg_t1;
+ """)
+ notContains "(preagg_t1), PREAGGREGATION: ON"
+ }
+ order_qt_q33 """
+ select max(case when k6 > 0 then cast(v9 as tinyint) else 0 end) from
preagg_t1;
+ """
+
+ //
-------------------------------------------------------------------------
+ // Two-table derived-key ownership tests (with repeated aggregate-key data)
+ //
+ // The mixed helper must validate returns relative to the current scan: a
+ // foreign value column must never justify exposing this scan's partial
+ // (unmerged) rows. Under join fan-out, a return that references a foreign
+ // value column would be evaluated once per partial row and double-counted.
+ //
-------------------------------------------------------------------------
+
+ // Test A: derived-key fan-out with repeated full aggregate keys.
preagg_own_l
+ // loads k1=1 twice (v7=10,20) and k1=0 twice (v7=5,7) in separate
rowsets, so
+ // if preagg_own_l were wrongly ON, its duplicate full-key partial rows
would
+ // fan out under the join and double-count the foreign r.v7 in the IF
return.
+ // Correct (l OFF, merged): t has a=1 (k1=1 merged v7=30) and a=0 (k1=0
merged
+ // v7=12). r is legitimately ON, exposing its own partials k1=1 {100,200}
and
+ // k1=0 {50,80}: a=1 joins both → 100+200=300; a=0 joins both but
if(0>0,...)=0
+ // → total 300. If l were wrongly ON, a=1 would appear twice and join both
r
+ // partials → 300*2=600, breaking the oracle.
+ explain {
+ sql("""
+ select sum(if(t.a > 0, r.v7, 0))
+ from (select abs(k1) as a from preagg_own_l) t
+ inner join preagg_own_r r on t.a = r.k1;
+ """)
+ notContains "(preagg_own_l), PREAGGREGATION: ON"
+ contains "(preagg_own_r), PREAGGREGATION: ON"
+ }
+ order_qt_test_a """
+ select sum(if(t.a > 0, r.v7, 0)) as res
+ from (select abs(k1) as a from preagg_own_l) t
+ inner join preagg_own_r r on t.a = r.k1;
+ """
+
+ // Test B: foreign value in the IF return on BOTH sides — ownership check
must
+ // turn both scans OFF:
+ // sum(if(t.a > 0, t.v7, r.v7))
+ // - for scan l: return r.v7 is foreign → l OFF
+ // - for scan r: return t.v7 is foreign → r OFF
+ // preagg_own_l's k1=0 row gives t.a = 0, so the ELSE branch (r.v7) is
actually
+ // evaluated, and both tables carry repeated full keys so a wrongly-ON scan
+ // exposes partial rows and changes the oracle. Correct (both OFF, merged):
+ // l = {a=1(v7=30), a=0(v7=12)}, r = {k1=1→300, k1=0→130};
+ // a=1 → if(1>0,30,300)=30, a=0 → if(0>0,12,130)=130 → total 160.
+ // If l were wrongly ON: a=1 and a=0 each appear twice → 10+20+130+130=290.
+ // If r were wrongly ON: r partials fan out → 30+30+50+80=190.
+ explain {
+ sql("""
+ select sum(if(t.a > 0, t.v7, r.v7))
+ from (select abs(k1) as a, v7 from preagg_own_l) t
+ inner join preagg_own_r r on t.a = r.k1;
+ """)
+ notContains "(preagg_own_l), PREAGGREGATION: ON"
+ notContains "(preagg_own_r), PREAGGREGATION: ON"
+ }
+ order_qt_test_b """
+ select sum(if(t.a > 0, t.v7, r.v7)) as res
+ from (select abs(k1) as a, v7 from preagg_own_l) t
+ inner join preagg_own_r r on t.a = r.k1;
+ """
+
+ // Positive MAX join case: MAX is idempotent (max(x, x) = x), so a foreign
+ // value branch in the IF return cannot change the result even under join
+ // fan-out. The ownership fence is skipped for MAX/MIN (it stays for
+ // SUM/COUNT), so both scans may be ON. With the one-time dataset:
+ // l.k1=1 (v9 1000,900) and l.k1=2 (v9 700) all satisfy l.k1 > 0, so
+ // max(if(l.k1 > 0, l.v9, r.v9)) = 1000.
+ explain {
+ sql("""
+ select max(if(l.k1 > 0, l.v9, r.v9))
+ from preagg_t1 l
+ inner join preagg_t2 r on l.k1 = r.k1;
+ """)
+ contains "(preagg_t1), PREAGGREGATION: ON"
+ contains "(preagg_t2), PREAGGREGATION: ON"
+ }
+ order_qt_q34 """
+ select max(if(l.k1 > 0, l.v9, r.v9))
+ from preagg_t1 l
+ inner join preagg_t2 r on l.k1 = r.k1;
+ """
+
+ // Negative: sum(DISTINCT ...) with a nested-aggregate condition slot.
+ // sum(distinct if(t.c > 0, r.v7, 0))
+ // t.c = sum(t.v7) has no OriginalColumn, so splitKeyValueSlots drops it
from
+ // the condition check (it is unclassified). Previously this let the route
+ // reach visitSum, which did NOT reject DISTINCT, so preagg_t4 was wrongly
+ // turned ON. Storage SUM would then merge the duplicate full key
(v7=1+1=2)
+ // and break DISTINCT semantics: ON sees {1,1} → 1 while OFF sees merged 2.
+ // KeyAndValueSlotsAggChecker.visitSum must reject sum.isDistinct() exactly
+ // like OneValueSlotAggChecker.visitSum does.
+ explain {
+ sql("""
+ select sum(distinct if(t.c > 0, r.v7, 0))
+ from (select k1, sum(v7) as c from preagg_t1 group by k1) t
+ inner join preagg_t4 r on t.k1 = r.k1;
+ """)
+ contains "(preagg_t1), PREAGGREGATION: ON"
+ notContains "(preagg_t4), PREAGGREGATION: ON"
+ }
+ order_qt_q35 """
+ select sum(distinct if(t.c > 0, r.v7, 0))
+ from (select k1, sum(v7) as c from preagg_t1 group by k1) t
+ inner join preagg_t4 r on t.k1 = r.k1;
+ """
+
+ // Negative signed-zero: signbit(max(if(k1 > 0, cast(v as float), cast(0
as float))))
+ // with v a DOUBLE MAX column. No cast is peeled for MAX/MIN: DOUBLE→FLOAT
can
+ // underflow (-1e-300 → FLOAT -0.0) and change the observable tie
representative
+ // under signbit, so preagg_t5 must stay OFF. preagg_t5 holds the same
full key
+ // k1=1 in two loads (v = -1e-300 and +0.0); storage MAX merges to +0.0,
so the
+ // merged result is signbit(+0.0) = 0.
+ explain {
+ sql("""
+ select signbit(max(if(k1 > 0, cast(v as float), cast(0 as
float)))) from preagg_t5;
+ """)
+ notContains "(preagg_t5), PREAGGREGATION: ON"
+ }
+ order_qt_q36 """
+ select signbit(max(if(k1 > 0, cast(v as float), cast(0 as float))))
from preagg_t5;
+ """
+
+ // Negative ASOF join selected-side: r.v7 is a direct correctly typed SUM
+ // column and ts is an aggregate key, so the match condition references
only
+ // key columns — the pre-existing join-value check cannot keep r OFF. Only
+ // the ASOF-specific fence (asofSelectedSideRelationIds) forces the
selected
+ // side OFF. preagg_asof_r loads the SAME full key (grp=1, ts=00:00) twice
+ // with v7=100 and v7=200 in separate rowsets; storage SUM merges to 300.
+ // With r OFF the probe l.ts=00:15 matches the merged row and returns 300.
+ // If r were wrongly ON, ASOF sees the two identical partials (ts=00:00
+ // both) and may pick either 100 or 200 — never 300 — so the oracle
+ // distinguishes a faulty ON from the correct merged result, and the
+ // ASOF-specific OFF reason pins the mechanism under test.
+ explain {
+ sql("""
+ select sum(if(l.grp > 0, r.v7, 0))
+ from preagg_asof_l l asof left join preagg_asof_r r
+ MATCH_CONDITION(l.ts >= r.ts) on l.grp = r.grp
+ where l.grp = 1;
+ """)
+ notContains "(preagg_asof_l), PREAGGREGATION: ON"
+ contains "(preagg_asof_r), PREAGGREGATION: OFF. Reason: can't turn
preAgg on because the scan is the selected side of an ASOF join"
+ }
+ order_qt_q37 """
+ select sum(if(l.grp > 0, r.v7, 0))
+ from preagg_asof_l l asof left join preagg_asof_r r
+ MATCH_CONDITION(l.ts >= r.ts) on l.grp = r.grp
+ where l.grp = 1;
+ """
+
+ // Positive MAX foreign-branch fanout: preagg_f_l has a non-positive key
+ // (k1=-1) so if(l.k1 > 0, l.v9, r.v9) actually evaluates the foreign
branch
+ // r.v9, and preagg_f_r repeats BOTH full keys across separate loads
+ // (k1=1: v9=1000,2000; k1=-1: v9=500,600), so PREAGG ON would expose
+ // duplicate full-key partial rows. MAX is idempotent, so the foreign value
+ // fanout cannot change the result: merged r is k1=1→2000, k1=-1→600;
+ // max(if(1>0, 100, ...)=100, if(-1>0, ..., 600)=600) = 600, and ON over
+ // partials gives max(100, 100, 500, 600) = 600 too. Both scans may be ON.
+ explain {
+ sql("""
+ select max(if(l.k1 > 0, l.v9, r.v9))
+ from preagg_f_l l inner join preagg_f_r r on l.k1 = r.k1;
+ """)
+ contains "(preagg_f_l), PREAGGREGATION: ON"
+ contains "(preagg_f_r), PREAGGREGATION: ON"
+ }
+ order_qt_q38 """
+ select max(if(l.k1 > 0, l.v9, r.v9))
+ from preagg_f_l l inner join preagg_f_r r on l.k1 = r.k1;
+ """
+
+ // MIN symmetry of the foreign-branch fanout, on the MIN column v9m:
+ // min(if(l.k1 > 0, l.v9m, r.v9m)). Merged r is k1=1→min(1000,2000)=1000,
+ // k1=-1→min(500,600)=500; min(100, 500) = 100, and ON over partials gives
+ // min(100, 100, 500, 600) = 100 too. Both scans ON.
+ explain {
+ sql("""
+ select min(if(l.k1 > 0, l.v9m, r.v9m))
+ from preagg_f_l l inner join preagg_f_r r on l.k1 = r.k1;
+ """)
+ contains "(preagg_f_l), PREAGGREGATION: ON"
+ contains "(preagg_f_r), PREAGGREGATION: ON"
+ }
+ order_qt_q39 """
+ select min(if(l.k1 > 0, l.v9m, r.v9m))
+ from preagg_f_l l inner join preagg_f_r r on l.k1 = r.k1;
+ """
+
+ // Retained non-movable project expressions (e.g. assert_true) must run on
+ // storage-merged rows. pruneOutputs deliberately keeps the unused
+ // assert_true(v7 > 0, 'bad') output; pre-agg ON would evaluate it on the
+ // raw partial row v7=-2 (preagg_g loads (1,-2,10) and (1,3,20) in separate
+ // rowsets) and throw InvalidArgument, while pre-agg OFF merges v7 to
+ // -2+3=1 and the assert passes, returning max(if(1>0, 20, 0)) = 20.
+ explain {
+ sql("""
+ select max(if(k1 > 0, v9, 0))
+ from (select k1, v9, assert_true(v7 > 0, 'bad') as checked from
preagg_g) t
+ """)
+ notContains "(preagg_g), PREAGGREGATION: ON"
+ }
+ order_qt_test_c """
+ select max(if(k1 > 0, v9, 0))
+ from (select k1, v9, assert_true(v7 > 0, 'bad') as checked from
preagg_g) t
+ """
+
+ // Slotless volatile retained non-movable outputs (e.g.
+ // assert_true(random() >= 0, 'bad')) have no input slots, so the
value-slot
+ // fence misses them, and the volatility checks only cover agg/filter/join/
+ // grouping expressions. Pre-agg ON would evaluate random() once per raw
+ // partial row (preagg_g loads (1,-2,10) and (1,3,20) in separate rowsets)
+ // instead of once per merged row — a different evaluation cardinality — so
+ // preagg_g must stay OFF. random() >= 0 always holds, so the assert never
+ // fires and the query still returns 20.
+ explain {
+ sql("""
+ select max(if(k1 > 0, v9, 0))
+ from (select k1, v9, assert_true(random() >= 0, 'bad') as checked
from preagg_g) t
+ """)
+ notContains "(preagg_g), PREAGGREGATION: ON"
+ }
+ order_qt_test_f """
+ select max(if(k1 > 0, v9, 0))
+ from (select k1, v9, assert_true(random() >= 0, 'bad') as checked from
preagg_g) t
+ """
+
+ // Exercise the literal acceptance in KeyAndValueSlotsAggChecker.visitMax:
+ // max(if(k6 > 0, v9, 0)) has a cast-free non-NULL literal (0) in the else
+ // branch. It reaches the checker with pre-agg ON — k6 is a key column, v9
a
+ // MAX column, the literal 0 is accepted. If literal acceptance regressed
to
+ // NULL-only, this scan would flip OFF and the suite would miss it.
+ explain {
+ sql("""
+ select max(if(k6 > 0, v9, 0)) from preagg_t1;
+ """)
+ contains "(preagg_t1), PREAGGREGATION: ON"
+ }
+ order_qt_test_d """
+ select max(if(k6 > 0, v9, 0)) from preagg_t1;
+ """
+
+ // MIN/CASE symmetry for the parallel changed branch (visitMin): the CASE
+ // else branch holds a cast-free non-NULL literal 0, and preagg_f_l's
+ // non-positive key k1=-1 makes the else branch actually evaluate. pre-agg
+ // stays ON via the same literal acceptance.
+ explain {
+ sql("""
+ select min(case when k1 > 0 then v9m else 0 end) from preagg_f_l;
+ """)
+ contains "(preagg_f_l), PREAGGREGATION: ON"
+ }
+ order_qt_test_e """
+ select min(case when k1 > 0 then v9m else 0 end) from preagg_f_l;
+ """
+
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]