Skip to content
Open
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
63 changes: 63 additions & 0 deletions omitzero_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package msgpack_test

import (
"testing"
"time"

"github.com/stretchr/testify/require"
"github.com/vmihailenco/msgpack/v5"
)

// zeroByMethod has a value-receiver IsZero, exercising the omitzero path that
// defers to a type's own IsZero rather than reflect's structural zero check.
type zeroByMethod struct{ n int }

func (z zeroByMethod) IsZero() bool { return z.n == 0 }

// TestOmitZero checks that the `omitzero` struct-tag option is honored, matching
// encoding/json (Go 1.24+): a field is omitted when it holds the zero value for
// its type, where a type's own IsZero method takes precedence. This is distinct
// from `omitempty`, which also drops non-nil empty slices, maps, and strings.
func TestOmitZero(t *testing.T) {
type S struct {
Keep int `msgpack:"keep"` // no option: always encoded
OmitInt int `msgpack:"omit_int,omitzero"` // dropped when 0
OmitStr string `msgpack:"omit_str,omitzero"` // dropped when ""
OmitBool bool `msgpack:"omit_bool,omitzero"` // dropped when false
OmitObj zeroByMethod `msgpack:"omit_obj,omitzero"` // dropped when IsZero()
OmitPtr *time.Time `msgpack:"omit_ptr,omitzero"` // dropped when nil
}

encodeToMap := func(t *testing.T, v any) map[string]any {
t.Helper()
b, err := msgpack.Marshal(v)
require.NoError(t, err)
var m map[string]any
require.NoError(t, msgpack.Unmarshal(b, &m))
return m
}

omitzeroKeys := []string{"omit_int", "omit_str", "omit_bool", "omit_obj", "omit_ptr"}

// Zero value: every omitzero field is dropped; the untagged field stays.
// The nil *time.Time also exercises the nil-pointer guard: its element type
// has an IsZero method, which must not be invoked on the nil pointer.
m := encodeToMap(t, S{})
require.Contains(t, m, "keep", "field without omitzero must always be encoded")
for _, k := range omitzeroKeys {
require.NotContains(t, m, k, "omitzero field %q must be omitted at the zero value", k)
}

// Non-zero value: every omitzero field is encoded.
now := time.Unix(1, 0)
m = encodeToMap(t, S{
OmitInt: 7,
OmitStr: "x",
OmitBool: true,
OmitObj: zeroByMethod{n: 1},
OmitPtr: &now,
})
for _, k := range omitzeroKeys {
require.Contains(t, m, k, "omitzero field %q must be encoded when non-zero", k)
}
}
38 changes: 37 additions & 1 deletion types.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,21 @@ type field struct {
name string
index []int
omitEmpty bool
omitZero bool // honor the encoding/json 1.24 `omitzero` tag option.
}

func (f *field) Omit(e *Encoder, strct reflect.Value) bool {
v, ok := fieldByIndex(strct, f.index)
if !ok {
return true
}
// `omitzero` omits the field when it holds the zero value for its
// type, matching encoding/json (Go 1.24+). It is independent of
// `omitempty`: a custom IsZero takes precedence, and unlike omitempty it
// does not treat a non-nil empty slice/map as omittable.
if f.omitZero && isZeroValue(v) {
return true
}
forced := e.flags&omitEmptyFlag != 0
return (f.omitEmpty || forced) && e.isEmptyValue(v)
}
Expand All @@ -128,6 +136,7 @@ type fields struct {
AsArray bool

hasOmitEmpty bool
hasOmitZero bool // any field carries the `omitzero` tag option.
}

func newFields(typ reflect.Type) *fields {
Expand All @@ -145,6 +154,9 @@ func (fs *fields) Add(field *field) {
if field.omitEmpty {
fs.hasOmitEmpty = true
}
if field.omitZero {
fs.hasOmitZero = true
}
}

func (fs *fields) warnIfFieldExists(name string) {
Expand All @@ -155,7 +167,7 @@ func (fs *fields) warnIfFieldExists(name string) {

func (fs *fields) OmitEmpty(e *Encoder, strct reflect.Value) []*field {
forced := e.flags&omitEmptyFlag != 0
if !fs.hasOmitEmpty && !forced {
if !fs.hasOmitEmpty && !fs.hasOmitZero && !forced {
return fs.List
}

Expand Down Expand Up @@ -202,6 +214,7 @@ func getFields(typ reflect.Type, fallbackTag string) *fields {
name: tag.Name,
index: f.Index,
omitEmpty: omitEmpty || tag.HasOption("omitempty"),
omitZero: tag.HasOption("omitzero"),
}

if tag.HasOption("intern") {
Expand Down Expand Up @@ -319,6 +332,29 @@ type isZeroer interface {
IsZero() bool
}

// isZeroValue reports whether v is the zero value for its type, using the same
// rule as encoding/json's `omitzero` (Go 1.24+): a type's own IsZero method
// takes precedence; otherwise the value is compared against the zero value via
// reflection. This is intentionally distinct from isEmptyValue (omitempty),
// which also omits non-nil empty slices, maps, and strings.
func isZeroValue(v reflect.Value) bool {
// A nil pointer or interface is the zero value; guard before invoking
// IsZero so a typed-nil (e.g. (*time.Time)(nil)) whose type carries an
// IsZero method does not get dereferenced. encoding/json guards this too.
if k := v.Kind(); (k == reflect.Pointer || k == reflect.Interface) && v.IsNil() {
return true
}
if z, ok := v.Interface().(isZeroer); ok {
return z.IsZero()
}
if v.CanAddr() {
if z, ok := v.Addr().Interface().(isZeroer); ok {
return z.IsZero()
}
}
return v.IsZero()
}

func (e *Encoder) isEmptyValue(v reflect.Value) bool {
kind := v.Kind()

Expand Down