From ac1c83f512d7aaa5eb51146099bb8f858dffd74d Mon Sep 17 00:00:00 2001 From: lichi Date: Wed, 26 Aug 2026 14:20:54 +0800 Subject: [PATCH 1/3] [fix](fe) Fix correlated outer column in QUALIFY being mis-handled after GROUP BY / over project fix comments fix comments fix fix comment fix comment fix comment fix comment fix compile fix compile --- .../analysis/FillUpQualifyMissingSlot.java | 422 +++++++++-- .../rules/rewrite/PushProjectIntoUnion.java | 9 + .../rewrite/PushProjectThroughUnion.java | 10 + .../FillUpQualifyMissingSlotTest.java | 228 ++++++ .../window_functions/test_qualify_query.out | 51 ++ .../test_qualify_query.groovy | 702 ++++++++++++++++++ 6 files changed, 1378 insertions(+), 44 deletions(-) create mode 100644 fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/FillUpQualifyMissingSlotTest.java diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/FillUpQualifyMissingSlot.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/FillUpQualifyMissingSlot.java index b157622d642999..acc73959f6c230 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/FillUpQualifyMissingSlot.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/FillUpQualifyMissingSlot.java @@ -17,6 +17,7 @@ package org.apache.doris.nereids.rules.analysis; +import org.apache.doris.nereids.analyzer.Scope; import org.apache.doris.nereids.exceptions.AnalysisException; import org.apache.doris.nereids.rules.Rule; import org.apache.doris.nereids.rules.RuleType; @@ -25,6 +26,7 @@ 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.SubqueryExpr; import org.apache.doris.nereids.trees.expressions.WindowExpression; import org.apache.doris.nereids.trees.expressions.visitor.DefaultExpressionRewriter; import org.apache.doris.nereids.trees.expressions.visitor.DefaultExpressionVisitor; @@ -38,11 +40,15 @@ import org.apache.doris.nereids.util.ExpressionUtils; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.Lists; +import com.google.common.collect.Maps; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; @@ -100,29 +106,42 @@ public List buildRules() { */ RuleType.FILL_UP_QUALIFY_PROJECT.build( logicalQualify(logicalProject()) - .then(qualify -> { + .thenApply(ctx -> { + LogicalQualify> qualify = ctx.root; checkWindow(qualify); + Optional outerScope = ctx.cascadesContext.getOuterScope(); LogicalProject project = qualify.child(); - return createPlan(project, qualify.getConjuncts(), (newConjuncts, projects) -> { - LogicalProject bottomProject = new LogicalProject<>(projects, project.child()); - LogicalQualify logicalQualify = new LogicalQualify<>(newConjuncts, bottomProject); - ImmutableList copyOutput = ImmutableList.copyOf(project.getOutput()); - return new LogicalProject<>(copyOutput, project.isDistinct(), logicalQualify); - }); + return createPlan(project, qualify.getConjuncts(), ImmutableSet.of(), outerScope, + (newConjuncts, newHavingConjuncts, projects) -> { + LogicalProject bottomProject = + new LogicalProject<>(projects, project.child()); + LogicalQualify logicalQualify = + new LogicalQualify<>(newConjuncts, bottomProject); + ImmutableList copyOutput = + ImmutableList.copyOf(project.getOutput()); + return new LogicalProject<>(copyOutput, project.isDistinct(), logicalQualify); + }); }) ), /* qualify -> agg */ RuleType.FILL_UP_QUALIFY_AGGREGATE.build( - logicalQualify(aggregate()).then(qualify -> { + logicalQualify(aggregate()).thenApply(ctx -> { + LogicalQualify> qualify = ctx.root; checkWindow(qualify); Aggregate agg = qualify.child(); - Resolver resolver = new Resolver(agg); - qualify.getConjuncts().forEach(expr -> resolver.resolve(expr, ResolvePlanType.QUALIFY)); + Optional outerScope = ctx.cascadesContext.getOuterScope(); + // resolve aggregate-output aliases that only depend on outer correlated slots, + // so the outer dependency is visible to subquery unnesting. The alias itself is + // kept in the aggregate output because it may be referenced by the select list. + Set qualifyConjuncts = resolveCorrelatedAggregateOutputAlias( + qualify.getConjuncts(), ImmutableSet.of(), agg.getOutputExpressions(), outerScope); + Resolver resolver = new Resolver(agg, outerScope); + qualifyConjuncts.forEach(expr -> resolver.resolve(expr, ResolvePlanType.QUALIFY)); return createPlan(resolver, agg, (r, a) -> { Set newConjuncts = ExpressionUtils.replace( - qualify.getConjuncts(), r.getSubstitution()); + qualifyConjuncts, r.getSubstitution()); boolean notChanged = newConjuncts.equals(qualify.getConjuncts()); if (notChanged && a.equals(agg)) { return null; @@ -135,21 +154,71 @@ public List buildRules() { qualify -> having -> agg */ RuleType.FILL_UP_QUALIFY_HAVING_AGGREGATE.build( - logicalQualify(logicalHaving(aggregate())).then(qualify -> { + logicalQualify(logicalHaving(aggregate())).thenApply(ctx -> { + LogicalQualify>> qualify = ctx.root; checkWindow(qualify); LogicalHaving> having = qualify.child(); Aggregate agg = qualify.child().child(); - Resolver resolver = new Resolver(agg); - qualify.getConjuncts().forEach(expr -> resolver.resolve(expr, ResolvePlanType.QUALIFY)); + Optional outerScope = ctx.cascadesContext.getOuterScope(); + // The window expression in qualify will be extracted into a project above the + // having during NormalizeAggregate. A correlated predicate left in the having + // would then sit below the window project and be silently dropped during subquery + // unnesting. A having predicate that only depends on the outer row (no aggregate + // function and no subquery, all input slots correlated) is constant over the + // aggregate rows, so it is equivalent before and after the window and can safely + // be conjoined into the qualify to be decorrelated together. A correlated having + // predicate that depends on the aggregate result (or contains a subquery) must be + // evaluated on the aggregate rows below the window, which is not supported + // together with QUALIFY; reject it explicitly instead of silently dropping it or + // changing the evaluation order. + Set newHavingConjuncts = new LinkedHashSet<>(); + Set qualifyConjuncts = new LinkedHashSet<>(qualify.getConjuncts()); + if (outerScope.isPresent()) { + Set correlatedSlots = outerScope.get().getCorrelatedSlots(); + for (Expression conjunct : having.getConjuncts()) { + Set inputSlots = conjunct.getInputSlots(); + if (inputSlots.isEmpty()) { + newHavingConjuncts.add(conjunct); + } else if (correlatedSlots.containsAll(inputSlots) + && !ExpressionUtils.hasNonWindowAggregateFunction(conjunct) + && !conjunct.containsType(SubqueryExpr.class) + // a volatile predicate (e.g. `random() < 0.5`) is not constant over + // the aggregate rows, so moving it above the window would change + // how many times it is evaluated; keep it below the window, or + // reject it if it is correlated (it would otherwise be dropped). + && !conjunct.containsVolatileExpression()) { + // the predicate only depends on the outer row, so it can safely be + // evaluated above the window project inside the qualify. + qualifyConjuncts.add(conjunct); + } else if (inputSlots.stream().anyMatch(correlatedSlots::contains)) { + // the predicate is correlated but depends on the aggregate result, a + // subquery, or a volatile expression, so it must be evaluated below + // the window; not supported. + throw new AnalysisException("Correlated predicate '" + conjunct.toSql() + + "' in HAVING depending on the aggregate result or containing " + + "a volatile expression is not supported together with QUALIFY"); + } else { + newHavingConjuncts.add(conjunct); + } + } + } else { + newHavingConjuncts.addAll(having.getConjuncts()); + } + Set resolvedQualifyConjuncts = resolveCorrelatedAggregateOutputAlias( + qualifyConjuncts, newHavingConjuncts, agg.getOutputExpressions(), outerScope); + Resolver resolver = new Resolver(agg, outerScope); + resolvedQualifyConjuncts.forEach(expr -> resolver.resolve(expr, ResolvePlanType.QUALIFY)); return createPlan(resolver, agg, (r, a) -> { Set newConjuncts = ExpressionUtils.replace( - qualify.getConjuncts(), r.getSubstitution()); - boolean notChanged = newConjuncts.equals(qualify.getConjuncts()); + resolvedQualifyConjuncts, r.getSubstitution()); + boolean notChanged = newConjuncts.equals(qualify.getConjuncts()) + && newHavingConjuncts.equals(having.getConjuncts()); if (notChanged && a.equals(agg)) { return null; } - return notChanged ? qualify.withChildren(having.withChildren(a)) : - new LogicalQualify<>(newConjuncts, having.withChildren(a)); + LogicalHaving newHaving = having.withConjuncts(newHavingConjuncts); + return notChanged ? qualify.withChildren(newHaving.withChildren(a)) : + new LogicalQualify<>(newConjuncts, newHaving.withChildren(a)); }); }) ), @@ -158,42 +227,216 @@ public List buildRules() { qualify -> having -> project(distinct) */ RuleType.FILL_UP_QUALIFY_HAVING_PROJECT.build( - logicalQualify(logicalHaving(logicalProject())).then(qualify -> { + logicalQualify(logicalHaving(logicalProject())).thenApply(ctx -> { + LogicalQualify>> qualify = ctx.root; checkWindow(qualify); + Optional outerScope = ctx.cascadesContext.getOuterScope(); LogicalHaving> having = qualify.child(); LogicalProject project = qualify.child().child(); - return createPlan(project, qualify.getConjuncts(), (newConjuncts, projects) -> { - ImmutableList copyOutput = ImmutableList.copyOf(project.getOutput()); - if (project.isDistinct()) { - Set missingSlots = having.getExpressions().stream() - .map(Expression::getInputSlots) - .flatMap(Set::stream) - .filter(s -> !projects.contains(s)) - .collect(Collectors.toSet()); - List output = ImmutableList.builder() - .addAll(projects).addAll(missingSlots).build(); - LogicalQualify> logicalQualify = - new LogicalQualify<>(newConjuncts, new LogicalProject<>(output, project.child())); - return having.withChildren(project.withProjects(copyOutput).withChildren(logicalQualify)); - } else { - return new LogicalProject<>(copyOutput, new LogicalQualify<>(newConjuncts, - having.withChildren(project.withProjects(projects)))); - } - }); + // The having conjuncts must take part in createPlan's alias classification too: + // an outer-only producer used through a SELECT alias consumed only by HAVING + // (e.g. `HAVING f = 1` where f = o.flag) would otherwise be left bound locally, + // and the window-bearing project would prevent later correlation extraction, + // leaving the outer slot dangling in the right subtree. + return createPlan(project, qualify.getConjuncts(), having.getConjuncts(), outerScope, + (newConjuncts, newHavingConjuncts, projects) -> { + ImmutableList copyOutput = ImmutableList.copyOf(project.getOutput()); + if (project.isDistinct()) { + // Keep correlated predicates that only depend on outer slots together with the + // having's own correlated predicates, on the same decorrelatable side of the + // distinct barrier, so subquery unnesting can collect and decorrelate them + // together (otherwise one of them is left dangling in the apply's right side). + // A predicate that is constant per outer row is equivalent before/after + // distinct, so moving it above the distinct project preserves semantics. + Set relocatedHavingConjuncts = + new LinkedHashSet<>(newHavingConjuncts); + Set distinctQualifyConjuncts = new LinkedHashSet<>(); + if (outerScope.isPresent()) { + Set correlatedSlots = outerScope.get().getCorrelatedSlots(); + for (Expression conjunct : newConjuncts) { + Set inputSlots = conjunct.getInputSlots(); + // Only relocate deterministic predicates: visible outer slots do + // not make a predicate constant (e.g. `o.flag <> 1 OR random() < 0.5` + // has only {o.flag} as input slots), and moving a volatile + // predicate changes its evaluation domain (per row before DISTINCT + // vs once after DISTINCT), so keep volatile predicates on their + // original side of the distinct barrier. + // A conjunct containing a subquery is never constant per outer row + // even when all its visible input slots are correlated outer slots: + // getInputSlots() does not traverse the subquery's inner plan, so + // the subquery may still depend on inner rows (e.g. `o.flag = 1 OR + // EXISTS (SELECT ... WHERE j.v = i.not_grouped)` reports only + // {o.flag}); moving it above the distinct would force the nested + // apply above the distinct project where its inner correlation has + // no owner. Keep such conjuncts on their original side of the + // distinct barrier. + if (!inputSlots.isEmpty() && correlatedSlots.containsAll(inputSlots) + && !conjunct.containsVolatileExpression() + && !conjunct.containsType(SubqueryExpr.class)) { + relocatedHavingConjuncts.add(conjunct); + } else { + distinctQualifyConjuncts.add(conjunct); + } + } + } else { + distinctQualifyConjuncts.addAll(newConjuncts); + } + Set missingSlots = relocatedHavingConjuncts.stream() + .map(Expression::getInputSlots) + .flatMap(Set::stream) + .filter(s -> !projects.contains(s)) + .filter(s -> !(outerScope.isPresent() + && outerScope.get().getCorrelatedSlots().contains(s))) + .collect(Collectors.toSet()); + List output = ImmutableList.builder() + .addAll(projects).addAll(missingSlots).build(); + LogicalQualify> logicalQualify = + new LogicalQualify<>(distinctQualifyConjuncts, + new LogicalProject<>(output, project.child())); + return having.withConjuncts(relocatedHavingConjuncts) + .withChildren(project.withProjects(copyOutput) + .withChildren(logicalQualify)); + } else { + return new LogicalProject<>(copyOutput, new LogicalQualify<>(newConjuncts, + having.withConjuncts(newHavingConjuncts) + .withChildren(project.withProjects(projects)))); + } + }); }) ) ); } interface PlanGenerator { - Plan apply(Set newConjuncts, List projects); + Plan apply(Set newConjuncts, Set newHavingConjuncts, + List projects); } - private Plan createPlan(LogicalProject project, Set conjuncts, PlanGenerator planGenerator) { + private Plan createPlan(LogicalProject project, Set qualifyConjuncts, + Set havingConjuncts, Optional outerScope, PlanGenerator planGenerator) { Set projectOutputSet = project.getOutputSet(); List newOutputSlots = Lists.newArrayList(); Set newConjuncts = new LinkedHashSet<>(); - for (Expression conjunct : conjuncts) { + Set newHavingConjuncts = new LinkedHashSet<>(); + + // A correlated column referenced in qualify or having may be hidden behind a project + // alias, e.g. `QUALIFY f = 1` or `HAVING f = 1` where f is aliased as an outer column + // o.flag. If the project also contains a window expression, filter pushdown cannot + // rewrite f back to its producer before apply decorrelation, so the alias-producer + // dependency would be lost and the correlation slot would never be collected into the + // apply. Resolve such aliases whose producers reference only outer correlated slots, so + // the correlation stays visible to subquery unnesting. The HAVING conjuncts must take + // part in this classification too: an outer-only producer used through a SELECT alias + // consumed only by HAVING would otherwise be left bound locally (FillUpMissingSlots sees + // the alias in the project output and does nothing), leaving the outer slot dangling + // below the window-bearing project. + Map correlatedAliasToProducer = Maps.newHashMap(); + // Split the conjuncts (both qualify and having): conjuncts that contain a subquery + // (IN/NOT IN/scalar/EXISTS) must not be rewritten, because ExpressionUtils.replace would + // descend into the subquery (e.g. the IN compare expression) and break the apply's slot + // ownership. Aliases in the remaining conjuncts are still resolved normally, so a + // replaceable correlated alias is not blocked just because another conjunct happens to + // contain a subquery. + Set qualifyReplaceableConjuncts = new LinkedHashSet<>(); + Set qualifySubqueryConjuncts = new LinkedHashSet<>(); + Set havingReplaceableConjuncts = new LinkedHashSet<>(); + Set havingSubqueryConjuncts = new LinkedHashSet<>(); + for (Expression conjunct : qualifyConjuncts) { + if (conjunct.containsType(SubqueryExpr.class)) { + qualifySubqueryConjuncts.add(conjunct); + } else { + qualifyReplaceableConjuncts.add(conjunct); + } + } + for (Expression conjunct : havingConjuncts) { + if (conjunct.containsType(SubqueryExpr.class)) { + havingSubqueryConjuncts.add(conjunct); + } else { + havingReplaceableConjuncts.add(conjunct); + } + } + Set classificationConjuncts = new LinkedHashSet<>(); + classificationConjuncts.addAll(qualifyConjuncts); + classificationConjuncts.addAll(havingConjuncts); + if (outerScope.isPresent()) { + Set correlatedSlots = outerScope.get().getCorrelatedSlots(); + for (Map.Entry entry : project.getAliasToProducer().entrySet()) { + Slot aliasSlot = entry.getKey(); + Expression producer = entry.getValue(); + if (!producer.getInputSlots().isEmpty() + && correlatedSlots.containsAll(producer.getInputSlots())) { + boolean referenced = classificationConjuncts.stream() + .anyMatch(c -> c.getInputSlots().contains(aliasSlot)); + if (!producer.containsType(WindowExpression.class) + // a window producer would be re-extracted into a fresh alias, which would be + // rewritten again, causing an infinite rewrite loop before a fixed point. + && !producer.containsType(SubqueryExpr.class) + // a producer containing a subquery would be copied into both the project and + // the rewritten qualify/having, and only one copy would be unnested, leaving a + // dangling slot in the other. + && !producer.containsVolatileExpression()) { + correlatedAliasToProducer.put(aliasSlot, producer); + } else if (referenced && producer.containsVolatileExpression()) { + // The alias only depends on outer correlated slots through a volatile + // expression (e.g. `random() + o.flag AS f`). Substituting it into the qualify + // or having would evaluate the volatile expression twice with different values, + // breaking the identity between the predicate and the returned value; keeping it + // in the project hides the correlation and leaves a dangling outer slot. Reject + // this usage explicitly. + throw new AnalysisException("QUALIFY referencing a correlated outer column " + + "through a volatile expression ('" + producer.toSql() + "') is not " + + "supported"); + } else if (referenced && producer.containsType(SubqueryExpr.class)) { + // The alias only depends on outer correlated slots through a subquery + // (e.g. `o.flag + (SELECT max(j) FROM t_j) AS f`). Substituting it into the + // qualify or having would copy the subquery into two plan nodes and only one + // copy would be unnested, leaving a dangling slot in the other; keeping it in + // the project also leaves a dangling outer slot. Reject this usage explicitly. + throw new AnalysisException("QUALIFY referencing a correlated outer column " + + "through a subquery ('" + producer.toSql() + "') is not supported"); + } else if (referenced && producer.containsType(WindowExpression.class)) { + // The alias only depends on outer correlated slots through a window expression + // (e.g. `row_number() over (order by o.flag) AS rn`). Substituting it into the + // qualify or having would be re-extracted into a fresh alias endlessly (no fixed + // point), while keeping it in the project leaves a dangling outer slot. Reject + // this usage explicitly. + throw new AnalysisException("QUALIFY referencing a correlated outer column " + + "through a window expression ('" + producer.toSql() + "') is not " + + "supported"); + } + } + } + // A correlated alias referenced inside a subquery-containing conjunct cannot be resolved + // (replacement would descend into the subquery and break slot ownership), and keeping it + // would leave a dangling outer slot in the inner project. Reject this usage explicitly. + for (Expression conjunct : classificationConjuncts) { + if (conjunct.containsType(SubqueryExpr.class)) { + for (Map.Entry entry : correlatedAliasToProducer.entrySet()) { + if (conjunct.getInputSlots().contains(entry.getKey())) { + throw new AnalysisException("QUALIFY referencing a correlated outer column " + + "through a subquery ('" + entry.getValue().toSql() + "') is not " + + "supported"); + } + } + } + } + } + boolean conjunctsRewritten = false; + if (!correlatedAliasToProducer.isEmpty()) { + Set rewrittenQualifyReplaceable = + ExpressionUtils.replace(qualifyReplaceableConjuncts, correlatedAliasToProducer); + Set rewrittenHavingReplaceable = + ExpressionUtils.replace(havingReplaceableConjuncts, correlatedAliasToProducer); + conjunctsRewritten = !rewrittenQualifyReplaceable.equals(qualifyReplaceableConjuncts) + || !rewrittenHavingReplaceable.equals(havingReplaceableConjuncts); + qualifyReplaceableConjuncts = rewrittenQualifyReplaceable; + havingReplaceableConjuncts = rewrittenHavingReplaceable; + } + + Set rewrittenQualifyConjuncts = new LinkedHashSet<>(); + rewrittenQualifyConjuncts.addAll(qualifyReplaceableConjuncts); + rewrittenQualifyConjuncts.addAll(qualifySubqueryConjuncts); + for (Expression conjunct : rewrittenQualifyConjuncts) { conjunct = conjunct.accept(new DefaultExpressionRewriter>() { @Override public Expression visitWindow(WindowExpression window, List context) { @@ -204,21 +447,112 @@ public Expression visitWindow(WindowExpression window, List con }, newOutputSlots); newConjuncts.addAll(ExpressionUtils.extractConjunctionToSet(conjunct)); } - Set notExistedInProject = conjuncts.stream() + // The having conjuncts cannot contain window expressions, so only the alias substitution + // above applies to them; the rewritten conjuncts stay on the having node. + newHavingConjuncts.addAll(havingReplaceableConjuncts); + newHavingConjuncts.addAll(havingSubqueryConjuncts); + Set notExistedInProject = new LinkedHashSet<>(); + notExistedInProject.addAll(rewrittenQualifyConjuncts.stream() .map(Expression::getInputSlots) .flatMap(Set::stream) .filter(s -> !projectOutputSet.contains(s)) - .collect(Collectors.toSet()); + // ATTN: exclude outer query's correlated slots, they belong to outer query + // and should not be filled up into the inner project's output. + .filter(s -> !(outerScope.isPresent() + && outerScope.get().getCorrelatedSlots().contains(s))) + .collect(Collectors.toSet())); + + // getInputSlots() deliberately does not traverse a subquery's inner plan, so a nested + // subquery in QUALIFY may correlate to a column of the inner query that is not directly + // referenced in any other conjunct (e.g. `EXISTS (SELECT ... WHERE j.v = i.not_grouped)`). + // For a plain project there is no distinct/aggregate barrier: the correlation slot is owned + // by the child plan, so it can be surfaced in the project output without changing semantics + // (the upper/lower project split then gives the nested apply a left child that owns the + // slot, and the unchanged upper project strips the helper). Add such slots to the missing + // slots. For a DISTINCT project, extending the distinct key with a non-output column would + // change the distinct semantics, so that shape is rejected with a clear error instead of + // failing with a cryptic slot-validation error. + for (Expression conjunct : rewrittenQualifyConjuncts) { + if (conjunct.containsType(SubqueryExpr.class)) { + Set subqueryExprs = + conjunct.collect(e -> e instanceof SubqueryExpr); + for (SubqueryExpr subqueryExpr : subqueryExprs) { + for (Slot correlatedSlot : subqueryExpr.getCorrelateSlots()) { + if (!projectOutputSet.contains(correlatedSlot) + && !(outerScope.isPresent() + && outerScope.get().getCorrelatedSlots().contains(correlatedSlot))) { + if (project.isDistinct()) { + throw new AnalysisException("QUALIFY nested subquery referencing " + + "column '" + correlatedSlot.toSql() + + "' that is not in the inner query output is not supported " + + "(subqueries in QUALIFY can only reference columns in the " + + "SELECT list)"); + } + notExistedInProject.add(correlatedSlot); + } + } + } + } + } newOutputSlots.addAll(notExistedInProject); - if (newOutputSlots.isEmpty()) { + if (newOutputSlots.isEmpty() && !conjunctsRewritten) { return null; } List projects = ImmutableList.builder() .addAll(project.getProjects()) .addAll(newOutputSlots).build(); - return planGenerator.apply(newConjuncts, projects); + return planGenerator.apply(newConjuncts, newHavingConjuncts, projects); + } + + /** + * Reject QUALIFY or HAVING references to aggregate output expressions that only depend on + * outer correlated slots. Such an expression cannot be produced by the aggregate (its child + * has no producer for the outer column), and after NormalizeAggregate/aggregate elimination + * the outer slot would be left dangling in an output project. This covers both an aggregate + * output ALIAS whose producer only depends on outer columns (e.g. `SELECT o.flag AS f ... + * GROUP BY ... QUALIFY f = ...` or `HAVING f = ...`) and a raw, unaliased aggregate output + * that is itself an outer correlated slot (e.g. `SELECT o.flag ... GROUP BY ... QUALIFY + * o.flag = ...`: with ONLY_FULL_GROUP_BY enabled the select item stays a raw SlotReference, + * so without this check Resolver.lookUp would self-match it as an aggregate output and + * NormalizeAggregate would report a false GROUP BY error). The HAVING conjuncts must take + * part in this classification too: an outer-only aggregate output alias consumed only by + * HAVING would otherwise be left in the aggregate output below the window project with the + * outer slot dangling. These shapes are not supported; reject them explicitly instead of + * failing with a cryptic slot-validation or GROUP BY error. + */ + private static Set resolveCorrelatedAggregateOutputAlias(Set qualifyConjuncts, + Set havingConjuncts, List aggregateOutput, + Optional outerScope) { + if (!outerScope.isPresent()) { + return qualifyConjuncts; + } + Set correlatedSlots = outerScope.get().getCorrelatedSlots(); + Set classificationConjuncts = new LinkedHashSet<>(); + classificationConjuncts.addAll(qualifyConjuncts); + classificationConjuncts.addAll(havingConjuncts); + for (NamedExpression output : aggregateOutput) { + if (output instanceof Alias) { + Expression producer = ((Alias) output).child(); + if (!producer.getInputSlots().isEmpty() + && correlatedSlots.containsAll(producer.getInputSlots()) + && classificationConjuncts.stream() + .anyMatch(c -> c.getInputSlots().contains(output.toSlot()))) { + throw new AnalysisException("Aggregate output alias '" + output.toSql() + + "' that only depends on outer correlated columns is not supported " + + "together with QUALIFY in a correlated subquery"); + } + } else if (!output.getInputSlots().isEmpty() + && correlatedSlots.containsAll(output.getInputSlots()) + && classificationConjuncts.stream() + .anyMatch(c -> c.getInputSlots().contains(output.toSlot()))) { + throw new AnalysisException("Aggregate output column '" + output.toSql() + + "' that is an outer correlated column is not supported together with " + + "QUALIFY in a correlated subquery"); + } + } + return qualifyConjuncts; } private void checkWindow(LogicalQualify qualify) throws AnalysisException { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushProjectIntoUnion.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushProjectIntoUnion.java index 53deff85eb6beb..4bc4b59a587a42 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushProjectIntoUnion.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushProjectIntoUnion.java @@ -100,6 +100,15 @@ private boolean canPushProjectIntoUnion(LogicalProject project) { if (union.getQualifier() != Qualifier.ALL || union.arity() != 0) { return false; } + // The project must only consume slots produced by the union. A correlated subquery may + // still have a project referencing an outer correlated slot (e.g. an aliased outer column + // that is resolved back to its producer); such slots have no constant producer in the + // union, so pushing the project into the union would leave a dangling slot reference. + for (NamedExpression ne : project.getProjects()) { + if (!union.getOutputSet().containsAll(ne.getInputSlots())) { + return false; + } + } for (List constExprs : union.getConstantExprsList()) { Set uniqueFunctionSlots = Sets.newHashSet(); for (int i = 0; i < constExprs.size(); i++) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushProjectThroughUnion.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushProjectThroughUnion.java index 87b239556ec01e..061d17b8880244 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushProjectThroughUnion.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushProjectThroughUnion.java @@ -63,6 +63,16 @@ public static boolean canPushProject(List projects, LogicalSetO if (projects.size() != logicalSetOperation.getOutput().size()) { return false; } + // A correlated subquery may reference outer slots in its project (e.g. an aliased outer + // column). Such slots have no producer inside the union children, so pushing the project + // through the union would leave a dangling slot reference in each child. Reject those + // projects here, before the children are rewritten (PushProjectIntoUnion has a similar + // late guard, but it is bypassed because this rule runs first). + for (NamedExpression project : projects) { + if (!logicalSetOperation.getOutputSet().containsAll(project.getInputSlots())) { + return false; + } + } boolean isAll = logicalSetOperation.getQualifier().equals(Qualifier.ALL); Set projectInputExprIds = Sets.newHashSetWithExpectedSize(projects.size()); for (NamedExpression project : projects) { diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/FillUpQualifyMissingSlotTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/FillUpQualifyMissingSlotTest.java new file mode 100644 index 00000000000000..92a00ce45c0000 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/FillUpQualifyMissingSlotTest.java @@ -0,0 +1,228 @@ +// 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.analysis; + +import org.apache.doris.nereids.exceptions.AnalysisException; +import org.apache.doris.nereids.rules.rewrite.PullUpProjectUnderApply; +import org.apache.doris.nereids.rules.rewrite.UnCorrelatedApplyFilter; +import org.apache.doris.nereids.trees.expressions.Expression; +import org.apache.doris.nereids.trees.expressions.Slot; +import org.apache.doris.nereids.trees.plans.Plan; +import org.apache.doris.nereids.trees.plans.logical.LogicalApply; +import org.apache.doris.nereids.util.ExpressionUtils; +import org.apache.doris.nereids.util.MemoPatternMatchSupported; +import org.apache.doris.nereids.util.PlanChecker; +import org.apache.doris.qe.SqlModeHelper; +import org.apache.doris.utframe.TestWithFeService; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +public class FillUpQualifyMissingSlotTest extends TestWithFeService implements MemoPatternMatchSupported { + + @Override + protected void runBeforeAll() throws Exception { + createDatabase("test"); + connectContext.setDatabase("test"); + + createTables( + "CREATE TABLE test.o (\n" + + " k INT,\n" + + " flag INT,\n" + + " h INT\n" + + ")\n" + + "DUPLICATE KEY (k)\n" + + "DISTRIBUTED BY HASH (k) BUCKETS 3\n" + + "PROPERTIES(\n" + + " 'replication_num' = '1'\n" + + ");", + "CREATE TABLE test.i (\n" + + " k INT,\n" + + " not_grouped INT\n" + + ")\n" + + "DUPLICATE KEY (k)\n" + + "DISTRIBUTED BY HASH (k) BUCKETS 3\n" + + "PROPERTIES(\n" + + " 'replication_num' = '1'\n" + + ");" + ); + } + + private LogicalApply findApply(Plan plan) { + List applies = plan.collectToList(LogicalApply.class::isInstance) + .stream().map(node -> (LogicalApply) node).collect(Collectors.toList()); + Assertions.assertEquals(1, applies.size(), + "expected exactly one LogicalApply in plan:\n" + plan.treeString()); + return applies.get(0); + } + + private Set getCorrelationFilterInputSlots(LogicalApply apply) { + Optional filter = apply.getCorrelationFilter(); + Assertions.assertTrue(filter.isPresent(), + "apply should record a correlation filter, plan:\n" + apply.treeString()); + Set conjuncts = ExpressionUtils.extractConjunctionToSet(filter.get()); + return conjuncts.stream().flatMap(e -> e.getInputSlots().stream()).collect(Collectors.toSet()); + } + + private boolean containsSlotNamed(Set slots, String name) { + return slots.stream().anyMatch(s -> s.getName().equals(name)); + } + + /** + * qualify -> having -> agg where both the having and the qualify reference correlated outer + * columns. The window expression in qualify is extracted into a project above the having during + * NormalizeAggregate; the having's correlated predicate must be conjoined into the qualify so it + * stays above that window project and is still collected into the apply during unnesting. + */ + @Test + public void testCorrelatedQualifyAndHaving() { + String sql = "SELECT o.k\n" + + "FROM o\n" + + "WHERE EXISTS (\n" + + " SELECT i.k\n" + + " FROM i\n" + + " GROUP BY i.k\n" + + " HAVING o.h = 1\n" + + " QUALIFY row_number() OVER (ORDER BY i.k) = 1 AND o.flag = 1\n" + + ")\n" + + "ORDER BY o.k"; + Plan plan = PlanChecker.from(connectContext) + .analyze(sql) + .applyBottomUp(new PullUpProjectUnderApply()) + .applyBottomUp(new UnCorrelatedApplyFilter()) + .getPlan(); + LogicalApply apply = findApply(plan); + Set slots = getCorrelationFilterInputSlots(apply); + // both the qualify correlation (o.flag) and the having correlation (o.h) must be collected + Assertions.assertTrue(containsSlotNamed(slots, "flag"), + "correlation filter should reference o.flag, plan:\n" + apply.treeString()); + Assertions.assertTrue(containsSlotNamed(slots, "h"), + "correlation filter should reference o.h, plan:\n" + apply.treeString()); + } + + /** + * qualify -> project where the qualify references a project alias (f) whose producer is a + * correlated outer column (o.flag). The alias-producer dependency must be resolved so the + * correlation slot is still collected into the apply even though the window expression in the + * project blocks filter pushdown. + */ + @Test + public void testCorrelatedQualifyWithAlias() { + String sql = "SELECT o.k\n" + + "FROM o\n" + + "WHERE EXISTS (\n" + + " SELECT i.k, o.flag AS f, row_number() OVER (ORDER BY i.k) AS rn\n" + + " FROM i\n" + + " QUALIFY rn = 1 AND f = 1\n" + + ")\n" + + "ORDER BY o.k"; + Plan plan = PlanChecker.from(connectContext) + .analyze(sql) + .applyBottomUp(new PullUpProjectUnderApply()) + .applyBottomUp(new UnCorrelatedApplyFilter()) + .getPlan(); + LogicalApply apply = findApply(plan); + Set slots = getCorrelationFilterInputSlots(apply); + Assertions.assertTrue(containsSlotNamed(slots, "flag"), + "correlation filter should reference o.flag (resolved from alias f), plan:\n" + + apply.treeString()); + } + + /** + * A having predicate that mixes outer correlated slots with aggregate results cannot be + * moved above the window project (it depends on the aggregate rows), and the window project + * would prevent subquery unnesting from collecting its correlation. Such a shape must be + * rejected during analysis instead of silently dropping the predicate. + */ + @Test + public void testMixedCorrelatedHavingRejected() { + String sql = "SELECT o.k\n" + + "FROM o\n" + + "WHERE EXISTS (\n" + + " SELECT i.k\n" + + " FROM i\n" + + " GROUP BY i.k\n" + + " HAVING o.h = sum(i.k)\n" + + " QUALIFY row_number() OVER (ORDER BY i.k) = 1 AND o.flag = 1\n" + + ")\n" + + "ORDER BY o.k"; + AnalysisException exception = Assertions.assertThrows(AnalysisException.class, + () -> PlanChecker.from(connectContext).analyze(sql)); + Assertions.assertTrue(exception.getMessage().contains("not supported"), + "unexpected exception message: " + exception.getMessage()); + } + + /** + * qualify -> having -> project where the HAVING references a project alias (f) whose producer + * is a correlated outer column (o.flag) consumed ONLY by HAVING (not by qualify). The having + * conjuncts must take part in the alias classification so `HAVING f = 1` is rewritten to + * `HAVING o.flag = 1`, and the correlation is still collected into the apply even though the + * window-bearing project blocks filter pushdown. + */ + @Test + public void testCorrelatedHavingWithAlias() { + String sql = "SELECT o.k\n" + + "FROM o\n" + + "WHERE EXISTS (\n" + + " SELECT i.k, o.flag AS f, row_number() OVER (ORDER BY i.k) AS rn\n" + + " FROM i\n" + + " HAVING f = 1\n" + + " QUALIFY rn = 1\n" + + ")\n" + + "ORDER BY o.k"; + Plan plan = PlanChecker.from(connectContext) + .analyze(sql) + .applyBottomUp(new PullUpProjectUnderApply()) + .applyBottomUp(new UnCorrelatedApplyFilter()) + .getPlan(); + LogicalApply apply = findApply(plan); + Set slots = getCorrelationFilterInputSlots(apply); + Assertions.assertTrue(containsSlotNamed(slots, "flag"), + "correlation filter should reference o.flag (resolved from alias f in HAVING), plan:\n" + + apply.treeString()); + } + + /** + * An aggregate output alias consumed ONLY by HAVING that only depends on outer correlated + * columns cannot be produced by the aggregate, so the shape is rejected: the having conjuncts + * take part in the aggregate-output classification too. + */ + @Test + public void testCorrelatedHavingAliasGroupedRejected() { + connectContext.getSessionVariable().setSqlMode(SqlModeHelper.MODE_DEFAULT); + String sql = "SELECT o.k\n" + + "FROM o\n" + + "WHERE EXISTS (\n" + + " SELECT i.k, o.flag AS f, row_number() OVER (ORDER BY i.k) AS rn\n" + + " FROM i\n" + + " GROUP BY i.k\n" + + " HAVING f = 1\n" + + " QUALIFY rn = 1\n" + + ")\n" + + "ORDER BY o.k"; + AnalysisException exception = Assertions.assertThrows(AnalysisException.class, + () -> PlanChecker.from(connectContext).analyze(sql)); + Assertions.assertTrue(exception.getMessage().contains("only depends on outer correlated columns"), + "unexpected exception message: " + exception.getMessage()); + } +} diff --git a/regression-test/data/query_p0/sql_functions/window_functions/test_qualify_query.out b/regression-test/data/query_p0/sql_functions/window_functions/test_qualify_query.out index c3853c92d281d3..bd07cb01a55165 100644 --- a/regression-test/data/query_p0/sql_functions/window_functions/test_qualify_query.out +++ b/regression-test/data/query_p0/sql_functions/window_functions/test_qualify_query.out @@ -121,3 +121,54 @@ Finland Phone 11 -- !select_36 -- 2001 Finland 6 +-- !select_37 -- +10 + +-- !select_38 -- +10 + +-- !select_39 -- +10 + +-- !select_40 -- + +-- !select_41 -- +10 + +-- !select_42 -- +10 + +-- !select_43 -- + +-- !select_44 -- +10 + +-- !select_45 -- +10 + +-- !select_46 -- +10 + +-- !select_47 -- +10 + +-- !select_48 -- +10 + +-- !select_49 -- +10 + +-- !select_50 -- +10 + +-- !select_51 -- +10 + +-- !select_52 -- +10 + +-- !select_53 -- + +-- !select_54 -- +10 + diff --git a/regression-test/suites/query_p0/sql_functions/window_functions/test_qualify_query.groovy b/regression-test/suites/query_p0/sql_functions/window_functions/test_qualify_query.groovy index 04ed0cd74157fc..68cfd8a19c80be 100644 --- a/regression-test/suites/query_p0/sql_functions/window_functions/test_qualify_query.groovy +++ b/regression-test/suites/query_p0/sql_functions/window_functions/test_qualify_query.groovy @@ -112,6 +112,708 @@ suite("test_qualify_query") { qt_select_35 "select year + 1, country from sales having profit >= 100 qualify row_number() over (order by profit) = 6;" qt_select_36 "select year + 1, country, row_number() over (order by profit) rk from sales having profit >= 100 qualify rk = 6;" + + // correlated subquery: an outer column referenced in qualify after an explicit group by + // should not be treated as a missing inner group-by column under ONLY_FULL_GROUP_BY. + qt_select_37 """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + UNION ALL + SELECT CAST(20 AS INT) AS k, CAST(0 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT i.k + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + GROUP BY i.k + QUALIFY row_number() OVER (ORDER BY i.k) = 1 + AND o.flag = 1 + ) + ORDER BY o.k; + """ + + // an inner non-grouped column referenced in qualify should still be rejected + // under ONLY_FULL_GROUP_BY. + test { + sql """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + UNION ALL + SELECT CAST(20 AS INT) AS k, CAST(0 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT i.k + FROM ( + SELECT CAST(1 AS INT) AS k, CAST(1 AS INT) AS not_grouped + UNION ALL + SELECT CAST(2 AS INT) AS k, CAST(2 AS INT) AS not_grouped + ) AS i + GROUP BY i.k + QUALIFY row_number() OVER (ORDER BY i.k) = 1 + AND i.not_grouped = 1 + ) + """ + exception "must appear in the GROUP BY clause" + } + + // correlated subquery over a plain project (no group by): the outer column in + // qualify must not be pushed into the inner project's output. + qt_select_38 """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + UNION ALL + SELECT CAST(20 AS INT) AS k, CAST(0 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT i.k + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + QUALIFY row_number() OVER (ORDER BY i.k) = 1 + AND o.flag = 1 + ) + ORDER BY o.k; + """ + + // correlated subquery over qualify -> having -> project: the outer column in + // qualify must not be pushed into the inner project's output. + qt_select_39 """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + UNION ALL + SELECT CAST(20 AS INT) AS k, CAST(0 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT i.k + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + HAVING i.k >= 1 + QUALIFY row_number() OVER (ORDER BY i.k) = 1 + AND o.flag = 1 + ) + ORDER BY o.k; + """ + + // qualify -> having -> agg where both the having and the qualify reference correlated outer + // columns. The window in qualify is extracted into a project above the having, so the having's + // correlated predicate must be conjoined into the qualify to be decorrelated together. + // o.h = 0 for k = 10 while o.flag = 1: the having predicate must still filter it out. + qt_select_40 """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag, CAST(0 AS INT) AS h + UNION ALL + SELECT CAST(20 AS INT) AS k, CAST(0 AS INT) AS flag, CAST(1 AS INT) AS h + ) AS o + WHERE EXISTS ( + SELECT i.k + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + GROUP BY i.k + HAVING o.h = 1 + QUALIFY row_number() OVER (ORDER BY i.k) = 1 + AND o.flag = 1 + ) + ORDER BY o.k; + """ + + // positive counterpart of select_40: only k = 10 satisfies both o.h = 1 and o.flag = 1. + qt_select_41 """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag, CAST(1 AS INT) AS h + UNION ALL + SELECT CAST(20 AS INT) AS k, CAST(0 AS INT) AS flag, CAST(0 AS INT) AS h + ) AS o + WHERE EXISTS ( + SELECT i.k + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + GROUP BY i.k + HAVING o.h = 1 + QUALIFY row_number() OVER (ORDER BY i.k) = 1 + AND o.flag = 1 + ) + ORDER BY o.k; + """ + + // qualify -> project where the qualify references a project alias (f) whose producer is a + // correlated outer column (o.flag). The alias-producer dependency must be preserved so the + // correlation is still extracted even though the project contains a window expression. + qt_select_42 """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + UNION ALL + SELECT CAST(20 AS INT) AS k, CAST(0 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT i.k, o.flag AS f, row_number() OVER (ORDER BY i.k) AS rn + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + QUALIFY rn = 1 + AND f = 1 + ) + ORDER BY o.k; + """ + + // negative counterpart of select_42: with o.flag = 0 everywhere the alias-resolved + // correlation must still filter out every outer row. + qt_select_43 """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(0 AS INT) AS flag + UNION ALL + SELECT CAST(20 AS INT) AS k, CAST(0 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT i.k, o.flag AS f, row_number() OVER (ORDER BY i.k) AS rn + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + QUALIFY rn = 1 + AND f = 1 + ) + ORDER BY o.k; + """ + + // DISTINCT subquery where the having and the qualify carry separate correlated outer + // predicates: both must stay on the same decorrelatable side of the distinct barrier, + // otherwise one of them is left dangling in the apply's right subtree. + // o.h = 0 < count(*) (2 distinct groups) and o.flag = 1 both hold only for k = 10. + qt_select_44 """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag, CAST(0 AS INT) AS h + UNION ALL + SELECT CAST(20 AS INT) AS k, CAST(1 AS INT) AS flag, CAST(5 AS INT) AS h + ) AS o + WHERE EXISTS ( + SELECT DISTINCT i.k + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + HAVING o.h < count(*) + QUALIFY row_number() OVER (ORDER BY i.k) = 1 + AND o.flag = 1 + ) + ORDER BY o.k; + """ + + // a project over a constant UNION ALL that references a correlated outer column: the + // project must not be pushed through the union (the outer slot has no producer inside + // the union children), and the correlation must still be decorrelated correctly. + qt_select_45 """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + UNION ALL + SELECT CAST(20 AS INT) AS k, CAST(0 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT i.k, o.flag AS f + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + QUALIFY row_number() OVER (ORDER BY i.k) = 1 + AND f = 1 + ) + ORDER BY o.k; + """ + + // a correlated having predicate that depends on the aggregate result cannot be evaluated + // below the window project and must be rejected instead of being silently dropped or + // moved above the window (which would change the evaluation order). + test { + sql """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(0 AS INT) AS h + ) AS o + WHERE EXISTS ( + SELECT i.k + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + GROUP BY i.k + HAVING count(*) = o.h + QUALIFY row_number() OVER (ORDER BY i.k) = 1 + ) + """ + exception "in HAVING depending on the aggregate result" + } + + // an aggregate output alias that only depends on outer correlated columns cannot be + // produced by the aggregate and must be rejected explicitly. + sql """SET sql_mode = '';""" + test { + sql """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT i.k, o.flag AS f, row_number() OVER (ORDER BY i.k) AS rn + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + GROUP BY i.k + QUALIFY f = 1 AND rn = 1 + ) + """ + exception "only depends on outer correlated columns is not supported" + } + + // Under ONLY_FULL_GROUP_BY an unaliased select item that is an outer correlated column + // (e.g. `SELECT i.k, o.flag ... GROUP BY i.k`) stays a raw SlotReference in the aggregate + // output, so the alias-only check above would skip it and NormalizeAggregate would report a + // false GROUP BY error. Classify direct correlated slot outputs too and reject them with + // the targeted unsupported-shape error. + sql """SET sql_mode = 'ONLY_FULL_GROUP_BY';""" + test { + sql """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT i.k, o.flag, row_number() OVER (ORDER BY i.k) AS rn + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + GROUP BY i.k + QUALIFY o.flag = 1 AND rn = 1 + ) + """ + exception "Aggregate output column 'flag' that is an outer correlated column is not supported" + } + sql """SET sql_mode = '';""" + + // A volatile correlated predicate in a DISTINCT subquery must stay on its original side of + // the distinct barrier (visible outer slots do not make a predicate constant, and moving a + // volatile predicate changes how many times it is evaluated). random() * 0.0 keeps the + // predicate volatile during analysis but is effectively `o.flag > 0.5`, so the result is + // deterministic: only k = 10 (flag = 1) survives, with duplicate k rows in the input. + qt_select_46 """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + UNION ALL + SELECT CAST(20 AS INT) AS k, CAST(0 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT DISTINCT i.k + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + QUALIFY row_number() OVER (ORDER BY i.k) = 1 + AND o.flag + random() * 0.0 > 0.5 + ) + ORDER BY o.k; + """ + + // A volatile alias producer (e.g. random() + o.flag AS f) must not be substituted into the + // qualify, otherwise the volatile expression would be evaluated twice with different values. + // This usage is rejected with a clear error instead of silently producing a mismatched result + // or failing later with a cryptic slot-validation error. + test { + sql """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT i.k, random() + o.flag AS f, row_number() OVER (ORDER BY i.k) AS rn + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + QUALIFY f >= o.flag AND rn = 1 + ) + """ + exception "QUALIFY referencing a correlated outer column through a volatile expression" + } + + // A replaceable correlated alias (f = o.flag) must still be resolved even when another qualify + // conjunct contains an uncorrelated IN subquery. The alias replacement is only skipped inside + // subquery-containing conjuncts, not for the whole qualify. + qt_select_47 """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + UNION ALL + SELECT CAST(20 AS INT) AS k, CAST(0 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT i.k, o.flag AS f, row_number() OVER (ORDER BY i.k) AS rn + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + QUALIFY rn = 1 + AND f = 1 + AND i.k IN (SELECT CAST(1 AS INT) UNION ALL SELECT CAST(2 AS INT)) + ) + ORDER BY o.k; + """ + + // A correlated alias referenced inside a subquery-containing qualify conjunct cannot be + // resolved (the replacement would descend into the subquery and break the apply's slot + // ownership), so this usage is rejected with a clear error. + test { + sql """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT i.k, o.flag AS f, row_number() OVER (ORDER BY i.k) AS rn + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + QUALIFY rn = 1 + AND f IN (SELECT CAST(1 AS INT) UNION ALL SELECT CAST(2 AS INT)) + ) + """ + exception "QUALIFY referencing a correlated outer column through a subquery" + } + + // A project alias whose producer contains a subquery (e.g. o.flag + (SELECT max(x.k) ...) AS f) + // must not be substituted into the qualify (the subquery would be copied into two plan nodes and + // only one copy would be unnested). This usage is rejected with a clear error. + test { + sql """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT i.k, (o.flag + (SELECT max(x.k) FROM (SELECT CAST(1 AS INT) AS k UNION ALL SELECT CAST(2 AS INT) AS k) AS x)) AS f, row_number() OVER (ORDER BY i.k) AS rn + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + QUALIFY f > 0 AND rn = 1 + ) + """ + exception "QUALIFY referencing a correlated outer column through a subquery" + } + + // A project alias whose producer is a window expression over only outer columns (e.g. + // row_number() OVER (ORDER BY o.flag) AS rn) must not be substituted into the qualify + // (it would be re-extracted into a fresh alias endlessly). This usage is rejected with a + // clear error instead of hanging or failing with a cryptic slot-validation error. + test { + sql """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT i.k, row_number() OVER (ORDER BY o.flag) AS rn + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + QUALIFY rn = 1 + ) + """ + exception "QUALIFY referencing a correlated outer column through a window expression" + } + + // A replaceable correlated alias in one conjunct must still be resolved even when other + // conjuncts contain subqueries: the alias replacement never crosses a subquery's apply + // boundary, so the alias (o.flag -> f) is substituted only in the subquery-free conjuncts + // and the scalar subquery / nested EXISTS are unnested independently. Full-pipeline + // mixed scalar/EXISTS coverage: o.flag = 1 and the scalar subquery > 0 and the nested + // EXISTS hold only for k = 10. + qt_select_48 """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + UNION ALL + SELECT CAST(20 AS INT) AS k, CAST(0 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT i.k, o.flag AS f, row_number() OVER (ORDER BY i.k) AS rn + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + QUALIFY rn = 1 + AND f = 1 + AND (SELECT max(x.k) FROM (SELECT CAST(1 AS INT) AS k UNION ALL SELECT CAST(3 AS INT) AS k) AS x) > 0 + AND EXISTS (SELECT 1 FROM (SELECT CAST(1 AS INT) AS v) AS j WHERE j.v = i.k) + ) + ORDER BY o.k; + """ + + // A nested subquery in a DISTINCT subquery's QUALIFY that is correlated to a column which + // is output by the inner query (here i.k) stays on its original side of the distinct + // barrier and is decorrelated together with the outer correlation: both predicates hold + // only for k = 10. + qt_select_49 """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + UNION ALL + SELECT CAST(20 AS INT) AS k, CAST(0 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT DISTINCT i.k + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + QUALIFY row_number() OVER (ORDER BY i.k) = 1 + AND (o.flag = 1 AND EXISTS (SELECT 1 FROM (SELECT CAST(1 AS INT) AS v) AS j WHERE j.v = i.k)) + ) + ORDER BY o.k; + """ + + // A nested subquery in QUALIFY correlated to a column of the inner query that is NOT in the + // SELECT list cannot be decorrelated: getInputSlots() does not traverse the subquery's inner + // plan, so the hidden correlation (i.not_grouped) is never surfaced in the project output and + // the apply would be left with a dangling slot. This shape is rejected with a clear error + // instead of failing with a cryptic slot-validation error. + test { + sql """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT DISTINCT i.k + FROM ( + SELECT CAST(1 AS INT) AS k, CAST(5 AS INT) AS not_grouped + UNION ALL + SELECT CAST(2 AS INT) AS k, CAST(6 AS INT) AS not_grouped + ) AS i + QUALIFY row_number() OVER (ORDER BY i.k) = 1 + AND (o.flag = 1 AND EXISTS (SELECT 1 FROM (SELECT CAST(100 AS INT) AS v) AS j WHERE j.v = i.not_grouped)) + ) + """ + exception "QUALIFY nested subquery referencing column 'not_grouped' that is not in the inner query output" + } + + // Same hidden non-output correlation through a nested scalar subquery: the scalar subquery + // correlates to i.not_grouped which is not produced by the DISTINCT query, so it cannot be + // decorrelated and is rejected with a clear error. + test { + sql """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT DISTINCT i.k + FROM ( + SELECT CAST(1 AS INT) AS k, CAST(5 AS INT) AS not_grouped + UNION ALL + SELECT CAST(2 AS INT) AS k, CAST(6 AS INT) AS not_grouped + ) AS i + QUALIFY row_number() OVER (ORDER BY i.k) = 1 + AND (o.flag = 1 AND (SELECT max(x.k) FROM (SELECT CAST(1 AS INT) AS k UNION ALL SELECT CAST(2 AS INT) AS k) AS x WHERE x.k = i.not_grouped) > 0) + ) + """ + exception "QUALIFY nested subquery referencing column 'not_grouped' that is not in the inner query output" + } + + // For a plain (non-DISTINCT) project, a nested subquery in QUALIFY correlated to a column of + // the inner query that is not in the SELECT list is still supported: the correlation slot is + // owned by the child plan and is surfaced in the lower project output (here it is also + // directly referenced by `i.not_grouped > 0`, so it is already classified as a child-owned + // support slot). The upper/lower project split gives the nested apply a left child that owns + // i.not_grouped and the unchanged upper project strips the helper. Both predicates hold only + // for k = 10. + qt_select_50 """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + UNION ALL + SELECT CAST(20 AS INT) AS k, CAST(0 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT i.k, row_number() OVER (ORDER BY i.k) AS rn + FROM ( + SELECT CAST(1 AS INT) AS k, CAST(5 AS INT) AS not_grouped + UNION ALL + SELECT CAST(2 AS INT) AS k, CAST(6 AS INT) AS not_grouped + ) AS i + QUALIFY rn = 1 + AND i.not_grouped > 0 + AND o.flag = 1 + AND EXISTS (SELECT 1 FROM (SELECT CAST(5 AS INT) AS v) AS j WHERE j.v = i.not_grouped) + ) + ORDER BY o.k; + """ + + // Same surfacing for a plain project when the nested subquery is the ONLY reference to the + // non-output column: the correlation slot is not visible to getInputSlots(), but because + // there is no distinct/aggregate barrier it is added to the project output and the nested + // apply is decorated normally. Holds only for k = 10. + qt_select_51 """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + UNION ALL + SELECT CAST(20 AS INT) AS k, CAST(0 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT i.k, row_number() OVER (ORDER BY i.k) AS rn + FROM ( + SELECT CAST(1 AS INT) AS k, CAST(5 AS INT) AS not_grouped + UNION ALL + SELECT CAST(2 AS INT) AS k, CAST(6 AS INT) AS not_grouped + ) AS i + QUALIFY rn = 1 + AND o.flag = 1 + AND EXISTS (SELECT 1 FROM (SELECT CAST(5 AS INT) AS v) AS j WHERE j.v = i.not_grouped) + ) + ORDER BY o.k; + """ + + // A correlated alias consumed ONLY by HAVING (f = o.flag is referenced in HAVING but not in + // QUALIFY) must still be resolved: the having conjuncts take part in the alias classification, + // so `HAVING f = 1` is rewritten to `HAVING o.flag = 1` and the correlation stays visible to + // subquery unnesting even though the project carries a window expression. Both predicates + // (f = 1 and rn = 1) hold only for k = 10. + qt_select_52 """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + UNION ALL + SELECT CAST(20 AS INT) AS k, CAST(0 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT i.k, o.flag AS f, row_number() OVER (ORDER BY i.k) AS rn + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + HAVING f = 1 + QUALIFY rn = 1 + ) + ORDER BY o.k; + """ + + // negative counterpart of select_52: with o.flag = 0 everywhere the alias-resolved correlation + // in HAVING must still filter out every outer row. + qt_select_53 """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(0 AS INT) AS flag + UNION ALL + SELECT CAST(20 AS INT) AS k, CAST(0 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT i.k, o.flag AS f, row_number() OVER (ORDER BY i.k) AS rn + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + HAVING f = 1 + QUALIFY rn = 1 + ) + ORDER BY o.k; + """ + + // Same alias-in-HAVING resolution for a DISTINCT project: the rewritten `HAVING o.flag = 1` + // stays together with the qualify on the decorrelatable side of the distinct barrier, and the + // window is evaluated inside the qualify. Holds only for k = 10. + qt_select_54 """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + UNION ALL + SELECT CAST(20 AS INT) AS k, CAST(0 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT DISTINCT i.k, o.flag AS f + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + HAVING f = 1 + QUALIFY row_number() OVER (ORDER BY i.k) = 1 + ) + ORDER BY o.k; + """ + + // An aggregate output alias that only depends on outer correlated columns and is consumed + // ONLY by HAVING cannot be produced by the aggregate, and the HAVING conjuncts must take part + // in the aggregate-output classification too. This shape is rejected explicitly (instead of + // leaving the outer slot dangling below the window project). + sql """SET sql_mode = '';""" + test { + sql """ + SELECT o.k + FROM ( + SELECT CAST(10 AS INT) AS k, CAST(1 AS INT) AS flag + ) AS o + WHERE EXISTS ( + SELECT i.k, o.flag AS f, row_number() OVER (ORDER BY i.k) AS rn + FROM ( + SELECT CAST(1 AS INT) AS k + UNION ALL + SELECT CAST(2 AS INT) AS k + ) AS i + GROUP BY i.k + HAVING f = 1 + QUALIFY rn = 1 + ) + """ + exception "only depends on outer correlated columns is not supported" + } + sql """SET sql_mode = '';""" } From 002da1059faa0ef7cf38cd28e60c1346c5860846 Mon Sep 17 00:00:00 2001 From: lichi Date: Wed, 2 Sep 2026 11:20:47 +0800 Subject: [PATCH 2/3] add case --- .../rewrite/PushProjectIntoUnionTest.java | 26 ++++++++++++++ .../rewrite/PushProjectThroughUnionTest.java | 34 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PushProjectIntoUnionTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PushProjectIntoUnionTest.java index 42f4f513b8b4ac..88bbe05025f95d 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PushProjectIntoUnionTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PushProjectIntoUnionTest.java @@ -118,6 +118,32 @@ public void testConstantExprIdsDistinctFromUnionOutputAndAcrossRows() { } } + @Test + public void testProjectReferencingOuterSlotIsNotPushedIntoUnion() { + // A correlated subquery may carry a project that references an outer correlated slot + // (e.g. an aliased outer column `o.flag AS f` resolved back to its producer). Such a + // slot has no constant producer in the union, so pushing the project into the union + // would leave a dangling slot reference; the rule must refuse to fire for this shape. + SlotReference unionOutput = new SlotReference(new ExprId(10), "s", + IntegerType.INSTANCE, true, ImmutableList.of()); + SlotReference outerSlot = new SlotReference(new ExprId(20), "flag", + IntegerType.INSTANCE, true, ImmutableList.of("o")); + LogicalUnion union = new LogicalUnion(Qualifier.ALL, + ImmutableList.of(unionOutput), ImmutableList.of(), ImmutableList.of(), false, ImmutableList.of()); + + Alias outerAlias = new Alias(new ExprId(100), outerSlot, "f"); + LogicalProject project = new LogicalProject<>( + ImmutableList.of(outerAlias), union); + + Plan rewritten = PlanChecker.from(MemoTestUtils.createConnectContext(), project) + .applyTopDown(new PushProjectIntoUnion()) + .getPlan(); + // The rule must not fire: the project stays above the union. + Assertions.assertTrue(rewritten instanceof LogicalProject, + "project referencing an outer slot must not be pushed into the union"); + Assertions.assertTrue(rewritten.child(0) instanceof LogicalUnion); + } + private LogicalUnion findUnion(Plan p) { if (p instanceof LogicalUnion) { return (LogicalUnion) p; diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PushProjectThroughUnionTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PushProjectThroughUnionTest.java index a877348491c9d3..e9208fb7564218 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PushProjectThroughUnionTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PushProjectThroughUnionTest.java @@ -164,6 +164,40 @@ public void testDistinctProjectRequiresAllOutputSlotsExactlyOnce() { Assertions.assertTrue(PushProjectThroughUnion.canPushProject(permutationProjects, unionDistinct)); } + @Test + public void testProjectReferencingOuterSlotIsNotPushedThroughUnion() { + // A correlated subquery may carry a project that references an outer correlated slot + // (e.g. an aliased outer column `o.flag AS f` resolved back to its producer). Such a + // slot has no producer inside the union children, so pushing the project through the + // union would leave a dangling slot reference in each child; the rule must refuse to + // fire for this shape. + SlotReference unionOutput = new SlotReference(new ExprId(10), "a", + IntegerType.INSTANCE, true, ImmutableList.of()); + SlotReference outerSlot = new SlotReference(new ExprId(20), "flag", + IntegerType.INSTANCE, true, ImmutableList.of("o")); + LogicalUnion union = new LogicalUnion(Qualifier.ALL, + ImmutableList.of(unionOutput), ImmutableList.of(), ImmutableList.of(), false, ImmutableList.of()); + + // Control: a project that only consumes the union output is still pushable. + ImmutableList innerOnlyProjects = ImmutableList.of( + new Alias(new ExprId(99), unionOutput, "a")); + Assertions.assertTrue(PushProjectThroughUnion.canPushProject(innerOnlyProjects, union)); + + // The project item is an aliased outer column (no producer in the union). + ImmutableList projects = ImmutableList.of( + new Alias(new ExprId(100), outerSlot, "f")); + Assertions.assertFalse(PushProjectThroughUnion.canPushProject(projects, union)); + + LogicalProject project = new LogicalProject<>(projects, union); + Plan rewritten = PlanChecker.from(MemoTestUtils.createConnectContext(), project) + .applyTopDown(new PushProjectThroughUnion()) + .getPlan(); + // The rule must not fire: the project stays above the union. + Assertions.assertTrue(rewritten instanceof LogicalProject, + "project referencing an outer slot must not be pushed through the union"); + Assertions.assertTrue(rewritten.child(0) instanceof LogicalUnion); + } + private LogicalUnion findUnion(Plan p) { if (p instanceof LogicalUnion) { return (LogicalUnion) p; From 92a31fa1e716701b7a7e568d2dd632ee1f618143 Mon Sep 17 00:00:00 2001 From: lichi Date: Wed, 2 Sep 2026 11:26:30 +0800 Subject: [PATCH 3/3] fix case --- .../sql_functions/window_functions/test_qualify_query.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/regression-test/suites/query_p0/sql_functions/window_functions/test_qualify_query.groovy b/regression-test/suites/query_p0/sql_functions/window_functions/test_qualify_query.groovy index 68cfd8a19c80be..464597b85055d3 100644 --- a/regression-test/suites/query_p0/sql_functions/window_functions/test_qualify_query.groovy +++ b/regression-test/suites/query_p0/sql_functions/window_functions/test_qualify_query.groovy @@ -209,7 +209,7 @@ suite("test_qualify_query") { // qualify -> having -> agg where both the having and the qualify reference correlated outer // columns. The window in qualify is extracted into a project above the having, so the having's // correlated predicate must be conjoined into the qualify to be decorrelated together. - // o.h = 0 for k = 10 while o.flag = 1: the having predicate must still filter it out. + // o.h = 1 for k = 10 while o.flag = 1: the having predicate must still filter it out. qt_select_40 """ SELECT o.k FROM (