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 fc3a7ab7c75 [fix](meta path) Keep whole-value TRY_CAST semantics under
nested column pruning (#67718)
fc3a7ab7c75 is described below
commit fc3a7ab7c7561bab88513d4e42a1e0f7f26731c4
Author: minghong <[email protected]>
AuthorDate: Wed Sep 23 11:13:59 2026 +0800
[fix](meta path) Keep whole-value TRY_CAST semantics under nested column
pruning (#67718)
### What problem does this PR solve?
Issue Number: N/A
Problem Summary:
Nested column pruning rewrites a struct-field access over `TRY_CAST`
into a plain `CAST` and narrows both the read and the conversion to the
accessed field, dropping the whole-value semantics of TRY_CAST.
Reproduction (single FE/BE, `enable_strict_cast=true`):
```sql
CREATE TABLE t (id INT, s STRUCT<a:STRING,b:STRING> NULL)
DISTRIBUTED BY HASH(id) BUCKETS 1
PROPERTIES ("replication_num"="1");
INSERT INTO t VALUES
(1,named_struct('a','bad','b','2')),
(2,named_struct('a','10','b','bad')),(3,NULL);
-- with nested column pruning enabled (default)
SELECT id, TRY_CAST(s AS STRUCT<a:INT,b:INT>)['a'] AS a_i FROM t;
```
- id=2: `b='bad'` makes the whole-struct TRY_CAST fail and should yield
NULL, but pruning drops field `b`, the conversion "succeeds" on
`a='10'`, and the query returns 10.
- id=1: the rebuilt plain CAST (instead of TRY_CAST) throws
`[INVALID_ARGUMENT] parse number fail, string: 'bad'` under
`enable_strict_cast=true` instead of returning NULL.
- Root cause: `AccessPathExpressionCollector.visitCast` handles
`TryCast` (a `Cast` subclass) like a plain cast and translates the
narrowed nested access path through it, so
`SlotTypeReplacer.rewriteCast` later rebuilds the expression as `new
Cast(...)` with a pruned target type.
Fix: do not translate a narrowed nested access path through a
`TRY_CAST`; the collector then falls back to reading the whole child
value, so the struct keeps all fields and the expression keeps its
TRY_CAST identity and full target type. Field-by-field pruning of plain
`CAST` over nested types is unchanged.
### Release note
None
### Check List (For Author)
- Test:
- [x] Unit Test:
`PruneNestedColumnTest#testTryCastNotPrunedThroughFieldAccess` (whole
class: 62 tests pass)
- [x] Manual test (detailed steps): reproduced the SQL above end-to-end
on a local FE+BE cluster built from this change. With
`enable_prune_nested_column` on and off all three rows return NULL (no
10, no exception under `enable_strict_cast=true`); `EXPLAIN VERBOSE`
keeps `element_at(TRY_CAST(s AS struct<a:int,b:int>), 'a')` with `all
access paths: [s]`, while plain `CAST` over the same struct is still
pruned to `struct<a:int>` with `[s.a]`.
- Behavior changed:
- [x] Yes: enabling/disabling nested column pruning no longer changes
TRY_CAST results or error semantics.
- Does this need documentation?
- [x] No.
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label
---
.../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 cfd22ee5eea..b3440c5a8bd 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
@@ -31,6 +31,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;
@@ -344,6 +345,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 ca8a3b5ba4c..102a87dfe37 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
@@ -624,7 +624,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<ColumnAccessPath> replaceAccessPathToFieldId(
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 17d59219a34..b83ad8af87f 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
@@ -29,6 +29,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;
@@ -156,6 +157,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"
@@ -582,6 +594,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
@@ -1512,6 +1556,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<ColumnAccessPath> expectContainAllAccessPaths,
List<ColumnAccessPath> 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]