From f0e4a2c1d66dcbf2fb8f456c635be37972d3525e Mon Sep 17 00:00:00 2001 From: Reda Laanait Date: Sat, 15 Aug 2026 11:33:30 +0100 Subject: [PATCH 1/5] fix: avro schema --- avro/default.go | 8 ++- avro/default_test.go | 22 ++++++- avro/schema.go | 11 ++++ avro/schema_test.go | 138 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 2 deletions(-) diff --git a/avro/default.go b/avro/default.go index a1138b7..c139f66 100644 --- a/avro/default.go +++ b/avro/default.go @@ -13,11 +13,17 @@ var ( ) func defaultOf(v any) (any, error) { + if v == nil { + return nil, nil + } rv := reflect.ValueOf(v) rt := reflect.TypeOf(v) if rt.Kind() == reflect.Ptr { - return nil, nil + if rv.IsNil() { + return nil, nil + } + return defaultOf(rv.Elem().Interface()) } switch rt.Kind() { diff --git a/avro/default_test.go b/avro/default_test.go index bef1445..67b047d 100644 --- a/avro/default_test.go +++ b/avro/default_test.go @@ -14,13 +14,33 @@ func TestDefaultOf(t *testing.T) { } tcs := []tc{ { + // nil pointer → null default val: func() any { type T struct { } - return &T{} + var p *T + return p }(), want: nil, }, + { + // non-nil pointer is dereferenced so Set(&Nested{...}) can seed nested defaults + val: func() any { + type T struct { + A string + } + return &T{A: "a"} + }(), + want: map[string]any{"A": "a"}, + }, + { + val: func() any { + type T struct { + } + return &T{} + }(), + want: map[string]any{}, + }, { val: func() any { type T struct { diff --git a/avro/schema.go b/avro/schema.go index 36a876a..9efa7cf 100644 --- a/avro/schema.go +++ b/avro/schema.go @@ -455,6 +455,17 @@ func schemaOfStruct(t reflect.Type, avroOpts []avro.SchemaOption, cfg *schemaCon // // That said, default values are defined and duplicated at multiple levels. fieldDef, fieldDefFound := fieldDefault(cfg.def, fieldName) + // Nested records under *T / []T are often resolved with cfg.def == nil, so fields + // would otherwise get no Avro default and break backward-compatible evolution. + // Fall back to the Go zero value so additive nested fields (string, []string, …) + // emit defaults ("" / [] / 0 / false / null for pointers). + if !fieldDefFound { + zd, err := defaultOf(reflect.Zero(f.Type).Interface()) + if err != nil { + return nil, err + } + fieldDef, fieldDefFound = zd, true + } // resolve field schema if fieldSchema == nil { diff --git a/avro/schema_test.go b/avro/schema_test.go index 3f2145c..a74fc18 100644 --- a/avro/schema_test.go +++ b/avro/schema_test.go @@ -216,3 +216,141 @@ func TestPackUnpackEventSchemas(t *testing.T) { t.Fatalf("expect be equals %v,%v", want, got) } } + +// TestNestedRecordFieldZeroDefaults ensures nested records under *T and []T get +// Avro zero-value defaults, so additive nested fields stay backward-compatible. +func TestNestedRecordFieldZeroDefaults(t *testing.T) { + a := NewAPI() + namespace := "nest" + event.UID().String() + reg := event.NewRegister(namespace) + defer reg.Clear() + + // Define v1/v2 in separate scopes so both use the same Avro record names + // (Nested / Item), matching real domain evolution that keeps type names. + sch1 := func() *avro.RecordSchema { + type Nested struct { + Name string + } + type Item struct { + Key string + } + type Ev struct { + Payload *Nested + Items []Item + } + reg.Set(Ev{}) + s, err := eventSchema(a, namespace) + if err != nil { + t.Fatal(err) + } + return s + }() + + events, err := UnpackEventSchemas(sch1) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 { + t.Fatalf("expect 1 event schema, got %d", len(events)) + } + assertFieldHasDefault(t, findNamedRecordField(t, events[0], "Payload"), "Name") + assertFieldHasDefault(t, findArrayItemRecord(t, events[0], "Items"), "Key") + + reg.Clear() + sch2 := func() *avro.RecordSchema { + type Nested struct { + Name string + Extra string + } + type Item struct { + Key string + Tags []string + } + type Ev struct { + Payload *Nested + Items []Item + } + event.NewRegister(namespace).Set(Ev{}) + s, err := eventSchema(a, namespace) + if err != nil { + t.Fatal(err) + } + return s + }() + + if err := NewCompatibilityAPI().Compatible(sch2, sch1); err != nil { + t.Fatalf("evolved schema must be backward-compatible with v1: %v", err) + } + + events2, err := UnpackEventSchemas(sch2) + if err != nil { + t.Fatal(err) + } + assertFieldHasDefault(t, findNamedRecordField(t, events2[0], "Payload"), "Extra") + assertFieldHasDefault(t, findArrayItemRecord(t, events2[0], "Items"), "Tags") +} + +func findNamedRecordField(t *testing.T, parent *avro.RecordSchema, fieldName string) *avro.RecordSchema { + t.Helper() + for _, f := range parent.Fields() { + if f.Name() != fieldName { + continue + } + typ := f.Type() + if typ.Type() == avro.Union { + for _, branch := range typ.(*avro.UnionSchema).Types() { + if branch.Type() == avro.Record { + return branch.(*avro.RecordSchema) + } + if branch.Type() == avro.Ref { + return branch.(*avro.RefSchema).Schema().(*avro.RecordSchema) + } + } + } + if typ.Type() == avro.Record { + return typ.(*avro.RecordSchema) + } + if typ.Type() == avro.Ref { + return typ.(*avro.RefSchema).Schema().(*avro.RecordSchema) + } + t.Fatalf("field %s: expected record (possibly in union), got %s", fieldName, typ.Type()) + } + t.Fatalf("field %s not found on %s", fieldName, parent.FullName()) + return nil +} + +func findArrayItemRecord(t *testing.T, parent *avro.RecordSchema, fieldName string) *avro.RecordSchema { + t.Helper() + for _, f := range parent.Fields() { + if f.Name() != fieldName { + continue + } + arr, ok := f.Type().(*avro.ArraySchema) + if !ok { + t.Fatalf("field %s: expected array, got %s", fieldName, f.Type().Type()) + } + items := arr.Items() + if items.Type() == avro.Ref { + return items.(*avro.RefSchema).Schema().(*avro.RecordSchema) + } + if items.Type() == avro.Record { + return items.(*avro.RecordSchema) + } + t.Fatalf("field %s: expected array of records, got items %s", fieldName, items.Type()) + } + t.Fatalf("field %s not found on %s", fieldName, parent.FullName()) + return nil +} + +func assertFieldHasDefault(t *testing.T, rec *avro.RecordSchema, fieldName string) { + t.Helper() + for _, f := range rec.Fields() { + if f.Name() == fieldName { + if !f.HasDefault() { + t.Fatalf("%s.%s: expected Avro default, got none", rec.FullName(), fieldName) + } + return + } + } + t.Fatalf("field %s not found on %s", fieldName, rec.FullName()) +} From ec6b6ecc89980ece0bf6f66d329b9f1c779a0b9d Mon Sep 17 00:00:00 2001 From: Reda Laanait Date: Sat, 15 Aug 2026 11:36:56 +0100 Subject: [PATCH 2/5] fix: ci --- .github/workflows/lint.yaml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 87f2f0f..6d588de 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -12,17 +12,18 @@ jobs: matrix: directory: [".", "tool"] steps: + - name: Check out code + uses: actions/checkout@v6 + - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: - go-version: '^1.22.x' - - - name: Check out code - uses: actions/checkout@v4 + go-version-file: ${{ matrix.directory }}/go.mod + cache-dependency-path: ${{ matrix.directory }}/go.sum - name: Run Linter - uses: golangci/golangci-lint-action@v6.1.1 + uses: golangci/golangci-lint-action@v9 with: - # version: "1.21" - args: --enable misspell - working-directory: ${{ matrix.directory }} \ No newline at end of file + version: v2.12 + args: --enable=misspell + working-directory: ${{ matrix.directory }} From 416ab3d4faad052b0602394148f487b8b9fb8e8d Mon Sep 17 00:00:00 2001 From: Reda Laanait Date: Sat, 15 Aug 2026 11:43:23 +0100 Subject: [PATCH 3/5] fix: ci --- avro/default.go | 2 +- avro/schema.go | 3 +-- event/register.go | 2 +- event/type.go | 2 +- go.mod | 1 - go.sum | 2 -- 6 files changed, 4 insertions(+), 8 deletions(-) diff --git a/avro/default.go b/avro/default.go index c139f66..e9d0571 100644 --- a/avro/default.go +++ b/avro/default.go @@ -19,7 +19,7 @@ func defaultOf(v any) (any, error) { rv := reflect.ValueOf(v) rt := reflect.TypeOf(v) - if rt.Kind() == reflect.Ptr { + if rt.Kind() == reflect.Pointer { if rv.IsNil() { return nil, nil } diff --git a/avro/schema.go b/avro/schema.go index 9efa7cf..61a4b39 100644 --- a/avro/schema.go +++ b/avro/schema.go @@ -2,6 +2,7 @@ package avro import ( "bytes" + "crypto/sha3" "encoding/hex" "encoding/json" "errors" @@ -12,8 +13,6 @@ import ( "strings" "time" - "golang.org/x/crypto/sha3" - sensitive "github.com/ln80/struct-sensitive" "github.com/ln80/avro/v2" diff --git a/event/register.go b/event/register.go index d49909d..918f455 100644 --- a/event/register.go +++ b/event/register.go @@ -148,7 +148,7 @@ func (r *register) Set(evt any, opts ...func(registryEntryProps)) Register { name := TypeOfWithNamespace(r.namespace, evt) rType, _ := resolveType(evt) - if reflect.TypeOf(evt).Kind() == reflect.Ptr { + if reflect.TypeOf(evt).Kind() == reflect.Pointer { evt = reflect.ValueOf(evt).Elem().Interface() } diff --git a/event/type.go b/event/type.go index d1edd14..1744af7 100644 --- a/event/type.go +++ b/event/type.go @@ -7,7 +7,7 @@ import ( ) func normalizeType(t reflect.Type) (reflect.Type, string) { - if t.Kind() == reflect.Ptr { + if t.Kind() == reflect.Pointer { t = t.Elem() } return t, t.String() diff --git a/go.mod b/go.mod index 5141bcd..37fa56f 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,6 @@ require ( github.com/ln80/struct-sensitive v0.7.0 github.com/modern-go/reflect2 v1.0.2 // indirect github.com/rs/zerolog v1.35.1 - golang.org/x/crypto v0.52.0 ) // replace github.com/ln80/struct-sensitive v0.6.0 => ../struct-sensitive diff --git a/go.sum b/go.sum index 180c3ca..87028ee 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,6 @@ github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRci github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= From 8f2107ebe2cc36b04e057558615957114da590fe Mon Sep 17 00:00:00 2001 From: Reda Laanait Date: Sat, 15 Aug 2026 11:52:31 +0100 Subject: [PATCH 4/5] fix: ci --- .github/workflows/module.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/module.yaml b/.github/workflows/module.yaml index 013261e..a2c14ee 100644 --- a/.github/workflows/module.yaml +++ b/.github/workflows/module.yaml @@ -13,7 +13,7 @@ jobs: test: strategy: matrix: - go-version: [ "1.22", "1.23" ] + go-version: [ "1.23", "1.25" ] runs-on: ubuntu-latest services: From 158e94c43a4adc5a866b8023326f857c88acec86 Mon Sep 17 00:00:00 2001 From: Reda Laanait Date: Sat, 15 Aug 2026 11:57:55 +0100 Subject: [PATCH 5/5] fix: ci --- .github/workflows/module.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/module.yaml b/.github/workflows/module.yaml index a2c14ee..a2a6120 100644 --- a/.github/workflows/module.yaml +++ b/.github/workflows/module.yaml @@ -13,7 +13,7 @@ jobs: test: strategy: matrix: - go-version: [ "1.23", "1.25" ] + go-version: [ "1.25", "1.26" ] runs-on: ubuntu-latest services: