You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by ze...@apache.org on 2025/09/18 19:03:26 UTC

(iceberg-go) branch main updated: feat(metadata): remove schemas + validations & tests (#567)

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 4ebe066d feat(metadata): remove schemas + validations & tests (#567)
4ebe066d is described below

commit 4ebe066d30e269e18b477d36947973104cc22999
Author: Tobias Pütz <to...@minio.io>
AuthorDate: Thu Sep 18 21:03:21 2025 +0200

    feat(metadata): remove schemas + validations & tests (#567)
    
    1. implement removeSchemas update
    2. Add validation of sortOrders against schema
---
 partitions.go                                      |  2 +-
 table/metadata.go                                  | 71 ++++++++++++++--
 table/metadata_builder_internal_test.go            | 94 +++++++++++++++++++++-
 table/metadata_internal_test.go                    | 46 ++++++++++-
 table/sorting.go                                   | 24 ++++++
 table/testdata/TableMetadataV1NoValidSchema.json   | 45 +++++++++++
 .../TableMetadataV1SchemasWithoutCurrentId.json    | 69 ++++++++++++++++
 .../TableMetadataV2CurrentSchemaNotFound.json      | 88 ++++++++++++++++++++
 table/testdata/TableMetadataV2MissingSchemas.json  | 71 ++++++++++++++++
 table/updates.go                                   |  8 +-
 table/updates_test.go                              | 13 ---
 11 files changed, 502 insertions(+), 29 deletions(-)

diff --git a/partitions.go b/partitions.go
index db66abd2..7bf5c0a2 100644
--- a/partitions.go
+++ b/partitions.go
@@ -96,7 +96,7 @@ type PartitionOption func(*PartitionSpec) error
 // Otherwise, the existing spec id will be used.
 // If a field in the spec is incompatible with the schema, an error will be
 // returned.
-func (p *PartitionSpec) BindToSchema(schema *Schema, lastPartitionID *int, newSpecID *int, isUnbound bool) (PartitionSpec, error) {
+func (p *PartitionSpec) BindToSchema(schema *Schema, lastPartitionID *int, newSpecID *int) (PartitionSpec, error) {
 	opts := make([]PartitionOption, 0)
 	if newSpecID != nil {
 		opts = append(opts, WithSpecID(*newSpecID))
diff --git a/table/metadata.go b/table/metadata.go
index 568c9fd3..bae44694 100644
--- a/table/metadata.go
+++ b/table/metadata.go
@@ -286,8 +286,12 @@ func (b *MetadataBuilder) AddSchema(schema *iceberg.Schema) error {
 
 func (b *MetadataBuilder) AddPartitionSpec(spec *iceberg.PartitionSpec, initial bool) error {
 	newSpecID := b.reuseOrCreateNewPartitionSpecID(*spec)
+	curSchema := b.CurrentSchema()
+	if curSchema == nil {
+		return errors.New("can't add sort order with no current schema")
+	}
 
-	freshSpec, err := spec.BindToSchema(b.CurrentSchema(), b.lastPartitionID, &newSpecID, false)
+	freshSpec, err := spec.BindToSchema(curSchema, b.lastPartitionID, &newSpecID)
 	if err != nil {
 		return err
 	}
@@ -381,6 +385,15 @@ func (b *MetadataBuilder) RemoveSnapshots(snapshotIds []int64) error {
 }
 
 func (b *MetadataBuilder) AddSortOrder(sortOrder *SortOrder, initial bool) error {
+	curSchema := b.CurrentSchema()
+	if curSchema == nil {
+		return errors.New("can't add sort order with no current schema")
+	}
+
+	if err := sortOrder.CheckCompatibility(curSchema); err != nil {
+		return fmt.Errorf("sort order %s is not compatible with current schema: %w", sortOrder, err)
+	}
+
 	var sortOrders []SortOrder
 	if !initial {
 		sortOrders = append(sortOrders, b.sortOrderList...)
@@ -412,7 +425,8 @@ func (b *MetadataBuilder) RemoveProperties(keys []string) error {
 }
 
 func (b *MetadataBuilder) SetCurrentSchemaID(currentSchemaID int) error {
-	if currentSchemaID == -1 {
+	takeLast := currentSchemaID == -1
+	if takeLast {
 		if b.lastAddedSchemaID == nil {
 			return errors.New("can't set current schema to last added schema, no schema has been added")
 		}
@@ -428,7 +442,11 @@ func (b *MetadataBuilder) SetCurrentSchemaID(currentSchemaID int) error {
 		return fmt.Errorf("can't set current schema to schema with id %d: %w", currentSchemaID, err)
 	}
 
-	b.updates = append(b.updates, NewSetCurrentSchemaUpdate(currentSchemaID))
+	if takeLast {
+		b.updates = append(b.updates, NewSetCurrentSchemaUpdate(-1))
+	} else {
+		b.updates = append(b.updates, NewSetCurrentSchemaUpdate(currentSchemaID))
+	}
 	b.currentSchemaID = currentSchemaID
 
 	return nil
@@ -774,6 +792,7 @@ func (b *MetadataBuilder) Build() (Metadata, error) {
 	if err != nil {
 		return nil, err
 	}
+
 	if err := common.validate(); err != nil {
 		return nil, err
 	}
@@ -871,6 +890,33 @@ func (b *MetadataBuilder) RemovePartitionSpecs(ints []int) error {
 	return nil
 }
 
+func (b *MetadataBuilder) RemoveSchemas(ints []int) error {
+	if len(ints) == 0 {
+		return nil
+	}
+
+	if slices.Contains(ints, b.currentSchemaID) {
+		return fmt.Errorf("can't remove current schema with id %d", b.currentSchemaID)
+	}
+
+	removed := make([]int, len(ints))
+	b.schemaList = slices.DeleteFunc(b.schemaList, func(s *iceberg.Schema) bool {
+		if slices.Contains(ints, s.ID) {
+			removed = append(removed, s.ID)
+
+			return true
+		}
+
+		return false
+	})
+
+	if len(removed) != 0 {
+		b.updates = append(b.updates, NewRemoveSchemasUpdate(ints))
+	}
+
+	return nil
+}
+
 // maxBy returns the maximum value of extract(e) for all e in elems.
 // If elems is empty, returns 0.
 func maxBy[S ~[]E, E any](elems S, extract func(e E) int) int {
@@ -1162,8 +1208,15 @@ func (c *commonMetadata) checkSortOrders() error {
 
 	for _, o := range c.SortOrderList {
 		if o.OrderID == c.DefaultSortOrderID {
+			if err := o.CheckCompatibility(c.CurrentSchema()); err != nil {
+				return fmt.Errorf("default sort order %d is not compatible with current schema: %w", o.OrderID, err)
+			}
+
 			return nil
 		}
+		if o.OrderID == UnsortedSortOrderID && len(o.Fields) != 0 {
+			return fmt.Errorf("sort order ID %d is reserved for unsorted order", UnsortedSortOrderID)
+		}
 	}
 
 	return fmt.Errorf("%w: default-sort-order-id %d can't be found in %+v",
@@ -1417,19 +1470,19 @@ func NewMetadataWithUUID(sc *iceberg.Schema, partitions *iceberg.PartitionSpec,
 		return nil, err
 	}
 
-	if err = builder.AddSortOrder(&reassignedIds.sortOrder, true); err != nil {
+	if err = builder.AddSchema(reassignedIds.schema); err != nil {
 		return nil, err
 	}
 
-	if err = builder.SetDefaultSortOrderID(-1); err != nil {
+	if err = builder.SetCurrentSchemaID(-1); err != nil {
 		return nil, err
 	}
 
-	if err = builder.AddSchema(reassignedIds.schema); err != nil {
+	if err = builder.AddSortOrder(&reassignedIds.sortOrder, true); err != nil {
 		return nil, err
 	}
 
-	if err = builder.SetCurrentSchemaID(-1); err != nil {
+	if err = builder.SetDefaultSortOrderID(-1); err != nil {
 		return nil, err
 	}
 
@@ -1437,6 +1490,10 @@ func NewMetadataWithUUID(sc *iceberg.Schema, partitions *iceberg.PartitionSpec,
 		return nil, err
 	}
 
+	if err = builder.SetDefaultSpecID(-1); err != nil {
+		return nil, err
+	}
+
 	if err = builder.SetLoc(location); err != nil {
 		return nil, err
 	}
diff --git a/table/metadata_builder_internal_test.go b/table/metadata_builder_internal_test.go
index 0ff33869..0c733110 100644
--- a/table/metadata_builder_internal_test.go
+++ b/table/metadata_builder_internal_test.go
@@ -71,15 +71,18 @@ func builderWithoutChanges(formatVersion int) MetadataBuilder {
 	if err = builder.SetFormatVersion(formatVersion); err != nil {
 		panic(err)
 	}
-	if err = builder.AddSortOrder(&sortOrder, true); err != nil {
-		panic(err)
-	}
 	if err = builder.AddSchema(&tableSchema); err != nil {
 		panic(err)
 	}
 	if err = builder.SetCurrentSchemaID(-1); err != nil {
 		panic(err)
 	}
+	if err = builder.AddSortOrder(&sortOrder, true); err != nil {
+		panic(err)
+	}
+	if err = builder.SetDefaultSortOrderID(-1); err != nil {
+		panic(err)
+	}
 	if err = builder.AddPartitionSpec(&partitionSpec, true); err != nil {
 		panic(err)
 	}
@@ -331,6 +334,91 @@ func TestCannotAddDuplicateSnapshotID(t *testing.T) {
 	require.ErrorContains(t, builder.AddSnapshot(&snapshot), "can't add snapshot with id 2, already exists")
 }
 
+func TestAddIncompatibleCurrentSchemaFails(t *testing.T) {
+	builder := builderWithoutChanges(2)
+	addedSchema := iceberg.NewSchema(1)
+	err := builder.AddSchema(addedSchema)
+	require.NoError(t, err)
+	err = builder.SetCurrentSchemaID(1)
+	require.NoError(t, err)
+	_, err = builder.Build()
+	require.ErrorContains(t, err, "with source id 3 not found in schema")
+}
+
+func TestActiveSchemaCannotBeRemoved(t *testing.T) {
+	builder := builderWithoutChanges(2)
+	// Try to remove the current schema
+	require.ErrorContains(t, builder.RemoveSchemas([]int{0}), "can't remove current schema with id 0")
+}
+
+func TestRemoveSchemas(t *testing.T) {
+	meta, err := getTestTableMetadata("TableMetadataV2Valid.json")
+	require.NoError(t, err)
+	require.Len(t, meta.Schemas(), 2, "expected 2 schemas in the metadata")
+	builder, err := MetadataBuilderFromBase(meta)
+	require.NoError(t, err)
+	err = builder.RemoveSchemas([]int{0})
+	require.NoError(t, err, "expected to remove schema with ID 1")
+	newMeta, err := builder.Build()
+	require.NoError(t, err)
+	require.Len(t, newMeta.Schemas(), 1, "expected 1 schema in the metadata after removal")
+	require.Equal(t, 1, newMeta.CurrentSchema().ID, "expected current schema to be 1")
+	require.Equal(t, 1, newMeta.(*metadataV2).CurrentSchemaID)
+	require.Len(t, builder.updates, 1, "expected one update for schema removal")
+	require.Equal(t, builder.updates[0].Action(), UpdateRemoveSchemas)
+	require.Equal(t, builder.updates[0].(*removeSchemasUpdate).SchemaIDs, []int{0}, "expected schema ID 0 to be removed")
+}
+
+// Java: TestTableMetadata.testUpdateSchema
+func TestUpdateSchema(t *testing.T) {
+	// Test schema updates and evolution
+	schema1 := iceberg.NewSchema(
+		0,
+		iceberg.NestedField{ID: 1, Name: "y", Type: iceberg.PrimitiveTypes.Int64, Required: true, Doc: "comment"},
+	)
+
+	meta, err := NewMetadata(
+		schema1,
+		iceberg.UnpartitionedSpec,
+		UnsortedSortOrder,
+		"s3://bucket/test/location",
+		map[string]string{},
+	)
+	require.NoError(t, err)
+
+	require.Equal(t, 0, meta.CurrentSchema().ID)
+	require.Len(t, meta.Schemas(), 1)
+	require.Equal(t, 1, meta.LastColumnID())
+
+	// Update schema by adding a field
+	schema2 := iceberg.NewSchema(
+		1,
+		iceberg.NestedField{ID: 1, Name: "y", Type: iceberg.PrimitiveTypes.Int64, Required: true, Doc: "comment"},
+		iceberg.NestedField{ID: 2, Name: "x", Type: iceberg.PrimitiveTypes.String, Required: true},
+	)
+
+	builder, err := MetadataBuilderFromBase(meta)
+	require.NoError(t, err)
+
+	err = builder.AddSchema(schema2)
+	require.NoError(t, err)
+
+	err = builder.SetCurrentSchemaID(-1) // Use last added
+	require.NoError(t, err)
+
+	updatedMeta, err := builder.Build()
+	require.NoError(t, err)
+
+	require.Equal(t, 1, updatedMeta.CurrentSchema().ID)
+	require.Len(t, updatedMeta.Schemas(), 2)
+	require.Equal(t, 2, updatedMeta.LastColumnID())
+
+	// Verify both schemas are preserved
+	schemas := updatedMeta.Schemas()
+	require.True(t, schemas[0].Equals(schema1))
+	require.True(t, schemas[1].Equals(schema2))
+}
+
 func TestRemoveMainSnapshotRef(t *testing.T) {
 	meta, err := getTestTableMetadata("TableMetadataV2Valid.json")
 	require.NoError(t, err)
diff --git a/table/metadata_internal_test.go b/table/metadata_internal_test.go
index 87c3fd55..78bc5fe0 100644
--- a/table/metadata_internal_test.go
+++ b/table/metadata_internal_test.go
@@ -755,7 +755,9 @@ func TestMetadataV2Serialize(t *testing.T) {
 func TestMetadataBuilderSetDefaultSpecIDLastPartition(t *testing.T) {
 	builder, err := NewMetadataBuilder()
 	assert.NoError(t, err)
-
+	schema := schema()
+	assert.NoError(t, builder.AddSchema(&schema))
+	assert.NoError(t, builder.SetCurrentSchemaID(-1))
 	partitionSpec := iceberg.NewPartitionSpecID(0)
 	assert.NoError(t, builder.AddPartitionSpec(&partitionSpec, false))
 
@@ -986,6 +988,48 @@ func TestDefaultPartitionSpec(t *testing.T) {
 	require.Equal(t, partitionSpec.ID(), defaultSpecID)
 }
 
+func TestTableMetadataV1SchemasWithoutCurrentId(t *testing.T) {
+	meta, err := getTestTableMetadata("TableMetadataV1SchemasWithoutCurrentId.json")
+	require.NoError(t, err)
+	require.Equal(t, meta.(*metadataV1).Version(), 1)
+	require.Equal(t, meta.TableUUID(), uuid.MustParse("d20125c8-7284-442c-9aea-15fee620737c"))
+	schema := meta.CurrentSchema()
+	require.Equal(t, len(schema.Fields()), 3)
+	require.Equal(t, schema.Fields()[0].Name, "x")
+	require.Equal(t, schema.Fields()[1].Name, "y")
+	require.Equal(t, schema.Fields()[2].Name, "z")
+}
+
+func TestTableMetadataV1NoValidSchema(t *testing.T) {
+	meta, err := getTestTableMetadata("TableMetadataV1NoValidSchema.json")
+	require.ErrorContains(t, err, "invalid metadata: current-schema-id -1 can't be found in any schema")
+	require.Nil(t, meta)
+}
+
+func TestTableMetadataV2SchemaNotFound(t *testing.T) {
+	meta, err := getTestTableMetadata("TableMetadataV2CurrentSchemaNotFound.json")
+	require.ErrorContains(t, err, "invalid metadata: current-schema-id 2 can't be found in any schema")
+	require.Nil(t, meta)
+}
+
+func TestTableMetadataV2MissingSchemas(t *testing.T) {
+	meta, err := getTestTableMetadata("TableMetadataV2MissingSchemas.json")
+	require.ErrorContains(t, err, "invalid metadata: current-schema-id -1 can't be found in any schema")
+	require.Nil(t, meta)
+}
+
+// Java: TestTableMetadata.testParseSchemaIdentifierFields
+func TestParseSchemaIdentifierFields(t *testing.T) {
+	meta, err := getTestTableMetadata("TableMetadataV2Valid.json")
+	require.NoError(t, err)
+	// Verify identifier fields
+	schemas := meta.Schemas()
+	require.Len(t, schemas, 2)
+
+	require.Empty(t, schemas[0].IdentifierFieldIDs)
+	require.Equal(t, []int{1, 2}, schemas[1].IdentifierFieldIDs)
+}
+
 func getTestTableMetadata(fileName string) (Metadata, error) {
 	fCont, err := os.ReadFile(path.Join("testdata", fileName))
 	if err != nil {
diff --git a/table/sorting.go b/table/sorting.go
index 1ae9f053..d72ceb78 100644
--- a/table/sorting.go
+++ b/table/sorting.go
@@ -137,6 +137,30 @@ type SortOrder struct {
 	Fields  []SortField `json:"fields"`
 }
 
+func (s *SortOrder) CheckCompatibility(schema *iceberg.Schema) error {
+	if s == nil {
+		return nil
+	}
+
+	for _, field := range s.Fields {
+		f, ok := schema.FindFieldByID(field.SourceID)
+		if !ok {
+			return fmt.Errorf("sort field with source id %d not found in schema", field.SourceID)
+		}
+
+		if _, ok := f.Type.(iceberg.PrimitiveType); !ok {
+			return fmt.Errorf("cannot sort by non-primitive source field: %s", f.Type.Type())
+		}
+
+		// FIXME: field.Transform should be made required
+		if field.Transform != nil && !field.Transform.CanTransform(f.Type) {
+			return fmt.Errorf("invalid source type %s for transform %s", f.Type.Type(), field.Transform)
+		}
+	}
+
+	return nil
+}
+
 func (s SortOrder) Equals(rhs SortOrder) bool {
 	return s.OrderID == rhs.OrderID &&
 		slices.Equal(s.Fields, rhs.Fields)
diff --git a/table/testdata/TableMetadataV1NoValidSchema.json b/table/testdata/TableMetadataV1NoValidSchema.json
new file mode 100644
index 00000000..b29c2fa3
--- /dev/null
+++ b/table/testdata/TableMetadataV1NoValidSchema.json
@@ -0,0 +1,45 @@
+{
+  "format-version": 1,
+  "table-uuid": "d20125c8-7284-442c-9aea-15fee620737c",
+  "location": "s3://bucket/test/location",
+  "last-updated-ms": 1602638573874,
+  "last-column-id": 3,
+  "schemas": [
+    {
+      "fields": [
+        {
+          "id": 1,
+          "name": "x",
+          "required": true,
+          "type": "long"
+        },
+        {
+          "id": 2,
+          "name": "y",
+          "required": true,
+          "type": "long",
+          "doc": "comment"
+        },
+        {
+          "id": 3,
+          "name": "z",
+          "required": true,
+          "type": "long"
+        }
+      ],
+      "schema-id": 0,
+      "type": "struct"
+    }
+  ],
+  "partition-spec": [
+    {
+      "name": "x",
+      "transform": "identity",
+      "source-id": 1,
+      "field-id": 1000
+    }
+  ],
+  "properties": {},
+  "current-snapshot-id": -1,
+  "snapshots": []
+}
\ No newline at end of file
diff --git a/table/testdata/TableMetadataV1SchemasWithoutCurrentId.json b/table/testdata/TableMetadataV1SchemasWithoutCurrentId.json
new file mode 100644
index 00000000..7276c2a8
--- /dev/null
+++ b/table/testdata/TableMetadataV1SchemasWithoutCurrentId.json
@@ -0,0 +1,69 @@
+{
+  "format-version": 1,
+  "table-uuid": "d20125c8-7284-442c-9aea-15fee620737c",
+  "location": "s3://bucket/test/location",
+  "last-updated-ms": 1602638573874,
+  "last-column-id": 3,
+  "schemas": [
+    {
+      "fields": [
+        {
+          "id": 1,
+          "name": "x",
+          "required": true,
+          "type": "long"
+        },
+        {
+          "id": 2,
+          "name": "y",
+          "required": true,
+          "type": "long",
+          "doc": "comment"
+        },
+        {
+          "id": 3,
+          "name": "z",
+          "required": true,
+          "type": "long"
+        }
+      ],
+      "schema-id": 0,
+      "type": "struct"
+    }
+  ],
+  "schema": {
+    "type": "struct",
+    "fields": [
+      {
+        "id": 1,
+        "name": "x",
+        "required": true,
+        "type": "long"
+      },
+      {
+        "id": 2,
+        "name": "y",
+        "required": true,
+        "type": "long",
+        "doc": "comment"
+      },
+      {
+        "id": 3,
+        "name": "z",
+        "required": true,
+        "type": "long"
+      }
+    ]
+  },
+  "partition-spec": [
+    {
+      "name": "x",
+      "transform": "identity",
+      "source-id": 1,
+      "field-id": 1000
+    }
+  ],
+  "properties": {},
+  "current-snapshot-id": -1,
+  "snapshots": []
+}
\ No newline at end of file
diff --git a/table/testdata/TableMetadataV2CurrentSchemaNotFound.json b/table/testdata/TableMetadataV2CurrentSchemaNotFound.json
new file mode 100644
index 00000000..d010785b
--- /dev/null
+++ b/table/testdata/TableMetadataV2CurrentSchemaNotFound.json
@@ -0,0 +1,88 @@
+{
+  "format-version": 2,
+  "table-uuid": "9c12d441-03fe-4693-9a96-a0705ddf69c1",
+  "location": "s3://bucket/test/location",
+  "last-sequence-number": 34,
+  "last-updated-ms": 1602638573590,
+  "last-column-id": 3,
+  "current-schema-id": 2,
+  "schemas": [
+    {
+      "type": "struct",
+      "schema-id": 0,
+      "fields": [
+        {
+          "id": 1,
+          "name": "x",
+          "required": true,
+          "type": "long"
+        }
+      ]
+    },
+    {
+      "type": "struct",
+      "schema-id": 1,
+      "fields": [
+        {
+          "id": 1,
+          "name": "x",
+          "required": true,
+          "type": "long"
+        },
+        {
+          "id": 2,
+          "name": "y",
+          "required": true,
+          "type": "long",
+          "doc": "comment"
+        },
+        {
+          "id": 3,
+          "name": "z",
+          "required": true,
+          "type": "long"
+        }
+      ]
+    }
+  ],
+  "default-spec-id": 0,
+  "partition-specs": [
+    {
+      "spec-id": 0,
+      "fields": [
+        {
+          "name": "x",
+          "transform": "identity",
+          "source-id": 1,
+          "field-id": 1000
+        }
+      ]
+    }
+  ],
+  "last-partition-id": 1000,
+  "default-sort-order-id": 3,
+  "sort-orders": [
+    {
+      "order-id": 3,
+      "fields": [
+        {
+          "transform": "identity",
+          "source-id": 2,
+          "direction": "asc",
+          "null-order": "nulls-first"
+        },
+        {
+          "transform": "bucket[4]",
+          "source-id": 3,
+          "direction": "desc",
+          "null-order": "nulls-last"
+        }
+      ]
+    }
+  ],
+  "properties": {},
+  "current-snapshot-id": -1,
+  "snapshots": [],
+  "snapshot-log": [],
+  "metadata-log": []
+}
\ No newline at end of file
diff --git a/table/testdata/TableMetadataV2MissingSchemas.json b/table/testdata/TableMetadataV2MissingSchemas.json
new file mode 100644
index 00000000..3754354e
--- /dev/null
+++ b/table/testdata/TableMetadataV2MissingSchemas.json
@@ -0,0 +1,71 @@
+{
+  "format-version": 2,
+  "table-uuid": "9c12d441-03fe-4693-9a96-a0705ddf69c1",
+  "location": "s3://bucket/test/location",
+  "last-sequence-number": 34,
+  "last-updated-ms": 1602638573590,
+  "last-column-id": 3,
+  "schema": {
+    "type": "struct",
+    "fields": [
+      {
+        "id": 1,
+        "name": "x",
+        "required": true,
+        "type": "long"
+      },
+      {
+        "id": 2,
+        "name": "y",
+        "required": true,
+        "type": "long",
+        "doc": "comment"
+      },
+      {
+        "id": 3,
+        "name": "z",
+        "required": true,
+        "type": "long"
+      }
+    ]
+  },
+  "default-spec-id": 0,
+  "partition-specs": [
+    {
+      "spec-id": 0,
+      "fields": [
+        {
+          "name": "x",
+          "transform": "identity",
+          "source-id": 1,
+          "field-id": 1000
+        }
+      ]
+    }
+  ],
+  "default-sort-order-id": 3,
+  "sort-orders": [
+    {
+      "order-id": 3,
+      "fields": [
+        {
+          "transform": "identity",
+          "source-id": 2,
+          "direction": "asc",
+          "null-order": "nulls-first"
+        },
+        {
+          "transform": "bucket[4]",
+          "source-id": 3,
+          "direction": "desc",
+          "null-order": "nulls-last"
+        }
+      ]
+    }
+  ],
+  "properties": {},
+  "current-snapshot-id": -1,
+  "snapshots": [],
+  "snapshot-log": [],
+  "metadata-log": []
+}
\ No newline at end of file
diff --git a/table/updates.go b/table/updates.go
index dd867aab..996e1db4 100644
--- a/table/updates.go
+++ b/table/updates.go
@@ -541,18 +541,18 @@ func (u *removeSpecUpdate) Apply(builder *MetadataBuilder) error {
 
 type removeSchemasUpdate struct {
 	baseUpdate
-	SchemaIds []int64 `json:"schema-ids"`
+	SchemaIDs []int `json:"schema-i-ds"`
 }
 
 // NewRemoveSchemasUpdate creates a new Update that removes a list of schemas from
 // the table metadata.
-func NewRemoveSchemasUpdate(schemaIds []int64) *removeSchemasUpdate {
+func NewRemoveSchemasUpdate(schemaIds []int) *removeSchemasUpdate {
 	return &removeSchemasUpdate{
 		baseUpdate: baseUpdate{ActionName: UpdateRemoveSchemas},
-		SchemaIds:  schemaIds,
+		SchemaIDs:  schemaIds,
 	}
 }
 
 func (u *removeSchemasUpdate) Apply(builder *MetadataBuilder) error {
-	return fmt.Errorf("%w: %s", iceberg.ErrNotImplemented, UpdateRemoveSchemas)
+	return builder.RemoveSchemas(u.SchemaIDs)
 }
diff --git a/table/updates_test.go b/table/updates_test.go
index c96a97c4..ade8a4da 100644
--- a/table/updates_test.go
+++ b/table/updates_test.go
@@ -19,7 +19,6 @@ package table
 
 import (
 	"encoding/json"
-	"errors"
 	"testing"
 
 	"github.com/apache/iceberg-go"
@@ -218,15 +217,3 @@ func TestUnmarshalUpdates(t *testing.T) {
 		})
 	}
 }
-
-func TestRemoveSchemas(t *testing.T) {
-	var builder *MetadataBuilder
-	removeSchemas := removeSchemasUpdate{
-		SchemaIds: []int64{},
-	}
-	t.Run("remove schemas should fail", func(t *testing.T) {
-		if err := removeSchemas.Apply(builder); !errors.Is(err, iceberg.ErrNotImplemented) {
-			t.Fatalf("Expected unimplemented error, got %v", err)
-		}
-	})
-}