This is an automated email from the ASF dual-hosted git repository.
morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 1c9b51c0c75 [test](iceberg) Port the branch-4.1 Iceberg V3 default
tests and reserve TIcebergFileDesc field 13 (#69394)
1c9b51c0c75 is described below
commit 1c9b51c0c759a9c9135593f7ce376025178407a8
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Fri Oct 23 10:11:07 2026 +0100
[test](iceberg) Port the branch-4.1 Iceberg V3 default tests and reserve
TIcebergFileDesc field 13 (#69394)
### What problem does this PR solve?
Issue Number: None
Related PR: #66538 #65851
Problem Summary:
**In short.** This ports the Iceberg V3 default-value tests of
branch-4.1 #66538 to master: 3 BE unit tests, 37 FE unit tests and one
regression check, all for behavior master already has. It also reserves
field id 13 of `TIcebergFileDesc`, which branch-4.1 and branch-4.2 use
for `equality_delete_schema`. No product code changes.
**Background.** Iceberg V3 initial / write defaults were implemented
twice: by #65851 on master and by #66538 on branch-4.1, which branch-4.2
inherits. The 4.1 tests target fe-core classes (`IcebergScanNode`,
`IcebergWriteSchemaContext`, `IcebergTransaction`, …) and V1 reader
internals that master no longer has. On master the Iceberg FE lives in
the `fe-connector-iceberg` plugin and the V1 reader in
`iceberg_reader_mixin.h`. So most of the 4.1 tests never reached master,
even where master implements the same behavior.
**The problem, and what it cost.** A scenario-by-scenario comparison of
the two test suites found that master implements most of the 4.1-tested
behavior with no test of its own. The untested behavior:
| Area | Untested on master before this PR |
|---|---|
| Equality-delete schema carrier | a dropped key keeps its latest name,
optionality and initial default; a dropped nested key comes back inside
its parent; a dropped nanosecond key stays `TIMESTAMP_NS`; only
referenced historical fields enter the carrier |
| Rolling-upgrade fences | missing-required-field and
nested-initial-default fences follow the projection; collection wrappers
do not trigger them; schema-only targets and cherry-picked ancestry are
covered; name-mapping alias collisions |
| Write commit fences | schema skew, a recreated table and current-spec
replacement are rejected before a transaction opens; a required column,
a spec drift to unpartitioned or a table replacement that lands while
the commit stages its snapshot is rejected at the final metadata swap |
| Pinned writer metadata | INSERT commit, merge sink (including the
unpartitioned delete spec id) and file conversion use the
statement-pinned spec, sort order and format, not the live table |
| Write defaults | typed SQL literal per primitive type; maps and NULL
elements in complex defaults; `DEFAULT` resolves by field id, not by a
reused name; UPDATE and matched MERGE do not inject write defaults |
| Wire compatibility | `TIcebergFileDesc` ends at field 12 on master,
while 4.1 / 4.2 send `13: optional ExternalTableSchema.TSchema
equality_delete_schema`; nothing stops master from reusing 13 for an
unrelated field |
Without these tests a regression in any of these paths would go
unnoticed, and a reused field 13 would misdecode 4.x plans.
**How this PR fixes it.** It rewrites the 4.1 scenarios against master's
APIs. Plugin tests use the existing Mockito-free fakes
(`InMemoryCatalog`, `Proxy` over `TableOperations`). The commit-fence
tests hook `newSnapshotId` on the staging operations, so the concurrent
change lands after the commit preflight and before the metadata swap.
The BE tests are copied unchanged from 4.1 (the helper signatures match
on master). `PlanNodes.thrift` gets a comment reserving field 13.
`test_iceberg_write_default` gains a `qt_` check that
`information_schema.columns.COLUMN_DEFAULT` stays NULL for a column
whose default is a connector-only write default. The behavior
differences found in the same comparison are fixed in separate PRs (for
example #69369).
**Results.** All new tests pass on master. To show they check something,
the fence and pinned-metadata tests were re-run with the guarded
condition removed from the product code; each failed.
| Suite | Result |
|---|---|
| `fe-connector-iceberg` (all tests) | 1656 run, 0 failures, 0 errors, 5
skipped; checkstyle clean |
| fe-core `ConnectorWriteSchemaUtilsTest`,
`ExternalRowLevelUpdatePlanBuilderTest`,
`ExternalRowLevelMergePlanBuilderTest` | 13 run, 0 failures |
| BE
`IcebergV2ReaderTest.IcebergProjectedStructDoesNotReuseReaddedEqualityKeyByName`,
`ParquetReadLinesTest.iceberg_row_id_fetch_materializes_readded_missing_column`,
`VOrcTransformerTest.SkipsInvalidBinaryChildrenHiddenByNullableCollections`
| pass |
| Regression `external_table_p0/iceberg/test_iceberg_write_default` |
pass |
Co-authored-by: daidai <[email protected]>
---
be/test/format/parquet/parquet_read_lines.cpp | 147 ++++++++
.../format/transformer/vorc_transformer_test.cpp | 76 ++++
be/test/format_v2/table/iceberg_reader_test.cpp | 72 ++++
.../iceberg/IcebergConnectorMetadataTest.java | 35 ++
.../iceberg/IcebergConnectorTransactionTest.java | 278 +++++++++++++++
.../iceberg/IcebergScanPlanProviderTest.java | 387 +++++++++++++++++++++
.../connector/iceberg/IcebergSchemaUtilsTest.java | 50 +++
.../iceberg/IcebergWritePlanProviderTest.java | 306 ++++++++++++++++
.../connector/iceberg/IcebergWriterHelperTest.java | 47 +++
.../commands/ConnectorWriteSchemaUtilsTest.java | 109 ++++++
.../ExternalRowLevelMergePlanBuilderTest.java | 55 +++
.../ExternalRowLevelUpdatePlanBuilderTest.java | 38 ++
gensrc/thrift/PlanNodes.thrift | 3 +
.../iceberg/test_iceberg_write_default.out | 4 +
.../iceberg/test_iceberg_write_default.groovy | 9 +
15 files changed, 1616 insertions(+)
diff --git a/be/test/format/parquet/parquet_read_lines.cpp
b/be/test/format/parquet/parquet_read_lines.cpp
index 835ae04bdf1..2769b94531c 100644
--- a/be/test/format/parquet/parquet_read_lines.cpp
+++ b/be/test/format/parquet/parquet_read_lines.cpp
@@ -42,6 +42,7 @@
#include "exprs/vexpr_context.h"
#include "format/orc/vorc_reader.h"
#include "format/parquet/vparquet_reader.h"
+#include "format/table/iceberg_scan_semantics.h"
#include "gtest/gtest_pred_impl.h"
#include "io/fs/local_file_system.h"
#include "orc/sargs/SearchArgument.hh"
@@ -322,4 +323,150 @@ TEST_F(ParquetReadLinesTest, test4) {
read_parquet_lines(numeric_types, types, read_lines, block_dump);
}
+TEST_F(ParquetReadLinesTest,
iceberg_row_id_fetch_materializes_readded_missing_column) {
+ TDescriptorTable thrift_desc;
+ TTableDescriptor table_desc;
+ table_desc.__set_id(0);
+ table_desc.__set_tableType(TTableType::OLAP_TABLE);
+ table_desc.__set_numCols(0);
+ table_desc.__set_numClusteringCols(0);
+ thrift_desc.tableDescriptors.push_back(table_desc);
+ thrift_desc.__isset.tableDescriptors = true;
+
+ const std::vector<std::tuple<std::string, int32_t, TPrimitiveType::type>>
columns {
+ {"new_name", 2, TPrimitiveType::STRING},
+ {"data", 3, TPrimitiveType::STRING},
+ {"id", 4, TPrimitiveType::INT},
+ };
+ for (size_t index = 0; index < columns.size(); ++index) {
+ const auto& [name, field_id, primitive_type] = columns[index];
+ TTypeNode type_node;
+ type_node.__set_type(TTypeNodeType::SCALAR);
+ TScalarType scalar_type;
+ scalar_type.__set_type(primitive_type);
+ type_node.__set_scalar_type(scalar_type);
+ TTypeDesc type;
+ type.types.push_back(type_node);
+
+ TSlotDescriptor slot;
+ slot.__set_id(cast_set<int32_t>(index));
+ slot.__set_parent(0);
+ slot.__set_slotType(type);
+ slot.__set_columnPos(cast_set<int32_t>(index));
+ slot.__set_byteOffset(0);
+ slot.__set_nullIndicatorByte(0);
+ slot.__set_nullIndicatorBit(cast_set<int32_t>(index));
+ slot.__set_colName(name);
+ slot.__set_slotIdx(cast_set<int32_t>(index));
+ slot.__set_isMaterialized(true);
+ slot.__set_col_unique_id(field_id);
+ thrift_desc.slotDescriptors.push_back(slot);
+ }
+ thrift_desc.__isset.slotDescriptors = true;
+
+ TTupleDescriptor tuple;
+ tuple.__set_id(0);
+ tuple.__set_byteSize(16);
+ tuple.__set_numNullBytes(1);
+ tuple.__set_tableId(0);
+ tuple.__isset.tableId = true;
+ thrift_desc.tupleDescriptors.push_back(tuple);
+
+ ObjectPool object_pool;
+ DescriptorTbl* desc_tbl = nullptr;
+ ASSERT_TRUE(DescriptorTbl::create(&object_pool, thrift_desc,
&desc_tbl).ok());
+ auto* tuple_desc =
const_cast<TupleDescriptor*>(desc_tbl->get_tuple_descriptor(0));
+ ASSERT_NE(tuple_desc, nullptr);
+
+ const auto make_field = [](const std::string& name, int32_t field_id,
+ TPrimitiveType::type primitive_type, bool
optional) {
+ auto field = std::make_shared<schema::external::TField>();
+ field->__set_name(name);
+ field->__set_id(field_id);
+ field->__set_is_optional(optional);
+ TColumnType type;
+ type.__set_type(primitive_type);
+ field->__set_type(type);
+ schema::external::TFieldPtr field_ptr;
+ field_ptr.__set_field_ptr(std::move(field));
+ return field_ptr;
+ };
+ schema::external::TStructField root;
+ root.__set_fields({make_field("new_new_id", 1, TPrimitiveType::INT, false),
+ make_field("new_name", 2, TPrimitiveType::STRING, true),
+ make_field("data", 3, TPrimitiveType::STRING, false),
+ make_field("id", 4, TPrimitiveType::INT, true)});
+ schema::external::TSchema schema;
+ schema.__set_schema_id(4);
+ schema.__set_root_field(root);
+
+ TFileScanRangeParams scan_params;
+ scan_params.__set_file_type(TFileType::FILE_LOCAL);
+ scan_params.__set_format_type(TFileFormatType::FORMAT_PARQUET);
+
scan_params.__set_num_of_columns_from_file(cast_set<int32_t>(columns.size()));
+ scan_params.__set_current_schema_id(4);
+ scan_params.__set_history_schema_info({schema});
+
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+ for (size_t index = 0; index < columns.size(); ++index) {
+ const auto* slot = tuple_desc->slots()[index];
+ TFileScanSlotInfo slot_info;
+ slot_info.__set_slot_id(slot->id());
+ slot_info.__set_is_file_slot(true);
+ scan_params.required_slots.push_back(slot_info);
+ scan_params.default_value_of_src_slot.emplace(slot->id(), TExpr {});
+ scan_params.column_idxs.push_back(cast_set<int32_t>(index + 1));
+ scan_params.slot_name_to_schema_pos.emplace(slot->col_name(),
cast_set<int32_t>(index + 1));
+ }
+ scan_params.__isset.required_slots = true;
+ scan_params.__isset.default_value_of_src_slot = true;
+ scan_params.__isset.column_idxs = true;
+ scan_params.__isset.slot_name_to_schema_pos = true;
+
+ const std::string path =
+
"./docker/thirdparties/docker-compose/iceberg/scripts/preinstalled_data/iceberg/"
+ "equality_delete_par_1/data/"
+ "00000-0-bd4d0a30-cdf6-48d7-933d-91e860870eb9-00001.parquet";
+ io::FileReaderSPtr file_reader;
+ ASSERT_TRUE(io::global_local_filesystem()->open_file(path,
&file_reader).ok());
+ TFileRangeDesc range;
+ range.__set_path(path);
+ range.__set_start_offset(0);
+ range.__set_size(file_reader->size());
+ range.__set_file_size(file_reader->size());
+ range.__set_format_type(TFileFormatType::FORMAT_PARQUET);
+ TTableFormatFileDesc table_format;
+ table_format.__set_table_format_type("iceberg");
+ table_format.__set_iceberg_params(TIcebergFileDesc {});
+ range.__set_table_format_params(table_format);
+
+ RuntimeState runtime_state {TQueryOptions(), TQueryGlobals()};
+ runtime_state.set_desc_tbl(desc_tbl);
+ std::unordered_map<std::string, int> colname_to_slot_id;
+ Block block;
+ for (const auto* slot : tuple_desc->slots()) {
+ colname_to_slot_id.emplace(slot->col_name(), slot->id());
+ block.insert({slot->get_empty_mutable_column(), slot->type(),
slot->col_name()});
+ }
+ RuntimeProfile profile("ExternalRowIDFetcher");
+ auto scanner = FileScanner::create_unique(&runtime_state, &profile,
&scan_params,
+ &colname_to_slot_id, tuple_desc);
+ ASSERT_TRUE(scanner->prepare_for_read_lines(range).ok());
+ ExternalFileMappingInfo external_info(0, range, false);
+ int64_t init_reader_ms = 0;
+ int64_t get_block_ms = 0;
+ const auto status = scanner->read_lines_from_range(range, {0}, &block,
external_info,
+ &init_reader_ms,
&get_block_ms);
+ ASSERT_TRUE(status.ok()) << status;
+ ASSERT_EQ(block.rows(), 1);
+ EXPECT_EQ(block.get_by_position(block.get_position_by_name("new_name"))
+ .column->get_data_at(0)
+ .to_string(),
+ "bob");
+ EXPECT_EQ(block.get_by_position(block.get_position_by_name("data"))
+ .column->get_data_at(0)
+ .to_string(),
+ "e");
+
EXPECT_TRUE(block.get_by_position(block.get_position_by_name("id")).column->is_null_at(0));
+}
+
} // namespace doris
diff --git a/be/test/format/transformer/vorc_transformer_test.cpp
b/be/test/format/transformer/vorc_transformer_test.cpp
index 5eeeef7fa45..a4af867cd33 100644
--- a/be/test/format/transformer/vorc_transformer_test.cpp
+++ b/be/test/format/transformer/vorc_transformer_test.cpp
@@ -631,7 +631,83 @@ TEST_F(VOrcTransformerTest,
SkipsInvalidBinaryChildrenHiddenByNullableCollection
block.insert({std::move(nullable_array), nullable_array_type,
"uuid_array"});
block.insert({std::move(nullable_map), nullable_map_type, "binary_map"});
ASSERT_TRUE(transformer.write(block).ok());
+
+ auto make_visible_block = [&](const std::string& array_uuid, const
std::string& map_uuid,
+ const std::string& map_fixed) {
+ auto visible_array_elements = ColumnString::create();
+ visible_array_elements->insert_data(array_uuid.data(),
array_uuid.size());
+ auto visible_array_offsets = ColumnArray::ColumnOffsets::create();
+ visible_array_offsets->get_data().push_back(1);
+ auto visible_array = ColumnNullable::create(
+
ColumnArray::create(ColumnNullable::create(std::move(visible_array_elements),
+
ColumnUInt8::create(1, 0)),
+ std::move(visible_array_offsets)),
+ ColumnUInt8::create(1, 0));
+
+ auto visible_map_keys = ColumnString::create();
+ visible_map_keys->insert_data(map_uuid.data(), map_uuid.size());
+ auto visible_map_values = ColumnString::create();
+ visible_map_values->insert_data(map_fixed.data(), map_fixed.size());
+ auto visible_map_offsets = ColumnArray::ColumnOffsets::create();
+ visible_map_offsets->get_data().push_back(1);
+ auto visible_map = ColumnNullable::create(
+ ColumnMap::create(std::move(visible_map_keys),
std::move(visible_map_values),
+ std::move(visible_map_offsets)),
+ ColumnUInt8::create(1, 0));
+
+ Block visible_block;
+ visible_block.insert({std::move(visible_array), nullable_array_type,
"uuid_array"});
+ visible_block.insert({std::move(visible_map), nullable_map_type,
"binary_map"});
+ return visible_block;
+ };
+
+ const std::string valid_uuid = "00112233-4455-6677-8899-aabbccddeeff";
+ Block invalid_uuid_block = make_visible_block("invalid-visible-uuid",
valid_uuid, "ABCD");
+ Status invalid_uuid = transformer.write(invalid_uuid_block);
+ ASSERT_FALSE(invalid_uuid.ok());
+ EXPECT_NE(invalid_uuid.to_string().find("Invalid UUID string length"),
std::string::npos);
+
+ Block invalid_fixed_block = make_visible_block(valid_uuid, valid_uuid,
"BAD");
+ Status invalid_fixed = transformer.write(invalid_fixed_block);
+ ASSERT_FALSE(invalid_fixed.ok());
+ EXPECT_NE(invalid_fixed.to_string().find("FIXED[4]"), std::string::npos);
ASSERT_TRUE(transformer.close().ok());
+
+ io::FileReaderSPtr file_reader;
+ ASSERT_TRUE(_fs->open_file(_file_path, &file_reader).ok());
+ auto input_stream = std::make_unique<ORCFileInputStream>(
+ _file_path, file_reader, nullptr, nullptr, 8L * 1024L * 1024L, 1L
* 1024L * 1024L);
+ auto reader = orc::createReader(std::move(input_stream),
orc::ReaderOptions());
+ auto row_reader = reader->createRowReader();
+ auto row_batch = row_reader->createRowBatch(2);
+ ASSERT_TRUE(row_reader->next(*row_batch));
+ const auto& root = assert_cast<const orc::StructVectorBatch&>(*row_batch);
+ ASSERT_EQ(root.numElements, 2);
+ const auto& array_batch = assert_cast<const
orc::ListVectorBatch&>(*root.fields[0]);
+ const auto& array_values = assert_cast<const
orc::StringVectorBatch&>(*array_batch.elements);
+ const auto& map_batch = assert_cast<const
orc::MapVectorBatch&>(*root.fields[1]);
+ const auto& map_keys_batch = assert_cast<const
orc::StringVectorBatch&>(*map_batch.keys);
+ const auto& map_values_batch = assert_cast<const
orc::StringVectorBatch&>(*map_batch.elements);
+ ASSERT_TRUE(array_batch.hasNulls);
+ EXPECT_FALSE(array_batch.notNull[0]);
+ EXPECT_TRUE(array_batch.notNull[1]);
+ EXPECT_EQ(array_batch.offsets[0], 0);
+ EXPECT_EQ(array_batch.offsets[1], 0);
+ EXPECT_EQ(array_batch.offsets[2], 1);
+ ASSERT_TRUE(map_batch.hasNulls);
+ EXPECT_FALSE(map_batch.notNull[0]);
+ EXPECT_TRUE(map_batch.notNull[1]);
+ EXPECT_EQ(map_batch.offsets[0], 0);
+ EXPECT_EQ(map_batch.offsets[1], 0);
+ EXPECT_EQ(map_batch.offsets[2], 1);
+
+ const std::array<uint8_t, 16> expected_uuid = {0x00, 0x11, 0x22, 0x33,
0x44, 0x55, 0x66, 0x77,
+ 0x88, 0x99, 0xaa, 0xbb,
0xcc, 0xdd, 0xee, 0xff};
+ ASSERT_EQ(array_values.length[0], expected_uuid.size());
+ EXPECT_EQ(0, std::memcmp(array_values.data[0], expected_uuid.data(),
expected_uuid.size()));
+ ASSERT_EQ(map_keys_batch.length[0], expected_uuid.size());
+ EXPECT_EQ(0, std::memcmp(map_keys_batch.data[0], expected_uuid.data(),
expected_uuid.size()));
+ EXPECT_EQ(std::string_view(map_values_batch.data[0],
map_values_batch.length[0]), "ABCD");
}
TEST_F(VOrcTransformerTest,
DetectsOnlyFieldsThatNeedIcebergBinaryNormalization) {
diff --git a/be/test/format_v2/table/iceberg_reader_test.cpp
b/be/test/format_v2/table/iceberg_reader_test.cpp
index adcecd1c3ad..d905b8cb726 100644
--- a/be/test/format_v2/table/iceberg_reader_test.cpp
+++ b/be/test/format_v2/table/iceberg_reader_test.cpp
@@ -4440,6 +4440,78 @@ TEST(IcebergV2ReaderTest,
IcebergMissingWholeStructEqualityKeyMaterializesDefaul
}
}
+TEST(IcebergV2ReaderTest,
IcebergProjectedStructDoesNotReuseReaddedEqualityKeyByName) {
+ 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_missing_reused_equality_key_" +
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_equality_parquet_file(file_path, {1, 2, 3}, {7, 8, 9},
+ {false, false, false}, true,
"k", 4);
+ write_nested_equality_parquet_file(delete_file_path, {}, {7},
{false}, true, "k", 3);
+ } else {
+ write_nested_equality_orc_file(file_path, {1, 2, 3}, {7, 8, 9},
{false, false, false},
+ "k", 4);
+ write_nested_equality_orc_file(delete_file_path, {}, {7}, {false},
"k", 3);
+ }
+
+ auto payload = external_struct_schema_field(
+ "payload", 1,
+ {external_schema_field("k", 4, {}, std::nullopt,
+
external_primitive_type(TPrimitiveType::INT), false, true),
+ external_schema_field("k", 3, {}, std::nullopt,
+
external_primitive_type(TPrimitiveType::INT), false, true)},
+ true);
+ std::vector<schema::external::TSchema> history = {
+ external_schema(-1, {external_schema_field("id", 0),
std::move(payload)})};
+
+ const auto int_type = std::make_shared<DataTypeInt32>();
+ std::vector<ColumnDefinition> projected_columns;
+ projected_columns.push_back(make_table_column(0, "id", int_type));
+ auto current_key = make_table_column(4, "k", int_type);
+ auto payload_type = std::make_shared<DataTypeStruct>(DataTypes
{int_type}, Strings {"k"});
+ auto projected_payload = make_table_column(1, "payload", payload_type);
+ projected_payload.children = {std::move(current_key)};
+ projected_columns.push_back(std::move(projected_payload));
+ 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(-1);
+ scan_params.__set_history_schema_info(std::move(history));
+
+ 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, {3},
thrift_file_format)}, 3));
+ ASSERT_TRUE(reader.prepare_split(split_options).ok());
+ EXPECT_EQ(read_iceberg_ids(&reader, projected_columns),
std::vector<int32_t>({1, 2, 3}));
+ ASSERT_TRUE(reader.close().ok());
+ std::filesystem::remove_all(test_dir);
+ };
+
+ for (const auto file_format : {FileFormat::PARQUET, FileFormat::ORC}) {
+ run_case(file_format);
+ }
+}
+
// A schema carrier may contain a dropped and a re-added child with the same
name. Keep the
// Parquet/ORC setup identical so both readers prove that equality-key
reconstruction follows the
// exact field-id path instead of attaching both same-name children to one
synthetic struct slot.
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java
index 4b28492c4ff..877ecfb65e1 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java
@@ -48,6 +48,7 @@ import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.io.FileNotFoundException;
+import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
@@ -56,6 +57,7 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
+import java.util.UUID;
/**
* Characterization tests for {@link IcebergConnectorMetadata}, pinning the
read-path behavior after
@@ -907,6 +909,39 @@ public class IcebergConnectorMetadataTest {
Assertions.assertTrue(name.isSourceNullable());
}
+ @Test
+ public void getTableSchemaLeavesIcebergDefaultsOutOfCatalogColumns() {
+ // Initial defaults reach BE through the scan dictionary and write
defaults are pinned per write, so the
+ // cached catalog column carries neither: DESCRIBE and SHOW CREATE
must not expose a possibly stale value.
+ RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
+ Schema defaultsSchema = new Schema(
+
Types.NestedField.optional("added_column").withId(1).ofType(Types.IntegerType.get())
+ .withInitialDefault(7).withWriteDefault(9).build(),
+ Types.NestedField.optional("added_timestamp").withId(2)
+ .ofType(Types.TimestampType.withoutZone())
+ .withInitialDefault(1_704_067_200_123_456L).build(),
+ Types.NestedField.optional("added_timestamptz").withId(3)
+ .ofType(Types.TimestampType.withZone())
+ .withInitialDefault(1_704_067_200_123_456L).build(),
+
Types.NestedField.optional("added_uuid").withId(4).ofType(Types.UUIDType.get())
+
.withInitialDefault(UUID.fromString("00000000-0000-0000-0000-000000000000")).build(),
+
Types.NestedField.optional("added_binary").withId(5).ofType(Types.BinaryType.get())
+ .withInitialDefault(ByteBuffer.wrap(new byte[] {0, 1,
2, (byte) 0xFF})).build(),
+
Types.NestedField.optional("added_fixed").withId(6).ofType(Types.FixedType.ofLength(4))
+ .withInitialDefault(ByteBuffer.wrap(new byte[] {3, 2,
1, 0})).build());
+ ops.table = new FakeIcebergTable(
+ "t1", defaultsSchema, PartitionSpec.unpartitioned(),
+ "s3://bucket/db1/t1",
Collections.singletonMap("format-version", "3"));
+
+ List<ConnectorColumn> columns =
+ metadataWith(ops).getTableSchema(null, new
IcebergTableHandle("db1", "t1")).getColumns();
+
+ for (ConnectorColumn column : columns.subList(0,
defaultsSchema.columns().size())) {
+ Assertions.assertNull(column.getDefaultValue(), column.getName());
+ Assertions.assertNull(column.getDefaultValueSql(),
column.getName());
+ }
+ }
+
@Test
public void getTableSchemaCopiesTablePropertiesAndLocation() {
RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java
index c0b42a634dc..acc8400b2bc 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTransactionTest.java
@@ -48,6 +48,7 @@ import org.apache.iceberg.PartitionSpec;
import org.apache.iceberg.Schema;
import org.apache.iceberg.Snapshot;
import org.apache.iceberg.Table;
+import org.apache.iceberg.TableOperations;
import org.apache.iceberg.Transaction;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.TableIdentifier;
@@ -67,6 +68,7 @@ import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.nio.ByteBuffer;
import java.util.ArrayList;
@@ -78,8 +80,10 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
+import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
@@ -284,6 +288,59 @@ public class IcebergConnectorTransactionTest {
return catalog.loadTable(id).currentSnapshot();
}
+ /** Writer metadata pinned from {@code table} at bind time, as the write
planner hands it to beginWrite. */
+ private static IcebergWriteContext pinnedCtx(WriteOperation operation,
Table table) {
+ return new IcebergWriteContext(operation, operation ==
WriteOperation.OVERWRITE, Collections.emptyMap(),
+ Optional.empty(), -1L, false,
+ IcebergWriteSchemaContext.create(table, table.name(),
Optional.empty(), false, false));
+ }
+
+ /** {@code table} behind operations that count metadata refreshes; opening
an SDK transaction refreshes once. */
+ private static Table countingRefreshes(Table table, AtomicInteger
refreshes) {
+ TableOperations operations = ((HasTableOperations) table).operations();
+ return new BaseTable((TableOperations)
Proxy.newProxyInstance(TableOperations.class.getClassLoader(),
+ new Class<?>[] {TableOperations.class}, (proxy, method, args)
-> {
+ if ("refresh".equals(method.getName())) {
+ refreshes.incrementAndGet();
+ }
+ return invokeUnwrapped(operations, method, args);
+ }), table.name());
+ }
+
+ /**
+ * A write table over {@code operations} that runs {@code
concurrentChange} once, while the SDK transaction stages
+ * its snapshot: after the commit-time preflight, before the final
metadata swap replays the staged update. The
+ * change may repoint {@code operations}, as a catalog that resolves the
name to a replacement table does.
+ */
+ private static Table stagingTable(String name,
AtomicReference<TableOperations> operations,
+ Runnable concurrentChange) {
+ return new BaseTable(stagingOperations(operations, concurrentChange,
new AtomicBoolean()), name);
+ }
+
+ private static TableOperations
stagingOperations(AtomicReference<TableOperations> operations,
+ Runnable concurrentChange, AtomicBoolean changed) {
+ return (TableOperations)
Proxy.newProxyInstance(TableOperations.class.getClassLoader(),
+ new Class<?>[] {TableOperations.class}, (proxy, method, args)
-> {
+ if ("temp".equals(method.getName())) {
+ // An SDK transaction stages its pending updates on
temporary operations.
+ TableOperations temp = (TableOperations)
invokeUnwrapped(operations.get(), method, args);
+ return stagingOperations(new AtomicReference<>(temp),
concurrentChange, changed);
+ }
+ if ("newSnapshotId".equals(method.getName()) &&
changed.compareAndSet(false, true)) {
+ concurrentChange.run();
+ }
+ return invokeUnwrapped(operations.get(), method, args);
+ });
+ }
+
+ private static Object invokeUnwrapped(Object target, Method method,
Object[] args) throws Throwable {
+ try {
+ return method.invoke(target, args);
+ } catch (InvocationTargetException e) {
+ throw e.getCause();
+ }
+ }
+
// ─────────────────── addCommitData: 14-field TBinaryProtocol round-trip
(T03, regression) ───────────────────
@Test
@@ -662,6 +719,43 @@ public class IcebergConnectorTransactionTest {
"the append must land on branch b1, not on main");
}
+ @Test
+ public void insertCommitUsesStatementPinnedWriterMetadata() {
+ // Files written against the pinned writer metadata are committed with
that spec, sort order and format,
+ // even though a spec, sort-order and format change the fences admit
landed before beginWrite.
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "t1");
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "age", Types.IntegerType.get()));
+ Table table = catalog.createTable(id, schema,
PartitionSpec.builderFor(schema).identity("id").build(),
+ props("format-version", "2", "write.format.default", "orc"));
+ table.replaceSortOrder().asc("age").commit();
+ int pinnedSpecId = table.spec().specId();
+ int pinnedSortOrderId = table.sortOrder().orderId();
+ IcebergWriteContext ctx = pinnedCtx(WriteOperation.INSERT, table);
+ table.updateSpec().removeField("id").commit();
+ table.replaceSortOrder().desc("id").commit();
+ table.updateProperties().set("write.format.default",
"parquet").commit();
+ IcebergConnectorTransaction txn = txnFor(opsReturning(table), new
RecordingConnectorContext());
+
+ txn.beginWrite(SESSION, "db1", "t1", ctx);
+ txn.addCommitData(commitBytes(
+ dataFileItem("s3://b/db1/t1/id=7/f1.orc", 1L, 1024L,
Collections.singletonList("7"))));
+ txn.commit();
+
+ Table committed = catalog.loadTable(id);
+ List<DataFile> added = new ArrayList<>();
+
committed.currentSnapshot().addedDataFiles(committed.io()).forEach(added::add);
+ Assertions.assertEquals(1, added.size());
+ DataFile file = added.get(0);
+ Assertions.assertNotEquals(pinnedSpecId, committed.spec().specId(),
"the live spec must have moved on");
+ Assertions.assertEquals(pinnedSpecId, file.specId());
+ Assertions.assertEquals(Integer.valueOf(7), file.partition().get(0,
Integer.class));
+ Assertions.assertEquals(Integer.valueOf(pinnedSortOrderId),
file.sortOrderId());
+ Assertions.assertEquals(FileFormat.ORC, file.format());
+ }
+
@Test
public void overwriteDynamicReplacesPartitions() {
InMemoryCatalog catalog = freshCatalog();
@@ -1144,6 +1238,57 @@ public class IcebergConnectorTransactionTest {
"a stale static overwrite must fail at begin before it can
degrade to an always-true filter");
}
+ @Test
+ public void
staticOverwriteRejectsConcurrentCurrentSpecReplacementBeforeOpening() {
+ // The pinned spec is still retained with its definition, but it is no
longer the current spec: an overwrite
+ // must fail before an SDK transaction opens instead of replacing data
through a superseded spec.
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "t1");
+ Table table = catalog.createTable(id, PART_SCHEMA,
+
PartitionSpec.builderFor(PART_SCHEMA).identity("region").build(),
props("format-version", "2"));
+ IcebergWriteContext boundContext = overwriteStaticCtx(table,
Collections.singletonMap("region", "us"));
+ table.updateSpec().removeField("region").addField("id").commit();
+ AtomicInteger refreshes = new AtomicInteger();
+ IcebergConnectorTransaction txn = txnFor(
+ opsReturning(countingRefreshes(catalog.loadTable(id),
refreshes)), new RecordingConnectorContext());
+
+ DorisConnectorException ex =
Assertions.assertThrows(DorisConnectorException.class,
+ () -> txn.beginWrite(SESSION, "db1", "t1", boundContext));
+ Assertions.assertTrue(ex.getMessage().contains("current partition spec
changed during overwrite planning"),
+ ex.getMessage());
+ Assertions.assertTrue(ex.getMessage().contains("retry the statement"),
ex.getMessage());
+ Assertions.assertEquals(0, refreshes.get(), "the overwrite must be
rejected before a transaction opens");
+ Assertions.assertNull(reloadCurrentSnapshot(catalog, id));
+ }
+
+ @Test
+ public void
dynamicOverwriteRejectsPartitionedToUnpartitionedSpecDriftAtCommit() {
+ // Dropping the last partition field after beginWrite must fail the
overwrite at commit, whether or not the
+ // statement wrote files; neither path may replace or clear data
through the superseded partitioned spec.
+ assertDynamicOverwriteRejectsUnpartitionedSpecDrift(true);
+ assertDynamicOverwriteRejectsUnpartitionedSpecDrift(false);
+ }
+
+ private static void
assertDynamicOverwriteRejectsUnpartitionedSpecDrift(boolean writesFiles) {
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "t1");
+ Table table = catalog.createTable(id, PART_SCHEMA,
+
PartitionSpec.builderFor(PART_SCHEMA).identity("region").build(),
props("format-version", "2"));
+ IcebergConnectorTransaction txn = txnFor(opsReturning(table), new
RecordingConnectorContext());
+ txn.beginWrite(SESSION, "db1", "t1",
pinnedCtx(WriteOperation.OVERWRITE, table));
+ if (writesFiles) {
+ txn.addCommitData(commitBytes(dataFileItem(
+ "s3://b/db1/t1/region=us/f1.parquet", 1L, 1024L,
Collections.singletonList("us"))));
+ }
+
+ table.updateSpec().removeField("region").commit();
+
+ DorisConnectorException ex =
Assertions.assertThrows(DorisConnectorException.class, txn::commit);
+ Assertions.assertTrue(ex.getMessage().contains("current partition spec
changed"), ex.getMessage());
+ Assertions.assertTrue(ex.getMessage().contains("retry the statement"),
ex.getMessage());
+ Assertions.assertNull(reloadCurrentSnapshot(catalog, id),
"writesFiles=" + writesFiles);
+ }
+
@Test
public void overwriteStaticNullBinaryKeyReplacesOnlyTheNullPartition() {
// A NULL static key must become key IS NULL for full and hybrid
specs; matching bytes or text
@@ -1386,6 +1531,139 @@ public class IcebergConnectorTransactionTest {
"a replacement exposed by newTransaction refresh must not
become the write baseline");
}
+ @Test
+ public void
beginWriteRejectsPinnedSchemaSkewBeforeOpeningInsertOrMergeTransaction() {
+ // A schema commit between binding and beginWrite is rejected from the
loaded metadata, before an SDK
+ // transaction refreshes the table and could become the conflict
baseline of the stale plan.
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "t1");
+ Table table = catalog.createTable(id, SCHEMA,
PartitionSpec.unpartitioned(), props("format-version", "2"));
+ List<IcebergWriteContext> contexts = Arrays.asList(
+ pinnedCtx(WriteOperation.INSERT, table),
pinnedCtx(WriteOperation.MERGE, table));
+ table.updateSchema().addColumn("added_after_binding",
Types.IntegerType.get()).commit();
+
+ for (IcebergWriteContext ctx : contexts) {
+ AtomicInteger refreshes = new AtomicInteger();
+ IcebergConnectorTransaction txn = txnFor(
+ opsReturning(countingRefreshes(catalog.loadTable(id),
refreshes)),
+ new RecordingConnectorContext());
+ DorisConnectorException ex =
Assertions.assertThrows(DorisConnectorException.class,
+ () -> txn.beginWrite(SESSION, "db1", "t1", ctx));
+ Assertions.assertTrue(ex.getMessage().contains("schema changed
during write planning"), ex.getMessage());
+ Assertions.assertTrue(ex.getMessage().contains("retry the
statement"), ex.getMessage());
+ Assertions.assertEquals(0, refreshes.get(),
ctx.getWriteOperation() + " opened a transaction");
+ }
+ Assertions.assertNull(reloadCurrentSnapshot(catalog, id));
+ }
+
+ @Test
+ public void beginWriteRejectsRecreatedTableForInsertOverwriteAndMerge() {
+ // A same-name DROP/CREATE after binding fails INSERT, OVERWRITE and
MERGE on the pinned table UUID before a
+ // transaction opens, so the replacement never becomes the baseline
for rows bound against the old table.
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "t1");
+ Table original = catalog.createTable(id, SCHEMA,
PartitionSpec.unpartitioned(), props("format-version", "2"));
+ List<IcebergWriteContext> contexts =
Arrays.asList(pinnedCtx(WriteOperation.INSERT, original),
+ pinnedCtx(WriteOperation.OVERWRITE, original),
pinnedCtx(WriteOperation.MERGE, original));
+ catalog.dropTable(id, false);
+ Table replacement = catalog.createTable(id, SCHEMA,
PartitionSpec.unpartitioned(),
+ props("format-version", "2"));
+
+ for (IcebergWriteContext ctx : contexts) {
+ AtomicInteger refreshes = new AtomicInteger();
+ IcebergConnectorTransaction txn = txnFor(
+ opsReturning(countingRefreshes(replacement, refreshes)),
new RecordingConnectorContext());
+ DorisConnectorException ex =
Assertions.assertThrows(DorisConnectorException.class,
+ () -> txn.beginWrite(SESSION, "db1", "t1", ctx));
+ Assertions.assertTrue(ex.getMessage().contains("identity
changed"), ex.getMessage());
+ Assertions.assertEquals(0, refreshes.get(),
ctx.getWriteOperation() + " opened a transaction");
+ }
+ Assertions.assertNull(reloadCurrentSnapshot(catalog, id));
+ }
+
+ @Test
+ public void
commitReplayRejectsRequiredSchemaChangeStagedBeforeTheMetadataSwap() {
+ // A required column added while the commit stages its snapshot passes
the commit preflight. The final
+ // metadata swap re-validates the replayed update and must reject it
rather than publish files without it.
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "t1");
+ Table table = catalog.createTable(id, SCHEMA,
PartitionSpec.unpartitioned(), props("format-version", "2"));
+ Table writeTable = stagingTable(table.name(),
+ new AtomicReference<>(((HasTableOperations)
table).operations()),
+ () -> addRequiredColumn(catalog, id,
"required_after_staging"));
+ IcebergConnectorTransaction txn = txnFor(opsReturning(writeTable), new
RecordingConnectorContext());
+
+ txn.beginWrite(SESSION, "db1", "t1", pinnedCtx(WriteOperation.INSERT,
table));
+ txn.addCommitData(commitBytes(dataFileItem("s3://b/db1/t1/f1.parquet",
1L, 1024L)));
+ DorisConnectorException ex =
Assertions.assertThrows(DorisConnectorException.class, txn::commit);
+
+ assertRejectedAfterStagedSchemaChange(catalog, id, ex);
+ }
+
+ @Test
+ public void
mergeCommitReplayRejectsRequiredSchemaChangeStagedBeforeTheMetadataSwap() {
+ // The same metadata-swap fence covers the RowDelta a MERGE stages for
its data and position-delete files.
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "t1");
+ Table table = catalog.createTable(id, SCHEMA,
PartitionSpec.unpartitioned(), props("format-version", "2"));
+ Table writeTable = stagingTable(table.name(),
+ new AtomicReference<>(((HasTableOperations)
table).operations()),
+ () -> addRequiredColumn(catalog, id,
"required_after_staging"));
+ IcebergConnectorTransaction txn = txnFor(opsReturning(writeTable), new
RecordingConnectorContext());
+
+ txn.beginWrite(SESSION, "db1", "t1", pinnedCtx(WriteOperation.MERGE,
table));
+
txn.addCommitData(commitBytes(dataFileItem("s3://b/db1/t1/new.parquet", 2L,
1024L)));
+ txn.addCommitData(commitBytes(
+ positionDeleteItem("s3://b/db1/t1/del.parquet", 2L,
"s3://b/db1/t1/old.parquet")));
+ DorisConnectorException ex =
Assertions.assertThrows(DorisConnectorException.class, txn::commit);
+
+ assertRejectedAfterStagedSchemaChange(catalog, id, ex);
+ }
+
+ private static void addRequiredColumn(InMemoryCatalog catalog,
TableIdentifier id, String column) {
+ catalog.loadTable(id).updateSchema().allowIncompatibleChanges()
+ .addRequiredColumn(column, Types.IntegerType.get()).commit();
+ }
+
+ private static void assertRejectedAfterStagedSchemaChange(InMemoryCatalog
catalog, TableIdentifier id,
+ DorisConnectorException ex) {
+ Assertions.assertTrue(ex.getMessage().contains("schema changed during
write planning"), ex.getMessage());
+ Assertions.assertTrue(ex.getMessage().contains("retry the statement"),
ex.getMessage());
+ Table current = catalog.loadTable(id);
+
Assertions.assertNotNull(current.schema().findField("required_after_staging"),
+ "the schema change must land while the commit stages its
snapshot");
+ Assertions.assertNull(current.currentSnapshot(), "the staged update
must not be published");
+ }
+
+ @Test
+ public void commitRejectsTableReplacementStagedBeforeTheMetadataSwap() {
+ // A same-name DROP/CREATE while the commit stages its snapshot must
not let the staged files land in the
+ // replacement: the metadata swap re-reads the current metadata and
fences the pinned table identity.
+ InMemoryCatalog catalog = freshCatalog();
+ TableIdentifier id = TableIdentifier.of("db1", "t1");
+ Table table = catalog.createTable(id, SCHEMA,
PartitionSpec.unpartitioned(), props("format-version", "2"));
+ UUID originalUuid = table.uuid();
+ AtomicReference<TableOperations> operations =
+ new AtomicReference<>(((HasTableOperations)
table).operations());
+ AtomicReference<Table> replacement = new AtomicReference<>();
+ Table writeTable = stagingTable(table.name(), operations, () -> {
+ catalog.dropTable(id, false);
+ replacement.set(catalog.createTable(id, SCHEMA,
PartitionSpec.unpartitioned(),
+ props("format-version", "2")));
+ // A catalog that does not compare UUIDs on refresh (e.g. a Hadoop
catalog) now resolves the replacement.
+ operations.set(((HasTableOperations)
replacement.get()).operations());
+ });
+ IcebergConnectorTransaction txn = txnFor(opsReturning(writeTable), new
RecordingConnectorContext());
+
+ txn.beginWrite(SESSION, "db1", "t1", pinnedCtx(WriteOperation.INSERT,
table));
+ txn.addCommitData(commitBytes(dataFileItem("s3://b/db1/t1/f1.parquet",
1L, 1024L)));
+ DorisConnectorException ex =
Assertions.assertThrows(DorisConnectorException.class, txn::commit);
+
+ Assertions.assertTrue(ex.getMessage().contains("identity changed"),
ex.getMessage());
+ Assertions.assertNotEquals(originalUuid, replacement.get().uuid());
+ Assertions.assertNull(reloadCurrentSnapshot(catalog, id), "the
replacement must not receive staged files");
+ }
+
// ─────────────────── commit-time conflict-detection validation suite
(T05) ───────────────────
@Test
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
index 5cbea55cfd4..b0a6866de3a 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
@@ -42,7 +42,9 @@ import org.apache.doris.thrift.TFileRangeDesc;
import org.apache.doris.thrift.TFileScanRangeParams;
import org.apache.doris.thrift.TIcebergDeleteFileDesc;
import org.apache.doris.thrift.TIcebergFileDesc;
+import org.apache.doris.thrift.TPrimitiveType;
import org.apache.doris.thrift.TTableFormatFileDesc;
+import org.apache.doris.thrift.schema.external.TField;
import org.apache.doris.thrift.schema.external.TFieldPtr;
import com.google.common.collect.ImmutableMap;
@@ -62,6 +64,7 @@ import org.apache.iceberg.PartitionData;
import org.apache.iceberg.PartitionSpec;
import org.apache.iceberg.Schema;
import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.SnapshotSummary;
import org.apache.iceberg.StructLike;
import org.apache.iceberg.SupportsDistributedScanPlanning;
import org.apache.iceberg.Table;
@@ -79,6 +82,10 @@ import org.apache.iceberg.io.InputFile;
import org.apache.iceberg.io.OutputFile;
import org.apache.iceberg.io.StorageCredential;
import org.apache.iceberg.io.SupportsStorageCredentials;
+import org.apache.iceberg.mapping.MappedField;
+import org.apache.iceberg.mapping.MappedFields;
+import org.apache.iceberg.mapping.NameMapping;
+import org.apache.iceberg.mapping.NameMappingParser;
import org.apache.iceberg.metrics.ScanReport;
import org.apache.iceberg.types.Conversions;
import org.apache.iceberg.types.Types;
@@ -109,6 +116,7 @@ import java.util.OptionalLong;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.UnaryOperator;
+import java.util.stream.Collectors;
/**
* Tests for {@link IcebergScanPlanProvider}. T01 pinned the capability
constants + that {@code planScan}
@@ -190,6 +198,16 @@ public class IcebergScanPlanProviderTest {
(proxy, method, args) -> method.getName().equals("io") ?
fileIO : invoke(method, table, args));
}
+ private static Table tableWithSchemas(Table table, Schema... schemas) {
+ Map<Integer, Schema> schemasById = new HashMap<>();
+ for (Schema schema : schemas) {
+ schemasById.put(schema.schemaId(), schema);
+ }
+ return (Table) Proxy.newProxyInstance(Table.class.getClassLoader(),
new Class<?>[] {Table.class},
+ (proxy, method, args) -> method.getName().equals("schemas")
+ ? schemasById : invoke(method, table, args));
+ }
+
private static Table serverPlannedTable(Table table, AtomicBoolean
localMetadataAccessed) {
return (Table) Proxy.newProxyInstance(Table.class.getClassLoader(),
new Class<?>[] {Table.class,
SupportsDistributedScanPlanning.class},
@@ -290,6 +308,25 @@ public class IcebergScanPlanProviderTest {
.orElseThrow(() -> new AssertionError("no range ending in " +
suffix));
}
+ /** Round-trip the scan properties onto real params and return the
top-level fields of the dictionary. */
+ private static List<TFieldPtr> dictRootFields(IcebergScanPlanProvider
provider, Map<String, String> props) {
+ TFileScanRangeParams params = new TFileScanRangeParams();
+ provider.populateScanLevelParams(params, props);
+ return params.getHistorySchemaInfo().get(0).getRootField().getFields();
+ }
+
+ private static TField dictFieldById(List<TFieldPtr> fields, int fieldId) {
+ return fields.stream().map(TFieldPtr::getFieldPtr).filter(field ->
field.getId() == fieldId)
+ .findFirst().orElseThrow(() -> new AssertionError("no
dictionary field with ID " + fieldId));
+ }
+
+ /** The rolling-upgrade fence the scan properties carry for the projected
columns, or null when absent. */
+ private static String requiredBackendSemantics(IcebergScanPlanProvider
provider, IcebergTableHandle handle,
+ ConnectorColumnHandle... columns) {
+ return provider.getScanNodeProperties(null, handle,
Arrays.asList(columns), Optional.empty())
+ .get(ScanNodePropertyKeys.REQUIRED_CURRENT_BACKEND_SEMANTICS);
+ }
+
private static RecordingIcebergCatalogOps opsReturning(Table table) {
RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
ops.table = table;
@@ -1885,6 +1922,130 @@ public class IcebergScanPlanProviderTest {
"a dropped field still referenced by a live equality delete
must remain resolvable by ID");
}
+ @Test
+ public void equalityCarrierKeepsLatestDefinitionOfDroppedEqualityKey()
throws Exception {
+ // BE fills an equality key that an old data file lacks from the key's
carrier entry, so a dropped key must
+ // resolve to its latest historical definition: its last name, its
optionality and its initial default.
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+
Types.NestedField.optional("k").withId(2).ofType(Types.IntegerType.get())
+ .withInitialDefault(7).build());
+ Table table = createTable("renamed_dropped_eq_key", schema,
PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "3"));
+ int keyId = table.schema().findField("k").fieldId();
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/renamed_dropped_eq_key/f1.parquet", 1024, null,
null)).commit();
+ table.newRowDelta().addDeletes(equalityDeleteFile(
+ "s3://b/db/renamed_dropped_eq_key/eq.parquet",
FileFormat.PARQUET, keyId)).commit();
+ table.updateSchema().renameColumn("k", "k2").commit();
+ table.updateSchema().deleteColumn("k2").commit();
+
+ IcebergScanPlanProvider provider = providerOver(table);
+ Map<String, String> props = provider.getScanNodeProperties(
+ null, new IcebergTableHandle("db1", "renamed_dropped_eq_key"),
+ Collections.singletonList(new IcebergColumnHandle("id",
table.schema().findField("id").fieldId())),
+ Optional.empty());
+
+ TField key = dictFieldById(dictRootFields(provider, props), keyId);
+ Assertions.assertEquals("k2", key.getName());
+ Assertions.assertTrue(key.isIsOptional());
+ Assertions.assertEquals("7", key.getInitialDefaultValue());
+ }
+
+ @Test
+ public void
equalityCarrierRestoresDroppedNestedEqualityKeyInsideItsParent() throws
Exception {
+ // A dropped nested equality key must come back inside the struct that
still exists, after the current
+ // children and with its own initial default, so BE resolves the key's
full field-ID path.
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "payload", Types.StructType.of(
+ Types.NestedField.optional(3, "existing",
Types.IntegerType.get()),
+
Types.NestedField.optional("k").withId(4).ofType(Types.IntegerType.get())
+ .withInitialDefault(7).build())));
+ Table table = createTable("dropped_nested_eq_key", schema,
PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "3"));
+ int payloadId = table.schema().findField("payload").fieldId();
+ int existingId =
table.schema().findField("payload.existing").fieldId();
+ int keyId = table.schema().findField("payload.k").fieldId();
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/dropped_nested_eq_key/f1.parquet", 1024, null,
null)).commit();
+ table.newRowDelta().addDeletes(equalityDeleteFile(
+ "s3://b/db/dropped_nested_eq_key/eq.parquet",
FileFormat.PARQUET, keyId)).commit();
+ table.updateSchema().deleteColumn("payload.k").commit();
+
+ List<NestedField> carrier =
IcebergScanPlanProvider.schemaForPotentialEqualityDeletes(
+ table, table.newScan(), table.schema());
+ Assertions.assertEquals(2, carrier.size(), "the nested key must not
add a second payload column");
+ NestedField payload = carrier.get(1);
+ Assertions.assertEquals(payloadId, payload.fieldId());
+ Assertions.assertTrue(payload.isOptional());
+ Assertions.assertEquals(Arrays.asList(existingId, keyId),
payload.type().asStructType().fields()
+
.stream().map(NestedField::fieldId).collect(Collectors.toList()));
+
+ IcebergScanPlanProvider provider = providerOver(table);
+ Map<String, String> props = provider.getScanNodeProperties(
+ null, new IcebergTableHandle("db1", "dropped_nested_eq_key"),
+ Collections.singletonList(new IcebergColumnHandle("id",
table.schema().findField("id").fieldId())),
+ Optional.empty());
+ TField payloadField = dictFieldById(dictRootFields(provider, props),
payloadId);
+ TField key =
dictFieldById(payloadField.getNestedField().getStructField().getFields(),
keyId);
+ Assertions.assertEquals("7", key.getInitialDefaultValue());
+ }
+
+ @Test
+ public void
equalityCarrierKeepsDroppedNanosecondEqualityKeyAsTimestampNs() throws
Exception {
+ // Unzoned nanosecond timestamps are readable, so a dropped nanosecond
equality key stays in the carrier as
+ // TIMESTAMP_NS instead of the STRING placeholder that stands in for
types Doris cannot read.
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "equality_nanos",
Types.TimestampNanoType.withoutZone()));
+ Table table = createTable("dropped_nanos_eq_key", schema,
PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "3"));
+ int keyId = table.schema().findField("equality_nanos").fieldId();
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/dropped_nanos_eq_key/f1.parquet", 1024, null,
null)).commit();
+ table.newRowDelta().addDeletes(equalityDeleteFile(
+ "s3://b/db/dropped_nanos_eq_key/eq.parquet",
FileFormat.PARQUET, keyId)).commit();
+ table.updateSchema().deleteColumn("equality_nanos").commit();
+
+ IcebergScanPlanProvider provider = providerOver(table);
+ Map<String, String> props = provider.getScanNodeProperties(
+ null, new IcebergTableHandle("db1", "dropped_nanos_eq_key"),
+ Collections.singletonList(new IcebergColumnHandle("id",
table.schema().findField("id").fieldId())),
+ Optional.empty());
+
+ TField key = dictFieldById(dictRootFields(provider, props), keyId);
+ Assertions.assertEquals("equality_nanos", key.getName());
+ Assertions.assertEquals(TPrimitiveType.TIMESTAMP_NS,
key.getType().getType());
+ }
+
+ @Test
+ public void
equalityCarrierTakesOnlyReferencedFieldsFromAHistoricalSchema() throws
Exception {
+ // Only the historical fields that live equality deletes reference may
enter the carrier. A sibling
+ // dropped in the same change, here a TIME column Doris cannot read,
must stay out of it.
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "equality_key",
Types.IntegerType.get()),
+ Types.NestedField.optional(3, "dropped_time",
Types.TimeType.get()));
+ Table table = createTable("dropped_unreferenced_sibling", schema,
PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "2"));
+ int idId = table.schema().findField("id").fieldId();
+ int keyId = table.schema().findField("equality_key").fieldId();
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/dropped_unreferenced_sibling/f1.parquet", 1024,
null, null)).commit();
+ table.newRowDelta().addDeletes(equalityDeleteFile(
+ "s3://b/db/dropped_unreferenced_sibling/eq.parquet",
FileFormat.PARQUET, keyId)).commit();
+
table.updateSchema().deleteColumn("equality_key").deleteColumn("dropped_time").commit();
+
+ IcebergScanPlanProvider provider = providerOver(table);
+ Map<String, String> props = provider.getScanNodeProperties(
+ null, new IcebergTableHandle("db1",
"dropped_unreferenced_sibling"),
+ Collections.singletonList(new IcebergColumnHandle("id",
idId)), Optional.empty());
+
+ Assertions.assertEquals(Arrays.asList(idId, keyId),
dictRootFields(provider, props).stream()
+ .map(field ->
field.getFieldPtr().getId()).collect(Collectors.toList()));
+ }
+
@Test
public void equalityCarrierSizeDoesNotGrowWithUnrelatedSchemaHistory()
throws Exception {
Schema schema = new Schema(
@@ -2089,6 +2250,232 @@ public class IcebergScanPlanProviderTest {
"the upgrade fence must be conservative and bounded by schema
history, not snapshot count");
}
+ @Test
+ public void missingRequiredFieldFenceFollowsProjection() {
+ // Older files cannot contain a required field added later, and only
current backends reject such a file
+ // instead of reading NULL. The fence must therefore follow the
projection, nested paths included.
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "payload", Types.StructType.of(
+ Types.NestedField.optional(3, "existing",
Types.IntegerType.get()))));
+ Table table = createTable("required_projection_fence", schema,
PartitionSpec.unpartitioned());
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/required_projection_fence/old.parquet", 128, null,
null)).commit();
+ table.updateSchema().allowIncompatibleChanges()
+ .addRequiredColumn("required_added", Types.IntegerType.get())
+ .addRequiredColumn("payload", "required_nested",
Types.IntegerType.get())
+ .commit();
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/required_projection_fence/new.parquet", 128, null,
null)).commit();
+ Schema current = table.schema();
+ int payloadId = current.findField("payload").fieldId();
+ ConnectorColumnHandle payload = new IcebergColumnHandle("payload",
payloadId);
+ IcebergScanPlanProvider provider = providerOver(table);
+ IcebergTableHandle handle = new IcebergTableHandle("db1",
"required_projection_fence");
+
+ Assertions.assertNull(requiredBackendSemantics(provider, handle,
+ new IcebergColumnHandle("id",
current.findField("id").fieldId())));
+ Assertions.assertEquals("Current Iceberg scan semantics",
requiredBackendSemantics(provider, handle,
+ new IcebergColumnHandle("required_added",
current.findField("required_added").fieldId())));
+ Assertions.assertNull(requiredBackendSemantics(provider, handle,
payload.withProjectedFieldIds(
+ ImmutableSet.of(payloadId,
current.findField("payload.existing").fieldId()))));
+ Assertions.assertEquals("Current Iceberg scan semantics",
requiredBackendSemantics(provider, handle,
+ payload.withProjectedFieldIds(ImmutableSet.of(
+ payloadId,
current.findField("payload.required_nested").fieldId()))));
+ }
+
+ @Test
+ public void
requiredCollectionWrappersDoNotTriggerMissingRequiredFieldFence() {
+ // A list element or a map key/value has no value of its own without
its collection, so a required wrapper
+ // missing from an older schema never needs the fence; a required
struct field inside the element does.
+ Types.NestedField existing = Types.NestedField.optional(32,
"existing", Types.IntegerType.get());
+ Schema historicalSchema = new Schema(0, Arrays.asList(
+ Types.NestedField.optional(10, "items",
Types.ListType.ofOptional(90, Types.IntegerType.get())),
+ Types.NestedField.optional(20, "entries",
Types.MapType.ofOptional(
+ 91, 92, Types.StringType.get(),
Types.IntegerType.get())),
+ Types.NestedField.optional(30, "struct_items",
Types.ListType.ofOptional(
+ 31, Types.StructType.of(existing)))));
+ Schema scanSchema = new Schema(1, Arrays.asList(
+ Types.NestedField.optional(10, "items",
Types.ListType.ofRequired(11, Types.IntegerType.get())),
+ Types.NestedField.optional(20, "entries",
Types.MapType.ofRequired(
+ 21, 22, Types.StringType.get(),
Types.IntegerType.get())),
+ Types.NestedField.optional(30, "struct_items",
Types.ListType.ofOptional(
+ 31, Types.StructType.of(existing,
+ Types.NestedField.required(33,
"required_nested", Types.IntegerType.get()))))));
+ Table snapshotTable = createTable("required_collection_wrappers",
SCHEMA, PartitionSpec.unpartitioned());
+ snapshotTable.newAppend().appendFile(dataFile(snapshotTable.spec(),
+ "s3://b/db/required_collection_wrappers/f1.parquet", 128,
null, null)).commit();
+ Table table = tableWithSchemas(snapshotTable, historicalSchema,
scanSchema);
+ Snapshot snapshot = snapshotTable.currentSnapshot();
+ ConnectorColumnHandle structItems = new
IcebergColumnHandle("struct_items", 30);
+
+
Assertions.assertFalse(IcebergScanPlanProvider.selectedHistoryRequiresMissingRequiredFieldRejection(
+ table, scanSchema,
IcebergScanPlanProvider.projectedFieldIds(scanSchema, Arrays.asList(
+ new IcebergColumnHandle("items", 10), new
IcebergColumnHandle("entries", 20))),
+ snapshot));
+ // The access path struct_items.*.existing selects the element's child
without the element wrapper ID.
+
Assertions.assertFalse(IcebergScanPlanProvider.selectedHistoryRequiresMissingRequiredFieldRejection(
+ table, scanSchema,
IcebergScanPlanProvider.projectedFieldIds(scanSchema, Collections.singletonList(
+ structItems.withProjectedFieldIds(ImmutableSet.of(30,
32)))),
+ snapshot));
+
Assertions.assertTrue(IcebergScanPlanProvider.selectedHistoryRequiresMissingRequiredFieldRejection(
+ table, scanSchema,
IcebergScanPlanProvider.projectedFieldIds(scanSchema, Collections.singletonList(
+ structItems.withProjectedFieldIds(ImmutableSet.of(30,
33)))),
+ snapshot));
+ }
+
+ @Test
+ public void missingRequiredFieldFenceCoversSchemaOnlyTarget() {
+ // A schema-only change has no snapshot of its own, so every selected
file predates the required field it
+ // adds; projecting that field still needs the fence.
+ Table table = createTable("schema_only_required", new Schema(
+ Types.NestedField.required(1, "id", Types.LongType.get())),
PartitionSpec.unpartitioned());
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/schema_only_required/f1.parquet", 128, null,
null)).commit();
+ table.updateSchema().allowIncompatibleChanges()
+ .addRequiredColumn("required_value",
Types.IntegerType.get()).commit();
+ Assertions.assertNotEquals(table.schema().schemaId(),
table.currentSnapshot().schemaId(),
+ "the selected snapshot must predate the scanned schema");
+ IcebergScanPlanProvider provider = providerOver(table);
+ IcebergTableHandle handle = new IcebergTableHandle("db1",
"schema_only_required");
+
+ Assertions.assertEquals("Current Iceberg scan semantics",
requiredBackendSemantics(provider, handle,
+ new IcebergColumnHandle("required_value",
table.schema().findField("required_value").fieldId())));
+ Assertions.assertNull(requiredBackendSemantics(provider, handle,
+ new IcebergColumnHandle("id",
table.schema().findField("id").fieldId())));
+ }
+
+ @Test
+ public void missingRequiredFieldFenceCoversCherryPickedSourceAncestry() {
+ // A cherry-picked snapshot has no parent on the target branch, but
its files come from a source branch
+ // whose ancestors were written before the required field existed;
projecting that field needs the fence.
+ Table table = createTable("cherry_picked_required", new Schema(
+ Types.NestedField.required(1, "id", Types.LongType.get())),
PartitionSpec.unpartitioned());
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/cherry_picked_required/old.parquet", 128, null,
null)).toBranch("audit").commit();
+ table.updateSchema().allowIncompatibleChanges()
+ .addRequiredColumn("required_value",
Types.IntegerType.get()).commit();
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/cherry_picked_required/new.parquet", 128, null,
null)).toBranch("audit").commit();
+ long sourceSnapshotId = table.refs().get("audit").snapshotId();
+ table.manageSnapshots().cherrypick(sourceSnapshotId).commit();
+ Snapshot target = table.currentSnapshot();
+ Assertions.assertNull(target.parentId(), "the target branch must have
no snapshot before the picked one");
+ Assertions.assertEquals(String.valueOf(sourceSnapshotId),
+ target.summary().get(SnapshotSummary.SOURCE_SNAPSHOT_ID_PROP));
+ IcebergScanPlanProvider provider = providerOver(table);
+ IcebergTableHandle handle = new IcebergTableHandle("db1",
"cherry_picked_required");
+
+ Assertions.assertEquals("Current Iceberg scan semantics",
requiredBackendSemantics(provider, handle,
+ new IcebergColumnHandle("required_value",
table.schema().findField("required_value").fieldId())));
+ Assertions.assertNull(requiredBackendSemantics(provider, handle,
+ new IcebergColumnHandle("id",
table.schema().findField("id").fieldId())));
+ }
+
+ @Test
+ public void
nestedInitialDefaultRequiresCurrentBackendSemanticsOnlyWhenProjected() {
+ // Older backends cannot materialize an initial default below the top
level. The fence follows the
+ // projected subtree: a top-level scalar default and a struct path
that skips the defaulted child stay
+ // schedulable on every backend.
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "payload", Types.StructType.of(
+ Types.NestedField.optional(3, "existing",
Types.IntegerType.get()))));
+ Table table = createTable("nested_default_fence", schema,
PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "3"));
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/nested_default_fence/f1.parquet", 1024, null,
null)).commit();
+ table.updateSchema()
+ .addColumn("scalar", Types.IntegerType.get(), Literal.of(5))
+ .addColumn("payload", "added", Types.IntegerType.get(),
Literal.of(7))
+ .commit();
+ Schema current = table.schema();
+ int payloadId = current.findField("payload").fieldId();
+ ConnectorColumnHandle payload = new IcebergColumnHandle("payload",
payloadId);
+ IcebergScanPlanProvider provider = providerOver(table);
+ IcebergTableHandle handle = new IcebergTableHandle("db1",
"nested_default_fence");
+
+ Assertions.assertNull(requiredBackendSemantics(provider, handle,
+ new IcebergColumnHandle("scalar",
current.findField("scalar").fieldId())));
+ Assertions.assertEquals("Current Iceberg scan semantics",
+ requiredBackendSemantics(provider, handle, payload));
+ Assertions.assertNull(requiredBackendSemantics(provider, handle,
payload.withProjectedFieldIds(
+ ImmutableSet.of(payloadId,
current.findField("payload.existing").fieldId()))));
+ Assertions.assertEquals("Current Iceberg scan semantics",
requiredBackendSemantics(provider, handle,
+ payload.withProjectedFieldIds(ImmutableSet.of(
+ payloadId,
current.findField("payload.added").fieldId()))));
+ }
+
+ @Test
+ public void nestedInitialDefaultLeavesSmoothUpgradeRejectionToTheEngine() {
+ // The connector plans the scan and only names the semantics it needs.
The engine then refuses to schedule
+ // it with "Current Iceberg scan semantics are unavailable while
backend <id> is a smooth upgrade source".
+ Schema schema = new Schema(Types.NestedField.optional(1, "payload",
Types.StructType.of(
+ Types.NestedField.optional(2, "existing",
Types.IntegerType.get()))));
+ Table table = createTable("nested_default_engine_fence", schema,
PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "3"));
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/nested_default_engine_fence/f1.parquet", 1024,
null, null)).commit();
+ table.updateSchema().addColumn("payload", "added",
Types.IntegerType.get(), Literal.of(5)).commit();
+ IcebergScanPlanProvider provider = providerOver(table);
+ ConnectorSession session = new FakeScanSession("UTC",
+ Collections.singletonMap("enable_file_scanner_v2", "true"));
+ IcebergTableHandle handle = new IcebergTableHandle("db1",
"nested_default_engine_fence");
+ List<ConnectorColumnHandle> columns = Collections.singletonList(
+ new IcebergColumnHandle("payload",
table.schema().findField("payload").fieldId()));
+
+ Assertions.assertEquals(1,
+ provider.planScan(session,
ConnectorScanRequest.builder(handle, columns).build()).size());
+ Assertions.assertEquals("Current Iceberg scan semantics",
+ provider.getScanNodeProperties(session, handle, columns,
Optional.empty())
+
.get(ScanNodePropertyKeys.REQUIRED_CURRENT_BACKEND_SEMANTICS));
+ }
+
+ @Test
+ public void
nameMappingAliasCollisionRequiresCurrentBackendSemanticsOnlyWhenProjected() {
+ // When a name-mapping alias equals a sibling's current name, older
backends may bind the wrong physical
+ // column of an ID-less file. The fence applies when a colliding field
is projected and spares the rest.
+ Schema schema = new Schema(
+ Types.NestedField.optional(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "root", Types.StructType.of(
+ Types.NestedField.optional(3, "renamed_payload",
+ Types.ListType.ofOptional(4,
Types.IntegerType.get())),
+ Types.NestedField.optional(5, "payload",
Types.MapType.ofOptional(
+ 6, 7, Types.StringType.get(),
Types.IntegerType.get())),
+ Types.NestedField.optional(8, "safe",
Types.IntegerType.get()))));
+ Table table = createTable("name_alias_collision", schema,
PartitionSpec.unpartitioned());
+ Schema current = table.schema();
+ int rootId = current.findField("root").fieldId();
+ int renamedPayloadId =
current.findField("root.renamed_payload").fieldId();
+ int payloadId = current.findField("root.payload").fieldId();
+ table.newAppend().appendFile(dataFile(table.spec(),
+ "s3://b/db/name_alias_collision/f1.parquet", 1024, null,
null)).commit();
+ // Mapped names are unique per level, so the collision is the renamed
field's old name that the newer,
+ // unmapped sibling now uses as its current name.
+ table.updateProperties().set(TableProperties.DEFAULT_NAME_MAPPING,
NameMappingParser.toJson(
+ NameMapping.of(MappedField.of(rootId, "root", MappedFields.of(
+ MappedField.of(renamedPayloadId,
Arrays.asList("payload", "renamed_payload"))))))).commit();
+ ConnectorColumnHandle root = new IcebergColumnHandle("root", rootId);
+ IcebergScanPlanProvider provider = providerOver(table);
+ IcebergTableHandle handle = new IcebergTableHandle("db1",
"name_alias_collision");
+
+ Assertions.assertNull(requiredBackendSemantics(provider, handle,
+ new IcebergColumnHandle("id",
current.findField("id").fieldId())));
+ Assertions.assertEquals("Current Iceberg scan semantics",
+ requiredBackendSemantics(provider, handle, root));
+ Assertions.assertNull(requiredBackendSemantics(provider, handle,
root.withProjectedFieldIds(
+ ImmutableSet.of(rootId,
current.findField("root.safe").fieldId()))));
+ Assertions.assertEquals("Current Iceberg scan semantics",
requiredBackendSemantics(provider, handle,
+ root.withProjectedFieldIds(ImmutableSet.of(rootId,
renamedPayloadId))));
+
+ // An alias that no sibling currently uses as its name is not a
collision.
+ table.updateProperties().set(TableProperties.DEFAULT_NAME_MAPPING,
NameMappingParser.toJson(
+ NameMapping.of(MappedField.of(rootId, "root", MappedFields.of(
+ MappedField.of(renamedPayloadId,
Arrays.asList("legacy_payload", "renamed_payload")),
+ MappedField.of(payloadId, "payload")))))).commit();
+ Assertions.assertNull(requiredBackendSemantics(provider, handle,
root));
+ }
+
@Test
public void
getScanNodePropertiesEmitsSchemaEvolutionDictForPartitionedTableToo() {
// The dict is emitted alongside path_partition_keys (it is
unconditional, like legacy
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaUtilsTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaUtilsTest.java
index 0e9278ab8bb..832ed2149bc 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaUtilsTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaUtilsTest.java
@@ -362,6 +362,24 @@ public class IcebergSchemaUtilsTest {
Assertions.assertEquals(TPrimitiveType.BOOLEAN,
fields.get("added_bool").getType().getType());
}
+ @Test
+ public void dictCarriesInitialDefaultRatherThanWriteDefault() {
+ // Old files are read with the initial default; the write default only
fills omitted columns of new rows.
+ // A timestamptz default keeps its UTC offset so every session zone
reads the same instant.
+ Schema schema = new Schema(
+
Types.NestedField.optional("added_column").withId(1).ofType(Types.IntegerType.get())
+ .withInitialDefault(7).withWriteDefault(9).build(),
+
Types.NestedField.optional("added_timestamptz").withId(2).ofType(Types.TimestampType.withZone())
+ .withInitialDefault(1_704_067_200_123_456L).build());
+
+ Map<String, TField> fields =
topFields(IcebergSchemaUtils.buildCurrentSchema(schema,
+ Arrays.asList("added_column", "added_timestamptz"),
Collections.emptyMap()));
+
+ Assertions.assertEquals("7",
fields.get("added_column").getInitialDefaultValue());
+ Assertions.assertEquals("2024-01-01 00:00:00.123456+00:00",
+ fields.get("added_timestamptz").getInitialDefaultValue());
+ }
+
@Test
public void complexInitialDefaultsUseIcebergJsonAndMarkBinaryLeaves() {
Types.StructType structType = Types.StructType.of(
@@ -412,6 +430,38 @@ public class IcebergSchemaUtilsTest {
.getFieldPtr().isInitialDefaultValueIsBase64());
}
+ @Test
+ public void structChildrenCarryTheirOwnInitialDefaultsAndRequiredness() {
+ // BE fills a struct child that an old file lacks from the child's own
entry, and rejects the file only when
+ // that entry is required without a default. Each child therefore
carries its own initial default and
+ // Iceberg requiredness, independently of its parent and siblings.
+ Schema schema = new Schema(Types.NestedField.required(1, "payload",
Types.StructType.of(
+
Types.NestedField.required("nested_int").withId(2).ofType(Types.IntegerType.get())
+ .withInitialDefault(17).build(),
+
Types.NestedField.optional("nested_binary").withId(3).ofType(Types.BinaryType.get())
+ .withInitialDefault(ByteBuffer.wrap(new byte[] {0, 1,
2, (byte) 0xFF})).build(),
+ Types.NestedField.optional(4, "nested_uuid",
Types.UUIDType.get()))));
+
+ TField payload = topFields(IcebergSchemaUtils.buildCurrentSchema(
+ schema, Collections.singletonList("payload"),
Collections.emptyMap())).get("payload");
+
+ Assertions.assertFalse(payload.isIsOptional());
+ Assertions.assertFalse(payload.isSetInitialDefaultValue());
+ TField nestedInt = childByName(payload, "nested_int");
+ Assertions.assertEquals("17", nestedInt.getInitialDefaultValue());
+ Assertions.assertFalse(nestedInt.isSetInitialDefaultValueIsBase64());
+ Assertions.assertFalse(nestedInt.isIsOptional());
+ TField nestedBinary = childByName(payload, "nested_binary");
+ Assertions.assertEquals(3, nestedBinary.getId());
+ Assertions.assertEquals("AAEC/w==",
nestedBinary.getInitialDefaultValue());
+ Assertions.assertTrue(nestedBinary.isInitialDefaultValueIsBase64());
+ Assertions.assertTrue(nestedBinary.isIsOptional());
+ TField nestedUuid = childByName(payload, "nested_uuid");
+ Assertions.assertFalse(nestedUuid.isSetInitialDefaultValue());
+ Assertions.assertFalse(nestedUuid.isSetInitialDefaultValueIsBase64());
+ Assertions.assertTrue(nestedUuid.isIsOptional());
+ }
+
@Test
public void equalityCarrierAllowsDropAndReaddNamesAtEveryLevel() throws
Exception {
Types.StructType nestedDuplicateNames = Types.StructType.of(
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
index 9193fbbc79b..e93355a4e9b 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
@@ -66,6 +66,9 @@ import org.apache.iceberg.PartitionSpecParser;
import org.apache.iceberg.Schema;
import org.apache.iceberg.SchemaParser;
import org.apache.iceberg.Table;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableMetadataParser;
+import org.apache.iceberg.TableOperations;
import org.apache.iceberg.TableProperties;
import org.apache.iceberg.catalog.Namespace;
import org.apache.iceberg.catalog.TableIdentifier;
@@ -75,11 +78,17 @@ import org.apache.iceberg.expressions.Literal;
import org.apache.iceberg.inmemory.InMemoryCatalog;
import org.apache.iceberg.io.FileIO;
import org.apache.iceberg.io.InputFile;
+import org.apache.iceberg.io.LocationProvider;
import org.apache.iceberg.io.OutputFile;
+import org.apache.iceberg.types.Type;
import org.apache.iceberg.types.Types;
+import org.apache.iceberg.util.DateTimeUtil;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Proxy;
+import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Arrays;
@@ -91,6 +100,7 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
+import java.util.UUID;
/**
* Pins {@link IcebergWritePlanProvider#planWrite} for INSERT/OVERWRITE
against legacy
@@ -412,6 +422,102 @@ public class IcebergWritePlanProviderTest {
IcebergWriteSchemaContext.toDorisSql(nestedType,
nestedDefault, false, false));
}
+ @Test
+ public void primitiveWriteDefaultsBecomeTypedSqlLiterals() {
+ // Each primitive write-default reaches the engine as SQL of the
column's own type. The initial-default only
+ // describes rows written before the field existed, so it must never
become the value of a new row.
+ Schema schema = new Schema(
+ defaultField(1, "boolean_col", Types.BooleanType.get(), true,
false),
+ defaultField(2, "int_col", Types.IntegerType.get(), 34, 35),
+ defaultField(3, "long_col", Types.LongType.get(),
4_900_000_000L, 4_900_000_001L),
+ defaultField(4, "float_col", Types.FloatType.get(), 12.25F,
13.5F),
+ defaultField(5, "double_col", Types.DoubleType.get(), -123.5D,
456.75D),
+ defaultField(6, "decimal_col", Types.DecimalType.of(20, 4),
+ new BigDecimal("12345.6789"), new
BigDecimal("98765.4321")),
+ defaultField(7, "date_col", Types.DateType.get(),
+ DateTimeUtil.isoDateToDays("2024-12-17"),
DateTimeUtil.isoDateToDays("2025-01-18")),
+ defaultField(8, "timestamp_col",
Types.TimestampType.withoutZone(),
+
DateTimeUtil.isoTimestampToMicros("2024-12-17T23:59:59.123456"),
+
DateTimeUtil.isoTimestampToMicros("2025-01-18T01:02:03.654321")),
+ defaultField(9, "timestamptz_col",
Types.TimestampType.withZone(),
+
DateTimeUtil.isoTimestamptzToMicros("2024-12-17T23:59:59.123456+00:00"),
+
DateTimeUtil.isoTimestamptzToMicros("2025-01-18T01:02:03.654321+00:00")),
+ defaultField(10, "string_col", Types.StringType.get(),
"initial-default", "write-default"),
+ defaultField(11, "uuid_col", Types.UUIDType.get(),
+
UUID.fromString("123e4567-e89b-12d3-a456-426614174000"),
+
UUID.fromString("123e4567-e89b-12d3-a456-426614174001")),
+ defaultField(12, "fixed_col", Types.FixedType.ofLength(4),
+ ByteBuffer.wrap(new byte[] {0x01, 0x02, 0x03, 0x04}),
+ ByteBuffer.wrap(new byte[] {0x0a, 0x0b, 0x0c, 0x0d})),
+ defaultField(13, "binary_col", Types.BinaryType.get(),
+ ByteBuffer.wrap(new byte[] {0x05, 0x06}),
ByteBuffer.wrap(new byte[] {0x0e, 0x0f})),
+ Types.NestedField.optional(14, "optional_col",
Types.IntegerType.get()),
+ Types.NestedField.required(15, "required_col",
Types.IntegerType.get()));
+ Table table = freshCatalog().createTable(TableIdentifier.of("db1",
"typed_defaults"), schema,
+ PartitionSpec.unpartitioned(),
Collections.singletonMap("format-version", "3"));
+ IcebergWriteSchemaContext writeContext =
IcebergWriteSchemaContext.create(
+ table, "db1.typed_defaults", Optional.empty(), false, false);
+ Map<String, ConnectorColumn> columns = new HashMap<>();
+ for (ConnectorColumn column : writeContext.getColumns()) {
+ columns.put(column.getName(), column);
+ }
+
+ assertWriteDefault(columns.get("boolean_col"),
ConnectorType.of("BOOLEAN"), "false");
+ assertWriteDefault(columns.get("int_col"), ConnectorType.of("INT"),
"35");
+ assertWriteDefault(columns.get("long_col"),
ConnectorType.of("BIGINT"), "4900000001");
+ assertWriteDefault(columns.get("float_col"),
ConnectorType.of("FLOAT"), "13.5");
+ assertWriteDefault(columns.get("double_col"),
ConnectorType.of("DOUBLE"), "456.75");
+ assertWriteDefault(columns.get("decimal_col"),
ConnectorType.of("DECIMALV3", 20, 4), "98765.4321");
+ assertWriteDefault(columns.get("date_col"),
ConnectorType.of("DATEV2"), "'2025-01-18'");
+ assertWriteDefault(columns.get("timestamp_col"),
ConnectorType.of("DATETIMEV2", 6, 0),
+ "'2025-01-18 01:02:03.654321'");
+ assertWriteDefault(columns.get("timestamptz_col"),
ConnectorType.of("TIMESTAMPTZ", 6, 0),
+ "'2025-01-18 01:02:03.654321+00:00'");
+ assertWriteDefault(columns.get("string_col"),
ConnectorType.of("STRING"), "'write-default'");
+ assertWriteDefault(columns.get("uuid_col"), ConnectorType.of("UUID"),
+ "'123e4567-e89b-12d3-a456-426614174001'");
+ assertWriteDefault(columns.get("fixed_col"),
ConnectorType.of("VARBINARY", 4, 0), "X'0A0B0C0D'");
+ assertWriteDefault(columns.get("binary_col"),
ConnectorType.of("VARBINARY"), "X'0E0F'");
+ assertWriteDefault(columns.get("optional_col"),
ConnectorType.of("INT"), "NULL");
+ assertWriteDefault(columns.get("required_col"),
ConnectorType.of("INT"), null);
+ Assertions.assertFalse(columns.get("string_col").isNullable());
+ Assertions.assertEquals(table.schema().schemaId(),
writeContext.getSchema().schemaId());
+ Assertions.assertEquals(SchemaParser.toJson(table.schema()),
writeContext.getSchemaJson());
+
Assertions.assertTrue(writeContext.getMergeSchemaJson().contains("_row_id"));
+ }
+
+ private static Types.NestedField defaultField(int id, String name, Type
type,
+ Object initialDefault, Object writeDefault) {
+ return Types.NestedField.required(name).withId(id).ofType(type)
+
.withInitialDefault(initialDefault).withWriteDefault(writeDefault).build();
+ }
+
+ private static void assertWriteDefault(ConnectorColumn column,
ConnectorType type, String defaultSql) {
+ Assertions.assertEquals(type, column.getType(), column.getName());
+ Assertions.assertEquals(defaultSql, column.getDefaultValueSql(),
column.getName());
+ }
+
+ @Test
+ public void complexWriteDefaultsRenderMapsAndNullElementsRecursively() {
+ // A complex write-default keeps every nested NULL in place and
renders maps entry by entry.
+ Types.StructType structType = Types.StructType.of(
+ Types.NestedField.required(101, "count",
Types.IntegerType.get()),
+ Types.NestedField.optional(102, "items",
Types.ListType.ofOptional(103, Types.StringType.get())),
+ Types.NestedField.optional(104, "attributes",
+ Types.MapType.ofOptional(105, 106,
Types.StringType.get(), Types.IntegerType.get())));
+ Map<String, Integer> attributes = new LinkedHashMap<>();
+ attributes.put("one", 1);
+ attributes.put("none", null);
+ GenericRecord structDefault = GenericRecord.create(structType);
+ structDefault.setField("count", 7);
+ structDefault.setField("items", Arrays.asList("a", null));
+ structDefault.setField("attributes", attributes);
+
+ Assertions.assertEquals("named_struct('count', 7, 'items', array('a',
NULL), "
+ + "'attributes', map('one', 1, 'none', NULL))",
+ IcebergWriteSchemaContext.toDorisSql(structType,
structDefault, false, false));
+ }
+
@Test
public void zonedAndNestedBinaryWriteDefaultsIgnoreLegacyMappingFlags() {
// A zoned default keeps its instant, and a nested binary leaf stays
bytes, with the old flags off.
@@ -618,6 +724,97 @@ public class IcebergWritePlanProviderTest {
Assertions.assertDoesNotThrow(() ->
writeContext.validateCurrentSchema(table, false));
}
+ @Test
+ public void pinnedWriterMetadataRejectsSchemaFormatSpecAndSortOrderDrift()
{
+ // Pinned writer metadata stays valid only while its schema
generation, format version, partition spec and
+ // sort order are still the table's; a spec or sort order that is
gone, or reused with another definition,
+ // would label the written files with metadata they were not written
with.
+ Table table = partitionedSortedTable(freshCatalog());
+ IcebergWriteSchemaContext writeContext =
IcebergWriteSchemaContext.create(
+ table, "db1.t1", Optional.empty(), false, false);
+ Assertions.assertDoesNotThrow(() ->
writeContext.validateCurrentSchema(table, false));
+
+ assertWriterMetadataDrift(writeContext, withMetadata(table, "specs",
Collections.emptyMap()),
+ "partition spec changed during write planning");
+ PartitionSpec redefinedSpec = PartitionSpec.builderFor(table.schema())
+ .withSpecId(table.spec().specId()).identity("name").build();
+ assertWriterMetadataDrift(writeContext, withMetadata(table, "specs",
+ Collections.singletonMap(redefinedSpec.specId(),
redefinedSpec)),
+ "partition spec changed during write planning");
+ assertWriterMetadataDrift(writeContext, withMetadata(table,
"sortOrders", Collections.emptyMap()),
+ "sort order changed during write planning");
+
+ table.updateProperties().set(TableProperties.FORMAT_VERSION,
"3").commit();
+ assertWriterMetadataDrift(writeContext, table, "schema changed during
write planning");
+ assertWriterMetadataDrift(writeContext, table, "/format 3");
+ table.updateSchema().addColumn("added_after_planning",
Types.IntegerType.get()).commit();
+ assertWriterMetadataDrift(writeContext, table, "current schema " +
table.schema().schemaId() + "/");
+ }
+
+ private static void assertWriterMetadataDrift(IcebergWriteSchemaContext
writeContext, Table current,
+ String expectedMessage) {
+ DorisConnectorException ex =
Assertions.assertThrows(DorisConnectorException.class,
+ () -> writeContext.validateCurrentSchema(current, false));
+ Assertions.assertTrue(ex.getMessage().contains(expectedMessage),
ex.getMessage());
+ Assertions.assertTrue(ex.getMessage().contains("retry the statement"),
ex.getMessage());
+ }
+
+ /** A view of {@code table} whose {@code method} returns {@code value};
every other call reaches the table. */
+ private static Table withMetadata(Table table, String method, Object
value) {
+ return (Table) Proxy.newProxyInstance(Table.class.getClassLoader(),
+ new Class<?>[] {Table.class, HasTableOperations.class},
(proxy, invoked, args) -> {
+ if (method.equals(invoked.getName())) {
+ return value;
+ }
+ try {
+ return invoked.invoke(table, args);
+ } catch (InvocationTargetException e) {
+ throw e.getCause();
+ }
+ });
+ }
+
+ @Test
+ public void
v1MetadataIdentityAcceptsRetainedAncestorAndRejectsReplacement() {
+ // A V1 table without a UUID is identified by its metadata file and
timestamp: a later commit that keeps the
+ // pinned file in its metadata log is the same table, a reused file
name with another timestamp is not.
+ String pinnedFile = "file:///tmp/v1_table/metadata/v1.metadata.json";
+ MutableMetadataOperations operations =
+ new MutableMetadataOperations(v1Metadata(pinnedFile, 1234L,
null, 0L));
+ Assertions.assertNull(operations.current().uuid());
+ Table table = new BaseTable(operations, "db1.v1_table");
+ IcebergWriteSchemaContext writeContext =
IcebergWriteSchemaContext.create(
+ table, "db1.v1_table", Optional.empty(), false, false);
+ Assertions.assertDoesNotThrow(() ->
writeContext.validateCurrentSchema(table, false));
+
+ operations.metadata =
v1Metadata("file:///tmp/v1_table/metadata/v2.metadata.json", 2345L,
+ pinnedFile, 1234L);
+ Assertions.assertDoesNotThrow(() ->
writeContext.validateCurrentSchema(table, false));
+
+ operations.metadata = v1Metadata(pinnedFile, 3456L, null, 0L);
+ DorisConnectorException ex =
Assertions.assertThrows(DorisConnectorException.class,
+ () -> writeContext.validateCurrentSchema(table, false));
+ Assertions.assertTrue(ex.getMessage().contains("identity changed"),
ex.getMessage());
+ }
+
+ /** V1 metadata without a table UUID, as written before Iceberg assigned
one to every table. */
+ private static TableMetadata v1Metadata(String metadataFile, long
lastUpdatedMillis,
+ String previousFile, long previousTimestampMillis) {
+ String metadataLog = previousFile == null ? "[]" :
"[{\"timestamp-ms\":" + previousTimestampMillis
+ + ",\"metadata-file\":\"" + previousFile + "\"}]";
+ return TableMetadataParser.fromJson(metadataFile,
"{\"format-version\":1,"
+ + "\"location\":\"file:///tmp/v1_table\","
+ + "\"last-updated-ms\":" + lastUpdatedMillis + ","
+ + "\"last-column-id\":1,"
+ +
"\"schema\":{\"type\":\"struct\",\"schema-id\":0,\"fields\":["
+ +
"{\"id\":1,\"name\":\"id\",\"required\":false,\"type\":\"int\"}]},"
+ + "\"partition-spec\":[],"
+ + "\"properties\":{\"write.format.default\":\"parquet\"},"
+ + "\"current-snapshot-id\":-1,"
+ + "\"snapshots\":[],"
+ + "\"metadata-log\":" + metadataLog + "}");
+ }
+
// ───────────────────────────── INSERT: table-derived fields
─────────────────────────────
@Test
@@ -2266,6 +2463,76 @@ public class IcebergWritePlanProviderTest {
Assertions.assertFalse(sink.isSetRewritableDeleteFileSets());
}
+ @Test
+ public void planWriteMergeSinkUsesCompleteStatementPinnedWriterMetadata() {
+ // Every merge-sink field that labels written files comes from the
statement-pinned writer metadata, so a
+ // property, spec or sort-order change after the pin cannot make files
claim metadata they were not
+ // written with. The bound write-metadata identity fence for spec and
sort order is covered separately.
+ InMemoryCatalog catalog = freshCatalog();
+ Map<String, String> tableProps = new HashMap<>();
+ tableProps.put("write.format.default", "parquet");
+ tableProps.put("write.parquet.compression-codec", "zstd");
+ tableProps.put("write.data.path", "oss://bucket/wh/db1/pinned/data");
+ tableProps.put("format-version", "3");
+ Table table = catalog.createTable(TableIdentifier.of("db1", "pinned"),
SCHEMA,
+ PartitionSpec.builderFor(SCHEMA).identity("id").build(),
tableProps);
+ table.replaceSortOrder().asc("id", NullOrder.NULLS_FIRST).commit();
+ RecordingConnectorContext ctx = contextWithStorage();
+ IcebergWritePlanProvider provider = providerFor(table, ctx);
+ WriteSession session = sessionFor(table, ctx);
+ IcebergTableHandle tableHandle = new IcebergTableHandle("db1",
"pinned");
+ provider.getWriteColumns(session, tableHandle, Optional.empty());
+ IcebergWriteSchemaContext pinned =
IcebergStatementScope.activeWriteSchema(session, "db1", "pinned")
+ .orElseThrow(AssertionError::new);
+ int pinnedSpecId = pinned.getPartitionSpec().specId();
+
+ table.updateProperties()
+ .set("write.format.default", "orc")
+ .set("write.orc.compression-codec", "zlib")
+ .set("write.data.path", "oss://bucket/wh/db1/pinned/moved")
+ .set("write.metadata.metrics.default", "none")
+ .commit();
+ table.updateSpec().removeField("id").addField("name").commit();
+ table.replaceSortOrder().desc("name").commit();
+ TIcebergMergeSink sink = provider.planWrite(session,
+ new
WriteHandle(tableHandle).writeOperation(WriteOperation.MERGE))
+ .getDataSink().getIcebergMergeSink();
+
+ Assertions.assertNotEquals(pinnedSpecId, table.spec().specId(), "the
live spec must have moved on");
+ Assertions.assertEquals(pinned.getMergeSchemaJson(),
sink.getSchemaJson());
+ Assertions.assertTrue(sink.getSchemaJson().contains("_row_id"));
+ Assertions.assertEquals(pinnedSpecId, sink.getPartitionSpecId());
+ Assertions.assertEquals(Collections.singletonMap(pinnedSpecId,
pinned.getPartitionSpecJson()),
+ sink.getPartitionSpecsJson());
+ Assertions.assertEquals(pinnedSpecId,
sink.getPartitionSpecIdForDelete());
+ Assertions.assertEquals(TFileFormatType.FORMAT_PARQUET,
sink.getFileFormat());
+ Assertions.assertEquals(TFileCompressType.ZSTD,
sink.getCompressionType());
+ Assertions.assertEquals("oss://bucket/wh/db1/pinned/data",
sink.getOriginalOutputPath());
+ Assertions.assertEquals("oss://bucket/wh/db1/pinned/data",
sink.getTableLocation());
+ Assertions.assertTrue(sink.isCollectColumnStats(), "the pinned metrics
policy still collects stats");
+ Assertions.assertEquals(1, sink.getSortFieldsSize());
+ Assertions.assertEquals(table.schema().findField("id").fieldId(),
+ sink.getSortFields().get(0).getSourceColumnId());
+ Assertions.assertTrue(sink.getSortFields().get(0).isAscending());
+ }
+
+ @Test
+ public void planWriteMergeSinkPinsUnpartitionedSpecIdForDelete() {
+ // Once the last partition field is dropped, position deletes are
written under the current unpartitioned
+ // spec. Its id is not the initial spec 0, so the delete side must
carry the pinned id, not a default.
+ Table table = partitionedSortedTable(freshCatalog());
+ table.updateSpec().removeField("id").commit();
+
+ TIcebergMergeSink sink = planMergeSink(table, contextWithStorage(),
+ new WriteHandle(new IcebergTableHandle("db1",
"t1")).writeOperation(WriteOperation.MERGE));
+
+ Assertions.assertFalse(table.spec().isPartitioned());
+ Assertions.assertNotEquals(0, table.spec().specId());
+ Assertions.assertFalse(sink.isSetPartitionSpecId());
+ Assertions.assertFalse(sink.isSetPartitionSpecsJson());
+ Assertions.assertEquals(table.spec().specId(),
sink.getPartitionSpecIdForDelete());
+ }
+
@Test
public void planWriteMergeSinkSortFieldsFromIdentitySortOrder() {
Table table = partitionedSortedTable(freshCatalog());
@@ -2604,4 +2871,43 @@ public class IcebergWritePlanProviderTest {
throw new UnsupportedOperationException();
}
}
+
+ /** Table operations over replaceable metadata, so a test can advance or
replace a table without a catalog. */
+ private static final class MutableMetadataOperations implements
TableOperations {
+ private TableMetadata metadata;
+
+ MutableMetadataOperations(TableMetadata metadata) {
+ this.metadata = metadata;
+ }
+
+ @Override
+ public TableMetadata current() {
+ return metadata;
+ }
+
+ @Override
+ public TableMetadata refresh() {
+ return metadata;
+ }
+
+ @Override
+ public void commit(TableMetadata base, TableMetadata updated) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public FileIO io() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String metadataFileLocation(String fileName) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public LocationProvider locationProvider() {
+ throw new UnsupportedOperationException();
+ }
+ }
}
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriterHelperTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriterHelperTest.java
index 7a7c61de093..75160363940 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriterHelperTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriterHelperTest.java
@@ -44,8 +44,10 @@ import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
/**
* Parity oracle for the connector-resident {@link IcebergWriterHelper} — the
self-contained port of legacy
@@ -210,6 +212,51 @@ public class IcebergWriterHelperTest {
Assertions.assertEquals(FileFormat.PARQUET, df.format());
}
+ @Test
+ public void convertToWriterResultUsesPinnedMetadataAfterTableEvolution() {
+ // Files are labeled with the spec, sort order, format and metrics
policy the writer was pinned to, even after
+ // the live table evolved every one of them.
+ InMemoryCatalog catalog = new InMemoryCatalog();
+ catalog.initialize("test", Collections.emptyMap());
+ catalog.createNamespace(Namespace.of("db1"));
+ Map<String, String> properties = new HashMap<>();
+ properties.put("write.format.default", "orc");
+ properties.put("write.metadata.metrics.default", "full");
+ Table table = catalog.createTable(TableIdentifier.of("db1", "t"),
schema,
+ PartitionSpec.builderFor(schema).identity("id").build(),
properties);
+ table.replaceSortOrder().desc("age").commit();
+ IcebergWriteSchemaContext context = IcebergWriteSchemaContext.create(
+ table, "db1.t", Optional.empty(), false, false);
+ int pinnedSortOrderId = table.sortOrder().orderId();
+ table.updateSpec().removeField("id").commit();
+ table.replaceSortOrder().asc("name").commit();
+ table.updateProperties().set("write.format.default", "parquet")
+ .set("write.metadata.metrics.default", "none").commit();
+
+ ByteBuffer ageBound =
Conversions.toByteBuffer(Types.IntegerType.get(), 42);
+ TIcebergColumnStats stats = new TIcebergColumnStats();
+ stats.setLowerBounds(Collections.singletonMap(3, ageBound));
+ stats.setUpperBounds(Collections.singletonMap(3, ageBound));
+ TIcebergCommitData commitData = new TIcebergCommitData();
+ commitData.setFilePath("/path/to/pinned-data.orc");
+ commitData.setPartitionValues(Collections.singletonList("7"));
+ commitData.setRowCount(1);
+ commitData.setFileSize(128);
+ commitData.setFileContent(TFileContent.DATA);
+ commitData.setColumnStats(stats);
+
+ DataFile dataFile = IcebergWriterHelper.convertToWriterResult(
+ context, Collections.singletonList(commitData),
ZoneOffset.UTC).dataFiles()[0];
+
+ Assertions.assertNotEquals(table.spec().specId(), dataFile.specId(),
"the live spec must have moved on");
+ Assertions.assertEquals(context.getPartitionSpec().specId(),
dataFile.specId());
+ Assertions.assertEquals(Integer.valueOf(7),
dataFile.partition().get(0, Integer.class));
+ Assertions.assertEquals(Integer.valueOf(pinnedSortOrderId),
dataFile.sortOrderId());
+ Assertions.assertEquals(FileFormat.ORC, dataFile.format());
+ Assertions.assertEquals(ageBound, dataFile.lowerBounds().get(3));
+ Assertions.assertEquals(ageBound, dataFile.upperBounds().get(3));
+ }
+
@Test
public void convertToWriterResultDecodesBinaryAndUuidPartitionValues() {
// BE commits binary-like identity partitions as 0x hex; UUID may also
arrive as canonical text.
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ConnectorWriteSchemaUtilsTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ConnectorWriteSchemaUtilsTest.java
index a998880094e..b61a51c1ead 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ConnectorWriteSchemaUtilsTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ConnectorWriteSchemaUtilsTest.java
@@ -18,19 +18,44 @@
package org.apache.doris.nereids.trees.plans.commands;
import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.PrimitiveType;
import org.apache.doris.catalog.ScalarType;
+import org.apache.doris.catalog.Type;
+import org.apache.doris.datasource.ExternalTable;
import org.apache.doris.nereids.analyzer.UnboundSlot;
+import org.apache.doris.nereids.exceptions.AnalysisException;
import org.apache.doris.nereids.parser.ParserTestBase;
+import org.apache.doris.nereids.rules.expression.rules.FoldConstantRuleOnFE;
import org.apache.doris.nereids.trees.expressions.Cast;
+import org.apache.doris.nereids.trees.expressions.Default;
import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.expressions.StatementScopeIdGenerator;
+import org.apache.doris.nereids.trees.expressions.literal.BigIntLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.BooleanLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.DecimalV3Literal;
+import org.apache.doris.nereids.trees.expressions.literal.DoubleLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.FloatLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.Literal;
+import org.apache.doris.nereids.trees.expressions.literal.TinyIntLiteral;
+import org.apache.doris.nereids.types.DataType;
+import org.apache.doris.nereids.types.DecimalV3Type;
+import org.apache.doris.nereids.util.TypeCoercionUtils;
import org.apache.doris.qe.ConnectContext;
import org.apache.doris.qe.SqlModeHelper;
+import com.google.common.collect.ImmutableList;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.math.BigDecimal;
+import java.util.List;
public class ConnectorWriteSchemaUtilsTest extends ParserTestBase {
+ private static final List<String> TARGET_NAME_PARTS =
ImmutableList.of("catalog", "database", "test_table");
+
@Test
public void connectorHexDefaultParsesIdenticallyInBothBackslashModes() {
Column column = new Column("path", ScalarType.createStringType());
@@ -68,4 +93,88 @@ public class ConnectorWriteSchemaUtilsTest extends
ParserTestBase {
"non-finite default must not be parsed as an identifier: "
+ defaultSql);
}
}
+
+ @Test
+ public void defaultReferencesResolveAgainstPinnedWriteColumns() {
+ // DEFAULT(column) in DML resolves the statement-pinned write default;
a name absent from the pinned writer
+ // schema is an error rather than a guess.
+ ExternalTable target = targetTable();
+ List<Column> pinned = ImmutableList.of(pinnedColumn("score", 1, "9"));
+
+ Assertions.assertEquals(new TinyIntLiteral((byte) 9),
ConnectorWriteSchemaUtils.resolveDefaultReferences(
+ new Default(new UnboundSlot("score")), pinned, target,
ConnectContext.get(),
+ TARGET_NAME_PARTS, null));
+ AnalysisException exception =
Assertions.assertThrows(AnalysisException.class,
+ () -> ConnectorWriteSchemaUtils.resolveDefaultReferences(
+ new Default(new UnboundSlot("missing")), pinned,
target, ConnectContext.get(),
+ TARGET_NAME_PARTS, null));
+ Assertions.assertTrue(exception.getMessage().contains("missing"),
exception.getMessage());
+ }
+
+ @Test
+ public void defaultOfBoundSlotDoesNotFallBackToReusedColumnName() {
+ // A slot bound to a dropped and re-added column must not borrow the
pinned default of the old field that
+ // reused its name: a bound slot resolves DEFAULT by field id only.
+ ExternalTable target = targetTable();
+ Column pinnedField = pinnedColumn("reused_name", 7, "9");
+ List<Column> pinned = ImmutableList.of(pinnedField);
+ Column replacement = new Column("reused_name",
ScalarType.createType(PrimitiveType.INT));
+ replacement.setUniqueId(8);
+
+ Assertions.assertEquals(new TinyIntLiteral((byte) 9),
ConnectorWriteSchemaUtils.resolveDefaultReferences(
+ new Default(boundSlot(target, pinnedField)), pinned, target,
ConnectContext.get(),
+ TARGET_NAME_PARTS, null));
+ AnalysisException exception =
Assertions.assertThrows(AnalysisException.class,
+ () -> ConnectorWriteSchemaUtils.resolveDefaultReferences(
+ new Default(boundSlot(target, replacement)), pinned,
target, ConnectContext.get(),
+ TARGET_NAME_PARTS, null));
+ Assertions.assertTrue(exception.getMessage().contains("Cannot find
column information for DEFAULT"),
+ exception.getMessage());
+ }
+
+ @Test
+ public void connectorScalarDefaultsCastLosslesslyToTheirWriteColumnTypes()
{
+ // Connector write-default SQL is untyped text; binding casts it to
the write column type, which must keep
+ // the whole value: BIGINT beyond INT range, decimal scale,
microseconds and dates.
+ Assertions.assertEquals(new BigIntLiteral(4_900_000_001L),
+ bindDefault(Type.BIGINT, "4900000001"));
+ Assertions.assertEquals(new
DecimalV3Literal(DecimalV3Type.createDecimalV3Type(20, 4),
+ new BigDecimal("98765.4321")),
bindDefault(ScalarType.createDecimalV3Type(20, 4), "98765.4321"));
+ Assertions.assertEquals(new DoubleLiteral(456.75),
bindDefault(Type.DOUBLE, "456.75"));
+ Assertions.assertEquals(new FloatLiteral(13.5F),
bindDefault(Type.FLOAT, "13.5"));
+ Assertions.assertEquals(BooleanLiteral.FALSE,
bindDefault(Type.BOOLEAN, "false"));
+ Assertions.assertEquals("2025-01-18",
+ ((Literal) bindDefault(ScalarType.createDateV2Type(),
"'2025-01-18'")).getStringValue());
+ Assertions.assertEquals("2025-01-18 01:02:03.654321", ((Literal)
bindDefault(
+ ScalarType.createDatetimeV2Type(6), "'2025-01-18
01:02:03.654321'")).getStringValue());
+ }
+
+ /** Parse, cast and fold a connector default the way binding fills an
omitted column. */
+ private static Expression bindDefault(Type columnType, String defaultSql) {
+ Column column = new Column("value", columnType);
+ column.setConnectorDefaultValueSql(defaultSql);
+ DataType targetType = DataType.fromCatalogType(columnType);
+ Expression bound =
FoldConstantRuleOnFE.evaluateWithoutContext(TypeCoercionUtils.castIfNotSameType(
+ ConnectorWriteSchemaUtils.resolveDefault(column), targetType));
+ Assertions.assertTrue(bound instanceof Literal, defaultSql + " -> " +
bound);
+ Assertions.assertEquals(targetType, bound.getDataType(), defaultSql);
+ return bound;
+ }
+
+ private static Column pinnedColumn(String name, int uniqueId, String
defaultSql) {
+ Column column = new Column(name,
ScalarType.createType(PrimitiveType.INT));
+ column.setUniqueId(uniqueId);
+ column.setConnectorDefaultValueSql(defaultSql);
+ return column;
+ }
+
+ private static ExternalTable targetTable() {
+ ExternalTable table = Mockito.mock(ExternalTable.class);
+ Mockito.when(table.getId()).thenReturn(9L);
+ return table;
+ }
+
+ private static SlotReference boundSlot(ExternalTable table, Column column)
{
+ return SlotReference.fromColumn(StatementScopeIdGenerator.newExprId(),
table, column, ImmutableList.of());
+ }
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ExternalRowLevelMergePlanBuilderTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ExternalRowLevelMergePlanBuilderTest.java
index e6287d502c2..ac13299d347 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ExternalRowLevelMergePlanBuilderTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ExternalRowLevelMergePlanBuilderTest.java
@@ -34,12 +34,15 @@ import
org.apache.doris.nereids.trees.expressions.SlotReference;
import
org.apache.doris.nereids.trees.expressions.functions.scalar.ShortCircuitIf;
import org.apache.doris.nereids.trees.expressions.literal.BooleanLiteral;
import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.Literal;
import org.apache.doris.nereids.trees.expressions.literal.StringLiteral;
import org.apache.doris.nereids.trees.plans.RelationId;
import org.apache.doris.nereids.trees.plans.commands.merge.MergeMatchedClause;
+import
org.apache.doris.nereids.trees.plans.commands.merge.MergeNotMatchedClause;
import
org.apache.doris.nereids.trees.plans.logical.LogicalExternalRowLevelMergeSink;
import org.apache.doris.nereids.trees.plans.logical.LogicalOneRowRelation;
import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
import org.apache.doris.nereids.types.ConnectorComputeVariantType;
import org.apache.doris.nereids.types.IntegerType;
import org.apache.doris.nereids.types.VarBinaryType;
@@ -129,6 +132,58 @@ public class ExternalRowLevelMergePlanBuilderTest {
metadataOrder.verify(table).getWriteSchemaSnapshot();
}
+ @Test
+ public void
matchedUpdateKeepsTargetValuesWhileNotMatchedInsertUsesWriteDefaults() {
+ // A WHEN MATCHED UPDATE keeps the target row's value for every
unassigned column; only the WHEN NOT MATCHED
+ // INSERT branch fills an omitted column with its pinned write default.
+ ConnectContext ctx = new ConnectContext();
+ ctx.setStatementContext(new StatementContext());
+ ctx.setThreadLocalInfo();
+ LogicalPlan source = new LogicalOneRowRelation(new RelationId(0),
+ ImmutableList.of(new UnboundAlias(new IntegerLiteral(1),
"dummy")));
+ ExternalRowLevelMergePlanBuilder builder = new
ExternalRowLevelMergePlanBuilder(
+ ImmutableList.of("test_catalog", "test_db", "test_table"),
+ Optional.empty(), Optional.empty(), source,
BooleanLiteral.TRUE,
+ ImmutableList.of(new MergeMatchedClause(Optional.empty(),
+ ImmutableList.of(new EqualTo(new UnboundSlot("c1"),
new IntegerLiteral(1))), false)),
+ ImmutableList.of(new MergeNotMatchedClause(Optional.empty(),
ImmutableList.of("c1"),
+ ImmutableList.of(new UnboundAlias(new
IntegerLiteral(5))))));
+ Column rowId = new Column(Column.ICEBERG_ROWID_COL,
ScalarType.createStringType());
+ rowId.setIsVisible(false);
+ Column assigned = new Column("c1",
ScalarType.createType(PrimitiveType.INT));
+ assigned.setConnectorDefaultValueSql("4141");
+ Column omitted = new Column("c2",
ScalarType.createType(PrimitiveType.INT));
+ omitted.setConnectorDefaultValueSql("4242");
+ PluginDrivenExternalTable table =
Mockito.mock(PluginDrivenExternalTable.class);
+ PluginDrivenExternalTable.WriteSchemaSnapshot writeSchema =
+
Mockito.mock(PluginDrivenExternalTable.WriteSchemaSnapshot.class);
+ Mockito.when(table.getName()).thenReturn("test_table");
+
Mockito.doReturn(Mockito.mock(ExternalDatabase.class)).when(table).getDatabase();
+ Mockito.doReturn(writeSchema).when(table).getWriteSchemaSnapshot();
+ Mockito.doReturn(Optional.of(ImmutableList.of(assigned, omitted)))
+ .when(table).resolveWriteColumns(Optional.empty());
+ Mockito.doReturn(ImmutableList.of(assigned, omitted,
rowId)).when(table).getFullSchema();
+
+ LogicalPlan plan = builder.buildMergePlan(ctx, table);
+
+ Expression c2 = ((LogicalProject<?>)
plan.child(0)).getProjects().stream()
+ .filter(project -> project instanceof UnboundAlias
+ && ((UnboundAlias)
project).getAlias().equals(Optional.of("c2")))
+ .findFirst().orElseThrow(AssertionError::new).child(0);
+ // The outermost selection picks the NOT MATCHED insert branch; its
else-branch selects the MATCHED update.
+ Expression insertBranch = c2.child(1);
+ Expression updateBranch = c2.child(2).child(1);
+ Assertions.assertTrue(insertBranch.anyMatch(expression ->
isLiteral(expression, "4242")), c2.toString());
+ Assertions.assertTrue(updateBranch.anyMatch(expression -> expression
instanceof UnboundSlot
+ && "c2".equalsIgnoreCase(((UnboundSlot)
expression).getNameParts().get(
+ ((UnboundSlot) expression).getNameParts().size() -
1))), c2.toString());
+ Assertions.assertFalse(updateBranch.anyMatch(expression ->
isLiteral(expression, "4242")), c2.toString());
+ }
+
+ private static boolean isLiteral(Object expression, String value) {
+ return expression instanceof Literal && value.equals(((Literal)
expression).getStringValue());
+ }
+
@Test
public void mergeBranchesUseThePinnedWriterType() {
VarBinaryType uuidType = VarBinaryType.createVarBinaryType(16);
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ExternalRowLevelUpdatePlanBuilderTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ExternalRowLevelUpdatePlanBuilderTest.java
index 7ebc2e87af7..e78a923492c 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ExternalRowLevelUpdatePlanBuilderTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ExternalRowLevelUpdatePlanBuilderTest.java
@@ -26,8 +26,10 @@ import
org.apache.doris.datasource.plugin.PluginDrivenExternalTable;
import org.apache.doris.nereids.StatementContext;
import org.apache.doris.nereids.analyzer.UnboundAlias;
import org.apache.doris.nereids.analyzer.UnboundSlot;
+import org.apache.doris.nereids.trees.expressions.EqualTo;
import org.apache.doris.nereids.trees.expressions.NamedExpression;
import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.Literal;
import org.apache.doris.nereids.trees.plans.RelationId;
import org.apache.doris.nereids.trees.plans.commands.merge.MergeOperation;
import
org.apache.doris.nereids.trees.plans.logical.LogicalExternalRowLevelMergeSink;
@@ -146,6 +148,42 @@ public class ExternalRowLevelUpdatePlanBuilderTest {
metadataOrder.verify(table).getWriteSchemaSnapshot();
}
+ @Test
+ public void
updateKeepsUnassignedTargetValuesInsteadOfInjectingWriteDefaults() {
+ // UPDATE rewrites only the assigned columns. An unassigned column
keeps the target row's value even when the
+ // pinned writer schema gives it a write default; write defaults only
fill values a write leaves out.
+ ConnectContext ctx = new ConnectContext();
+ ctx.setStatementContext(new StatementContext());
+ ctx.setThreadLocalInfo();
+ LogicalPlan basePlan = new LogicalOneRowRelation(new RelationId(0),
+ ImmutableList.of(new UnboundAlias(new IntegerLiteral(1),
"dummy")));
+ ExternalRowLevelUpdatePlanBuilder builder = new
ExternalRowLevelUpdatePlanBuilder(
+ ImmutableList.of("test_catalog", "test_db", "test_table"),
null, ImmutableList.of(), basePlan);
+ Column assigned = new Column("c1",
ScalarType.createType(PrimitiveType.INT));
+ assigned.setConnectorDefaultValueSql("4141");
+ Column unassigned = new Column("c2",
ScalarType.createType(PrimitiveType.INT));
+ unassigned.setConnectorDefaultValueSql("4242");
+ PluginDrivenExternalTable table =
Mockito.mock(PluginDrivenExternalTable.class);
+ PluginDrivenExternalTable.WriteSchemaSnapshot writeSchema =
+
Mockito.mock(PluginDrivenExternalTable.WriteSchemaSnapshot.class);
+ Mockito.when(table.getName()).thenReturn("test_table");
+
Mockito.doReturn(Mockito.mock(ExternalDatabase.class)).when(table).getDatabase();
+ Mockito.doReturn(writeSchema).when(table).getWriteSchemaSnapshot();
+ Mockito.doReturn(Optional.of(ImmutableList.of(assigned, unassigned)))
+ .when(table).resolveWriteColumns(Optional.empty());
+
+ LogicalPlan plan = builder.buildMergePlan(ctx, basePlan,
+ ImmutableList.of(new EqualTo(new UnboundSlot("c1"), new
IntegerLiteral(1))), table);
+
+ List<NamedExpression> outputs = ((LogicalProject<?>)
plan.child(0)).getProjects();
+ Assertions.assertTrue(outputs.stream().anyMatch(output -> output
instanceof UnboundSlot
+ && "c2".equalsIgnoreCase(((UnboundSlot)
output).getNameParts().get(
+ ((UnboundSlot) output).getNameParts().size() - 1))),
outputs.toString());
+ Assertions.assertFalse(outputs.stream().anyMatch(output ->
output.anyMatch(expression ->
+ expression instanceof Literal && (((Literal)
expression).getStringValue().equals("4141")
+ || ((Literal)
expression).getStringValue().equals("4242")))), outputs.toString());
+ }
+
@Test
public void testBuildUpdateSelectItemsSkipsHiddenColumns() {
ExternalRowLevelUpdatePlanBuilder command = new
ExternalRowLevelUpdatePlanBuilder(
diff --git a/gensrc/thrift/PlanNodes.thrift b/gensrc/thrift/PlanNodes.thrift
index a3e997ddeb6..1cb49705c5b 100644
--- a/gensrc/thrift/PlanNodes.thrift
+++ b/gensrc/thrift/PlanNodes.thrift
@@ -362,6 +362,9 @@ struct TIcebergFileDesc {
// Only for format_version >= 3, the sequence number which last updated
this file.
11: optional i64 last_updated_sequence_number;
12: optional string serialized_split;
+ // Reserved for branch-4.1 / branch-4.2 field `13: optional
ExternalTableSchema.TSchema
+ // equality_delete_schema` (the split-local schema of dropped
equality-delete keys). Master
+ // carries those schemas in TFileScanRangeParams.history_schema_info. Do
not reuse this id.
}
struct TPaimonDeletionFileDesc {
diff --git
a/regression-test/data/external_table_p0/iceberg/test_iceberg_write_default.out
b/regression-test/data/external_table_p0/iceberg/test_iceberg_write_default.out
new file mode 100644
index 00000000000..65a51659d9d
--- /dev/null
+++
b/regression-test/data/external_table_p0/iceberg/test_iceberg_write_default.out
@@ -0,0 +1,4 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !desc_write_default --
+\N
+
diff --git
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_write_default.groovy
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_write_default.groovy
index 9759a32461e..f356ea3103d 100644
---
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_write_default.groovy
+++
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_write_default.groovy
@@ -70,6 +70,15 @@ suite("test_iceberg_write_default", "p0,external") {
assertTrue(cRow != null, "column c must exist in DESC: " +
descRows.toString())
assertFalse(cRow.toString().contains("42"),
"DESC must not show connector write default 42, got: " +
cRow.toString())
+ // information_schema reads the cached catalog Column too, so
COLUMN_DEFAULT stays NULL.
+ qt_desc_write_default """
+ SELECT COLUMN_DEFAULT
+ FROM ${catalog_name}.information_schema.columns
+ WHERE TABLE_CATALOG = '${catalog_name}'
+ AND TABLE_SCHEMA = '${db}'
+ AND TABLE_NAME = '${tbl}'
+ AND COLUMN_NAME = 'c'
+ """
// 2) INSERT with column c omitted must apply the write default.
sql """ INSERT INTO ${tbl} (id) VALUES (1) """
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]