laskoviymishka commented on code in PR #2073:
URL: https://github.com/apache/iceberg-go/pull/2073#discussion_r4145292089


##########
schema_compatibility.go:
##########
@@ -0,0 +1,251 @@
+// 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
+// nullability checks.
+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 writeSchema == nil {

Review Comment:
   `writeSchema == nil` is caught here, but `readSchema == nil` falls through 
to `PreOrderVisit` and comes back as `cannot visit nil schema`. Same error 
type, different message. I'd add the matching guard right above so both nil 
cases return the same `cannot check compatibility against nil schema`.



##########
schema_compatibility.go:
##########
@@ -0,0 +1,251 @@
+// 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
+// nullability checks.
+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 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 := -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, 
st.FieldList[lastOrdinal].Name))

Review Comment:
   The two `%s` come from different schemas: `readField.Name` is the read 
field, `st.FieldList[lastOrdinal].Name` is the write field. After a rename 
(same ID, different names) the message reads `new_b is out of order, before 
old_a`, mixing both. Since the ordering walk is over the read struct, I'd 
source both names from the read side.



##########
schema_compatibility.go:
##########
@@ -0,0 +1,251 @@
+// 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
+// nullability checks.
+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 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 := -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, 
st.FieldList[lastOrdinal].Name))
+                       }
+                       lastOrdinal = ordinal
+               }
+       }
+
+       return errs
+}
+
+func (c *compatibilityChecker) Field(readField NestedField, fieldErrors func() 
[]string) []string {
+       if c.inContainer {
+               c.inContainer = false
+
+               return fieldErrors()
+       }
+
+       st := c.current.(*StructType)
+       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.Type(), readPrimitive)}

Review Comment:
   Every other method formats the write type as `c.current` (full `Stringer`), 
but this one uses `c.current.Type()`, which prints the short name, so you get 
`: struct cannot be read as a int` here vs `: list<int> cannot be read as a 
struct` from `Struct()`. Tests pass because they use `Contains`. I'd drop 
`.Type()` for consistency.



##########
schema_compatibility.go:
##########
@@ -0,0 +1,251 @@
+// 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
+// nullability checks.
+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 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 := -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, 
st.FieldList[lastOrdinal].Name))
+                       }
+                       lastOrdinal = ordinal
+               }
+       }
+
+       return errs
+}
+
+func (c *compatibilityChecker) Field(readField NestedField, fieldErrors func() 
[]string) []string {
+       if c.inContainer {
+               c.inContainer = false
+
+               return fieldErrors()
+       }
+
+       st := c.current.(*StructType)

Review Comment:
   This assertion is unguarded, while every other method here (`Struct`, 
`List`, `Map`) uses comma-ok. It's only safe today because `Struct()` 
short-circuits before invoking the field thunks when `c.current` isn't a 
`*StructType`, and that guarantee lives hundreds of lines away where it isn't 
visible here. If `Struct()` ever changes to keep collecting errors past a 
mismatch, this panics, and `PreOrderVisit`'s recover turns it into `error 
encountered during schema visitor: interface conversion: ...` rather than a 
domain message. I'd match the other methods:
   
   ```go
   st, ok := c.current.(*StructType)
   if !ok {
       return nil // Struct() already reported the mismatch
   }
   ```



##########
schema_compatibility_test.go:
##########
@@ -0,0 +1,483 @@
+// 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.Contains(t, errs[0], "struct cannot be read as a string")
+}
+
+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.Contains(t, errs[0], "map cannot be read as a string")
+}
+
+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")
+}

Review Comment:
   All the container tests use primitive elements, so the `inContainer` flag 
never gets exercised through two layers. I'd add a `list<map<...>>` or 
`map<string, struct<...>>` case with a nested type mismatch, which is where a 
regression in the flag lifecycle would actually surface.



##########
schema_compatibility.go:
##########
@@ -0,0 +1,251 @@
+// 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

Review Comment:
   The doc says this is `WriteCompatibilityErrors` without nullability checks, 
but `checkNullability` only gates struct-field nullability. The list-element 
and map-value required checks in `List`/`Map` still fire regardless. That 
matches Java, so I wouldn't change the behavior, but I'd narrow the comment to 
say struct-field nullability is skipped while container element and value 
nullability is still enforced. Otherwise a caller reaching for this to merge 
list schemas across a required/optional gap gets a surprise error.



##########
schema_compatibility_test.go:
##########
@@ -0,0 +1,483 @@
+// 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.Contains(t, errs[0], "struct cannot be read as a string")
+}
+
+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.Contains(t, errs[0], "map cannot be read as a string")
+}
+
+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.Contains(t, errs[0], "list cannot be read as a string")
+}
+
+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.Contains(t, errs[0], "field_b is out of order, before field_a")

Review Comment:
   This uses `Contains`, so it won't catch the read/write name mixing in the 
ordering branch of `Struct()`. Once that's fixed, I'd switch this to 
`assert.Equal` with the full expected string; the path-prefix tests already use 
`Equal`, so this one should match.



##########
schema_compatibility_test.go:
##########
@@ -0,0 +1,483 @@
+// 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.Contains(t, errs[0], "struct cannot be read as a string")
+}
+
+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.Contains(t, errs[0], "map cannot be read as a string")
+}
+
+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.Contains(t, errs[0], "list cannot be read as a string")
+}
+
+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.Contains(t, errs[0], "field_b is out of order, before field_a")
+}
+
+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)

Review Comment:
   Both `TypeCompatibilityErrors` tests assert no errors, so nothing confirms a 
type mismatch still gets reported when `checkNullability` is false. Invert the 
flag in `Field()` and this suite stays green. I'd add one case with an actual 
promotion violation under `TypeCompatibilityErrors` and assert the error still 
comes back.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to