This is an automated email from the ASF dual-hosted git repository.

laskoviymishka 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 2987cc761 Schema compatibility functions (#2073)
2987cc761 is described below

commit 2987cc761b30a32331d4483c800af5d84ad4f37c
Author: Alex Stephen <[email protected]>
AuthorDate: Thu Oct 1 00:59:38 2026 -0700

    Schema compatibility functions (#2073)
    
    * Schema compatibility functions
    
    * quick changes
    
    * PR comments
---
 schema_compatibility.go      | 255 +++++++++++++++++++++
 schema_compatibility_test.go | 531 +++++++++++++++++++++++++++++++++++++++++++
 table/update_schema.go       |  29 +--
 3 files changed, 788 insertions(+), 27 deletions(-)

diff --git a/schema_compatibility.go b/schema_compatibility.go
new file mode 100644
index 000000000..fe4468848
--- /dev/null
+++ b/schema_compatibility.go
@@ -0,0 +1,255 @@
+// 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.
+
+package iceberg
+
+import (
+       "fmt"
+       "strings"
+)
+
+// IsPromotionAllowed reports whether schema evolution may change a column
+// from one type to another: int to long, float to double, or widening a
+// decimal's precision with the same scale.
+func IsPromotionAllowed(from, to PrimitiveType) bool {
+       if from.Equals(to) {
+               return true
+       }
+
+       switch f := from.(type) {
+       case Int32Type:
+               _, ok := to.(Int64Type)
+
+               return ok
+       case Float32Type:
+               _, ok := to.(Float64Type)
+
+               return ok
+       case DecimalType:
+               t, ok := to.(DecimalType)
+               if !ok {
+                       return false
+               }
+
+               return f.Scale() == t.Scale() && f.Precision() <= t.Precision()
+       }
+
+       return false
+}
+
+// ReadCompatibilityErrors returns the problems with reading data written
+// with writeSchema using readSchema. Fields are matched by ID and field
+// order is not checked.
+func ReadCompatibilityErrors(readSchema, writeSchema *Schema) ([]string, 
error) {
+       return checkCompatibility(readSchema, writeSchema, false, true)
+}
+
+// WriteCompatibilityErrors returns the problems with writing data in
+// writeSchema to a table whose schema is readSchema. If checkOrdering is
+// set, reordered fields are also reported.
+func WriteCompatibilityErrors(readSchema, writeSchema *Schema, checkOrdering 
bool) ([]string, error) {
+       return checkCompatibility(readSchema, writeSchema, checkOrdering, true)
+}
+
+// TypeCompatibilityErrors is WriteCompatibilityErrors without the struct
+// field nullability check. Required list elements and map values are still
+// checked.
+func TypeCompatibilityErrors(readSchema, writeSchema *Schema, checkOrdering 
bool) ([]string, error) {
+       return checkCompatibility(readSchema, writeSchema, checkOrdering, false)
+}
+
+func checkCompatibility(readSchema, writeSchema *Schema, checkOrdering, 
checkNullability bool) ([]string, error) {
+       if readSchema == nil || writeSchema == nil {
+               return nil, fmt.Errorf("%w: cannot check compatibility against 
nil schema", ErrInvalidArgument)
+       }
+
+       return PreOrderVisit(readSchema, &compatibilityChecker{
+               schema:           writeSchema,
+               checkOrdering:    checkOrdering,
+               checkNullability: checkNullability,
+       })
+}
+
+// compatibilityChecker walks the read schema, tracking the matching type in
+// the write schema. Errors starting with ":" get the enclosing field's name
+// prepended; others are joined to it with ".".
+type compatibilityChecker struct {
+       schema           *Schema
+       checkOrdering    bool
+       checkNullability bool
+
+       current Type
+       // PreOrderVisit sends list elements and map keys/values through Field,
+       // but only struct fields should be looked up by ID.
+       inContainer bool
+}
+
+func (c *compatibilityChecker) Schema(_ *Schema, structErrors func() []string) 
[]string {
+       st := c.schema.asStructRef()
+       c.current = &st
+       defer func() { c.current = nil }()
+
+       return structErrors()
+}
+
+func (c *compatibilityChecker) Struct(readStruct StructType, fieldErrors 
[]func() []string) []string {
+       st, ok := c.current.(*StructType)
+       if !ok {
+               return []string{fmt.Sprintf(": %s cannot be read as a struct", 
c.current)}
+       }
+
+       var errs []string
+       for _, fieldErrs := range fieldErrors {
+               errs = append(errs, fieldErrs()...)
+       }
+
+       if c.checkOrdering {
+               ordinals := make(map[int]int, len(st.FieldList))
+               for i, f := range st.FieldList {
+                       ordinals[f.ID] = i
+               }
+
+               lastOrdinal, lastName := -1, ""
+               for _, readField := range readStruct.FieldList {
+                       ordinal, ok := ordinals[readField.ID]
+                       if !ok {
+                               continue
+                       }
+                       if lastOrdinal >= ordinal {
+                               errs = append(errs, fmt.Sprintf("%s is out of 
order, before %s", readField.Name, lastName))
+                       }
+                       lastOrdinal, lastName = ordinal, readField.Name
+               }
+       }
+
+       return errs
+}
+
+func (c *compatibilityChecker) Field(readField NestedField, fieldErrors func() 
[]string) []string {
+       if c.inContainer {
+               c.inContainer = false
+
+               return fieldErrors()
+       }
+
+       st, ok := c.current.(*StructType)
+       if !ok {
+               return nil // Struct() already reported the mismatch
+       }
+
+       var (
+               writeField NestedField
+               found      bool
+       )
+       for _, f := range st.FieldList {
+               if f.ID == readField.ID {
+                       writeField, found = f, true
+
+                       break
+               }
+       }
+
+       if !found {
+               if readField.Required {
+                       return []string{readField.Name + " is required, but is 
missing"}
+               }
+
+               // an optional field is read as nulls
+               return nil
+       }
+
+       c.current = writeField.Type
+       defer func() { c.current = st }()
+
+       var errs []string
+       if c.checkNullability && readField.Required && !writeField.Required {
+               errs = append(errs, readField.Name+" should be required, but is 
optional")
+       }
+
+       for _, err := range fieldErrors() {
+               if strings.HasPrefix(err, ":") {
+                       errs = append(errs, readField.Name+err)
+               } else {
+                       errs = append(errs, readField.Name+"."+err)
+               }
+       }
+
+       return errs
+}
+
+func (c *compatibilityChecker) List(readList ListType, elementErrors func() 
[]string) []string {
+       list, ok := c.current.(*ListType)
+       if !ok {
+               return []string{fmt.Sprintf(": %s cannot be read as a list", 
c.current)}
+       }
+
+       var errs []string
+       if readList.ElementRequired && !list.ElementRequired {
+               errs = append(errs, ": elements should be required, but are 
optional")
+       }
+
+       c.current, c.inContainer = list.Element, true
+       defer func() { c.current = list }()
+
+       return append(errs, elementErrors()...)
+}
+
+func (c *compatibilityChecker) Map(readMap MapType, keyErrors, valueErrors 
func() []string) []string {
+       m, ok := c.current.(*MapType)
+       if !ok {
+               return []string{fmt.Sprintf(": %s cannot be read as a map", 
c.current)}
+       }
+       defer func() { c.current = m }()
+
+       var errs []string
+       if readMap.ValueRequired && !m.ValueRequired {
+               errs = append(errs, ": values should be required, but are 
optional")
+       }
+
+       c.current, c.inContainer = m.KeyType, true
+       errs = append(errs, keyErrors()...)
+
+       c.current, c.inContainer = m.ValueType, true
+
+       return append(errs, valueErrors()...)
+}
+
+func (c *compatibilityChecker) Primitive(readPrimitive PrimitiveType) []string 
{
+       if c.current.Equals(readPrimitive) {
+               return nil
+       }
+
+       writePrimitive, ok := c.current.(PrimitiveType)
+       if !ok {
+               return []string{fmt.Sprintf(": %s cannot be read as a %s", 
c.current, readPrimitive)}
+       }
+
+       if !IsPromotionAllowed(writePrimitive, readPrimitive) {
+               return []string{fmt.Sprintf(": %s cannot be promoted to %s", 
writePrimitive, readPrimitive)}
+       }
+
+       return nil
+}
+
+func (c *compatibilityChecker) Variant(readVariant VariantType) []string {
+       if _, ok := c.current.(VariantType); ok {
+               return nil
+       }
+
+       // promotion to variant is not allowed
+       return []string{fmt.Sprintf(": %s cannot be read as a %s", c.current, 
readVariant)}
+}
diff --git a/schema_compatibility_test.go b/schema_compatibility_test.go
new file mode 100644
index 000000000..6fbde7983
--- /dev/null
+++ b/schema_compatibility_test.go
@@ -0,0 +1,531 @@
+// 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.
+
+package iceberg_test
+
+import (
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+// Ported from Java's TestReadabilityChecks.
+
+func compatPrimitives(t *testing.T) []iceberg.PrimitiveType {
+       t.Helper()
+
+       geomCRS84, err := iceberg.GeometryTypeOf(iceberg.DefaultGeoCRS)
+       require.NoError(t, err)
+       geom3857, err := iceberg.GeometryTypeOf("srid:3857")
+       require.NoError(t, err)
+       geogCRS84, err := iceberg.GeographyTypeOf(iceberg.DefaultGeoCRS, 
"spherical")
+       require.NoError(t, err)
+       geog4269, err := iceberg.GeographyTypeOf("srid:4269", "spherical")
+       require.NoError(t, err)
+       geog4269Karney, err := iceberg.GeographyTypeOf("srid:4269", "karney")
+       require.NoError(t, err)
+
+       return []iceberg.PrimitiveType{
+               iceberg.PrimitiveTypes.Bool,
+               iceberg.PrimitiveTypes.Int32,
+               iceberg.PrimitiveTypes.Int64,
+               iceberg.PrimitiveTypes.Float32,
+               iceberg.PrimitiveTypes.Float64,
+               iceberg.PrimitiveTypes.Date,
+               iceberg.PrimitiveTypes.Time,
+               iceberg.PrimitiveTypes.Timestamp,
+               iceberg.PrimitiveTypes.TimestampTz,
+               iceberg.PrimitiveTypes.TimestampNs,
+               iceberg.PrimitiveTypes.TimestampTzNs,
+               iceberg.PrimitiveTypes.String,
+               iceberg.PrimitiveTypes.UUID,
+               iceberg.FixedTypeOf(3),
+               iceberg.FixedTypeOf(4),
+               iceberg.PrimitiveTypes.Binary,
+               iceberg.DecimalTypeOf(9, 2),
+               iceberg.DecimalTypeOf(11, 2),
+               iceberg.DecimalTypeOf(9, 3),
+               geomCRS84,
+               geom3857,
+               geogCRS84,
+               geog4269,
+               geog4269Karney,
+       }
+}
+
+func required(id int, name string, typ iceberg.Type) iceberg.NestedField {
+       return iceberg.NestedField{ID: id, Name: name, Type: typ, Required: 
true}
+}
+
+func optional(id int, name string, typ iceberg.Type) iceberg.NestedField {
+       return iceberg.NestedField{ID: id, Name: name, Type: typ}
+}
+
+func schemaOf(fields ...iceberg.NestedField) *iceberg.Schema {
+       return iceberg.NewSchema(0, fields...)
+}
+
+func writeErrors(t *testing.T, read, write *iceberg.Schema) []string {
+       t.Helper()
+
+       errs, err := iceberg.WriteCompatibilityErrors(read, write, true)
+       require.NoError(t, err)
+
+       return errs
+}
+
+func TestCompatibilityPrimitiveTypes(t *testing.T) {
+       primitives := compatPrimitives(t)
+       for _, from := range primitives {
+               fromSchema := schemaOf(required(1, "from_field", from))
+               for _, to := range primitives {
+                       errs := writeErrors(t, schemaOf(required(1, "to_field", 
to)), fromSchema)
+
+                       if iceberg.IsPromotionAllowed(from, to) {
+                               assert.Empty(t, errs, "%s -> %s", from, to)
+                       } else {
+                               require.Len(t, errs, 1, "%s -> %s", from, to)
+                               assert.Contains(t, errs[0], "cannot be promoted 
to")
+                       }
+               }
+
+               structSchema := schemaOf(required(1, "struct_field", 
&iceberg.StructType{
+                       FieldList: []iceberg.NestedField{required(2, "from", 
from)},
+               }))
+               errs := writeErrors(t, structSchema, fromSchema)
+               require.Len(t, errs, 1)
+               assert.Contains(t, errs[0], "cannot be read as a struct")
+
+               listSchema := schemaOf(required(1, "list_field", 
&iceberg.ListType{
+                       ElementID: 2, Element: from, ElementRequired: true,
+               }))
+               errs = writeErrors(t, listSchema, fromSchema)
+               require.Len(t, errs, 1)
+               assert.Contains(t, errs[0], "cannot be read as a list")
+
+               mapSchema := schemaOf(required(1, "map_field", &iceberg.MapType{
+                       KeyID: 2, KeyType: iceberg.PrimitiveTypes.String,
+                       ValueID: 3, ValueType: from, ValueRequired: true,
+               }))
+               errs = writeErrors(t, mapSchema, fromSchema)
+               require.Len(t, errs, 1)
+               assert.Contains(t, errs[0], "cannot be read as a map")
+       }
+}
+
+func TestIsPromotionAllowed(t *testing.T) {
+       tests := []struct {
+               from, to iceberg.PrimitiveType
+               allowed  bool
+       }{
+               {iceberg.PrimitiveTypes.Int32, iceberg.PrimitiveTypes.Int32, 
true},
+               {iceberg.PrimitiveTypes.Int32, iceberg.PrimitiveTypes.Int64, 
true},
+               {iceberg.PrimitiveTypes.Int64, iceberg.PrimitiveTypes.Int32, 
false},
+               {iceberg.PrimitiveTypes.Float32, 
iceberg.PrimitiveTypes.Float64, true},
+               {iceberg.PrimitiveTypes.Float64, 
iceberg.PrimitiveTypes.Float32, false},
+               {iceberg.DecimalTypeOf(9, 2), iceberg.DecimalTypeOf(11, 2), 
true},
+               {iceberg.DecimalTypeOf(11, 2), iceberg.DecimalTypeOf(9, 2), 
false},
+               {iceberg.DecimalTypeOf(9, 2), iceberg.DecimalTypeOf(9, 3), 
false},
+               {iceberg.PrimitiveTypes.String, iceberg.PrimitiveTypes.Binary, 
false},
+               {iceberg.FixedTypeOf(16), iceberg.PrimitiveTypes.UUID, false},
+               {iceberg.PrimitiveTypes.Date, iceberg.PrimitiveTypes.Timestamp, 
false},
+       }
+
+       for _, tt := range tests {
+               assert.Equal(t, tt.allowed, iceberg.IsPromotionAllowed(tt.from, 
tt.to), "%s -> %s", tt.from, tt.to)
+       }
+}
+
+func TestCompatibilityVariantToVariant(t *testing.T) {
+       errs := writeErrors(t,
+               schemaOf(required(1, "to_field", iceberg.VariantType{})),
+               schemaOf(required(1, "from_field", iceberg.VariantType{})))
+       assert.Empty(t, errs)
+}
+
+func TestCompatibilityIncompatibleTypesToVariant(t *testing.T) {
+       from := []iceberg.Type{
+               &iceberg.StructType{FieldList: 
[]iceberg.NestedField{required(1, "from", iceberg.PrimitiveTypes.Int32)}},
+               &iceberg.MapType{
+                       KeyID: 1, KeyType: iceberg.PrimitiveTypes.String,
+                       ValueID: 2, ValueType: iceberg.PrimitiveTypes.Int32, 
ValueRequired: true,
+               },
+               &iceberg.ListType{ElementID: 1, Element: 
iceberg.PrimitiveTypes.String, ElementRequired: true},
+       }
+       for _, p := range compatPrimitives(t) {
+               from = append(from, p)
+       }
+
+       for _, typ := range from {
+               errs := writeErrors(t,
+                       schemaOf(required(3, "to_field", 
iceberg.VariantType{})),
+                       schemaOf(required(3, "from_field", typ)))
+               require.Len(t, errs, 1, "%s", typ)
+               assert.Contains(t, errs[0], "cannot be read as a variant")
+       }
+}
+
+func TestCompatibilityRequiredSchemaField(t *testing.T) {
+       write := schemaOf(optional(1, "from_field", 
iceberg.PrimitiveTypes.Int32))
+       read := schemaOf(required(1, "to_field", iceberg.PrimitiveTypes.Int32))
+
+       errs := writeErrors(t, read, write)
+       require.Len(t, errs, 1)
+       assert.Contains(t, errs[0], "should be required, but is optional")
+}
+
+func TestCompatibilityMissingSchemaField(t *testing.T) {
+       write := schemaOf(required(0, "other_field", 
iceberg.PrimitiveTypes.Int32))
+       read := schemaOf(required(1, "to_field", iceberg.PrimitiveTypes.Int32))
+
+       errs := writeErrors(t, read, write)
+       require.Len(t, errs, 1)
+       assert.Contains(t, errs[0], "is required, but is missing")
+}
+
+func nestedStruct(fields ...iceberg.NestedField) *iceberg.StructType {
+       return &iceberg.StructType{FieldList: fields}
+}
+
+func TestCompatibilityRequiredStructField(t *testing.T) {
+       write := schemaOf(required(0, "nested", nestedStruct(optional(1, 
"from_field", iceberg.PrimitiveTypes.Int32))))
+       read := schemaOf(required(0, "nested", nestedStruct(required(1, 
"to_field", iceberg.PrimitiveTypes.Int32))))
+
+       errs := writeErrors(t, read, write)
+       require.Len(t, errs, 1)
+       assert.Contains(t, errs[0], "should be required, but is optional")
+}
+
+func TestCompatibilityMissingRequiredStructField(t *testing.T) {
+       write := schemaOf(required(0, "nested", nestedStruct(optional(2, 
"from_field", iceberg.PrimitiveTypes.Int32))))
+       read := schemaOf(required(0, "nested", nestedStruct(required(1, 
"to_field", iceberg.PrimitiveTypes.Int32))))
+
+       errs := writeErrors(t, read, write)
+       require.Len(t, errs, 1)
+       assert.Contains(t, errs[0], "is required, but is missing")
+}
+
+func TestCompatibilityMissingOptionalStructField(t *testing.T) {
+       write := schemaOf(required(0, "nested", nestedStruct(required(2, 
"from_field", iceberg.PrimitiveTypes.Int32))))
+       read := schemaOf(required(0, "nested", nestedStruct(optional(1, 
"to_field", iceberg.PrimitiveTypes.Int32))))
+
+       assert.Empty(t, writeErrors(t, read, write))
+}
+
+func TestCompatibilityIncompatibleStructField(t *testing.T) {
+       write := schemaOf(required(0, "nested", nestedStruct(required(1, 
"from_field", iceberg.PrimitiveTypes.Int32))))
+       read := schemaOf(required(0, "nested", nestedStruct(required(1, 
"to_field", iceberg.PrimitiveTypes.Float32))))
+
+       errs := writeErrors(t, read, write)
+       require.Len(t, errs, 1)
+       assert.Contains(t, errs[0], "cannot be promoted to float")
+}
+
+func TestCompatibilityIncompatibleStructAndPrimitive(t *testing.T) {
+       write := schemaOf(required(0, "nested", nestedStruct(required(1, 
"from_field", iceberg.PrimitiveTypes.String))))
+       read := schemaOf(required(0, "nested", iceberg.PrimitiveTypes.String))
+
+       errs := writeErrors(t, read, write)
+       require.Len(t, errs, 1)
+       assert.Equal(t, "nested: struct<1: from_field: required string> cannot 
be read as a string", errs[0])
+}
+
+func TestCompatibilityMultipleErrors(t *testing.T) {
+       // required field is optional and cannot be promoted to the read type
+       write := schemaOf(required(0, "nested", nestedStruct(optional(1, 
"from_field", iceberg.PrimitiveTypes.Int32))))
+       read := schemaOf(required(0, "nested", nestedStruct(required(1, 
"to_field", iceberg.PrimitiveTypes.Float32))))
+
+       errs := writeErrors(t, read, write)
+       require.Len(t, errs, 2)
+       assert.Contains(t, errs[0], "should be required, but is optional")
+       assert.Contains(t, errs[1], "cannot be promoted to float")
+}
+
+func TestCompatibilityRequiredMapValue(t *testing.T) {
+       write := schemaOf(required(0, "map_field", &iceberg.MapType{
+               KeyID: 1, KeyType: iceberg.PrimitiveTypes.String,
+               ValueID: 2, ValueType: iceberg.PrimitiveTypes.Int32,
+       }))
+       read := schemaOf(required(0, "map_field", &iceberg.MapType{
+               KeyID: 1, KeyType: iceberg.PrimitiveTypes.String,
+               ValueID: 2, ValueType: iceberg.PrimitiveTypes.Int32, 
ValueRequired: true,
+       }))
+
+       errs := writeErrors(t, read, write)
+       require.Len(t, errs, 1)
+       assert.Contains(t, errs[0], "values should be required, but are 
optional")
+}
+
+func TestCompatibilityIncompatibleMapKey(t *testing.T) {
+       write := schemaOf(required(0, "map_field", &iceberg.MapType{
+               KeyID: 1, KeyType: iceberg.PrimitiveTypes.Int32,
+               ValueID: 2, ValueType: iceberg.PrimitiveTypes.String,
+       }))
+       read := schemaOf(required(0, "map_field", &iceberg.MapType{
+               KeyID: 1, KeyType: iceberg.PrimitiveTypes.Float64,
+               ValueID: 2, ValueType: iceberg.PrimitiveTypes.String,
+       }))
+
+       errs := writeErrors(t, read, write)
+       require.Len(t, errs, 1)
+       assert.Contains(t, errs[0], "cannot be promoted to double")
+}
+
+func TestCompatibilityIncompatibleMapValue(t *testing.T) {
+       write := schemaOf(required(0, "map_field", &iceberg.MapType{
+               KeyID: 1, KeyType: iceberg.PrimitiveTypes.String,
+               ValueID: 2, ValueType: iceberg.PrimitiveTypes.Int32,
+       }))
+       read := schemaOf(required(0, "map_field", &iceberg.MapType{
+               KeyID: 1, KeyType: iceberg.PrimitiveTypes.String,
+               ValueID: 2, ValueType: iceberg.PrimitiveTypes.Float64,
+       }))
+
+       errs := writeErrors(t, read, write)
+       require.Len(t, errs, 1)
+       assert.Contains(t, errs[0], "cannot be promoted to double")
+}
+
+func TestCompatibilityIncompatibleMapAndPrimitive(t *testing.T) {
+       write := schemaOf(required(0, "map_field", &iceberg.MapType{
+               KeyID: 1, KeyType: iceberg.PrimitiveTypes.String,
+               ValueID: 2, ValueType: iceberg.PrimitiveTypes.Int32,
+       }))
+       read := schemaOf(required(0, "map_field", 
iceberg.PrimitiveTypes.String))
+
+       errs := writeErrors(t, read, write)
+       require.Len(t, errs, 1)
+       assert.Equal(t, "map_field: map<string, int> cannot be read as a 
string", errs[0])
+}
+
+func TestCompatibilityRequiredListElement(t *testing.T) {
+       write := schemaOf(required(0, "list_field", 
&iceberg.ListType{ElementID: 1, Element: iceberg.PrimitiveTypes.Int32}))
+       read := schemaOf(required(0, "list_field", &iceberg.ListType{ElementID: 
1, Element: iceberg.PrimitiveTypes.Int32, ElementRequired: true}))
+
+       errs := writeErrors(t, read, write)
+       require.Len(t, errs, 1)
+       assert.Contains(t, errs[0], "elements should be required, but are 
optional")
+}
+
+func TestCompatibilityIncompatibleListElement(t *testing.T) {
+       write := schemaOf(required(0, "list_field", 
&iceberg.ListType{ElementID: 1, Element: iceberg.PrimitiveTypes.Int32}))
+       read := schemaOf(required(0, "list_field", &iceberg.ListType{ElementID: 
1, Element: iceberg.PrimitiveTypes.String}))
+
+       errs := writeErrors(t, read, write)
+       require.Len(t, errs, 1)
+       assert.Contains(t, errs[0], "cannot be promoted to string")
+}
+
+func TestCompatibilityIncompatibleListAndPrimitive(t *testing.T) {
+       write := schemaOf(required(0, "list_field", 
&iceberg.ListType{ElementID: 1, Element: iceberg.PrimitiveTypes.Int32}))
+       read := schemaOf(required(0, "list_field", 
iceberg.PrimitiveTypes.String))
+
+       errs := writeErrors(t, read, write)
+       require.Len(t, errs, 1)
+       assert.Equal(t, "list_field: list<int> cannot be read as a string", 
errs[0])
+}
+
+func TestCompatibilityIncompatibleNestedListOfMap(t *testing.T) {
+       write := schemaOf(required(0, "list_field", 
&iceberg.ListType{ElementID: 1, Element: &iceberg.MapType{
+               KeyID: 2, KeyType: iceberg.PrimitiveTypes.String,
+               ValueID: 3, ValueType: iceberg.PrimitiveTypes.Int32,
+       }}))
+       read := schemaOf(required(0, "list_field", &iceberg.ListType{ElementID: 
1, Element: &iceberg.MapType{
+               KeyID: 2, KeyType: iceberg.PrimitiveTypes.String,
+               ValueID: 3, ValueType: iceberg.PrimitiveTypes.String,
+       }}))
+
+       errs := writeErrors(t, read, write)
+       assert.Equal(t, []string{"list_field: int cannot be promoted to 
string"}, errs)
+}
+
+func TestCompatibilityIncompatibleMapOfStruct(t *testing.T) {
+       write := schemaOf(required(0, "map_field", &iceberg.MapType{
+               KeyID: 1, KeyType: iceberg.PrimitiveTypes.String,
+               ValueID: 2, ValueType: nestedStruct(required(3, "x", 
iceberg.PrimitiveTypes.Int32)),
+       }))
+       read := schemaOf(required(0, "map_field", &iceberg.MapType{
+               KeyID: 1, KeyType: iceberg.PrimitiveTypes.String,
+               ValueID: 2, ValueType: nestedStruct(required(3, "x", 
iceberg.PrimitiveTypes.Float64)),
+       }))
+
+       errs := writeErrors(t, read, write)
+       assert.Equal(t, []string{"map_field.x: int cannot be promoted to 
double"}, errs)
+}
+
+func reorderedSchemas() (read, write *iceberg.Schema) {
+       read = schemaOf(required(0, "nested", nestedStruct(
+               required(1, "field_a", iceberg.PrimitiveTypes.Int32),
+               required(2, "field_b", iceberg.PrimitiveTypes.Int32))))
+       write = schemaOf(required(0, "nested", nestedStruct(
+               required(2, "field_b", iceberg.PrimitiveTypes.Int32),
+               required(1, "field_a", iceberg.PrimitiveTypes.Int32))))
+
+       return read, write
+}
+
+func TestCompatibilityDifferentFieldOrdering(t *testing.T) {
+       read, write := reorderedSchemas()
+
+       errs, err := iceberg.WriteCompatibilityErrors(read, write, false)
+       require.NoError(t, err)
+       assert.Empty(t, errs)
+}
+
+func TestCompatibilityStructWriteReordering(t *testing.T) {
+       // writes should not reorder fields
+       read, write := reorderedSchemas()
+
+       errs := writeErrors(t, read, write)
+       require.Len(t, errs, 1)
+       assert.Equal(t, "nested.field_b is out of order, before field_a", 
errs[0])
+}
+
+func TestCompatibilityStructWriteReorderingRenamed(t *testing.T) {
+       // the message should use read-side names only
+       read, _ := reorderedSchemas()
+       write := schemaOf(required(0, "nested", nestedStruct(
+               required(2, "old_b", iceberg.PrimitiveTypes.Int32),
+               required(1, "old_a", iceberg.PrimitiveTypes.Int32))))
+
+       errs := writeErrors(t, read, write)
+       assert.Equal(t, []string{"nested.field_b is out of order, before 
field_a"}, errs)
+}
+
+func TestCompatibilityStructReadReordering(t *testing.T) {
+       // reads should allow reordering
+       read, write := reorderedSchemas()
+
+       errs, err := iceberg.ReadCompatibilityErrors(read, write)
+       require.NoError(t, err)
+       assert.Empty(t, errs)
+}
+
+func TestCompatibilityCheckNullabilityRequiredSchemaField(t *testing.T) {
+       write := schemaOf(optional(1, "from_field", 
iceberg.PrimitiveTypes.Int32))
+       read := schemaOf(required(1, "to_field", iceberg.PrimitiveTypes.Int32))
+
+       errs, err := iceberg.TypeCompatibilityErrors(read, write, true)
+       require.NoError(t, err)
+       assert.Empty(t, errs)
+}
+
+func TestCompatibilityCheckNullabilityRequiredStructField(t *testing.T) {
+       write := schemaOf(required(0, "nested", nestedStruct(optional(1, 
"from_field", iceberg.PrimitiveTypes.Int32))))
+       read := schemaOf(required(0, "nested", nestedStruct(required(1, 
"to_field", iceberg.PrimitiveTypes.Int32))))
+
+       errs, err := iceberg.TypeCompatibilityErrors(read, write, true)
+       require.NoError(t, err)
+       assert.Empty(t, errs)
+}
+
+func TestCompatibilityTypeErrorsStillReportPromotion(t *testing.T) {
+       write := schemaOf(optional(1, "f", iceberg.PrimitiveTypes.Int32))
+       read := schemaOf(required(1, "f", iceberg.PrimitiveTypes.String))
+
+       errs, err := iceberg.TypeCompatibilityErrors(read, write, true)
+       require.NoError(t, err)
+       assert.Equal(t, []string{"f: int cannot be promoted to string"}, errs)
+}
+
+func TestReadCompatibilitySchemaEvolution(t *testing.T) {
+       current := schemaOf(
+               required(1, "id", iceberg.PrimitiveTypes.Int32),
+               optional(2, "name", iceberg.PrimitiveTypes.String),
+               optional(3, "tags", &iceberg.ListType{ElementID: 4, Element: 
iceberg.PrimitiveTypes.String}),
+       )
+
+       tests := []struct {
+               name     string
+               proposed *iceberg.Schema
+               errs     []string
+       }{
+               {
+                       name: "promote, rename, drop and add optional",
+                       proposed: schemaOf(
+                               required(1, "id", iceberg.PrimitiveTypes.Int64),
+                               optional(2, "full_name", 
iceberg.PrimitiveTypes.String),
+                               optional(5, "added", 
iceberg.PrimitiveTypes.Bool),
+                       ),
+               },
+               {
+                       name: "add required column",
+                       proposed: schemaOf(
+                               required(1, "id", iceberg.PrimitiveTypes.Int32),
+                               required(5, "added", 
iceberg.PrimitiveTypes.Bool),
+                       ),
+                       errs: []string{"added is required, but is missing"},
+               },
+               {
+                       name: "make optional column required",
+                       proposed: schemaOf(
+                               required(1, "id", iceberg.PrimitiveTypes.Int32),
+                               required(2, "name", 
iceberg.PrimitiveTypes.String),
+                       ),
+                       errs: []string{"name should be required, but is 
optional"},
+               },
+               {
+                       name: "narrow type",
+                       proposed: schemaOf(
+                               required(1, "id", 
iceberg.PrimitiveTypes.String),
+                       ),
+                       errs: []string{"id: int cannot be promoted to string"},
+               },
+               {
+                       name: "nested list element error is prefixed with the 
field name",
+                       proposed: schemaOf(
+                               required(1, "id", iceberg.PrimitiveTypes.Int32),
+                               optional(3, "tags", 
&iceberg.ListType{ElementID: 4, Element: iceberg.PrimitiveTypes.Int32, 
ElementRequired: true}),
+                       ),
+                       errs: []string{
+                               "tags: elements should be required, but are 
optional",
+                               "tags: string cannot be promoted to int",
+                       },
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       errs, err := 
iceberg.ReadCompatibilityErrors(tt.proposed, current)
+                       require.NoError(t, err)
+                       assert.Equal(t, tt.errs, errs)
+               })
+       }
+}
+
+func TestCompatibilityNestedFieldErrorPath(t *testing.T) {
+       write := schemaOf(required(0, "outer", nestedStruct(
+               required(1, "inner", nestedStruct(required(2, "leaf", 
iceberg.PrimitiveTypes.Int64))))))
+       read := schemaOf(required(0, "outer", nestedStruct(
+               required(1, "inner", nestedStruct(required(2, "leaf", 
iceberg.PrimitiveTypes.Int32))))))
+
+       errs, err := iceberg.ReadCompatibilityErrors(read, write)
+       require.NoError(t, err)
+       assert.Equal(t, []string{"outer.inner.leaf: long cannot be promoted to 
int"}, errs)
+}
+
+func TestCompatibilityNilSchema(t *testing.T) {
+       sc := schemaOf(required(1, "id", iceberg.PrimitiveTypes.Int32))
+
+       for _, tc := range []struct{ read, write *iceberg.Schema }{{nil, sc}, 
{sc, nil}} {
+               _, err := iceberg.ReadCompatibilityErrors(tc.read, tc.write)
+               assert.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+               assert.ErrorContains(t, err, "cannot check compatibility 
against nil schema")
+       }
+}
diff --git a/table/update_schema.go b/table/update_schema.go
index fead23a4c..dea77812f 100644
--- a/table/update_schema.go
+++ b/table/update_schema.go
@@ -834,7 +834,7 @@ func (u *UpdateSchema) unionUpdateColumn(path []string, 
existing, newField icebe
                // else (non-primitive, cross-kind, or a disallowed promotion) 
is an error.
                existingPrim, existingIsPrim := 
existing.Type.(iceberg.PrimitiveType)
                newPrim, newIsPrim := newField.Type.(iceberg.PrimitiveType)
-               if !existingIsPrim || !newIsPrim || 
!isPromotionAllowed(existingPrim, newPrim) {
+               if !existingIsPrim || !newIsPrim || 
!iceberg.IsPromotionAllowed(existingPrim, newPrim) {
                        return fmt.Errorf("cannot change column type: %s: %s -> 
%s",
                                strings.Join(path, "."), existing.Type, 
newField.Type)
                }
@@ -1010,7 +1010,7 @@ func isIgnorableTypeUpdate(existingType, newType 
iceberg.Type) bool {
                        return false
                }
 
-               return isPromotionAllowed(newPrimitive, existingPrim)
+               return iceberg.IsPromotionAllowed(newPrimitive, existingPrim)
        }
 
        switch existingType.(type) {
@@ -1031,31 +1031,6 @@ func isIgnorableTypeUpdate(existingType, newType 
iceberg.Type) bool {
        return false
 }
 
-func isPromotionAllowed(from, to iceberg.PrimitiveType) bool {
-       if from.Equals(to) {
-               return true
-       }
-       switch f := from.(type) {
-       case iceberg.Int32Type:
-               _, ok := to.(iceberg.Int64Type)
-
-               return ok
-       case iceberg.Float32Type:
-               _, ok := to.(iceberg.Float64Type)
-
-               return ok
-       case iceberg.DecimalType:
-               t, ok := to.(iceberg.DecimalType)
-               if !ok {
-                       return false
-               }
-
-               return f.Scale() == t.Scale() && f.Precision() <= t.Precision()
-       }
-
-       return false
-}
-
 func (u *UpdateSchema) BuildUpdates() ([]Update, []Requirement, error) {
        if u.err != nil {
                return nil, nil, u.err

Reply via email to