From 167dc75c1968c224ae0b41a5124570f6ea335f67 Mon Sep 17 00:00:00 2001 From: Jordan Liggitt Date: Tue, 25 Mar 2025 15:19:59 -0400 Subject: [PATCH 1/3] Add omitzero support Signed-off-by: Jordan Liggitt --- README.md | 5 +- cache.go | 15 ++-- doc.go | 7 +- encode.go | 165 ++++++++++++++++++++++++++++++++++-------- encode_map.go | 4 +- omitzero_go124.go | 8 ++ omitzero_pre_go124.go | 8 ++ structfields.go | 11 ++- 8 files changed, 180 insertions(+), 43 deletions(-) create mode 100644 omitzero_go124.go create mode 100644 omitzero_pre_go124.go diff --git a/README.md b/README.md index ce016f36..37e66151 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Codec passed multiple confidential security assessments in 2022. No vulnerabili __🗜️  Data Size__ -Struct tags (`toarray`, `keyasint`, `omitempty`) automatically reduce size of encoded structs. Encoding optionally shrinks float64→32→16 when values fit. +Struct tags (`toarray`, `keyasint`, `omitempty`, `omitzero`) automatically reduce size of encoded structs. Encoding optionally shrinks float64→32→16 when values fit. __:jigsaw:  Usability__ @@ -147,6 +147,7 @@ We can write less code by using struct tags: - `toarray`: encode without field names (decode back to original struct) - `keyasint`: encode field names as integers (decode back to original struct) - `omitempty`: omit empty fields when encoding +- `omitzero`: omit zero-value fields when encoding ![alt text](https://github.com/fxamacker/images/raw/master/cbor/v2.3.0/cbor_struct_tags_api.svg?sanitize=1 "CBOR API and Go Struct Tags") @@ -350,7 +351,7 @@ err = em.MarshalToBuffer(v, &buf) // encode v to provided buf ### Struct Tags -Struct tags (`toarray`, `keyasint`, `omitempty`) reduce encoded size of structs. +Struct tags (`toarray`, `keyasint`, `omitempty`, `omitzero`) reduce encoded size of structs.
🔎  Example encoding 3-level nested Go struct to 1 byte CBOR

diff --git a/cache.go b/cache.go index c18635a4..8562fd89 100644 --- a/cache.go +++ b/cache.go @@ -17,6 +17,7 @@ import ( type encodeFuncs struct { ef encodeFunc ief isEmptyFunc + izf isZeroFunc } var ( @@ -237,7 +238,7 @@ func getEncodingStructType(t reflect.Type) (*encodingStructType, error) { e := getEncodeBuffer() for i := 0; i < len(flds); i++ { // Get field's encodeFunc - flds[i].ef, flds[i].ief = getEncodeFunc(flds[i].typ) + flds[i].ef, flds[i].ief, flds[i].izf = getEncodeFunc(flds[i].typ) if flds[i].ef == nil { err = &UnsupportedTypeError{t} break @@ -321,7 +322,7 @@ func getEncodingStructType(t reflect.Type) (*encodingStructType, error) { func getEncodingStructToArrayType(t reflect.Type, flds fields) (*encodingStructType, error) { for i := 0; i < len(flds); i++ { // Get field's encodeFunc - flds[i].ef, flds[i].ief = getEncodeFunc(flds[i].typ) + flds[i].ef, flds[i].ief, flds[i].izf = getEncodeFunc(flds[i].typ) if flds[i].ef == nil { structType := &encodingStructType{err: &UnsupportedTypeError{t}} encodingStructTypeCache.Store(t, structType) @@ -337,14 +338,14 @@ func getEncodingStructToArrayType(t reflect.Type, flds fields) (*encodingStructT return structType, structType.err } -func getEncodeFunc(t reflect.Type) (encodeFunc, isEmptyFunc) { +func getEncodeFunc(t reflect.Type) (encodeFunc, isEmptyFunc, isZeroFunc) { if v, _ := encodeFuncCache.Load(t); v != nil { fs := v.(encodeFuncs) - return fs.ef, fs.ief + return fs.ef, fs.ief, fs.izf } - ef, ief := getEncodeFuncInternal(t) - encodeFuncCache.Store(t, encodeFuncs{ef, ief}) - return ef, ief + ef, ief, izf := getEncodeFuncInternal(t) + encodeFuncCache.Store(t, encodeFuncs{ef, ief, izf}) + return ef, ief, izf } func getTypeInfo(t reflect.Type) *typeInfo { diff --git a/doc.go b/doc.go index 23f68b98..201fbdd8 100644 --- a/doc.go +++ b/doc.go @@ -111,9 +111,14 @@ Decoding Options: https://github.com/fxamacker/cbor#decoding-options Struct tags like `cbor:"name,omitempty"` and `json:"name,omitempty"` work as expected. If both struct tags are specified then `cbor` is used. -Struct tags like "keyasint", "toarray", and "omitempty" make it easy to use +Struct tags like "keyasint", "toarray", "omitempty", and "omitzero" make it easy to use very compact formats like COSE and CWT (CBOR Web Tokens) with structs. +The "omitzero" option omits zero values from encoding, matching +[stdlib encoding/json behavior](https://pkg.go.dev/encoding/json#Marshal). +When specified in the `cbor` tag, the option is always honored. +When specified in the `json` tag, the option is honored when building with Go 1.24+. + For example, "toarray" makes struct fields encode to array elements. And "keyasint" makes struct fields encode to elements of CBOR map with int keys. diff --git a/encode.go b/encode.go index 9fb7980b..a3f99922 100644 --- a/encode.go +++ b/encode.go @@ -58,8 +58,10 @@ import ( // // Marshal supports format string stored under the "cbor" key in the struct // field's tag. CBOR format string can specify the name of the field, -// "omitempty" and "keyasint" options, and special case "-" for field omission. -// If "cbor" key is absent, Marshal uses "json" key. +// "omitempty", "omitzero" and "keyasint" options, and special case "-" for +// field omission. If "cbor" key is absent, Marshal uses "json" key. +// When using the "json" key, the "omitzero" option is honored when building +// with Go 1.24+ to match stdlib encoding/json behavior. // // Struct field name is treated as integer if it has "keyasint" option in // its format string. The format string must specify an integer as its @@ -67,8 +69,8 @@ import ( // // Special struct field "_" is used to specify struct level options, such as // "toarray". "toarray" option enables Go struct to be encoded as CBOR array. -// "omitempty" is disabled by "toarray" to ensure that the same number -// of elements are encoded every time. +// "omitempty" and "omitzero" are disabled by "toarray" to ensure that the +// same number of elements are encoded every time. // // Anonymous struct fields are marshaled as if their exported fields // were fields in the outer struct. Marshal follows the same struct fields @@ -975,6 +977,7 @@ func putEncodeBuffer(e *bytes.Buffer) { type encodeFunc func(e *bytes.Buffer, em *encMode, v reflect.Value) error type isEmptyFunc func(em *encMode, v reflect.Value) (empty bool, err error) +type isZeroFunc func(v reflect.Value) (zero bool, err error) func encode(e *bytes.Buffer, em *encMode, v reflect.Value) error { if !v.IsValid() { @@ -983,7 +986,7 @@ func encode(e *bytes.Buffer, em *encMode, v reflect.Value) error { return nil } vt := v.Type() - f, _ := getEncodeFunc(vt) + f, _, _ := getEncodeFunc(vt) if f == nil { return &UnsupportedTypeError{vt} } @@ -1483,6 +1486,15 @@ func encodeStruct(e *bytes.Buffer, em *encMode, v reflect.Value) (err error) { continue } } + if f.omitZero { + zero, err := f.izf(fv) + if err != nil { + return err + } + if zero { + continue + } + } if !f.keyAsInt && em.fieldName == FieldNameToByteString { e.Write(f.cborNameByteString) @@ -1775,32 +1787,32 @@ var ( typeByteString = reflect.TypeOf(ByteString("")) ) -func getEncodeFuncInternal(t reflect.Type) (ef encodeFunc, ief isEmptyFunc) { +func getEncodeFuncInternal(t reflect.Type) (ef encodeFunc, ief isEmptyFunc, izf isZeroFunc) { k := t.Kind() if k == reflect.Pointer { - return getEncodeIndirectValueFunc(t), isEmptyPtr + return getEncodeIndirectValueFunc(t), isEmptyPtr, getIsZeroFunc(t) } switch t { case typeSimpleValue: - return encodeMarshalerType, isEmptyUint + return encodeMarshalerType, isEmptyUint, getIsZeroFunc(t) case typeTag: - return encodeTag, alwaysNotEmpty + return encodeTag, alwaysNotEmpty, getIsZeroFunc(t) case typeTime: - return encodeTime, alwaysNotEmpty + return encodeTime, alwaysNotEmpty, getIsZeroFunc(t) case typeBigInt: - return encodeBigInt, alwaysNotEmpty + return encodeBigInt, alwaysNotEmpty, getIsZeroFunc(t) case typeRawMessage: - return encodeMarshalerType, isEmptySlice + return encodeMarshalerType, isEmptySlice, getIsZeroFunc(t) case typeByteString: - return encodeMarshalerType, isEmptyString + return encodeMarshalerType, isEmptyString, getIsZeroFunc(t) } if reflect.PointerTo(t).Implements(typeMarshaler) { - return encodeMarshalerType, alwaysNotEmpty + return encodeMarshalerType, alwaysNotEmpty, getIsZeroFunc(t) } if reflect.PointerTo(t).Implements(typeBinaryMarshaler) { defer func() { @@ -1815,39 +1827,39 @@ func getEncodeFuncInternal(t reflect.Type) (ef encodeFunc, ief isEmptyFunc) { } switch k { case reflect.Bool: - return encodeBool, isEmptyBool + return encodeBool, isEmptyBool, getIsZeroFunc(t) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return encodeInt, isEmptyInt + return encodeInt, isEmptyInt, getIsZeroFunc(t) case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return encodeUint, isEmptyUint + return encodeUint, isEmptyUint, getIsZeroFunc(t) case reflect.Float32, reflect.Float64: - return encodeFloat, isEmptyFloat + return encodeFloat, isEmptyFloat, getIsZeroFunc(t) case reflect.String: - return encodeString, isEmptyString + return encodeString, isEmptyString, getIsZeroFunc(t) case reflect.Slice: if t.Elem().Kind() == reflect.Uint8 { - return encodeByteString, isEmptySlice + return encodeByteString, isEmptySlice, getIsZeroFunc(t) } fallthrough case reflect.Array: - f, _ := getEncodeFunc(t.Elem()) + f, _, _ := getEncodeFunc(t.Elem()) if f == nil { - return nil, nil + return nil, nil, nil } - return arrayEncodeFunc{f: f}.encode, isEmptySlice + return arrayEncodeFunc{f: f}.encode, isEmptySlice, getIsZeroFunc(t) case reflect.Map: f := getEncodeMapFunc(t) if f == nil { - return nil, nil + return nil, nil, nil } - return f, isEmptyMap + return f, isEmptyMap, getIsZeroFunc(t) case reflect.Struct: // Get struct's special field "_" tag options @@ -1855,23 +1867,23 @@ func getEncodeFuncInternal(t reflect.Type) (ef encodeFunc, ief isEmptyFunc) { tag := f.Tag.Get("cbor") if tag != "-" { if hasToArrayOption(tag) { - return encodeStructToArray, isEmptyStruct + return encodeStructToArray, isEmptyStruct, isZeroFieldStruct } } } - return encodeStruct, isEmptyStruct + return encodeStruct, isEmptyStruct, getIsZeroFunc(t) case reflect.Interface: - return encodeIntf, isEmptyIntf + return encodeIntf, isEmptyIntf, getIsZeroFunc(t) } - return nil, nil + return nil, nil, nil } func getEncodeIndirectValueFunc(t reflect.Type) encodeFunc { for t.Kind() == reflect.Pointer { t = t.Elem() } - f, _ := getEncodeFunc(t) + f, _, _ := getEncodeFunc(t) if f == nil { return nil } @@ -1987,3 +1999,96 @@ func float32NaNFromReflectValue(v reflect.Value) float32 { f32 := p.Convert(reflect.TypeOf((*float32)(nil))).Elem().Interface().(float32) return f32 } + +type isZeroer interface { + IsZero() bool +} + +var isZeroerType = reflect.TypeOf((*isZeroer)(nil)).Elem() + +// getIsZeroFunc returns a function for the given type that can be called to determine if a given value is zero. +// Types that implement `IsZero() bool` are delegated to for non-nil values. +// Types that do not implement `IsZero() bool` use the reflect.Value#IsZero() implementation. +// The returned function matches behavior of stdlib encoding/json behavior in Go 1.24+. +func getIsZeroFunc(t reflect.Type) isZeroFunc { + // Provide a function that uses a type's IsZero method if defined. + switch { + case t == nil: + return isZeroDefault + case t.Kind() == reflect.Interface && t.Implements(isZeroerType): + return isZeroInterfaceCustom + case t.Kind() == reflect.Pointer && t.Implements(isZeroerType): + return isZeroPointerCustom + case t.Implements(isZeroerType): + return isZeroCustom + case reflect.PointerTo(t).Implements(isZeroerType): + return isZeroAddrCustom + default: + return isZeroDefault + } +} + +// isZeroInterfaceCustom returns true for nil or pointer-to-nil values, +// and delegates to the custom IsZero() implementation otherwise. +func isZeroInterfaceCustom(v reflect.Value) (bool, error) { + kind := v.Kind() + + switch kind { + case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer, reflect.Interface, reflect.Slice: + if v.IsNil() { + return true, nil + } + } + + switch kind { + case reflect.Interface, reflect.Pointer: + if elem := v.Elem(); elem.Kind() == reflect.Pointer && elem.IsNil() { + return true, nil + } + } + + return v.Interface().(isZeroer).IsZero(), nil +} + +// isZeroPointerCustom returns true for nil values, +// and delegates to the custom IsZero() implementation otherwise. +func isZeroPointerCustom(v reflect.Value) (bool, error) { + if v.IsNil() { + return true, nil + } + return v.Interface().(isZeroer).IsZero(), nil +} + +// isZeroCustom delegates to the custom IsZero() implementation. +func isZeroCustom(v reflect.Value) (bool, error) { + return v.Interface().(isZeroer).IsZero(), nil +} + +// isZeroAddrCustom delegates to the custom IsZero() implementation of the addr of the value. +func isZeroAddrCustom(v reflect.Value) (bool, error) { + if !v.CanAddr() { + // Temporarily box v so we can take the address. + v2 := reflect.New(v.Type()).Elem() + v2.Set(v) + v = v2 + } + return v.Addr().Interface().(isZeroer).IsZero(), nil +} + +// isZeroDefault calls reflect.Value#IsZero() +func isZeroDefault(v reflect.Value) (bool, error) { + if !v.IsValid() { + // v is zero value + return true, nil + } + return v.IsZero(), nil +} + +// isZeroFieldStruct is used to determine whether to omit toarray structs +func isZeroFieldStruct(v reflect.Value) (bool, error) { + structType, err := getEncodingStructType(v.Type()) + if err != nil { + return false, err + } + return len(structType.fields) == 0, nil +} diff --git a/encode_map.go b/encode_map.go index 3b60da79..2871bfda 100644 --- a/encode_map.go +++ b/encode_map.go @@ -65,8 +65,8 @@ func (me *mapKeyValueEncodeFunc) encodeKeyValues(e *bytes.Buffer, em *encMode, v } func getEncodeMapFunc(t reflect.Type) encodeFunc { - kf, _ := getEncodeFunc(t.Key()) - ef, _ := getEncodeFunc(t.Elem()) + kf, _, _ := getEncodeFunc(t.Key()) + ef, _, _ := getEncodeFunc(t.Elem()) if kf == nil || ef == nil { return nil } diff --git a/omitzero_go124.go b/omitzero_go124.go new file mode 100644 index 00000000..c893a411 --- /dev/null +++ b/omitzero_go124.go @@ -0,0 +1,8 @@ +// Copyright (c) Faye Amacker. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +//go:build go1.24 + +package cbor + +var jsonStdlibSupportsOmitzero = true diff --git a/omitzero_pre_go124.go b/omitzero_pre_go124.go new file mode 100644 index 00000000..db86a632 --- /dev/null +++ b/omitzero_pre_go124.go @@ -0,0 +1,8 @@ +// Copyright (c) Faye Amacker. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +//go:build !go1.24 + +package cbor + +var jsonStdlibSupportsOmitzero = false diff --git a/structfields.go b/structfields.go index 429c6e2d..593508d9 100644 --- a/structfields.go +++ b/structfields.go @@ -18,9 +18,11 @@ type field struct { typ reflect.Type ef encodeFunc ief isEmptyFunc + izf isZeroFunc typInfo *typeInfo // used to decoder to reuse type info tagged bool // used to choose dominant field (at the same level tagged fields dominate untagged fields) omitEmpty bool // used to skip empty field + omitZero bool // used to skip zero field keyAsInt bool // used to encode/decode field name as int } @@ -165,9 +167,11 @@ func appendFields( continue } + cborTag := true tag := f.Tag.Get("cbor") if tag == "" { tag = f.Tag.Get("json") + cborTag = false } if tag == "-" { continue @@ -177,7 +181,7 @@ func appendFields( // Parse field tag options var tagFieldName string - var omitempty, keyasint bool + var omitempty, omitzero, keyasint bool for j := 0; tag != ""; j++ { var token string idx := strings.IndexByte(tag, ',') @@ -192,6 +196,10 @@ func appendFields( switch token { case "omitempty": omitempty = true + case "omitzero": + if cborTag || jsonStdlibSupportsOmitzero { + omitzero = true + } case "keyasint": keyasint = true } @@ -213,6 +221,7 @@ func appendFields( idx: fIdx, typ: f.Type, omitEmpty: omitempty, + omitZero: omitzero, keyAsInt: keyasint, tagged: tagged}) } else { From 101dea75b05aacdb65fc9e1cb857a055b2c5953e Mon Sep 17 00:00:00 2001 From: Jordan Liggitt Date: Tue, 25 Mar 2025 16:04:47 -0400 Subject: [PATCH 2/3] Copy OmitEmpty tests to OmitZero tests Signed-off-by: Jordan Liggitt --- encode_test.go | 387 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 387 insertions(+) diff --git a/encode_test.go b/encode_test.go index 79e9a8d4..c3a5bfbd 100644 --- a/encode_test.go +++ b/encode_test.go @@ -1597,6 +1597,393 @@ func TestOmitEmptyForBigInt(t *testing.T) { testRoundTrip(t, []roundTripTest{{"default values", v, want}}, em, dm) } +func TestOmitZeroForBuiltinType(t *testing.T) { + type T struct { + B bool `cbor:"b"` + Bo bool `cbor:"bo,omitzero"` + UI uint `cbor:"ui"` + UIo uint `cbor:"uio,omitzero"` + I int `cbor:"i"` + Io int `cbor:"io,omitzero"` + F float64 `cbor:"f"` + Fo float64 `cbor:"fo,omitzero"` + S string `cbor:"s"` + So string `cbor:"so,omitzero"` + Slc []string `cbor:"slc"` + Slco []string `cbor:"slco,omitzero"` + M map[int]string `cbor:"m"` + Mo map[int]string `cbor:"mo,omitzero"` + P *int `cbor:"p"` + Po *int `cbor:"po,omitzero"` + Intf any `cbor:"intf"` + Intfo any `cbor:"intfo,omitzero"` + } + + v := T{} + // {"b": false, "ui": 0, "i":0, "f": 0, "s": "", "slc": null, "m": {}, "p": nil, "intf": nil } + want := []byte{0xa9, + 0x61, 0x62, 0xf4, + 0x62, 0x75, 0x69, 0x00, + 0x61, 0x69, 0x00, + 0x61, 0x66, 0xfb, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x61, 0x73, 0x60, + 0x63, 0x73, 0x6c, 0x63, 0xf6, + 0x61, 0x6d, 0xf6, + 0x61, 0x70, 0xf6, + 0x64, 0x69, 0x6e, 0x74, 0x66, 0xf6, + } + + em, _ := EncOptions{}.EncMode() + dm, _ := DecOptions{}.DecMode() + testRoundTrip(t, []roundTripTest{{"default values", v, want}}, em, dm) +} + +func TestOmitZeroForAnonymousStruct(t *testing.T) { + type T struct { + Str struct{} `cbor:"str"` + Stro struct{} `cbor:"stro,omitzero"` + } + + v := T{} + want := []byte{0xa1, 0x63, 0x73, 0x74, 0x72, 0xa0} // {"str": {}} + + em, _ := EncOptions{}.EncMode() + dm, _ := DecOptions{}.DecMode() + testRoundTrip(t, []roundTripTest{{"default values", v, want}}, em, dm) +} + +func TestOmitZeroForStruct1(t *testing.T) { + type T1 struct { + Bo bool `cbor:"bo,omitzero"` + UIo uint `cbor:"uio,omitzero"` + Io int `cbor:"io,omitzero"` + Fo float64 `cbor:"fo,omitzero"` + So string `cbor:"so,omitzero"` + Slco []string `cbor:"slco,omitzero"` + Mo map[int]string `cbor:"mo,omitzero"` + Po *int `cbor:"po,omitzero"` + Intfo any `cbor:"intfo,omitzero"` + } + type T struct { + Str T1 `cbor:"str"` + Stro T1 `cbor:"stro,omitzero"` + } + + v := T{} + want := []byte{0xa1, 0x63, 0x73, 0x74, 0x72, 0xa0} // {"str": {}} + + em, _ := EncOptions{}.EncMode() + dm, _ := DecOptions{}.DecMode() + testRoundTrip(t, []roundTripTest{{"default values", v, want}}, em, dm) +} + +func TestOmitZeroForStruct2(t *testing.T) { + type T1 struct { + Bo bool `cbor:"bo,omitzero"` + UIo uint `cbor:"uio,omitzero"` + Io int `cbor:"io,omitzero"` + Fo float64 `cbor:"fo,omitzero"` + So string `cbor:"so,omitzero"` + Slco []string `cbor:"slco,omitzero"` + Mo map[int]string `cbor:"mo,omitzero"` + Po *int `cbor:"po,omitzero"` + Intfo any `cbor:"intfo"` + } + type T struct { + Stro T1 `cbor:"stro,omitzero"` + } + + v := T{} + want := []byte{0xa1, 0x64, 0x73, 0x74, 0x72, 0x6f, 0xa1, 0x65, 0x69, 0x6e, 0x74, 0x66, 0x6f, 0xf6} // {"stro": {intfo: nil}} + + em, _ := EncOptions{}.EncMode() + dm, _ := DecOptions{}.DecMode() + testRoundTrip(t, []roundTripTest{{"non-default values", v, want}}, em, dm) +} + +func TestOmitZeroForNestedStruct(t *testing.T) { + type T1 struct { + Bo bool `cbor:"bo,omitzero"` + UIo uint `cbor:"uio,omitzero"` + Io int `cbor:"io,omitzero"` + Fo float64 `cbor:"fo,omitzero"` + So string `cbor:"so,omitzero"` + Slco []string `cbor:"slco,omitzero"` + Mo map[int]string `cbor:"mo,omitzero"` + Po *int `cbor:"po,omitzero"` + Intfo any `cbor:"intfo,omitzero"` + } + type T2 struct { + Stro T1 `cbor:"stro,omitzero"` + } + type T struct { + Str T2 `cbor:"str"` + Stro T2 `cbor:"stro,omitzero"` + } + + v := T{} + want := []byte{0xa1, 0x63, 0x73, 0x74, 0x72, 0xa0} // {"str": {}} + + em, _ := EncOptions{}.EncMode() + dm, _ := DecOptions{}.DecMode() + testRoundTrip(t, []roundTripTest{{"default values", v, want}}, em, dm) +} + +func TestOmitZeroForToArrayStruct1(t *testing.T) { + type T1 struct { + _ struct{} `cbor:",toarray"` + b bool + ui uint + i int + f float64 + s string + slc []string + m map[int]string + p *int + intf any + } + type T struct { + Str T1 `cbor:"str"` + Stro T1 `cbor:"stro,omitzero"` + } + + v := T{ + Str: T1{b: false, ui: 0, i: 0, f: 0.0, s: "", slc: nil, m: nil, p: nil, intf: nil}, + Stro: T1{b: false, ui: 0, i: 0, f: 0.0, s: "", slc: nil, m: nil, p: nil, intf: nil}, + } + want := []byte{0xa1, 0x63, 0x73, 0x74, 0x72, 0x80} // {"str": []} + + em, _ := EncOptions{}.EncMode() + dm, _ := DecOptions{}.DecMode() + testRoundTrip(t, []roundTripTest{{"no exportable fields", v, want}}, em, dm) +} + +func TestOmitZeroForToArrayStruct2(t *testing.T) { + type T1 struct { + _ struct{} `cbor:",toarray"` + Bo bool `cbor:"bo"` + UIo uint `cbor:"uio"` + Io int `cbor:"io"` + Fo float64 `cbor:"fo"` + So string `cbor:"so"` + Slco []string `cbor:"slco"` + Mo map[int]string `cbor:"mo"` + Po *int `cbor:"po"` + Intfo any `cbor:"intfo"` + } + type T struct { + Stro T1 `cbor:"stro,omitzero"` + } + + v := T{} + // {"stro": [false, 0, 0, 0.0, "", [], {}, nil, nil]} + want := []byte{0xa1, 0x64, 0x73, 0x74, 0x72, 0x6f, 0x89, 0xf4, 0x00, 0x00, 0xfb, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0xf6, 0xf6, 0xf6, 0xf6} + + em, _ := EncOptions{}.EncMode() + dm, _ := DecOptions{}.DecMode() + testRoundTrip(t, []roundTripTest{{"has exportable fields", v, want}}, em, dm) +} + +func TestOmitZeroForStructWithPtrToAnonymousField(t *testing.T) { + type ( + T1 struct { + X int `cbor:"x,omitzero"` + Y int `cbor:"y,omitzero"` + } + T2 struct { + *T1 + } + T struct { + Stro T2 `cbor:"stro,omitzero"` + } + ) + + testCases := []struct { + name string + obj any + wantCborData []byte + }{ + { + name: "null pointer to anonymous field", + obj: T{}, + wantCborData: []byte{0xa0}, // {} + }, + { + name: "not-null pointer to anonymous field", + obj: T{T2{&T1{}}}, + wantCborData: []byte{0xa0}, // {} + }, + { + name: "not empty value in field 1", + obj: T{T2{&T1{X: 1}}}, + wantCborData: []byte{0xa1, 0x64, 0x73, 0x74, 0x72, 0x6f, 0xa1, 0x61, 0x78, 0x01}, // {stro:{x:1}} + }, + { + name: "not empty value in field 2", + obj: T{T2{&T1{Y: 2}}}, + wantCborData: []byte{0xa1, 0x64, 0x73, 0x74, 0x72, 0x6f, 0xa1, 0x61, 0x79, 0x02}, // {stro:{y:2}} + }, + { + name: "not empty value in all fields", + obj: T{T2{&T1{X: 1, Y: 2}}}, + wantCborData: []byte{0xa1, 0x64, 0x73, 0x74, 0x72, 0x6f, 0xa2, 0x61, 0x78, 0x01, 0x61, 0x79, 0x02}, // {stro:{x:1, y:2}} + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + b, err := Marshal(tc.obj) + if err != nil { + t.Errorf("Marshal(%+v) returned error %v", tc.obj, err) + } + if !bytes.Equal(b, tc.wantCborData) { + t.Errorf("Marshal(%+v) = 0x%x, want 0x%x", tc.obj, b, tc.wantCborData) + } + }) + } +} + +func TestOmitZeroForStructWithAnonymousField(t *testing.T) { + type ( + T1 struct { + X int `cbor:"x,omitzero"` + Y int `cbor:"y,omitzero"` + } + T2 struct { + T1 + } + T struct { + Stro T2 `cbor:"stro,omitzero"` + } + ) + + testCases := []struct { + name string + obj any + wantCborData []byte + }{ + { + name: "default values", + obj: T{}, + wantCborData: []byte{0xa0}, // {} + }, + { + name: "default values", + obj: T{T2{T1{}}}, + wantCborData: []byte{0xa0}, // {} + }, + { + name: "not empty value in field 1", + obj: T{T2{T1{X: 1}}}, + wantCborData: []byte{0xa1, 0x64, 0x73, 0x74, 0x72, 0x6f, 0xa1, 0x61, 0x78, 0x01}, // {stro:{x:1}} + }, + { + name: "not empty value in field 2", + obj: T{T2{T1{Y: 2}}}, + wantCborData: []byte{0xa1, 0x64, 0x73, 0x74, 0x72, 0x6f, 0xa1, 0x61, 0x79, 0x02}, // {stro:{y:2}} + }, + { + name: "not empty value in all fields", + obj: T{T2{T1{X: 1, Y: 2}}}, + wantCborData: []byte{0xa1, 0x64, 0x73, 0x74, 0x72, 0x6f, 0xa2, 0x61, 0x78, 0x01, 0x61, 0x79, 0x02}, // {stro:{x:1, y:2}} + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + b, err := Marshal(tc.obj) + if err != nil { + t.Errorf("Marshal(%+v) returned error %v", tc.obj, err) + } + if !bytes.Equal(b, tc.wantCborData) { + t.Errorf("Marshal(%+v) = 0x%x, want 0x%x", tc.obj, b, tc.wantCborData) + } + }) + } +} + +func TestOmitZeroForBinaryMarshaler1(t *testing.T) { + type T1 struct { + No number `cbor:"no,omitzero"` + } + type T struct { + Str T1 `cbor:"str"` + Stro T1 `cbor:"stro,omitzero"` + } + + testCases := []roundTripTest{ + { + "empty BinaryMarshaler", + T1{}, + []byte{0xa0}, // {} + }, + { + "empty struct containing empty BinaryMarshaler", + T{}, + []byte{0xa1, 0x63, 0x73, 0x74, 0x72, 0xa0}, // {str: {}} + }, + } + + em, _ := EncOptions{}.EncMode() + dm, _ := DecOptions{}.DecMode() + testRoundTrip(t, testCases, em, dm) +} + +func TestOmitZeroForBinaryMarshaler2(t *testing.T) { + type T1 struct { + So stru `cbor:"so,omitzero"` + } + type T struct { + Str T1 `cbor:"str"` + Stro T1 `cbor:"stro,omitzero"` + } + + testCases := []roundTripTest{ + { + "empty BinaryMarshaler", + T1{}, + []byte{0xa0}, // {} + }, + { + "empty struct containing empty BinaryMarshaler", + T{}, + []byte{0xa1, 0x63, 0x73, 0x74, 0x72, 0xa0}, // {str: {}} + }, + } + + em, _ := EncOptions{}.EncMode() + dm, _ := DecOptions{}.DecMode() + testRoundTrip(t, testCases, em, dm) +} + +// omitzero is a no-op for time.Time. +func TestOmitZeroForTime(t *testing.T) { + type T struct { + Tm time.Time `cbor:"t,omitzero"` + } + + v := T{} + want := []byte{0xa1, 0x61, 0x74, 0xf6} // {"t": nil} + + em, _ := EncOptions{}.EncMode() + dm, _ := DecOptions{}.DecMode() + testRoundTrip(t, []roundTripTest{{"default values", v, want}}, em, dm) +} + +// omitzero is a no-op for big.Int. +func TestOmitZeroForBigInt(t *testing.T) { + type T struct { + I big.Int `cbor:"bi,omitzero"` + } + + v := T{} + want := []byte{0xa1, 0x62, 0x62, 0x69, 0xc2, 0x40} // {"bi": 2([])} + + em, _ := EncOptions{BigIntConvert: BigIntConvertNone}.EncMode() + dm, _ := DecOptions{}.DecMode() + testRoundTrip(t, []roundTripTest{{"default values", v, want}}, em, dm) +} + func TestTaggedField(t *testing.T) { // A field (T2.X) with a tag dominates untagged field. type ( From 69da12b0b49fbf973952b6b9d2e36a6e16fe24f3 Mon Sep 17 00:00:00 2001 From: Jordan Liggitt Date: Tue, 25 Mar 2025 16:05:19 -0400 Subject: [PATCH 3/3] Adjust OmitZero tests to zero behavior Signed-off-by: Jordan Liggitt --- encode_test.go | 365 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 359 insertions(+), 6 deletions(-) diff --git a/encode_test.go b/encode_test.go index c3a5bfbd..e1f34cc8 100644 --- a/encode_test.go +++ b/encode_test.go @@ -11,6 +11,7 @@ import ( "math" "math/big" "reflect" + "runtime/debug" "strings" "testing" "time" @@ -1694,7 +1695,7 @@ func TestOmitZeroForStruct2(t *testing.T) { } v := T{} - want := []byte{0xa1, 0x64, 0x73, 0x74, 0x72, 0x6f, 0xa1, 0x65, 0x69, 0x6e, 0x74, 0x66, 0x6f, 0xf6} // {"stro": {intfo: nil}} + want := []byte{0xa0} // {} em, _ := EncOptions{}.EncMode() dm, _ := DecOptions{}.DecMode() @@ -1811,7 +1812,7 @@ func TestOmitZeroForStructWithPtrToAnonymousField(t *testing.T) { { name: "not-null pointer to anonymous field", obj: T{T2{&T1{}}}, - wantCborData: []byte{0xa0}, // {} + wantCborData: []byte{0xa1, 0x64, 0x73, 0x74, 0x72, 0x6f, 0xa0}, // {"stro":{}} }, { name: "not empty value in field 1", @@ -1956,34 +1957,386 @@ func TestOmitZeroForBinaryMarshaler2(t *testing.T) { testRoundTrip(t, testCases, em, dm) } -// omitzero is a no-op for time.Time. func TestOmitZeroForTime(t *testing.T) { type T struct { Tm time.Time `cbor:"t,omitzero"` } v := T{} - want := []byte{0xa1, 0x61, 0x74, 0xf6} // {"t": nil} + want := []byte{0xa0} // {} em, _ := EncOptions{}.EncMode() dm, _ := DecOptions{}.DecMode() testRoundTrip(t, []roundTripTest{{"default values", v, want}}, em, dm) } -// omitzero is a no-op for big.Int. func TestOmitZeroForBigInt(t *testing.T) { type T struct { I big.Int `cbor:"bi,omitzero"` } v := T{} - want := []byte{0xa1, 0x62, 0x62, 0x69, 0xc2, 0x40} // {"bi": 2([])} + want := []byte{0xa0} // {} em, _ := EncOptions{BigIntConvert: BigIntConvertNone}.EncMode() dm, _ := DecOptions{}.DecMode() testRoundTrip(t, []roundTripTest{{"default values", v, want}}, em, dm) } +func TestIsZero(t *testing.T) { + var zeroStructZeroer isZeroer = zeroTestTypeCustom{value: 42} + + testcases := []struct { + name string + t reflect.Type + v reflect.Value + + expect bool + expectErr bool + }{ + { + name: "nil", + t: reflect.TypeOf(nil), + v: reflect.ValueOf(nil), + expect: true, + }, + { + name: "string-zero", + t: reflect.TypeOf(""), + v: reflect.ValueOf(""), + expect: true, + }, + + { + name: "string-nonzero", + t: reflect.TypeOf(""), + v: reflect.ValueOf("a"), + expect: false, + }, + { + name: "int-zero", + t: reflect.TypeOf(0), + v: reflect.ValueOf(0), + expect: true, + }, + { + name: "int-nonzero", + t: reflect.TypeOf(0), + v: reflect.ValueOf(1), + expect: false, + }, + + { + name: "bool-zero", + t: reflect.TypeOf(false), + v: reflect.ValueOf(false), + expect: true, + }, + { + name: "bool-nonzero", + t: reflect.TypeOf(false), + v: reflect.ValueOf(true), + expect: false, + }, + + { + name: "slice-zero", + t: reflect.TypeOf([]string(nil)), + v: reflect.ValueOf([]string(nil)), + expect: true, + }, + { + name: "slice-nonzero", + t: reflect.TypeOf([]string(nil)), + v: reflect.ValueOf([]string{}), + expect: false, + }, + + { + name: "map-zero", + t: reflect.TypeOf(map[string]string(nil)), + v: reflect.ValueOf(map[string]string(nil)), + expect: true, + }, + { + name: "map-nonzero", + t: reflect.TypeOf(map[string]string(nil)), + v: reflect.ValueOf(map[string]string{}), + expect: false, + }, + + { + name: "struct-zero", + t: reflect.TypeOf(zeroTestType{}), + v: reflect.ValueOf(zeroTestType{}), + expect: true, + }, + { + name: "struct-nonzero", + t: reflect.TypeOf(zeroTestType{}), + v: reflect.ValueOf(zeroTestType{value: 42}), + expect: false, + }, + + { + name: "pointer-zero", + t: reflect.TypeOf((*zeroTestType)(nil)), + v: reflect.ValueOf((*zeroTestType)(nil)), + expect: true, + }, + { + name: "pointer-nonzero", + t: reflect.TypeOf((*zeroTestType)(nil)), + v: reflect.ValueOf(&zeroTestType{}), + expect: false, + }, + + { + name: "any-struct-zero", + t: reflect.TypeOf(any(nil)), + v: reflect.ValueOf(zeroTestType{}), + expect: true, + }, + { + name: "any-struct-nonzero", + t: reflect.TypeOf(any(nil)), + v: reflect.ValueOf(zeroTestType{value: 42}), + expect: false, + }, + + { + name: "any-pointer-zero", + t: reflect.TypeOf(any(nil)), + v: reflect.ValueOf((*zeroTestType)(nil)), + expect: true, + }, + { + name: "any-pointer-nonzero", + t: reflect.TypeOf(any(nil)), + v: reflect.ValueOf(&zeroTestType{}), + expect: false, + }, + + { + name: "custom-structreceiver-zero-structvalue", + t: reflect.TypeOf(zeroTestTypeCustom{}), + v: reflect.ValueOf(zeroTestTypeCustom{value: 42}), + expect: true, + }, + { + name: "custom-structreceiver-nonzero-structvalue", + t: reflect.TypeOf(zeroTestTypeCustom{}), + v: reflect.ValueOf(zeroTestTypeCustom{value: 1}), + expect: false, + }, + { + name: "custom-structreceiver-zero-pointervalue", + t: reflect.TypeOf(zeroTestTypeCustom{}), + v: reflect.ValueOf(&zeroTestTypeCustom{value: 42}), + expect: true, + }, + { + name: "custom-structreceiver-nonzero-pointervalue", + t: reflect.TypeOf(zeroTestTypeCustom{}), + v: reflect.ValueOf(&zeroTestTypeCustom{value: 1}), + expect: false, + }, + + { + name: "custom-structreceiver-zero-pointervalue", + t: reflect.TypeOf(&zeroTestTypeCustom{}), + v: reflect.ValueOf(&zeroTestTypeCustom{value: 42}), + expect: true, + }, + { + name: "custom-structreceiver-nonzero-pointervalue", + t: reflect.TypeOf(&zeroTestTypeCustom{}), + v: reflect.ValueOf(&zeroTestTypeCustom{value: 1}), + expect: false, + }, + { + name: "custom-structreceiver-zero-nil-pointervalue", + t: reflect.TypeOf(&zeroTestTypeCustom{}), + v: reflect.ValueOf((*zeroTestTypeCustom)(nil)), + expect: true, + }, + + { + name: "custom-pointerreceiver-zero-structvalue", + t: reflect.TypeOf(zeroTestTypeCustomPointer{}), + v: reflect.ValueOf(zeroTestTypeCustomPointer{value: 42}), + expect: true, + }, + { + name: "custom-pointerreceiver-nonzero-structvalue", + t: reflect.TypeOf(zeroTestTypeCustomPointer{}), + v: reflect.ValueOf(zeroTestTypeCustomPointer{value: 1}), + expect: false, + }, + + { + name: "custom-pointerreceiver-zero-pointervalue", + t: reflect.TypeOf(&zeroTestTypeCustomPointer{}), + v: reflect.ValueOf(&zeroTestTypeCustomPointer{value: 42}), + expect: true, + }, + { + name: "custom-pointerreceiver-nonzero-pointervalue", + t: reflect.TypeOf(&zeroTestTypeCustomPointer{}), + v: reflect.ValueOf(&zeroTestTypeCustomPointer{value: 1}), + expect: false, + }, + + { + name: "custom-interface-nil-pointer", + t: isZeroerType, + v: reflect.ValueOf((*zeroTestTypeCustom)(nil)), + expect: true, + }, + { + name: "custom-interface-zero-structreceiver-pointer", + t: isZeroerType, + v: reflect.ValueOf(&zeroTestTypeCustom{value: 42}), + expect: true, + }, + { + name: "custom-interface-zero-structreceiver", + t: isZeroerType, + v: reflect.ValueOf(zeroStructZeroer), + expect: true, + }, + { + name: "custom-interface-nonzero-struct", + t: isZeroerType, + v: reflect.ValueOf(&zeroTestTypeCustom{value: 1}), + expect: false, + }, + { + name: "custom-interface-nil-pointerreceiver", + t: isZeroerType, + v: reflect.ValueOf((*zeroTestTypeCustomPointer)(nil)), + expect: true, + }, + { + name: "custom-interface-zero-pointerreceiver", + t: isZeroerType, + v: reflect.ValueOf(&zeroTestTypeCustomPointer{value: 42}), + expect: true, + }, + { + name: "custom-interface-nonzero-pointerreceiver", + t: isZeroerType, + v: reflect.ValueOf(&zeroTestTypeCustomPointer{value: 1}), + expect: false, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + defer func() { + if err := recover(); err != nil { + t.Log(string(debug.Stack())) + t.Errorf("unexpected panic %v", err) + } + }() + got, err := getIsZeroFunc(tc.t)(tc.v) + if tc.expectErr != (err != nil) { + t.Errorf("got err=%v, expected %v", err, tc.expectErr) + } + if tc.expect != got { + t.Errorf("got %v, expected %v", got, tc.expect) + } + }) + } +} + +type zeroTestType struct { + value int +} + +type zeroTestTypeCustom struct { + value int +} + +func (z zeroTestTypeCustom) IsZero() bool { + return z.value == 42 +} + +type zeroTestTypeCustomPointer struct { + value int +} + +func (z *zeroTestTypeCustomPointer) IsZero() bool { + return z.value == 42 +} + +func TestJSONStdlibOmitZero(t *testing.T) { + type CBOR struct { + S string `cbor:"s,omitzero"` + } + type JSON struct { + S string `json:"s,omitzero"` + } + + testcases := []struct { + name string + stdlib bool + obj any + want []byte + }{ + { + name: "cbor-stdlib-off", + stdlib: false, + obj: CBOR{}, + want: []byte{0xa0}, // {} + }, + { + name: "cbor-stdlib-on", + stdlib: true, + obj: CBOR{}, + want: []byte{0xa0}, // {} + }, + { + name: "json-stdlib-off", + stdlib: false, + obj: JSON{}, + want: []byte{0xa1, 0x61, 0x73, 0x60}, // {"s":""} + }, + { + name: "json-stdlib-on", + stdlib: true, + obj: JSON{}, + want: []byte{0xa0}, // {} + }, + } + + original := jsonStdlibSupportsOmitzero + reset := func() { + // reset to original + jsonStdlibSupportsOmitzero = original + // clear type caches + encodingStructTypeCache.Range(func(key, _ any) bool { + encodingStructTypeCache.Delete(key) + return true + }) + typeInfoCache.Range(func(key, _ any) bool { + typeInfoCache.Delete(key) + return true + }) + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + reset() + jsonStdlibSupportsOmitzero = tc.stdlib + t.Cleanup(reset) + + em, _ := EncOptions{}.EncMode() + dm, _ := DecOptions{}.DecMode() + testRoundTrip(t, []roundTripTest{{tc.name, tc.obj, tc.want}}, em, dm) + }) + } +} + func TestTaggedField(t *testing.T) { // A field (T2.X) with a tag dominates untagged field. type (