This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.0
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.0 by this push:
new 3e5dbd99726 branch-4.0: [fix](mtmv) Fix query err when calc mv
functional dependency which has variant and log more detailed info for
troubleshoot a problem #59933 (#61659)
3e5dbd99726 is described below
commit 3e5dbd99726a57567c2670e644e5233216c2006e
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Mar 24 17:44:37 2026 +0800
branch-4.0: [fix](mtmv) Fix query err when calc mv functional dependency
which has variant and log more detailed info for troubleshoot a problem #59933
(#61659)
Cherry-picked from #59933
Co-authored-by: seawinde <[email protected]>
---
.../trees/plans/logical/LogicalOlapScan.java | 70 +++++--
.../trees/plans/logical/LogicalOlapScanTest.java | 222 +++++++++++++++++++++
2 files changed, 278 insertions(+), 14 deletions(-)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapScan.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapScan.java
index 840d0907cd5..2ab3405f3c0 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapScan.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapScan.java
@@ -62,8 +62,10 @@ import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
+import java.util.Collections;
import java.util.HashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@@ -871,10 +873,9 @@ public class LogicalOlapScan extends
LogicalCatalogRelation implements OlapScan,
scoreRangeInfo, annOrderKeys, annLimit);
}
- private Map<Slot, Slot> constructReplaceMap(MTMV mtmv) {
+ @VisibleForTesting
+ Map<Slot, Slot> constructReplaceMap(MTMV mtmv) {
Map<Slot, Slot> replaceMap = new HashMap<>();
- // Need remove invisible column, and then mapping them
- List<Slot> originOutputs = new ArrayList<>();
MTMVCache cache;
try {
cache = mtmv.getOrGenerateCache(ConnectContext.get());
@@ -882,21 +883,62 @@ public class LogicalOlapScan extends
LogicalCatalogRelation implements OlapScan,
LOG.warn(String.format("LogicalOlapScan constructReplaceMap fail,
mv name is %s", mtmv.getName()), e);
return replaceMap;
}
- for (Slot originSlot : cache.getOriginalFinalPlan().getOutput()) {
- if (!(originSlot instanceof SlotReference) || (((SlotReference)
originSlot).isVisible())) {
- originOutputs.add(originSlot);
+ // Get MV plan's visible output slots (ordered, matching MV definition
SELECT list).
+ // This includes all visible slots: regular columns AND variant
subPath columns
+ // like payload['issue'] that are real physical columns of the MV.
+ List<Slot> mvPlanVisibleOutputs = new ArrayList<>();
+ for (Slot slot : cache.getOriginalFinalPlan().getOutput()) {
+ if (slot instanceof SlotReference && ((SlotReference)
slot).isVisible()) {
+ mvPlanVisibleOutputs.add(slot);
}
}
- List<Slot> targetOutputs = new ArrayList<>();
- for (Slot targeSlot : getOutput()) {
- if (!(targeSlot instanceof SlotReference) || (((SlotReference)
targeSlot).isVisible())) {
- targetOutputs.add(targeSlot);
+ // Get MV table's visible physical columns (ordered).
+ // getBaseSchema() returns visible-only columns whose names are
derived from the
+ // CREATE MV AS SELECT aliases. These names are guaranteed unique per
table.
+ // Using physical column names as keys (instead of plan slot's
originalColumn.getName())
+ // correctly handles:
+ // - Aliased columns (e.g. SELECT sum_total AS agg3): key is "agg3",
not "sum_total"
+ // - Self-join MVs: physical column names are unique even if source
columns collide
+ // - Variant columns (e.g. SELECT payload['issue']): they are physical
columns in getBaseSchema()
+ List<Column> mvPhysicalColumns = mtmv.getBaseSchema();
+ if (mvPlanVisibleOutputs.size() != mvPhysicalColumns.size()) {
+ LOG.error("LogicalOlapScan constructReplaceMap: MV plan visible
output size {} "
+ + "doesn't match physical column size {} for mv {}",
+ mvPlanVisibleOutputs.size(), mvPhysicalColumns.size(),
mtmv.getName());
+ // not throw exception here to avoid query failed, compute mv fd
should not influence query process
+ return Collections.emptyMap();
+ }
+ // Build mvOutputsMap: the i-th visible plan output corresponds to the
i-th physical column.
+ Map<List<String>, Slot> mvOutputsMap = new HashMap<>();
+ for (int i = 0; i < mvPlanVisibleOutputs.size(); i++) {
+ String physicalColName =
mvPhysicalColumns.get(i).getName().toLowerCase(Locale.ROOT);
+ List<String> key = Lists.newArrayList(physicalColName);
+ mvOutputsMap.put(key, mvPlanVisibleOutputs.get(i));
+ }
+ // Match scan output slots against mvOutputsMap.
+ // Scan slot's originalColumn.getName() refers to the MV's physical
column, so keys match.
+ // Extra subPath slots added by VariantSubPathPruning during query
optimization won't
+ // match any mvOutputsMap entry (their keys include subPath elements)
and are safely skipped.
+ for (Slot scanSlot : getOutput()) {
+ if (scanSlot instanceof SlotReference && ((SlotReference)
scanSlot).isVisible()) {
+ SlotReference scanRef = (SlotReference) scanSlot;
+ String scanName =
scanRef.getOriginalColumn().map(Column::getName).orElse(scanRef.getName());
+ List<String> key =
Lists.newArrayList(scanName.toLowerCase(Locale.ROOT));
+ key.addAll(scanRef.getSubPath());
+ Slot mvMappingSlot = mvOutputsMap.get(key);
+ if (mvMappingSlot != null) {
+ replaceMap.put(mvMappingSlot, scanSlot);
+ }
}
}
- Preconditions.checkArgument(originOutputs.size() ==
targetOutputs.size(),
- "constructReplaceMap, the size of originOutputs and
targetOutputs should be same");
- for (int i = 0; i < targetOutputs.size(); i++) {
- replaceMap.put(originOutputs.get(i), targetOutputs.get(i));
+ // Every MV plan slot must be mapped
+ if (mvOutputsMap.size() != replaceMap.size()) {
+ LOG.error(String.format("LogicalOlapScan constructReplaceMap size
not match,"
+ + "mv name is %s, mvOutputsMap is %s, mv output is %s,
scan output is %s",
+ mtmv.getName(), mvOutputsMap,
+ cache.getOriginalFinalPlan().getOutput(), getOutput()));
+ // not throw exception here to avoid query failed, compute mv fd
should not influence query process
+ return Collections.emptyMap();
}
return replaceMap;
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapScanTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapScanTest.java
new file mode 100644
index 00000000000..32ecb65ee98
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapScanTest.java
@@ -0,0 +1,222 @@
+// 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.trees.plans.logical;
+
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.MTMV;
+import org.apache.doris.catalog.OlapTable;
+import org.apache.doris.catalog.PrimitiveType;
+import org.apache.doris.mtmv.MTMVCache;
+import org.apache.doris.nereids.trees.expressions.Slot;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.RelationId;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.SessionVariable;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Tests for LogicalOlapScan
+ */
+public class LogicalOlapScanTest {
+
+ private ConnectContext connectContext;
+ private MockedStatic<ConnectContext> mockedConnectContext;
+
+ @BeforeEach
+ public void setUp() {
+ connectContext = new ConnectContext();
+ connectContext.setSessionVariable(new SessionVariable());
+ mockedConnectContext = Mockito.mockStatic(ConnectContext.class);
+
mockedConnectContext.when(ConnectContext::get).thenReturn(connectContext);
+ }
+
+ @AfterEach
+ public void tearDown() {
+ if (mockedConnectContext != null) {
+ mockedConnectContext.close();
+ }
+ }
+
+ private SlotReference createMockSlot(String name, boolean isVisible) {
+ SlotReference slot = Mockito.mock(SlotReference.class);
+ Mockito.when(slot.isVisible()).thenReturn(isVisible);
+ Mockito.when(slot.getName()).thenReturn(name);
+ Mockito.when(slot.getSubPath()).thenReturn(Collections.emptyList());
+ Mockito.when(slot.getOriginalColumn()).thenReturn(Optional.empty());
+ return slot;
+ }
+
+ private SlotReference createMockSlot(String name, String
originalColumnName,
+ List<String> subPath, boolean isVisible) {
+ SlotReference slot = Mockito.mock(SlotReference.class);
+ Column column = createColumn(originalColumnName);
+ Mockito.when(slot.isVisible()).thenReturn(isVisible);
+ Mockito.when(slot.getName()).thenReturn(name);
+ Mockito.when(slot.getSubPath()).thenReturn(subPath);
+ Mockito.when(slot.getOriginalColumn()).thenReturn(Optional.of(column));
+ return slot;
+ }
+
+ private Column createColumn(String name) {
+ return new Column(name, PrimitiveType.INT);
+ }
+
+ private LogicalOlapScan createMockScan(List<Slot> outputSlots) {
+ OlapTable olapTable = Mockito.mock(OlapTable.class);
+ Mockito.when(olapTable.getId()).thenReturn(1L);
+ Mockito.when(olapTable.getName()).thenReturn("test_table");
+
Mockito.when(olapTable.getFullQualifiers()).thenReturn(ImmutableList.of("db",
"test_table"));
+
+ LogicalOlapScan scan = Mockito.spy(new LogicalOlapScan(
+ new RelationId(1),
+ olapTable,
+ ImmutableList.of("db"),
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Optional.empty(),
+ Collections.emptyList()
+ ));
+ Mockito.doReturn(outputSlots).when(scan).getOutput();
+ return scan;
+ }
+
+ /**
+ * Test constructReplaceMap returns empty map when MV plan output size
+ * doesn't match physical column size.
+ */
+ @Test
+ public void testConstructReplaceMapSizeMismatch() throws Exception {
+ MTMV mtmv = Mockito.mock(MTMV.class);
+ MTMVCache cache = Mockito.mock(MTMVCache.class);
+ Plan originalPlan = Mockito.mock(Plan.class);
+
+ // MV plan has 3 visible output slots
+ List<Slot> originOutputs = ImmutableList.of(
+ createMockSlot("col1", true),
+ createMockSlot("col2", true),
+ createMockSlot("col3", true));
+
+ Mockito.when(mtmv.getOrGenerateCache(Mockito.any())).thenReturn(cache);
+ Mockito.when(mtmv.getName()).thenReturn("test_mv");
+ Mockito.when(cache.getOriginalFinalPlan()).thenReturn(originalPlan);
+ Mockito.when(originalPlan.getOutput()).thenReturn(originOutputs);
+
+ // But MV physical table only has 2 columns (size mismatch with plan
output)
+ Mockito.when(mtmv.getBaseSchema()).thenReturn(ImmutableList.of(
+ createColumn("col1"), createColumn("col2")));
+
+ LogicalOlapScan scan = createMockScan(ImmutableList.of(
+ createMockSlot("col1", "col1", Collections.emptyList(), true),
+ createMockSlot("col2", "col2", Collections.emptyList(),
true)));
+
+ Map<Slot, Slot> replaceMap = scan.constructReplaceMap(mtmv);
+
+ Assertions.assertTrue(replaceMap.isEmpty(),
+ "replaceMap should be empty when plan output size doesn't
match physical column size");
+ }
+
+ /**
+ * Test constructReplaceMap ignores extra subPath slots in scan output
+ * (added by VariantSubPathPruning during query optimization).
+ */
+ @Test
+ public void testConstructReplaceMapIgnoresExtraScanSlots() throws
Exception {
+ MTMV mtmv = Mockito.mock(MTMV.class);
+ MTMVCache cache = Mockito.mock(MTMVCache.class);
+ Plan originalPlan = Mockito.mock(Plan.class);
+
+ SlotReference mvSlot = createMockSlot("col1", true);
+ Mockito.when(mtmv.getOrGenerateCache(Mockito.any())).thenReturn(cache);
+ Mockito.when(mtmv.getName()).thenReturn("test_mv");
+ Mockito.when(cache.getOriginalFinalPlan()).thenReturn(originalPlan);
+
Mockito.when(originalPlan.getOutput()).thenReturn(ImmutableList.of(mvSlot));
+
Mockito.when(mtmv.getBaseSchema()).thenReturn(ImmutableList.of(createColumn("col1")));
+
+ // Scan has base slot + extra subPath slot from VariantSubPathPruning
+ SlotReference scanSlotBase = createMockSlot("col1", "col1",
Collections.emptyList(), true);
+ SlotReference scanSlotHelper = createMockSlot("col1", "col1",
Arrays.asList("a", "b"), true);
+ LogicalOlapScan scan = createMockScan(ImmutableList.of(scanSlotBase,
scanSlotHelper));
+
+ Map<Slot, Slot> replaceMap = scan.constructReplaceMap(mtmv);
+
+ Assertions.assertEquals(1, replaceMap.size());
+ Assertions.assertSame(scanSlotBase, replaceMap.get(mvSlot));
+ }
+
+ /**
+ * Test constructReplaceMap correctly handles aliased columns.
+ * MV SQL: SELECT l_orderkey, sum_total AS agg3, max_total AS agg4 FROM mv1
+ * Plan slots have originalColumn names from source table (sum_total,
max_total),
+ * but MV physical columns are named agg3, agg4 (the aliases).
+ * Physical column name is used as the key, so the mapping succeeds.
+ */
+ @Test
+ public void testConstructReplaceMapWithAliasedColumns() throws Exception {
+ MTMV mtmv = Mockito.mock(MTMV.class);
+ MTMVCache cache = Mockito.mock(MTMVCache.class);
+ Plan originalPlan = Mockito.mock(Plan.class);
+
+ // MV plan output: slot names are from source table.
+ // Use 2-arg createMockSlot (no originalColumn) to avoid nested mock
Column objects
+ // causing Mockito UnfinishedStubbingException. constructReplaceMap
only calls
+ // getOriginalColumn() on scan slots, not on MV plan output slots.
+ SlotReference mvSlot1 = createMockSlot("l_orderkey", true);
+ SlotReference mvSlot2 = createMockSlot("sum_total", true);
+ SlotReference mvSlot3 = createMockSlot("max_total", true);
+
+ Mockito.when(mtmv.getOrGenerateCache(Mockito.any())).thenReturn(cache);
+ Mockito.when(mtmv.getName()).thenReturn("test_alias_mv");
+ Mockito.when(cache.getOriginalFinalPlan()).thenReturn(originalPlan);
+
Mockito.when(originalPlan.getOutput()).thenReturn(ImmutableList.of(mvSlot1,
mvSlot2, mvSlot3));
+
+ // Physical columns have aliased names
+ Mockito.when(mtmv.getBaseSchema()).thenReturn(ImmutableList.of(
+ createColumn("l_orderkey"),
+ createColumn("agg3"), // aliased from sum_total
+ createColumn("agg4") // aliased from max_total
+ ));
+
+ // Scan slots reference MV's physical column names
+ SlotReference scanSlot1 = createMockSlot("l_orderkey", "l_orderkey",
Collections.emptyList(), true);
+ SlotReference scanSlot2 = createMockSlot("agg3", "agg3",
Collections.emptyList(), true);
+ SlotReference scanSlot3 = createMockSlot("agg4", "agg4",
Collections.emptyList(), true);
+ LogicalOlapScan scan = createMockScan(ImmutableList.of(scanSlot1,
scanSlot2, scanSlot3));
+
+ Map<Slot, Slot> replaceMap = scan.constructReplaceMap(mtmv);
+
+ // All 3 should be mapped despite plan slots having different names
than scan slots
+ Assertions.assertEquals(3, replaceMap.size());
+ Assertions.assertSame(scanSlot1, replaceMap.get(mvSlot1));
+ Assertions.assertSame(scanSlot2, replaceMap.get(mvSlot2));
+ Assertions.assertSame(scanSlot3, replaceMap.get(mvSlot3));
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]