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

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git


The following commit(s) were added to refs/heads/main by this push:
     new 65da9a886 fix!: store binary and fixed defaults as hex strings in 
schemas (#2372)
65da9a886 is described below

commit 65da9a886fec8126fd5ebe9a506d78a8949374fe
Author: Revanth <[email protected]>
AuthorDate: Thu Nov 5 11:11:59 2026 -0600

    fix!: store binary and fixed defaults as hex strings in schemas (#2372)
    
    * refactor!: store binary and fixed defaults as hex strings in schemas
    
    * docs: scope the hex default conversion to binary and fixed fields
---
 defaults.go                    | 113 ++++++++++++++++++++++++++++++++++++++++-
 defaults_test.go               |  98 +++++++++++++++++++++++++++++++++++
 schema.go                      |   7 +++
 schema_test.go                 |   3 +-
 table/metadata_getters_test.go |   2 -
 table/update_schema_test.go    |  44 ++++++++++++++++
 types.go                       |  12 +----
 view/metadata_test.go          |   6 +--
 website/src/releases.md        |   1 +
 9 files changed, 267 insertions(+), 19 deletions(-)

diff --git a/defaults.go b/defaults.go
index db18843ed..1c655e50a 100644
--- a/defaults.go
+++ b/defaults.go
@@ -17,7 +17,11 @@
 
 package iceberg
 
-import "slices"
+import (
+       "slices"
+
+       "github.com/apache/iceberg-go/internal"
+)
 
 // CloneDefaultValue returns a deep copy of an Iceberg initial or write 
default value.
 func CloneDefaultValue(value any) any {
@@ -49,3 +53,110 @@ func CloneDefaultValue(value any) any {
                return value
        }
 }
+
+// hexDefault returns the hex single-value form of a binary or fixed default
+// given as bytes. It reports false for any other type or value, including a
+// default that is already a string.
+func hexDefault(typ Type, value any) (string, bool) {
+       switch typ.(type) {
+       case BinaryType, FixedType:
+               switch value := value.(type) {
+               case []byte:
+                       return internal.EncodeDefaultBytes(value), true
+               case BinaryLiteral:
+                       return internal.EncodeDefaultBytes(value), true
+               case FixedLiteral:
+                       return internal.EncodeDefaultBytes(value), true
+               }
+       }
+
+       return "", false
+}
+
+// normalizeDefaults stores binary and fixed defaults in their hex single-value
+// form, the same form metadata JSON decodes to, so the default of a binary or
+// fixed field no longer shares a byte slice with the caller. Byte slices
+// inside other defaults, such as a []byte in a []any or map[string]any
+// default, are left as they are. When something is rewritten it
+// copies the fields and nested types on the path to that default and reports
+// true; otherwise it returns fields itself. The input is never modified.
+func normalizeDefaults(fields []NestedField) ([]NestedField, bool) {
+       var out []NestedField
+       for i, field := range fields {
+               normalized, ok := normalizeFieldDefaults(field)
+               if !ok {
+                       continue
+               }
+               if out == nil {
+                       out = slices.Clone(fields)
+               }
+               out[i] = normalized
+       }
+       if out == nil {
+               return fields, false
+       }
+
+       return out, true
+}
+
+func normalizeFieldDefaults(field NestedField) (NestedField, bool) {
+       changed := false
+       if value, ok := hexDefault(field.Type, field.InitialDefault); ok {
+               field.InitialDefault, changed = value, true
+       }
+       if value, ok := hexDefault(field.Type, field.WriteDefault); ok {
+               field.WriteDefault, changed = value, true
+       }
+       if typ, ok := normalizeTypeDefaults(field.Type); ok {
+               field.Type, changed = typ, true
+       }
+
+       return field, changed
+}
+
+func normalizeTypeDefaults(typ Type) (Type, bool) {
+       switch typ := typ.(type) {
+       case *StructType:
+               if typ == nil {
+                       return nil, false
+               }
+               fields, ok := normalizeDefaults(typ.FieldList)
+               if !ok {
+                       return nil, false
+               }
+
+               return &StructType{FieldList: fields}, true
+       case *ListType:
+               if typ == nil {
+                       return nil, false
+               }
+               elem, ok := normalizeTypeDefaults(typ.Element)
+               if !ok {
+                       return nil, false
+               }
+               cloned := *typ
+               cloned.Element = elem
+
+               return &cloned, true
+       case *MapType:
+               if typ == nil {
+                       return nil, false
+               }
+               key, keyOK := normalizeTypeDefaults(typ.KeyType)
+               value, valueOK := normalizeTypeDefaults(typ.ValueType)
+               if !keyOK && !valueOK {
+                       return nil, false
+               }
+               cloned := *typ
+               if keyOK {
+                       cloned.KeyType = key
+               }
+               if valueOK {
+                       cloned.ValueType = value
+               }
+
+               return &cloned, true
+       }
+
+       return nil, false
+}
diff --git a/defaults_test.go b/defaults_test.go
index 50f91b023..b4298995b 100644
--- a/defaults_test.go
+++ b/defaults_test.go
@@ -18,6 +18,7 @@
 package iceberg_test
 
 import (
+       "encoding/json"
        "testing"
 
        iceberg "github.com/apache/iceberg-go"
@@ -51,3 +52,100 @@ func TestCloneDefaultValue(t *testing.T) {
        assert.NotContains(t, original, "added")
        assert.NotContains(t, original["nested"].([]any)[0], "added")
 }
+
+func TestNewSchemaStoresByteDefaultsAsHex(t *testing.T) {
+       tests := []struct {
+               name  string
+               typ   iceberg.Type
+               value any
+       }{
+               {"binary bytes", iceberg.PrimitiveTypes.Binary, []byte{0x0a, 
0xff}},
+               {"binary literal", iceberg.PrimitiveTypes.Binary, 
iceberg.BinaryLiteral{0x0a, 0xff}},
+               {"fixed bytes", iceberg.MustFixedTypeOf(2), []byte{0x0a, 0xff}},
+               {"fixed literal", iceberg.MustFixedTypeOf(2), 
iceberg.FixedLiteral{0x0a, 0xff}},
+               {"hex string", iceberg.PrimitiveTypes.Binary, "0aff"},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       sc := iceberg.NewSchema(0, iceberg.NestedField{
+                               ID: 1, Name: "b", Type: tt.typ, InitialDefault: 
tt.value, WriteDefault: tt.value,
+                       })
+
+                       field := sc.Field(0)
+                       assert.Equal(t, "0aff", field.InitialDefault)
+                       assert.Equal(t, "0aff", field.WriteDefault)
+               })
+       }
+}
+
+func TestNewSchemaLeavesOtherDefaultsAlone(t *testing.T) {
+       sc := iceberg.NewSchema(0,
+               iceberg.NestedField{ID: 1, Name: "s", Type: 
iceberg.PrimitiveTypes.String, InitialDefault: "0aff"},
+               iceberg.NestedField{ID: 2, Name: "i", Type: 
iceberg.PrimitiveTypes.Int32, InitialDefault: int32(7)},
+               iceberg.NestedField{ID: 3, Name: "n", Type: 
iceberg.PrimitiveTypes.Binary},
+       )
+
+       assert.Equal(t, "0aff", sc.Field(0).InitialDefault)
+       assert.Equal(t, int32(7), sc.Field(1).InitialDefault)
+       assert.Nil(t, sc.Field(2).InitialDefault)
+}
+
+// A schema built in process must equal the same schema read back from
+// metadata, where binary and fixed defaults are hex strings.
+func TestNewSchemaByteDefaultsSurviveJSONRoundTrip(t *testing.T) {
+       sc := iceberg.NewSchema(0,
+               iceberg.NestedField{ID: 1, Name: "b", Type: 
iceberg.PrimitiveTypes.Binary, InitialDefault: []byte{1, 2}},
+               iceberg.NestedField{ID: 2, Name: "f", Type: 
iceberg.MustFixedTypeOf(2), WriteDefault: iceberg.FixedLiteral{3, 4}},
+       )
+
+       data, err := json.Marshal(sc)
+       require.NoError(t, err)
+
+       var decoded iceberg.Schema
+       require.NoError(t, json.Unmarshal(data, &decoded))
+       assert.True(t, sc.Equals(&decoded), "built: %v, decoded: %v", 
sc.Fields(), decoded.Fields())
+}
+
+func TestNewSchemaByteDefaultIsIsolatedFromCaller(t *testing.T) {
+       value := []byte{1, 2}
+       sc := iceberg.NewSchema(0, iceberg.NestedField{
+               ID: 1, Name: "b", Type: iceberg.PrimitiveTypes.Binary, 
InitialDefault: value,
+       })
+
+       value[0] = 9
+       assert.Equal(t, "0102", sc.Field(0).InitialDefault)
+}
+
+func TestNewSchemaNormalizesNestedByteDefaultsWithoutModifyingCaller(t 
*testing.T) {
+       inner := &iceberg.StructType{FieldList: []iceberg.NestedField{
+               {ID: 5, Name: "payload", Type: iceberg.PrimitiveTypes.Binary, 
InitialDefault: []byte{1, 2}},
+               {ID: 6, Name: "label", Type: iceberg.PrimitiveTypes.String, 
InitialDefault: "x"},
+       }}
+       list := &iceberg.ListType{ElementID: 4, Element: inner, 
ElementRequired: true}
+       mapType := &iceberg.MapType{
+               KeyID: 2, KeyType: iceberg.PrimitiveTypes.String,
+               ValueID: 3, ValueType: list, ValueRequired: true,
+       }
+       fields := []iceberg.NestedField{
+               {ID: 1, Name: "m", Type: mapType},
+               {ID: 7, Name: "top", Type: iceberg.PrimitiveTypes.Binary, 
WriteDefault: []byte{3}},
+       }
+
+       sc := iceberg.NewSchema(0, fields...)
+
+       payload, ok := sc.FindFieldByID(5)
+       require.True(t, ok)
+       assert.Equal(t, "0102", payload.InitialDefault)
+       label, ok := sc.FindFieldByID(6)
+       require.True(t, ok)
+       assert.Equal(t, "x", label.InitialDefault)
+       top, ok := sc.FindFieldByID(7)
+       require.True(t, ok)
+       assert.Equal(t, "03", top.WriteDefault)
+
+       assert.Equal(t, []byte{1, 2}, inner.FieldList[0].InitialDefault)
+       assert.Same(t, list, mapType.ValueType)
+       assert.Same(t, inner, list.Element)
+       assert.Equal(t, []byte{3}, fields[1].WriteDefault)
+}
diff --git a/schema.go b/schema.go
index e518e3a5e..5b3ef89c3 100644
--- a/schema.go
+++ b/schema.go
@@ -77,7 +77,14 @@ func NewSchema(id int, fields ...NestedField) *Schema {
 // NewSchemaWithIdentifiers constructs a new schema with the provided ID
 // and fields, along with a slice of field IDs to be listed as identifier
 // fields.
+//
+// The defaults of binary and fixed fields, including fields nested in structs,
+// lists and maps, are stored as hex strings when given as []byte,
+// [BinaryLiteral] or [FixedLiteral], the form they take in metadata JSON.
+// Byte slices inside other defaults are left as they are. The provided fields
+// and types are not modified.
 func NewSchemaWithIdentifiers(id int, identifierIDs []int, fields 
...NestedField) *Schema {
+       fields, _ = normalizeDefaults(fields)
        s := &Schema{ID: id, fields: fields, IdentifierFieldIDs: identifierIDs}
        s.init()
 
diff --git a/schema_test.go b/schema_test.go
index 573df769d..7a895586d 100644
--- a/schema_test.go
+++ b/schema_test.go
@@ -346,7 +346,6 @@ func TestSchemaFieldGettersReturnDefensiveCopies(t 
*testing.T) {
                person, ok := personField.Type.(*iceberg.StructType)
                require.True(t, ok)
                person.FieldList[0].Name = "hijacked"
-               payloadField.InitialDefault.(iceberg.BinaryLiteral)[0] = 9
                
payloadField.WriteDefault.(map[string]any)["nested"].([]any)[0].([]byte)[0] = 9
 
                actual, ok := schema.FindFieldByID(2)
@@ -354,7 +353,7 @@ func TestSchemaFieldGettersReturnDefensiveCopies(t 
*testing.T) {
                assert.Equal(t, "name", actual.Name)
                payload, ok := schema.FindFieldByID(3)
                require.True(t, ok)
-               assert.Equal(t, iceberg.BinaryLiteral{1, 2}, 
payload.InitialDefault)
+               assert.Equal(t, "0102", payload.InitialDefault)
                assert.Equal(t, map[string]any{"nested": []any{[]byte{3, 4}}}, 
payload.WriteDefault)
        }
 
diff --git a/table/metadata_getters_test.go b/table/metadata_getters_test.go
index b463b22cd..f427845b0 100644
--- a/table/metadata_getters_test.go
+++ b/table/metadata_getters_test.go
@@ -191,8 +191,6 @@ func TestMetadataSchemaGetterCopiesNestedValues(t 
*testing.T) {
        cloned.IdentifierFieldIDs[0] = 99
        fields := cloned.FieldsRef(iceinternal.SchemaRef{})
        payload := fields[0].Type.(*iceberg.StructType)
-       payload.FieldList[0].InitialDefault.([]byte)[0] = 99
-       payload.FieldList[0].WriteDefault.(iceberg.BinaryLiteral)[0] = 99
        payload.FieldList[1].Name = "changed"
        
payload.FieldList[1].Type.(*iceberg.ListType).Element.(*iceberg.StructType).FieldList[0].InitialDefault.([]any)[0].(map[string]any)["bytes"].([]byte)[0]
 = 99
        
payload.FieldList[2].Type.(*iceberg.MapType).ValueType.(*iceberg.StructType).FieldList[0].WriteDefault.(map[string]any)["values"].([]any)[0]
 = "changed"
diff --git a/table/update_schema_test.go b/table/update_schema_test.go
index 6ad7ca53b..6a1d795a6 100644
--- a/table/update_schema_test.go
+++ b/table/update_schema_test.go
@@ -18,6 +18,7 @@
 package table
 
 import (
+       "encoding/json"
        "testing"
 
        "github.com/apache/iceberg-go"
@@ -2221,6 +2222,49 @@ func TestUnionByNameAddedColumnPreservesInitialDefault(t 
*testing.T) {
        assert.Equal(t, int32(42), count.WriteDefault)
 }
 
+func TestAddColumnStoresBinaryDefaultAsHex(t *testing.T) {
+       current := iceberg.NewSchema(1,
+               iceberg.NestedField{ID: 1, Name: "id", Type: 
iceberg.PrimitiveTypes.Int32, Required: true},
+       )
+
+       applied, err := NewUpdateSchema(unionTxn(t, current), true, false).
+               AddColumn([]string{"payload"}, iceberg.PrimitiveTypes.Binary, 
"", false, iceberg.NewLiteral([]byte{0x0a, 0xff})).
+               Apply()
+       require.NoError(t, err)
+
+       payload, ok := applied.FindFieldByName("payload")
+       require.True(t, ok)
+       assert.Equal(t, "0aff", payload.InitialDefault)
+       assert.Equal(t, "0aff", payload.WriteDefault)
+
+       data, err := json.Marshal(applied)
+       require.NoError(t, err)
+       var decoded iceberg.Schema
+       require.NoError(t, json.Unmarshal(data, &decoded))
+       assert.True(t, applied.Equals(&decoded))
+}
+
+// A binary write default read from metadata and the same default built in
+// process must compare equal, so union-by-name does not rewrite it.
+func TestUnionByNameKeepsUnchangedBinaryWriteDefault(t *testing.T) {
+       var current iceberg.Schema
+       require.NoError(t, 
json.Unmarshal([]byte(`{"type":"struct","schema-id":1,"fields":[`+
+               
`{"id":1,"name":"payload","type":"binary","required":false,"initial-default":"0aff","write-default":"0aff"}]}`),
 &current))
+       incoming := iceberg.NewSchema(1, iceberg.NestedField{
+               ID: 1, Name: "payload", Type: iceberg.PrimitiveTypes.Binary,
+               InitialDefault: []byte{0x0a, 0xff}, WriteDefault: []byte{0x0a, 
0xff},
+       })
+
+       meta, err := NewMetadata(&current, iceberg.UnpartitionedSpec, 
UnsortedSortOrder, "",
+               iceberg.Properties{PropertyFormatVersion: "3"})
+       require.NoError(t, err)
+       txn := New([]string{"tbl"}, meta, "", nil, nil).NewTransaction()
+
+       applied, err := NewUpdateSchema(txn, true, 
false).UnionByNameWith(incoming).Apply()
+       require.NoError(t, err)
+       assert.Equal(t, current.AsStruct(), applied.AsStruct())
+}
+
 func TestUnionByNameRejectsInvalidAddedColumnDefault(t *testing.T) {
        current := iceberg.NewSchema(1,
                iceberg.NestedField{ID: 1, Name: "id", Type: 
iceberg.PrimitiveTypes.Int32, Required: true},
diff --git a/types.go b/types.go
index 0e780fbdc..50bbf01dd 100644
--- a/types.go
+++ b/types.go
@@ -358,16 +358,8 @@ func (n NestedField) MarshalJSON() ([]byte, error) {
 }
 
 func defaultValueToJSON(typ Type, value any) any {
-       switch typ.(type) {
-       case BinaryType, FixedType:
-               switch value := value.(type) {
-               case []byte:
-                       return internal.EncodeDefaultBytes(value)
-               case BinaryLiteral:
-                       return internal.EncodeDefaultBytes(value)
-               case FixedLiteral:
-                       return internal.EncodeDefaultBytes(value)
-               }
+       if encoded, ok := hexDefault(typ, value); ok {
+               return encoded
        }
 
        return value
diff --git a/view/metadata_test.go b/view/metadata_test.go
index 957cb7d28..953fb4c93 100644
--- a/view/metadata_test.go
+++ b/view/metadata_test.go
@@ -761,8 +761,6 @@ func TestCloneSchemaCopiesNestedValues(t *testing.T) {
        fields := cloned.FieldsRef(iceinternal.SchemaRef{})
        structField := fields[0].Type.(*iceberg.StructType)
        structField.FieldList[0].Name = "changed"
-       structField.FieldList[0].InitialDefault.([]byte)[0] = 9
-       structField.FieldList[0].WriteDefault.(iceberg.BinaryLiteral)[0] = 9
        listField := fields[1].Type.(*iceberg.ListType)
        listField.Element.(*iceberg.StructType).FieldList[0].Name = "changed"
        mapField := fields[2].Type.(*iceberg.MapType)
@@ -771,8 +769,8 @@ func TestCloneSchemaCopiesNestedValues(t *testing.T) {
        assert.Equal(t, []int{1}, schema.IdentifierFieldIDs)
        originalStruct := schema.Field(0).Type.(*iceberg.StructType)
        assert.Equal(t, "nested", originalStruct.FieldList[0].Name)
-       assert.Equal(t, []byte{1}, originalStruct.FieldList[0].InitialDefault)
-       assert.Equal(t, iceberg.BinaryLiteral{2}, 
originalStruct.FieldList[0].WriteDefault)
+       assert.Equal(t, "01", originalStruct.FieldList[0].InitialDefault)
+       assert.Equal(t, "02", originalStruct.FieldList[0].WriteDefault)
        assert.Equal(t, "element", 
schema.Field(1).Type.(*iceberg.ListType).Element.(*iceberg.StructType).FieldList[0].Name)
        assert.Equal(t, "value", 
schema.Field(2).Type.(*iceberg.MapType).ValueType.(*iceberg.StructType).FieldList[0].Name)
 }
diff --git a/website/src/releases.md b/website/src/releases.md
index 5e1518b31..d748cb181 100644
--- a/website/src/releases.md
+++ b/website/src/releases.md
@@ -42,6 +42,7 @@ Per-release notes (highlights, breaking changes, 
contributors) are published on
 
 ### Unreleased compatibility notes
 
+- **Schemas store binary and fixed defaults as hex strings.** `NewSchema`, 
`NewSchemaWithIdentifiers` and `UpdateSchema.AddColumn` (through `Apply()`) 
convert `[]byte`, `BinaryLiteral` and `FixedLiteral` defaults of binary and 
fixed fields, including fields nested in structs, lists and maps, to the 
lowercase hex string iceberg-go writes to metadata JSON, so a field read back 
from a schema holds a `string` such as `"0aff"`. Code that type-asserts these 
defaults to `[]byte` must decode the [...]
 - **Predicates reject NaN literals when bound.** `EqualTo`, `NotEqualTo`, 
`LessThan`, `LessThanEqual`, `GreaterThan`, `GreaterThanEqual`, `IsIn`, `NotIn` 
and `LiteralPredicate`/`SetPredicate` with a NaN float literal now fail in 
`Bind` with `ErrInvalidArgument`, as Java does. That includes the string 
`"NaN"` on a float column and a NaN on a decimal column, which used to bind as 
zero. Scans, `ExpressionEvaluator` and the metrics evaluators all bind first, 
so they return the error too. Use [...]
 - **SQL view APIs align with `catalog.ViewCatalog`.** `CreateView` now takes a 
`*view.Version`, schema and options, and returns `*view.View`. `LoadView` now 
returns `*view.View`; use `Metadata()` to access its metadata. If the caller 
leaves `DefaultCatalog` empty, SQL still sets it to the catalog name.
 - **`LocationProvider.NewDataLocation` takes the partition path.** The method 
is now `NewDataLocation(dataFileName, partitionPath string)`, matching Iceberg 
Java and PyIceberg, so custom `LocationProvider` implementations must add the 
argument. Callers pass the partition path there, or `""` for an unpartitioned 
file, instead of prefixing the file name.

Reply via email to