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

englefly 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 a50bbf24da8 [fix](fe) Keep a renaming STRUCT cast instead of folding 
it into a no-op (#69328)
a50bbf24da8 is described below

commit a50bbf24da8c5bd85072715594bb4e636ff6ddb1
Author: minghong <[email protected]>
AuthorDate: Thu Oct 22 10:41:42 2026 +0800

    [fix](fe) Keep a renaming STRUCT cast instead of folding it into a no-op 
(#69328)
    
    ### What problem does this PR solve?
    
    Issue Number: DORIS-29733, DORIS-29734
    
    Related PR: None
    
    Problem Summary:
    
    A STRUCT -> STRUCT cast converts positionally and its result carries the
    target field names, but
    the FE treated a cast that only renames fields as a no-op and dropped
    the CAST node when
    serializing expressions to thrift: `CastExpr.noOp` comes from
    `Type.matchExactType`, whose struct
    branch compared the fields positionally and ignored their names. The
    folded-away cast also
    overwrote the child expression's type with the target type, so the FE
    bookkeeping and the physical
    column disagreed: the value kept the source field names, while the
    ELEMENT_AT binding and the
    nested column pruning of the cast target were planned against the target
    names.
    
    Reproduction with the default session variables on a single FE/BE:
    
    ```sql
    CREATE TABLE t (id INT, s STRUCT<a:STRING,b:STRING>) ENGINE = OLAP
    DUPLICATE KEY(id) DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES 
("replication_num" = "1");
    INSERT INTO t VALUES (1, named_struct('a', 'left', 'b', 'right'));
    
    SELECT element_at(CAST(s AS STRUCT<b:STRING,a:STRING>), 'b') FROM t;
    ```
    
    Before the fix:
    
    * with nested column pruning on (the default) the pruned slot kept only
    source field `a`, and the
      query failed with
    `errCode = 2, detailMessage = ... [RUNTIME_ERROR]Element not found for
    function element_at: name b not found in Struct(a:Nullable(String))`;
    * with nested column pruning off the query silently returned `right`
    (the source field `b`)
      instead of `left`;
    * reading both target fields returned (`right`, `left`) instead of
    (`left`, `right`), while the
      whole value CAST correctly returned `{b:'left', a:'right'}`.
    
    `EXPLAIN VERBOSE` is correct in every case because the Nereids plan
    keeps the cast; only the thrift
    sent to BE lost it, which is what makes this easy to misdiagnose as a
    pruning bug.
    
    Root cause in one line: `noOp == true` meant "no CAST_EXPR node", and
    BE's `element_at` resolves the
    field name against the type attached to the argument column in the Block
    (`try_get_position_by_name`
    on the struct type of the block position). With the cast gone, that type
    was the scan type, i.e.
    the source field names. BE has an explicit hook for this FE/BE type
    divergence
    (`VExpr::execute_type`, "for slot-ref expressions the type of the
    returned Column may not match
    data_type"), which is exactly the gap the elided cast fell into.
    
    Fix:
    
    A struct field name is part of the value identity, so
    `Type.matchExactType` now also requires the
    field names to match (comparing the already normalized lower case name,
    consistent with BE's case
    insensitive struct field lookup). A rename-only cast is serialized
    instead of being skipped, and BE
    relabels the value with the target type. Since the positional field
    types are unchanged, BE keeps
    the identity cast wrapper (`prepare_impl` compares with
    `DataTypeStruct::equals`, which ignores
    names), so no data is copied. Renames nested in ARRAY/MAP are covered by
    the same recursion, and
    `try_cast` inherits the fixed no-op decision.
    
    After the fix both toggle states return the positional result, b=`left`
    and a=`right`, matching the
    whole value CAST, and per-field pruning is preserved: the scan still
    reads only the source field
    behind the accessed target field (`all access paths: [s.a]` / `[s.b]`).
    
    ### Release note
    
    Fixed a wrong result and an "Element not found for function element_at"
    error when a
    STRUCT -> STRUCT cast renames fields and its result is accessed by field
    name.
    
    ### Check List (For Author)
    
    - Test
        - [x] Regression test
        - [x] Unit Test
        - [x] Manual test (add detailed scripts or steps below)
        - [ ] No need to test or manual test. Explain why:
    - Behavior changed:
        - [ ] No.
    - [x] Yes. A cast that only renames STRUCT fields is no longer folded
    away, so its result
    carries the target field names as the type system already promised.
    Casts whose field names
          already match keep the existing no-op behavior.
    - Does this need documentation?
        - [x] No.
    
    Test detail:
    
    * Unit test: `mvn test -pl fe-core -am
    
-Dtest='CastExprHashPartitionTest,CastMonotonicityTest,CastTest,CheckCastTest,SimplifyCastRuleTest,TryCastTest,TypeTest,ExprToThriftBehaviorTest'`
    passes.
    `ExprToThriftBehaviorTest#testStructRenameCastIsNotNoOp` asserts
    `isNoOp() == false` and that the
    serialized tree is `CAST_EXPR(type=struct<b,a>)` +
    `SLOT_REF(type=struct<a,b>)`;
      `#testStructRenameNestedInArrayCastIsNotNoOp` covers the recursion;
    `#testSameStructTypeCastIsStillNoOp` pins that the no-op optimization is
    kept;
    `TypeTest#testStructWithNestedFieldsExactMatch` covers the match rule
    itself.
    * Regression test: `./run-regression-test.sh --run -d
    nereids_rules_p0/column_pruning` — 16 suites
    pass, including the new `struct_cast_rename_pruning`, which asserts the
    values and
      `all access paths: [s.a]` / `[s.b]` for pruning on and off.
    * Manual test: the snippet above plus WHERE / GROUP BY / ORDER BY /
    subquery / CTE / `INSERT INTO` a
    table whose struct column is named differently / ARRAY and MAP nested
    renames, all with nested
      column pruning both on and off, on a local FE built from this commit.
---
 .../java/org/apache/doris/analysis/CastExpr.java   |   5 +-
 .../doris/analysis/ExprToThriftBehaviorTest.java   |  49 +++++++++
 .../java/org/apache/doris/catalog/TypeTest.java    |  18 +++-
 .../main/java/org/apache/doris/catalog/Type.java   |  12 ++-
 .../column_pruning/struct_cast_rename_pruning.out  |  34 ++++++
 .../struct_cast_rename_pruning.groovy              | 120 +++++++++++++++++++++
 6 files changed, 232 insertions(+), 6 deletions(-)

diff --git 
a/fe/fe-catalog/src/main/java/org/apache/doris/analysis/CastExpr.java 
b/fe/fe-catalog/src/main/java/org/apache/doris/analysis/CastExpr.java
index 7b63d468d73..4e813f2516f 100644
--- a/fe/fe-catalog/src/main/java/org/apache/doris/analysis/CastExpr.java
+++ b/fe/fe-catalog/src/main/java/org/apache/doris/analysis/CastExpr.java
@@ -53,8 +53,9 @@ public class CastExpr extends Expr {
             if (type.isDecimalV2() && e.type.isDecimalV2()) {
                 getChild(0).setType(type);
             }
-            // as the targetType have struct field name, if use the default 
name will be
-            // like col1,col2, col3... in struct, and the filed name is import 
in BE.
+            // matchExactType already requires the same struct field names, so 
the target type only
+            // contributes the field metadata (originalName/comment) here. A 
cast that renames the
+            // fields is not a no-op: BE has to relabel the value with the 
target field names.
             if (type.isStructType() && e.type.isStructType()) {
                 getChild(0).setType(type);
             }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/analysis/ExprToThriftBehaviorTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/analysis/ExprToThriftBehaviorTest.java
index 3f9b5fc2f2d..0bc5fcae379 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/analysis/ExprToThriftBehaviorTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/analysis/ExprToThriftBehaviorTest.java
@@ -17,7 +17,10 @@
 
 package org.apache.doris.analysis;
 
+import org.apache.doris.catalog.ArrayType;
 import org.apache.doris.catalog.Function.NullableMode;
+import org.apache.doris.catalog.StructField;
+import org.apache.doris.catalog.StructType;
 import org.apache.doris.catalog.Type;
 import org.apache.doris.common.AnalysisException;
 import org.apache.doris.thrift.TExpr;
@@ -269,6 +272,45 @@ public class ExprToThriftBehaviorTest {
         Assertions.assertEquals(TExprNodeType.INT_LITERAL, 
nodes.get(0).node_type);
     }
 
+    @Test
+    public void testStructRenameCastIsNotNoOp() {
+        // CAST(s AS STRUCT<b:STRING,a:STRING>) on a STRUCT<a:STRING,b:STRING> 
must reach BE: the
+        // conversion is positional, so the result carries the target field 
names. Folding it away
+        // would leave the value labeled with the source names, and any name 
based field access on
+        // the cast result (ELEMENT_AT, nested field references) would resolve 
against the source.
+        StructType sourceType = structType("a", "b");
+        StructType targetType = structType("b", "a");
+        CastExpr expr = new CastExpr(targetType, new SlotRef(sourceType, 
true), true);
+        Assertions.assertFalse(expr.isNoOp());
+
+        List<TExprNode> nodes = 
ExprToThriftVisitor.treeToThrift(expr).getNodes();
+        Assertions.assertEquals(2, nodes.size());
+        Assertions.assertEquals(TExprNodeType.CAST_EXPR, 
nodes.get(0).node_type);
+        Assertions.assertEquals(TExprOpcode.CAST, nodes.get(0).getOpcode());
+        StructType emittedTarget = (StructType) 
Type.fromThrift(nodes.get(0).getType());
+        Assertions.assertEquals("b", 
emittedTarget.getFields().get(0).getName());
+        Assertions.assertEquals("a", 
emittedTarget.getFields().get(1).getName());
+        Assertions.assertEquals(TExprNodeType.SLOT_REF, 
nodes.get(1).node_type);
+    }
+
+    @Test
+    public void testStructRenameNestedInArrayCastIsNotNoOp() {
+        ArrayType sourceType = new ArrayType(structType("a", "b"), true);
+        ArrayType targetType = new ArrayType(structType("b", "a"), true);
+        Assertions.assertFalse(new CastExpr(targetType, new 
SlotRef(sourceType, true), true).isNoOp());
+    }
+
+    @Test
+    public void testSameStructTypeCastIsStillNoOp() {
+        CastExpr expr = new CastExpr(structType("a", "b"),
+                new SlotRef(structType("a", "b"), true), true);
+        Assertions.assertTrue(expr.isNoOp());
+
+        List<TExprNode> nodes = 
ExprToThriftVisitor.treeToThrift(expr).getNodes();
+        Assertions.assertEquals(1, nodes.size());
+        Assertions.assertEquals(TExprNodeType.SLOT_REF, 
nodes.get(0).node_type);
+    }
+
     // ======================== treesToThrift ========================
 
     @Test
@@ -309,4 +351,11 @@ public class ExprToThriftBehaviorTest {
         Assertions.assertFalse(texpr.getNodes().isEmpty());
         return texpr.getNodes().get(0);
     }
+
+    private static StructType structType(String firstName, String secondName) {
+        return new StructType(
+                new StructField(firstName, Type.STRING, null, true),
+                new StructField(secondName, Type.STRING, null, true)
+        );
+    }
 }
diff --git a/fe/fe-core/src/test/java/org/apache/doris/catalog/TypeTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/catalog/TypeTest.java
index 7c66345fb6d..5d07f899658 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/catalog/TypeTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/catalog/TypeTest.java
@@ -116,8 +116,22 @@ public class TypeTest {
                 new StructField("x", Type.INT, null, true),
                 new StructField("y", new ArrayType(Type.INT, true), null, true)
         );
-        // names are ignored by matchExactType recursion; matchesType requires 
containsNull equal
-        Assertions.assertTrue(Type.matchExactType(s1, s2, false));
+        // field names take part in the match: a rename-only struct cast is 
not a no-op because
+        // name based field access resolves against the cast result's field 
names at runtime
+        Assertions.assertFalse(Type.matchExactType(s1, s2, false));
+
+        // identical names and positional types match
+        StructType s1SameNames = new StructType(
+                new StructField("f1", Type.INT, null, true),
+                new StructField("f2", new ArrayType(Type.INT, true), null, 
true)
+        );
+        Assertions.assertTrue(Type.matchExactType(s1, s1SameNames, false));
+
+        // a renamed field nested inside an array is not a no-op either
+        Assertions.assertFalse(Type.matchExactType(
+                new ArrayType(s1, true), new ArrayType(s2, true), false));
+        Assertions.assertTrue(Type.matchExactType(
+                new ArrayType(s1, true), new ArrayType(s1SameNames, true), 
false));
 
         // inner element type differs
         StructType s3 = new StructType(
diff --git a/fe/fe-type/src/main/java/org/apache/doris/catalog/Type.java 
b/fe/fe-type/src/main/java/org/apache/doris/catalog/Type.java
index fd8976c4730..5888bf01ec2 100644
--- a/fe/fe-type/src/main/java/org/apache/doris/catalog/Type.java
+++ b/fe/fe-type/src/main/java/org/apache/doris/catalog/Type.java
@@ -1249,8 +1249,16 @@ public abstract class Type {
                     return false;
                 }
                 for (int i = 0; i < struct1.getFields().size(); i++) {
-                    if (!matchExactType(struct1.getFields().get(i).getType(),
-                            struct2.getFields().get(i).getType(), 
ignorePrecision)) {
+                    StructField field1 = struct1.getFields().get(i);
+                    StructField field2 = struct2.getFields().get(i);
+                    // A struct field name is part of the value identity: the 
cast is defined to
+                    // return the target field names positionally, and BE 
resolves name based
+                    // access (ELEMENT_AT, nested field references) against 
the type attached to
+                    // the result column at execution time. Dropping a 
rename-only cast would let
+                    // those lookups see the source names, so such a cast is 
not a no-op even when
+                    // the positional field types are identical.
+                    if (!field1.getName().equals(field2.getName())
+                            || !matchExactType(field1.getType(), 
field2.getType(), ignorePrecision)) {
                         return false;
                     }
                 }
diff --git 
a/regression-test/data/nereids_rules_p0/column_pruning/struct_cast_rename_pruning.out
 
b/regression-test/data/nereids_rules_p0/column_pruning/struct_cast_rename_pruning.out
new file mode 100644
index 00000000000..94a601e8e7b
--- /dev/null
+++ 
b/regression-test/data/nereids_rules_p0/column_pruning/struct_cast_rename_pruning.out
@@ -0,0 +1,34 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !rename_prune_on_b --
+1      left
+
+-- !rename_prune_on_a --
+1      right
+
+-- !rename_prune_on_both --
+1      left    right
+
+-- !rename_prune_on_full_cast --
+1      {"b":"left", "a":"right"}
+
+-- !rename_prune_on_array --
+1      left
+
+-- !rename_prune_off_b --
+1      left
+
+-- !rename_prune_off_a --
+1      right
+
+-- !rename_prune_off_both --
+1      left    right
+
+-- !rename_prune_off_full_cast --
+1      {"b":"left", "a":"right"}
+
+-- !rename_prune_off_array --
+1      left
+
+-- !same_names_cast --
+1      left    right
+
diff --git 
a/regression-test/suites/nereids_rules_p0/column_pruning/struct_cast_rename_pruning.groovy
 
b/regression-test/suites/nereids_rules_p0/column_pruning/struct_cast_rename_pruning.groovy
new file mode 100644
index 00000000000..b0214e892fe
--- /dev/null
+++ 
b/regression-test/suites/nereids_rules_p0/column_pruning/struct_cast_rename_pruning.groovy
@@ -0,0 +1,120 @@
+// 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.
+
+// Regression tests for a rename-only STRUCT -> STRUCT cast.
+//
+// A STRUCT -> STRUCT cast converts positionally and carries the target field 
names, so
+// element_at(cast(s as struct<b:...,a:...>), 'b') on a struct<a:...,b:...> 
returns the source
+// field a. The cast must therefore reach BE even when the positional field 
types are identical:
+// name based access (ELEMENT_AT, nested field references) resolves against 
the type attached to
+// the cast result at execution time. Before the fix the FE folded such a cast 
into a no-op,
+// leaving the value labeled with the source field names, which made
+//   * nested column pruning on: the pruned slot keep the source field name 
and element_at fail
+//     with "name b not found in Struct(a:...)";
+//   * nested column pruning off: element_at silently return the source field 
b.
+// Both settings must return the same positional result, and field level 
pruning must survive.
+
+suite("struct_cast_rename_pruning") {
+    sql "set enable_sql_cache = false"
+    sql "DROP TABLE IF EXISTS struct_cast_rename_pruning_tbl"
+    sql """
+        CREATE TABLE struct_cast_rename_pruning_tbl (
+            id     INT,
+            s      STRUCT<a: STRING, b: STRING>,
+            arr_s  ARRAY<STRUCT<a: STRING, b: STRING>>
+        ) ENGINE = OLAP
+        DUPLICATE KEY(id)
+        DISTRIBUTED BY HASH(id) BUCKETS 1
+        PROPERTIES ("replication_allocation" = "tag.location.default: 1")
+    """
+    sql """
+        INSERT INTO struct_cast_rename_pruning_tbl VALUES
+            (1, named_struct('a', 'left', 'b', 'right'),
+                array(named_struct('a', 'left', 'b', 'right')))
+    """
+
+    // ── 1. Field level pruning still follows the positional mapping 
─────────────
+    // Target b is the source position 0 (a), target a is the source position 
1 (b).
+    explain {
+        sql "select element_at(cast(s as struct<b:string,a:string>), 'b') from 
struct_cast_rename_pruning_tbl"
+        contains "all access paths: [s.a]"
+    }
+    explain {
+        sql "select element_at(cast(s as struct<b:string,a:string>), 'a') from 
struct_cast_rename_pruning_tbl"
+        contains "all access paths: [s.b]"
+    }
+
+    // ── 2. Pruning on: name based access resolves against the target names 
──────
+    order_qt_rename_prune_on_b """
+        SELECT id, element_at(CAST(s AS STRUCT<b:STRING,a:STRING>), 'b') AS b_v
+        FROM struct_cast_rename_pruning_tbl ORDER BY id
+    """
+    order_qt_rename_prune_on_a """
+        SELECT id, element_at(CAST(s AS STRUCT<b:STRING,a:STRING>), 'a') AS a_v
+        FROM struct_cast_rename_pruning_tbl ORDER BY id
+    """
+    order_qt_rename_prune_on_both """
+        SELECT id,
+               element_at(CAST(s AS STRUCT<b:STRING,a:STRING>), 'b') AS b_v,
+               element_at(CAST(s AS STRUCT<b:STRING,a:STRING>), 'a') AS a_v
+        FROM struct_cast_rename_pruning_tbl ORDER BY id
+    """
+    order_qt_rename_prune_on_full_cast """
+        SELECT id, CAST(s AS STRUCT<b:STRING,a:STRING>) AS renamed
+        FROM struct_cast_rename_pruning_tbl ORDER BY id
+    """
+    // A rename nested in an array is positional as well.
+    order_qt_rename_prune_on_array """
+        SELECT id, element_at(element_at(CAST(arr_s AS 
ARRAY<STRUCT<b:STRING,a:STRING>>), 1), 'b') AS b_v
+        FROM struct_cast_rename_pruning_tbl ORDER BY id
+    """
+
+    // ── 3. Pruning off: identical results 
──────────────────────────────────────
+    sql "set enable_prune_nested_column = false"
+    order_qt_rename_prune_off_b """
+        SELECT id, element_at(CAST(s AS STRUCT<b:STRING,a:STRING>), 'b') AS b_v
+        FROM struct_cast_rename_pruning_tbl ORDER BY id
+    """
+    order_qt_rename_prune_off_a """
+        SELECT id, element_at(CAST(s AS STRUCT<b:STRING,a:STRING>), 'a') AS a_v
+        FROM struct_cast_rename_pruning_tbl ORDER BY id
+    """
+    order_qt_rename_prune_off_both """
+        SELECT id,
+               element_at(CAST(s AS STRUCT<b:STRING,a:STRING>), 'b') AS b_v,
+               element_at(CAST(s AS STRUCT<b:STRING,a:STRING>), 'a') AS a_v
+        FROM struct_cast_rename_pruning_tbl ORDER BY id
+    """
+    order_qt_rename_prune_off_full_cast """
+        SELECT id, CAST(s AS STRUCT<b:STRING,a:STRING>) AS renamed
+        FROM struct_cast_rename_pruning_tbl ORDER BY id
+    """
+    order_qt_rename_prune_off_array """
+        SELECT id, element_at(element_at(CAST(arr_s AS 
ARRAY<STRUCT<b:STRING,a:STRING>>), 1), 'b') AS b_v
+        FROM struct_cast_rename_pruning_tbl ORDER BY id
+    """
+
+    // ── 4. A cast whose field names already match is still a no-op 
─────────────
+    sql "set enable_prune_nested_column = true"
+    order_qt_same_names_cast """
+        SELECT id,
+               element_at(CAST(s AS STRUCT<a:STRING,b:STRING>), 'a') AS a_v,
+               element_at(CAST(s AS STRUCT<a:STRING,b:STRING>), 'b') AS b_v
+        FROM struct_cast_rename_pruning_tbl ORDER BY id
+    """
+    sql "set enable_sql_cache = true"
+}


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

Reply via email to