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

morningman 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 326ed5b15ff [test](fe) Cover CAST predicates kept out of Paimon and 
table-scoped Hive file listings (#68960)
326ed5b15ff is described below

commit 326ed5b15ffae2baaa792e312406cd4bc9188187
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Tue Oct 13 14:53:25 2026 +0800

    [test](fe) Cover CAST predicates kept out of Paimon and table-scoped Hive 
file listings (#68960)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #67933, #68577, #67479, #68568
    
    Problem Summary:
    
    **In short.** This ports the tests for two branch-4.x external table
    fixes whose behavior master already has: Paimon must not receive
    predicates that contain a CAST (#67933), and Hive file listings must be
    cached per table (#68577). It changes no production code. The other two
    fixes of the same batch (#67479, #68568) are on master with their tests
    through #67687 and #68582.
    
    **Background.**
    
    - Paimon: Doris converts pushed conjuncts into connector expressions,
    and that converter drops CASTs. #67933 found that `CAST(code AS INT) =
    5` then reached Paimon as `code = 5`, and Paimon pruned the data files
    holding `'05'` or `' 5'`, so the query silently lost rows. branch-4.x
    rejected such predicates inside its Paimon converter. On master the
    Paimon connector declares `supportsCastPredicatePushdown() = false`, and
    `PluginDrivenScanNode.buildRemainingFilter` keeps every conjunct with a
    CAST anywhere in its tree in Doris: only the plain conjuncts go to the
    connector, and the source LIMIT is turned off when a conjunct is held
    back.
    - Hive: #68577 scoped the branch-4.2 file cache by table, so two tables
    whose partitions point at the same directory no longer share a listing.
    master's `HiveFileListingCache` keys entries by database, table,
    location and partition values, and REFRESH TABLE drops only that table's
    entries.
    
    **The problem, and what it cost.** None of this was pinned on master:
    
    | Behavior | Tested on master before this PR |
    |---|---|
    | `buildRemainingFilter` keeps conjuncts with a CAST away from a
    connector that does not accept CASTs, leaves them for Doris and turns
    the source LIMIT off | no; tests pin only the value of Paimon's switch |
    | A plain conjunct next to a CAST conjunct is still pushed | no |
    | The Paimon converter pushes string equality, INT greater-than, IN and
    NOT IN | INT equality only |
    | Two Hive tables with the same partition directory and values keep
    separate listings across a refresh | only separately (different
    locations, partitionless keys); a cache key without the table name
    passes every existing `HiveFileListingCacheTest` case |
    | CAST predicates on a Paimon table, end to end, through the JNI and the
    native reader | no |
    
    **How this PR fixes it.**
    
    - `PluginDrivenScanNodeCastPredicateTest` (new, fe-core): plans the nine
    CAST shapes of the branch-4.x test (`=`, reversed `=`, `>`, `IN`, `NOT
    IN`, `IS NULL`, `IS NOT NULL`, `OR`, decimal scale) and calls
    `buildRemainingFilter` on a node whose connector does not accept CASTs:
    nothing is sent, the conjuncts stay as they were and the source LIMIT is
    off. A plain conjunct next to a CAST conjunct is still pushed alone,
    plain conjuncts are pushed whole, and the same CAST conjunct is pushed
    once the connector accepts CASTs.
    - `PaimonPredicateConverterTest`: string equality, INT greater-than, IN
    and NOT IN are pushed, checked by evaluating the Paimon predicates on
    rows.
    - `HiveFileListingCacheTest`: two tables with the same partition
    directory and values list it separately, and refreshing one keeps the
    other's entry.
    - `test_paimon_cast_predicate_pushdown` (new regression): writes one row
    per data file so file pruning can happen, checks with EXPLAIN that the
    CAST conjuncts show `predicatesFromPaimon: NONE` while plain conjuncts
    are pushed, and reads the nine shapes, an AND with a plain conjunct and
    a LIMIT through the JNI and the native reader.
    
    **Results.** All the tests pass. Each new unit test fails when the logic
    it guards is changed:
    
    | Change to the code under test | Result |
    |---|---|
    | Remove the CAST check from `buildRemainingFilter` | 10 of the 14 cases
    fail; the plain-conjunct case shows the filter sent to the connector as
    `((code = 5) AND (id = 1))`, the stripped CAST that #67933 fixed |
    | Drop IN support from the Paimon converter |
    `plainStringRangeAndInListPredicatesArePushed` fails |
    | Drop the table from the Hive cache key |
    `refreshTableWithSharedPartitionLocationIsScopedPerTable` fails, the
    other `HiveFileListingCacheTest` cases pass |
    
    In the regression, the JNI and the native reader return the same rows,
    and they match an internal Doris table holding the same data.
    
    Co-authored-by: Gabriel <[email protected]>
    Co-authored-by: 924060929 <[email protected]>
---
 .../connector/hive/HiveFileListingCacheTest.java   |  23 ++++
 .../paimon/PaimonPredicateConverterTest.java       |  45 ++++++
 .../PluginDrivenScanNodeCastPredicateTest.java     | 152 +++++++++++++++++++++
 .../paimon/test_paimon_cast_predicate_pushdown.out |  95 +++++++++++++
 .../test_paimon_cast_predicate_pushdown.groovy     | 109 +++++++++++++++
 5 files changed, 424 insertions(+)

diff --git 
a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveFileListingCacheTest.java
 
b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveFileListingCacheTest.java
index 29bf96e6650..1a654fd327e 100644
--- 
a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveFileListingCacheTest.java
+++ 
b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveFileListingCacheTest.java
@@ -163,6 +163,29 @@ public class HiveFileListingCacheTest {
         Assertions.assertEquals(1, (int) lister.callsPerLocation.get("locB"));
     }
 
+    @Test
+    public void refreshTableWithSharedPartitionLocationIsScopedPerTable() {
+        CountingLister lister = new CountingLister();
+        HiveFileListingCache cache = new 
HiveFileListingCache(HiveTestProperties.minimal(), lister);
+        String location = "/warehouse/shared/p=p1";
+        List<String> values = Collections.singletonList("p1");
+
+        // Two tables whose partitions point at the same directory with the 
same values (branch-4.x #68577).
+        // WHY: b must list the shared directory itself instead of serving a's 
listing, and refreshing b must
+        // not touch a. MUTATION: dropping (db, table) from the key -> b 
reuses a's list -> red.
+        List<HiveFileStatus> filesA = cache.listDataFiles("db", "a", location, 
values, FS);
+        List<HiveFileStatus> filesB = cache.listDataFiles("db", "b", location, 
values, FS);
+        Assertions.assertEquals(2, lister.totalCalls);
+        Assertions.assertSame(filesA, cache.listDataFiles("db", "a", location, 
values, FS));
+        Assertions.assertSame(filesB, cache.listDataFiles("db", "b", location, 
values, FS));
+
+        cache.invalidateTable("db", "b");
+
+        Assertions.assertNotSame(filesB, cache.listDataFiles("db", "b", 
location, values, FS));
+        Assertions.assertSame(filesA, cache.listDataFiles("db", "a", location, 
values, FS));
+        Assertions.assertEquals(3, lister.totalCalls);
+    }
+
     @Test
     public void invalidateAllDropsEverything() {
         CountingLister lister = new CountingLister();
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonPredicateConverterTest.java
 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonPredicateConverterTest.java
index 1b0763cfd2d..65a8af2be21 100644
--- 
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonPredicateConverterTest.java
+++ 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonPredicateConverterTest.java
@@ -20,10 +20,13 @@ package org.apache.doris.connector.paimon;
 import org.apache.doris.connector.spi.ConnectorType;
 import org.apache.doris.connector.spi.pushdown.ConnectorColumnRef;
 import org.apache.doris.connector.spi.pushdown.ConnectorComparison;
+import org.apache.doris.connector.spi.pushdown.ConnectorExpression;
+import org.apache.doris.connector.spi.pushdown.ConnectorIn;
 import org.apache.doris.connector.spi.pushdown.ConnectorLike;
 import org.apache.doris.connector.spi.pushdown.ConnectorLiteral;
 
 import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.data.Timestamp;
 import org.apache.paimon.predicate.Equal;
 import org.apache.paimon.predicate.IsNull;
@@ -37,6 +40,7 @@ import org.junit.jupiter.api.Test;
 
 import java.time.LocalDateTime;
 import java.time.ZoneOffset;
+import java.util.Arrays;
 import java.util.List;
 
 /**
@@ -174,6 +178,47 @@ public class PaimonPredicateConverterTest {
                 "the INT literal must be carried through unchanged");
     }
 
+    @Test
+    public void plainStringRangeAndInListPredicatesArePushed() {
+        RowType rowType = RowType.builder()
+                .field("id", DataTypes.INT())
+                .field("code", DataTypes.STRING())
+                .build();
+        PaimonPredicateConverter converter = new 
PaimonPredicateConverter(rowType);
+        ConnectorColumnRef id = new ConnectorColumnRef("id", ANY);
+        ConnectorColumnRef code = new ConnectorColumnRef("code", ANY);
+        List<ConnectorExpression> oneAndTwo = 
Arrays.asList(ConnectorLiteral.ofInt(1), ConnectorLiteral.ofInt(2));
+
+        // WHY: Paimon declares supportsCastPredicatePushdown() = false, so 
the engine keeps every conjunct that
+        // contains a CAST in Doris and this converter never sees one 
(PluginDrivenScanNodeCastPredicateTest).
+        // The plain shapes branch-4.x checked next to its CAST cases (#67933) 
must still be pushed, otherwise
+        // the conservative CAST rule would have turned into "push nothing". 
The predicates are checked by
+        // evaluating them on rows, since paimon may build IN as an OR of 
equalities.
+        // MUTATION: dropping string equality, GT or IN support from the 
converter -> empty result -> red.
+        Predicate stringEquality = single(converter.convert(new 
ConnectorComparison(
+                ConnectorComparison.Operator.EQ, code, 
ConnectorLiteral.ofString("5"))));
+        Assertions.assertTrue(stringEquality.test(GenericRow.of(1, 
BinaryString.fromString("5"))));
+        Assertions.assertFalse(stringEquality.test(GenericRow.of(1, 
BinaryString.fromString("05"))));
+
+        Predicate greaterThan = single(converter.convert(new 
ConnectorComparison(
+                ConnectorComparison.Operator.GT, id, 
ConnectorLiteral.ofInt(1))));
+        Assertions.assertTrue(greaterThan.test(GenericRow.of(2, 
BinaryString.fromString("x"))));
+        Assertions.assertFalse(greaterThan.test(GenericRow.of(1, 
BinaryString.fromString("x"))));
+
+        Predicate in = single(converter.convert(new ConnectorIn(id, oneAndTwo, 
false)));
+        Assertions.assertTrue(in.test(GenericRow.of(2, 
BinaryString.fromString("x"))));
+        Assertions.assertFalse(in.test(GenericRow.of(3, 
BinaryString.fromString("x"))));
+
+        Predicate notIn = single(converter.convert(new ConnectorIn(id, 
oneAndTwo, true)));
+        Assertions.assertTrue(notIn.test(GenericRow.of(3, 
BinaryString.fromString("x"))));
+        Assertions.assertFalse(notIn.test(GenericRow.of(2, 
BinaryString.fromString("x"))));
+    }
+
+    private static Predicate single(List<Predicate> predicates) {
+        Assertions.assertEquals(1, predicates.size(), "the predicate must be 
pushed as one paimon predicate");
+        return predicates.get(0);
+    }
+
     // ---------- null-safe equality (<=>) ----------
 
     @Test
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeCastPredicateTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeCastPredicateTest.java
new file mode 100644
index 00000000000..2f1ab81edff
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeCastPredicateTest.java
@@ -0,0 +1,152 @@
+// 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.datasource.scan;
+
+import org.apache.doris.analysis.Expr;
+import org.apache.doris.common.FeConstants;
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.connector.spi.ConnectorMetadata;
+import org.apache.doris.connector.spi.pushdown.ConnectorColumnRef;
+import org.apache.doris.connector.spi.pushdown.ConnectorComparison;
+import org.apache.doris.connector.spi.pushdown.ConnectorExpression;
+import org.apache.doris.qe.StmtExecutor;
+import org.apache.doris.utframe.TestWithFeService;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.Mockito;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Guards how {@link PluginDrivenScanNode} keeps CAST predicates in Doris for 
a connector that cannot
+ * evaluate them, such as Paimon.
+ *
+ * <p>The neutral expression converter drops CASTs, so a pushed {@code 
CAST(code AS INT) = 5} would reach
+ * the connector as {@code code = 5} and could prune files that hold {@code 
'05'} or {@code ' 5'}, losing
+ * rows. A connector that returns false from {@code 
supportsCastPredicatePushdown} therefore never receives
+ * a conjunct with a CAST anywhere in its tree; the conjunct stays in Doris 
and is evaluated after the
+ * scan. branch-4.x fixed the same row loss inside its Paimon predicate 
converter (#67933); master enforces
+ * it in {@code buildRemainingFilter}, which had no direct test.</p>
+ *
+ * <p>The conjuncts come from planning real queries, so the shapes are the 
ones Nereids produces. The
+ * filter is then built through the real private method on a {@code 
CALLS_REAL_METHODS} node with only the
+ * connector metadata injected.</p>
+ */
+public class PluginDrivenScanNodeCastPredicateTest extends TestWithFeService {
+
+    @Override
+    protected void runBeforeAll() throws Exception {
+        FeConstants.runningUnitTest = true;
+        createDatabase("db1");
+        createTables("create table db1.cast_predicates (id int, code string, 
amount decimal(10, 2))"
+                + " distributed by hash(id) properties('replication_num' = 
'1')");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"cast(code as int) = 5", "5 = cast(code as int)", 
"cast(code as int) > 5",
+            "cast(code as int) in (5, 6)", "cast(code as int) not in (5, 6)",
+            "cast(code as int) is null", "cast(code as int) is not null",
+            "cast(code as int) = 5 or id = 1", "cast(amount as decimal(10, 1)) 
= 1.2"})
+    public void castConjunctsStayInDoris(String predicate) throws Exception {
+        List<Expr> conjuncts = planConjuncts(predicate);
+        // Precondition: the planner keeps the CAST as a CastExpr node, the 
shape the rule looks for.
+        
Assertions.assertTrue(conjuncts.stream().anyMatch(PluginDrivenScanNode::containsCastExpr),
 predicate);
+        List<Expr> before = Expr.cloneList(conjuncts);
+
+        PluginDrivenScanNode node = nodeWith(conjuncts, false);
+        Optional<ConnectorExpression> filter = Deencapsulation.invoke(node, 
"buildRemainingFilter");
+
+        Assertions.assertFalse(filter.isPresent(), predicate);
+        // Nothing was sent to the connector, so the source LIMIT must stay 
off ...
+        Assertions.assertEquals(Collections.emptyList(),
+                Deencapsulation.getField(node, "filteredToOriginalIndex"), 
predicate);
+        // ... and every conjunct must remain for Doris to evaluate.
+        Assertions.assertEquals(before, conjuncts, predicate);
+    }
+
+    @Test
+    public void independentPlainConjunctIsStillPushed() throws Exception {
+        List<Expr> conjuncts = planConjuncts("cast(code as int) = 5 and id = 
1");
+        Assertions.assertEquals(2, conjuncts.size());
+        int plainIndex = 
PluginDrivenScanNode.containsCastExpr(conjuncts.get(0)) ? 1 : 0;
+        
Assertions.assertFalse(PluginDrivenScanNode.containsCastExpr(conjuncts.get(plainIndex)));
+        List<Expr> before = Expr.cloneList(conjuncts);
+
+        PluginDrivenScanNode node = nodeWith(conjuncts, false);
+        Optional<ConnectorExpression> filter = Deencapsulation.invoke(node, 
"buildRemainingFilter");
+
+        Assertions.assertTrue(filter.isPresent());
+        Assertions.assertTrue(filter.get() instanceof ConnectorComparison, 
filter.get().toString());
+        ConnectorComparison comparison = (ConnectorComparison) filter.get();
+        Assertions.assertEquals("id", ((ConnectorColumnRef) 
comparison.getLeft()).getColumnName());
+        Assertions.assertEquals(Collections.singletonList(plainIndex),
+                Deencapsulation.getField(node, "filteredToOriginalIndex"));
+        Assertions.assertEquals(before, conjuncts);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"code = '5'", "id > 1", "id in (1, 2)"})
+    public void plainConjunctsArePushedWhole(String predicate) throws 
Exception {
+        List<Expr> conjuncts = planConjuncts(predicate);
+        
Assertions.assertFalse(conjuncts.stream().anyMatch(PluginDrivenScanNode::containsCastExpr),
 predicate);
+
+        PluginDrivenScanNode node = nodeWith(conjuncts, false);
+        Optional<ConnectorExpression> filter = Deencapsulation.invoke(node, 
"buildRemainingFilter");
+
+        Assertions.assertTrue(filter.isPresent(), predicate);
+        // Every conjunct went to the connector, so no index remapping is 
needed.
+        Assertions.assertNull(Deencapsulation.getField(node, 
"filteredToOriginalIndex"), predicate);
+    }
+
+    @Test
+    public void castConjunctIsPushedWhenTheConnectorAcceptsCasts() throws 
Exception {
+        List<Expr> conjuncts = planConjuncts("cast(code as int) = 5");
+
+        PluginDrivenScanNode node = nodeWith(conjuncts, true);
+        Optional<ConnectorExpression> filter = Deencapsulation.invoke(node, 
"buildRemainingFilter");
+
+        // The same conjunct is pushed once the connector accepts CASTs, so 
the rule above is the switch.
+        Assertions.assertTrue(filter.isPresent());
+        Assertions.assertNull(Deencapsulation.getField(node, 
"filteredToOriginalIndex"));
+    }
+
+    private List<Expr> planConjuncts(String predicate) throws Exception {
+        StmtExecutor executor = new StmtExecutor(connectContext,
+                "select * from db1.cast_predicates where " + predicate);
+        executor.execute();
+        return executor.planner().getScanNodes().get(0).getConjuncts();
+    }
+
+    /**
+     * A node whose connector declares {@code supportsCast} and whose 
conjuncts are {@code conjuncts}.
+     * {@code cachedMetadata} is injected directly so the per-statement 
metadata funnel stays out of the test.
+     */
+    private static PluginDrivenScanNode nodeWith(List<Expr> conjuncts, boolean 
supportsCast) {
+        PluginDrivenScanNode node = Mockito.mock(PluginDrivenScanNode.class, 
Mockito.CALLS_REAL_METHODS);
+        ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class);
+        
Mockito.when(metadata.supportsCastPredicatePushdown(Mockito.any())).thenReturn(supportsCast);
+        Deencapsulation.setField(node, "cachedMetadata", metadata);
+        Deencapsulation.setField(node, "conjuncts", conjuncts);
+        return node;
+    }
+}
diff --git 
a/regression-test/data/external_table_p0/paimon/test_paimon_cast_predicate_pushdown.out
 
b/regression-test/data/external_table_p0/paimon/test_paimon_cast_predicate_pushdown.out
new file mode 100644
index 00000000000..42744006644
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/paimon/test_paimon_cast_predicate_pushdown.out
@@ -0,0 +1,95 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !jni_0 --
+1      05      1.24
+2       5      1.20
+3      5       1.30
+
+-- !jni_1 --
+1      05      1.24
+2       5      1.20
+3      5       1.30
+
+-- !jni_2 --
+1      05      1.24
+2       5      1.20
+3      5       1.30
+
+-- !jni_3 --
+1      05      1.24
+2       5      1.20
+3      5       1.30
+
+-- !jni_4 --
+
+-- !jni_5 --
+4      abc     \N
+5      \N      1.25
+
+-- !jni_6 --
+1      05      1.24
+2       5      1.20
+3      5       1.30
+
+-- !jni_7 --
+1      05      1.24
+2       5      1.20
+3      5       1.30
+4      abc     \N
+
+-- !jni_8 --
+1      05      1.24
+2       5      1.20
+
+-- !jni_9 --
+3      5       1.30
+
+-- !jni_limit --
+1
+
+-- !native_0 --
+1      05      1.24
+2       5      1.20
+3      5       1.30
+
+-- !native_1 --
+1      05      1.24
+2       5      1.20
+3      5       1.30
+
+-- !native_2 --
+1      05      1.24
+2       5      1.20
+3      5       1.30
+
+-- !native_3 --
+1      05      1.24
+2       5      1.20
+3      5       1.30
+
+-- !native_4 --
+
+-- !native_5 --
+4      abc     \N
+5      \N      1.25
+
+-- !native_6 --
+1      05      1.24
+2       5      1.20
+3      5       1.30
+
+-- !native_7 --
+1      05      1.24
+2       5      1.20
+3      5       1.30
+4      abc     \N
+
+-- !native_8 --
+1      05      1.24
+2       5      1.20
+
+-- !native_9 --
+3      5       1.30
+
+-- !native_limit --
+1
+
diff --git 
a/regression-test/suites/external_table_p0/paimon/test_paimon_cast_predicate_pushdown.groovy
 
b/regression-test/suites/external_table_p0/paimon/test_paimon_cast_predicate_pushdown.groovy
new file mode 100644
index 00000000000..5b48699a813
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/paimon/test_paimon_cast_predicate_pushdown.groovy
@@ -0,0 +1,109 @@
+// 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.
+
+// WHY: Paimon cannot evaluate a Doris CAST, and the connector expression 
converter drops CASTs. A pushed
+// CAST(code AS INT) = 5 would reach Paimon as code = 5 and let it prune the 
data files that hold '05' or ' 5',
+// so the query would silently return fewer rows. Paimon declares 
supportsCastPredicatePushdown() = false: a
+// conjunct with a CAST anywhere in it stays in Doris, and only the plain 
conjuncts reach Paimon. branch-4.x
+// fixed the same row loss in its Paimon predicate converter (#67933).
+//
+// Each row is written in its own INSERT so the rows land in separate data 
files and file-level pruning is
+// reachable; with a single file the scan would read every row anyway and a 
wrong pushdown would hide.
+suite("test_paimon_cast_predicate_pushdown", "p0,external") {
+    String enabled = context.config.otherConfigs.get("enablePaimonTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable paimon test")
+        return
+    }
+
+    String minio_port = context.config.otherConfigs.get("iceberg_minio_port")
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String catalog_name = "test_paimon_cast_predicate_pushdown"
+    String dbName = "test_paimon_cast_predicate_pushdown_db"
+
+    spark_paimon_multi """
+        create database if not exists paimon.${dbName};
+        drop table if exists paimon.${dbName}.cast_pushdown_tbl;
+        create table paimon.${dbName}.cast_pushdown_tbl (id int, code string, 
amount decimal(10, 2)) using paimon;
+        insert into paimon.${dbName}.cast_pushdown_tbl values (1, '05', 1.24);
+        insert into paimon.${dbName}.cast_pushdown_tbl values (2, ' 5', 1.20);
+        insert into paimon.${dbName}.cast_pushdown_tbl values (3, '5', 1.30);
+        insert into paimon.${dbName}.cast_pushdown_tbl values (4, 'abc', null);
+        insert into paimon.${dbName}.cast_pushdown_tbl values (5, null, 1.25);
+    """
+
+    sql """drop catalog if exists ${catalog_name}"""
+    sql """
+        CREATE CATALOG ${catalog_name} PROPERTIES (
+                'type' = 'paimon',
+                'warehouse' = 's3://warehouse/wh',
+                's3.endpoint' = 'http://${externalEnvIp}:${minio_port}',
+                's3.access_key' = 'admin',
+                's3.secret_key' = 'password',
+                's3.path.style.access' = 'true'
+        );
+    """
+    sql """use `${catalog_name}`.`${dbName}`"""
+
+    def castPredicates = [
+            "cast(code as int) = 5",
+            "5 = cast(code as int)",
+            "cast(code as int) > 4",
+            "cast(code as int) in (5, 6)",
+            "cast(code as int) not in (5, 6)",
+            "cast(code as int) is null",
+            "cast(code as int) is not null",
+            "cast(code as int) = 5 or id = 4",
+            "cast(amount as decimal(10, 1)) = 1.2",
+    ]
+
+    // A conjunct that contains a CAST never reaches Paimon.
+    castPredicates.each { predicate ->
+        explain {
+            sql "select id from cast_pushdown_tbl where ${predicate}"
+            contains "predicatesFromPaimon: NONE"
+        }
+    }
+    // Plain conjuncts are still pushed, also next to a CAST conjunct that 
stays in Doris.
+    ["id = 3", "cast(code as int) = 5 and id = 3"].each { predicate ->
+        explain {
+            sql "select id from cast_pushdown_tbl where ${predicate}"
+            contains "predicatesFromPaimon:"
+            notContains "predicatesFromPaimon: NONE"
+        }
+    }
+
+    try {
+        ["jni": "true", "native": "false"].each { reader, forceJni ->
+            sql """set force_jni_scanner = ${forceJni}"""
+            (castPredicates + ["cast(code as int) = 5 and id = 
3"]).eachWithIndex { predicate, index ->
+                "order_qt_${reader}_${index}"("""
+                    select id, code, amount from cast_pushdown_tbl where 
${predicate} order by id
+                """)
+            }
+            // The source LIMIT stays off while a CAST conjunct is evaluated 
in Doris, so one matching row is
+            // returned instead of whatever row Paimon would have read first.
+            "order_qt_${reader}_limit"("""
+                select count(*) from (
+                    select id from cast_pushdown_tbl where cast(code as int) = 
5 limit 1
+                ) t
+            """)
+        }
+    } finally {
+        sql """set force_jni_scanner = false"""
+    }
+}


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

Reply via email to