Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
version: v2.12
args: --enable=misspell
working-directory: ${{ matrix.directory }}
2 changes: 1 addition & 1 deletion .github/workflows/module.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
test:
strategy:
matrix:
go-version: [ "1.22", "1.23" ]
go-version: [ "1.25", "1.26" ]
runs-on: ubuntu-latest

services:
Expand Down
10 changes: 8 additions & 2 deletions avro/default.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 rt.Kind() == reflect.Pointer {
if rv.IsNil() {
return nil, nil
}
return defaultOf(rv.Elem().Interface())
}

switch rt.Kind() {
Expand Down
22 changes: 21 additions & 1 deletion avro/default_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 12 additions & 2 deletions avro/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package avro

import (
"bytes"
"crypto/sha3"
"encoding/hex"
"encoding/json"
"errors"
Expand All @@ -12,8 +13,6 @@ import (
"strings"
"time"

"golang.org/x/crypto/sha3"

sensitive "github.com/ln80/struct-sensitive"

"github.com/ln80/avro/v2"
Expand Down Expand Up @@ -455,6 +454,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 {
Expand Down
138 changes: 138 additions & 0 deletions avro/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
2 changes: 1 addition & 1 deletion event/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
2 changes: 1 addition & 1 deletion event/type.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Loading