This is an automated email from the ASF dual-hosted git repository.

Gabriel39 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 427a6400802 [fix](nereids) Rewrite non_nullable MV outputs for 
nullable source columns (#70219)
427a6400802 is described below

commit 427a6400802cdc036f6264567490dfa073f1fa29
Author: Gabriel <[email protected]>
AuthorDate: Thu Nov 5 17:16:21 2026 +0800

    [fix](nereids) Rewrite non_nullable MV outputs for nullable source columns 
(#70219)
    
    ### What problem does this PR solve?
    
    Problem Summary:
    
    An asynchronous MV defined with `non_nullable(vec) AS vec` cannot
    rewrite queries selecting the original nullable `vec`. This prevents
    transparent use of NOT NULL materialized vector columns and their ANN
    indexes. The missing value mapping is in the shared async
    materialization context, so it affects external catalogs generally.
    
    Keep the existing `non_nullable(slot)` mapping and add one equivalent
    raw-slot mapping to a same-type NOT NULL MV output. Preserve query
    nullability and ExprIds through existing output alignment. Prefer direct
    outputs and avoid multiplying equivalent mapping alternatives. Also
    recognize value-preserving nullable wrappers in ANN TopN virtual
    expressions, while keeping surviving casts on the scalar path because
    ANN requires a direct indexed slot. Request early MV rewriting only when
    the actual sole order-key Alias directly computes approximate distance,
    so ANN pushdown runs after discovering the MV index under the default
    strategy without adding a pre-optimizer for unrelated projections.
    
    ### Release note
    
    Allow nullable source-column queries to use asynchronous MVs
    materialized with `non_nullable(column)`, including ANN TopN queries,
    without adding the assertion to the query.
    
    ### Check List (For Author)
    
    - Test:
    - [x] Unit Test: all 31 focused FE tests pass with no skips. Five new
    assertion cases for cast fallback and order-key eligibility fail before
    the guard fixes and pass afterward. Existing mapping, nullable ANN and
    strategy controls remain covered.
    - [x] Regression test: the native suite passes after the guard fixes,
    covering scalar/array projection, filters, ANN plan and execution
    counter, cast-vector scalar fallback, outer-join NULLs, failed
    refresh/fallback and partial partition UNION. The two cast queries
    compare an indexed MV with the unrewritten scalar source and assert no
    ANN SORT INFO. Loopback JDBC source comparison was validated for the
    initial mapping change and was not rerun for this update.
    - Added gated external suites for Hive, Iceberg, Paimon, Hudi, JDBC,
    ADBC, Elasticsearch, Fluss, plugin-based sources and MaxCompute. Hive
    ORC/Parquet and Iceberg include array columns and ANN queries.
    - Quote reserved schema identifiers in the external MV regression source
    paths, including sources reused by metadata, DDL and vector queries.
    Both affected suites pass SQL-generation checks covering 159 statements
    and seven shared-helper invocations; all 12 related Groovy scripts
    compile. Full external fixture execution remains for CI.
    - Remote source suites require their respective fixtures and were not
    executed in the local environment. Local SQL validation uses the
    modified FE with an earlier master ASAN BE build; this PR has no BE code
    changes.
        - FE build, focused incremental compilation and FE Checkstyle.
    - CI triage at `788935f3` found shared object-storage authorization
    failures across regression builds; no new PR-suite failure was observed
    in the collected evidence. Shared access must be restored before a full
    pipeline result is conclusive. The native MV/ANN suites passed in P0 and
    cloud, and the new Hive, Iceberg, Paimon and ADBC suites passed
    externally at that head.
    - Behavior changed:
    - [x] Yes. More queries can use equivalent MV outputs. Source schema,
    NULL assertion failures and freshness checks are preserved.
    - Does this need documentation?
        - [x] No.
---
 .../mv/AsyncMaterializationContext.java            |  26 +++
 .../mv/PreMaterializedViewRewriter.java            |  25 ++-
 .../rewrite/PushDownVectorTopNIntoOlapScan.java    |  15 +-
 .../mv/AsyncMaterializationContextTest.java        | 105 ++++++++++++
 .../mv/PreMaterializedViewRewriterTopNTest.java    | 100 ++++++++++++
 .../PushDownVectorTopNIntoOlapScanTest.java        | 119 ++++++++++++++
 .../mv/non_nullable/non_nullable_mv_rewrite.out    |  56 +++++++
 .../plugins/plugin_non_nullable_mtmv.groovy        |  85 ++++++++++
 .../adbc/test_non_nullable_adbc_mtmv.groovy        |  48 ++++++
 .../fluss/test_non_nullable_fluss_mtmv.groovy      |  30 ++++
 .../jdbc/test_non_nullable_jdbc_mtmv.groovy        |  35 ++++
 .../hive/test_non_nullable_trino_mtmv.groovy       |  34 ++++
 .../es/test_non_nullable_es_mtmv.groovy            |  34 ++++
 .../hudi/test_non_nullable_hudi_mtmv.groovy        |  36 ++++
 .../test_non_nullable_maxcompute_mtmv.groovy       |  36 ++++
 .../mtmv_p0/test_non_nullable_hive_mtmv.groovy     |  42 +++++
 .../mtmv_p0/test_non_nullable_iceberg_mtmv.groovy  |  39 +++++
 .../mtmv_p0/test_non_nullable_paimon_mtmv.groovy   |  34 ++++
 .../mv/non_nullable/non_nullable_mv_rewrite.groovy | 181 +++++++++++++++++++++
 19 files changed, 1075 insertions(+), 5 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AsyncMaterializationContext.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AsyncMaterializationContext.java
index a6e5c23c637..96e8902ea07 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AsyncMaterializationContext.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AsyncMaterializationContext.java
@@ -26,6 +26,9 @@ import org.apache.doris.mtmv.MTMVCache;
 import org.apache.doris.mtmv.MTMVRelatedTableIf;
 import org.apache.doris.nereids.CascadesContext;
 import org.apache.doris.nereids.rules.exploration.mv.mapping.ExpressionMapping;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.NonNullable;
 import org.apache.doris.nereids.trees.plans.ObjectId;
 import org.apache.doris.nereids.trees.plans.Plan;
 import org.apache.doris.nereids.trees.plans.PreAggStatus;
@@ -72,6 +75,29 @@ public class AsyncMaterializationContext extends 
MaterializationContext {
         return mtmv;
     }
 
+    @Override
+    public void tryGenerateScanPlan(CascadesContext cascadesContext) {
+        super.tryGenerateScanPlan(cascadesContext);
+        if (!isAvailable()) {
+            return;
+        }
+        Multimap<Expression, Expression> mapping = 
shuttledExprToScanExprMapping.getExpressionMapping();
+        for (Expression expression : planOutputShuttledExpressions) {
+            if (!(expression instanceof NonNullable) || !(expression.child(0) 
instanceof SlotReference)
+                    || mapping.containsKey(expression.child(0))) {
+                continue;
+            }
+            // A successful non_nullable(slot) materialization preserves the 
slot's values. This
+            // mapping is local to the MV and does not make the source column 
globally non-nullable.
+            for (Expression target : mapping.get(expression)) {
+                if (!target.nullable() && 
target.getDataType().equals(expression.child(0).getDataType())) {
+                    mapping.put(expression.child(0), target);
+                    break;
+                }
+            }
+        }
+    }
+
     @Override
     Plan doGenerateScanPlan(CascadesContext cascadesContext) {
         return MaterializedViewUtils.generateMvScanPlan(this.mtmv, 
this.mtmv.getBaseIndexId(),
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/PreMaterializedViewRewriter.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/PreMaterializedViewRewriter.java
index 717dbef4a0a..74f4927a735 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/PreMaterializedViewRewriter.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/PreMaterializedViewRewriter.java
@@ -24,7 +24,13 @@ import org.apache.doris.nereids.StatementContext;
 import org.apache.doris.nereids.jobs.executor.Optimizer;
 import org.apache.doris.nereids.memo.Group;
 import org.apache.doris.nereids.rules.RuleType;
+import org.apache.doris.nereids.trees.expressions.Alias;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import 
org.apache.doris.nereids.trees.expressions.functions.scalar.InnerProductApproximate;
+import 
org.apache.doris.nereids.trees.expressions.functions.scalar.L2DistanceApproximate;
 import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
+import org.apache.doris.nereids.trees.plans.logical.LogicalTopN;
 import org.apache.doris.nereids.trees.plans.physical.PhysicalPlan;
 
 import org.apache.commons.lang3.EnumUtils;
@@ -170,7 +176,24 @@ public class PreMaterializedViewRewriter {
         PreRewriteStrategy preRewriteStrategy = PreRewriteStrategy.getEnum(
                 
statementContext.getConnectContext().getSessionVariable().getPreMaterializedViewRewriteStrategy());
         boolean shouldPreRewrite = !needPreRewriteRuleSet.isEmpty()
-                || PreRewriteStrategy.FORCE_IN_RBO.equals(preRewriteStrategy);
+                || PreRewriteStrategy.FORCE_IN_RBO.equals(preRewriteStrategy)
+                // ANN pushdown runs in RBO, so discover an MV's index before 
that stage finishes.
+                || cascadesContext.getRewritePlan().anyMatch(node -> {
+                    if (!(node instanceof LogicalTopN)) {
+                        return false;
+                    }
+                    LogicalTopN<?> topN = (LogicalTopN<?>) node;
+                    if (topN.getOrderKeys().size() != 1 || !(topN.child() 
instanceof LogicalProject)
+                            || !(topN.getOrderKeys().get(0).getExpr() 
instanceof SlotReference)) {
+                        return false;
+                    }
+                    SlotReference orderKey = (SlotReference) 
topN.getOrderKeys().get(0).getExpr();
+                    // Only the order-key alias can enable ANN pushdown and 
justify another optimizer pass.
+                    return ((LogicalProject<?>) 
topN.child()).getProjects().stream().anyMatch(project ->
+                            project instanceof Alias && 
project.getExprId().equals(orderKey.getExprId())
+                                    && (((Alias) project).child() instanceof 
L2DistanceApproximate
+                                            || ((Alias) project).child() 
instanceof InnerProductApproximate));
+                });
         if (!shouldPreRewrite && LOG.isDebugEnabled()) {
             LOG.debug("does not need pre rewrite, because 
needPreRewriteRuleSet is empty or "
                             + "preRewriteStrategy is not FORCE_IN_RBO, query 
id is {}",
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushDownVectorTopNIntoOlapScan.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushDownVectorTopNIntoOlapScan.java
index 2f238f2b093..6fc5e201b2d 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushDownVectorTopNIntoOlapScan.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PushDownVectorTopNIntoOlapScan.java
@@ -24,12 +24,12 @@ import org.apache.doris.catalog.info.IndexType;
 import org.apache.doris.nereids.rules.Rule;
 import org.apache.doris.nereids.rules.RuleType;
 import org.apache.doris.nereids.trees.expressions.Alias;
-import org.apache.doris.nereids.trees.expressions.Cast;
 import org.apache.doris.nereids.trees.expressions.Expression;
 import org.apache.doris.nereids.trees.expressions.NamedExpression;
 import org.apache.doris.nereids.trees.expressions.SlotReference;
 import 
org.apache.doris.nereids.trees.expressions.functions.scalar.InnerProductApproximate;
 import 
org.apache.doris.nereids.trees.expressions.functions.scalar.L2DistanceApproximate;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.Nullable;
 import org.apache.doris.nereids.trees.plans.Plan;
 import org.apache.doris.nereids.trees.plans.logical.LogicalFilter;
 import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan;
@@ -116,10 +116,17 @@ public class PushDownVectorTopNIntoOlapScan implements 
RewriteRuleFactory {
             left = innerProductApproximate.left();
         }
 
-        while (left instanceof Cast) {
-            left = ((Cast) left).child();
+        Expression annOrderKeyExpr = orderKeyExpr;
+        if (left instanceof Nullable) {
+            // MV rewriting preserves nullable query outputs with a 
value-preserving wrapper.
+            // The ANN runtime needs the underlying indexed slot in its 
virtual expression.
+            while (left instanceof Nullable) {
+                left = left.child(0);
+            }
+            annOrderKeyExpr = orderKeyExpr.withChildren(left, 
orderKeyExpr.child(1));
         }
 
+        // ANN evaluates the indexed slot directly; surviving casts must stay 
on the scalar path.
         if (l2Dist) {
             if (!(left instanceof SlotReference && ((L2DistanceApproximate) 
orderKeyExpr).right().isConstant())) {
                 return null;
@@ -160,7 +167,7 @@ public class PushDownVectorTopNIntoOlapScan implements 
RewriteRuleFactory {
 
         // Keep the score that BindScoreToOlapScan bound to the scan.
         Plan plan = scan.appendVirtualColumnsAndTopN(
-                ImmutableList.of(orderKeyAlias),
+                ImmutableList.of((Alias) 
orderKeyAlias.withChildren(annOrderKeyExpr)),
                 topN.getOrderKeys(), Optional.of(topN.getLimit() + 
topN.getOffset()),
                 scan.getScoreOrderKeys(), scan.getScoreLimit(), 
scan.getScoreRangeInfo());
 
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/AsyncMaterializationContextTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/AsyncMaterializationContextTest.java
index 2490cead969..4f5d6c0cf35 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/AsyncMaterializationContextTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/AsyncMaterializationContextTest.java
@@ -24,7 +24,13 @@ import org.apache.doris.mtmv.MTMVRelatedTableIf;
 import org.apache.doris.nereids.CascadesContext;
 import org.apache.doris.nereids.StatementContext;
 import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.NonNullable;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.Nullable;
 import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.types.ArrayType;
+import org.apache.doris.nereids.types.FloatType;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -40,6 +46,105 @@ import java.util.Set;
 
 public class AsyncMaterializationContextTest {
 
+    @Test
+    public void testNonNullableOutputAlsoRepresentsSourceSlot() {
+        SlotReference source = new SlotReference("vec", 
ArrayType.of(FloatType.INSTANCE), true);
+        NonNullable expression = new NonNullable(source);
+        SlotReference target = new SlotReference("mv_vec", 
source.getDataType(), false);
+        Plan scan = Mockito.mock(Plan.class);
+        Mockito.doReturn(ImmutableList.of(target)).when(scan).getOutput();
+        AsyncMaterializationContext context = 
createMappingContext(ImmutableList.of(expression), scan);
+
+        context.tryGenerateScanPlan(null);
+
+        Assertions.assertEquals(ImmutableList.of(target), ImmutableList.copyOf(
+                
context.getShuttledExprToScanExprMapping().getExpressionMapping().get(expression)));
+        Assertions.assertEquals(ImmutableList.of(target), ImmutableList.copyOf(
+                
context.getShuttledExprToScanExprMapping().getExpressionMapping().get(source)));
+        NamedExpression aligned = 
MaterializedViewUtils.normalizeExpression(source, target, false);
+        Assertions.assertEquals(source.getExprId(), aligned.getExprId());
+        Assertions.assertTrue(aligned.nullable());
+        Assertions.assertTrue(aligned.child(0) instanceof Nullable);
+        Assertions.assertTrue(source.nullable());
+        Assertions.assertFalse(target.nullable());
+
+        SlotReference regenerated = new SlotReference("mv_vec", 
source.getDataType(), false);
+        Mockito.doReturn(ImmutableList.of(regenerated)).when(scan).getOutput();
+        context.clearScanPlan(null);
+        context.tryGenerateScanPlan(null);
+        Assertions.assertEquals(ImmutableList.of(regenerated), 
ImmutableList.copyOf(
+                
context.getShuttledExprToScanExprMapping().getExpressionMapping().get(source)));
+    }
+
+    @Test
+    public void testDirectOutputMappingTakesPrecedence() {
+        SlotReference source = new SlotReference("vec", 
ArrayType.of(FloatType.INSTANCE), true);
+        NonNullable expression = new NonNullable(source);
+        SlotReference assertedTarget = new SlotReference("asserted_vec", 
source.getDataType(), false);
+        SlotReference directTarget = new SlotReference("original_vec", 
source.getDataType(), true);
+        Plan scan = Mockito.mock(Plan.class);
+        Mockito.doReturn(ImmutableList.of(assertedTarget, 
directTarget)).when(scan).getOutput();
+        AsyncMaterializationContext context = 
createMappingContext(ImmutableList.of(expression, source), scan);
+
+        context.tryGenerateScanPlan(null);
+
+        Assertions.assertEquals(ImmutableList.of(directTarget), 
ImmutableList.copyOf(
+                
context.getShuttledExprToScanExprMapping().getExpressionMapping().get(source)));
+    }
+
+    @Test
+    public void testRepeatedAssertionsDoNotMultiplySourceMappings() {
+        SlotReference source = new SlotReference("vec", 
ArrayType.of(FloatType.INSTANCE), true);
+        NonNullable expression = new NonNullable(source);
+        SlotReference first = new SlotReference("first_vec", 
source.getDataType(), false);
+        SlotReference second = new SlotReference("second_vec", 
source.getDataType(), false);
+        Plan scan = Mockito.mock(Plan.class);
+        Mockito.doReturn(ImmutableList.of(first, 
second)).when(scan).getOutput();
+        AsyncMaterializationContext context = 
createMappingContext(ImmutableList.of(expression, expression), scan);
+
+        context.tryGenerateScanPlan(null);
+
+        Assertions.assertEquals(ImmutableList.of(first), ImmutableList.copyOf(
+                
context.getShuttledExprToScanExprMapping().getExpressionMapping().get(source)));
+        Assertions.assertEquals(2, 
context.getShuttledExprToScanExprMapping().flattenMap().size());
+    }
+
+    @Test
+    public void 
testDoNotInferSourceMappingFromNullableTargetOrNestedExpression() {
+        SlotReference source = new SlotReference("vec", 
ArrayType.of(FloatType.INSTANCE), true);
+        NonNullable expression = new NonNullable(source);
+        Nullable nested = new Nullable(source);
+        NonNullable nestedExpression = new NonNullable(nested);
+        SlotReference nullableTarget = new SlotReference("nullable_vec", 
source.getDataType(), true);
+        SlotReference nestedTarget = new SlotReference("nested_vec", 
source.getDataType(), false);
+        Plan scan = Mockito.mock(Plan.class);
+        Mockito.doReturn(ImmutableList.of(nullableTarget, 
nestedTarget)).when(scan).getOutput();
+        AsyncMaterializationContext context = createMappingContext(
+                ImmutableList.of(expression, nestedExpression), scan);
+
+        context.tryGenerateScanPlan(null);
+
+        
Assertions.assertFalse(context.getShuttledExprToScanExprMapping().getExpressionMapping().containsKey(source));
+        
Assertions.assertFalse(context.getShuttledExprToScanExprMapping().getExpressionMapping().containsKey(nested));
+    }
+
+    private static AsyncMaterializationContext 
createMappingContext(List<Expression> expressions, Plan scan) {
+        CascadesContext cascadesContext = Mockito.mock(CascadesContext.class);
+        
Mockito.when(cascadesContext.getStatementContext()).thenReturn(Mockito.mock(StatementContext.class));
+        StructInfo structInfo = Mockito.mock(StructInfo.class);
+        
Mockito.when(structInfo.withoutRedundantMvFilters(cascadesContext)).thenReturn(structInfo);
+        
Mockito.doReturn(expressions).when(structInfo).getPlanOutputShuttledExpressions();
+        Plan plan = Mockito.mock(Plan.class);
+        Mockito.doReturn(scan.getOutput()).when(plan).getOutput();
+        return new AsyncMaterializationContext(Mockito.mock(MTMV.class), plan, 
plan,
+                ImmutableList.of(), ImmutableList.of(), cascadesContext, 
structInfo) {
+            @Override
+            Plan doGenerateScanPlan(CascadesContext ignored) {
+                return scan;
+            }
+        };
+    }
+
     @Test
     public void testCalculatePartitionMappingsMergesUncoveredFilters() throws 
Exception {
         List<String> qualifiers = ImmutableList.of("ctl", "db", "t1");
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/PreMaterializedViewRewriterTopNTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/PreMaterializedViewRewriterTopNTest.java
new file mode 100644
index 00000000000..0913279758e
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/PreMaterializedViewRewriterTopNTest.java
@@ -0,0 +1,100 @@
+// 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.exploration.mv;
+
+import org.apache.doris.catalog.MTMV;
+import org.apache.doris.nereids.CascadesContext;
+import org.apache.doris.nereids.StatementContext;
+import org.apache.doris.nereids.util.PlanChecker;
+import org.apache.doris.utframe.TestWithFeService;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.Mockito;
+
+class PreMaterializedViewRewriterTopNTest extends TestWithFeService {
+    @Override
+    protected void runBeforeAll() throws Exception {
+        createDatabase("pre_mv_topn");
+        connectContext.setDatabase("pre_mv_topn");
+        
connectContext.getSessionVariable().setDisableNereidsRules("PRUNE_EMPTY_PARTITION");
+        createTable("CREATE TABLE vector_source (id INT, vec ARRAY<FLOAT> 
NULL) "
+                + "DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES 
('replication_num' = '1')");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"l2_distance", "inner_product"})
+    void testUnrelatedOrderKeyDoesNotRequestEarlyRewrite(String metric) {
+        String previous = 
connectContext.getSessionVariable().getPreMaterializedViewRewriteStrategy();
+        try {
+            
connectContext.getSessionVariable().setPreMaterializedViewRewriteStrategy("TRY_IN_RBO");
+            CascadesContext context = prepareCandidate("SELECT id, " + metric
+                    + "_approximate(vec, [1.0, 2.0]) AS distance FROM 
vector_source ORDER BY id LIMIT 3");
+            
Assertions.assertFalse(PreMaterializedViewRewriter.needPreRewrite(context),
+                    context.getRewritePlan().treeString());
+        } finally {
+            
connectContext.getSessionVariable().setPreMaterializedViewRewriteStrategy(previous);
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"l2_distance", "inner_product"})
+    void testNestedApproximateOrderDoesNotRequestEarlyRewrite(String metric) {
+        String previous = 
connectContext.getSessionVariable().getPreMaterializedViewRewriteStrategy();
+        try {
+            
connectContext.getSessionVariable().setPreMaterializedViewRewriteStrategy("TRY_IN_RBO");
+            CascadesContext context = prepareCandidate("SELECT id, abs(" + 
metric
+                    + "_approximate(vec, [1.0, 2.0])) AS distance FROM 
vector_source ORDER BY distance LIMIT 3");
+            
Assertions.assertFalse(PreMaterializedViewRewriter.needPreRewrite(context),
+                    context.getRewritePlan().treeString());
+        } finally {
+            
connectContext.getSessionVariable().setPreMaterializedViewRewriteStrategy(previous);
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"l2_distance", "inner_product"})
+    void testApproximateOrderHonorsPreRewriteStrategy(String metric) {
+        String previous = 
connectContext.getSessionVariable().getPreMaterializedViewRewriteStrategy();
+        try {
+            
connectContext.getSessionVariable().setPreMaterializedViewRewriteStrategy("TRY_IN_RBO");
+            CascadesContext context = prepareCandidate("SELECT id, " + metric
+                    + "_approximate(vec, [1.0, 2.0]) AS distance FROM 
vector_source ORDER BY distance "
+                    + (metric.equals("inner_product") ? "DESC" : "ASC") + " 
LIMIT 3");
+            
Assertions.assertTrue(PreMaterializedViewRewriter.needPreRewrite(context),
+                    context.getRewritePlan().treeString());
+            
connectContext.getSessionVariable().setPreMaterializedViewRewriteStrategy("NOT_IN_RBO");
+            
Assertions.assertFalse(PreMaterializedViewRewriter.needPreRewrite(context));
+            
connectContext.getSessionVariable().setPreMaterializedViewRewriteStrategy("FORCE_IN_RBO");
+            
Assertions.assertTrue(PreMaterializedViewRewriter.needPreRewrite(context));
+        } finally {
+            
connectContext.getSessionVariable().setPreMaterializedViewRewriteStrategy(previous);
+        }
+    }
+
+    private CascadesContext prepareCandidate(String query) {
+        CascadesContext context = 
PlanChecker.from(connectContext).analyze(query).rewrite().getCascadesContext();
+        StatementContext statement = context.getStatementContext();
+        statement.getCandidateMTMVs().add(Mockito.mock(MTMV.class));
+        
statement.getPlannerHooks().add(InitMaterializationContextHook.INSTANCE);
+        statement.addTmpPlanForMvRewrite(context.getRewritePlan());
+        statement.getNeedPreMvRewriteRuleMasks().clear();
+        return context;
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PushDownVectorTopNIntoOlapScanTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PushDownVectorTopNIntoOlapScanTest.java
new file mode 100644
index 00000000000..d3f4bec1fd5
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PushDownVectorTopNIntoOlapScanTest.java
@@ -0,0 +1,119 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.nereids.rules.rewrite;
+
+import org.apache.doris.catalog.MTMV;
+import org.apache.doris.nereids.CascadesContext;
+import org.apache.doris.nereids.StatementContext;
+import 
org.apache.doris.nereids.rules.exploration.mv.InitMaterializationContextHook;
+import 
org.apache.doris.nereids.rules.exploration.mv.PreMaterializedViewRewriter;
+import org.apache.doris.nereids.trees.expressions.Alias;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan;
+import org.apache.doris.nereids.util.PlanChecker;
+import org.apache.doris.utframe.TestWithFeService;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.Mockito;
+
+class PushDownVectorTopNIntoOlapScanTest extends TestWithFeService {
+    @Override
+    protected void runBeforeAll() throws Exception {
+        createDatabase("vector_topn");
+        connectContext.setDatabase("vector_topn");
+        
connectContext.getSessionVariable().setDisableNereidsRules("PRUNE_EMPTY_PARTITION");
+        createTable("CREATE TABLE vector_source (id INT, vec ARRAY<FLOAT> 
NULL) "
+                + "DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES 
('replication_num' = '1')");
+        for (String metric : new String[] {"l2_distance", "inner_product"}) {
+            createTable("CREATE TABLE vector_" + metric + " (id INT, vec 
ARRAY<FLOAT> NOT NULL, "
+                    + "INDEX ann_vec (vec) USING ANN PROPERTIES ('index_type' 
= 'hnsw', "
+                    + "'metric_type' = '" + metric + "', 'dim' = '2')) "
+                    + "DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES 
('replication_num' = '1')");
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"l2_distance", "inner_product"})
+    void testNullableWrapperPreservesAnnTopN(String metric) {
+        for (String vector : new String[] {"vec", "nullable(vec)", 
"nullable(nullable(vec))"}) {
+            for (String filter : new String[] {"", " WHERE id > 0"}) {
+                String query = "SELECT id, " + metric + "_approximate(" + 
vector + ", [1.0, 2.0]) AS distance"
+                        + " FROM vector_" + metric + filter + " ORDER BY 
distance "
+                        + (metric.equals("inner_product") ? "DESC" : "ASC") + 
" LIMIT 3 OFFSET 1";
+                Plan plan = 
PlanChecker.from(connectContext).analyze(query).rewrite()
+                        .getCascadesContext().getRewritePlan();
+                LogicalOlapScan scan = (LogicalOlapScan) 
plan.collectFirst(LogicalOlapScan.class::isInstance).get();
+                Assertions.assertEquals(4L, scan.getAnnLimit().orElse(-1L), 
plan.treeString());
+                Alias distance = (Alias) scan.getVirtualColumns().get(0);
+                // The BE ANN runtime consumes a direct slot, while the 
original query may retain nullable outputs.
+                Assertions.assertInstanceOf(SlotReference.class, 
distance.child().child(0), plan.treeString());
+            }
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"l2_distance", "inner_product"})
+    void testCastsRetainScalarEvaluation(String metric) {
+        String cast = "CAST(CAST(vec AS ARRAY<DOUBLE>) AS ARRAY<FLOAT>)";
+        for (String vector : new String[] {"nullable(" + cast + ")", cast}) {
+            for (String filter : new String[] {"", " WHERE id > 0"}) {
+                String query = "SELECT id, " + metric + "_approximate(" + 
vector + ", [1.0, 2.0]) AS distance"
+                        + " FROM vector_" + metric + filter + " ORDER BY 
distance "
+                        + (metric.equals("inner_product") ? "DESC" : "ASC") + 
" LIMIT 3";
+                Plan plan = 
PlanChecker.from(connectContext).analyze(query).rewrite()
+                        .getCascadesContext().getRewritePlan();
+                LogicalOlapScan scan = (LogicalOlapScan) 
plan.collectFirst(LogicalOlapScan.class::isInstance).get();
+                // ANN evaluates indexed slots, so a surviving cast must stay 
in the scalar expression.
+                Assertions.assertTrue(scan.getAnnLimit().isEmpty(), 
plan.treeString());
+                Assertions.assertTrue(scan.getVirtualColumns().isEmpty(), 
plan.treeString());
+            }
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"l2_distance", "inner_product"})
+    void testVectorTopNRequestsEarlyMvRewrite(String metric) {
+        String previous = 
connectContext.getSessionVariable().getPreMaterializedViewRewriteStrategy();
+        try {
+            
connectContext.getSessionVariable().setPreMaterializedViewRewriteStrategy("TRY_IN_RBO");
+            CascadesContext context = 
PlanChecker.from(connectContext).analyze("SELECT id, " + metric
+                    + "_approximate(vec, [1.0, 2.0]) AS distance FROM 
vector_source ORDER BY distance LIMIT 3")
+                    .rewrite().getCascadesContext();
+            Plan plan = context.getRewritePlan();
+            StatementContext statement = context.getStatementContext();
+            statement.getCandidateMTMVs().add(Mockito.mock(MTMV.class));
+            
statement.getPlannerHooks().add(InitMaterializationContextHook.INSTANCE);
+            statement.addTmpPlanForMvRewrite(plan);
+            statement.getNeedPreMvRewriteRuleMasks().clear();
+            
Assertions.assertTrue(PreMaterializedViewRewriter.needPreRewrite(context));
+
+            Plan scalar = PlanChecker.from(connectContext).analyze("SELECT id 
FROM vector_source ORDER BY id LIMIT 3")
+                    .rewrite().getCascadesContext().getRewritePlan();
+            context.setRewritePlan(scalar);
+            
Assertions.assertFalse(PreMaterializedViewRewriter.needPreRewrite(context));
+            context.setRewritePlan(plan);
+            
connectContext.getSessionVariable().setPreMaterializedViewRewriteStrategy("NOT_IN_RBO");
+            
Assertions.assertFalse(PreMaterializedViewRewriter.needPreRewrite(context));
+        } finally {
+            
connectContext.getSessionVariable().setPreMaterializedViewRewriteStrategy(previous);
+        }
+    }
+}
diff --git 
a/regression-test/data/nereids_rules_p0/mv/non_nullable/non_nullable_mv_rewrite.out
 
b/regression-test/data/nereids_rules_p0/mv/non_nullable/non_nullable_mv_rewrite.out
new file mode 100644
index 00000000000..628c4c43018
--- /dev/null
+++ 
b/regression-test/data/nereids_rules_p0/mv/non_nullable/non_nullable_mv_rewrite.out
@@ -0,0 +1,56 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !plain --
+0      10      [0, 0, 0]
+1      20      [1, 0, 0]
+2      30      [2, 0, 0]
+3      40      [3, 0, 0]
+
+-- !plain_without_mv --
+0      10      [0, 0, 0]
+1      20      [1, 0, 0]
+2      30      [2, 0, 0]
+3      40      [3, 0, 0]
+
+-- !wrapped --
+0      [0, 0, 0]
+1      [1, 0, 0]
+2      [2, 0, 0]
+3      [3, 0, 0]
+
+-- !filtered --
+2      [2, 0, 0]
+3      [3, 0, 0]
+
+-- !ann --
+0      0.0
+1      1.0
+2      2.0
+
+-- !outer_join --
+0      [0, 0, 0]
+4      \N
+
+-- !outer_join_without_mv --
+0      [0, 0, 0]
+4      \N
+
+-- !failed_refresh --
+FAILED true
+
+-- !previous_mv --
+0      [0, 0, 0]
+1      [1, 0, 0]
+2      [2, 0, 0]
+3      [3, 0, 0]
+
+-- !null_fallback --
+4      \N
+
+-- !partial --
+1      2024-01-01      [1, 0, 0]
+2      2024-01-02      \N
+
+-- !partial_without_mv --
+1      2024-01-01      [1, 0, 0]
+2      2024-01-02      \N
+
diff --git a/regression-test/plugins/plugin_non_nullable_mtmv.groovy 
b/regression-test/plugins/plugin_non_nullable_mtmv.groovy
new file mode 100644
index 00000000000..a5749d1d4d1
--- /dev/null
+++ b/regression-test/plugins/plugin_non_nullable_mtmv.groovy
@@ -0,0 +1,85 @@
+// 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.
+
+import org.apache.doris.regression.suite.Suite
+
+Suite.metaClass.checkNonNullableMvRewrite = { String source, columns, String 
mv ->
+    Suite suite = delegate as Suite
+    List<String> names = columns instanceof List ? columns : [columns]
+    String projection = names.collect { "`${it}`" }.join(", ")
+    String asserted = names.collect { "non_nullable(`${it}`) AS `${it}`" 
}.join(", ")
+    String previousRewrite = suite.sql("SELECT 
@@enable_materialized_view_rewrite")[0][0].toString()
+    try {
+        suite.sql("SET enable_materialized_view_rewrite = false")
+        def schema = suite.sql("DESC ${source}")
+        names.each { name ->
+            suite.assertTrue(schema.any { it[0] == name && it[2] == "Yes" },
+                    "The fixture must expose a nullable source column: 
${name}")
+        }
+        def expected = suite.sql("SELECT ${projection} FROM ${source} ORDER BY 
1")
+        suite.assertFalse(expected.isEmpty(), "The source fixture must 
exercise materialized rows")
+        suite.sql("DROP MATERIALIZED VIEW IF EXISTS ${mv}")
+        suite.sql("""
+            CREATE MATERIALIZED VIEW ${mv}
+            BUILD DEFERRED REFRESH COMPLETE ON MANUAL
+            DISTRIBUTED BY RANDOM BUCKETS 1
+            PROPERTIES ("replication_num" = "1", "grace_period" = "3600000")
+            AS SELECT ${asserted} FROM ${source}
+        """)
+        if (names.contains("vec")) {
+            suite.sql("""CREATE INDEX ${mv}_ann ON ${mv}(vec) USING ANN
+                PROPERTIES ("index_type" = "hnsw", "metric_type" = 
"l2_distance", "dim" = "2")""")
+            String indexJobs = "SHOW ALTER TABLE COLUMN WHERE TableName = 
'${mv}' ORDER BY CreateTime DESC LIMIT 1"
+            if (!suite.sql(indexJobs).isEmpty()) {
+                suite.waitForSchemaChangeDone {
+                    sql indexJobs
+                    time 600
+                }
+            }
+            suite.sql("SYNC")
+        }
+        suite.sql("REFRESH MATERIALIZED VIEW ${mv} COMPLETE")
+        suite.waitingMTMVTaskFinishedByMvName(mv)
+        suite.sql("SET enable_materialized_view_rewrite = true")
+        // Keep non-versioned sources eligible while the fixture remains 
unchanged.
+        String query = "SELECT /*+ use_mv(${mv}) */ ${projection} FROM 
${source} ORDER BY 1"
+        suite.mv_rewrite_success(query, mv)
+        suite.assertEquals(expected.toString(), suite.sql(query).toString())
+        String wrapped = "SELECT /*+ use_mv(${mv}) */ ${asserted} " +
+                "FROM ${source} ORDER BY 1"
+        suite.mv_rewrite_success(wrapped, mv)
+        suite.assertEquals(expected.toString(), suite.sql(wrapped).toString())
+        String filtered = "SELECT /*+ use_mv(${mv}) */ ${projection} FROM 
${source} " +
+                "WHERE `${names[0]}` IS NOT NULL ORDER BY 1"
+        suite.mv_rewrite_success(filtered, mv)
+        suite.assertEquals(expected.toString(), suite.sql(filtered).toString())
+        if (names.contains("vec")) {
+            String ann = "SELECT /*+ use_mv(${mv}) */ id, " +
+                    "l2_distance_approximate(vec, [0.0, 0.0]) AS distance FROM 
${source} ORDER BY distance LIMIT 2"
+            suite.mv_rewrite_success(ann, mv)
+            suite.explain {
+                sql ann
+                contains "ANN SORT INFO"
+            }
+            def actual = suite.sql(ann)
+            suite.sql("SET enable_materialized_view_rewrite = false")
+            suite.assertEquals(suite.sql(ann).toString(), actual.toString())
+        }
+    } finally {
+        suite.sql("SET enable_materialized_view_rewrite = ${previousRewrite}")
+    }
+}
diff --git 
a/regression-test/suites/external_table_p0/adbc/test_non_nullable_adbc_mtmv.groovy
 
b/regression-test/suites/external_table_p0/adbc/test_non_nullable_adbc_mtmv.groovy
new file mode 100644
index 00000000000..8ee5d7bfbff
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/adbc/test_non_nullable_adbc_mtmv.groovy
@@ -0,0 +1,48 @@
+// 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("test_non_nullable_adbc_mtmv", "p0,external") {
+    String driver = context.config.otherConfigs.get("adbcDriverPath")
+    if (driver == null || driver.isEmpty()) {
+        String thirdparty = System.getenv("DORIS_THIRDPARTY")
+        if (thirdparty == null || thirdparty.isEmpty()) {
+            thirdparty = new File(context.config.suitePath).parentFile.parent 
+ "/thirdparty"
+        }
+        driver = thirdparty + "/installed/lib64/libadbc_driver_flightsql.so"
+    }
+    if (!new File(driver).canRead()) {
+        logger.info("Skip non-nullable ADBC MV regression: the Flight SQL 
driver is not installed")
+        return
+    }
+    sql "SET materialized_view_rewrite_enable_contain_external_table = true"
+    sql "DROP MATERIALIZED VIEW IF EXISTS non_nullable_adbc_mv"
+    sql "DROP TABLE IF EXISTS non_nullable_adbc_base"
+    sql """CREATE TABLE non_nullable_adbc_base (id INT NULL)
+        DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = 
"1")"""
+    sql "INSERT INTO non_nullable_adbc_base VALUES (1), (2), (3)"
+    def frontend = sql("SHOW FRONTENDS")[0]
+    String db = context.dbName
+    sql "DROP CATALOG IF EXISTS non_nullable_mv_adbc"
+    sql """CREATE CATALOG non_nullable_mv_adbc PROPERTIES (
+        "type" = "adbc", "driver_url" = "${driver}",
+        "sql_dialect" = "doris", "uri" = 
"grpc://${frontend[1]}:${frontend[6]}",
+        "user" = "${context.config.jdbcUser}", "password" = 
"${context.config.jdbcPassword}",
+        "partitioned_read" = "required"
+    )"""
+    
checkNonNullableMvRewrite("non_nullable_mv_adbc.${db}.non_nullable_adbc_base",
+            "id", "non_nullable_adbc_mv")
+}
diff --git 
a/regression-test/suites/external_table_p0/fluss/test_non_nullable_fluss_mtmv.groovy
 
b/regression-test/suites/external_table_p0/fluss/test_non_nullable_fluss_mtmv.groovy
new file mode 100644
index 00000000000..6327ee2d079
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/fluss/test_non_nullable_fluss_mtmv.groovy
@@ -0,0 +1,30 @@
+// 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("test_non_nullable_fluss_mtmv", "p0,external") {
+    if 
(!"true".equalsIgnoreCase(context.config.otherConfigs.get("enableFlussTest"))) {
+        return
+    }
+    sql "SET materialized_view_rewrite_enable_contain_external_table = true"
+    String host = context.config.otherConfigs.get("externalEnvIp")
+    String port = context.config.otherConfigs.get("fluss_coordinator_port")
+    sql "DROP CATALOG IF EXISTS non_nullable_mv_fluss"
+    sql """CREATE CATALOG non_nullable_mv_fluss PROPERTIES (
+        "type" = "fluss", "fluss.bootstrap.servers" = "${host}:${port}", 
"fluss.union_read.mode" = "disabled"
+    )"""
+    checkNonNullableMvRewrite("non_nullable_mv_fluss.fluss_test.log_basic", 
"id", "non_nullable_fluss_mv")
+}
diff --git 
a/regression-test/suites/external_table_p0/jdbc/test_non_nullable_jdbc_mtmv.groovy
 
b/regression-test/suites/external_table_p0/jdbc/test_non_nullable_jdbc_mtmv.groovy
new file mode 100644
index 00000000000..dd5787597a3
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/jdbc/test_non_nullable_jdbc_mtmv.groovy
@@ -0,0 +1,35 @@
+// 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("test_non_nullable_jdbc_mtmv", "p0,external") {
+    sql "SET materialized_view_rewrite_enable_contain_external_table = true"
+    sql "DROP MATERIALIZED VIEW IF EXISTS non_nullable_jdbc_mv"
+    sql "DROP TABLE IF EXISTS non_nullable_jdbc_base"
+    sql """CREATE TABLE non_nullable_jdbc_base (id INT NULL)
+        DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = 
"1")"""
+    sql "INSERT INTO non_nullable_jdbc_base VALUES (1), (2), (3)"
+    String driver = 
"https://${getS3BucketName()}.${getS3Endpoint()}/regression/jdbc_driver/mysql-connector-j-8.4.0.jar"
+    String db = context.dbName
+    sql "DROP CATALOG IF EXISTS non_nullable_mv_jdbc"
+    sql """CREATE CATALOG non_nullable_mv_jdbc PROPERTIES (
+        "type" = "jdbc", "user" = "${context.config.jdbcUser}", "password" = 
"${context.config.jdbcPassword}",
+        "jdbc_url" = "${context.config.jdbcUrl}", "driver_url" = "${driver}",
+        "driver_class" = "com.mysql.cj.jdbc.Driver"
+    )"""
+    
checkNonNullableMvRewrite("non_nullable_mv_jdbc.${db}.non_nullable_jdbc_base",
+            "id", "non_nullable_jdbc_mv")
+}
diff --git 
a/regression-test/suites/external_table_p0/trino_connector/hive/test_non_nullable_trino_mtmv.groovy
 
b/regression-test/suites/external_table_p0/trino_connector/hive/test_non_nullable_trino_mtmv.groovy
new file mode 100644
index 00000000000..f2627dca41e
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/trino_connector/hive/test_non_nullable_trino_mtmv.groovy
@@ -0,0 +1,34 @@
+// 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("test_non_nullable_trino_mtmv", "p0,external") {
+    if 
(!"true".equalsIgnoreCase(context.config.otherConfigs.get("enableHiveTest"))) {
+        return
+    }
+    sql "SET materialized_view_rewrite_enable_contain_external_table = true"
+    String host = context.config.otherConfigs.get("externalEnvIp")
+    def hosts = (sql("SHOW BACKENDS").collect { it[1] } + sql("SHOW 
FRONTENDS").collect { it[1] }).unique()
+    dispatchTrinoConnectors(hosts)
+    String port = context.config.otherConfigs.get("hive2HmsPort")
+    sql "DROP CATALOG IF EXISTS non_nullable_mv_trino"
+    sql """CREATE CATALOG non_nullable_mv_trino PROPERTIES (
+        "type" = "trino-connector", "trino.connector.name" = "hive",
+        "trino.hive.metastore.uri" = "thrift://${host}:${port}"
+    )"""
+    // Quote the reserved schema name before the helper embeds it in DESC and 
MV queries.
+    
checkNonNullableMvRewrite("`non_nullable_mv_trino`.`default`.`mtmv_base1`", 
"id", "non_nullable_trino_mv")
+}
diff --git 
a/regression-test/suites/external_table_p2/es/test_non_nullable_es_mtmv.groovy 
b/regression-test/suites/external_table_p2/es/test_non_nullable_es_mtmv.groovy
new file mode 100644
index 00000000000..f5e90acf95b
--- /dev/null
+++ 
b/regression-test/suites/external_table_p2/es/test_non_nullable_es_mtmv.groovy
@@ -0,0 +1,34 @@
+// 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("test_non_nullable_es_mtmv", "p2,external") {
+    if 
(!"true".equalsIgnoreCase(context.config.otherConfigs.get("enableEsTest"))) {
+        return
+    }
+    sql "SET materialized_view_rewrite_enable_contain_external_table = true"
+    String host = context.config.otherConfigs.get("externalEnvIp")
+    for (int version : [5, 6, 7, 8]) {
+        String port = context.config.otherConfigs.get("es_${version}_port")
+        String catalog = "non_nullable_mv_es${version}"
+        sql "DROP CATALOG IF EXISTS ${catalog}"
+        sql """CREATE CATALOG ${catalog} PROPERTIES (
+            "type" = "es", "hosts" = "http://${host}:${port}";,
+            "nodes_discovery" = "false", "enable_keyword_sniff" = "true"
+        )"""
+        checkNonNullableMvRewrite("${catalog}.default_db.test1", "test1", 
"non_nullable_es${version}_mv")
+    }
+}
diff --git 
a/regression-test/suites/external_table_p2/hudi/test_non_nullable_hudi_mtmv.groovy
 
b/regression-test/suites/external_table_p2/hudi/test_non_nullable_hudi_mtmv.groovy
new file mode 100644
index 00000000000..ef843165d19
--- /dev/null
+++ 
b/regression-test/suites/external_table_p2/hudi/test_non_nullable_hudi_mtmv.groovy
@@ -0,0 +1,36 @@
+// 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("test_non_nullable_hudi_mtmv", "p2,external") {
+    if 
(!"true".equalsIgnoreCase(context.config.otherConfigs.get("enableHudiTest"))) {
+        return
+    }
+    sql "SET materialized_view_rewrite_enable_contain_external_table = true"
+    String host = context.config.otherConfigs.get("externalEnvIp")
+    String hmsPort = context.config.otherConfigs.get("hudiHmsPort")
+    String minioPort = context.config.otherConfigs.get("hudiMinioPort")
+    String accessKey = context.config.otherConfigs.get("hudiMinioAccessKey")
+    String secretKey = context.config.otherConfigs.get("hudiMinioSecretKey")
+    sql "DROP CATALOG IF EXISTS non_nullable_mv_hudi"
+    sql """CREATE CATALOG non_nullable_mv_hudi PROPERTIES (
+        "type" = "hms", "hive.metastore.uris" = "thrift://${host}:${hmsPort}",
+        "s3.endpoint" = "http://${host}:${minioPort}";, "s3.region" = 
"us-east-1",
+        "s3.access_key" = "${accessKey}", "s3.secret_key" = "${secretKey}", 
"use_path_style" = "true"
+    )"""
+    
checkNonNullableMvRewrite("non_nullable_mv_hudi.hudi_mtmv_regression_test.hudi_table_1",
+            "age", "non_nullable_hudi_mv")
+}
diff --git 
a/regression-test/suites/external_table_p2/maxcompute/test_non_nullable_maxcompute_mtmv.groovy
 
b/regression-test/suites/external_table_p2/maxcompute/test_non_nullable_maxcompute_mtmv.groovy
new file mode 100644
index 00000000000..b6a84b16e1a
--- /dev/null
+++ 
b/regression-test/suites/external_table_p2/maxcompute/test_non_nullable_maxcompute_mtmv.groovy
@@ -0,0 +1,36 @@
+// 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("test_non_nullable_maxcompute_mtmv", "p2,external") {
+    if 
(!"true".equalsIgnoreCase(context.config.otherConfigs.get("enableMaxComputeTest")))
 {
+        return
+    }
+    sql "SET materialized_view_rewrite_enable_contain_external_table = true"
+    String project = context.config.otherConfigs.get("mcDefaultProject") ?: 
"mc_datalake"
+    String endpoint = context.config.otherConfigs.get("mcEndpoint")
+            ?: "https://service.cn-beijing.maxcompute.aliyun.com/api";
+    String accessKey = context.config.otherConfigs.get("ak")
+    String secretKey = context.config.otherConfigs.get("sk")
+    sql "DROP CATALOG IF EXISTS non_nullable_mv_maxcompute"
+    sql """CREATE CATALOG non_nullable_mv_maxcompute PROPERTIES (
+        "type" = "max_compute", "mc.default.project" = "${project}",
+        "mc.access_key" = "${accessKey}", "mc.secret_key" = "${secretKey}",
+        "mc.endpoint" = "${endpoint}", "mc.quota" = "pay-as-you-go"
+    )"""
+    
checkNonNullableMvRewrite("non_nullable_mv_maxcompute.${project}.mc_all_types",
+            "id", "non_nullable_maxcompute_mv")
+}
diff --git a/regression-test/suites/mtmv_p0/test_non_nullable_hive_mtmv.groovy 
b/regression-test/suites/mtmv_p0/test_non_nullable_hive_mtmv.groovy
new file mode 100644
index 00000000000..92accc26a4f
--- /dev/null
+++ b/regression-test/suites/mtmv_p0/test_non_nullable_hive_mtmv.groovy
@@ -0,0 +1,42 @@
+// 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("test_non_nullable_hive_mtmv", 
"p0,external,hive,external_docker,external_docker_hive") {
+    if 
(!"true".equalsIgnoreCase(context.config.otherConfigs.get("enableHiveTest"))) {
+        return
+    }
+    sql "SET materialized_view_rewrite_enable_contain_external_table = true"
+    String host = context.config.otherConfigs.get("externalEnvIp")
+    for (String version : ["hive2", "hive3"]) {
+        String port = context.config.otherConfigs.get(version + "HmsPort")
+        String catalog = "non_nullable_mv_${version}"
+        sql "DROP CATALOG IF EXISTS ${catalog}"
+        sql """CREATE CATALOG ${catalog} PROPERTIES (
+            "type" = "hms", "hive.metastore.uris" = "thrift://${host}:${port}"
+        )"""
+        // The helper reuses this name in DESC and MV queries, where default 
is a reserved keyword.
+        checkNonNullableMvRewrite("`${catalog}`.`default`.`mtmv_base1`", "id", 
"non_nullable_${version}_mv")
+        for (String format : ["orc", "parquet"]) {
+            String source = 
"`${catalog}`.`default`.`non_nullable_vector_${format}`"
+            sql "DROP TABLE IF EXISTS ${source}"
+            sql """CREATE TABLE ${source} (id INT, vec ARRAY<FLOAT>) 
ENGINE=HIVE
+                PROPERTIES ("file_format" = "${format}")"""
+            sql "INSERT INTO ${source} VALUES (1, [1.0, 0.0]), (2, [2.0, 0.0])"
+            checkNonNullableMvRewrite(source, ["id", "vec"], 
"non_nullable_${version}_${format}_mv")
+        }
+    }
+}
diff --git 
a/regression-test/suites/mtmv_p0/test_non_nullable_iceberg_mtmv.groovy 
b/regression-test/suites/mtmv_p0/test_non_nullable_iceberg_mtmv.groovy
new file mode 100644
index 00000000000..e6dc9664ad0
--- /dev/null
+++ b/regression-test/suites/mtmv_p0/test_non_nullable_iceberg_mtmv.groovy
@@ -0,0 +1,39 @@
+// 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("test_non_nullable_iceberg_mtmv", "p0,external,iceberg") {
+    if 
(!"true".equalsIgnoreCase(context.config.otherConfigs.get("enableIcebergTest")))
 {
+        return
+    }
+    sql "SET materialized_view_rewrite_enable_contain_external_table = true"
+    String host = context.config.otherConfigs.get("externalEnvIp")
+    String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+    sql "DROP CATALOG IF EXISTS non_nullable_mv_iceberg"
+    sql """CREATE CATALOG non_nullable_mv_iceberg PROPERTIES (
+        "type" = "iceberg", "iceberg.catalog.type" = "rest", "uri" = 
"http://${host}:${restPort}";,
+        "s3.endpoint" = "http://${host}:${minioPort}";, "s3.region" = 
"us-east-1",
+        "s3.access_key" = "admin", "s3.secret_key" = "password"
+    )"""
+    
checkNonNullableMvRewrite("non_nullable_mv_iceberg.format_v2.sample_mor_parquet",
+            "id", "non_nullable_iceberg_mv")
+    String source = 
"non_nullable_mv_iceberg.format_v2.non_nullable_vector_source"
+    sql "DROP TABLE IF EXISTS ${source}"
+    sql """CREATE TABLE ${source} (id INT, vec ARRAY<FLOAT>) ENGINE=ICEBERG"""
+    sql "INSERT INTO ${source} VALUES (1, [1.0, 0.0]), (2, [2.0, 0.0])"
+    checkNonNullableMvRewrite(source, ["id", "vec"], 
"non_nullable_iceberg_vector_mv")
+}
diff --git 
a/regression-test/suites/mtmv_p0/test_non_nullable_paimon_mtmv.groovy 
b/regression-test/suites/mtmv_p0/test_non_nullable_paimon_mtmv.groovy
new file mode 100644
index 00000000000..24fb6aa394b
--- /dev/null
+++ b/regression-test/suites/mtmv_p0/test_non_nullable_paimon_mtmv.groovy
@@ -0,0 +1,34 @@
+// 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("test_non_nullable_paimon_mtmv", 
"p0,external,mtmv,external_docker,external_docker_doris") {
+    if 
(!"true".equalsIgnoreCase(context.config.otherConfigs.get("enablePaimonTest"))) 
{
+        return
+    }
+    sql "SET materialized_view_rewrite_enable_contain_external_table = true"
+    String host = context.config.otherConfigs.get("externalEnvIp")
+    String port = context.config.otherConfigs.get("iceberg_minio_port")
+    sql "DROP CATALOG IF EXISTS non_nullable_mv_paimon"
+    sql """CREATE CATALOG non_nullable_mv_paimon PROPERTIES (
+        "type" = "paimon", "warehouse" = "s3://warehouse/wh/",
+        "s3.endpoint" = "http://${host}:${port}";, "s3.region" = "us-east-1",
+        "s3.access_key" = "admin", "s3.secret_key" = "password"
+    )"""
+    // This nullable field is populated in both the ORC and Parquet fixtures.
+    
checkNonNullableMvRewrite("non_nullable_mv_paimon.test_paimon_spark.test_tb_mix_format",
+            "value", "non_nullable_paimon_mv")
+}
diff --git 
a/regression-test/suites/nereids_rules_p0/mv/non_nullable/non_nullable_mv_rewrite.groovy
 
b/regression-test/suites/nereids_rules_p0/mv/non_nullable/non_nullable_mv_rewrite.groovy
new file mode 100644
index 00000000000..2549265c1cb
--- /dev/null
+++ 
b/regression-test/suites/nereids_rules_p0/mv/non_nullable/non_nullable_mv_rewrite.groovy
@@ -0,0 +1,181 @@
+// 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.
+
+import org.apache.doris.regression.util.Http
+
+suite("non_nullable_mv_rewrite") {
+    sql "SET enable_sql_cache = false"
+    sql "SET enable_segment_limit_pushdown = true"
+    sql "SET ann_index_candidate_rows_threshold = 0"
+    sql "SET ann_index_candidate_rows_percent_threshold = 0"
+    sql "DROP MATERIALIZED VIEW IF EXISTS non_nullable_mv"
+    sql "DROP MATERIALIZED VIEW IF EXISTS non_nullable_part_mv"
+    sql "DROP TABLE IF EXISTS non_nullable_base"
+    sql "DROP TABLE IF EXISTS non_nullable_part_base"
+    sql "DROP TABLE IF EXISTS non_nullable_keys"
+    sql "DROP MATERIALIZED VIEW IF EXISTS non_nullable_helper_mv"
+    sql "DROP TABLE IF EXISTS non_nullable_helper_base"
+    sql """CREATE TABLE non_nullable_base (id INT, value INT NULL, vec 
ARRAY<FLOAT> NULL)
+        DUPLICATE KEY(id) DISTRIBUTED BY HASH(id) BUCKETS 1
+        PROPERTIES ("replication_num" = "1")"""
+    sql """INSERT INTO non_nullable_base VALUES
+        (0, 10, [0.0, 0.0, 0.0]), (1, 20, [1.0, 0.0, 0.0]),
+        (2, 30, [2.0, 0.0, 0.0]), (3, 40, [3.0, 0.0, 0.0])"""
+    sql """CREATE MATERIALIZED VIEW non_nullable_mv
+        BUILD DEFERRED REFRESH COMPLETE ON MANUAL
+        DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+        AS SELECT id, non_nullable(value) AS value, non_nullable(vec) AS vec 
FROM non_nullable_base"""
+    sql """CREATE INDEX non_nullable_ann ON non_nullable_mv(vec) USING ANN
+        PROPERTIES ("index_type" = "hnsw", "metric_type" = "l2_distance", 
"dim" = "3")"""
+    String indexJobs = "SHOW ALTER TABLE COLUMN WHERE TableName = 
\"non_nullable_mv\" ORDER BY CreateTime DESC LIMIT 1"
+    // Light index changes finish in the DDL; ordinary schema changes publish 
an alter job.
+    if (!sql(indexJobs).isEmpty()) {
+        waitForSchemaChangeDone {
+            sql indexJobs
+            time 600
+        }
+    }
+    sql "REFRESH MATERIALIZED VIEW non_nullable_mv COMPLETE"
+    waitingMTMVTaskFinishedByMvName("non_nullable_mv")
+    sql "ANALYZE TABLE non_nullable_base WITH SYNC"
+    sql "ANALYZE TABLE non_nullable_mv WITH SYNC"
+
+    // Stabilize the cost choice for this tiny fixture, including the nullable 
output projection.
+    sql """ALTER TABLE non_nullable_base MODIFY COLUMN id SET STATS 
("row_count" = "1000")"""
+
+    String plain = "SELECT /*+ use_mv(non_nullable_mv) */ id, value, vec FROM 
non_nullable_base ORDER BY id"
+    mv_rewrite_success(plain, "non_nullable_mv")
+    qt_plain plain
+    sql "SET enable_materialized_view_rewrite = false"
+    qt_plain_without_mv "SELECT id, value, vec FROM non_nullable_base ORDER BY 
id"
+    sql "SET enable_materialized_view_rewrite = true"
+    String wrapped = "SELECT /*+ use_mv(non_nullable_mv) */ id, 
non_nullable(vec) FROM non_nullable_base ORDER BY id"
+    mv_rewrite_success(wrapped, "non_nullable_mv")
+    qt_wrapped wrapped
+    String filtered = "SELECT /*+ use_mv(non_nullable_mv) */ id, vec FROM 
non_nullable_base WHERE value > 20 ORDER BY id"
+    mv_rewrite_success(filtered, "non_nullable_mv")
+    qt_filtered filtered
+
+    sql "SET enable_profile = true"
+    sql "SET profile_level = 2"
+    String ann = """SELECT /*+ use_mv(non_nullable_mv) */ id,
+        l2_distance_approximate(vec, [0.0, 0.0, 0.0]) AS distance FROM 
non_nullable_base
+        ORDER BY distance LIMIT 3"""
+    mv_rewrite_success(ann, "non_nullable_mv")
+    explain {
+        sql ann
+        contains "non_nullable_mv"
+        contains "ANN SORT INFO"
+    }
+    qt_ann ann
+    String queryId = sql("SELECT last_query_id()")[0][0].toString()
+    boolean searchedIndex = false
+    for (int attempt = 0; attempt < 30 && !searchedIndex; attempt++) {
+        String profile = 
Http.GET("http://${context.config.feHttpAddress}/api/profile/text/?query_id=${queryId}";,
+                false, false, context.config.feHttpUser, 
context.config.feHttpPassword).toString()
+        def counters = profile =~ /AnnIndexTopNSearchCnt:\s*([0-9]+)/
+        searchedIndex = counters.any { it[1].toLong() > 0 }
+        if (!searchedIndex) {
+            sleep(500)
+        }
+    }
+    assertTrue(searchedIndex, "The rewritten query must execute an ANN index 
search")
+    sql "SET enable_profile = false"
+
+    // A surviving cast must use scalar evaluation even when its underlying 
column has an ANN index.
+    ["", "WHERE id > 0"].eachWithIndex { String filter, int i ->
+        String castDistance = """SELECT id, l2_distance_approximate(
+            nullable(CAST(CAST(vec AS ARRAY<DOUBLE>) AS ARRAY<FLOAT>)), [0.0, 
0.0, 0.0]) AS distance
+            FROM non_nullable_mv ${filter} ORDER BY distance LIMIT 3"""
+        explain {
+            sql castDistance
+            notContains "ANN SORT INFO"
+        }
+        quickTest("ann_cast_${i}", castDistance,
+            """SELECT /*+ SET_VAR(enable_materialized_view_rewrite=false) */ 
id,
+                l2_distance_approximate(vec, [0.0, 0.0, 0.0]) AS distance
+                FROM non_nullable_base ${filter} ORDER BY distance LIMIT 3""")
+    }
+
+    sql """CREATE TABLE non_nullable_keys (id INT)
+        DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = 
"1")"""
+    sql "INSERT INTO non_nullable_keys VALUES (0), (4)"
+    // A join above the substituted scan must still generate a NULL vector for 
an unmatched row.
+    String outerJoin = """SELECT /*+ use_mv(non_nullable_mv) */ k.id, b.vec
+        FROM non_nullable_keys k LEFT JOIN non_nullable_base b ON k.id = b.id 
ORDER BY k.id"""
+    mv_rewrite_success(outerJoin, "non_nullable_mv")
+    qt_outer_join outerJoin
+    sql "SET enable_materialized_view_rewrite = false"
+    qt_outer_join_without_mv """SELECT k.id, b.vec FROM non_nullable_keys k
+        LEFT JOIN non_nullable_base b ON k.id = b.id ORDER BY k.id"""
+    sql "SET enable_materialized_view_rewrite = true"
+
+    sql "INSERT INTO non_nullable_base VALUES (4, 50, NULL)"
+    test {
+        sql "SELECT non_nullable(vec) FROM non_nullable_base WHERE id = 4"
+        exception "illegal for non_nullable"
+    }
+    sql "REFRESH MATERIALIZED VIEW non_nullable_mv COMPLETE"
+    waitingMTMVTaskFinishedNotNeedSuccess(getJobName(context.dbName, 
"non_nullable_mv"))
+    // Failed refreshes must preserve the last published data and retain 
source NULL rows on fallback.
+    qt_failed_refresh """SELECT Status, ErrorMsg LIKE "%illegal for 
non_nullable%"
+        FROM tasks("type" = "mv") WHERE MvDatabaseName = "${context.dbName}" 
AND MvName = "non_nullable_mv"
+        ORDER BY CreateTime DESC LIMIT 1"""
+    qt_previous_mv "SELECT id, vec FROM non_nullable_mv ORDER BY id"
+    mv_not_part_in("SELECT id, vec FROM non_nullable_base WHERE vec IS NULL", 
"non_nullable_mv")
+    qt_null_fallback "SELECT id, vec FROM non_nullable_base WHERE vec IS NULL 
ORDER BY id"
+
+    sql """CREATE TABLE non_nullable_part_base (id INT, d DATE NOT NULL, vec 
ARRAY<FLOAT> NULL)
+        DUPLICATE KEY(id, d)
+        PARTITION BY RANGE(d) (
+            PARTITION p1 VALUES [('2024-01-01'), ('2024-01-02')),
+            PARTITION p2 VALUES [('2024-01-02'), ('2024-01-03'))
+        ) DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = 
"1")"""
+    sql """INSERT INTO non_nullable_part_base VALUES
+        (1, '2024-01-01', [1.0, 0.0, 0.0]), (2, '2024-01-02', NULL)"""
+    // Make a partial MV cheaper than scanning the whole source without 
overriding partition statistics.
+    sql """INSERT INTO non_nullable_part_base SELECT number + 10, 
'2024-01-01', [1.0, 0.0, 0.0]
+        FROM numbers("number" = "100")"""
+    sql """CREATE MATERIALIZED VIEW non_nullable_part_mv
+        BUILD DEFERRED REFRESH AUTO ON MANUAL PARTITION BY(d)
+        DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+        AS SELECT id, d, non_nullable(vec) AS vec FROM 
non_nullable_part_base"""
+    sql "REFRESH MATERIALIZED VIEW non_nullable_part_mv 
PARTITIONS(p_20240101_20240102)"
+    waitingMTMVTaskFinishedByMvName("non_nullable_part_mv")
+    sql "ANALYZE TABLE non_nullable_part_base WITH SYNC"
+    // The unmaterialized partition still contains a NULL; only the MV branch 
is non-nullable.
+    String partial = """SELECT /*+ use_mv(non_nullable_part_mv) */ id, d, vec
+        FROM non_nullable_part_base WHERE id < 3 ORDER BY id"""
+    mv_rewrite_success(partial, "non_nullable_part_mv")
+    explain {
+        sql partial
+        contains "VUNION"
+    }
+    qt_partial partial
+    sql "SET enable_materialized_view_rewrite = false"
+    qt_partial_without_mv "SELECT id, d, vec FROM non_nullable_part_base WHERE 
id < 3 ORDER BY id"
+    sql "SET enable_materialized_view_rewrite = true"
+
+    // Exercise the shared array/ANN helper even when external fixtures are 
unavailable.
+    sql """CREATE TABLE non_nullable_helper_base (id INT NULL, vec 
ARRAY<FLOAT> NULL)
+        DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = 
"1")"""
+    sql "INSERT INTO non_nullable_helper_base VALUES (1, [1.0, 0.0]), (2, 
[2.0, 0.0])"
+    sql "ANALYZE TABLE non_nullable_helper_base WITH SYNC"
+    sql """ALTER TABLE non_nullable_helper_base MODIFY COLUMN id SET STATS 
("row_count" = "1000")"""
+    checkNonNullableMvRewrite("non_nullable_helper_base", ["id", "vec"], 
"non_nullable_helper_mv")
+
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to