This is an automated email from the ASF dual-hosted git repository.
morrySnow 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 76bbe581b96 [fix](fe) Add null reject compensation for join rewrite
(#63268)
76bbe581b96 is described below
commit 76bbe581b96916ef071fc9c6ed7b812c37b00459
Author: seawinde <[email protected]>
AuthorDate: Mon May 18 15:55:30 2026 +0800
[fix](fe) Add null reject compensation for join rewrite (#63268)
### What problem does this PR solve?
Related PR: #62492
Problem Summary:
INNER JoinEdge null-reject inference can validate rewriting an INNER
JOIN query by an OUTER JOIN materialized view without adding the
required non-null compensation predicate. The rewritten plan can keep
null-padded rows from the MV side that should be rejected by the
original query.
Root cause: In AbstractMaterializedViewRule.predicatesCompensate(), the
previous check treated INNER JoinEdge null-reject inference as proof
that an OUTER JOIN MV rewrite was valid, but the proof was not
materialized as a real IS NOT NULL predicate in the rewritten query.
Change Summary:
AbstractMaterializedViewRule.java | Split predicate-based null-reject
proof from INNER JoinEdge proof and add query-based IS NOT NULL
compensation when only JoinEdge proof covers required MV nullable sides.
Fail rewrite if no safe MV output slot can carry the compensation
predicate.
NullRejectInferenceTest.java | Add unit coverage for LEFT/FULL OUTER
JOIN MV rewrites that require INNER JoinEdge null-reject compensation on
both sides.
inner_join_null_reject_compensation.groovy | Add regression coverage
with unmatched OUTER JOIN MV rows, including the LEFT JOIN MV to INNER
JOIN query repro with nullable join keys.
Design rationale: Existing query predicates already flow through normal
predicate compensation, so they do not need extra filters. INNER
JoinEdge proof is only logical evidence; when it is needed to reject
null-generated MV rows, the rewrite must add a real IS NOT NULL
predicate on an MV output slot. If no such slot is available, the
rewrite is rejected conservatively.
### Release note
Fixed an issue where OUTER JOIN materialized view rewrite could return
extra null-padded rows for INNER JOIN queries.
Co-authored-by: seawinde <[email protected]>
Co-authored-by: Copilot <[email protected]>
---
.../mv/AbstractMaterializedViewRule.java | 171 ++++++++++++----
.../exploration/mv/NullRejectInferenceTest.java | 120 +++++++++++-
.../mv/dimension/dimension_self_conn.groovy | 5 +-
.../inner_join_null_reject_compensation.groovy | 217 +++++++++++++++++++++
4 files changed, 474 insertions(+), 39 deletions(-)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AbstractMaterializedViewRule.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AbstractMaterializedViewRule.java
index a293e94d775..ef94ad458bf 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AbstractMaterializedViewRule.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AbstractMaterializedViewRule.java
@@ -18,6 +18,7 @@
package org.apache.doris.nereids.rules.exploration.mv;
import org.apache.doris.catalog.MTMV;
+import org.apache.doris.catalog.TableIf;
import org.apache.doris.common.AnalysisException;
import org.apache.doris.common.Id;
import org.apache.doris.common.Pair;
@@ -44,7 +45,9 @@ import
org.apache.doris.nereids.rules.expression.rules.FoldConstantRuleOnFE;
import org.apache.doris.nereids.rules.rewrite.MergeProjectable;
import org.apache.doris.nereids.trees.expressions.ComparisonPredicate;
import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.IsNull;
import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.Not;
import org.apache.doris.nereids.trees.expressions.Slot;
import org.apache.doris.nereids.trees.expressions.SlotReference;
import org.apache.doris.nereids.trees.expressions.functions.scalar.DateTrunc;
@@ -821,21 +824,28 @@ public abstract class AbstractMaterializedViewRule
implements ExplorationRuleFac
Set<Set<Slot>> requireNoNullableViewSlot =
comparisonResult.getViewNoNullableSlot();
// check query is use the null reject slot which view comparison need
if (!requireNoNullableViewSlot.isEmpty()) {
+ // Required null-reject slots are recorded on the view side. Map
query slots to view slots
+ // before checking whether query predicates or INNER JoinEdges can
reject those null rows.
SlotMapping queryToViewMapping = viewToQuerySlotMapping.inverse();
- // try to use
- boolean valid = containsNullRejectSlot(requireNoNullableViewSlot,
- queryStructInfo.getPredicates().getPulledUpPredicates(),
queryToViewMapping, queryStructInfo,
- viewStructInfo, cascadesContext);
- if (!valid) {
+ Optional<Set<Expression>>
queryBasedNullRejectCompensationPredicates =
+ getQueryBasedNullRejectCompensationPredicates(
+ requireNoNullableViewSlot,
+
queryStructInfo.getPredicates().getPulledUpPredicates(), queryToViewMapping,
+ queryStructInfo, viewStructInfo,
viewToQuerySlotMapping, cascadesContext);
+ if (!queryBasedNullRejectCompensationPredicates.isPresent()) {
queryStructInfo =
queryStructInfo.withPredicates(queryStructInfo.getPredicates()
.mergePulledUpPredicates(comparisonResult.getQueryAllPulledUpExpressions()));
- valid = containsNullRejectSlot(requireNoNullableViewSlot,
-
queryStructInfo.getPredicates().getPulledUpPredicates(), queryToViewMapping,
- queryStructInfo, viewStructInfo, cascadesContext);
+ queryBasedNullRejectCompensationPredicates =
getQueryBasedNullRejectCompensationPredicates(
+ requireNoNullableViewSlot,
queryStructInfo.getPredicates().getPulledUpPredicates(),
+ queryToViewMapping, queryStructInfo, viewStructInfo,
viewToQuerySlotMapping, cascadesContext);
}
- if (!valid) {
+ if (!queryBasedNullRejectCompensationPredicates.isPresent()) {
return SplitPredicate.INVALID_INSTANCE;
}
+ if (!queryBasedNullRejectCompensationPredicates.get().isEmpty()) {
+ queryStructInfo =
queryStructInfo.withPredicates(queryStructInfo.getPredicates()
+
.mergePulledUpPredicates(queryBasedNullRejectCompensationPredicates.get()));
+ }
}
// compensate couldNot PulledUp Conjunctions
Map<Expression, ExpressionInfo> couldNotPulledUpCompensateConjunctions
=
@@ -863,45 +873,106 @@ public abstract class AbstractMaterializedViewRule
implements ExplorationRuleFac
}
/**
- * Check the queryPredicates contains the required nullable slot
+ * Check whether query-side null-reject evidence covers each required
view-side slot set.
+ *
+ * <p>The check is view-based because the required null-reject slots come
from the MV join graph.
+ * The returned compensation predicates are query-based because they will
be merged into queryStructInfo.
+ *
+ * <p>Return meanings:
+ * Optional.empty(): no valid proof, or no safe output slot can carry the
compensation predicate.
+ * Optional.of(emptySet()): existing query predicates already provide the
required null-reject.
+ * Optional.of(nonEmptySet): INNER JoinEdge proof must be materialized as
these IS NOT NULL predicates.
*/
- private boolean containsNullRejectSlot(Set<Set<Slot>>
requireNoNullableViewSlot,
+ private Optional<Set<Expression>>
getQueryBasedNullRejectCompensationPredicates(
+ Set<Set<Slot>> requireNoNullableViewSlot,
Set<Expression> queryPredicates,
SlotMapping queryToViewMapping,
StructInfo queryStructInfo,
StructInfo viewStructInfo,
+ SlotMapping viewToQueryMapping,
CascadesContext cascadesContext) {
- Set<Slot> queryNullRejectSlots = new HashSet<>();
+ Set<Slot> predicateNullRejectViewSlots = getViewBasedNullRejectSlots(
+ getPredicateNullRejectSlots(queryPredicates, cascadesContext),
queryToViewMapping, queryStructInfo);
+ Set<Slot> innerJoinNullRejectViewSlots = getViewBasedNullRejectSlots(
+ getInnerJoinNullRejectSlots(queryStructInfo, cascadesContext),
queryToViewMapping, queryStructInfo);
+ Set<Slot> allNullRejectViewSlots = new
HashSet<>(predicateNullRejectViewSlots);
+ allNullRejectViewSlots.addAll(innerJoinNullRejectViewSlots);
+ if (allNullRejectViewSlots.isEmpty()) {
+ return Optional.empty();
+ }
+ Set<Slot> viewOutputSlots =
viewStructInfo.getPlanOutputShuttledExpressions().stream()
+ .filter(Slot.class::isInstance)
+ .map(Slot.class::cast)
+ .collect(Collectors.toSet());
+ Map<SlotReference, SlotReference> viewToQuerySlotReferenceMap =
viewToQueryMapping.toSlotReferenceMap();
+ Set<Expression> compensationPredicates = new HashSet<>();
+ for (Set<Slot> requiredViewSlots :
getShuttledRequireNoNullableViewSlots(
+ requireNoNullableViewSlot, viewStructInfo)) {
+ if (Sets.intersection(requiredViewSlots,
allNullRejectViewSlots).isEmpty()) {
+ return Optional.empty();
+ }
+ if (!Sets.intersection(requiredViewSlots,
predicateNullRejectViewSlots).isEmpty()) {
+ continue;
+ }
+ Optional<Slot> compensationViewSlot = findCompensationViewSlot(
+ requiredViewSlots, viewOutputSlots,
innerJoinNullRejectViewSlots);
+ if (!compensationViewSlot.isPresent()) {
+ return Optional.empty();
+ }
+ Slot querySlot =
viewToQuerySlotReferenceMap.get(compensationViewSlot.get());
+ if (querySlot == null) {
+ return Optional.empty();
+ }
+ compensationPredicates.add(new Not(new IsNull(querySlot), false));
+ }
+ return Optional.of(compensationPredicates);
+ }
+
+ private Set<Slot> getPredicateNullRejectSlots(Set<Expression>
queryPredicates, CascadesContext cascadesContext) {
+ Set<Slot> nullRejectSlots = new HashSet<>();
for (Expression queryPredicate : queryPredicates) {
- Optional<Slot> explicitNotNullSlot =
TypeUtils.isNotNull(queryPredicate);
- explicitNotNullSlot.ifPresent(queryNullRejectSlots::add);
+
TypeUtils.isNotNull(queryPredicate).ifPresent(nullRejectSlots::add);
}
- Set<Expression> queryNullRejectPredicates =
ExpressionUtils.inferNotNull(queryPredicates, cascadesContext);
- for (Expression queryNullRejectPredicate : queryNullRejectPredicates) {
- Optional<Slot> notNullSlot =
TypeUtils.isNotNull(queryNullRejectPredicate);
- notNullSlot.ifPresent(queryNullRejectSlots::add);
+ for (Expression inferredNotNull :
ExpressionUtils.inferNotNull(queryPredicates, cascadesContext)) {
+
TypeUtils.isNotNull(inferredNotNull).ifPresent(nullRejectSlots::add);
}
+ return nullRejectSlots;
+ }
+
+ private Set<Slot> getInnerJoinNullRejectSlots(StructInfo queryStructInfo,
CascadesContext cascadesContext) {
+ Set<Slot> nullRejectSlots = new HashSet<>();
// INNER JOIN conditions guarantee NOT NULL on join-key slots.
- // After EliminateOuterJoin converts LEFT→INNER, the JoinEdge objects
in the HyperGraph
+ // After EliminateOuterJoin converts LEFT to INNER, the JoinEdge
objects in the HyperGraph
// retain the INNER type even though EliminateNotNull removes
filter-level NOT NULL predicates.
for (JoinEdge joinEdge :
queryStructInfo.getHyperGraph().getJoinEdges()) {
if (joinEdge.getJoinType().isInnerJoin()) {
- queryNullRejectSlots.addAll(ExpressionUtils.inferNotNullSlots(
+ nullRejectSlots.addAll(ExpressionUtils.inferNotNullSlots(
ImmutableSet.copyOf(joinEdge.getExpressions()),
cascadesContext));
}
}
- if (queryNullRejectSlots.isEmpty()) {
- return false;
+ return nullRejectSlots;
+ }
+
+ private Set<Slot> getViewBasedNullRejectSlots(Set<Slot>
queryNullRejectSlots,
+ SlotMapping queryToViewMapping, StructInfo queryStructInfo) {
+ Set<Slot> viewBasedSlots = new HashSet<>();
+ for (Slot queryNullRejectSlot : queryNullRejectSlots) {
+ Expression shuttledQuerySlot =
ExpressionUtils.shuttleExpressionWithLineage(
+ queryNullRejectSlot, queryStructInfo.getTopPlan());
+ if (!(shuttledQuerySlot instanceof Slot)) {
+ continue;
+ }
+ Expression viewSlot = ExpressionUtils.replace(shuttledQuerySlot,
+ queryToViewMapping.toSlotReferenceMap());
+ if (viewSlot instanceof Slot) {
+ viewBasedSlots.add((Slot) viewSlot);
+ }
}
- Set<Slot> queryUsedNeedRejectNullSlotsViewBased =
ExpressionUtils.shuttleExpressionWithLineage(
- new ArrayList<>(queryNullRejectSlots),
queryStructInfo.getTopPlan()).stream()
- .filter(Slot.class::isInstance)
- .map(Slot.class::cast)
- .map(slot -> ExpressionUtils.replace(slot,
queryToViewMapping.toSlotReferenceMap()))
- .filter(Slot.class::isInstance)
- .map(Slot.class::cast)
- .collect(Collectors.toSet());
- // view slot need shuttle to use table slot, avoid alias influence
+ return viewBasedSlots;
+ }
+
+ private Set<Set<Slot>>
getShuttledRequireNoNullableViewSlots(Set<Set<Slot>> requireNoNullableViewSlot,
+ StructInfo viewStructInfo) {
Set<Set<Slot>> shuttledRequireNoNullableViewSlot = new HashSet<>();
for (Set<Slot> requireNullableSlots : requireNoNullableViewSlot) {
shuttledRequireNoNullableViewSlot.add(
@@ -909,9 +980,41 @@ public abstract class AbstractMaterializedViewRule
implements ExplorationRuleFac
viewStructInfo.getTopPlan()).stream().map(Slot.class::cast)
.collect(Collectors.toSet()));
}
- // query pulledUp predicates should have null reject predicates and
contains any require noNullable slot
- return
shuttledRequireNoNullableViewSlot.stream().noneMatch(viewRequiredNullSlotSet ->
- Sets.intersection(viewRequiredNullSlotSet,
queryUsedNeedRejectNullSlotsViewBased).isEmpty());
+ return shuttledRequireNoNullableViewSlot;
+ }
+
+ private Optional<Slot> findCompensationViewSlot(Set<Slot>
requiredViewSlots, Set<Slot> viewOutputSlots,
+ Set<Slot> innerJoinNullRejectViewSlots) {
+ Set<Slot> outputRequiredSlots = Sets.intersection(requiredViewSlots,
viewOutputSlots);
+ Optional<Slot> compensationViewSlot = outputRequiredSlots.stream()
+ .filter(innerJoinNullRejectViewSlots::contains)
+ .findFirst();
+ if (compensationViewSlot.isPresent()) {
+ return compensationViewSlot;
+ }
+ return outputRequiredSlots.stream()
+ .filter(slot ->
isOriginalNonNullableSlotOnInnerJoinProofTable(slot,
innerJoinNullRejectViewSlots))
+ .findFirst();
+ }
+
+ private boolean isOriginalNonNullableSlotOnInnerJoinProofTable(Slot slot,
Set<Slot> innerJoinNullRejectViewSlots) {
+ if (!(slot instanceof SlotReference)) {
+ return false;
+ }
+ SlotReference slotReference = (SlotReference) slot;
+ if (!slotReference.getOriginalColumn().map(column ->
!column.isAllowNull()).orElse(!slot.nullable())) {
+ return false;
+ }
+ Optional<TableIf> originalTable = slotReference.getOriginalTable();
+ if (!originalTable.isPresent()) {
+ return false;
+ }
+ return innerJoinNullRejectViewSlots.stream()
+ .filter(SlotReference.class::isInstance)
+ .map(SlotReference.class::cast)
+ .map(SlotReference::getOriginalTable)
+ .anyMatch(referenceTable -> referenceTable.isPresent()
+ && referenceTable.get().equals(originalTable.get()));
}
/**
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/NullRejectInferenceTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/NullRejectInferenceTest.java
index 21cbf0ccf94..5fd7628096b 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/NullRejectInferenceTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/NullRejectInferenceTest.java
@@ -24,6 +24,10 @@ import
org.apache.doris.nereids.rules.exploration.mv.Predicates.SplitPredicate;
import org.apache.doris.nereids.rules.exploration.mv.mapping.RelationMapping;
import org.apache.doris.nereids.rules.exploration.mv.mapping.SlotMapping;
import org.apache.doris.nereids.sqltest.SqlTestBase;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.IsNull;
+import org.apache.doris.nereids.trees.expressions.Not;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
import org.apache.doris.nereids.trees.plans.Plan;
import org.apache.doris.nereids.util.PlanChecker;
@@ -41,8 +45,10 @@ class NullRejectInferenceTest extends SqlTestBase {
void testTwoHopNullRejectFromInnerJoinConditions() {
connectContext.getSessionVariable().setDisableNereidsRules("INFER_PREDICATES,PRUNE_EMPTY_PARTITION");
CascadesContext queryContext = createCascadesContext(
- "select T1.id from T1 inner join T2 on T1.id = T2.id "
- + "inner join T3 on T2.id = T3.id where T3.score = 1",
+ "select lineitem.l_orderkey, supplier.s_name, nation.n_name
from lineitem "
+ + "inner join supplier on lineitem.l_suppkey =
supplier.s_suppkey "
+ + "inner join nation on supplier.s_nationkey =
nation.n_nationkey "
+ + "where nation.n_name = 'CHINA'",
connectContext
);
Plan queryPlan = PlanChecker.from(queryContext)
@@ -52,8 +58,9 @@ class NullRejectInferenceTest extends SqlTestBase {
.getAllPlan().get(0).child(0);
CascadesContext viewContext = createCascadesContext(
- "select T1.id from T1 left outer join T2 on T1.id = T2.id "
- + "left outer join T3 on T2.id = T3.id",
+ "select lineitem.l_orderkey, supplier.s_name, nation.n_name
from lineitem "
+ + "left outer join supplier on lineitem.l_suppkey =
supplier.s_suppkey "
+ + "left outer join nation on supplier.s_nationkey =
nation.n_nationkey",
connectContext
);
Plan viewPlan = PlanChecker.from(viewContext)
@@ -79,6 +86,111 @@ class NullRejectInferenceTest extends SqlTestBase {
SplitPredicate compensatePredicates =
TEST_RULE.predicatesCompensateForTest(
queryStructInfo, viewStructInfo, viewToQuery,
comparisonResult, queryContext);
Assertions.assertFalse(compensatePredicates.isInvalid());
+ Assertions.assertTrue(compensatePredicates.toList().stream()
+ .anyMatch(expression -> isNotNullOnSlot(expression,
"s_name")));
+ }
+
+ @Test
+ void testNullRejectCompensationForInnerJoinFullJoinRewrite() {
+
connectContext.getSessionVariable().setDisableNereidsRules("INFER_PREDICATES,PRUNE_EMPTY_PARTITION");
+ CascadesContext queryContext = createCascadesContext(
+ "select lineitem.l_shipdate, orders.o_orderdate from lineitem "
+ + "inner join orders on lineitem.l_orderkey =
orders.o_orderkey "
+ + "where orders.o_orderdate = '2023-10-17'",
+ connectContext
+ );
+ Plan queryPlan = PlanChecker.from(queryContext)
+ .analyze()
+ .rewrite()
+ .applyExploration(RuleSet.BUSHY_TREE_JOIN_REORDER)
+ .getAllPlan().get(0).child(0);
+
+ CascadesContext viewContext = createCascadesContext(
+ "select lineitem.l_shipdate, orders.o_orderdate from lineitem "
+ + "full outer join orders on lineitem.l_orderkey =
orders.o_orderkey",
+ connectContext
+ );
+ Plan viewPlan = PlanChecker.from(viewContext)
+ .analyze()
+ .rewrite()
+ .applyExploration(RuleSet.BUSHY_TREE_JOIN_REORDER)
+ .getAllPlan().get(0).child(0);
+
+ StructInfo queryStructInfo = StructInfo.of(queryPlan, queryPlan,
queryContext);
+ StructInfo viewStructInfo = StructInfo.of(viewPlan, viewPlan,
viewContext);
+ RelationMapping relationMapping = RelationMapping.generate(
+ queryStructInfo.getRelations(), viewStructInfo.getRelations(),
8).get(0);
+ SlotMapping queryToView = SlotMapping.generate(relationMapping);
+ SlotMapping viewToQuery = queryToView.inverse();
+ LogicalCompatibilityContext compatibilityContext =
LogicalCompatibilityContext.from(
+ relationMapping, viewToQuery, queryStructInfo, viewStructInfo);
+ ComparisonResult comparisonResult = StructInfo.isGraphLogicalEquals(
+ queryStructInfo, viewStructInfo, compatibilityContext);
+
+ Assertions.assertFalse(comparisonResult.isInvalid());
+
Assertions.assertFalse(comparisonResult.getViewNoNullableSlot().isEmpty());
+
+ SplitPredicate compensatePredicates =
TEST_RULE.predicatesCompensateForTest(
+ queryStructInfo, viewStructInfo, viewToQuery,
comparisonResult, queryContext);
+ Assertions.assertFalse(compensatePredicates.isInvalid());
+ Assertions.assertTrue(compensatePredicates.toList().stream()
+ .anyMatch(expression -> isNotNullOnSlot(expression,
"l_shipdate")));
+ }
+
+ @Test
+ void testNullRejectCompensationForInnerJoinFullJoinRewriteOnRightSide() {
+
connectContext.getSessionVariable().setDisableNereidsRules("INFER_PREDICATES,PRUNE_EMPTY_PARTITION");
+ CascadesContext queryContext = createCascadesContext(
+ "select lineitem.l_shipdate, orders.o_orderdate from lineitem "
+ + "inner join orders on lineitem.l_orderkey =
orders.o_orderkey "
+ + "where lineitem.l_shipdate = '2023-10-17'",
+ connectContext
+ );
+ Plan queryPlan = PlanChecker.from(queryContext)
+ .analyze()
+ .rewrite()
+ .applyExploration(RuleSet.BUSHY_TREE_JOIN_REORDER)
+ .getAllPlan().get(0).child(0);
+
+ CascadesContext viewContext = createCascadesContext(
+ "select lineitem.l_shipdate, orders.o_orderdate from lineitem "
+ + "full outer join orders on lineitem.l_orderkey =
orders.o_orderkey",
+ connectContext
+ );
+ Plan viewPlan = PlanChecker.from(viewContext)
+ .analyze()
+ .rewrite()
+ .applyExploration(RuleSet.BUSHY_TREE_JOIN_REORDER)
+ .getAllPlan().get(0).child(0);
+
+ StructInfo queryStructInfo = StructInfo.of(queryPlan, queryPlan,
queryContext);
+ StructInfo viewStructInfo = StructInfo.of(viewPlan, viewPlan,
viewContext);
+ RelationMapping relationMapping = RelationMapping.generate(
+ queryStructInfo.getRelations(), viewStructInfo.getRelations(),
8).get(0);
+ SlotMapping queryToView = SlotMapping.generate(relationMapping);
+ SlotMapping viewToQuery = queryToView.inverse();
+ LogicalCompatibilityContext compatibilityContext =
LogicalCompatibilityContext.from(
+ relationMapping, viewToQuery, queryStructInfo, viewStructInfo);
+ ComparisonResult comparisonResult = StructInfo.isGraphLogicalEquals(
+ queryStructInfo, viewStructInfo, compatibilityContext);
+
+ Assertions.assertFalse(comparisonResult.isInvalid());
+
Assertions.assertFalse(comparisonResult.getViewNoNullableSlot().isEmpty());
+
+ SplitPredicate compensatePredicates =
TEST_RULE.predicatesCompensateForTest(
+ queryStructInfo, viewStructInfo, viewToQuery,
comparisonResult, queryContext);
+ Assertions.assertFalse(compensatePredicates.isInvalid());
+ Assertions.assertTrue(compensatePredicates.toList().stream()
+ .anyMatch(expression -> isNotNullOnSlot(expression,
"o_orderdate")));
+ }
+
+ private static boolean isNotNullOnSlot(Expression expression, String
slotName) {
+ if (!(expression instanceof Not) || ((Not)
expression).isGeneratedIsNotNull()
+ || !(((Not) expression).child() instanceof IsNull)) {
+ return false;
+ }
+ Expression slot = ((IsNull) ((Not) expression).child()).child();
+ return slot instanceof SlotReference &&
slotName.equals(((SlotReference) slot).getName());
}
private static class TestMaterializedViewRule extends
AbstractMaterializedViewRule {
diff --git
a/regression-test/suites/nereids_rules_p0/mv/dimension/dimension_self_conn.groovy
b/regression-test/suites/nereids_rules_p0/mv/dimension/dimension_self_conn.groovy
index 6bac965910f..e63203ada1c 100644
---
a/regression-test/suites/nereids_rules_p0/mv/dimension/dimension_self_conn.groovy
+++
b/regression-test/suites/nereids_rules_p0/mv/dimension/dimension_self_conn.groovy
@@ -308,7 +308,10 @@ suite("partition_mv_rewrite_dimension_self_conn") {
} else {
for (int j = 0; j < join_type_stmt_list.size(); j++) {
logger.info("j:" + j)
- if (i == j || (j == 1 && i in [0, 2, 3])) {
+ // INNER query can use an OUTER JOIN MV only when the MV
output has a safe slot
+ // to filter null-generated rows. Here only RIGHT JOIN MV
exposes a non-nullable
+ // left-side slot for that compensation.
+ if (i == j || (j == 1 && i == 2)) {
mv_rewrite_success(join_type_stmt_list[j],
join_type_self_conn_mv)
compare_res(join_type_stmt_list[j] + " order by 1,2,3")
} else {
diff --git
a/regression-test/suites/nereids_rules_p0/mv/join_infer_derive/inner_join_null_reject_compensation.groovy
b/regression-test/suites/nereids_rules_p0/mv/join_infer_derive/inner_join_null_reject_compensation.groovy
new file mode 100644
index 00000000000..a678dd461c8
--- /dev/null
+++
b/regression-test/suites/nereids_rules_p0/mv/join_infer_derive/inner_join_null_reject_compensation.groovy
@@ -0,0 +1,217 @@
+// 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("inner_join_null_reject_compensation") {
+ String db = context.config.getDbNameByFile(context.file)
+ sql "use ${db}"
+ sql "set runtime_filter_mode=OFF"
+ sql "set enable_nereids_planner=true"
+ sql "set enable_fallback_to_original_planner=false"
+ sql "set enable_materialized_view_rewrite=true"
+ sql "set pre_materialized_view_rewrite_strategy=FORCE_IN_RBO"
+ sql "set enable_nereids_timeout=false"
+
+ sql """drop materialized view if exists
mv_inner_join_null_reject_compensation"""
+ sql """drop materialized view if exists mv_repro_left_join"""
+ sql """drop materialized view if exists
mv_repro_left_join_missing_right_output"""
+ sql """drop materialized view if exists
mv_repro_left_join_nullable_right_output"""
+ sql """drop table if exists mv_repro_a"""
+ sql """drop table if exists mv_repro_b"""
+ sql """drop table if exists orders_inner_join_null_reject"""
+ sql """drop table if exists lineitem_inner_join_null_reject"""
+
+ sql """
+ create table lineitem_inner_join_null_reject (
+ l_orderkey int not null,
+ l_shipdate date not null,
+ l_suppkey int not null
+ )
+ duplicate key(l_orderkey)
+ distributed by hash(l_orderkey) buckets 1
+ properties (
+ "replication_num" = "1"
+ )
+ """
+
+ sql """
+ create table orders_inner_join_null_reject (
+ o_orderkey int not null,
+ o_orderdate date not null
+ )
+ duplicate key(o_orderkey)
+ distributed by hash(o_orderkey) buckets 1
+ properties (
+ "replication_num" = "1"
+ )
+ """
+
+ sql """
+ create table mv_repro_a (
+ id int null,
+ k int null
+ )
+ duplicate key(id)
+ distributed by hash(id) buckets 1
+ properties (
+ "replication_num" = "1"
+ )
+ """
+
+ sql """
+ create table mv_repro_b (
+ k int null,
+ v int null
+ )
+ duplicate key(k)
+ distributed by hash(k) buckets 1
+ properties (
+ "replication_num" = "1"
+ )
+ """
+
+ sql """
+ insert into lineitem_inner_join_null_reject values
+ (1, '2023-10-17', 10),
+ (999, '2023-10-17', 20)
+ """
+
+ sql """
+ insert into orders_inner_join_null_reject values
+ (1, '2023-10-17'),
+ (888, '2023-10-17')
+ """
+
+ sql """
+ insert into mv_repro_a values
+ (1, 10),
+ (2, 20)
+ """
+
+ sql """
+ insert into mv_repro_b values
+ (10, 100)
+ """
+
+ sql """analyze table lineitem_inner_join_null_reject with sync"""
+ sql """analyze table orders_inner_join_null_reject with sync"""
+ sql """analyze table mv_repro_a with sync"""
+ sql """analyze table mv_repro_b with sync"""
+
+ def withUseMvHint = { def stmt, def mvName ->
+ stmt.replaceFirst("(?i)\\bselect\\b", "select /*+ use_mv(${mvName})
*/")
+ }
+
+ def compare_res_with_forced_mv = { def stmt, def mvName ->
+ def stmtWithUseMvHint = withUseMvHint(stmt, mvName)
+ sql "set enable_materialized_view_rewrite=false"
+ def origin_res = sql stmt
+ logger.info("origin_res: " + origin_res)
+ sql "set enable_materialized_view_rewrite=true"
+ mv_rewrite_success(stmtWithUseMvHint, mvName)
+ def mv_origin_res = sql stmtWithUseMvHint
+ logger.info("mv_origin_res: " + mv_origin_res)
+ assertTrue((mv_origin_res == [] && origin_res == []) ||
(mv_origin_res.size() == origin_res.size()))
+ for (int row = 0; row < mv_origin_res.size(); row++) {
+ assertTrue(mv_origin_res[row].size() == origin_res[row].size())
+ for (int col = 0; col < mv_origin_res[row].size(); col++) {
+ assertTrue(mv_origin_res[row][col] == origin_res[row][col])
+ }
+ }
+ }
+
+ def mvName = "mv_inner_join_null_reject_compensation"
+ def mvSql = """
+ select l.l_shipdate, l.l_suppkey, o.o_orderdate
+ from lineitem_inner_join_null_reject l
+ full outer join orders_inner_join_null_reject o
+ on l.l_orderkey = o.o_orderkey
+ """
+
+ create_async_mv(db, mvName, mvSql)
+
+ def queryNeedLeftSideCompensation = """
+ select l.l_shipdate, l.l_suppkey, o.o_orderdate
+ from lineitem_inner_join_null_reject l
+ inner join orders_inner_join_null_reject o
+ on l.l_orderkey = o.o_orderkey
+ where o.o_orderdate = '2023-10-17'
+ order by 1, 2, 3
+ """
+
+ def queryNeedRightSideCompensation = """
+ select l.l_shipdate, l.l_suppkey, o.o_orderdate
+ from lineitem_inner_join_null_reject l
+ inner join orders_inner_join_null_reject o
+ on l.l_orderkey = o.o_orderkey
+ where l.l_shipdate = '2023-10-17'
+ order by 1, 2, 3
+ """
+
+ compare_res_with_forced_mv(queryNeedLeftSideCompensation, mvName)
+ compare_res_with_forced_mv(queryNeedRightSideCompensation, mvName)
+
+ def leftJoinMvName = "mv_repro_left_join"
+ def leftJoinMvSql = """
+ select
+ a.id as a_id,
+ a.k as a_k,
+ b.k as b_k,
+ b.v as b_v
+ from mv_repro_a a
+ left join mv_repro_b b
+ on a.k = b.k
+ """
+
+ create_async_mv(db, leftJoinMvName, leftJoinMvSql)
+
+ def innerJoinQueryOnLeftJoinMv = """
+ select a.id
+ from mv_repro_a a
+ inner join mv_repro_b b
+ on a.k = b.k
+ order by 1
+ """
+
+ compare_res_with_forced_mv(innerJoinQueryOnLeftJoinMv, leftJoinMvName)
+
+ def leftJoinMvWithoutRightOutputName =
"mv_repro_left_join_missing_right_output"
+ def leftJoinMvWithoutRightOutputSql = """
+ select
+ a.id as a_id,
+ a.k as a_k
+ from mv_repro_a a
+ left join mv_repro_b b
+ on a.k = b.k
+ """
+
+ create_async_mv(db, leftJoinMvWithoutRightOutputName,
leftJoinMvWithoutRightOutputSql)
+ mv_rewrite_fail(innerJoinQueryOnLeftJoinMv,
leftJoinMvWithoutRightOutputName)
+
+ def leftJoinMvWithNullableRightOutputName =
"mv_repro_left_join_nullable_right_output"
+ def leftJoinMvWithNullableRightOutputSql = """
+ select
+ a.id as a_id,
+ a.k as a_k,
+ b.v as b_v
+ from mv_repro_a a
+ left join mv_repro_b b
+ on a.k = b.k
+ """
+
+ create_async_mv(db, leftJoinMvWithNullableRightOutputName,
leftJoinMvWithNullableRightOutputSql)
+ mv_rewrite_fail(innerJoinQueryOnLeftJoinMv,
leftJoinMvWithNullableRightOutputName)
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]