924060929 commented on code in PR #65805:
URL: https://github.com/apache/doris/pull/65805#discussion_r3621131279


##########
fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/AccessPathPlanCollector.java:
##########
@@ -401,7 +403,7 @@ static List<CollectAccessPathResult> 
normalizeDataSkippingOnlyAccessPaths(
         List<CollectAccessPathResult> normalizedAccessPaths = new 
ArrayList<>();
         for (CollectAccessPathResult accessPath : accessPaths) {
             List<String> path = accessPath.getPath();
-            if (isDataSkippingOnlyAccessPath(path) && path.size() > 1) {
+            if (path.size() > 1 && accessPath.getType() == 
ColumnAccessPathType.META) {

Review Comment:
   **Blocking: this regresses nested pruning on external tables.**
   
   I invoked `NestedColumnPruning.pruneDataType` reflectively with the real 
`tbl.s` slot (`struct<city:text, 
data:array<map<int,struct<a:int,b:double>>>>`), feeding it exactly what the 
collector emits for `WHERE element_at(s,'city') IS NULL` — `META:s.NULL` + 
`META:s.city.NULL`, the shape this PR's own `testFilter` asserts:
   
   ```
   [A] OLAP (no normalize)          : prunedType=STRUCT<city:TEXT>
                                      all=[META:s.NULL, META:s.city.NULL]
   [B] External post-PR (normalized): 
prunedType=STRUCT<city:TEXT,data:ARRAY<MAP<INT,STRUCT<a:INT,b:DOUBLE>>>>
                                      all=[DATA:s]
   [C] External pre-PR baseline     : prunedType=STRUCT<city:TEXT>
                                      all=[DATA:s.city]
   ```
   
   B vs C is the regression. The newly added struct-parent `META:[s,NULL]` is 
stripped here to `DATA:[s]`; length 1 then triggers the whole-column collapse 
in `buildColumnAccessPaths` (`accessWholeColumn`), and 
`setAccessByPath([s],0,DATA)` sets `accessAll`, so the pruned type reverts as 
well. Both channels collapse.
   
   Net effect: any Hive/Iceberg/Paimon query containing `element_at(struct,'f') 
IS NULL` reads the full struct — here that drags back the entire 
`data:ARRAY<MAP<INT,STRUCT<a,b>>>` subtree. This is exactly the workload nested 
pruning exists for.
   
   Suggested fix, in this method: after stripping the marker, drop the result 
when it is a **proper prefix** of another path in the same collection — the 
deeper path already covers that read. Keep it when there is no deeper path, 
since `WHERE s IS NULL` alone still needs the full column (external readers 
have no null-map-only mode).
   
   Unrelated nit in the same collapse logic: `buildColumnAccessPaths` 
initialises `accessWholeColumnType = ColumnAccessPathType.META`, which is 
unreachable — META paths always carry a terminal marker, so their length is >= 
2 and the `else` branch always forces DATA. It reads as if a whole-column META 
read were supported.



##########
regression-test/suites/nereids_rules_p0/column_pruning/null_column_pruning.groovy:
##########
@@ -220,14 +220,13 @@ suite("null_column_pruning") {
     order_qt_12 "select struct_col from ncp_tbl where struct_col is null";
 
     // ─── Nested struct field IS NULL 
────────────────────────────────────────────
-    // element_at(struct_col, 'city') IS NULL should produce a null-flag-only
-    // predicate path [struct_col.city.NULL] while the projection reads city 
data.
-    // [struct_col.city.NULL] remains in predicateAccessPaths beside the 
projected city data path.
+    // element_at(struct_col, 'city') IS NULL needs both the parent Struct 
null map and the
+    // selected field null map, while the projection reads city data.
     explain {
         sql "select element_at(struct_col, 'city') from ncp_tbl where 
element_at(struct_col, 'city') is null"
         contains "nested columns"
         contains "struct_col.city"
-        contains "predicate access paths: [struct_col.city.NULL]"
+        contains "predicate access paths: [struct_col.NULL, 
struct_col.city.NULL]"

Review Comment:
   **These assertions cannot pin the thing this PR introduces.**
   
   EXPLAIN drops the access-path type:
   
   ```java
   // PlanNode.java:957
   .map(a -> StringUtils.join(a.getPath(), "."))
   ```
   
   `DATA:["s","NULL"]` and `META:["s","NULL"]` both render as `s.NULL`. Every 
assertion in this file that mentions `.NULL` or `.OFFSET` would still pass if 
FE emitted the wrong type — blind to exactly the DATA/META distinction the PR 
is built on.
   
   Suggest rendering the type (`META:struct_col.NULL`) so these assertions 
actually constrain it. That also makes the plan output diagnosable for the 
`struct<\`null\`:int>` case, where the same string means two different things.



##########
regression-test/suites/nereids_rules_p0/column_pruning/null_column_pruning.groovy:
##########
@@ -183,23 +183,23 @@ suite("null_column_pruning") {
     order_qt_10 "select int_col from ncp_tbl where int_col is null";
 
     // ─── Mixed: struct IS NULL + partial field access 
───────────────────────────
-    // struct_col IS NULL in WHERE + element_at in SELECT needs struct data 
for projection, while
-    // the predicate keeps the parent null map separately.
+    // struct_col IS NULL in WHERE + element_at in SELECT needs only the 
projected field data,
+    // while the predicate keeps the parent null map separately.
     explain {
         sql "select element_at(struct_col, 'city') from ncp_tbl where 
struct_col is null"
         contains "nested columns"
-        contains "all access paths: [struct_col]"
+        contains "all access paths: [struct_col.city, struct_col.NULL]"

Review Comment:
   **The behaviour change here has no data-level coverage.**
   
   Only EXPLAIN assertions changed in this file, and no `.out` file changed 
anywhere in the PR. None of the three tables in this suite has a row where a 
STRUCT column is itself NULL — `ncp_tbl` holds one row, `named_struct('city', 
null, 'zip', 10001)`, which is a non-null struct with a null field. 
`ncp_nested_tbl` and `struct_map_pruning_lazy_read` are the same.
   
   So the new two-layer null rule (`element_at(s,'f') IS NULL` always emitting 
`[s,NULL]`) is asserted as a plan shape and never as a result. If the previous 
behaviour returned wrong rows for a NULL struct, no case here would have failed 
before, and none passes differently now.
   
   More importantly, the wrong result this PR actually fixes has no case at 
all. Please add, with data and result comparison:
   
   1. `STRUCT<\`null\`:INT>` + plain projection (the repro in the summary).
   2. An `offset` variant — `STRUCT<\`offset\`:INT>` takes the `OFFSET_ONLY` 
branch rather than the NULL branch, so the symptom may be worse than returning 
NULL.
   3. `v['NULL']` on a VARIANT column. The variant path never lowercases 
(`AccessPathExpressionCollector:356` vs `:397`), so it collides regardless of 
case — pre-PR it was misclassified as metadata by the last-component string 
rule. This is a real bug this PR fixes, and it is currently untested.
   4. A row where the struct column itself is NULL, to give the two-layer rule 
actual coverage.



##########
fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/AccessPathExpressionCollector.java:
##########
@@ -334,6 +362,29 @@ public Void visitElementAt(ElementAt elementAt, 
CollectorContext context) {
             Expression fieldName = arguments.get(1);
             DataType fieldType = fieldName.getDataType();
             if (fieldName.isLiteral() && (fieldType.isIntegerLikeType() || 
fieldType.isStringLikeType())) {
+                // element_at(s, 'field') IS NULL has two-layer null semantics:

Review Comment:
   The two-layer rule is applied only in the `IS NULL` branch, which makes it 
internally inconsistent.
   
   If a struct's own null map is *not* implied by a child path, then these have 
identical exposure and get no `[s,NULL]`:
   
   - `length(element_at(s,'f'))` — META OFFSET context, falls through without 
emitting the parent path
   - plain `element_at(s,'f')` — pruned to `struct<f>`, no parent path either
   
   If it *is* implied — which the long-standing correctness of plain projection 
suggests — then `[s,NULL]` is redundant, and it is what triggers the 
external-table collapse flagged on `AccessPathPlanCollector.java`.
   
   Both cannot be true. Whichever holds, please state the BE invariant in a 
comment here: whether reading any descendant of a nullable struct always 
materialises the parent null map. That single sentence decides whether this 
block is required or removable.



##########
fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/AccessPathExpressionCollector.java:
##########
@@ -365,6 +416,7 @@ public Void visitMapKeys(MapKeys mapKeys, CollectorContext 
context) {
                     = new CollectorContext(context.statementContext, 
context.bottomFilter);
             
removeStarContext.accessPathBuilder.accessPath.addAll(suffixPath.subList(1, 
suffixPath.size()));
             
removeStarContext.accessPathBuilder.addPrefix(AccessPathInfo.ACCESS_MAP_KEYS);
+            removeStarContext.setType(context.type);

Review Comment:
   **Please confirm this shape.** Measured on this head:
   
   ```
   SQL: select 1 from str_tbl where map_keys(map_col)[1] is null
     all =[META:map_col.KEYS.NULL]   pred=[META:map_col.KEYS.NULL]
   ```
   
   Two concerns:
   
   1. Semantics — `map_keys(m)[1] IS NULL` asks whether the map has a first 
entry (an out-of-range subscript yields NULL). The KEYS null map cannot answer 
that; offsets are what encode it.
   2. Safety — map key sub-columns are not nullable, and the scalar 
`NULL_MAP_ONLY` path asserts `DORIS_CHECK(is_column_nullable(*dst))` 
(`column_reader.cpp:2708`), which throws in Release and cores in Debug.
   
   The `isUnderIsNull` early return above only covers `map_keys(m) IS NULL`; 
the subscripted form arrives with a two-element suffix and falls into this 
`ACCESS_ALL` branch instead. This is most likely pre-existing rather than 
introduced here, but the PR is touching this exact routing, so it is worth 
settling.



##########
fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/NestedColumnPruning.java:
##########
@@ -624,17 +602,17 @@ public boolean 
replacePathByAnotherTree(DataTypeAccessTree cast, List<String> pa
                         String originFieldName = ((StructType) 
type).getFields().get(i).getName();
                         path.set(index, originFieldName);
                         return 
children.get(originFieldName).replacePathByAnotherTree(
-                                cast.children.get(castFieldName), path, index 
+ 1
+                                cast.children.get(castFieldName), path, index 
+ 1, pathType
                         );
                     }
                 }
             } else if (cast.type instanceof ArrayType) {
                 return 
children.values().iterator().next().replacePathByAnotherTree(
-                        cast.children.values().iterator().next(), path, index 
+ 1);
+                        cast.children.values().iterator().next(), path, index 
+ 1, pathType);
             } else if (cast.type instanceof MapType) {
                 String fieldName = path.get(index);
                 return 
children.get(AccessPathInfo.ACCESS_MAP_VALUES).replacePathByAnotherTree(
-                        cast.children.get(fieldName), path, index + 1
+                        cast.children.get(fieldName), path, index + 1, pathType

Review Comment:
   Pre-existing, but this change makes it load-bearing.
   
   The Map branch hardcodes `children.get(ACCESS_MAP_VALUES)` on the origin 
side while resolving the cast side with `fieldName`, which can be `KEYS`. For a 
path like `[m, KEYS, OFFSET]` the recursion therefore pairs the origin 
**value** subtree against the cast **key** subtree.
   
   The new terminal check then returns `type.equals(cast.type)` on that 
mismatched pair. It fails safe — an unequal result just falls back to reading 
the original data — but the comparison it performs is not the one intended, and 
for `map<string,string>` it returns true by coincidence rather than by 
correctness. Worth fixing while this code is open.



##########
fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/AccessPathExpressionCollector.java:
##########
@@ -164,17 +182,23 @@ public Void visitSlotReference(SlotReference 
slotReference, CollectorContext con
         // For any other nullable column type (e.g. INT, BIGINT) accessed via 
IS NULL / IS NOT NULL:
         // record the [col_name, NULL] path so NestedColumnPruning can emit 
null-only access paths.
         // Skip NestedColumnPrunable types (already handled above) and string 
types (handled above).
+        // Check getOriginalColumn() rather than slotReference.nullable(): the 
latter may be
+        // inflated by outer join, while the former reflects the physical 
column's null map.
+        // Only NULL paths need the null-map check; OFFSET paths don't depend 
on nullability.
         if (!(dataType instanceof NestedColumnPrunable) && 
!dataType.isStringLikeType()
-                && !context.accessPathBuilder.isEmpty() && 
slotReference.nullable()) {
+                && isUnderIsNull(context.accessPathBuilder.accessPath)

Review Comment:
   `isUnderIsNull` still decides purely by raw string:
   
   ```java
   private static boolean isUnderIsNull(List<String> suffixPath) {
       return suffixPath.size() == 1 && 
AccessPathInfo.ACCESS_NULL.equals(suffixPath.get(0));
   }
   ```
   
   6 of its 10 call sites carry no `context.type == META` guard (`:189`, 
`:409`, `:429`, `:471`, `:492`, `:511`, `:730`); only `:148`, `:165` and `:370` 
do. It is currently safe only because a DATA context cannot present a 
single-element suffix spelled `"NULL"` — which follows from `StructField` 
lowercasing every field name, not from anything local to this file.
   
   Now that the type is carried per path, worth closing here too. And since the 
whole design rests on it, the "struct field names are always lowercase, markers 
are always uppercase" invariant deserves an explicit comment — `StructField`'s 
constructor is the only thing enforcing it, three modules away.



##########
fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PruneNestedColumnTest.java:
##########
@@ -1041,6 +1136,51 @@ public void testDataTypeAccessTree() {
         );
     }
 
+    @Test
+    public void testDataPathNamedLikeMetadataComponent() {

Review Comment:
   This test does not exercise the defect, in two ways.
   
   `new StructField("NULL", ...)` is lowercased to `null` by the constructor — 
the test's own `assertEquals("STRUCT<null:TEXT>", ...)` below confirms it. A 
struct field literally named `NULL` cannot be constructed.
   
   More importantly, it drives `setAccessByPath` directly, which is the FE side 
— and FE was never wrong here. FE compares case-sensitively, so 
`"null".equals("NULL")` never matched. The defect was BE's `StringCaseEqual` 
consuming `["s","null"]` as a sentinel. **This test passes both before and 
after the fix.**
   
   What is missing is an end-to-end case: create the table, insert a row, 
select the field, compare results with pruning on and off. Suggested in more 
detail on `null_column_pruning.groovy`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to