This is an automated email from the ASF dual-hosted git repository. zeroshade pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push: new 941d301d feat(metadata): prevent reserved table properties from being set / removed (#572) 941d301d is described below commit 941d301dfafcf9c2155a360fcfd3f3e0388bbb92 Author: Tobias Pütz <tob...@minio.io> AuthorDate: Mon Sep 22 22:07:43 2025 +0200 feat(metadata): prevent reserved table properties from being set / removed (#572) --- catalog/sql/sql_test.go | 4 +-- table/metadata.go | 16 ++++++++-- table/metadata_builder_internal_test.go | 54 +++++++++++++++++++++++++++++++++ table/metadata_internal_test.go | 2 +- table/properties.go | 25 +++++++++++++++ table/table_test.go | 18 +++++------ 6 files changed, 104 insertions(+), 15 deletions(-) diff --git a/catalog/sql/sql_test.go b/catalog/sql/sql_test.go index 2581486c..b4b061a1 100644 --- a/catalog/sql/sql_test.go +++ b/catalog/sql/sql_test.go @@ -386,7 +386,7 @@ func (s *SqliteCatalogTestSuite) TestCreateV1Table() { ns := catalog.NamespaceFromIdent(tt.tblID) s.Require().NoError(tt.cat.CreateNamespace(context.Background(), ns, nil)) tbl, err := tt.cat.CreateTable(context.Background(), tt.tblID, tableSchemaNested, - catalog.WithProperties(iceberg.Properties{"format-version": "1"})) + catalog.WithProperties(iceberg.Properties{table.PropertyFormatVersion: "1"})) s.Require().NoError(err) s.FileExists(strings.TrimPrefix(tbl.MetadataLocation(), "file://")) @@ -441,7 +441,7 @@ func (s *SqliteCatalogTestSuite) TestCreateDuplicatedTable() { ns := catalog.NamespaceFromIdent(tt.tblID) s.Require().NoError(tt.cat.CreateNamespace(context.Background(), ns, nil)) _, err := tt.cat.CreateTable(context.Background(), tt.tblID, tableSchemaNested, - catalog.WithProperties(iceberg.Properties{"format-version": "1"})) + catalog.WithProperties(iceberg.Properties{table.PropertyFormatVersion: "1"})) s.Require().NoError(err) _, err = tt.cat.CreateTable(context.Background(), tt.tblID, tableSchemaNested) diff --git a/table/metadata.go b/table/metadata.go index bae44694..f59f11a3 100644 --- a/table/metadata.go +++ b/table/metadata.go @@ -415,7 +415,11 @@ func (b *MetadataBuilder) RemoveProperties(keys []string) error { if len(keys) == 0 { return nil } - + for _, reserved := range ReservedProperties { + if slices.Contains(keys, reserved) { + return fmt.Errorf("can't remove reserved property %s", reserved) + } + } b.updates = append(b.updates, NewRemovePropertiesUpdate(keys)) for _, key := range keys { delete(b.props, key) @@ -543,6 +547,12 @@ func (b *MetadataBuilder) SetProperties(props iceberg.Properties) error { return nil } + for _, key := range ReservedProperties { + if _, ok := props[key]; ok { + return fmt.Errorf("can't set reserved property %s", key) + } + } + b.updates = append(b.updates, NewSetPropertiesUpdate(props)) if b.props == nil { b.props = props @@ -1443,12 +1453,12 @@ func NewMetadataWithUUID(sc *iceberg.Schema, partitions *iceberg.PartitionSpec, var err error formatVersion := DefaultFormatVersion if props != nil { - verStr, ok := props["format-version"] + verStr, ok := props[PropertyFormatVersion] if ok { if formatVersion, err = strconv.Atoi(verStr); err != nil { formatVersion = DefaultFormatVersion } - delete(props, "format-version") + delete(props, PropertyFormatVersion) } } diff --git a/table/metadata_builder_internal_test.go b/table/metadata_builder_internal_test.go index 0c733110..3c5fc985 100644 --- a/table/metadata_builder_internal_test.go +++ b/table/metadata_builder_internal_test.go @@ -442,3 +442,57 @@ func TestDefaultSpecCannotBeRemoved(t *testing.T) { require.ErrorContains(t, builder.RemovePartitionSpecs([]int{0}), "can't remove default partition spec with id 0") } + +func TestSetReservedPropertiesFails(t *testing.T) { + builder := builderWithoutChanges(2) + + // Test that setting non-reserved properties works + err := builder.SetProperties(iceberg.Properties{ + "custom-property": "value1", + "another-custom-property": "value2", + }) + require.NoError(t, err) + require.True(t, builder.HasChanges()) + + // Test setting each reserved property individually + for _, reserved := range ReservedProperties { + err := builder.SetProperties(iceberg.Properties{reserved: "some-value"}) + require.ErrorContains(t, err, "can't set reserved property "+reserved) + } + + // Test setting multiple properties where one is reserved + err = builder.SetProperties(iceberg.Properties{ + "custom-property": "allowed", + PropertyCurrentSnapshotId: "12345", + "another-custom-property": "also-allowed", + }) + require.ErrorContains(t, err, "can't set reserved property "+PropertyCurrentSnapshotId) +} + +func TestRemoveReservedPropertiesFails(t *testing.T) { + builder := builderWithoutChanges(2) + + // Test removing each reserved property individually + for _, reserved := range ReservedProperties { + err := builder.RemoveProperties([]string{reserved}) + require.ErrorContains(t, err, "can't remove reserved property "+reserved) + } + + // Test removing multiple properties where one is reserved + err := builder.RemoveProperties([]string{ + "custom-property", + PropertyUuid, + "another-custom-property", + }) + require.ErrorContains(t, err, "can't remove reserved property "+PropertyUuid) + + // Add some custom properties first, then test that removing non-reserved properties works + require.NoError(t, builder.SetProperties(iceberg.Properties{ + "custom-property": "value1", + "another-custom-property": "value2", + })) + + err = builder.RemoveProperties([]string{"custom-property"}) + require.NoError(t, err) + require.True(t, builder.HasChanges()) +} diff --git a/table/metadata_internal_test.go b/table/metadata_internal_test.go index 78bc5fe0..6863b201 100644 --- a/table/metadata_internal_test.go +++ b/table/metadata_internal_test.go @@ -560,7 +560,7 @@ func TestNewMetadataWithExplicitV1Format(t *testing.T) { }}, } - actual, err := NewMetadata(schema, &partitionSpec, sortOrder, "s3://some_v1_location/", iceberg.Properties{"format-version": "1"}) + actual, err := NewMetadata(schema, &partitionSpec, sortOrder, "s3://some_v1_location/", iceberg.Properties{PropertyFormatVersion: "1"}) require.NoError(t, err) expectedSchema := iceberg.NewSchemaWithIdentifiers(0, []int{2}, diff --git a/table/properties.go b/table/properties.go index bb3c2b08..8ef3d026 100644 --- a/table/properties.go +++ b/table/properties.go @@ -85,3 +85,28 @@ const ( MaxRefAgeMsKey = "max-ref-age-ms" MaxRefAgeMsDefault = math.MaxInt ) + +// Reserved properties +const ( + PropertyFormatVersion = "format-version" + PropertyUuid = "uuid" + PropertySnapshotCount = "snapshot-count" + PropertyCurrentSnapshotId = "current-snapshot-id" + PropertyCurrentSnapshotSummary = "current-snapshot-summary" + PropertyCurrentSnapshotTimestamp = "current-snapshot-timestamp" + PropertyCurrentSchema = "current-schema" + PropertyDefaultPartitionSpec = "default-partition-spec" + PropertyDefaultSortOrder = "default-sort-order" +) + +var ReservedProperties = [9]string{ + PropertyFormatVersion, + PropertyUuid, + PropertySnapshotCount, + PropertyCurrentSnapshotId, + PropertyCurrentSnapshotSummary, + PropertyCurrentSnapshotTimestamp, + PropertyCurrentSchema, + PropertyDefaultPartitionSpec, + PropertyDefaultSortOrder, +} diff --git a/table/table_test.go b/table/table_test.go index 93050048..694e860b 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -318,7 +318,7 @@ func (t *TableWritingTestSuite) writeParquet(fio iceio.WriteFileIO, filePath str func (t *TableWritingTestSuite) createTable(identifier table.Identifier, formatVersion int, spec iceberg.PartitionSpec, sc *iceberg.Schema) *table.Table { meta, err := table.NewMetadata(sc, &spec, table.UnsortedSortOrder, - t.location, iceberg.Properties{"format-version": strconv.Itoa(formatVersion)}) + t.location, iceberg.Properties{table.PropertyFormatVersion: strconv.Itoa(formatVersion)}) t.Require().NoError(err) return table.New( @@ -820,7 +820,7 @@ func (t *TableWritingTestSuite) TestReplaceDataFiles() { ident := table.Identifier{"default", "replace_data_files_v" + strconv.Itoa(t.formatVersion)} meta, err := table.NewMetadata(t.tableSchemaPromotedTypes, iceberg.UnpartitionedSpec, - table.UnsortedSortOrder, t.location, iceberg.Properties{"format-version": strconv.Itoa(t.formatVersion)}) + table.UnsortedSortOrder, t.location, iceberg.Properties{table.PropertyFormatVersion: strconv.Itoa(t.formatVersion)}) t.Require().NoError(err) ctx := context.Background() @@ -902,7 +902,7 @@ func (t *TableWritingTestSuite) TestExpireSnapshots() { ident := table.Identifier{"default", "replace_data_files_v" + strconv.Itoa(t.formatVersion)} meta, err := table.NewMetadata(t.tableSchemaPromotedTypes, iceberg.UnpartitionedSpec, - table.UnsortedSortOrder, t.location, iceberg.Properties{"format-version": strconv.Itoa(t.formatVersion)}) + table.UnsortedSortOrder, t.location, iceberg.Properties{table.PropertyFormatVersion: strconv.Itoa(t.formatVersion)}) t.Require().NoError(err) ctx := context.Background() @@ -1152,7 +1152,7 @@ func (t *TableWritingTestSuite) TestMergeManifests() { table.ParquetCompressionKey: "snappy", table.ManifestMergeEnabledKey: "true", table.ManifestMinMergeCountKey: "1", - "format-version": strconv.Itoa(t.formatVersion), + table.PropertyFormatVersion: strconv.Itoa(t.formatVersion), }, tableSchema()) tblB := t.createTableWithProps(table.Identifier{"default", "merge_manifest_b"}, @@ -1161,14 +1161,14 @@ func (t *TableWritingTestSuite) TestMergeManifests() { table.ManifestMergeEnabledKey: "true", table.ManifestMinMergeCountKey: "1", table.ManifestTargetSizeBytesKey: "1", - "format-version": strconv.Itoa(t.formatVersion), + table.PropertyFormatVersion: strconv.Itoa(t.formatVersion), }, tableSchema()) tblC := t.createTableWithProps(table.Identifier{"default", "merge_manifest_c"}, iceberg.Properties{ table.ParquetCompressionKey: "snappy", table.ManifestMinMergeCountKey: "1", - "format-version": strconv.Itoa(t.formatVersion), + table.PropertyFormatVersion: strconv.Itoa(t.formatVersion), }, tableSchema()) arrTable := arrowTableWithNull() @@ -1300,7 +1300,7 @@ func TestNullableStructRequiredField(t *testing.T) { require.NoError(t, cat.CreateNamespace(t.Context(), table.Identifier{"testing"}, nil)) tbl, err := cat.CreateTable(t.Context(), table.Identifier{"testing", "nullable_struct_required_field"}, sc, - catalog.WithProperties(iceberg.Properties{"format-version": "2"}), + catalog.WithProperties(iceberg.Properties{table.PropertyFormatVersion: "2"}), catalog.WithLocation("file://"+loc)) require.NoError(t, err) require.NotNil(t, tbl) @@ -1397,7 +1397,7 @@ func (t *TableWritingTestSuite) TestDeleteOldMetadataLogsErrorOnFileNotFound() { ident := table.Identifier{"default", "file_v" + strconv.Itoa(t.formatVersion)} meta, err := table.NewMetadata(t.tableSchemaPromotedTypes, iceberg.UnpartitionedSpec, - table.UnsortedSortOrder, t.location, iceberg.Properties{"format-version": strconv.Itoa(t.formatVersion), "write.metadata.delete-after-commit.enabled": "true"}) + table.UnsortedSortOrder, t.location, iceberg.Properties{table.PropertyFormatVersion: strconv.Itoa(t.formatVersion), "write.metadata.delete-after-commit.enabled": "true"}) t.Require().NoError(err) tbl := table.New(ident, meta, t.getMetadataLoc(), func(ctx context.Context) (iceio.IO, error) { @@ -1443,7 +1443,7 @@ func (t *TableWritingTestSuite) TestDeleteOldMetadataNoErrorLogsOnFileFound() { } ident := table.Identifier{"default", "file_v" + strconv.Itoa(t.formatVersion)} - meta, err := table.NewMetadata(t.tableSchemaPromotedTypes, iceberg.UnpartitionedSpec, table.UnsortedSortOrder, t.location, iceberg.Properties{"format-version": strconv.Itoa(t.formatVersion), "write.metadata.delete-after-commit.enabled": "true"}) + meta, err := table.NewMetadata(t.tableSchemaPromotedTypes, iceberg.UnpartitionedSpec, table.UnsortedSortOrder, t.location, iceberg.Properties{table.PropertyFormatVersion: strconv.Itoa(t.formatVersion), "write.metadata.delete-after-commit.enabled": "true"}) t.Require().NoError(err) tbl := table.New(