This is an automated email from the ASF dual-hosted git repository.
924060929 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 960967b6eab [fix](rbo) Preserve column mappings when merging nested
set operations (#69131)
960967b6eab is described below
commit 960967b6eabc4065b2f1c12bdb145555e8f65e6d
Author: feiniaofeiafei <[email protected]>
AuthorDate: Thu Oct 22 10:22:00 2026 +0800
[fix](rbo) Preserve column mappings when merging nested set operations
(#69131)
### What problem does this PR solve?
Merging nested set operations can lose the parent’s input column mapping
and return values from the wrong columns. For example, a query grouping
by country can return campaign names after pruning an unused date
column.
Each set operation’s regularChildrenOutputs[i] specifies which child
slots supply its output columns, in output order. This mapping can
differ from the child’s own output order.
In the following plan, the parent reads the nested UNION as [country#63,
campaign#62], while the nested UNION’s own output is [campaign#62,
country#63].
Before MERGE_SET_OPERATION:
```sql
Union
outputs: [country#70, tag#71]
child mappings:
[country#46, tag#49]
[country#63, campaign#62]
├─ Project [country#46, campaign#45 AS tag#49]
│ └─ Scan
└─ Union
outputs: [campaign#62, country#63]
child mappings:
[campaign#51, country#52]
[campaign#57, country#58]
├─ Project [campaign#51, country#52]
│ └─ Scan
└─ Project [campaign#57, country#58]
└─ Scan
```
The original rewrite directly copied the nested UNION’s child mappings
into the parent, discarding the parent’s required column permutation.
After the original rewrite — incorrect:
```sql
Union
outputs: [country#70, tag#71]
child mappings:
[country#46, tag#49]
[campaign#51, country#52] ← country#70 receives campaign#51
[campaign#57, country#58] ← country#70 receives campaign#57
├─ Project [country#46, campaign#45 AS tag#49]
│ └─ Scan
├─ Project [campaign#51, country#52]
│ └─ Scan
└─ Project [campaign#57, country#58]
└─ Scan
```
The fix composes the parent’s input mapping with the nested operation’s
output ordinals before flattening its children.
After the fixed rewrite — correct:
```sql
Union
outputs: [country#70, tag#71]
child mappings:
[country#46, tag#49]
[country#52, campaign#51]
[country#58, campaign#57]
├─ Project [country#46, campaign#45 AS tag#49]
│ └─ Scan
├─ Project [campaign#51, country#52]
│ └─ Scan
└─ Project [campaign#57, country#58]
└─ Scan
The child Project nodes keep their original output order. The corrected
mappings ensure that country#70 receives country values from every branch.
```
The same mapping composition is applied to nested INTERSECT and
left-deep EXCEPT operations. UNION constant rows are reordered
consistently, and the parent EXCEPT’s remaining inputs retain their
original mappings.
---
.../nereids/rules/rewrite/MergeSetOperations.java | 41 +++++++++-
.../rules/rewrite/MergeSetOperationsExcept.java | 5 +-
.../rewrite/SetOperationOutputMappingTest.java | 53 +++++++++++++
.../merge_union_output_mapping.out | 12 +++
.../merge_union_output_mapping.groovy | 88 ++++++++++++++++++++++
5 files changed, 195 insertions(+), 4 deletions(-)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/MergeSetOperations.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/MergeSetOperations.java
index 57fedeb350e..53817b658e4 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/MergeSetOperations.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/MergeSetOperations.java
@@ -19,6 +19,7 @@ package org.apache.doris.nereids.rules.rewrite;
import org.apache.doris.nereids.rules.Rule;
import org.apache.doris.nereids.rules.RuleType;
+import org.apache.doris.nereids.trees.expressions.ExprId;
import org.apache.doris.nereids.trees.expressions.NamedExpression;
import org.apache.doris.nereids.trees.expressions.SlotReference;
import org.apache.doris.nereids.trees.plans.Plan;
@@ -27,9 +28,12 @@ import
org.apache.doris.nereids.trees.plans.logical.LogicalIntersect;
import org.apache.doris.nereids.trees.plans.logical.LogicalSetOperation;
import org.apache.doris.nereids.trees.plans.logical.LogicalUnion;
+import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
/**
* Merge nodes of the same type and same qualifier.
@@ -83,8 +87,13 @@ public class MergeSetOperations implements
RewriteRuleFactory {
if (canMerge(parentUnion, child) && child.getOutput().size() ==
parentUnion.getOutput().size()) {
LogicalUnion childUnion = (LogicalUnion) child;
newChildren.addAll(childUnion.children());
-
newChildrenOutputs.addAll(childUnion.getRegularChildrenOutputs());
- constantList.addAll(childUnion.getConstantExprsList());
+ List<Integer> outputOrder = getChildOutputOrder(parentUnion,
i, childUnion);
+ for (List<SlotReference> outputs :
childUnion.getRegularChildrenOutputs()) {
+ newChildrenOutputs.add(reorderOutputs(outputs,
outputOrder));
+ }
+ for (List<NamedExpression> constants :
childUnion.getConstantExprsList()) {
+ constantList.add(reorderOutputs(constants, outputOrder));
+ }
needMerge = true;
} else {
newChildren.add(child);
@@ -108,7 +117,10 @@ public class MergeSetOperations implements
RewriteRuleFactory {
if (canMerge(parentSetIntersect, child)) {
LogicalIntersect childIntersect = (LogicalIntersect) child;
newChildren.addAll(childIntersect.children());
-
newChildrenOutputs.addAll(childIntersect.getRegularChildrenOutputs());
+ List<Integer> outputOrder =
getChildOutputOrder(parentSetIntersect, i, childIntersect);
+ for (List<SlotReference> outputs :
childIntersect.getRegularChildrenOutputs()) {
+ newChildrenOutputs.add(reorderOutputs(outputs,
outputOrder));
+ }
} else {
newChildren.add(child);
newChildrenOutputs.add(parentSetIntersect.getRegularChildOutput(i));
@@ -118,6 +130,29 @@ public class MergeSetOperations implements
RewriteRuleFactory {
newChildren.build(), newChildrenOutputs.build());
}
+ static List<Integer> getChildOutputOrder(
+ LogicalSetOperation parent, int childIndex, LogicalSetOperation
child) {
+ Map<ExprId, Integer> outputIndexes = new HashMap<>();
+ for (int i = 0; i < child.getOutput().size(); i++) {
+ outputIndexes.put(child.getOutput().get(i).getExprId(), i);
+ }
+ ImmutableList.Builder<Integer> order = ImmutableList.builder();
+ for (SlotReference slot : parent.getRegularChildOutput(childIndex)) {
+ Integer index = outputIndexes.get(slot.getExprId());
+ Preconditions.checkState(index != null, "Set operation input must
belong to child output");
+ order.add(index);
+ }
+ return order.build();
+ }
+
+ static <T> List<T> reorderOutputs(List<T> outputs, List<Integer> order) {
+ ImmutableList.Builder<T> reordered =
ImmutableList.builderWithExpectedSize(order.size());
+ for (int index : order) {
+ reordered.add(outputs.get(index));
+ }
+ return reordered.build();
+ }
+
/** canMerge */
public static boolean canMerge(LogicalSetOperation parent) {
Plan left = parent.child(0);
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/MergeSetOperationsExcept.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/MergeSetOperationsExcept.java
index 2eb284e69c5..79d62285bca 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/MergeSetOperationsExcept.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/MergeSetOperationsExcept.java
@@ -54,7 +54,10 @@ public class MergeSetOperationsExcept extends
OneRewriteRuleFactory {
newChildren.addAll(childExcept.children());
newChildren.addAll(parentExcept.children().subList(1,
parentExcept.children().size()));
-
newChildrenOutputs.addAll(childExcept.getRegularChildrenOutputs());
+ List<Integer> outputOrder =
MergeSetOperations.getChildOutputOrder(parentExcept, 0, childExcept);
+ for (List<SlotReference> outputs :
childExcept.getRegularChildrenOutputs()) {
+
newChildrenOutputs.add(MergeSetOperations.reorderOutputs(outputs, outputOrder));
+ }
newChildrenOutputs.addAll(
parentExcept.getRegularChildrenOutputs().subList(1,
parentExcept.children().size()));
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/SetOperationOutputMappingTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/SetOperationOutputMappingTest.java
index a42c974dabf..e1d4489e18c 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/SetOperationOutputMappingTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/SetOperationOutputMappingTest.java
@@ -26,6 +26,7 @@ import
org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
import org.apache.doris.nereids.trees.plans.Plan;
import org.apache.doris.nereids.trees.plans.RelationId;
import org.apache.doris.nereids.trees.plans.algebra.SetOperation.Qualifier;
+import org.apache.doris.nereids.trees.plans.logical.LogicalExcept;
import org.apache.doris.nereids.trees.plans.logical.LogicalOneRowRelation;
import org.apache.doris.nereids.trees.plans.logical.LogicalUnion;
import org.apache.doris.nereids.types.IntegerType;
@@ -63,6 +64,58 @@ class SetOperationOutputMappingTest extends
TestWithFeService implements MemoPat
StatementScopeIdGenerator.clear();
}
+ @Test
+ void testMergeSetOperationsComposesChildOutputMapping() {
+ Alias first = new Alias(new ExprId(101), new IntegerLiteral(10),
"campaign");
+ Alias second = new Alias(new ExprId(102), new IntegerLiteral(20),
"country");
+ LogicalOneRowRelation row = new LogicalOneRowRelation(
+ new RelationId(101), ImmutableList.of(first, second));
+ SlotReference campaign = new SlotReference(new ExprId(103), "campaign",
+ IntegerType.INSTANCE, false, ImmutableList.of());
+ SlotReference country = new SlotReference(new ExprId(104), "country",
+ IntegerType.INSTANCE, false, ImmutableList.of());
+ LogicalUnion child = new LogicalUnion(Qualifier.ALL,
ImmutableList.of(campaign, country),
+ ImmutableList.of(ImmutableList.of((SlotReference)
first.toSlot(), (SlotReference) second.toSlot())),
+ ImmutableList.of(ImmutableList.of(first, second)), false,
ImmutableList.of(row));
+ LogicalUnion parent = new LogicalUnion(Qualifier.ALL,
ImmutableList.of(country, campaign),
+ ImmutableList.of(ImmutableList.of(country, campaign)),
ImmutableList.of(),
+ false, ImmutableList.of(child));
+
+ LogicalUnion rewritten = (LogicalUnion)
PlanChecker.from(MemoTestUtils.createConnectContext(), parent)
+ .applyTopDown(new MergeSetOperations()).getPlan();
+ Assertions.assertEquals(ImmutableList.of(row), rewritten.children());
+ Assertions.assertEquals(ImmutableList.of(second.toSlot(),
first.toSlot()),
+ rewritten.getRegularChildOutput(0));
+ Assertions.assertEquals(ImmutableList.of(second, first),
rewritten.getConstantExprsList().get(0));
+
+ Alias rightFirst = new Alias(new ExprId(105), new IntegerLiteral(30),
"right_first");
+ Alias rightSecond = new Alias(new ExprId(106), new IntegerLiteral(40),
"right_second");
+ LogicalOneRowRelation right = new LogicalOneRowRelation(
+ new RelationId(102), ImmutableList.of(rightFirst,
rightSecond));
+ List<SlotReference> rightOutputs = ImmutableList.of(
+ (SlotReference) rightFirst.toSlot(), (SlotReference)
rightSecond.toSlot());
+ LogicalExcept childExcept = new LogicalExcept(Qualifier.DISTINCT,
ImmutableList.of(campaign, country),
+ ImmutableList.of(child.getRegularChildOutput(0),
rightOutputs), ImmutableList.of(row, right));
+ Alias outerFirst = new Alias(new ExprId(107), new IntegerLiteral(50),
"outer_first");
+ Alias outerSecond = new Alias(new ExprId(108), new IntegerLiteral(60),
"outer_second");
+ LogicalOneRowRelation outerRight = new LogicalOneRowRelation(
+ new RelationId(103), ImmutableList.of(outerFirst,
outerSecond));
+ List<SlotReference> outerRightOutputs = ImmutableList.of(
+ (SlotReference) outerFirst.toSlot(), (SlotReference)
outerSecond.toSlot());
+ LogicalExcept parentExcept = new LogicalExcept(Qualifier.DISTINCT,
ImmutableList.of(country, campaign),
+ ImmutableList.of(ImmutableList.of(country, campaign),
outerRightOutputs),
+ ImmutableList.of(childExcept, outerRight));
+ LogicalExcept rewrittenExcept = (LogicalExcept) PlanChecker.from(
+ MemoTestUtils.createConnectContext(), parentExcept)
+ .applyTopDown(new MergeSetOperationsExcept()).getPlan();
+ Assertions.assertEquals(ImmutableList.of(row, right, outerRight),
rewrittenExcept.children());
+ Assertions.assertEquals(ImmutableList.of(second.toSlot(),
first.toSlot()),
+ rewrittenExcept.getRegularChildOutput(0));
+ Assertions.assertEquals(ImmutableList.of(rightSecond.toSlot(),
rightFirst.toSlot()),
+ rewrittenExcept.getRegularChildOutput(1));
+ Assertions.assertEquals(outerRightOutputs,
rewrittenExcept.getRegularChildOutput(2));
+ }
+
@Test
void testMergeOneRowRelationUsesRegularChildOutput() {
Alias firstProject = new Alias(new ExprId(1), new IntegerLiteral(10),
"first_col");
diff --git
a/regression-test/data/nereids_rules_p0/merge_one_row_relation/merge_union_output_mapping.out
b/regression-test/data/nereids_rules_p0/merge_one_row_relation/merge_union_output_mapping.out
new file mode 100644
index 00000000000..41dab4ef646
--- /dev/null
+++
b/regression-test/data/nereids_rules_p0/merge_one_row_relation/merge_union_output_mapping.out
@@ -0,0 +1,12 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !country --
+FR
+
+-- !country_date --
+FR 2026-01-01
+
+-- !except_reordered --
+FR AD_A
+
+-- !except_removed --
+
diff --git
a/regression-test/suites/nereids_rules_p0/merge_one_row_relation/merge_union_output_mapping.groovy
b/regression-test/suites/nereids_rules_p0/merge_one_row_relation/merge_union_output_mapping.groovy
new file mode 100644
index 00000000000..6a95bceba9a
--- /dev/null
+++
b/regression-test/suites/nereids_rules_p0/merge_one_row_relation/merge_union_output_mapping.groovy
@@ -0,0 +1,88 @@
+// 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.
+
+suite("merge_union_output_mapping", "p0") {
+ sql "DROP TABLE IF EXISTS merge_union_output_mapping"
+ sql """CREATE TABLE merge_union_output_mapping (
+ id INT, campaign VARCHAR(40), country VARCHAR(40), dt VARCHAR(40)
+ ) DUPLICATE KEY(id) DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES ('replication_num' = '1')"""
+ sql "INSERT INTO merge_union_output_mapping VALUES (1, 'AD_A', 'FR',
'2026-01-01')"
+ sql "SET enable_sql_cache=false"
+ order_qt_country """WITH sa AS (
+ SELECT CAST(dt AS DATE) AS dt, campaign, country, campaign AS tag FROM
merge_union_output_mapping
+ UNION ALL
+ SELECT CAST(dt AS DATE), campaign, country, campaign
+ FROM (
+ SELECT campaign, country, dt, id AS x0, id AS x1 FROM
merge_union_output_mapping
+ UNION ALL
+ SELECT campaign, country, dt, id AS x0, id AS x1 FROM
merge_union_output_mapping
+ ) u
+), c AS (
+ SELECT dt, country, tag, 'SA' AS ad_type FROM sa
+), e AS (
+ SELECT 'FR' AS country
+)
+SELECT a.country
+FROM c a
+LEFT JOIN e ON CASE
+ WHEN a.country = 'PL' AND a.ad_type = 'DSP' THEN 'PL_DSP'
+ ELSE a.country
+END = e.country
+LEFT JOIN c b ON a.tag = b.tag
+GROUP BY a.country"""
+ order_qt_country_date """WITH sa AS (
+ SELECT CAST(dt AS DATE) AS dt, campaign, country, campaign AS tag FROM
merge_union_output_mapping
+ UNION ALL
+ SELECT CAST(dt AS DATE), campaign, country, campaign
+ FROM (
+ SELECT campaign, country, dt, id AS x0, id AS x1 FROM
merge_union_output_mapping
+ UNION ALL
+ SELECT campaign, country, dt, id AS x0, id AS x1 FROM
merge_union_output_mapping
+ ) u
+), c AS (
+ SELECT dt, country, tag, 'SA' AS ad_type FROM sa
+), e AS (
+ SELECT 'FR' AS country
+)
+SELECT a.country, a.dt
+FROM c a
+LEFT JOIN e ON CASE
+ WHEN a.country = 'PL' AND a.ad_type = 'DSP' THEN 'PL_DSP'
+ ELSE a.country
+END = e.country
+LEFT JOIN c b ON a.tag = b.tag
+GROUP BY a.country, a.dt"""
+ order_qt_except_reordered """
+ SELECT country, campaign FROM (
+ SELECT campaign, country FROM merge_union_output_mapping
+ EXCEPT
+ SELECT campaign, country FROM merge_union_output_mapping WHERE id
< 0
+ ) u
+ EXCEPT
+ SELECT country, campaign FROM merge_union_output_mapping WHERE id < 0
+ """
+ order_qt_except_removed """
+ SELECT country, campaign FROM (
+ SELECT campaign, country FROM merge_union_output_mapping
+ EXCEPT
+ SELECT campaign, country FROM merge_union_output_mapping WHERE id
< 0
+ ) u
+ EXCEPT
+ SELECT country, campaign FROM merge_union_output_mapping
+ """
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]