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 <[email protected]>
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(