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 7581291e8 refactor: encode nested types without aliasing their 
exported fields (#2361)
7581291e8 is described below

commit 7581291e82650407697232213ab8551c1e0b957e
Author: Revanth <[email protected]>
AuthorDate: Wed Nov 4 11:11:50 2026 -0600

    refactor: encode nested types without aliasing their exported fields (#2361)
    
    * refactor: encode nested types without aliasing their exported fields
    
    * refactor: name every JSON key in Schema and NestedField decoding
---
 nested_type_json_internal_test.go |  72 ++++++++++++++++++++++++++
 schema.go                         |  40 +++++++--------
 types.go                          |  86 ++++++++++++++++++-------------
 types_test.go                     | 105 ++++++++++++++++++++++++++++++++++++++
 4 files changed, 246 insertions(+), 57 deletions(-)

diff --git a/nested_type_json_internal_test.go 
b/nested_type_json_internal_test.go
new file mode 100644
index 000000000..30eca8487
--- /dev/null
+++ b/nested_type_json_internal_test.go
@@ -0,0 +1,72 @@
+// 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 (
+       "encoding/json"
+       "reflect"
+       "testing"
+
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+// TestNestedTypeJSONRoundTripsEveryField fails when a field is added to one of
+// these types without a value in its fixture, and when the JSON methods drop a
+// field. It walks unexported fields too, so it keeps working after #2062.
+func TestNestedTypeJSONRoundTripsEveryField(t *testing.T) {
+       t.Run("StructType", func(t *testing.T) {
+               requireJSONRoundTripsEveryField(t, StructType{FieldList: 
[]NestedField{
+                       {ID: 1, Name: "a", Type: PrimitiveTypes.Int32, 
Required: true},
+               }})
+       })
+       t.Run("ListType", func(t *testing.T) {
+               requireJSONRoundTripsEveryField(t, ListType{
+                       ElementID: 1, Element: PrimitiveTypes.String, 
ElementRequired: true,
+               })
+       })
+       t.Run("MapType", func(t *testing.T) {
+               requireJSONRoundTripsEveryField(t, MapType{
+                       KeyID: 1, KeyType: PrimitiveTypes.String,
+                       ValueID: 2, ValueType: PrimitiveTypes.Int32, 
ValueRequired: true,
+               })
+       })
+       t.Run("NestedField", func(t *testing.T) {
+               requireJSONRoundTripsEveryField(t, NestedField{
+                       ID: 1, Name: "a", Type: PrimitiveTypes.String, 
Required: true,
+                       Doc: "doc", InitialDefault: "initial", WriteDefault: 
"write",
+               })
+       })
+}
+
+func requireJSONRoundTripsEveryField[T any](t *testing.T, value T) {
+       t.Helper()
+
+       v := reflect.ValueOf(value)
+       for i := range v.NumField() {
+               require.Falsef(t, v.Field(i).IsZero(), "populate %s.%s in the 
fixture",
+                       v.Type().Name(), v.Type().Field(i).Name)
+       }
+
+       data, err := json.Marshal(&value)
+       require.NoError(t, err)
+
+       var decoded T
+       require.NoError(t, json.Unmarshal(data, &decoded))
+       assert.Equal(t, value, decoded)
+}
diff --git a/schema.go b/schema.go
index 94c8fc0e4..e518e3a5e 100644
--- a/schema.go
+++ b/schema.go
@@ -295,13 +295,12 @@ func (s *Schema) FieldIDs() []int {
 }
 
 func (s *Schema) UnmarshalJSON(b []byte) error {
-       type Alias Schema
-       var decoded Schema
        aux := struct {
-               Type   *string         `json:"type"`
-               Fields json.RawMessage `json:"fields"`
-               *Alias
-       }{Alias: (*Alias)(&decoded)}
+               Type               *string         `json:"type"`
+               Fields             json.RawMessage `json:"fields"`
+               ID                 int             `json:"schema-id"`
+               IdentifierFieldIDs []int           `json:"identifier-field-ids"`
+       }{}
 
        if err := json.Unmarshal(b, &aux); err != nil {
                return err
@@ -326,14 +325,14 @@ func (s *Schema) UnmarshalJSON(b []byte) error {
                return err
        }
 
-       decoded.fields = fields
-       if decoded.IdentifierFieldIDs == nil {
-               decoded.IdentifierFieldIDs = []int{}
+       ids := aux.IdentifierFieldIDs
+       if ids == nil {
+               ids = []int{}
        }
 
-       s.ID = decoded.ID
-       s.IdentifierFieldIDs = decoded.IdentifierFieldIDs
-       s.fields = decoded.fields
+       s.ID = aux.ID
+       s.IdentifierFieldIDs = ids
+       s.fields = fields
        s.idToName.Store(nil)
        s.idToField.Store(nil)
        s.nameToID.Store(nil)
@@ -354,17 +353,14 @@ func (s *Schema) MarshalJSON() ([]byte, error) {
                fields = []NestedField{}
        }
 
-       type Alias Schema
-
-       // Keep this literal in sync with every JSON-marshaled Schema field. 
Copying
-       // the whole Schema would also copy its lazy atomic caches after they 
are used.
-       aliasCopy := Alias{ID: s.ID, IdentifierFieldIDs: ids}
-
+       // Name every serialized field instead of encoding through an alias of
+       // Schema, so the wire format does not depend on which fields are 
exported.
        return json.Marshal(struct {
-               Type   string        `json:"type"`
-               Fields []NestedField `json:"fields"`
-               *Alias
-       }{Type: "struct", Fields: fields, Alias: &aliasCopy})
+               Type               string        `json:"type"`
+               Fields             []NestedField `json:"fields"`
+               ID                 int           `json:"schema-id"`
+               IdentifierFieldIDs []int         `json:"identifier-field-ids"`
+       }{Type: "struct", Fields: fields, ID: s.ID, IdentifierFieldIDs: ids})
 }
 
 func cloneFields(fields []NestedField) []NestedField {
diff --git a/types.go b/types.go
index adabde404..0e780fbdc 100644
--- a/types.go
+++ b/types.go
@@ -374,42 +374,41 @@ func defaultValueToJSON(typ Type, value any) any {
 }
 
 func (n *NestedField) UnmarshalJSON(b []byte) error {
-       type Alias NestedField
-       var next NestedField
        aux := struct {
-               ID       *int      `json:"id"`
-               Required *bool     `json:"required"`
-               Type     typeIFace `json:"type"`
-               *Alias
-       }{
-               Alias: (*Alias)(&next),
-       }
+               ID             *int      `json:"id"`
+               Name           string    `json:"name"`
+               Required       *bool     `json:"required"`
+               Type           typeIFace `json:"type"`
+               Doc            string    `json:"doc"`
+               InitialDefault any       `json:"initial-default"`
+               WriteDefault   any       `json:"write-default"`
+       }{}
 
        if err := json.Unmarshal(b, &aux); err != nil {
                return err
        }
 
-       next.Type = aux.Type.Type
-
        if aux.ID == nil {
                return fmt.Errorf("%w: field is missing required 'id' key in 
JSON", ErrInvalidSchema)
        }
-       next.ID = *aux.ID
 
-       if next.Name == "" {
+       if aux.Name == "" {
                return fmt.Errorf("%w: field is missing required 'name' key in 
JSON", ErrInvalidSchema)
        }
 
-       if next.Type == nil {
-               return fmt.Errorf("%w: field %q is missing required 'type' key 
in JSON", ErrInvalidSchema, next.Name)
+       if aux.Type.Type == nil {
+               return fmt.Errorf("%w: field %q is missing required 'type' key 
in JSON", ErrInvalidSchema, aux.Name)
        }
 
        if aux.Required == nil {
                return fmt.Errorf("%w: field is missing required 'required' key 
in JSON", ErrInvalidSchema)
        }
-       next.Required = *aux.Required
 
-       *n = next
+       *n = NestedField{
+               Type: aux.Type.Type,
+               ID:   *aux.ID, Name: aux.Name, Required: *aux.Required, Doc: 
aux.Doc,
+               InitialDefault: aux.InitialDefault, WriteDefault: 
aux.WriteDefault,
+       }
 
        return nil
 }
@@ -431,13 +430,28 @@ func (s *StructType) Equals(other Type) bool {
 
 func (s *StructType) Fields() []NestedField { return s.FieldList }
 
-func (s *StructType) MarshalJSON() ([]byte, error) {
-       type Alias StructType
+// The JSON methods of the nested types name every serialized field instead of
+// encoding through an alias of the type, so the wire format does not depend on
+// which Go fields are exported.
 
+func (s *StructType) MarshalJSON() ([]byte, error) {
        return json.Marshal(struct {
-               Type string `json:"type"`
-               *Alias
-       }{Type: s.Type(), Alias: (*Alias)(s)})
+               Type   string        `json:"type"`
+               Fields []NestedField `json:"fields"`
+       }{Type: s.Type(), Fields: s.FieldList})
+}
+
+func (s *StructType) UnmarshalJSON(b []byte) error {
+       aux := struct {
+               Fields []NestedField `json:"fields"`
+       }{}
+       if err := json.Unmarshal(b, &aux); err != nil {
+               return err
+       }
+
+       s.FieldList = aux.Fields
+
+       return nil
 }
 
 func (*StructType) Type() string { return "struct" }
@@ -474,13 +488,15 @@ type ListType struct {
 }
 
 func (l *ListType) MarshalJSON() ([]byte, error) {
-       type Alias ListType
-
        return json.Marshal(struct {
-               Type string `json:"type"`
-               *Alias
-               Element *typeIFace `json:"element"`
-       }{Type: l.Type(), Alias: (*Alias)(l), Element: &typeIFace{l.Element}})
+               Type            string     `json:"type"`
+               ElementID       int        `json:"element-id"`
+               ElementRequired bool       `json:"element-required"`
+               Element         *typeIFace `json:"element"`
+       }{
+               Type: l.Type(), ElementID: l.ElementID, ElementRequired: 
l.ElementRequired,
+               Element: &typeIFace{l.Element},
+       })
 }
 
 func (l *ListType) Equals(other Type) bool {
@@ -546,15 +562,15 @@ type MapType struct {
 }
 
 func (m *MapType) MarshalJSON() ([]byte, error) {
-       type Alias MapType
-
        return json.Marshal(struct {
-               Type string `json:"type"`
-               *Alias
-               KeyType   *typeIFace `json:"key"`
-               ValueType *typeIFace `json:"value"`
+               Type          string     `json:"type"`
+               KeyID         int        `json:"key-id"`
+               ValueID       int        `json:"value-id"`
+               ValueRequired bool       `json:"value-required"`
+               KeyType       *typeIFace `json:"key"`
+               ValueType     *typeIFace `json:"value"`
        }{
-               Type: m.Type(), Alias: (*Alias)(m),
+               Type: m.Type(), KeyID: m.KeyID, ValueID: m.ValueID, 
ValueRequired: m.ValueRequired,
                KeyType:   &typeIFace{m.KeyType},
                ValueType: &typeIFace{m.ValueType},
        })
diff --git a/types_test.go b/types_test.go
index eb3a453cd..0224942c1 100644
--- a/types_test.go
+++ b/types_test.go
@@ -304,6 +304,111 @@ func TestMapTypeUnmarshalRequiresValue(t *testing.T) {
        assert.ErrorContains(t, err, "missing required 'value' key")
 }
 
+// nestedTypeWireFixture nests every composite type inside the others, with
+// required and optional children, docs and defaults on nested fields.
+func nestedTypeWireFixture() *iceberg.StructType {
+       return &iceberg.StructType{FieldList: []iceberg.NestedField{
+               {ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, 
Required: true},
+               {ID: 2, Name: "tags", Doc: "labels", Type: &iceberg.ListType{
+                       ElementID: 3, Element: iceberg.PrimitiveTypes.String, 
ElementRequired: false,
+               }},
+               {ID: 4, Name: "attrs", Required: true, Type: &iceberg.MapType{
+                       KeyID: 5, KeyType: iceberg.PrimitiveTypes.String,
+                       ValueID: 6, ValueType: &iceberg.StructType{FieldList: 
[]iceberg.NestedField{
+                               {ID: 7, Name: "score", Type: 
iceberg.PrimitiveTypes.Float64, Required: true},
+                               {ID: 8, Name: "note", Type: 
iceberg.PrimitiveTypes.String, InitialDefault: "n/a", WriteDefault: "none"},
+                       }},
+                       ValueRequired: true,
+               }},
+               {ID: 9, Name: "points", Type: &iceberg.ListType{
+                       ElementID: 10, ElementRequired: true, Element: 
&iceberg.StructType{FieldList: []iceberg.NestedField{
+                               {ID: 11, Name: "x", Type: 
iceberg.PrimitiveTypes.Float32, Required: true},
+                               {ID: 12, Name: "y", Type: 
iceberg.PrimitiveTypes.Float32, Required: true},
+                       }},
+               }},
+               {ID: 13, Name: "flags", Type: &iceberg.MapType{
+                       KeyID: 14, KeyType: iceberg.PrimitiveTypes.Int32,
+                       ValueID: 15, ValueType: &iceberg.ListType{
+                               ElementID: 16, Element: 
iceberg.PrimitiveTypes.Bool, ElementRequired: false,
+                       },
+                       ValueRequired: false,
+               }},
+               {ID: 17, Name: "active", Type: iceberg.PrimitiveTypes.Bool, 
InitialDefault: true},
+       }}
+}
+
+const nestedTypeWireJSON = `{"type":"struct","fields":[` +
+       `{"type":"long","id":1,"name":"id","required":true},` +
+       
`{"type":{"type":"list","element-id":3,"element-required":false,"element":"string"},`
 +
+       `"id":2,"name":"tags","required":false,"doc":"labels"},` +
+       
`{"type":{"type":"map","key-id":5,"value-id":6,"value-required":true,"key":"string",`
 +
+       `"value":{"type":"struct","fields":[` +
+       `{"type":"double","id":7,"name":"score","required":true},` +
+       
`{"type":"string","id":8,"name":"note","required":false,"initial-default":"n/a","write-default":"none"}]}},`
 +
+       `"id":4,"name":"attrs","required":true},` +
+       `{"type":{"type":"list","element-id":10,"element-required":true,` +
+       `"element":{"type":"struct","fields":[` +
+       `{"type":"float","id":11,"name":"x","required":true},` +
+       `{"type":"float","id":12,"name":"y","required":true}]}},` +
+       `"id":9,"name":"points","required":false},` +
+       
`{"type":{"type":"map","key-id":14,"value-id":15,"value-required":false,"key":"int",`
 +
+       
`"value":{"type":"list","element-id":16,"element-required":false,"element":"boolean"}},`
 +
+       `"id":13,"name":"flags","required":false},` +
+       
`{"type":"boolean","id":17,"name":"active","required":false,"initial-default":true}]}`
+
+// TestNestedTypeJSONWireFormat pins the exact bytes, key order included, that
+// nested types encode to, and that decoding them rebuilds an equal type.
+func TestNestedTypeJSONWireFormat(t *testing.T) {
+       typ := nestedTypeWireFixture()
+
+       out, err := json.Marshal(typ)
+       require.NoError(t, err)
+       assert.Equal(t, nestedTypeWireJSON, string(out))
+
+       var decoded iceberg.StructType
+       require.NoError(t, json.Unmarshal([]byte(nestedTypeWireJSON), &decoded))
+       assert.True(t, typ.Equals(&decoded), "decoded: %s", &decoded)
+
+       again, err := json.Marshal(&decoded)
+       require.NoError(t, err)
+       assert.Equal(t, nestedTypeWireJSON, string(again))
+
+       t.Run("in schema", func(t *testing.T) {
+               sc := iceberg.NewSchema(0, typ.FieldList...)
+               out, err := json.Marshal(sc)
+               require.NoError(t, err)
+
+               var decoded iceberg.Schema
+               require.NoError(t, json.Unmarshal(out, &decoded))
+               assert.True(t, sc.Equals(&decoded))
+       })
+}
+
+// TestStructTypeUnmarshalMissingFieldsIsEmpty decodes into a populated type:
+// like the other nested types, a decode replaces the whole value.
+func TestStructTypeUnmarshalMissingFieldsIsEmpty(t *testing.T) {
+       typ := iceberg.StructType{FieldList: []iceberg.NestedField{
+               {ID: 1, Name: "stale", Type: iceberg.PrimitiveTypes.String},
+       }}
+       require.NoError(t, json.Unmarshal([]byte(`{"type":"struct"}`), &typ))
+       assert.Empty(t, typ.FieldList)
+}
+
+func TestStructTypeUnmarshalErrorLeavesTypeUnchanged(t *testing.T) {
+       kept := func() iceberg.StructType {
+               return iceberg.StructType{FieldList: []iceberg.NestedField{
+                       {ID: 1, Name: "kept", Type: 
iceberg.PrimitiveTypes.String},
+               }}
+       }
+       typ := kept()
+
+       err := json.Unmarshal([]byte(`{"type":"struct","fields":[`+
+               `{"id":2,"name":"replaced","type":"int","required":true},`+
+               `{"id":3,"name":"bad","required":false}]}`), &typ)
+       assert.ErrorIs(t, err, iceberg.ErrInvalidSchema)
+       assert.Equal(t, kept(), typ)
+}
+
 var NonParameterizedTypes = []iceberg.Type{
        iceberg.PrimitiveTypes.Bool,
        iceberg.PrimitiveTypes.Int32,

Reply via email to