This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.2 by this push:
new 1e9486cec3e branch-4.2: [fix](meta path) Keep whole-value TRY_CAST
semantics under nested column pruning #67718 (#70051)
1e9486cec3e is described below
commit 1e9486cec3e52dc857bce7c0a4824d5abd7a9ff7
Author: morrySnow <[email protected]>
AuthorDate: Wed Nov 4 08:12:41 2026 +0800
branch-4.2: [fix](meta path) Keep whole-value TRY_CAST semantics under
nested column pruning #67718 (#70051)
picked from master #67718
Co-authored-by: minghong <[email protected]>
---
.../rewrite/AccessPathExpressionCollector.java | 16 +++
.../nereids/rules/rewrite/SlotTypeReplacer.java | 10 +-
.../rules/rewrite/PruneNestedColumnTest.java | 64 ++++++++++++
.../column_pruning/try_cast_nested_pruning.out | 25 +++++
.../column_pruning/try_cast_nested_pruning.groovy | 107 +++++++++++++++++++++
5 files changed, 221 insertions(+), 1 deletion(-)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/AccessPathExpressionCollector.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/AccessPathExpressionCollector.java
index a814617fbec..fcd84cf9d83 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/AccessPathExpressionCollector.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/AccessPathExpressionCollector.java
@@ -30,6 +30,7 @@ import org.apache.doris.nereids.trees.expressions.Expression;
import org.apache.doris.nereids.trees.expressions.IsNull;
import org.apache.doris.nereids.trees.expressions.Not;
import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.expressions.TryCast;
import org.apache.doris.nereids.trees.expressions.functions.scalar.ArrayCount;
import org.apache.doris.nereids.trees.expressions.functions.scalar.ArrayExists;
import org.apache.doris.nereids.trees.expressions.functions.scalar.ArrayFilter;
@@ -337,6 +338,21 @@ public class AccessPathExpressionCollector extends
DefaultExpressionVisitor<Void
);
}
+ @Override
+ public Void visitTryCast(TryCast tryCast, CollectorContext context) {
+ // TRY_CAST semantics cover the WHOLE value: for a composite type any
element
+ // conversion failure makes the entire cast NULL. Narrowing the
read/type down to
+ // only the fields an outer expression accesses would drop the
conversion attempts
+ // of the other fields and silently change the result (e.g.
element_at(try_cast(s as
+ // struct<a:int,b:int>), 'a') must still yield NULL when only field b
is unparsable,
+ // even though field a alone converts fine). Plain Cast over nested
types is pruned
+ // field-by-field on purpose, a TryCast never is: read the whole child
value and keep
+ // the cast identity and target type intact.
+ return tryCast.child(0).accept(this,
+ new CollectorContext(context.statementContext,
context.bottomFilter)
+ );
+ }
+
// array element at
@Override
public Void visitElementAt(ElementAt elementAt, CollectorContext context) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/SlotTypeReplacer.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/SlotTypeReplacer.java
index e4ca2a09600..016092ee770 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/SlotTypeReplacer.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/SlotTypeReplacer.java
@@ -620,7 +620,15 @@ public class SlotTypeReplacer extends
DefaultPlanRewriter<Void> {
newType = prunedTree.pruneCastType(originTree, castTree);
}
- return new Cast(newChild, newType);
+ // Rebuild through withChildren/withTargetType so the concrete cast
kind survives. A
+ // TRY_CAST that reaches here has kept its whole immediate child value
(see
+ // AccessPathExpressionCollector.visitTryCast, which never translates
an access path
+ // through the cast), but that child can still be rebuilt: for
+ // element_at(try_cast(element_at(wrapper, 'f') as struct<...>), 'g')
the collector
+ // records [wrapper, f], so pruning may drop a sibling of wrapper and
replace the
+ // inner element_at. Constructing a Cast here would silently downgrade
the whole-value
+ // TRY_CAST into a strict CAST, turning its NULL result into a cast
error.
+ return
cast.withChildren(ImmutableList.of(newChild)).withTargetType(newType);
}
private List<TColumnAccessPath> replaceIcebergAccessPathToId(
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PruneNestedColumnTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PruneNestedColumnTest.java
index 070bc4fcddc..1afe9326e94 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PruneNestedColumnTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PruneNestedColumnTest.java
@@ -27,6 +27,7 @@ import
org.apache.doris.nereids.rules.rewrite.AccessPathExpressionCollector.Coll
import
org.apache.doris.nereids.rules.rewrite.NestedColumnPruning.DataTypeAccessTree;
import org.apache.doris.nereids.trees.expressions.Alias;
import org.apache.doris.nereids.trees.expressions.ArrayItemReference;
+import org.apache.doris.nereids.trees.expressions.Cast;
import org.apache.doris.nereids.trees.expressions.Expression;
import org.apache.doris.nereids.trees.expressions.IsNull;
import org.apache.doris.nereids.trees.expressions.NamedExpression;
@@ -163,6 +164,17 @@ public class PruneNestedColumnTest extends
TestWithFeService implements MemoPatt
+ " s struct<`outer`: struct<`a`: string, `inner_f`:
string>>\n"
+ ") properties ('replication_num'='1')");
+ // Struct holding a nested struct plus a sibling field. The sibling
makes the column
+ // prunable, while element_at(wrapper, 'payload') gives the TRY_CAST a
derived child that
+ // is rebuilt (not just re-typed) when the sibling is pruned away.
+ createTable("create table try_cast_nested_tbl(\n"
+ + " id int,\n"
+ + " wrapper struct<\n"
+ + " payload: struct<good: string, bad: string>,\n"
+ + " unused: int\n"
+ + " >\n"
+ + ") properties ('replication_num'='1')");
+
// Tables for outer-join nullability test: verifying that synthetic
nullability
// from outer join does NOT cause META NULL paths on physically NOT
NULL columns.
createTable("create table driving_tbl(\n"
@@ -637,6 +649,38 @@ public class PruneNestedColumnTest extends
TestWithFeService implements MemoPatt
);
}
+ @Test
+ public void testTryCastNotPrunedThroughFieldAccess() throws Exception {
+ // TRY_CAST of a composite type keeps whole-value semantics: the
conversion fails
+ // (and TRY_CAST returns NULL) if ANY field conversion fails. An outer
field access
+ // element_at(try_cast(s as struct<k:int,...>), 'k') must therefore
not narrow the
+ // underlying read to the accessed field only — the whole struct is
read and the
+ // cast target/identity are unchanged.
+ assertColumn(
+ "select element_at(try_cast(s as
struct<k:int,l:array<map<int,struct<a:int,b:double>>>>), 'k')"
+ + " from tbl",
+
"struct<city:text,data:array<map<int,struct<a:int,b:double>>>>",
+ ImmutableList.of(path("s")),
+ ImmutableList.of());
+ }
+
+ @Test
+ public void testTryCastKeptWhenDerivedChildIsRebuilt() throws Exception {
+ // Unlike testTryCastNotPrunedThroughFieldAccess, the TRY_CAST child
here is not the slot
+ // itself but a derived element_at. The whole-value context opened by
visitTryCast records
+ // [wrapper, payload], which only pins the payload value: the sibling
field wrapper.unused
+ // is still pruned away, the wrapper slot type changes, and
SlotTypeReplacer therefore
+ // rebuilds the inner element_at. Rebuilding the cast over that
changed child must not
+ // downgrade the whole-value TRY_CAST into a strict CAST.
+ String sql = "select element_at(try_cast(element_at(wrapper,
'payload') as struct<good:int,bad:int>),"
+ + " 'good') from try_cast_nested_tbl";
+ assertColumn(sql,
+ "struct<payload:struct<good:text,bad:text>>",
+ ImmutableList.of(path("wrapper", "payload")),
+ ImmutableList.of());
+ assertTryCastPreserved(sql, "STRUCT<good:INT,bad:INT>");
+ }
+
@Test
public void testPruneArrayLambda() throws Exception {
// map_values(element_at(s, 'data').*)[0].a
@@ -1537,6 +1581,26 @@ public class PruneNestedColumnTest extends
TestWithFeService implements MemoPatt
assertColumns(sql, expectType == null ? null :
ImmutableList.of(Triple.of(expectType, expectAllAccessPaths,
expectPredicateAccessPaths)));
}
+ /** Assert the physical plan contains a TRY_CAST of the given target type
and no plain CAST of it. */
+ private void assertTryCastPreserved(String sql, String expectTargetType)
throws Exception {
+ PhysicalPlan physicalPlan = collectComplexSlots(sql).first;
+ List<String> casts = new ArrayList<>();
+ physicalPlan.foreachUp(plan -> {
+ for (Expression expression : ((PhysicalPlan)
plan).getExpressions()) {
+ expression.foreach(e -> {
+ if (e instanceof Cast) {
+ Cast cast = (Cast) e;
+ casts.add(cast.getClass().getSimpleName() + " -> " +
cast.getDataType().toSql());
+ }
+ });
+ }
+ });
+ Assertions.assertTrue(casts.contains("TryCast -> " + expectTargetType),
+ "expected TRY_CAST -> " + expectTargetType + " in the physical
plan, but found " + casts);
+ Assertions.assertFalse(casts.contains("Cast -> " + expectTargetType),
+ "TRY_CAST was downgraded to a plain CAST: " + casts);
+ }
+
private void assertAllAccessPathsContain(String sql,
List<TColumnAccessPath> expectContainAllAccessPaths,
List<TColumnAccessPath> expectNotContainAllAccessPaths) throws
Exception {
Pair<PhysicalPlan, List<SlotDescriptor>> result =
collectComplexSlots(sql);
diff --git
a/regression-test/data/nereids_rules_p0/column_pruning/try_cast_nested_pruning.out
b/regression-test/data/nereids_rules_p0/column_pruning/try_cast_nested_pruning.out
new file mode 100644
index 00000000000..c7f5ea4e11f
--- /dev/null
+++
b/regression-test/data/nereids_rules_p0/column_pruning/try_cast_nested_pruning.out
@@ -0,0 +1,25 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !direct_slot_try_cast --
+1 10
+2 \N
+3 \N
+4 \N
+
+-- !derived_child_try_cast --
+1 10
+2 \N
+3 \N
+4 \N
+
+-- !direct_slot_try_cast_pruning_off --
+1 10
+2 \N
+3 \N
+4 \N
+
+-- !derived_child_try_cast_pruning_off --
+1 10
+2 \N
+3 \N
+4 \N
+
diff --git
a/regression-test/suites/nereids_rules_p0/column_pruning/try_cast_nested_pruning.groovy
b/regression-test/suites/nereids_rules_p0/column_pruning/try_cast_nested_pruning.groovy
new file mode 100644
index 00000000000..883b780d34c
--- /dev/null
+++
b/regression-test/suites/nereids_rules_p0/column_pruning/try_cast_nested_pruning.groovy
@@ -0,0 +1,107 @@
+// 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 TRY_CAST under nested column pruning.
+//
+// TRY_CAST of a composite type keeps whole-value semantics: the conversion
fails (and the
+// result is NULL) if ANY field conversion fails. Nested column pruning may
narrow which
+// columns a scan reads, but it must never narrow the value a TRY_CAST
converts, and it must
+// never downgrade a TRY_CAST into a strict CAST while rebuilding the
expressions whose slots
+// were narrowed.
+//
+// Three shapes are covered:
+// 1. the TRY_CAST child is the slot itself: the whole struct stays read
([s]);
+// 2. the TRY_CAST child is a derived struct access: the container's sibling
field is still
+// pruned ([wrapper.payload]), so the derived element_at is rebuilt and
the TRY_CAST must
+// survive that rebuild. Without the fix the rebuilt expression becomes a
plain CAST and
+// raises "parse number fail" under enable_strict_cast instead of
returning NULL;
+// 3. plain CAST over a nested type: still pruned field by field ([s.a]),
unchanged.
+
+suite("try_cast_nested_pruning") {
+ sql "set enable_prune_nested_column = true"
+ sql "set enable_strict_cast = true"
+ sql "DROP TABLE IF EXISTS try_cast_pruning_tbl"
+ sql """
+ CREATE TABLE try_cast_pruning_tbl (
+ id INT,
+ s STRUCT<a: VARCHAR(10), b: VARCHAR(10)>,
+ wrapper STRUCT<payload: STRUCT<good: VARCHAR(10), bad:
VARCHAR(10)>, unused: INT>
+ ) ENGINE = OLAP
+ DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES ("replication_allocation" = "tag.location.default: 1")
+ """
+
+ sql """
+ INSERT INTO try_cast_pruning_tbl VALUES
+ (1, named_struct('a', '10', 'b', '20'),
+ named_struct('payload', named_struct('good', '10', 'bad',
'20'), 'unused', 1)),
+ (2, named_struct('a', '10', 'b', 'bad'),
+ named_struct('payload', named_struct('good', '10', 'bad',
'bad'), 'unused', 2)),
+ (3, NULL, NULL),
+ (4, named_struct('a', 'bad', 'b', '20'),
+ named_struct('payload', named_struct('good', 'bad', 'bad',
'20'), 'unused', 4))
+ """
+
+ // ── 1. TRY_CAST over the slot itself
────────────────────────────────────────
+ // The whole struct is read: narrowing to s.a would drop the failing b
conversion and
+ // silently turn the NULL of row 2 into 10.
+ explain {
+ sql "select element_at(try_cast(s as struct<a:int,b:int>), 'a') from
try_cast_pruning_tbl"
+ contains "all access paths: [s]"
+ }
+
+ order_qt_direct_slot_try_cast """
+ SELECT id, element_at(TRY_CAST(s AS STRUCT<a:INT,b:INT>), 'a') AS a_i
+ FROM try_cast_pruning_tbl ORDER BY id
+ """
+
+ // ── 2. TRY_CAST over a derived struct access
────────────────────────────────
+ // The whole-value context only pins payload, so the sibling field
wrapper.unused is still
+ // pruned away and the inner element_at is rebuilt on the narrowed wrapper
slot. The cast
+ // must keep both its TRY_CAST identity and its full target type across
that rebuild.
+ explain {
+ sql """
+ select element_at(try_cast(element_at(wrapper, 'payload') as
struct<good:int,bad:int>), 'good')
+ from try_cast_pruning_tbl
+ """
+ contains "all access paths: [wrapper.payload]"
+ }
+
+ order_qt_derived_child_try_cast """
+ SELECT id, element_at(TRY_CAST(element_at(wrapper, 'payload') AS
STRUCT<good:INT,bad:INT>), 'good') AS good_i
+ FROM try_cast_pruning_tbl ORDER BY id
+ """
+
+ // ── 3. Plain CAST over a nested type is still pruned field by field
─────────
+ explain {
+ sql "select element_at(cast(s as struct<a:int,b:int>), 'a') from
try_cast_pruning_tbl"
+ contains "all access paths: [s.a]"
+ }
+
+ // ── 4. Toggling pruning must not change any result
──────────────────────────
+ sql "set enable_prune_nested_column = false"
+ order_qt_direct_slot_try_cast_pruning_off """
+ SELECT id, element_at(TRY_CAST(s AS STRUCT<a:INT,b:INT>), 'a') AS a_i
+ FROM try_cast_pruning_tbl ORDER BY id
+ """
+ order_qt_derived_child_try_cast_pruning_off """
+ SELECT id, element_at(TRY_CAST(element_at(wrapper, 'payload') AS
STRUCT<good:INT,bad:INT>), 'good') AS good_i
+ FROM try_cast_pruning_tbl ORDER BY id
+ """
+ sql "set enable_prune_nested_column = true"
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]