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 de40eaee9e1 branch-4.2: [fix](iceberg) Read ID-less Parquet files by 
positional field ids (#68962) (#69839)
de40eaee9e1 is described below

commit de40eaee9e132f580ae00deb023fdb46d24ae885
Author: daidai <[email protected]>
AuthorDate: Thu Nov 5 16:48:27 2026 +0800

    branch-4.2: [fix](iceberg) Read ID-less Parquet files by positional field 
ids (#68962) (#69839)
    
    pick #68962
---
 be/src/format_v2/column_mapper.cpp                 |  86 +++-
 be/src/format_v2/table/iceberg_reader.cpp          |  33 ++
 be/src/format_v2/table/iceberg_reader.h            |  20 +-
 be/src/format_v2/table_reader.h                    |   7 +-
 be/test/format_v2/column_mapper_test.cpp           | 246 ++++++++++
 be/test/format_v2/table/iceberg_reader_test.cpp    | 495 ++++++++++++++++++++-
 ...ceberg_migrated_nested_without_name_mapping.out |   7 +-
 .../iceberg/test_gen_iceberg_by_api.groovy         |  16 +-
 ...erg_migrated_nested_without_name_mapping.groovy |  18 +-
 9 files changed, 893 insertions(+), 35 deletions(-)

diff --git a/be/src/format_v2/column_mapper.cpp 
b/be/src/format_v2/column_mapper.cpp
index 5839ececd7c..baa84ad5c7c 100644
--- a/be/src/format_v2/column_mapper.cpp
+++ b/be/src/format_v2/column_mapper.cpp
@@ -526,6 +526,10 @@ std::string TableColumnMapper::debug_string() const {
     return out.str();
 }
 
+// Defined with the mapping-shape helpers below. Whole-root shortcuts must use 
the same
+// missing-child contract as the generic slot rewrite.
+static bool mapping_slot_has_unmapped_child(const ColumnMapping& mapping);
+
 static const FileSlotRewriteInfo* find_slot_rewrite_info(
         const VExprSPtr& expr,
         const std::map<GlobalIndex, FileSlotRewriteInfo>& global_to_file_slot,
@@ -535,7 +539,7 @@ static const FileSlotRewriteInfo* find_slot_rewrite_info(
     }
     VExprSPtr slot_expr = expr;
     const bool input_is_cast = is_cast_expr(expr) && expr->get_num_children() 
== 1;
-    if (is_cast_expr(expr) && expr->get_num_children() == 1) {
+    if (input_is_cast) {
         slot_expr = expr->children()[0];
     }
     if (!slot_expr->is_slot_ref()) {
@@ -549,6 +553,13 @@ static const FileSlotRewriteInfo* find_slot_rewrite_info(
     if (input_is_cast && 
!expr->data_type()->equals(*rewrite_it->second.table_type)) {
         return nullptr;
     }
+    DORIS_CHECK(rewrite_it->second.root_mapping != nullptr);
+    if (mapping_slot_has_unmapped_child(*rewrite_it->second.root_mapping)) {
+        // A file slot with missing descendants is not the finalized table 
value. Let the generic
+        // slot rewrite retain the predicate above TableReader, including 
binary/IN and cast
+        // shortcuts that would otherwise return before visiting the slot.
+        return nullptr;
+    }
     if (slot_ref != nullptr) {
         *slot_ref = candidate_slot_ref;
     }
@@ -965,6 +976,22 @@ static const ColumnMapping* 
find_projected_child_mapping(const ColumnMapping& ma
     return child_it == mapping.child_mappings.end() ? nullptr : &*child_it;
 }
 
+static bool nested_result_has_unmapped_child(const ColumnMapping& mapping,
+                                             const LocalColumnIndex& path) {
+    if (path.children.empty()) {
+        return mapping_slot_has_unmapped_child(mapping);
+    }
+    DORIS_CHECK(path.children.size() == 1);
+    const auto& child_path = path.children.front();
+    const auto* child = find_projected_child_mapping(mapping, 
child_path.local_id());
+    if (child == nullptr) {
+        // Schema-only descriptors can resolve a path without child mappings. 
They carry no
+        // missing-child metadata; the existing type/nullability checks still 
apply to that path.
+        return false;
+    }
+    return nested_result_has_unmapped_child(*child, child_path);
+}
+
 static bool projected_mapping_allows_file_filtering(const ColumnMapping& 
mapping,
                                                     const LocalColumnIndex* 
projection) {
     if (!can_filter_before_table_nullability_alignment(mapping.file_type, 
mapping.table_type)) {
@@ -1051,6 +1078,13 @@ static bool rewrite_struct_element_path_to_file_expr(
                                                  
&rewrite_it->second.scan_projection)) {
         return false;
     }
+    if (nested_result_has_unmapped_child(*rewrite_it->second.root_mapping,
+                                         resolved.file_projection)) {
+        // A selected complex value must include its default/NULL-filled 
descendants before any
+        // wrapper consumes it. Missing siblings outside the selected result 
do not affect a mapped
+        // scalar leaf, so check the result subtree rather than the whole root.
+        return false;
+    }
     for (size_t idx = 0; idx < struct_element_chain.size(); ++idx) {
         // Accessor results become nullable for missing ARRAY indices and NULL 
parents. Compare the
         // file child with the declared table child instead, or that 
execution-only wrapper can
@@ -1285,6 +1319,15 @@ static VExprSPtr 
rewrite_struct_or_slot_expr_to_file_expr(
         return expr;
     }
     const auto& rewrite_info = rewrite_it->second;
+    DORIS_CHECK(rewrite_info.root_mapping != nullptr);
+    if (mapping_slot_has_unmapped_child(*rewrite_info.root_mapping)) {
+        // Final materialization substitutes a default/NULL for every child 
the projection left
+        // unmapped, so the file slot carries only a partial value: any 
file-local evaluation under
+        // this slot (whole-root casts included) would use a different value 
than the user sees.
+        // Keep such predicates above TableReader instead of filtering on the 
physical row.
+        *can_localize = false;
+        return expr;
+    }
     auto file_slot = create_file_slot_ref(*slot_ref, rewrite_info, 
rewrite_context);
     if (rewrite_info.file_type->equals(*rewrite_info.table_type)) {
         return file_slot;
@@ -1347,25 +1390,19 @@ static VExprSPtr rewrite_table_expr_to_file_expr(
     // inserted Cast(slot). Keep that rewrite idempotent: rewrite the cast 
child from table slot to
     // the current split's file slot, and drop the cast when the current split 
no longer needs it.
     if (is_cast_expr(expr) && expr->get_num_children() == 1) {
-        const auto& child = expr->children()[0];
-        if (child->is_slot_ref()) {
-            const auto* slot_ref = assert_cast<const VSlotRef*>(child.get());
-            const auto rewrite_it = 
global_to_file_slot.find(slot_ref_global_index(*slot_ref));
-            if (rewrite_it != global_to_file_slot.end() &&
-                expr->data_type()->equals(*rewrite_it->second.table_type)) {
-                auto rewritten_child =
-                        create_file_slot_ref(*slot_ref, rewrite_it->second, 
rewrite_context);
-                if 
(rewrite_it->second.file_type->equals(*rewrite_it->second.table_type)) {
-                    return rewritten_child;
-                }
-                if (needs_complex_file_slot_cast(rewrite_it->second.file_type,
-                                                 
rewrite_it->second.table_type)) {
-                    *can_localize = false;
-                    return expr;
-                }
-                expr->set_children({std::move(rewritten_child)});
+        const VSlotRef* slot_ref = nullptr;
+        const auto* rewrite_info = find_slot_rewrite_info(expr, 
global_to_file_slot, &slot_ref);
+        if (rewrite_info != nullptr) {
+            auto rewritten_child = create_file_slot_ref(*slot_ref, 
*rewrite_info, rewrite_context);
+            if (rewrite_info->file_type->equals(*rewrite_info->table_type)) {
+                return rewritten_child;
+            }
+            if (needs_complex_file_slot_cast(rewrite_info->file_type, 
rewrite_info->table_type)) {
+                *can_localize = false;
                 return expr;
             }
+            expr->set_children({std::move(rewritten_child)});
+            return expr;
         }
     }
 
@@ -1497,6 +1534,16 @@ static bool needs_complex_rematerialize(const 
ColumnMapping& mapping) {
     return false;
 }
 
+// The physical slot is only a partial carrier of the table value when the 
projection left at least
+// one descendant without a file mapping: final materialization substitutes a 
default/NULL for that
+// child, so a predicate rooted at this slot must not be evaluated on the file 
value (DORIS-29685).
+// Scalar type promotion and struct renaming keep their existing localization 
paths.
+static bool mapping_slot_has_unmapped_child(const ColumnMapping& mapping) {
+    return std::ranges::any_of(mapping.child_mappings, [](const ColumnMapping& 
child) {
+        return !child.file_local_id.has_value() || 
mapping_slot_has_unmapped_child(child);
+    });
+}
+
 static bool mapping_can_use_file_column_directly(const ColumnMapping& mapping) 
{
     if (mapping.table_type == nullptr || mapping.file_type == nullptr) {
         return false;
@@ -1509,6 +1556,9 @@ static bool mapping_can_use_file_column_directly(const 
ColumnMapping& mapping) {
     if (!mapping.table_type->equals(*mapping.file_type) && 
!same_timestamptz_with_different_scale) {
         return false;
     }
+    if (mapping_slot_has_unmapped_child(mapping)) {
+        return false;
+    }
     return !needs_complex_rematerialize(mapping);
 }
 
diff --git a/be/src/format_v2/table/iceberg_reader.cpp 
b/be/src/format_v2/table/iceberg_reader.cpp
index 03de2fa308c..2d999089462 100644
--- a/be/src/format_v2/table/iceberg_reader.cpp
+++ b/be/src/format_v2/table/iceberg_reader.cpp
@@ -138,6 +138,37 @@ bool 
IcebergTableReader::_scan_has_any_authoritative_name_mapping() const {
     return false;
 }
 
+Status IcebergTableReader::annotate_file_schema(
+        std::vector<format::ColumnDefinition>* file_schema) {
+    DORIS_CHECK(file_schema != nullptr);
+    if (file_schema->empty() || _format != FileFormat::PARQUET) {
+        return Status::OK();
+    }
+    // Iceberg's hasIds is existential: even a single nested ID prevents 
positional fallback.
+    if (schema_has_any_field_id(*file_schema)) {
+        return Status::OK();
+    }
+    // An explicit name mapping remains authoritative even when it does not 
cover this file.
+    // Legacy FE plans keep their historical name fallback during rolling 
upgrades.
+    if (!supports_iceberg_scan_semantics_v2(_scan_params) ||
+        _scan_has_any_authoritative_name_mapping()) {
+        return Status::OK();
+    }
+    // ParquetSchemaUtil.addFallbackIds numbers physical top-level fields 
only. Reader-generated
+    // row-position/global-row-ID columns do not consume ordinals, and nested 
IDs are not invented.
+    int32_t ordinal = 0;
+    for (auto& field : *file_schema) {
+        if (field.column_type != format::ColumnType::DATA_COLUMN) {
+            continue;
+        }
+        // ID-less Parquet fields have STRING identifiers containing their 
physical names.
+        // Replace those identifiers so the shared BY_FIELD_ID projection and 
predicates agree.
+        field.identifier = Field::create_field<TYPE_INT>(++ordinal);
+    }
+    _positional_id_fallback = true;
+    return Status::OK();
+}
+
 template <typename T>
 static std::string join_values_for_debug(const std::vector<T>& values) {
     std::ostringstream out;
@@ -999,6 +1030,7 @@ Status IcebergTableReader::prepare_split(const 
format::SplitReadOptions& options
         _row_lineage_columns = {};
         _iceberg_params.reset();
         _delete_predicates_initialized = false;
+        _positional_id_fallback = false;
         _position_delete_rows_storage.clear();
         _equality_delete_filters.clear();
         _split_cache = options.cache;
@@ -1091,6 +1123,7 @@ std::string IcebergTableReader::debug_string() const {
         << ", deletion_vector_file_count=" << deletion_vector_file_count
         << ", position_delete_rows_storage_count=" << 
_position_delete_rows_storage.size()
         << ", equality_delete_filter_count=" << _equality_delete_filters.size()
+        << ", positional_id_fallback=" << _positional_id_fallback
         << ", equality_delete_filters=" << equality_filters.str() << "}";
     return out.str();
 }
diff --git a/be/src/format_v2/table/iceberg_reader.h 
b/be/src/format_v2/table/iceberg_reader.h
index 68279112493..3aecd776fc1 100644
--- a/be/src/format_v2/table/iceberg_reader.h
+++ b/be/src/format_v2/table/iceberg_reader.h
@@ -75,8 +75,8 @@ public:
         }
         if (!_data_reader.file_schema.empty() && 
supports_iceberg_scan_semantics_v2(_scan_params) &&
             !_scan_has_any_authoritative_name_mapping()) {
-            // ID-less migrated files are name-readable only while Iceberg's 
explicit default name
-            // mapping exists; current names must not resurrect file fields 
after it is removed.
+            // Parquet annotation supplies positional IDs. Other ID-less files 
remain unreadable
+            // without an explicit default name mapping; current names must 
not resurrect fields.
             return format::TableColumnMappingMode::BY_FIELD_ID;
         }
         return format::TableColumnMappingMode::BY_NAME;
@@ -91,6 +91,20 @@ protected:
     // Iceberg UUID uses the same 16-byte STRING/VARBINARY carrier in data, 
defaults and deletes.
     bool preserve_binary_uuid() const override { return true; }
 
+    // Match Iceberg's ParquetSchemaUtil.addFallbackIds: when a Parquet file 
has neither field IDs
+    // nor an authoritative name mapping, annotate only its top-level fields 
with IDs 1..N.
+    // Nested children keep their original identifiers and remain missing 
without a name mapping.
+    // Legacy FE plans retain name matching, and ORC retains its existing 
behavior.
+    Status annotate_file_schema(std::vector<format::ColumnDefinition>* 
file_schema) override;
+
+    // Changing the name mapping can change a positional binding without 
changing the file or
+    // table-predicate digest. Such splits must neither read nor publish 
cached filter results.
+    bool _should_enable_condition_cache(
+            const format::FileScanRequest& file_request) const override {
+        return !_positional_id_fallback &&
+               
format::TableReader::_should_enable_condition_cache(file_request);
+    }
+
     void configure_mapper_options(format::TableColumnMapperOptions* options) 
const override {
         options->enable_row_lineage_virtual_columns = true;
         options->allow_idless_complex_wrapper_projection =
@@ -203,6 +217,8 @@ private:
     size_t _row_position_block_position = 0;
     std::optional<TIcebergFileDesc> _iceberg_params;
     bool _delete_predicates_initialized = false;
+    // Per-split positional binding state, used by condition-cache eligibility 
and debug_string().
+    bool _positional_id_fallback = false;
     format::DeleteRows _position_delete_rows_storage;
     struct EqualityDeleteFilter {
         std::vector<int> field_ids;
diff --git a/be/src/format_v2/table_reader.h b/be/src/format_v2/table_reader.h
index 1f8a6d9b29a..7ecf39b6f18 100644
--- a/be/src/format_v2/table_reader.h
+++ b/be/src/format_v2/table_reader.h
@@ -578,7 +578,12 @@ protected:
     Status _open_local_filter_exprs(const FileScanRequest& file_request);
     Status _init_reader_condition_cache(const FileScanRequest& file_request);
     void _finalize_reader_condition_cache();
-    bool _should_enable_condition_cache(const FileScanRequest& file_request) 
const;
+    // The condition cache key covers the file identity plus the table-level 
predicate/session
+    // digest only. A table format whose file-column binding can change 
without changing that digest
+    // (Iceberg's positional fallback resolves an ID-less file by physical 
ordinals) must override
+    // this to false: a stale all-false granule would silently drop matching 
rows, because the
+    // cached verdict was produced by a different physical binding.
+    virtual bool _should_enable_condition_cache(const FileScanRequest& 
file_request) const;
 
     Status _evaluate_constant_filters(bool* can_filter_all) {
         DORIS_CHECK(can_filter_all != nullptr);
diff --git a/be/test/format_v2/column_mapper_test.cpp 
b/be/test/format_v2/column_mapper_test.cpp
index 4d80ab209ac..fa3b183b2fb 100644
--- a/be/test/format_v2/column_mapper_test.cpp
+++ b/be/test/format_v2/column_mapper_test.cpp
@@ -40,6 +40,7 @@
 #include "core/data_type/data_type_timestamptz.h"
 #include "core/data_type/data_type_varbinary.h"
 #include "core/data_type/data_type_variant_v2.h"
+#include "exprs/runtime_filter_expr.h"
 #include "exprs/vectorized_fn_call.h"
 #include "exprs/vexpr.h"
 #include "exprs/vexpr_context.h"
@@ -656,6 +657,73 @@ VExprSPtr create_in_predicate() {
     return VInPredicate::create_shared(node);
 }
 
+std::vector<std::pair<std::string, VExprSPtr>> whole_struct_filter_exprs(const 
DataTypePtr& type,
+                                                                         const 
Field& inner_value) {
+    const auto slot = [&] { return table_slot(11, 0, type, "s"); };
+    const auto value = [&] {
+        return literal(type, Field::create_field<TYPE_STRUCT>(Struct 
{inner_value}));
+    };
+    const auto in = [&](const VExprSPtr& probe) {
+        auto predicate = create_in_predicate();
+        predicate->add_child(probe);
+        predicate->add_child(value());
+        return predicate;
+    };
+    return {
+            {"binary", binary_predicate(TExprOpcode::EQ, slot(), value())},
+            {"null_safe_binary", binary_predicate(TExprOpcode::EQ_FOR_NULL, 
slot(), value())},
+            {"reverse_binary", binary_predicate(TExprOpcode::EQ, value(), 
slot())},
+            {"in", in(slot())},
+            {"cast_binary", binary_predicate(TExprOpcode::EQ, 
cast_expr(slot(), type), value())},
+            {"cast_in", in(cast_expr(slot(), type))},
+            {"same_type_cast", like_expr(cast_expr(cast_expr(slot(), type), 
str()),
+                                         inner_value.is_null() ? "%null%" : 
"%0%")},
+    };
+}
+
+VExprSPtr runtime_filter_wrapper_expr(VExprSPtr impl) {
+    TExprNode node;
+    node.__set_node_type(TExprNodeType::SLOT_REF);
+    node.__set_type(u8()->to_thrift());
+    node.__set_num_children(1);
+    return RuntimeFilterExpr::create_shared(node, std::move(impl), 0.0, false, 
/*filter_id=*/1);
+}
+
+void expect_residual_whole_struct_filter(const FileScanRequest& request) {
+    EXPECT_TRUE(request.conjuncts.empty());
+    EXPECT_EQ(request.metadata_pruning_safe_conjunct_count, 0);
+    EXPECT_EQ(request.constant_pruning_safe_table_filter_count, 0);
+    EXPECT_TRUE(request.predicate_columns.empty());
+    ASSERT_EQ(request.non_predicate_columns.size(), 1);
+    EXPECT_EQ(request.non_predicate_columns[0].column_id(), LocalColumnId(7));
+    ASSERT_EQ(request.residual_predicate_columns.size(), 1);
+    EXPECT_EQ(request.residual_predicate_columns[0], LocalColumnId(7));
+}
+
+void expect_localized_whole_struct_filter(const FileScanRequest& request) {
+    EXPECT_EQ(request.conjuncts.size(), 1);
+    EXPECT_TRUE(request.residual_predicate_columns.empty());
+    ASSERT_EQ(request.predicate_columns.size(), 1);
+    EXPECT_EQ(request.predicate_columns[0].column_id(), LocalColumnId(7));
+}
+
+void check_struct_filter(const ColumnDefinition& table_struct, const 
ColumnDefinition& file_struct,
+                         const VExprSPtr& predicate, bool runtime_filter, bool 
should_localize) {
+    TableColumnMapper mapper({.mode = TableColumnMappingMode::BY_FIELD_ID});
+    ASSERT_TRUE(mapper.create_mapping({table_struct}, {}, {file_struct}).ok());
+    auto root = runtime_filter ? runtime_filter_wrapper_expr(predicate) : 
predicate;
+    TableFilter filter {.conjunct = 
VExprContext::create_shared(std::move(root)),
+                        .global_indices = {GlobalIndex(0)}};
+    FileScanRequest request;
+    ASSERT_TRUE(mapper.create_scan_request({filter}, {table_struct}, 
&request).ok());
+
+    if (should_localize) {
+        expect_localized_whole_struct_filter(request);
+    } else {
+        expect_residual_whole_struct_filter(request);
+    }
+}
+
 // ----------------------------------------------------------------------
 // L0 schema projection helper tests.
 // These tests isolate LocalColumnIndex projection semantics before
@@ -2103,6 +2171,184 @@ TEST(ColumnMapperLocalizeFiltersTest, 
VisibleLocalFilterAddsPredicateColumnAndCo
     EXPECT_TRUE(localized_slot->data_type()->equals(*int_type));
 }
 
+// DORIS-29685: an ID-less Parquet file is matched by positional ids, so a 
struct root can be
+// mapped while its children stay unmatched. Final materialization then 
substitutes NULL for those
+// children, which means the physical slot no longer carries the table value: 
a whole-root predicate
+// such as LIKE(CAST(s AS STRING), '%null%') must stay above TableReader 
instead of being evaluated
+// on the raw file slot (which would still see the child's physical value and 
drop the row).
+TEST(ColumnMapperLocalizeFiltersTest, 
PartiallyMappedStructRootPredicateStaysAboveFileReader) {
+    auto table_struct = struct_col("s", 1, {field_id_col("inner", 2, i32())});
+    // Positional fallback stamped the root only; the child keeps its physical 
name identifier.
+    auto file_struct = struct_col("s", 1, {name_col("inner", i32(), 0)}, 0);
+
+    TableColumnMapper mapper({.mode = TableColumnMappingMode::BY_FIELD_ID});
+    ASSERT_TRUE(mapper.create_mapping({table_struct}, {}, {file_struct}).ok());
+
+    ASSERT_EQ(mapper.mappings().size(), 1);
+    ASSERT_TRUE(mapper.mappings()[0].file_local_id.has_value());
+    ASSERT_EQ(mapper.mappings()[0].child_mappings.size(), 1);
+    
EXPECT_FALSE(mapper.mappings()[0].child_mappings[0].file_local_id.has_value());
+    EXPECT_FALSE(mapper.mappings()[0].is_trivial);
+
+    auto cast_to_string = cast_expr(table_slot(11, 0, table_struct.type, "s"), 
str());
+    TableFilter filter {
+            .conjunct = VExprContext::create_shared(like_expr(cast_to_string, 
"%null%")),
+            .global_indices = {GlobalIndex(0)}};
+
+    FileScanRequest request;
+    ASSERT_TRUE(mapper.localize_filters({filter}, &request).ok());
+
+    // No file-local copy of the predicate: the row must be filtered on the 
materialized
+    // {"inner": null} value, not on the physical {"inner": <file value>}.
+    EXPECT_TRUE(request.conjuncts.empty());
+}
+
+// A physical s.inner=5 must not discard a row whose logical s.inner is a 
missing child's
+// default (0) or NULL. Exercise literal shortcuts, split-local same-type 
casts, and RF wrappers.
+TEST(ColumnMapperLocalizeFiltersTest, 
PartiallyMappedWholeStructShortcutsStayAboveFileReader) {
+    const auto child_type = make_nullable(i32());
+    for (const bool has_default : {true, false}) {
+        auto child = field_id_col("inner", 2, child_type);
+        const auto zero = Field::create_field<TYPE_INT>(0);
+        if (has_default) {
+            child.default_expr = 
VExprContext::create_shared(literal(child_type, zero));
+        }
+        const auto table_struct = struct_col("s", 1, {child});
+        const auto file_struct = struct_col("s", 1, {name_col("inner", 
child_type, 0)}, 7);
+        for (const auto& [name, predicate] :
+             whole_struct_filter_exprs(table_struct.type, has_default ? zero : 
Field {})) {
+            for (const bool runtime_filter : {false, true}) {
+                SCOPED_TRACE(::testing::Message() << name << " default=" << 
has_default
+                                                  << " runtime_filter=" << 
runtime_filter);
+                check_struct_filter(table_struct, file_struct, predicate, 
runtime_filter,
+                                    /*should_localize=*/false);
+            }
+        }
+    }
+}
+
+TEST(ColumnMapperLocalizeFiltersTest, 
FullyMappedWholeStructShortcutsRemainLocalized) {
+    const auto child = field_id_col("inner", 2, make_nullable(i32()));
+    const auto table_struct = struct_col("s", 1, {child});
+    const auto file_struct = struct_col("s", 1, {field_id_col("inner", 2, 
child.type, 0)}, 7);
+    for (const auto& [name, predicate] :
+         whole_struct_filter_exprs(table_struct.type, 
Field::create_field<TYPE_INT>(0))) {
+        for (const bool runtime_filter : {false, true}) {
+            SCOPED_TRACE(::testing::Message() << name << " runtime_filter=" << 
runtime_filter);
+            check_struct_filter(table_struct, file_struct, predicate, 
runtime_filter,
+                                /*should_localize=*/true);
+        }
+    }
+}
+
+TEST(ColumnMapperLocalizeFiltersTest, 
MappedStructLeafWithMissingSiblingRemainsLocalized) {
+    auto missing = field_id_col("missing", 3, i32());
+    missing.default_expr =
+            VExprContext::create_shared(literal(i32(), 
Field::create_field<TYPE_INT>(0)));
+    const auto table_struct = struct_col("s", 1, {field_id_col("kept", 2, 
i32()), missing});
+    const auto file_struct = struct_col("s", 1, {field_id_col("kept", 2, 
i32(), 0)}, 7);
+    auto leaf = struct_element(table_slot(11, 0, table_struct.type, "s"), 
i32(), "kept");
+    TableFilter filter {.conjunct = VExprContext::create_shared(int_gt(leaf, 
0)),
+                        .global_indices = {GlobalIndex(0)}};
+    TableColumnMapper mapper({.mode = TableColumnMappingMode::BY_FIELD_ID});
+    ASSERT_TRUE(mapper.create_mapping({table_struct}, {}, {file_struct}).ok());
+    
ASSERT_FALSE(mapper.mappings()[0].child_mappings[1].file_local_id.has_value());
+
+    FileScanRequest request;
+    ASSERT_TRUE(mapper.create_scan_request({filter}, {table_struct}, 
&request).ok());
+
+    EXPECT_EQ(request.conjuncts.size(), 1);
+    EXPECT_TRUE(request.residual_predicate_columns.empty());
+}
+
+TEST(ColumnMapperLocalizeFiltersTest, 
PartiallyMappedNestedStructResultStaysAboveFileReader) {
+    for (const bool has_default : {true, false}) {
+        auto inner = field_id_col("inner", 3, make_nullable(i32()));
+        if (has_default) {
+            inner.default_expr = VExprContext::create_shared(
+                    literal(inner.type, Field::create_field<TYPE_INT>(0)));
+        }
+        const auto nested = struct_col("a", 2, {inner});
+        const auto table_struct = struct_col("s", 1, {nested});
+        const auto file_nested = struct_col("a", 2, {name_col("inner", 
inner.type, 0)}, 0);
+        const auto file_struct = struct_col("s", 1, {file_nested}, 7);
+        for (const bool use_element_at : {false, true}) {
+            for (const bool same_type_cast : {false, true}) {
+                for (const bool runtime_filter : {false, true}) {
+                    SCOPED_TRACE(::testing::Message()
+                                 << "default=" << has_default << " 
element_at=" << use_element_at
+                                 << " same_type_cast=" << same_type_cast
+                                 << " runtime_filter=" << runtime_filter);
+                    auto slot = table_slot(11, 0, table_struct.type, "s");
+                    auto selected = use_element_at ? element_at(slot, 
nested.type, "a")
+                                                   : struct_element(slot, 
nested.type, "a");
+                    if (same_type_cast) {
+                        selected = cast_expr(selected, nested.type);
+                    }
+                    auto predicate =
+                            like_expr(cast_expr(selected, str()), has_default 
? "%0%" : "%null%");
+                    check_struct_filter(table_struct, file_struct, predicate, 
runtime_filter,
+                                        /*should_localize=*/false);
+                }
+            }
+        }
+    }
+}
+
+TEST(ColumnMapperLocalizeFiltersTest, 
FullyMappedNestedStructResultRemainsLocalized) {
+    const auto inner = field_id_col("inner", 3, make_nullable(i32()));
+    const auto nested = struct_col("a", 2, {inner});
+    const auto table_struct = struct_col("s", 1, {nested});
+    const auto file_nested = struct_col("a", 2, {field_id_col("inner", 3, 
inner.type, 0)}, 0);
+    const auto file_struct = struct_col("s", 1, {file_nested}, 7);
+    for (const bool use_element_at : {false, true}) {
+        for (const bool same_type_cast : {false, true}) {
+            for (const bool runtime_filter : {false, true}) {
+                SCOPED_TRACE(::testing::Message() << "element_at=" << 
use_element_at
+                                                  << " same_type_cast=" << 
same_type_cast
+                                                  << " runtime_filter=" << 
runtime_filter);
+                auto slot = table_slot(11, 0, table_struct.type, "s");
+                auto selected = use_element_at ? element_at(slot, nested.type, 
"a")
+                                               : struct_element(slot, 
nested.type, "a");
+                if (same_type_cast) {
+                    selected = cast_expr(selected, nested.type);
+                }
+                auto predicate = like_expr(cast_expr(selected, str()), "%0%");
+                check_struct_filter(table_struct, file_struct, predicate, 
runtime_filter,
+                                    /*should_localize=*/true);
+            }
+        }
+    }
+}
+
+TEST(ColumnMapperLocalizeFiltersTest, 
MappedNestedStructLeafWithMissingSiblingRemainsLocalized) {
+    auto missing = field_id_col("missing", 4, i32());
+    missing.default_expr =
+            VExprContext::create_shared(literal(i32(), 
Field::create_field<TYPE_INT>(0)));
+    const auto nested = struct_col("a", 2, {field_id_col("kept", 3, i32()), 
missing});
+    const auto table_struct = struct_col("s", 1, {nested});
+    const auto file_nested = struct_col("a", 2, {field_id_col("kept", 3, 
i32(), 0)}, 0);
+    const auto file_struct = struct_col("s", 1, {file_nested}, 7);
+    for (const bool runtime_filter : {false, true}) {
+        auto selected = element_at(table_slot(11, 0, table_struct.type, "s"), 
nested.type, "a");
+        auto leaf = element_at(selected, i32(), "kept");
+        check_struct_filter(table_struct, file_struct, int_gt(leaf, 0), 
runtime_filter,
+                            /*should_localize=*/true);
+    }
+}
+
+TEST(ColumnMapperLocalizeFiltersTest, 
SchemaOnlyNestedStructResultRemainsLocalized) {
+    const auto inner = field_id_col("inner", 3, make_nullable(i32()));
+    const auto nested = struct_col("a", 2, {inner});
+    auto table_struct = struct_col("s", 1, {nested});
+    table_struct.children.clear();
+    const auto file_nested = struct_col("a", 2, {field_id_col("inner", 3, 
inner.type, 0)}, 0);
+    const auto file_struct = struct_col("s", 1, {file_nested}, 7);
+    auto selected = element_at(table_slot(11, 0, table_struct.type, "s"), 
nested.type, "a");
+    check_struct_filter(table_struct, file_struct, 
like_expr(cast_expr(selected, str()), "%0%"),
+                        /*runtime_filter=*/false, /*should_localize=*/true);
+}
+
 TEST(ColumnMapperLocalizeFiltersTest, VarbinaryFilterStaysAboveFileReader) {
     const auto binary_type = varbinary();
     const auto table_column = name_col("partition_key", binary_type);
diff --git a/be/test/format_v2/table/iceberg_reader_test.cpp 
b/be/test/format_v2/table/iceberg_reader_test.cpp
index df941721cdf..a2bf5ee031d 100644
--- a/be/test/format_v2/table/iceberg_reader_test.cpp
+++ b/be/test/format_v2/table/iceberg_reader_test.cpp
@@ -248,6 +248,13 @@ private:
 class IcebergTableReaderMappingModeTestHelper final
         : public doris::format::iceberg::IcebergTableReader {
 public:
+    Status annotate_schema(std::vector<ColumnDefinition>* file_schema,
+                           TFileScanRangeParams* scan_params, FileFormat 
format) {
+        _scan_params = scan_params;
+        _format = format;
+        return annotate_file_schema(file_schema);
+    }
+
     TableColumnMappingMode mapping_mode_for_schema(
             std::vector<ColumnDefinition> file_schema, TFileScanRangeParams* 
scan_params = nullptr,
             std::vector<ColumnDefinition> projected_columns = {}) {
@@ -650,6 +657,55 @@ void write_two_int_parquet_file(const std::string& 
file_path, const std::string&
                                                       builder.build()));
 }
 
+// Parquet files written by non-Iceberg writers carry no `field_id` at all. 
Iceberg reads them by
+// file ordinal through ParquetSchemaUtil.addFallbackIds/pruneColumnsFallback 
whenever the table has
+// no `schema.name-mapping.default`; these helpers produce exactly that 
physical layout.
+void write_idless_int_columns_parquet_file(const std::string& file_path,
+                                           const std::vector<std::string>& 
names,
+                                           const 
std::vector<std::vector<int32_t>>& column_values) {
+    ASSERT_EQ(names.size(), column_values.size());
+    std::vector<std::shared_ptr<arrow::Field>> fields;
+    std::vector<std::shared_ptr<arrow::Array>> arrays;
+    for (size_t idx = 0; idx < names.size(); ++idx) {
+        fields.push_back(arrow::field(names[idx], arrow::int32(), false));
+        arrays.push_back(build_int32_array(column_values[idx]));
+    }
+    auto table = arrow::Table::Make(arrow::schema(fields), arrays);
+
+    auto file_result = arrow::io::FileOutputStream::Open(file_path);
+    ASSERT_TRUE(file_result.ok()) << file_result.status();
+    std::shared_ptr<arrow::io::FileOutputStream> out = *file_result;
+    ::parquet::WriterProperties::Builder builder;
+    builder.version(::parquet::ParquetVersion::PARQUET_2_6);
+    builder.data_page_version(::parquet::ParquetDataPageVersion::V2);
+    builder.compression(::parquet::Compression::UNCOMPRESSED);
+    const auto rows = column_values.empty() ? static_cast<int64_t>(0)
+                                            : 
static_cast<int64_t>(column_values[0].size());
+    PARQUET_THROW_NOT_OK(::parquet::arrow::WriteTable(*table, 
arrow::default_memory_pool(), out,
+                                                      rows, builder.build()));
+}
+
+// A fully id-less nested file: `s STRUCT<inner INT>` with no `field_id` at 
any level.
+void write_idless_struct_parquet_file(const std::string& file_path,
+                                      const std::vector<int32_t>& values) {
+    auto inner_field = arrow::field("inner", arrow::int32(), false);
+    auto struct_result = arrow::StructArray::Make({build_int32_array(values)}, 
{inner_field});
+    ASSERT_TRUE(struct_result.ok()) << struct_result.status();
+    auto struct_field = arrow::field("s", arrow::struct_({inner_field}), 
false);
+    auto table = arrow::Table::Make(arrow::schema({struct_field}), 
{*struct_result});
+
+    auto file_result = arrow::io::FileOutputStream::Open(file_path);
+    ASSERT_TRUE(file_result.ok()) << file_result.status();
+    std::shared_ptr<arrow::io::FileOutputStream> out = *file_result;
+    ::parquet::WriterProperties::Builder builder;
+    builder.version(::parquet::ParquetVersion::PARQUET_2_6);
+    builder.data_page_version(::parquet::ParquetDataPageVersion::V2);
+    builder.compression(::parquet::Compression::UNCOMPRESSED);
+    PARQUET_THROW_NOT_OK(::parquet::arrow::WriteTable(*table, 
arrow::default_memory_pool(), out,
+                                                      
static_cast<int64_t>(values.size()),
+                                                      builder.build()));
+}
+
 void write_two_int_orc_file(const std::string& file_path, const std::string& 
first_name,
                             const std::vector<int64_t>& first_values,
                             std::optional<int32_t> first_field_id, const 
std::string& second_name,
@@ -1322,6 +1378,15 @@ void expect_iceberg_rowid_column_values(const IColumn& 
column, const std::string
     }
 }
 
+void expect_nullable_int32_column_is_null(const IColumn& column, size_t rows) {
+    const auto full_column = column.convert_to_full_column_if_const();
+    const auto& nullable_column = assert_cast<const 
ColumnNullable&>(*full_column);
+    ASSERT_EQ(nullable_column.size(), rows);
+    for (size_t row = 0; row < rows; ++row) {
+        EXPECT_EQ(nullable_column.get_null_map_data()[row], 1);
+    }
+}
+
 void expect_int32_column_values(const IColumn& column,
                                 const std::vector<int32_t>& expected_values) {
     const auto full_column = column.convert_to_full_column_if_const();
@@ -3400,7 +3465,8 @@ TEST(IcebergV2ReaderTest, 
IcebergLegacyPlanKeepsAllFieldIdsMappingRule) {
               TableColumnMappingMode::BY_NAME);
 }
 
-TEST(IcebergV2ReaderTest, IcebergV2IdlessFileRequiresAuthoritativeNameMapping) 
{
+// Parquet positional annotation keeps ID-less files on the field-ID path 
without a name mapping.
+TEST(IcebergV2ReaderTest, IcebergV2IdlessFileMappingModeSelection) {
     IcebergTableReaderMappingModeTestHelper reader;
     TFileScanRangeParams scan_params;
     
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
@@ -3419,6 +3485,433 @@ TEST(IcebergV2ReaderTest, 
IcebergV2IdlessFileRequiresAuthoritativeNameMapping) {
               TableColumnMappingMode::BY_NAME);
 }
 
+TEST(IcebergV2ReaderTest, 
IcebergPositionalIdsSkipVirtualColumnsAndNestedFields) {
+    IcebergTableReaderMappingModeTestHelper reader;
+    TFileScanRangeParams scan_params;
+    
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+    auto first = make_file_column(1, "first", 
std::make_shared<DataTypeInt32>());
+    first.identifier = Field::create_field<TYPE_STRING>("first");
+    auto child = first;
+    child.local_id = 3;
+    auto second = first;
+    second.local_id = 2;
+    second.name = "second";
+    second.identifier = Field::create_field<TYPE_STRING>("second");
+    second.type = std::make_shared<DataTypeStruct>(DataTypes {child.type}, 
Strings {child.name});
+    second.children = {child};
+    std::vector<ColumnDefinition> file_schema 
{global_rowid_column_definition(), first,
+                                               
row_position_column_definition(), second};
+
+    ASSERT_TRUE(reader.annotate_schema(&file_schema, &scan_params, 
FileFormat::PARQUET).ok());
+    EXPECT_EQ(file_schema[0].column_type, ColumnType::GLOBAL_ROWID);
+    EXPECT_EQ(file_schema[1].get_identifier_field_id(), 1);
+    EXPECT_EQ(file_schema[1].local_id, 1);
+    EXPECT_EQ(file_schema[2].column_type, ColumnType::ROW_NUMBER);
+    EXPECT_EQ(file_schema[3].get_identifier_field_id(), 2);
+    EXPECT_EQ(file_schema[3].local_id, 2);
+    EXPECT_FALSE(file_schema[3].children[0].has_identifier_field_id());
+}
+
+TEST(IcebergV2ReaderTest, IcebergPositionalIdsExcludeOrcAndPartiallyIdedFiles) 
{
+    IcebergTableReaderMappingModeTestHelper reader;
+    TFileScanRangeParams scan_params;
+    
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+    auto root = make_file_column(1, "root", std::make_shared<DataTypeInt32>());
+    root.identifier = Field::create_field<TYPE_STRING>("root");
+    std::vector<ColumnDefinition> file_schema {root};
+    ASSERT_TRUE(reader.annotate_schema(&file_schema, &scan_params, 
FileFormat::ORC).ok());
+    EXPECT_FALSE(file_schema[0].has_identifier_field_id());
+
+    // A real ID anywhere in the physical tree prevents fallback for the 
entire file.
+    file_schema[0].children = {make_file_column(2, "child", 
std::make_shared<DataTypeInt32>())};
+    file_schema[0].type = std::make_shared<DataTypeStruct>(
+            DataTypes {file_schema[0].children[0].type}, Strings {"child"});
+    ASSERT_TRUE(reader.annotate_schema(&file_schema, &scan_params, 
FileFormat::PARQUET).ok());
+    EXPECT_FALSE(file_schema[0].has_identifier_field_id());
+    EXPECT_EQ(file_schema[0].children[0].get_identifier_field_id(), 2);
+}
+
+// Exposes the condition-cache decision of the current split so a test can 
assert that the
+// positional fallback keeps its splits out of the cache.
+class IcebergConditionCacheProbeReader final : public 
doris::format::iceberg::IcebergTableReader {
+public:
+    void TEST_force_condition_cache_digest(uint64_t digest) {
+        _condition_cache_digest = digest;
+        _condition_cache_digest_covers_current_split = true;
+    }
+    bool TEST_condition_cache_enabled_for_current_split() const {
+        DORIS_CHECK(_file_scan_request != nullptr);
+        return _should_enable_condition_cache(*_file_scan_request);
+    }
+};
+
+// DORIS-29685: the condition cache key covers the file identity plus the 
table-level
+// predicate/session digest only, so it cannot tell two physical bindings of 
the same table column
+// apart. Iceberg's positional fallback can change that binding when
+// `schema.name-mapping.default` is added or removed without touching the data 
file, so those
+// splits must stay out of the condition cache entirely (neither read nor 
written).
+TEST(IcebergV2ReaderTest, PositionalIdFallbackSplitsDisableConditionCache) {
+    const auto run_case = [](bool idless_file, int32_t table_field_id) {
+        const auto test_dir = std::filesystem::temp_directory_path() /
+                              (idless_file ? 
"doris_iceberg_cache_fallback_test"
+                                           : 
"doris_iceberg_cache_field_id_test");
+        std::filesystem::remove_all(test_dir);
+        std::filesystem::create_directories(test_dir);
+        const auto file_path = (test_dir / "split.parquet").string();
+        if (idless_file) {
+            write_idless_int_columns_parquet_file(file_path, {"legacy_id"}, 
{{7}});
+        } else {
+            write_single_int_parquet_file(file_path, "id", {7}, 0);
+        }
+
+        std::vector<ColumnDefinition> projected_columns;
+        projected_columns.push_back(
+                make_table_column(table_field_id, "id", 
std::make_shared<DataTypeInt32>()));
+
+        RuntimeProfile profile("test_profile");
+        RuntimeState state {TQueryOptions(), TQueryGlobals()};
+        auto scan_params = make_local_parquet_scan_params();
+        
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+        io::FileReaderStats file_reader_stats;
+        io::FileCacheStatistics file_cache_stats;
+        auto io_ctx = make_io_context(&file_reader_stats, &file_cache_stats);
+        ShardedKVCache cache(1);
+        IcebergConditionCacheProbeReader reader;
+        ASSERT_TRUE(reader.init({.projected_columns = projected_columns,
+                                 .conjuncts = {prepared_conjunct(
+                                         &state, 
table_int32_greater_than_expr(0, 0, 0))},
+                                 .format = FileFormat::PARQUET,
+                                 .scan_params = &scan_params,
+                                 .io_ctx = io_ctx,
+                                 .runtime_state = &state,
+                                 .scanner_profile = &profile})
+                            .ok());
+
+        auto split_options = build_split_options(file_path);
+        split_options.cache = &cache;
+        split_options.current_range.__set_table_format_params(
+                make_iceberg_table_format_desc(file_path, {}));
+        ASSERT_TRUE(reader.prepare_split(split_options).ok());
+
+        Block block = build_table_block(projected_columns);
+        bool eos = false;
+        ASSERT_TRUE(reader.get_block(&block, &eos).ok());
+        ASSERT_FALSE(eos);
+        // Force a digest so the base decision is not disabled for unrelated 
reasons: without the
+        // override an ID-less split would be cacheable here.
+        reader.TEST_force_condition_cache_digest(0x5f5e100);
+        EXPECT_EQ(reader.TEST_condition_cache_enabled_for_current_split(), 
!idless_file);
+
+        ASSERT_TRUE(reader.close().ok());
+        std::filesystem::remove_all(test_dir);
+    };
+
+    // A file carrying real field ids keeps the table column bound to the same 
physical column, so
+    // the condition cache stays available.
+    run_case(false, 0);
+    // The positional fallback binds by physical ordinal, which the cache key 
cannot represent.
+    run_case(true, 1);
+}
+
+// DORIS-29685: an add_files split (no field ids) cherry-picked back into an 
evolved table read as a
+// whole all-NULL row because the table no longer carried a usable name 
mapping. Iceberg's Parquet
+// reader binds such files by position (`ParquetSchemaUtil.addFallbackIds` + 
`pruneColumnsFallback`,
+// `iceberg.netflix.unsafe-parquet-id-fallback.enabled` defaults to true), so 
Doris must do the same
+// instead of silently materializing NULL. The physical names deliberately 
differ from the table
+// names: the fallback is "file ordinal k == Iceberg field id k", never name 
based.
+TEST(IcebergV2ReaderTest, 
IcebergIdlessParquetWithoutNameMappingUsesPositionalIds) {
+    const auto test_dir =
+            std::filesystem::temp_directory_path() / 
"doris_iceberg_positional_id_fallback_test";
+    std::filesystem::remove_all(test_dir);
+    std::filesystem::create_directories(test_dir);
+    const auto file_path = (test_dir / "split.parquet").string();
+    write_idless_int_columns_parquet_file(file_path, {"legacy_id", 
"legacy_value"},
+                                          {{1, 2, 3}, {10, 20, 30}});
+
+    std::vector<ColumnDefinition> projected_columns;
+    projected_columns.push_back(make_table_column(1, "id", 
std::make_shared<DataTypeInt32>()));
+    projected_columns.push_back(make_table_column(2, "value", 
std::make_shared<DataTypeInt32>()));
+
+    RuntimeProfile profile("test_profile");
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    auto scan_params = make_local_parquet_scan_params();
+    
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+    io::FileReaderStats file_reader_stats;
+    io::FileCacheStatistics file_cache_stats;
+    auto io_ctx = make_io_context(&file_reader_stats, &file_cache_stats);
+    ShardedKVCache cache(1);
+    doris::format::iceberg::IcebergTableReader reader;
+    init_iceberg_reader(&reader, projected_columns, &scan_params, io_ctx, 
&state, &profile);
+
+    auto split_options = build_split_options(file_path);
+    split_options.cache = &cache;
+    split_options.current_range.__set_table_format_params(
+            make_iceberg_table_format_desc(file_path, {}));
+    ASSERT_TRUE(reader.prepare_split(split_options).ok());
+
+    Block block = build_table_block(projected_columns);
+    bool eos = false;
+    ASSERT_TRUE(reader.get_block(&block, &eos).ok());
+    ASSERT_FALSE(eos);
+    EXPECT_NE(reader.debug_string().find("positional_id_fallback=1"), 
std::string::npos);
+    ASSERT_EQ(block.rows(), 3);
+    expect_int32_column_values(*block.get_by_position(0).column, {1, 2, 3});
+    expect_int32_column_values(*block.get_by_position(1).column, {10, 20, 30});
+
+    // A reused reader must clear the positional binding state before the next 
ID-bearing split.
+    block.clear_column_data();
+    ASSERT_TRUE(reader.get_block(&block, &eos).ok());
+    ASSERT_TRUE(eos);
+    const auto next_file_path = (test_dir / "with-ids.parquet").string();
+    write_two_int_parquet_file(next_file_path, "id", {7}, 1, "value", {70}, 2);
+    split_options = build_split_options(next_file_path);
+    split_options.cache = &cache;
+    split_options.current_range.__set_table_format_params(
+            make_iceberg_table_format_desc(next_file_path, {}));
+    ASSERT_TRUE(reader.prepare_split(split_options).ok());
+    block = build_table_block(projected_columns);
+    eos = false;
+    ASSERT_TRUE(reader.get_block(&block, &eos).ok());
+    ASSERT_FALSE(eos);
+    EXPECT_NE(reader.debug_string().find("positional_id_fallback=0"), 
std::string::npos);
+    ASSERT_EQ(block.rows(), 1);
+    expect_int32_column_values(*block.get_by_position(0).column, {7});
+    expect_int32_column_values(*block.get_by_position(1).column, {70});
+
+    ASSERT_TRUE(reader.close().ok());
+    std::filesystem::remove_all(test_dir);
+}
+
+// Iceberg's fallback matches a table field with id k to the k-th physical 
top-level field, so a
+// dropped (or re-added) field does not shift the remaining bindings the way a 
table-position rule
+// would. Table ids 1 and 3 must therefore read file ordinals 1 and 3.
+TEST(IcebergV2ReaderTest, 
IcebergPositionalFallbackMatchesFieldIdNotColumnPosition) {
+    const auto test_dir =
+            std::filesystem::temp_directory_path() / 
"doris_iceberg_positional_id_field_id_test";
+    std::filesystem::remove_all(test_dir);
+    std::filesystem::create_directories(test_dir);
+    const auto file_path = (test_dir / "split.parquet").string();
+    write_idless_int_columns_parquet_file(file_path, {"first", "second", 
"third"},
+                                          {{1, 2}, {10, 20}, {100, 200}});
+
+    std::vector<ColumnDefinition> projected_columns;
+    projected_columns.push_back(make_table_column(1, "first", 
std::make_shared<DataTypeInt32>()));
+    projected_columns.push_back(make_table_column(3, "third", 
std::make_shared<DataTypeInt32>()));
+
+    RuntimeProfile profile("test_profile");
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    auto scan_params = make_local_parquet_scan_params();
+    
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+    io::FileReaderStats file_reader_stats;
+    io::FileCacheStatistics file_cache_stats;
+    auto io_ctx = make_io_context(&file_reader_stats, &file_cache_stats);
+    ShardedKVCache cache(1);
+    doris::format::iceberg::IcebergTableReader reader;
+    init_iceberg_reader(&reader, projected_columns, &scan_params, io_ctx, 
&state, &profile);
+
+    auto split_options = build_split_options(file_path);
+    split_options.cache = &cache;
+    split_options.current_range.__set_table_format_params(
+            make_iceberg_table_format_desc(file_path, {}));
+    ASSERT_TRUE(reader.prepare_split(split_options).ok());
+
+    Block block = build_table_block(projected_columns);
+    bool eos = false;
+    ASSERT_TRUE(reader.get_block(&block, &eos).ok());
+    ASSERT_FALSE(eos);
+    ASSERT_EQ(block.rows(), 2);
+    expect_int32_column_values(*block.get_by_position(0).column, {1, 2});
+    expect_int32_column_values(*block.get_by_position(1).column, {100, 200});
+
+    ASSERT_TRUE(reader.close().ok());
+    std::filesystem::remove_all(test_dir);
+}
+
+// The positional fallback only applies while the table exposes no usable name 
mapping. When an
+// authoritative mapping is present, Iceberg applies it and prunes by id: the 
covered alias binds,
+// and a field whose mapping says "absent from this file" stays NULL even 
though a physical column
+// exists at that ordinal.
+TEST(IcebergV2ReaderTest, 
IcebergIdlessParquetKeepsAuthoritativeNameMappingPath) {
+    const auto test_dir = std::filesystem::temp_directory_path() /
+                          
"doris_iceberg_positional_id_mapping_precedence_test";
+    std::filesystem::remove_all(test_dir);
+    std::filesystem::create_directories(test_dir);
+    const auto file_path = (test_dir / "split.parquet").string();
+    write_idless_int_columns_parquet_file(file_path, {"legacy_id", 
"legacy_value"},
+                                          {{1, 2}, {10, 20}});
+
+    auto mapped_column = make_table_column(1, "id", 
std::make_shared<DataTypeInt32>());
+    mapped_column.has_name_mapping = true;
+    mapped_column.name_mapping = {"legacy_id"};
+    auto unmapped_column = make_table_column(2, "value", 
std::make_shared<DataTypeInt32>());
+    unmapped_column.has_name_mapping = true;
+    std::vector<ColumnDefinition> projected_columns {mapped_column, 
unmapped_column};
+
+    RuntimeProfile profile("test_profile");
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    auto scan_params = make_local_parquet_scan_params();
+    
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+    io::FileReaderStats file_reader_stats;
+    io::FileCacheStatistics file_cache_stats;
+    auto io_ctx = make_io_context(&file_reader_stats, &file_cache_stats);
+    ShardedKVCache cache(1);
+    doris::format::iceberg::IcebergTableReader reader;
+    init_iceberg_reader(&reader, projected_columns, &scan_params, io_ctx, 
&state, &profile);
+
+    auto split_options = build_split_options(file_path);
+    split_options.cache = &cache;
+    split_options.current_range.__set_table_format_params(
+            make_iceberg_table_format_desc(file_path, {}));
+    ASSERT_TRUE(reader.prepare_split(split_options).ok());
+
+    Block block = build_table_block(projected_columns);
+    bool eos = false;
+    ASSERT_TRUE(reader.get_block(&block, &eos).ok());
+    ASSERT_FALSE(eos);
+    EXPECT_NE(reader.debug_string().find("positional_id_fallback=0"), 
std::string::npos);
+    ASSERT_EQ(block.rows(), 2);
+    expect_int32_column_values(*block.get_by_position(0).column, {1, 2});
+    expect_nullable_int32_column_is_null(*block.get_by_position(1).column, 2);
+
+    ASSERT_TRUE(reader.close().ok());
+    std::filesystem::remove_all(test_dir);
+}
+
+// An old-FE plan (scan semantics version 1) keeps the historical name 
fallback for id-less files;
+// the positional fallback must stay opt-in for version 2 so a rolling upgrade 
cannot change it.
+TEST(IcebergV2ReaderTest, IcebergLegacyPlanKeepsNameFallbackForIdlessParquet) {
+    const auto test_dir =
+            std::filesystem::temp_directory_path() / 
"doris_iceberg_positional_id_legacy_fe_test";
+    std::filesystem::remove_all(test_dir);
+    std::filesystem::create_directories(test_dir);
+    const auto file_path = (test_dir / "split.parquet").string();
+    write_idless_int_columns_parquet_file(file_path, {"id", "value"}, {{1, 2}, 
{10, 20}});
+
+    std::vector<ColumnDefinition> projected_columns;
+    projected_columns.push_back(make_table_column(1, "id", 
std::make_shared<DataTypeInt32>()));
+    projected_columns.push_back(make_table_column(2, "value", 
std::make_shared<DataTypeInt32>()));
+
+    RuntimeProfile profile("test_profile");
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    auto scan_params = make_local_parquet_scan_params();
+    io::FileReaderStats file_reader_stats;
+    io::FileCacheStatistics file_cache_stats;
+    auto io_ctx = make_io_context(&file_reader_stats, &file_cache_stats);
+    ShardedKVCache cache(1);
+    doris::format::iceberg::IcebergTableReader reader;
+    init_iceberg_reader(&reader, projected_columns, &scan_params, io_ctx, 
&state, &profile);
+
+    auto split_options = build_split_options(file_path);
+    split_options.cache = &cache;
+    split_options.current_range.__set_table_format_params(
+            make_iceberg_table_format_desc(file_path, {}));
+    ASSERT_TRUE(reader.prepare_split(split_options).ok());
+
+    Block block = build_table_block(projected_columns);
+    bool eos = false;
+    ASSERT_TRUE(reader.get_block(&block, &eos).ok());
+    ASSERT_FALSE(eos);
+    EXPECT_NE(reader.debug_string().find("positional_id_fallback=0"), 
std::string::npos);
+    ASSERT_EQ(block.rows(), 2);
+    expect_int32_column_values(*block.get_by_position(0).column, {1, 2});
+    expect_int32_column_values(*block.get_by_position(1).column, {10, 20});
+
+    ASSERT_TRUE(reader.close().ok());
+    std::filesystem::remove_all(test_dir);
+}
+
+// Iceberg stamps ordinal ids on top-level fields only, so an id-less nested 
file still materializes
+// the struct root by position while its children stay NULL until a name 
mapping (or a rewrite that
+// persists field ids) resolves them. Doris must not invent nested positions 
either.
+TEST(IcebergV2ReaderTest, IcebergIdlessParquetStructChildStaysNull) {
+    const auto test_dir =
+            std::filesystem::temp_directory_path() / 
"doris_iceberg_positional_id_nested_test";
+    std::filesystem::remove_all(test_dir);
+    std::filesystem::create_directories(test_dir);
+    const auto file_path = (test_dir / "split.parquet").string();
+    write_idless_struct_parquet_file(file_path, {7});
+
+    const auto int_type = std::make_shared<DataTypeInt32>();
+    auto inner_type = std::make_shared<DataTypeStruct>(DataTypes {int_type}, 
Strings {"inner"});
+    auto outer = make_table_column(1, "s", inner_type);
+    outer.children = {make_table_column(2, "inner", int_type)};
+    std::vector<ColumnDefinition> projected_columns {outer};
+
+    RuntimeProfile profile("test_profile");
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    auto scan_params = make_local_parquet_scan_params();
+    
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+    io::FileReaderStats file_reader_stats;
+    io::FileCacheStatistics file_cache_stats;
+    auto io_ctx = make_io_context(&file_reader_stats, &file_cache_stats);
+    ShardedKVCache cache(1);
+    doris::format::iceberg::IcebergTableReader reader;
+    init_iceberg_reader(&reader, projected_columns, &scan_params, io_ctx, 
&state, &profile);
+
+    auto split_options = build_split_options(file_path);
+    split_options.cache = &cache;
+    split_options.current_range.__set_table_format_params(
+            make_iceberg_table_format_desc(file_path, {}));
+    ASSERT_TRUE(reader.prepare_split(split_options).ok());
+
+    Block block = build_table_block(projected_columns);
+    bool eos = false;
+    ASSERT_TRUE(reader.get_block(&block, &eos).ok());
+    ASSERT_FALSE(eos);
+    EXPECT_NE(reader.debug_string().find("positional_id_fallback=1"), 
std::string::npos);
+    ASSERT_EQ(block.rows(), 1);
+    const auto& struct_column =
+            assert_cast<const 
ColumnStruct&>(expect_not_null_table_column(block, 0));
+    expect_nullable_int32_column_is_null(struct_column.get_column(0), 1);
+
+    ASSERT_TRUE(reader.close().ok());
+    std::filesystem::remove_all(test_dir);
+}
+
+// Iceberg's hasIds contract is existential: one real field id keeps the file 
on the id path, so
+// neither the name mapping nor the positional fallback may rescue the 
remaining id-less fields.
+TEST(IcebergV2ReaderTest, IcebergPartiallyIdedParquetKeepsIdOnlyMatching) {
+    const auto test_dir =
+            std::filesystem::temp_directory_path() / 
"doris_iceberg_positional_id_partial_ids_test";
+    std::filesystem::remove_all(test_dir);
+    std::filesystem::create_directories(test_dir);
+    const auto file_path = (test_dir / "split.parquet").string();
+    write_two_int_parquet_file(file_path, "id", {1, 2}, 0, "extra", {9, 9}, 
std::nullopt);
+
+    std::vector<ColumnDefinition> projected_columns;
+    projected_columns.push_back(make_table_column(0, "id", 
std::make_shared<DataTypeInt32>()));
+    projected_columns.push_back(make_table_column(1, "extra", 
std::make_shared<DataTypeInt32>()));
+
+    RuntimeProfile profile("test_profile");
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    auto scan_params = make_local_parquet_scan_params();
+    
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+    io::FileReaderStats file_reader_stats;
+    io::FileCacheStatistics file_cache_stats;
+    auto io_ctx = make_io_context(&file_reader_stats, &file_cache_stats);
+    ShardedKVCache cache(1);
+    doris::format::iceberg::IcebergTableReader reader;
+    init_iceberg_reader(&reader, projected_columns, &scan_params, io_ctx, 
&state, &profile);
+
+    auto split_options = build_split_options(file_path);
+    split_options.cache = &cache;
+    split_options.current_range.__set_table_format_params(
+            make_iceberg_table_format_desc(file_path, {}));
+    ASSERT_TRUE(reader.prepare_split(split_options).ok());
+
+    Block block = build_table_block(projected_columns);
+    bool eos = false;
+    ASSERT_TRUE(reader.get_block(&block, &eos).ok());
+    ASSERT_FALSE(eos);
+    EXPECT_NE(reader.debug_string().find("positional_id_fallback=0"), 
std::string::npos);
+    ASSERT_EQ(block.rows(), 2);
+    expect_int32_column_values(*block.get_by_position(0).column, {1, 2});
+    expect_nullable_int32_column_is_null(*block.get_by_position(1).column, 2);
+
+    ASSERT_TRUE(reader.close().ok());
+    std::filesystem::remove_all(test_dir);
+}
+
 TEST(IcebergV2ReaderTest, 
IcebergTableReaderDoesNotPushDownAggregateWithPositionDelete) {
     const auto test_dir =
             std::filesystem::temp_directory_path() / 
"doris_iceberg_aggregate_position_delete_test";
diff --git 
a/regression-test/data/external_table_p0/iceberg/test_iceberg_migrated_nested_without_name_mapping.out
 
b/regression-test/data/external_table_p0/iceberg/test_iceberg_migrated_nested_without_name_mapping.out
index 9aea1c252d0..df25da67760 100644
--- 
a/regression-test/data/external_table_p0/iceberg/test_iceberg_migrated_nested_without_name_mapping.out
+++ 
b/regression-test/data/external_table_p0/iceberg/test_iceberg_migrated_nested_without_name_mapping.out
@@ -1,3 +1,6 @@
 -- This file is automatically generated. You should know what you did if you 
want to edit this
--- !idless_nested_fields_are_missing --
-\N     \N      \N
+-- !idless_top_level_field_uses_positional_id --
+1      \N      \N
+
+-- !idless_top_level_field_filter_uses_positional_id --
+1
diff --git 
a/regression-test/suites/external_table_p0/iceberg/test_gen_iceberg_by_api.groovy
 
b/regression-test/suites/external_table_p0/iceberg/test_gen_iceberg_by_api.groovy
index 00d14b30cae..638b1f5058d 100644
--- 
a/regression-test/suites/external_table_p0/iceberg/test_gen_iceberg_by_api.groovy
+++ 
b/regression-test/suites/external_table_p0/iceberg/test_gen_iceberg_by_api.groovy
@@ -48,12 +48,16 @@ suite("test_gen_iceberg_by_api", 
"p0,external,doris,external_docker,external_doc
     def q01 = {
         qt_q01 """ select * from multi_partition2 order by val """
 
-        test {
-            sql """ select count(*) from table_with_append_file where MAN_ID 
is not null """
-            // This fixture has no field IDs or authoritative name mapping, so 
its required
-            // columns must be treated as missing instead of being matched by 
their current names.
-            exception "Missing required field: MAN_ID"
-        }
+        // This fixture has no field IDs and no authoritative name mapping. 
Iceberg's Parquet reader
+        // binds such a file by physical ordinals 
(ParquetSchemaUtil.addFallbackIds +
+        // pruneColumnsFallback), so a required column is no longer reported 
as missing and the
+        // appended record must stay readable. Exact values of ID-less 
fixtures are pinned by
+        // test_iceberg_migrated_nested_without_name_mapping.
+        sql """ select count(*) from table_with_append_file """
+
+        // The positional binding resolves MAN_ID to one of the file's 
physical columns; the query
+        // must no longer fail with "Missing required field: MAN_ID".
+        sql """ select count(*) from table_with_append_file where MAN_ID is 
not null """
     }
 
     q01()
diff --git 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_migrated_nested_without_name_mapping.groovy
 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_migrated_nested_without_name_mapping.groovy
index 8f1ceec0906..5f5d96e0a7a 100644
--- 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_migrated_nested_without_name_mapping.groovy
+++ 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_migrated_nested_without_name_mapping.groovy
@@ -41,15 +41,23 @@ suite("test_iceberg_migrated_nested_without_name_mapping", 
"p0,external") {
     """
     sql """switch ${catalog_name}"""
     sql """use multi_catalog"""
-    sql """set enable_fallback_to_original_planner=false"""
 
-    // The physical Parquet fields predate migration and have no Iceberg IDs. 
Once the explicit
-    // default name mapping is removed, neither the root nor nested values may 
match by current name.
-    qt_idless_nested_fields_are_missing """
+    // The physical Parquet fields predate migration and carry no Iceberg IDs, 
and this registered
+    // metadata has no `schema.name-mapping.default`. Iceberg's Parquet reader 
binds such a file by
+    // position (ParquetSchemaUtil.addFallbackIds + pruneColumnsFallback, see 
DORIS-29685): the k-th
+    // physical top-level field is field id k, so `id` (field id 1) must read 
its real value instead
+    // of NULL. Fallback ids are stamped on top-level fields only, so the 
nested children stay NULL
+    // until a name mapping is restored.
+    qt_idless_top_level_field_uses_positional_id """
         SELECT id, nested_struct.value_a, nested_struct.value_b
         FROM migrated_nested_without_name_mapping
         ORDER BY id
     """
 
-    sql """drop catalog if exists ${catalog_name}"""
+    // The same positional binding must feed predicate/row-group filtering, 
otherwise the row would
+    // be readable but unfilterable.
+    order_qt_idless_top_level_field_filter_uses_positional_id """
+        SELECT count(*) FROM migrated_nested_without_name_mapping WHERE id = 1
+    """
+
 }


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

Reply via email to