This is an automated email from the ASF dual-hosted git repository.
Gabriel39 pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.1 by this push:
new 55f38f22789 branch-4.1: [fix](iceberg) Fix V2 reads across nested
schema evolution #67574 (#67690)
55f38f22789 is described below
commit 55f38f227896d429459f443806e79245babf8556
Author: Gabriel <[email protected]>
AuthorDate: Thu Sep 10 11:27:26 2026 +0800
branch-4.1: [fix](iceberg) Fix V2 reads across nested schema evolution
#67574 (#67690)
### What problem does this PR solve?
This PR backports #67574 to `branch-4.1`.
Two Iceberg V2 failures are fixed:
1. A query that projected two nested siblings returned `[10, 11]`
instead of `[11, 12]` after a predicate on a missing nested child was
rejected.
2. A migrated ID-less nested Parquet file returned stale physical values
where the Iceberg schema required `[NULL, ...]` after
`schema.name-mapping.default` was removed.
The first failure was caused by a stale nested file projection.
Predicate demotion widened the physical struct projection, but the table
mapping still used child ordinals from the earlier narrow projection.
The second failure occurred because an ID-less file could fall back to
current field names even when no authoritative Iceberg name mapping
remained.
### Behavior changes
Before this change:
- A rejected nested predicate could shift projected sibling values by
physical ordinal.
- V2 could match ID-less migrated fields by current names after the
explicit name mapping was removed, exposing stale data.
- Fuzzy session initialization could randomly select the legacy file
scanner.
After this change:
- Table mappings are reconciled with the final physical projection after
predicate demotion and table-format customization.
- V2 reads ID-less files by name only when an authoritative Iceberg
mapping exists, including mappings available only through schema
history.
- A present but malformed name-mapping property falls back to a mapping
generated from the current schema; an absent property remains
distinguishable and does not authorize current-name matching.
- Fuzzy sessions consistently initialize with File Scanner V2. Dedicated
compatibility tests can still explicitly select the legacy scanner
afterward.
The behavior change is limited to file-scanner selection in fuzzy tests
and Iceberg V2 schema-evolution reads. Queries that previously exposed
stale physical values can now return NULL, or a missing-required-field
error for required columns, which follows Iceberg field identity
semantics.
### Implementation
- Reapply finalized scan projections to column mappings and rebuild
localized filter entries.
- Compact file-block positions and remap localized slot references after
predicate demotion.
- Reconcile mappings after Iceberg adds hidden equality-delete
dependencies.
- Require authoritative name mapping for V2 ID-less files and inspect
complete schema history for metadata-only scans.
- Preserve current-schema fallback for malformed mapping properties in
the branch-4.1 `fe-core` Iceberg utility.
### Feature, refactoring, and optimization scope
- No new user-facing feature is added.
- Refactoring is limited to introducing the projection-reconciliation
step needed to preserve the scanner layout invariant.
- No performance optimization is intended; the additional work occurs
during scan-request construction rather than per-row processing.
### branch-4.1 adaptations
- Port the dense file-block position remapping dependency that already
exists in the original PR baseline.
- Apply the FE mapping fallback in the branch-4.1 `fe-core` Iceberg
utility because the newer connector module is not present on this
branch.
- Validate metadata-only mapping through schema history directly because
branch-4.1 does not expose synthesized table columns.
### Test
- ASAN BE unit-test build passed.
- `ColumnMapperTest.*` and `IcebergV2ReaderTest.*`: 102 tests passed.
-
`IcebergUtilsTest#testMalformedNameMappingFallsBackToCurrentSchemaNames`
passed.
- `SessionVariablesTest#testFileScannerV2StaysEnabledInFuzzyMode`
passed.
- FE Checkstyle passed.
- clang-format 16 passed for every affected C/C++ file.
- External regression coverage is included for nested sibling projection
alignment, migrated ID-less nested NULL semantics, and missing required
fields.
---
be/src/format_v2/column_mapper.cpp | 107 +++++++++
be/src/format_v2/column_mapper.h | 4 +
be/src/format_v2/table/iceberg_reader.cpp | 71 ++++++
be/src/format_v2/table/iceberg_reader.h | 8 +
be/src/format_v2/table/iceberg_schema_utils.h | 12 +
be/src/format_v2/table_reader.cpp | 2 +
be/src/format_v2/table_reader.h | 4 +-
be/test/format_v2/column_mapper_test.cpp | 38 ++++
be/test/format_v2/table/iceberg_reader_test.cpp | 251 ++++++++++++++++++++-
.../iceberg_load/run01.sql | 10 +-
.../data/data.parquet | Bin 0 -> 1081 bytes
...aafe0-9de1-4b3f-942d-305dc33fb82f.metadata.json | 1 +
...09fba-61c6-4e4b-b0f5-6023f70d5033.metadata.json | 1 +
...3fffe-54f9-486a-b15e-7333fdb96f61.metadata.json | 1 +
...3e871-e421-48ed-a735-354f4e1c9502.metadata.json | 1 +
...544-1-94f693c5-6efb-4e9d-a40e-f02ac301fc14.avro | Bin 0 -> 4667 bytes
...ifest-d04009d1-83e2-4d2a-859e-79d57e8dd381.avro | Bin 0 -> 7501 bytes
.../doris/datasource/iceberg/IcebergUtils.java | 8 +-
.../java/org/apache/doris/qe/SessionVariable.java | 3 +
.../doris/datasource/iceberg/IcebergUtilsTest.java | 17 ++
.../org/apache/doris/qe/SessionVariablesTest.java | 1 +
.../iceberg/test_gen_iceberg_by_api.out | 3 -
...ceberg_migrated_nested_without_name_mapping.out | 3 +
.../test_iceberg_struct_schema_evolution.out | 3 +
.../iceberg/test_gen_iceberg_by_api.groovy | 9 +-
...erg_migrated_nested_without_name_mapping.groovy | 55 +++++
.../test_iceberg_struct_schema_evolution.groovy | 9 +
27 files changed, 607 insertions(+), 15 deletions(-)
diff --git a/be/src/format_v2/column_mapper.cpp
b/be/src/format_v2/column_mapper.cpp
index 57132df997f..f123917f4b2 100644
--- a/be/src/format_v2/column_mapper.cpp
+++ b/be/src/format_v2/column_mapper.cpp
@@ -20,6 +20,7 @@
#include <algorithm>
#include <cctype>
#include <cstddef>
+#include <map>
#include <memory>
#include <optional>
#include <set>
@@ -47,6 +48,7 @@
#include "exprs/vexpr_context.h"
#include "exprs/vin_predicate.h"
#include "exprs/vliteral.h"
+#include "exprs/vslot_ref.h"
#include "format_v2/column_mapper_nested.h"
#include "format_v2/expr/cast.h"
#include "format_v2/file_reader.h"
@@ -243,6 +245,56 @@ std::string field_debug_string(const Field& field) {
return out.str();
}
+void remap_localized_slot_positions(const VExprSPtr& expr,
+ const std::map<size_t, size_t>&
position_remap,
+ std::set<const VExpr*>* visited) {
+ if (expr == nullptr || !visited->insert(expr.get()).second) {
+ return;
+ }
+ if (auto* slot = dynamic_cast<VSlotRef*>(expr.get());
+ slot != nullptr && slot->column_id() >= 0) {
+ const auto remap =
position_remap.find(static_cast<size_t>(slot->column_id()));
+ DORIS_CHECK(remap != position_remap.end()) << slot->column_id();
+ slot->set_column_id(static_cast<int>(remap->second));
+ }
+ for (const auto& child : expr->children()) {
+ remap_localized_slot_positions(child, position_remap, visited);
+ }
+ remap_localized_slot_positions(expr->get_impl(), position_remap, visited);
+}
+
+void compact_file_block_positions(FileScanRequest* request) {
+ std::set<size_t> occupied_positions;
+ for (const auto& [_, position] : request->local_positions) {
+ occupied_positions.insert(position.value());
+ }
+ for (const auto& [_, position] : request->non_predicate_positions) {
+ occupied_positions.insert(position.value());
+ }
+
+ std::map<size_t, size_t> position_remap;
+ size_t dense_position = 0;
+ for (size_t old_position : occupied_positions) {
+ position_remap.emplace(old_position, dense_position++);
+ }
+ for (auto& [_, position] : request->local_positions) {
+ position = LocalIndex(position_remap.at(position.value()));
+ }
+ for (auto& [_, position] : request->non_predicate_positions) {
+ position = LocalIndex(position_remap.at(position.value()));
+ }
+
+ // Slot refs were localized before predicate demotion removed duplicate
file-block positions;
+ // remap them with the compacted layout so residual predicates keep
reading the same columns.
+ std::set<const VExpr*> visited;
+ for (const auto& conjunct : request->conjuncts) {
+ remap_localized_slot_positions(conjunct->root(), position_remap,
&visited);
+ }
+ for (const auto& conjunct : request->delete_conjuncts) {
+ remap_localized_slot_positions(conjunct->root(), position_remap,
&visited);
+ }
+}
+
template <typename T, typename Formatter>
std::string join_debug_strings(const std::vector<T>& values, Formatter
formatter) {
std::ostringstream out;
@@ -2023,6 +2075,20 @@ static const LocalColumnIndex* find_scan_projection(
return projection_it == scan_columns.end() ? nullptr : &*projection_it;
}
+static bool same_projected_file_shape(const std::vector<ColumnDefinition>& lhs,
+ const std::vector<ColumnDefinition>&
rhs) {
+ if (lhs.size() != rhs.size()) {
+ return false;
+ }
+ for (size_t index = 0; index < lhs.size(); ++index) {
+ if (lhs[index].local_id != rhs[index].local_id ||
+ !same_projected_file_shape(lhs[index].children,
rhs[index].children)) {
+ return false;
+ }
+ }
+ return true;
+}
+
// Apply the final scan projection of one root file column back to its
ColumnMapping. This updates
// mapping.file_type/projected_file_children from the original file schema to
the exact shape that
// FileReader will return.
@@ -2428,6 +2494,36 @@ Status TableColumnMapper::create_scan_request(
return Status::OK();
}
+Status TableColumnMapper::reconcile_scan_request_after_customization(
+ FileScanRequest* file_request) {
+ DORIS_CHECK(file_request != nullptr);
+ bool output_shape_changed = false;
+ for (auto& mapping : _mappings) {
+ if (!mapping.file_local_id.has_value() ||
+
!file_request->local_positions.contains(LocalColumnId(*mapping.file_local_id)))
{
+ continue;
+ }
+ const auto previous_file_type = mapping.file_type;
+ const auto previous_file_children = mapping.projected_file_children;
+
RETURN_IF_ERROR(apply_scan_projection_to_mapping_file_type(*file_request,
&mapping));
+ output_shape_changed |=
+ previous_file_type == nullptr || mapping.file_type == nullptr
||
+ !previous_file_type->equals(*mapping.file_type) ||
+ !same_projected_file_shape(previous_file_children,
mapping.projected_file_children);
+ rebuild_projection(&mapping, file_request->non_predicate_position(
+
LocalColumnId(*mapping.file_local_id)));
+ }
+ if (output_shape_changed) {
+ // Localized conjuncts embed nested child ordinals from the pre-hook
projection. Scanner
+ // still evaluates the original table conjuncts, so discard stale
file-local copies rather
+ // than allowing a late equality-delete dependency to reinterpret
another child.
+ file_request->conjuncts.clear();
+ file_request->metadata_pruning_safe_conjunct_count = 0;
+ }
+ RETURN_IF_ERROR(_build_filter_entries(*file_request));
+ return Status::OK();
+}
+
ColumnMapping* TableColumnMapper::_find_mapping(GlobalIndex global_index) {
for (auto& mapping : _mappings) {
if (mapping.global_index == global_index) {
@@ -2682,6 +2778,17 @@ Status TableColumnMapper::localize_filters(const
std::vector<TableFilter>& table
FileScanRequestBuilder builder(file_request);
RETURN_IF_ERROR(builder.add_non_predicate_column(std::move(demoted_projection)));
}
+ // Predicate demotion can widen a nested projection after mappings were
localized. Reapply the
+ // final shape so TableReader interprets the same child ordinals that
FileReader returns.
+ for (auto& mapping : _mappings) {
+ if (mapping.file_local_id.has_value() &&
+
file_request->local_positions.contains(LocalColumnId(*mapping.file_local_id))) {
+
RETURN_IF_ERROR(apply_scan_projection_to_mapping_file_type(*file_request,
&mapping));
+ }
+ }
+ // Final readers allocate a dense file block, so every retained slot must
follow the same compaction.
+ compact_file_block_positions(file_request);
+ RETURN_IF_ERROR(_build_filter_entries(*file_request));
return Status::OK();
}
diff --git a/be/src/format_v2/column_mapper.h b/be/src/format_v2/column_mapper.h
index ccfbd090407..cd2959c8700 100644
--- a/be/src/format_v2/column_mapper.h
+++ b/be/src/format_v2/column_mapper.h
@@ -206,6 +206,10 @@ public:
RuntimeState* runtime_state = nullptr,
const std::map<LocalColumnId, LocalIndex>* fixed_local_positions =
nullptr);
+ // Table-format hooks may append hidden physical dependencies after the
initial request is
+ // localized. Reconcile output mappings with that final layout before
opening expressions.
+ Status reconcile_scan_request_after_customization(FileScanRequest*
file_request);
+
// Localize table-level filters to the file schema.
// Trivial mappings can copy structured predicates directly. Type changes
may be localized with
// a safe cast. Expressions that cannot be pushed down safely should be
handled by the
diff --git a/be/src/format_v2/table/iceberg_reader.cpp
b/be/src/format_v2/table/iceberg_reader.cpp
index 078b413a059..c18cec8108f 100644
--- a/be/src/format_v2/table/iceberg_reader.cpp
+++ b/be/src/format_v2/table/iceberg_reader.cpp
@@ -55,6 +55,7 @@
#include "format_v2/orc/orc_reader.h"
#include "format_v2/parquet/parquet_reader.h"
#include "format_v2/parquet/reader/column_reader.h"
+#include "format_v2/table/schema_history_util.h"
#include "format_v2/table_reader.h"
#include "io/file_factory.h"
#include "util/debug_points.h"
@@ -66,6 +67,76 @@ namespace doris::format::iceberg {
static constexpr const char* ROW_LINEAGE_ROW_ID = "_row_id";
static constexpr int32_t ROW_LINEAGE_ROW_ID_FIELD_ID = 2147483540;
+namespace {
+
+const schema::external::TField* get_external_field_ptr(
+ const schema::external::TFieldPtr& field_ptr) {
+ if (!field_ptr.__isset.field_ptr || field_ptr.field_ptr == nullptr) {
+ return nullptr;
+ }
+ return field_ptr.field_ptr.get();
+}
+
+bool external_field_has_authoritative_name_mapping(const
schema::external::TField& field) {
+ if (field.__isset.name_mapping_is_authoritative &&
field.name_mapping_is_authoritative) {
+ return true;
+ }
+ if (!field.__isset.nestedField) {
+ return false;
+ }
+ if (field.nestedField.__isset.struct_field &&
field.nestedField.struct_field.__isset.fields) {
+ return std::ranges::any_of(field.nestedField.struct_field.fields,
[](const auto& child) {
+ const auto* child_field = get_external_field_ptr(child);
+ return child_field != nullptr &&
+ external_field_has_authoritative_name_mapping(*child_field);
+ });
+ }
+ if (field.nestedField.__isset.array_field &&
field.nestedField.array_field.__isset.item_field) {
+ const auto* item =
get_external_field_ptr(field.nestedField.array_field.item_field);
+ return item != nullptr &&
external_field_has_authoritative_name_mapping(*item);
+ }
+ if (field.nestedField.__isset.map_field) {
+ const auto& map_field = field.nestedField.map_field;
+ if (map_field.__isset.key_field) {
+ const auto* key = get_external_field_ptr(map_field.key_field);
+ if (key != nullptr &&
external_field_has_authoritative_name_mapping(*key)) {
+ return true;
+ }
+ }
+ if (map_field.__isset.value_field) {
+ const auto* value = get_external_field_ptr(map_field.value_field);
+ return value != nullptr &&
external_field_has_authoritative_name_mapping(*value);
+ }
+ }
+ return false;
+}
+
+} // namespace
+
+bool IcebergTableReader::_scan_has_any_authoritative_name_mapping() const {
+ if (schema_has_any_authoritative_name_mapping(_projected_columns)) {
+ return true;
+ }
+ if (_scan_params == nullptr || !_scan_params->__isset.history_schema_info)
{
+ return false;
+ }
+ // Metadata-only scans have no projected data column carrying aliases.
Consult the complete
+ // schema so hidden equality-delete keys use the same authoritative
mapping as visible fields.
+ for (const auto& schema : _scan_params->history_schema_info) {
+ if (!schema.__isset.root_field || !schema.root_field.__isset.fields) {
+ continue;
+ }
+ if (std::ranges::any_of(schema.root_field.fields, [](const auto&
field) {
+ const auto* schema_field = get_external_field_ptr(field);
+ return schema_field != nullptr &&
+
external_field_has_authoritative_name_mapping(*schema_field);
+ })) {
+ return true;
+ }
+ }
+ return false;
+}
+
template <typename T>
static std::string join_values_for_debug(const std::vector<T>& values) {
std::ostringstream out;
diff --git a/be/src/format_v2/table/iceberg_reader.h
b/be/src/format_v2/table/iceberg_reader.h
index 5760631e577..21609b9866f 100644
--- a/be/src/format_v2/table/iceberg_reader.h
+++ b/be/src/format_v2/table/iceberg_reader.h
@@ -69,6 +69,12 @@ public:
if (!_data_reader.file_schema.empty() && has_field_ids) {
return format::TableColumnMappingMode::BY_FIELD_ID;
}
+ 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.
+ return format::TableColumnMappingMode::BY_FIELD_ID;
+ }
return format::TableColumnMappingMode::BY_NAME;
}
@@ -113,6 +119,8 @@ private:
static constexpr size_t ICEBERG_FILE_PATH_BLOCK_POSITION = 0;
static constexpr size_t ICEBERG_ROW_POS_BLOCK_POSITION = 1;
+ bool _scan_has_any_authoritative_name_mapping() const;
+
class PositionDeleteRowsCollector final {
public:
using PositionDeleteFile = std::unordered_map<std::string,
format::DeleteRows>;
diff --git a/be/src/format_v2/table/iceberg_schema_utils.h
b/be/src/format_v2/table/iceberg_schema_utils.h
index 516c982194b..c37b907c018 100644
--- a/be/src/format_v2/table/iceberg_schema_utils.h
+++ b/be/src/format_v2/table/iceberg_schema_utils.h
@@ -50,4 +50,16 @@ inline bool schema_has_all_field_ids(const
std::vector<ColumnDefinition>& schema
return true;
}
+inline bool schema_has_any_authoritative_name_mapping(const
std::vector<ColumnDefinition>& schema) {
+ for (const auto& field : schema) {
+ if (field.column_type != ColumnType::DATA_COLUMN) {
+ continue;
+ }
+ if (field.has_name_mapping ||
schema_has_any_authoritative_name_mapping(field.children)) {
+ return true;
+ }
+ }
+ return false;
+}
+
} // namespace doris::format::iceberg
diff --git a/be/src/format_v2/table_reader.cpp
b/be/src/format_v2/table_reader.cpp
index 78089087c76..8e03d3d2282 100644
--- a/be/src/format_v2/table_reader.cpp
+++ b/be/src/format_v2/table_reader.cpp
@@ -1354,6 +1354,8 @@ Status TableReader::refresh_conjuncts(VExprContextSPtrs
conjuncts,
}
}
RETURN_IF_ERROR(customize_file_scan_request(refreshed_request.get()));
+ RETURN_IF_ERROR(
+
refreshed_mapper->reconcile_scan_request_after_customization(refreshed_request.get()));
if (_file_scan_request == nullptr ||
!same_physical_scan_layout(*refreshed_request, *_file_scan_request)) {
// A reader cannot reinterpret columns already materialized with
another block layout.
diff --git a/be/src/format_v2/table_reader.h b/be/src/format_v2/table_reader.h
index 0d6928b96b9..797bba2a0a1 100644
--- a/be/src/format_v2/table_reader.h
+++ b/be/src/format_v2/table_reader.h
@@ -487,7 +487,6 @@ protected:
RETURN_IF_ERROR(close_current_reader());
return Status::OK();
}
- RETURN_IF_ERROR(validate_file_mapping(*_data_reader.column_mapper));
// COUNT(*) has no semantic column argument, but Nereids retains a
minimum-width scan slot
// so the scan node still has an output tuple. Record only the current
non-predicate file
// columns before table-format hooks add row-position or
equality-delete dependencies. This
@@ -508,6 +507,9 @@ protected:
}
}
RETURN_IF_ERROR(customize_file_scan_request(file_request.get()));
+
RETURN_IF_ERROR(_data_reader.column_mapper->reconcile_scan_request_after_customization(
+ file_request.get()));
+ RETURN_IF_ERROR(validate_file_mapping(*_data_reader.column_mapper));
RETURN_IF_ERROR(_open_local_filter_exprs(*file_request));
_data_reader.file_block_layout.clear();
_data_reader.block_template.clear();
diff --git a/be/test/format_v2/column_mapper_test.cpp
b/be/test/format_v2/column_mapper_test.cpp
index 06bbd949396..ff402bd0502 100644
--- a/be/test/format_v2/column_mapper_test.cpp
+++ b/be/test/format_v2/column_mapper_test.cpp
@@ -4213,6 +4213,44 @@ TEST(ColumnMapperTest,
PredicateAccessPathsCreateDeferredStructOutputProjection)
EXPECT_TRUE(request.is_predicate_only(LocalColumnId(0)));
}
+TEST(ColumnMapperTest,
RejectedMissingStructPredicateRestoresFullOutputMapping) {
+ auto table_renamed = field_id_col("renamed", 2, i64());
+ auto table_keep = field_id_col("keep", 3, i64());
+ auto table_added = field_id_col("added", 6, i64());
+ auto table_struct = struct_col("s", 1, {table_renamed, table_keep});
+ auto full_table_struct = struct_col("s", 1, {table_renamed, table_keep,
table_added});
+ table_struct.type = full_table_struct.type;
+ table_struct.has_predicate_access_paths = true;
+ table_struct.predicate_children = {table_added};
+
+ auto file_removed = field_id_col("removed", 7, i64(), 0);
+ auto file_renamed = field_id_col("rename_me", 2, i64(), 1);
+ auto file_keep = field_id_col("keep", 3, i64(), 2);
+ auto file_struct = struct_col("s", 1, {file_removed, file_renamed,
file_keep}, 0);
+
+ ParquetColumnMapper mapper({.mode = TableColumnMappingMode::BY_FIELD_ID});
+ ASSERT_TRUE(mapper.create_mapping({table_struct}, {}, {file_struct}).ok());
+
+ auto added = struct_element(table_slot(0, 0, table_struct.type, "s"),
i64(), "added");
+ auto predicate = binary_predicate(TExprOpcode::GT, added,
+ literal(i64(),
Field::create_field<TYPE_BIGINT>(0)));
+ TableFilter filter {.conjunct = VExprContext::create_shared(predicate),
+ .global_indices = {GlobalIndex(0)}};
+
+ FileScanRequest request;
+ ASSERT_TRUE(mapper.create_scan_request({filter}, {table_struct},
&request).ok());
+ EXPECT_TRUE(request.predicate_columns.empty());
+ ASSERT_EQ(request.non_predicate_columns.size(), 1) <<
request.debug_string();
+ EXPECT_TRUE(request.non_predicate_columns[0].project_all_children);
+
+ ASSERT_EQ(mapper.mappings().size(), 1);
+ const auto& mapping = mapper.mappings()[0];
+ ASSERT_EQ(mapping.projected_file_children.size(), 3);
+ EXPECT_EQ(mapping.projected_file_children[0].name, "removed");
+ EXPECT_EQ(mapping.projected_file_children[1].name, "rename_me");
+ EXPECT_EQ(mapping.projected_file_children[2].name, "keep");
+}
+
TEST(ColumnMapperTest,
PredicateAccessPathsCreateDeferredVariantRootProjection) {
auto table_variant = field_id_col("v", 10, variant_v2());
table_variant.has_predicate_access_paths = true;
diff --git a/be/test/format_v2/table/iceberg_reader_test.cpp
b/be/test/format_v2/table/iceberg_reader_test.cpp
index b66655832d7..7585ef73568 100644
--- a/be/test/format_v2/table/iceberg_reader_test.cpp
+++ b/be/test/format_v2/table/iceberg_reader_test.cpp
@@ -240,10 +240,12 @@ private:
class IcebergTableReaderMappingModeTestHelper final
: public doris::format::iceberg::IcebergTableReader {
public:
- TableColumnMappingMode
mapping_mode_for_schema(std::vector<ColumnDefinition> file_schema,
- TFileScanRangeParams*
scan_params = nullptr) {
+ TableColumnMappingMode mapping_mode_for_schema(
+ std::vector<ColumnDefinition> file_schema, TFileScanRangeParams*
scan_params = nullptr,
+ std::vector<ColumnDefinition> projected_columns = {}) {
_scan_params = scan_params;
_data_reader.file_schema = std::move(file_schema);
+ _projected_columns = std::move(projected_columns);
return mapping_mode();
}
};
@@ -818,6 +820,69 @@ void write_nested_equality_orc_file(const std::string&
file_path, const std::vec
output.write(memory_stream.getData(),
static_cast<std::streamsize>(memory_stream.getLength()));
}
+void write_nested_key_value_parquet_file(const std::string& file_path,
+ const std::vector<int32_t>& keys,
+ const std::vector<int32_t>& values) {
+ ASSERT_EQ(keys.size(), values.size());
+ auto key_field = arrow::field("delete_key", arrow::int32(), false)
+
->WithMetadata(arrow::key_value_metadata({"PARQUET:field_id"}, {"2"}));
+ auto value_field =
+ arrow::field("visible_value", arrow::int32(), false)
+
->WithMetadata(arrow::key_value_metadata({"PARQUET:field_id"}, {"3"}));
+ auto payload_result = arrow::StructArray::Make(
+ {build_int32_array(keys), build_int32_array(values)}, {key_field,
value_field});
+ ASSERT_TRUE(payload_result.ok()) << payload_result.status();
+ auto payload_field =
+ arrow::field("payload", arrow::struct_({key_field, value_field}),
false)
+
->WithMetadata(arrow::key_value_metadata({"PARQUET:field_id"}, {"1"}));
+ auto table = arrow::Table::Make(arrow::schema({payload_field}),
{*payload_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>(keys.size()),
+ builder.build()));
+}
+
+void write_nested_key_value_orc_file(const std::string& file_path, const
std::vector<int64_t>& keys,
+ const std::vector<int64_t>& values) {
+ ASSERT_EQ(keys.size(), values.size());
+ auto type = std::unique_ptr<::orc::Type>(::orc::Type::buildTypeFromString(
+ "struct<payload:struct<delete_key:int,visible_value:int>>"));
+ type->getSubtype(0)->setAttribute("iceberg.id", "1");
+ type->getSubtype(0)->getSubtype(0)->setAttribute("iceberg.id", "2");
+ type->getSubtype(0)->getSubtype(1)->setAttribute("iceberg.id", "3");
+
+ MemoryOutputStream memory_stream(1024 * 1024);
+ ::orc::WriterOptions options;
+ options.setCompression(::orc::CompressionKind_NONE);
+ options.setMemoryPool(::orc::getDefaultPool());
+ auto writer = ::orc::createWriter(*type, &memory_stream, options);
+ auto batch = writer->createRowBatch(keys.size());
+ auto& root_batch = dynamic_cast<::orc::StructVectorBatch&>(*batch);
+ auto& payload_batch =
dynamic_cast<::orc::StructVectorBatch&>(*root_batch.fields[0]);
+ const std::array value_sets = {&keys, &values};
+ for (size_t field_idx = 0; field_idx < value_sets.size(); ++field_idx) {
+ auto& value_batch =
dynamic_cast<::orc::LongVectorBatch&>(*payload_batch.fields[field_idx]);
+ for (size_t row = 0; row < keys.size(); ++row) {
+ value_batch.data[row] = (*value_sets[field_idx])[row];
+ }
+ value_batch.numElements = keys.size();
+ }
+ root_batch.numElements = keys.size();
+ payload_batch.numElements = keys.size();
+ writer->add(*batch);
+ writer->close();
+
+ std::ofstream output(file_path, std::ios::binary);
+ output.write(memory_stream.getData(),
static_cast<std::streamsize>(memory_stream.getLength()));
+}
+
void write_timestamp_int_parquet_file(const std::string& file_path,
const std::vector<int64_t>& timestamps,
const std::vector<int32_t>& ids) {
@@ -1295,6 +1360,16 @@ void
init_iceberg_reader(doris::format::iceberg::IcebergTableReader* reader,
.ok());
}
+ColumnDefinition make_authoritatively_name_mapped_table_column(int32_t id,
std::string name,
+ const
DataTypePtr& type) {
+ auto column = make_table_column(id, name, type);
+ // Equality-delete tests need a readable result carrier while
independently exercising an
+ // ID-less hidden key. Make that carrier explicitly name-readable under
the V2 mapping rules.
+ column.name_mapping = {std::move(name)};
+ column.has_name_mapping = true;
+ return column;
+}
+
void expect_idless_equality_key_uses_delete_file_name(FileFormat file_format,
bool
authoritative_name_mapping) {
const std::string format_name = file_format == FileFormat::PARQUET ?
"parquet" : "orc";
@@ -1317,7 +1392,8 @@ void
expect_idless_equality_key_uses_delete_file_name(FileFormat file_format,
}
std::vector<ColumnDefinition> projected_columns;
- projected_columns.push_back(make_table_column(0, "id",
std::make_shared<DataTypeInt32>()));
+ projected_columns.push_back(make_authoritatively_name_mapped_table_column(
+ 0, "id", std::make_shared<DataTypeInt32>()));
auto equality_field = external_schema_field("future_name", 1, {}, "7");
if (authoritative_name_mapping) {
@@ -2798,6 +2874,25 @@ TEST(IcebergV2ReaderTest,
IcebergLegacyPlanKeepsAllFieldIdsMappingRule) {
TableColumnMappingMode::BY_NAME);
}
+TEST(IcebergV2ReaderTest, IcebergV2IdlessFileRequiresAuthoritativeNameMapping)
{
+ IcebergTableReaderMappingModeTestHelper reader;
+ TFileScanRangeParams scan_params;
+
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+
+ auto file_column = make_file_column(1, "legacy_name",
std::make_shared<DataTypeInt32>());
+ file_column.identifier = Field {};
+ auto table_column = make_table_column(1, "current_name",
std::make_shared<DataTypeInt32>());
+
+ EXPECT_EQ(reader.mapping_mode_for_schema({file_column}, &scan_params,
{table_column}),
+ TableColumnMappingMode::BY_FIELD_ID);
+
+ table_column.has_name_mapping = true;
+ table_column.name_mapping = {"legacy_name"};
+ EXPECT_EQ(reader.mapping_mode_for_schema({std::move(file_column)},
&scan_params,
+ {std::move(table_column)}),
+ TableColumnMappingMode::BY_NAME);
+}
+
TEST(IcebergV2ReaderTest,
IcebergTableReaderDoesNotPushDownAggregateWithPositionDelete) {
const auto test_dir =
std::filesystem::temp_directory_path() /
"doris_iceberg_aggregate_position_delete_test";
@@ -3092,6 +3187,106 @@ TEST(IcebergV2ReaderTest,
IcebergNestedEqualityDeleteFiltersCurrentAndDroppedFie
run_case(FileFormat::PARQUET, false, false);
}
+// Keep the Parquet/ORC setup identical because both readers must preserve the
projected child
+// ordinal when an equality-delete key widens the same struct root after
mapper localization.
+//
NOLINTNEXTLINE(readability-function-cognitive-complexity,readability-function-size)
+TEST(IcebergV2ReaderTest,
IcebergNestedEqualityDeletePreservesVisibleSiblingProjection) {
+ const auto run_case = [](FileFormat file_format) {
+ const bool is_parquet = file_format == FileFormat::PARQUET;
+ const std::string format_name = is_parquet ? "parquet" : "orc";
+ const auto test_dir = std::filesystem::temp_directory_path() /
+ ("doris_v2_nested_equality_visible_sibling_" +
format_name);
+ std::filesystem::remove_all(test_dir);
+ std::filesystem::create_directories(test_dir);
+ const auto file_path = (test_dir / ("split." + format_name)).string();
+ const auto delete_file_path = (test_dir / ("equality-delete." +
format_name)).string();
+ if (is_parquet) {
+ write_nested_key_value_parquet_file(file_path, {100, 200, 300},
{10, 20, 30});
+ write_nested_equality_parquet_file(delete_file_path, {}, {200},
{false}, true,
+ "delete_key", 2);
+ } else {
+ write_nested_key_value_orc_file(file_path, {100, 200, 300}, {10,
20, 30});
+ write_nested_equality_orc_file(delete_file_path, {}, {200},
{false}, "delete_key", 2);
+ }
+
+ auto schema_payload = external_struct_schema_field(
+ "payload", 1,
+ {external_schema_field("delete_key", 2, {}, std::nullopt,
+
external_primitive_type(TPrimitiveType::INT)),
+ external_schema_field("visible_value", 3, {}, std::nullopt,
+
external_primitive_type(TPrimitiveType::INT))});
+ auto scan_params = make_local_scan_params(file_format);
+
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+ scan_params.__set_current_schema_id(100);
+ scan_params.__set_history_schema_info({external_schema(100,
{schema_payload})});
+
+ const auto int_type = std::make_shared<DataTypeInt32>();
+ auto visible_value = make_table_column(3, "visible_value", int_type);
+ auto payload_type =
+ std::make_shared<DataTypeStruct>(DataTypes {int_type}, Strings
{"visible_value"});
+ auto payload = make_table_column(1, "payload", payload_type);
+ payload.children = {visible_value};
+ std::vector<ColumnDefinition> projected_columns = {payload};
+
+ RuntimeProfile profile("test_profile");
+ RuntimeState state {TQueryOptions(), TQueryGlobals()};
+ 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,
+ file_format);
+
+ auto split_options = build_split_options(file_path);
+ split_options.cache = &cache;
+ split_options.current_split_format = file_format;
+ const auto thrift_file_format =
+ is_parquet ? TFileFormatType::FORMAT_PARQUET :
TFileFormatType::FORMAT_ORC;
+
split_options.current_range.__set_table_format_params(make_iceberg_table_format_desc(
+ file_path,
+ {make_iceberg_equality_delete_file(delete_file_path, {2},
thrift_file_format)}, 3));
+ 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);
+ const auto& payload_result =
+ assert_cast<const
ColumnStruct&>(expect_not_null_table_column(block, 0));
+ expect_int32_column_values(payload_result.get_column(0), {10, 30});
+
+ ASSERT_TRUE(reader.close().ok());
+ std::filesystem::remove_all(test_dir);
+ };
+
+ for (const auto file_format : {FileFormat::PARQUET, FileFormat::ORC}) {
+ run_case(file_format);
+ }
+}
+
+TEST(IcebergV2ReaderTest, IcebergMetadataOnlyMappingUsesHistoryNameMapping) {
+ IcebergTableReaderMappingModeTestHelper reader;
+ TFileScanRangeParams scan_params;
+
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+
+ auto file_column = make_file_column(1, "legacy_key",
std::make_shared<DataTypeInt32>());
+ file_column.identifier = Field {};
+ EXPECT_EQ(reader.mapping_mode_for_schema({file_column}, &scan_params),
+ TableColumnMappingMode::BY_FIELD_ID);
+
+ auto key_field =
+ external_schema_field("current_key", 1, {"legacy_key"},
std::nullopt,
+
external_primitive_type(TPrimitiveType::INT), false, true);
+ key_field.field_ptr->__set_name_mapping_is_authoritative(true);
+ scan_params.__set_history_schema_info({external_schema(100,
{std::move(key_field)})});
+
+ // Metadata-only scans have no projected column carrying aliases, so the
complete schema must
+ // remain authoritative for resolving hidden equality-delete keys in
ID-less files.
+ EXPECT_EQ(reader.mapping_mode_for_schema({std::move(file_column)},
&scan_params),
+ TableColumnMappingMode::BY_NAME);
+}
+
// Keep the shared Parquet/ORC reader setup together so both V2 paths exercise
identical ID-less
// nested-name resolution.
//
NOLINTNEXTLINE(readability-function-cognitive-complexity,readability-function-size)
@@ -3138,7 +3333,8 @@ TEST(IcebergV2ReaderTest,
IcebergIdlessNestedEqualityKeyUsesAliasPathAndDeleteLe
}
std::vector<ColumnDefinition> projected_columns;
- projected_columns.push_back(make_table_column(0, "id",
std::make_shared<DataTypeInt32>()));
+
projected_columns.push_back(make_authoritatively_name_mapped_table_column(
+ 0, "id", std::make_shared<DataTypeInt32>()));
auto scan_params = make_local_scan_params(file_format);
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
scan_params.__set_current_schema_id(100);
@@ -3534,7 +3730,8 @@ TEST(IcebergV2ReaderTest,
IcebergEqualityDeleteMissingKeyDoesNotReadUnsupportedU
write_iceberg_equality_delete_parquet_file(delete_file_path, 1, 7,
"added_column");
std::vector<ColumnDefinition> projected_columns;
- projected_columns.push_back(make_table_column(0, "id",
std::make_shared<DataTypeInt32>()));
+ projected_columns.push_back(make_authoritatively_name_mapped_table_column(
+ 0, "id", std::make_shared<DataTypeInt32>()));
RuntimeProfile profile("test_profile");
RuntimeState state {TQueryOptions(), TQueryGlobals()};
@@ -4129,6 +4326,50 @@ TEST(IcebergV2ReaderTest,
ParquetReadsIdlessWrapperWithAuthoritativeEmptyMapping
std::filesystem::remove_all(test_dir);
}
+TEST(IcebergV2ReaderTest,
ParquetTreatsIdlessNestedFileAsMissingWithoutNameMapping) {
+ const auto test_dir = std::filesystem::temp_directory_path() /
+
"doris_iceberg_idless_nested_without_name_mapping_test";
+ std::filesystem::remove_all(test_dir);
+ std::filesystem::create_directories(test_dir);
+ const auto file_path = (test_dir / "split.parquet").string();
+ write_nested_equality_parquet_file(file_path, {}, {42}, {false}, true,
"leaf", 30, "outer",
+ false, false);
+
+ const auto int_type = std::make_shared<DataTypeInt32>();
+ auto leaf = make_table_column(30, "leaf", int_type);
+ auto outer_type = std::make_shared<DataTypeStruct>(DataTypes {int_type},
Strings {"leaf"});
+ auto outer = make_table_column(10, "outer", outer_type);
+ outer.children = {leaf};
+ 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);
+ const auto& result = assert_cast<const
ColumnNullable&>(*block.get_by_position(0).column);
+ ASSERT_EQ(result.size(), 1);
+ EXPECT_EQ(result.get_null_map_data()[0], 1);
+
+ ASSERT_TRUE(reader.close().ok());
+ std::filesystem::remove_all(test_dir);
+}
+
TEST(IcebergV2ReaderTest,
ParquetUsesUnprojectedSiblingIdToRetainNullableWrapper) {
const auto test_dir = std::filesystem::temp_directory_path() /
"doris_iceberg_unprojected_sibling_id_wrapper_test";
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/create_preinstalled_scripts/iceberg_load/run01.sql
b/docker/thirdparties/docker-compose/iceberg/scripts/create_preinstalled_scripts/iceberg_load/run01.sql
index bfe0a838131..4075719c954 100644
---
a/docker/thirdparties/docker-compose/iceberg/scripts/create_preinstalled_scripts/iceberg_load/run01.sql
+++
b/docker/thirdparties/docker-compose/iceberg/scripts/create_preinstalled_scripts/iceberg_load/run01.sql
@@ -9,6 +9,7 @@ drop table if exists multi_catalog.equality_delete_par_3;
drop table if exists multi_catalog.equality_delete_orc_1;
drop table if exists multi_catalog.equality_delete_orc_2;
drop table if exists multi_catalog.equality_delete_orc_3;
+drop table if exists multi_catalog.migrated_nested_without_name_mapping;
CALL system.register_table(
@@ -42,6 +43,13 @@ CALL system.register_table(
metadata_file =>
's3a://warehouse/wh/multi_catalog/equality_delete_orc_3/metadata/00010-f6ba4ee7-256f-41f3-8932-25ec703d8c8b.metadata.json'
);
+-- The imported Parquet file has no Iceberg field IDs, and the latest table
metadata intentionally
+-- omits schema.name-mapping.default to verify missing-field semantics after
migration.
+CALL system.register_table(
+ table => 'multi_catalog.migrated_nested_without_name_mapping',
+ metadata_file =>
's3a://warehouse/wh/multi_catalog/migrated_nested_without_name_mapping/metadata/00003-97f3e871-e421-48ed-a735-354f4e1c9502.metadata.json'
+);
+
-- flink
-- CREATE CATALOG iceberg_rest
-- WITH (
@@ -199,4 +207,4 @@ CALL system.register_table(
-- ALTER TABLE equality_delete_orc_3 MODIFY (
-- `new_id` INT , `name` string ,`data` STRING ,
-- PRIMARY KEY (new_id, `data`) NOT ENFORCED);
--- insert into equality_delete_orc_3 values(1, 'smith4', 'aaa');
\ No newline at end of file
+-- insert into equality_delete_orc_3 values(1, 'smith4', 'aaa');
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/data/data.parquet
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/data/data.parquet
new file mode 100644
index 00000000000..0311c6f2595
Binary files /dev/null and
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/data/data.parquet
differ
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/metadata/00000-4c8aafe0-9de1-4b3f-942d-305dc33fb82f.metadata.json
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/metadata/00000-4c8aafe0-9de1-4b3f-942d-305dc33fb82f.metadata.json
new file mode 100644
index 00000000000..57b95fe8f77
--- /dev/null
+++
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/metadata/00000-4c8aafe0-9de1-4b3f-942d-305dc33fb82f.metadata.json
@@ -0,0 +1 @@
+{"format-version":2,"table-uuid":"bd7ffeda-37f2-4199-92e0-1b97e7ddf643","location":"s3a://warehouse/wh/multi_catalog/migrated_nested_without_name_mapping","last-sequence-number":0,"last-updated-ms":1788760308700,"last-column-id":4,"current-schema-id":0,"schemas":[{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","required":false,"type":"int"},{"id":2,"name":"nested_struct","required":false,"type":{"type":"struct","fields":[{"id":3,"name":"value_a","required":false,"type":"int"
[...]
\ No newline at end of file
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/metadata/00001-94d09fba-61c6-4e4b-b0f5-6023f70d5033.metadata.json
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/metadata/00001-94d09fba-61c6-4e4b-b0f5-6023f70d5033.metadata.json
new file mode 100644
index 00000000000..9f50c4f5d4e
--- /dev/null
+++
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/metadata/00001-94d09fba-61c6-4e4b-b0f5-6023f70d5033.metadata.json
@@ -0,0 +1 @@
+{"format-version":2,"table-uuid":"bd7ffeda-37f2-4199-92e0-1b97e7ddf643","location":"s3a://warehouse/wh/multi_catalog/migrated_nested_without_name_mapping","last-sequence-number":0,"last-updated-ms":1788760308887,"last-column-id":4,"current-schema-id":0,"schemas":[{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","required":false,"type":"int"},{"id":2,"name":"nested_struct","required":false,"type":{"type":"struct","fields":[{"id":3,"name":"value_a","required":false,"type":"int"
[...]
\ No newline at end of file
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/metadata/00002-7493fffe-54f9-486a-b15e-7333fdb96f61.metadata.json
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/metadata/00002-7493fffe-54f9-486a-b15e-7333fdb96f61.metadata.json
new file mode 100644
index 00000000000..b3fca919fff
--- /dev/null
+++
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/metadata/00002-7493fffe-54f9-486a-b15e-7333fdb96f61.metadata.json
@@ -0,0 +1 @@
+{"format-version":2,"table-uuid":"bd7ffeda-37f2-4199-92e0-1b97e7ddf643","location":"s3a://warehouse/wh/multi_catalog/migrated_nested_without_name_mapping","last-sequence-number":1,"last-updated-ms":1788760313465,"last-column-id":4,"current-schema-id":0,"schemas":[{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","required":false,"type":"int"},{"id":2,"name":"nested_struct","required":false,"type":{"type":"struct","fields":[{"id":3,"name":"value_a","required":false,"type":"int"
[...]
\ No newline at end of file
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/metadata/00003-97f3e871-e421-48ed-a735-354f4e1c9502.metadata.json
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/metadata/00003-97f3e871-e421-48ed-a735-354f4e1c9502.metadata.json
new file mode 100644
index 00000000000..5c706e40195
--- /dev/null
+++
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/metadata/00003-97f3e871-e421-48ed-a735-354f4e1c9502.metadata.json
@@ -0,0 +1 @@
+{"format-version":2,"table-uuid":"bd7ffeda-37f2-4199-92e0-1b97e7ddf643","location":"s3a://warehouse/wh/multi_catalog/migrated_nested_without_name_mapping","last-sequence-number":1,"last-updated-ms":1788760313716,"last-column-id":4,"current-schema-id":0,"schemas":[{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","required":false,"type":"int"},{"id":2,"name":"nested_struct","required":false,"type":{"type":"struct","fields":[{"id":3,"name":"value_a","required":false,"type":"int"
[...]
\ No newline at end of file
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/metadata/snap-2748466134835934544-1-94f693c5-6efb-4e9d-a40e-f02ac301fc14.avro
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/metadata/snap-2748466134835934544-1-94f693c5-6efb-4e9d-a40e-f02ac301fc14.avro
new file mode 100644
index 00000000000..b24d91edf04
Binary files /dev/null and
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/metadata/snap-2748466134835934544-1-94f693c5-6efb-4e9d-a40e-f02ac301fc14.avro
differ
diff --git
a/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/metadata/stage-7-task-401-manifest-d04009d1-83e2-4d2a-859e-79d57e8dd381.avro
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/metadata/stage-7-task-401-manifest-d04009d1-83e2-4d2a-859e-79d57e8dd381.avro
new file mode 100644
index 00000000000..d087d3f68c0
Binary files /dev/null and
b/docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/migrated_nested_without_name_mapping/metadata/stage-7-task-401-manifest-d04009d1-83e2-4d2a-859e-79d57e8dd381.avro
differ
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUtils.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUtils.java
index a43ce9dc9d2..da4513ff8a1 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUtils.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUtils.java
@@ -117,6 +117,7 @@ import org.apache.iceberg.hive.HiveCatalog;
import org.apache.iceberg.io.CloseableIterable;
import org.apache.iceberg.mapping.MappedField;
import org.apache.iceberg.mapping.MappedFields;
+import org.apache.iceberg.mapping.MappingUtil;
import org.apache.iceberg.mapping.NameMapping;
import org.apache.iceberg.mapping.NameMappingParser;
import org.apache.iceberg.transforms.Transforms;
@@ -2352,8 +2353,13 @@ public class IcebergUtils {
extractMappingsFromNameMapping(mapping.asMappedFields(), result);
return Optional.of(result);
} catch (Exception e) {
+ // Keep ID-less files readable by current names when a malformed
property cannot provide
+ // authoritative aliases; Optional.empty() must remain reserved
for an absent property.
LOG.warn("Failed to parse name mapping from Iceberg table
properties", e);
- return Optional.empty();
+ Map<Integer, List<String>> fallback = new HashMap<>();
+ extractMappingsFromNameMapping(
+
MappingUtil.create(icebergTable.schema()).asMappedFields(), fallback);
+ return Optional.of(fallback);
}
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
index a4fe894e927..fd719d84b4d 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
@@ -3835,6 +3835,9 @@ public class SessionVariable implements Serializable,
Writable {
this.useSerialExchange = random.nextBoolean();
this.enableCommonExpPushDownForInvertedIndex = random.nextBoolean();
this.enableExprZonemapFilter = Config.pull_request_id % 2 == 0;
+ // Fuzzy sessions must exercise the production-default V2 path
consistently. Dedicated
+ // compatibility cases can still select the legacy scanner explicitly
after initialization.
+ this.enableFileScannerV2 = true;
this.disableStreamPreaggregations = random.nextBoolean();
this.enableStreamingAggHashJoinForcePassthrough = random.nextBoolean();
this.enableLocalExchangeBeforeAgg = random.nextBoolean();
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergUtilsTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergUtilsTest.java
index 393d60cf6f5..616f1eefa5f 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergUtilsTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergUtilsTest.java
@@ -173,6 +173,23 @@ public class IcebergUtilsTest {
Mockito.verify(operations, Mockito.times(1)).current();
}
+ @Test
+ public void testMalformedNameMappingFallsBackToCurrentSchemaNames() {
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "name", Types.StringType.get()));
+ Table table = Mockito.mock(Table.class);
+ Mockito.when(table.properties()).thenReturn(Collections.singletonMap(
+ TableProperties.DEFAULT_NAME_MAPPING, "{not valid json"));
+ Mockito.when(table.schema()).thenReturn(schema);
+
+ Optional<Map<Integer, List<String>>> mapping =
IcebergUtils.getNameMapping(table);
+ Assert.assertTrue(mapping.isPresent());
+ Map<Integer, List<String>> fallback = mapping.get();
+ Assert.assertEquals(Collections.singletonList("id"), fallback.get(1));
+ Assert.assertEquals(Collections.singletonList("name"),
fallback.get(2));
+ }
+
@Test
public void testGetFileFormatUsesPropertiesWithoutPlanningDataFiles() {
Table table = Mockito.mock(Table.class);
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/qe/SessionVariablesTest.java
b/fe/fe-core/src/test/java/org/apache/doris/qe/SessionVariablesTest.java
index 52e7b4c1a43..1e47c3a4c72 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/qe/SessionVariablesTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/qe/SessionVariablesTest.java
@@ -204,6 +204,7 @@ public class SessionVariablesTest extends TestWithFeService
{
VariableMgr.VarAttr varAttr =
field.getAnnotation(VariableMgr.VarAttr.class);
Assertions.assertFalse(varAttr.fuzzy());
+ sessionVar.enableFileScannerV2 = false;
sessionVar.initFuzzyModeVariables();
Assertions.assertTrue(sessionVar.enableFileScannerV2);
}
diff --git
a/regression-test/data/external_table_p0/iceberg/test_gen_iceberg_by_api.out
b/regression-test/data/external_table_p0/iceberg/test_gen_iceberg_by_api.out
index b3d42dfcd19..21218a4e51c 100644
--- a/regression-test/data/external_table_p0/iceberg/test_gen_iceberg_by_api.out
+++ b/regression-test/data/external_table_p0/iceberg/test_gen_iceberg_by_api.out
@@ -5,6 +5,3 @@
2 1970-01-03T09:02:04.000001 c
2 1970-01-03T09:02:04.000001 d
--- !q02 --
-463870
-
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
new file mode 100644
index 00000000000..9aea1c252d0
--- /dev/null
+++
b/regression-test/data/external_table_p0/iceberg/test_iceberg_migrated_nested_without_name_mapping.out
@@ -0,0 +1,3 @@
+-- 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
diff --git
a/regression-test/data/external_table_p0/iceberg/test_iceberg_struct_schema_evolution.out
b/regression-test/data/external_table_p0/iceberg/test_iceberg_struct_schema_evolution.out
index a364316df42..b106ac908a9 100644
---
a/regression-test/data/external_table_p0/iceberg/test_iceberg_struct_schema_evolution.out
+++
b/regression-test/data/external_table_p0/iceberg/test_iceberg_struct_schema_evolution.out
@@ -39,6 +39,9 @@ a_struct
struct<renamed:bigint,keep:bigint,drop_and_add:bigint,added:bigint> Yes
-- !struct_predicate_4 --
2
+-- !struct_projected_siblings_with_missing_predicate --
+11 12
+
-- !struct_multi --
11 12 \N \N
21 22 23 24
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 512adecf7f3..4f0bf7c1ca2 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
@@ -45,10 +45,11 @@ suite("test_gen_iceberg_by_api",
"p0,external,doris,external_docker,external_doc
def q01 = {
qt_q01 """ select * from multi_partition2 order by val """
- try {
- qt_q02 """ select count(*) from table_with_append_file where
MAN_ID is not null """
- } catch (Exception e) {
- assertTrue(e.getMessage().contains("name_mapping must be set when
read missing field id data file."), e.getMessage());
+ 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"
}
}
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
new file mode 100644
index 00000000000..8f1ceec0906
--- /dev/null
+++
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_migrated_nested_without_name_mapping.groovy
@@ -0,0 +1,55 @@
+// 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.
+
+suite("test_iceberg_migrated_nested_without_name_mapping", "p0,external") {
+ String enabled = context.config.otherConfigs.get("enableIcebergTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("disable iceberg test.")
+ return
+ }
+
+ String rest_port = context.config.otherConfigs.get("iceberg_rest_uri_port")
+ String minio_port = context.config.otherConfigs.get("iceberg_minio_port")
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+ String catalog_name = "test_iceberg_migrated_nested_without_name_mapping"
+
+ sql """drop catalog if exists ${catalog_name}"""
+ sql """
+ CREATE CATALOG ${catalog_name} PROPERTIES (
+ 'type'='iceberg',
+ 'iceberg.catalog.type'='rest',
+ 'uri' = 'http://${externalEnvIp}:${rest_port}',
+ 's3.access_key' = 'admin',
+ 's3.secret_key' = 'password',
+ 's3.endpoint' = 'http://${externalEnvIp}:${minio_port}',
+ 's3.region' = 'us-east-1'
+ )
+ """
+ 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 """
+ 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}"""
+}
diff --git
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_struct_schema_evolution.groovy
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_struct_schema_evolution.groovy
index e6142771df4..f9dab204421 100644
---
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_struct_schema_evolution.groovy
+++
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_struct_schema_evolution.groovy
@@ -89,6 +89,15 @@ suite("test_iceberg_struct_schema_evolution",
"p0,external,doris,external_docker
qt_struct_predicate_3 """SELECT id FROM ${table_name} WHERE
element_at(a_struct, 'added') IS NULL ORDER BY id"""
qt_struct_predicate_4 """SELECT id FROM ${table_name} WHERE
element_at(a_struct, 'added') IS NOT NULL ORDER BY id"""
+ // A missing predicate child widens the physical scan back to the full
struct. The projected
+ // sibling fields must keep their Iceberg field-id mapping instead of
shifting by file ordinal.
+ qt_struct_projected_siblings_with_missing_predicate """
+ SELECT element_at(a_struct, 'renamed'), element_at(a_struct, 'keep')
+ FROM ${table_name}
+ WHERE element_at(a_struct, 'added') IS NULL
+ ORDER BY id
+ """
+
// Test 7: Multiple struct fields in one query
qt_struct_multi """SELECT element_at(a_struct, 'renamed'),
element_at(a_struct, 'keep'), element_at(a_struct, 'drop_and_add'),
element_at(a_struct, 'added') FROM ${table_name} ORDER BY id"""
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]