This is an automated email from the ASF dual-hosted git repository.
wgtmac pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git
The following commit(s) were added to refs/heads/main by this push:
new 9f006124 fix(schema): preserve doc-only schema evolution (#1052)
9f006124 is described below
commit 9f00612448baa13cd852d2aac34487eb6138f30e
Author: Guotao Yu <[email protected]>
AuthorDate: Tue Oct 27 17:23:54 2026 +0800
fix(schema): preserve doc-only schema evolution (#1052)
Compare field documentation during schema equality so metadata
deduplication retains doc-only updates. Cover nested fields, metadata round
trips, unchanged docs, and historical schema reuse; align existing
deserialization expectations with their fixture docs.
---
src/iceberg/schema_field.cc | 2 +-
src/iceberg/test/metadata_serde_test.cc | 13 +++---
src/iceberg/test/schema_field_test.cc | 15 +++++++
src/iceberg/test/schema_test.cc | 46 ++++++++++++++++++++
src/iceberg/test/table_metadata_builder_test.cc | 56 +++++++++++++++++++++++++
5 files changed, 125 insertions(+), 7 deletions(-)
diff --git a/src/iceberg/schema_field.cc b/src/iceberg/schema_field.cc
index 7a6e90fc..50e2d7d0 100644
--- a/src/iceberg/schema_field.cc
+++ b/src/iceberg/schema_field.cc
@@ -243,7 +243,7 @@ bool DefaultEquals(const std::shared_ptr<const Literal>&
lhs,
bool SchemaField::Equals(const SchemaField& other) const {
return field_id_ == other.field_id_ && name_ == other.name_ && *type_ ==
*other.type_ &&
- optional_ == other.optional_ &&
+ optional_ == other.optional_ && doc_ == other.doc_ &&
DefaultEquals(initial_default_, other.initial_default_) &&
DefaultEquals(write_default_, other.write_default_);
}
diff --git a/src/iceberg/test/metadata_serde_test.cc
b/src/iceberg/test/metadata_serde_test.cc
index 88272866..41530db6 100644
--- a/src/iceberg/test/metadata_serde_test.cc
+++ b/src/iceberg/test/metadata_serde_test.cc
@@ -142,7 +142,7 @@ TEST(MetadataSerdeTest, DeserializeV1Valid) {
auto expected_schema = std::make_shared<Schema>(
std::vector<SchemaField>{SchemaField::MakeRequired(1, "x", int64()),
- SchemaField::MakeRequired(2, "y", int64()),
+ SchemaField::MakeRequired(2, "y", int64(),
"comment"),
SchemaField::MakeRequired(3, "z", int64())});
auto expected_spec_result = PartitionSpec::Make(
@@ -188,11 +188,12 @@ TEST(MetadataSerdeTest, DeserializeV2Valid) {
ICEBERG_UNWRAP_OR_FAIL(
std::shared_ptr<Schema> expected_schema_2,
- Schema::Make(std::vector<SchemaField>{SchemaField::MakeRequired(1, "x",
int64()),
- SchemaField::MakeRequired(2, "y",
int64()),
- SchemaField::MakeRequired(3, "z",
int64())},
- /*schema_id=*/1,
- /*identifier_field_ids=*/std::vector<int32_t>{1, 2}));
+ Schema::Make(
+ std::vector<SchemaField>{SchemaField::MakeRequired(1, "x", int64()),
+ SchemaField::MakeRequired(2, "y", int64(),
"comment"),
+ SchemaField::MakeRequired(3, "z", int64())},
+ /*schema_id=*/1,
+ /*identifier_field_ids=*/std::vector<int32_t>{1, 2}));
auto expected_spec_result = PartitionSpec::Make(
/*spec_id=*/0,
diff --git a/src/iceberg/test/schema_field_test.cc
b/src/iceberg/test/schema_field_test.cc
index 7a66b9d3..2c97c670 100644
--- a/src/iceberg/test/schema_field_test.cc
+++ b/src/iceberg/test/schema_field_test.cc
@@ -86,6 +86,21 @@ TEST(SchemaFieldTest, Equality) {
ASSERT_EQ(field6, field1);
}
+TEST(SchemaFieldTest, EqualityWithDoc) {
+ for (bool optional : {false, true}) {
+ SchemaField original(1, "foo", int32(), optional, "old");
+ SchemaField same(1, "foo", int32(), optional, "old");
+ SchemaField updated(1, "foo", int32(), optional, "new");
+ SchemaField without_doc(1, "foo", int32(), optional);
+
+ EXPECT_EQ(original, same);
+ EXPECT_NE(original, updated);
+ EXPECT_NE(updated, original);
+ EXPECT_NE(original, without_doc);
+ EXPECT_NE(without_doc, original);
+ }
+}
+
TEST(SchemaFieldTest, WithDoc) {
{
SchemaField field(/*field_id=*/1, /*name=*/"foo", int32(),
diff --git a/src/iceberg/test/schema_test.cc b/src/iceberg/test/schema_test.cc
index 90cfdb13..ed0c5f5a 100644
--- a/src/iceberg/test/schema_test.cc
+++ b/src/iceberg/test/schema_test.cc
@@ -98,6 +98,52 @@ TEST(SchemaTest, Equality) {
ASSERT_EQ(schema5, schema1);
}
+TEST(SchemaTest, SameSchemaWithDoc) {
+ using iceberg::int64;
+ using iceberg::ListType;
+ using iceberg::MapType;
+ using iceberg::Schema;
+ using iceberg::SchemaField;
+ using iceberg::string;
+ using iceberg::StructType;
+ using iceberg::Type;
+ auto make_schemas = [](std::string_view doc, int32_t schema_id) {
+ auto nested = SchemaField::MakeRequired(2, "nested", int64(), doc);
+ auto element = SchemaField::MakeOptional(2, "element", int64(), doc);
+ auto key = SchemaField::MakeRequired(2, "key", string());
+ auto value = SchemaField::MakeOptional(3, "value", int64());
+ auto key_with_doc = SchemaField::MakeRequired(2, "key", string(), doc);
+ auto value_with_doc = SchemaField::MakeOptional(3, "value", int64(), doc);
+ std::vector<std::shared_ptr<Type>> types{
+ std::make_shared<StructType>(std::vector<SchemaField>{nested}),
+ std::make_shared<ListType>(element),
+ std::make_shared<MapType>(key_with_doc, value),
+ std::make_shared<MapType>(key, value_with_doc)};
+ std::vector<std::unique_ptr<Schema>> schemas;
+ schemas.push_back(std::make_unique<Schema>(
+ std::vector<SchemaField>{SchemaField::MakeRequired(1, "c", int64(),
doc)},
+ schema_id));
+ for (auto& type : types) {
+ schemas.push_back(std::make_unique<Schema>(
+ std::vector<SchemaField>{SchemaField::MakeRequired(1, "c", type)},
schema_id));
+ }
+ return schemas;
+ };
+
+ const auto original = make_schemas("old", 0);
+ const auto same = make_schemas("old", 1);
+ const auto updated = make_schemas("new", 0);
+ const auto without_doc = make_schemas("", 0);
+ for (size_t i = 0; i < original.size(); ++i) {
+ SCOPED_TRACE(i);
+ EXPECT_TRUE(original[i]->SameSchema(*same[i]));
+ EXPECT_FALSE(original[i]->SameSchema(*updated[i]));
+ EXPECT_FALSE(updated[i]->SameSchema(*original[i]));
+ EXPECT_FALSE(original[i]->SameSchema(*without_doc[i]));
+ EXPECT_NE(*original[i], *updated[i]);
+ }
+}
+
TEST(SchemaTest, ValidateRejectsV3TypesBeforeFormatV3) {
iceberg::Schema timestamp_ns_schema(
{iceberg::SchemaField(1, "timestamp_ns", iceberg::timestamp_ns(),
false)});
diff --git a/src/iceberg/test/table_metadata_builder_test.cc
b/src/iceberg/test/table_metadata_builder_test.cc
index 5e21c6b8..5a9681ad 100644
--- a/src/iceberg/test/table_metadata_builder_test.cc
+++ b/src/iceberg/test/table_metadata_builder_test.cc
@@ -20,11 +20,14 @@
#include <memory>
#include <ranges>
#include <string>
+#include <utility>
#include <vector>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
+#include <nlohmann/json.hpp>
+#include "iceberg/json_serde_internal.h"
#include "iceberg/partition_spec.h"
#include "iceberg/result.h"
#include "iceberg/schema.h"
@@ -792,6 +795,59 @@ TEST(TableMetadataBuilderTest, SetCurrentSchemaBasic) {
EXPECT_EQ(metadata->current_schema_id, 0);
}
+TEST(TableMetadataBuilderTest, SetCurrentSchemaDocOnlyChange) {
+ auto make_schema = [](const std::string& doc) {
+ return std::make_shared<Schema>(
+ std::vector<SchemaField>{SchemaField::MakeRequired(1, "c", int64(),
doc)}, 0);
+ };
+ const std::vector<std::pair<std::string, std::string>> doc_changes{
+ {"old", "new"}, {"", "new"}, {"old", ""}};
+ for (const auto& [old_doc, new_doc] : doc_changes) {
+ SCOPED_TRACE(old_doc + " -> " + new_doc);
+ auto base = CreateBaseMetadata();
+ base->schemas = {make_schema(old_doc)};
+ base->last_column_id = 1;
+ auto new_schema = make_schema(new_doc);
+ auto builder = TableMetadataBuilder::BuildFrom(base.get());
+ builder->SetCurrentSchema(new_schema, base->last_column_id);
+
+ ICEBERG_UNWRAP_OR_FAIL(auto metadata, builder->Build());
+ ICEBERG_UNWRAP_OR_FAIL(auto current_schema, metadata->Schema());
+ EXPECT_EQ(current_schema->fields()[0].doc(), new_doc);
+ EXPECT_NE(metadata->current_schema_id, base->current_schema_id);
+ EXPECT_EQ(metadata->last_column_id, base->last_column_id);
+ ASSERT_EQ(metadata->schemas.size(), 2);
+ EXPECT_EQ(metadata->schemas[0]->fields()[0].doc(), old_doc);
+ EXPECT_EQ(metadata->schemas[1]->schema_id(), metadata->current_schema_id);
+ EXPECT_EQ(metadata->schemas[1]->fields()[0].doc(), new_doc);
+
+ ICEBERG_UNWRAP_OR_FAIL(auto json, ToJson(*metadata));
+ ICEBERG_UNWRAP_OR_FAIL(auto restored, TableMetadataFromJson(json));
+ ICEBERG_UNWRAP_OR_FAIL(auto restored_schema, restored->Schema());
+ EXPECT_EQ(restored_schema->fields()[0].doc(), new_doc);
+ EXPECT_EQ(restored->current_schema_id, metadata->current_schema_id);
+ ASSERT_EQ(restored->schemas.size(), 2);
+ EXPECT_EQ(restored->schemas[0]->fields()[0].doc(), old_doc);
+
+ // Repeating the new doc must reuse its schema ID.
+ builder = TableMetadataBuilder::BuildFrom(metadata.get());
+ builder->SetCurrentSchema(make_schema(new_doc), metadata->last_column_id);
+ ICEBERG_UNWRAP_OR_FAIL(auto unchanged, builder->Build());
+ EXPECT_EQ(unchanged->schemas.size(), 2);
+ EXPECT_EQ(unchanged->current_schema_id, metadata->current_schema_id);
+ EXPECT_TRUE(builder->changes().empty());
+
+ // Restoring the old doc must reuse the historical schema, without adding
a third.
+ builder = TableMetadataBuilder::BuildFrom(metadata.get());
+ builder->SetCurrentSchema(make_schema(old_doc), metadata->last_column_id);
+ ICEBERG_UNWRAP_OR_FAIL(auto reverted, builder->Build());
+ EXPECT_EQ(reverted->schemas.size(), 2);
+ EXPECT_EQ(reverted->current_schema_id, base->current_schema_id);
+ ICEBERG_UNWRAP_OR_FAIL(auto reverted_schema, reverted->Schema());
+ EXPECT_EQ(reverted_schema->fields()[0].doc(), old_doc);
+ }
+}
+
TEST(TableMetadataBuilderTest, SetCurrentSchemaWithInvalidLastColumnId) {
auto base = CreateBaseMetadata();
auto builder = TableMetadataBuilder::BuildFrom(base.get());