diff --git a/src/iceberg/json_serde.cc b/src/iceberg/json_serde.cc index 7d6c9ee25..10fd346ba 100644 --- a/src/iceberg/json_serde.cc +++ b/src/iceberg/json_serde.cc @@ -1270,6 +1270,24 @@ Result> NameMappingFromJson(const nlohmann::json& j return NameMapping::Make(std::move(mapped_fields)); } +std::optional UpdateMappingFromJsonString( + std::string_view mapping_json, const std::map& updates, + const std::multimap& adds) { + auto json_result = FromJsonString(std::string(mapping_json)); + if (!json_result) return std::nullopt; + + auto current_mapping = NameMappingFromJson(*json_result); + if (!current_mapping) return std::nullopt; + + auto updated_mapping = UpdateMapping(**current_mapping, updates, adds); + if (!updated_mapping) return std::nullopt; + + auto json_str = ToJsonString(ToJson(**updated_mapping)); + if (!json_str) return std::nullopt; + + return std::move(*json_str); +} + nlohmann::json ToJson(const TableIdentifier& identifier) { nlohmann::json json; json[kNamespace] = identifier.ns.levels; diff --git a/src/iceberg/json_serde_internal.h b/src/iceberg/json_serde_internal.h index 7b09acdbc..da5cf3d62 100644 --- a/src/iceberg/json_serde_internal.h +++ b/src/iceberg/json_serde_internal.h @@ -19,7 +19,11 @@ #pragma once +#include #include +#include +#include +#include #include @@ -347,6 +351,14 @@ ICEBERG_EXPORT nlohmann::json ToJson(const NameMapping& name_mapping); ICEBERG_EXPORT Result> NameMappingFromJson( const nlohmann::json& json); +/// \brief Update a name mapping from its JSON string and return updated JSON. +/// +/// Parses the JSON, calls UpdateMapping, and serializes the result. +/// Returns nullopt if any step fails. +ICEBERG_EXPORT std::optional UpdateMappingFromJsonString( + std::string_view mapping_json, const std::map& updates, + const std::multimap& adds); + /// \brief Serializes a `TableIdentifier` object to JSON. /// /// \param identifier The `TableIdentifier` object to be serialized. diff --git a/src/iceberg/name_mapping.cc b/src/iceberg/name_mapping.cc index eaf6199ee..73dfb53ee 100644 --- a/src/iceberg/name_mapping.cc +++ b/src/iceberg/name_mapping.cc @@ -323,6 +323,158 @@ class CreateMappingVisitor { } }; +// Visitor class for updating name mappings with schema changes +class UpdateMappingVisitor { + public: + UpdateMappingVisitor(const std::map& updates, + const std::multimap& adds) + : updates_(updates), adds_(adds) {} + + Result> VisitMapping(const NameMapping& mapping) { + auto fields_result = VisitFields(mapping.AsMappedFields()); + ICEBERG_RETURN_UNEXPECTED(fields_result); + return AddNewFields(std::move(*fields_result), + -1 /* parent ID for top-level fields */); + } + + private: + Result> VisitFields(const MappedFields& fields) { + // Recursively visit all fields + std::vector field_results; + field_results.reserve(fields.Size()); + + for (const auto& field : fields.fields()) { + auto field_result = VisitField(field); + ICEBERG_RETURN_UNEXPECTED(field_result); + field_results.push_back(std::move(*field_result)); + } + + // Build update assignments map for removing reassigned names + std::unordered_map update_assignments; + std::ranges::for_each(field_results, [&](const auto& field) { + if (field.field_id.has_value()) { + auto update_it = updates_.find(field.field_id.value()); + if (update_it != updates_.end()) { + update_assignments.emplace(std::string(update_it->second.name()), + field.field_id.value()); + } + } + }); + + // Remove reassigned names from all fields + for (auto& field : field_results) { + field = RemoveReassignedNames(field, update_assignments); + } + + return MappedFields::Make(std::move(field_results)); + } + + Result VisitField(const MappedField& field) { + // Update this field's names + std::unordered_set field_names = field.names; + if (field.field_id.has_value()) { + auto update_it = updates_.find(field.field_id.value()); + if (update_it != updates_.end()) { + field_names.insert(std::string(update_it->second.name())); + } + } + + std::unique_ptr nested_mapping = nullptr; + if (field.nested_mapping != nullptr) { + auto nested_result = VisitFields(*field.nested_mapping); + ICEBERG_RETURN_UNEXPECTED(nested_result); + nested_mapping = std::move(*nested_result); + } + + // Add a new mapping for any new nested fields + if (field.field_id.has_value()) { + auto nested_result = + AddNewFields(std::move(nested_mapping), field.field_id.value()); + ICEBERG_RETURN_UNEXPECTED(nested_result); + nested_mapping = std::move(*nested_result); + } + + return MappedField{ + .names = std::move(field_names), + .field_id = field.field_id, + .nested_mapping = std::move(nested_mapping), + }; + } + + Result> AddNewFields( + std::unique_ptr mapping, int32_t parent_id) { + auto range = adds_.equal_range(parent_id); + std::vector fields_to_add; + for (auto it = range.first; it != range.second; ++it) { + auto update_it = updates_.find(it->second); + if (update_it != updates_.end()) { + fields_to_add.push_back(&update_it->second); + } + } + + if (fields_to_add.empty()) { + return std::move(mapping); + } + + std::vector new_fields; + CreateMappingVisitor create_visitor; + for (const auto* field_to_add : fields_to_add) { + auto nested_result = VisitType( + *field_to_add->type(), + [&create_visitor](const auto& type) { return create_visitor.Visit(type); }); + ICEBERG_RETURN_UNEXPECTED(nested_result); + + new_fields.emplace_back(MappedField{ + .names = {std::string(field_to_add->name())}, + .field_id = field_to_add->field_id(), + .nested_mapping = std::move(*nested_result), + }); + } + + if (mapping == nullptr || mapping->Size() == 0) { + return MappedFields::Make(std::move(new_fields)); + } + + // Build assignments map for removing reassigned names + std::unordered_map assignments; + for (const auto* field_to_add : fields_to_add) { + assignments.emplace(std::string(field_to_add->name()), field_to_add->field_id()); + } + + // create a copy of fields that can be updated (append new fields, replace existing + // for reassignment) + std::vector fields; + fields.reserve(mapping->Size() + new_fields.size()); + for (const auto& field : mapping->fields()) { + fields.push_back(RemoveReassignedNames(field, assignments)); + } + + fields.insert(fields.end(), std::make_move_iterator(new_fields.begin()), + std::make_move_iterator(new_fields.end())); + + return MappedFields::Make(std::move(fields)); + } + + static MappedField RemoveReassignedNames( + const MappedField& field, + const std::unordered_map& assignments) { + std::unordered_set updated_names = field.names; + std::erase_if(updated_names, [&](const std::string& name) { + auto assign_it = assignments.find(name); + return assign_it != assignments.end() && + (!field.field_id.has_value() || assign_it->second != field.field_id.value()); + }); + return MappedField{ + .names = std::move(updated_names), + .field_id = field.field_id, + .nested_mapping = field.nested_mapping, + }; + } + + const std::map& updates_; + const std::multimap& adds_; +}; + } // namespace Result> CreateMapping(const Schema& schema) { @@ -335,4 +487,13 @@ Result> CreateMapping(const Schema& schema) { return NameMapping::Make(std::move(*result)); } +Result> UpdateMapping( + const NameMapping& mapping, const std::map& updates, + const std::multimap& adds) { + UpdateMappingVisitor visitor(updates, adds); + auto result = visitor.VisitMapping(mapping); + ICEBERG_RETURN_UNEXPECTED(result); + return NameMapping::Make(std::move(*result)); +} + } // namespace iceberg diff --git a/src/iceberg/name_mapping.h b/src/iceberg/name_mapping.h index 41ff2d14e..1eff32e47 100644 --- a/src/iceberg/name_mapping.h +++ b/src/iceberg/name_mapping.h @@ -20,6 +20,7 @@ #pragma once #include +#include #include #include #include @@ -143,16 +144,14 @@ ICEBERG_EXPORT std::string ToString(const NameMapping& mapping); /// \return A new NameMapping instance initialized with the schema's fields and names. ICEBERG_EXPORT Result> CreateMapping(const Schema& schema); -/// TODO(gangwu): implement this function once SchemaUpdate is supported -/// /// \brief Update a name-based mapping using changes to a schema. /// \param mapping a name-based mapping /// \param updates a map from field ID to updated field definitions /// \param adds a map from parent field ID to nested fields to be added /// \return an updated mapping with names added to renamed fields and the mapping extended /// for new fields -// ICEBERG_EXPORT Result> UpdateMapping( -// const NameMapping& mapping, const std::map& updates, -// const std::multimap& adds); +ICEBERG_EXPORT Result> UpdateMapping( + const NameMapping& mapping, const std::map& updates, + const std::multimap& adds); } // namespace iceberg diff --git a/src/iceberg/test/CMakeLists.txt b/src/iceberg/test/CMakeLists.txt index 215d883b0..ccd76e0da 100644 --- a/src/iceberg/test/CMakeLists.txt +++ b/src/iceberg/test/CMakeLists.txt @@ -179,6 +179,7 @@ if(ICEBERG_BUILD_BUNDLE) SOURCES expire_snapshots_test.cc fast_append_test.cc + name_mapping_update_test.cc set_snapshot_test.cc transaction_test.cc update_location_test.cc diff --git a/src/iceberg/test/name_mapping_update_test.cc b/src/iceberg/test/name_mapping_update_test.cc new file mode 100644 index 000000000..f63a4c6d3 --- /dev/null +++ b/src/iceberg/test/name_mapping_update_test.cc @@ -0,0 +1,236 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include +#include + +#include +#include + +#include "iceberg/json_serde_internal.h" +#include "iceberg/name_mapping.h" +#include "iceberg/schema.h" +#include "iceberg/table_properties.h" +#include "iceberg/test/matchers.h" +#include "iceberg/test/update_test_base.h" +#include "iceberg/type.h" +#include "iceberg/update/update_properties.h" +#include "iceberg/update/update_schema.h" + +namespace iceberg { + +class UpdateMappingTest : public UpdateTestBase {}; + +TEST_F(UpdateMappingTest, AddColumnMappingUpdate) { + // Set initial name mapping to match current schema (x, y, z) + ICEBERG_UNWRAP_OR_FAIL(auto schema, table_->metadata()->Schema()); + auto initial_mapping = CreateMapping(*schema); + ASSERT_TRUE(initial_mapping.has_value()); + ICEBERG_UNWRAP_OR_FAIL(auto mapping_json, ToJsonString(ToJson(**initial_mapping))); + + ICEBERG_UNWRAP_OR_FAIL(auto props_update, table_->NewUpdateProperties()); + props_update->Set(std::string(TableProperties::kDefaultNameMapping), + std::move(mapping_json)); + EXPECT_THAT(props_update->Commit(), IsOk()); + + ICEBERG_UNWRAP_OR_FAIL(auto reloaded, catalog_->LoadTable(table_ident_)); + + // Add column ts + ICEBERG_UNWRAP_OR_FAIL(auto schema_update, reloaded->NewUpdateSchema()); + schema_update->AddColumn("ts", timestamp_tz(), "Timestamp"); + EXPECT_THAT(schema_update->Commit(), IsOk()); + + ICEBERG_UNWRAP_OR_FAIL(auto updated_table, catalog_->LoadTable(table_ident_)); + auto updated_mapping_str = updated_table->metadata()->properties.configs().at( + std::string(TableProperties::kDefaultNameMapping)); + ICEBERG_UNWRAP_OR_FAIL(auto json, FromJsonString(updated_mapping_str)); + ICEBERG_UNWRAP_OR_FAIL(auto updated_mapping, NameMappingFromJson(json)); + + auto expected = MappedFields::Make({ + MappedField{.names = {"x"}, .field_id = 1}, + MappedField{.names = {"y"}, .field_id = 2}, + MappedField{.names = {"z"}, .field_id = 3}, + MappedField{.names = {"ts"}, .field_id = 4}, + }); + EXPECT_EQ(updated_mapping->AsMappedFields(), *expected); +} + +TEST_F(UpdateMappingTest, AddNestedColumnMappingUpdate) { + // Set initial name mapping (schema has x, y, z) + ICEBERG_UNWRAP_OR_FAIL(auto schema, table_->metadata()->Schema()); + auto initial_mapping = CreateMapping(*schema); + ASSERT_TRUE(initial_mapping.has_value()); + ICEBERG_UNWRAP_OR_FAIL(auto mapping_json, ToJsonString(ToJson(**initial_mapping))); + + ICEBERG_UNWRAP_OR_FAIL(auto props_update, table_->NewUpdateProperties()); + props_update->Set(std::string(TableProperties::kDefaultNameMapping), + std::move(mapping_json)); + EXPECT_THAT(props_update->Commit(), IsOk()); + + ICEBERG_UNWRAP_OR_FAIL(auto reloaded, catalog_->LoadTable(table_ident_)); + + // Add point struct with x, y - mapping updated automatically on commit + auto point_struct = std::make_shared(std::vector{ + SchemaField::MakeRequired(4, "x", float64()), + SchemaField::MakeRequired(5, "y", float64()), + }); + ICEBERG_UNWRAP_OR_FAIL(auto add_point, reloaded->NewUpdateSchema()); + add_point->AddColumn("point", point_struct, "Point struct"); + EXPECT_THAT(add_point->Commit(), IsOk()); + + ICEBERG_UNWRAP_OR_FAIL(auto with_point, catalog_->LoadTable(table_ident_)); + + // Add point.z - mapping updated automatically on commit + ICEBERG_UNWRAP_OR_FAIL(auto add_z, with_point->NewUpdateSchema()); + add_z->AddColumn("point", "z", float64(), "Z coordinate"); + EXPECT_THAT(add_z->Commit(), IsOk()); + + ICEBERG_UNWRAP_OR_FAIL(auto with_z, catalog_->LoadTable(table_ident_)); + auto mapping_after_z = with_z->metadata()->properties.configs().at( + std::string(TableProperties::kDefaultNameMapping)); + ICEBERG_UNWRAP_OR_FAIL(auto json2, FromJsonString(mapping_after_z)); + ICEBERG_UNWRAP_OR_FAIL(auto mapping2, NameMappingFromJson(json2)); + + auto expected_after_z = MappedFields::Make({ + MappedField{.names = {"x"}, .field_id = 1}, + MappedField{.names = {"y"}, .field_id = 2}, + MappedField{.names = {"z"}, .field_id = 3}, + MappedField{.names = {"point"}, + .field_id = 4, + .nested_mapping = MappedFields::Make({ + MappedField{.names = {"x"}, .field_id = 5}, + MappedField{.names = {"y"}, .field_id = 6}, + MappedField{.names = {"z"}, .field_id = 7}, + })}, + }); + EXPECT_EQ(mapping2->AsMappedFields(), *expected_after_z); +} + +TEST_F(UpdateMappingTest, RenameMappingUpdate) { + ICEBERG_UNWRAP_OR_FAIL(auto schema, table_->metadata()->Schema()); + auto initial_mapping = CreateMapping(*schema); + ASSERT_TRUE(initial_mapping.has_value()); + ICEBERG_UNWRAP_OR_FAIL(auto mapping_json, ToJsonString(ToJson(**initial_mapping))); + + ICEBERG_UNWRAP_OR_FAIL(auto props_update, table_->NewUpdateProperties()); + props_update->Set(std::string(TableProperties::kDefaultNameMapping), + std::move(mapping_json)); + EXPECT_THAT(props_update->Commit(), IsOk()); + + ICEBERG_UNWRAP_OR_FAIL(auto reloaded, catalog_->LoadTable(table_ident_)); + + // Rename x -> x_renamed + ICEBERG_UNWRAP_OR_FAIL(auto rename_update, reloaded->NewUpdateSchema()); + rename_update->RenameColumn("x", "x_renamed"); + EXPECT_THAT(rename_update->Commit(), IsOk()); + + ICEBERG_UNWRAP_OR_FAIL(auto updated_table, catalog_->LoadTable(table_ident_)); + auto updated_mapping_str = updated_table->metadata()->properties.configs().at( + std::string(TableProperties::kDefaultNameMapping)); + ICEBERG_UNWRAP_OR_FAIL(auto json, FromJsonString(updated_mapping_str)); + ICEBERG_UNWRAP_OR_FAIL(auto updated_mapping, NameMappingFromJson(json)); + + // Field 1 should have both names + EXPECT_THAT(updated_mapping->Find(1)->get().names, + testing::UnorderedElementsAre("x", "x_renamed")); +} + +TEST_F(UpdateMappingTest, RenameNestedFieldMappingUpdate) { + auto point_struct = std::make_shared(std::vector{ + SchemaField::MakeRequired(4, "x", float64()), + SchemaField::MakeRequired(5, "y", float64()), + }); + ICEBERG_UNWRAP_OR_FAIL(auto add_point, table_->NewUpdateSchema()); + add_point->AddColumn("point", point_struct, "Point struct"); + EXPECT_THAT(add_point->Commit(), IsOk()); + + ICEBERG_UNWRAP_OR_FAIL(auto with_point, catalog_->LoadTable(table_ident_)); + ICEBERG_UNWRAP_OR_FAIL(auto schema, with_point->metadata()->Schema()); + auto initial_mapping = CreateMapping(*schema); + ASSERT_TRUE(initial_mapping.has_value()); + ICEBERG_UNWRAP_OR_FAIL(auto mapping_json, ToJsonString(ToJson(**initial_mapping))); + + ICEBERG_UNWRAP_OR_FAIL(auto props_update, with_point->NewUpdateProperties()); + props_update->Set(std::string(TableProperties::kDefaultNameMapping), + std::move(mapping_json)); + EXPECT_THAT(props_update->Commit(), IsOk()); + + ICEBERG_UNWRAP_OR_FAIL(auto with_mapping, catalog_->LoadTable(table_ident_)); + + // Rename point.x -> X, point.y -> Y + ICEBERG_UNWRAP_OR_FAIL(auto rename_update, with_mapping->NewUpdateSchema()); + rename_update->RenameColumn("point.x", "X").RenameColumn("point.y", "Y"); + EXPECT_THAT(rename_update->Commit(), IsOk()); + + ICEBERG_UNWRAP_OR_FAIL(auto updated_table, catalog_->LoadTable(table_ident_)); + auto updated_mapping_str = updated_table->metadata()->properties.configs().at( + std::string(TableProperties::kDefaultNameMapping)); + ICEBERG_UNWRAP_OR_FAIL(auto json, FromJsonString(updated_mapping_str)); + ICEBERG_UNWRAP_OR_FAIL(auto updated_mapping, NameMappingFromJson(json)); + + auto point_field = updated_mapping->Find("point"); + ASSERT_TRUE(point_field.has_value()); + auto x_field = updated_mapping->Find("point.X"); + ASSERT_TRUE(x_field.has_value()); + auto y_field = updated_mapping->Find("point.Y"); + ASSERT_TRUE(y_field.has_value()); + EXPECT_THAT(x_field->get().names, testing::UnorderedElementsAre("x", "X")); + EXPECT_THAT(y_field->get().names, testing::UnorderedElementsAre("y", "Y")); +} + +TEST_F(UpdateMappingTest, RenameComplexFieldMappingUpdate) { + auto point_struct = std::make_shared(std::vector{ + SchemaField::MakeRequired(4, "x", float64()), + SchemaField::MakeRequired(5, "y", float64()), + }); + ICEBERG_UNWRAP_OR_FAIL(auto add_point, table_->NewUpdateSchema()); + add_point->AddColumn("point", point_struct, "Point struct"); + EXPECT_THAT(add_point->Commit(), IsOk()); + + ICEBERG_UNWRAP_OR_FAIL(auto with_point, catalog_->LoadTable(table_ident_)); + ICEBERG_UNWRAP_OR_FAIL(auto schema, with_point->metadata()->Schema()); + auto initial_mapping = CreateMapping(*schema); + ASSERT_TRUE(initial_mapping.has_value()); + ICEBERG_UNWRAP_OR_FAIL(auto mapping_json, ToJsonString(ToJson(**initial_mapping))); + + ICEBERG_UNWRAP_OR_FAIL(auto props_update, with_point->NewUpdateProperties()); + props_update->Set(std::string(TableProperties::kDefaultNameMapping), + std::move(mapping_json)); + EXPECT_THAT(props_update->Commit(), IsOk()); + + ICEBERG_UNWRAP_OR_FAIL(auto with_mapping, catalog_->LoadTable(table_ident_)); + + // Rename point -> p2 + ICEBERG_UNWRAP_OR_FAIL(auto rename_update, with_mapping->NewUpdateSchema()); + rename_update->RenameColumn("point", "p2"); + EXPECT_THAT(rename_update->Commit(), IsOk()); + + ICEBERG_UNWRAP_OR_FAIL(auto updated_table, catalog_->LoadTable(table_ident_)); + auto updated_mapping_str = updated_table->metadata()->properties.configs().at( + std::string(TableProperties::kDefaultNameMapping)); + ICEBERG_UNWRAP_OR_FAIL(auto json, FromJsonString(updated_mapping_str)); + ICEBERG_UNWRAP_OR_FAIL(auto updated_mapping, NameMappingFromJson(json)); + + // Field 4 (point) should have both names + auto point_field = updated_mapping->Find(4); + ASSERT_TRUE(point_field.has_value()); + EXPECT_THAT(point_field->get().names, testing::UnorderedElementsAre("point", "p2")); +} + +} // namespace iceberg diff --git a/src/iceberg/transaction.cc b/src/iceberg/transaction.cc index b24aa0da3..6aee6f786 100644 --- a/src/iceberg/transaction.cc +++ b/src/iceberg/transaction.cc @@ -222,6 +222,10 @@ Status Transaction::ApplyUpdateSchema(UpdateSchema& update) { ICEBERG_ASSIGN_OR_RAISE(auto result, update.Apply()); metadata_builder_->SetCurrentSchema(std::move(result.schema), result.new_last_column_id); + if (!result.updated_props.empty()) { + metadata_builder_->SetProperties(result.updated_props); + } + return {}; } diff --git a/src/iceberg/update/update_schema.cc b/src/iceberg/update/update_schema.cc index a4c453491..ff1b25007 100644 --- a/src/iceberg/update/update_schema.cc +++ b/src/iceberg/update/update_schema.cc @@ -29,9 +29,12 @@ #include #include +#include "iceberg/json_serde_internal.h" +#include "iceberg/name_mapping.h" #include "iceberg/schema.h" #include "iceberg/schema_field.h" #include "iceberg/table_metadata.h" +#include "iceberg/table_properties.h" #include "iceberg/transaction.h" #include "iceberg/type.h" #include "iceberg/util/checked_cast.h" @@ -592,8 +595,33 @@ Result UpdateSchema::Apply() { auto new_schema, Schema::Make(std::move(new_fields), schema_->schema_id(), fresh_identifier_ids)); + std::unordered_map updated_props; + const auto& base_metadata = base(); + const auto& properties = base_metadata.properties.configs(); + + auto mapping_it = properties.find(std::string(TableProperties::kDefaultNameMapping)); + if (mapping_it != properties.end() && !mapping_it->second.empty()) { + std::map updates; + for (const auto& [id, field_ptr] : updates_) { + updates.emplace(id, *field_ptr); + } + std::multimap adds; + for (const auto& [parent_id, child_ids] : parent_to_added_ids_) { + std::ranges::for_each(child_ids, [&adds, parent_id](int32_t child_id) { + adds.emplace(parent_id, child_id); + }); + } + auto updated_mapping_json = + UpdateMappingFromJsonString(mapping_it->second, updates, adds); + if (updated_mapping_json) { + updated_props[std::string(TableProperties::kDefaultNameMapping)] = + std::move(*updated_mapping_json); + } + } + return ApplyResult{.schema = std::move(new_schema), - .new_last_column_id = last_column_id_}; + .new_last_column_id = last_column_id_, + .updated_props = std::move(updated_props)}; } // TODO(Guotao Yu): v3 default value is not yet supported diff --git a/src/iceberg/update/update_schema.h b/src/iceberg/update/update_schema.h index a1c3e92d2..2223c0b81 100644 --- a/src/iceberg/update/update_schema.h +++ b/src/iceberg/update/update_schema.h @@ -337,6 +337,7 @@ class ICEBERG_EXPORT UpdateSchema : public PendingUpdate { struct ApplyResult { std::shared_ptr schema; int32_t new_last_column_id; + std::unordered_map updated_props; }; /// \brief Apply the pending changes to the original schema and return the result.