Skip to content

Commit

Permalink
encoding/json: add omitzero option
Browse files Browse the repository at this point in the history
Fixes #45669

Change-Id: Ic13523c0b3acdfc5b3e29a717bc62fde302ed8fd
GitHub-Last-Rev: 57030f2
GitHub-Pull-Request: #69622
Reviewed-on: https://go-review.googlesource.com/c/go/+/615676
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Ian Lance Taylor <iant@google.com>
Reviewed-by: Ian Lance Taylor <iant@google.com>
Reviewed-by: Joseph Tsai <joetsai@digital-static.net>
Reviewed-by: Michael Knyszek <mknyszek@google.com>
  • Loading branch information
callthingsoff authored and gopherbot committed Oct 2, 2024
1 parent 41d189a commit e86982c
Show file tree
Hide file tree
Showing 3 changed files with 248 additions and 4 deletions.
7 changes: 7 additions & 0 deletions doc/next/6-stdlib/99-minor/encoding/json/45669.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
When marshaling, a struct field with the new `omitzero` option in the struct field
tag will be omitted if its value is zero. If the field type has an `IsZero() bool`
method, that will be used to determine whether the value is zero. Otherwise, the
value is zero if it is [the zero value for its type](/ref/spec#The_zero_value).

If both `omitempty` and `omitzero` are specified, the field will be omitted if the
value is either empty or zero (or both).
57 changes: 56 additions & 1 deletion src/encoding/json/encode.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,17 @@ import (
// // Field appears in JSON as key "-".
// Field int `json:"-,"`
//
// The "omitzero" option specifies that the field should be omitted
// from the encoding if the field has a zero value, according to rules:
//
// 1) If the field type has an "IsZero() bool" method, that will be used to
// determine whether the value is zero.
//
// 2) Otherwise, the value is zero if it is the zero value for its type.
//
// If both "omitempty" and "omitzero" are specified, the field will be omitted
// if the value is either empty or zero (or both).
//
// The "string" option signals that a field is stored as JSON inside a
// JSON-encoded string. It applies only to fields of string, floating point,
// integer, or boolean types. This extra level of encoding is sometimes used
Expand Down Expand Up @@ -701,7 +712,8 @@ FieldLoop:
fv = fv.Field(i)
}

if f.omitEmpty && isEmptyValue(fv) {
if (f.omitEmpty && isEmptyValue(fv)) ||
(f.omitZero && (f.isZero == nil && fv.IsZero() || (f.isZero != nil && f.isZero(fv)))) {
continue
}
e.WriteByte(next)
Expand Down Expand Up @@ -1048,11 +1060,19 @@ type field struct {
index []int
typ reflect.Type
omitEmpty bool
omitZero bool
isZero func(reflect.Value) bool
quoted bool

encoder encoderFunc
}

type isZeroer interface {
IsZero() bool
}

var isZeroerType = reflect.TypeFor[isZeroer]()

// typeFields returns a list of fields that JSON should recognize for the given type.
// The algorithm is breadth-first search over the set of structs to include - the top struct
// and then any reachable anonymous structs.
Expand Down Expand Up @@ -1154,6 +1174,7 @@ func typeFields(t reflect.Type) structFields {
index: index,
typ: ft,
omitEmpty: opts.Contains("omitempty"),
omitZero: opts.Contains("omitzero"),
quoted: quoted,
}
field.nameBytes = []byte(field.name)
Expand All @@ -1163,6 +1184,40 @@ func typeFields(t reflect.Type) structFields {
field.nameEscHTML = `"` + string(nameEscBuf) + `":`
field.nameNonEsc = `"` + field.name + `":`

if field.omitZero {
t := sf.Type
// Provide a function that uses a type's IsZero method.
switch {
case t.Kind() == reflect.Interface && t.Implements(isZeroerType):
field.isZero = func(v reflect.Value) bool {
// Avoid panics calling IsZero on a nil interface or
// non-nil interface with nil pointer.
return v.IsNil() ||
(v.Elem().Kind() == reflect.Pointer && v.Elem().IsNil()) ||
v.Interface().(isZeroer).IsZero()
}
case t.Kind() == reflect.Pointer && t.Implements(isZeroerType):
field.isZero = func(v reflect.Value) bool {
// Avoid panics calling IsZero on nil pointer.
return v.IsNil() || v.Interface().(isZeroer).IsZero()
}
case t.Implements(isZeroerType):
field.isZero = func(v reflect.Value) bool {
return v.Interface().(isZeroer).IsZero()
}
case reflect.PointerTo(t).Implements(isZeroerType):
field.isZero = func(v reflect.Value) bool {
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()
}
}
}

fields = append(fields, field)
if count[f.typ] > 1 {
// If there were multiple instances, add a second,
Expand Down
188 changes: 185 additions & 3 deletions src/encoding/json/encode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ import (
"runtime/debug"
"strconv"
"testing"
"time"
)

type Optionals struct {
type OptionalsEmpty struct {
Sr string `json:"sr"`
So string `json:"so,omitempty"`
Sw string `json:"-"`
Expand Down Expand Up @@ -45,7 +46,7 @@ type Optionals struct {
}

func TestOmitEmpty(t *testing.T) {
var want = `{
const want = `{
"sr": "",
"omitempty": 0,
"slr": null,
Expand All @@ -56,7 +57,7 @@ func TestOmitEmpty(t *testing.T) {
"str": {},
"sto": {}
}`
var o Optionals
var o OptionalsEmpty
o.Sw = "something"
o.Mr = map[string]any{}
o.Mo = map[string]any{}
Expand All @@ -70,6 +71,187 @@ func TestOmitEmpty(t *testing.T) {
}
}

type NonZeroStruct struct{}

func (nzs NonZeroStruct) IsZero() bool {
return false
}

type NoPanicStruct struct {
Int int `json:"int,omitzero"`
}

func (nps *NoPanicStruct) IsZero() bool {
return nps.Int != 0
}

type OptionalsZero struct {
Sr string `json:"sr"`
So string `json:"so,omitzero"`
Sw string `json:"-"`

Ir int `json:"omitzero"` // actually named omitzero, not an option
Io int `json:"io,omitzero"`

Slr []string `json:"slr,random"`
Slo []string `json:"slo,omitzero"`
SloNonNil []string `json:"slononnil,omitzero"`

Mr map[string]any `json:"mr"`
Mo map[string]any `json:",omitzero"`
Moo map[string]any `json:"moo,omitzero"`

Fr float64 `json:"fr"`
Fo float64 `json:"fo,omitzero"`
Foo float64 `json:"foo,omitzero"`
Foo2 [2]float64 `json:"foo2,omitzero"`

Br bool `json:"br"`
Bo bool `json:"bo,omitzero"`

Ur uint `json:"ur"`
Uo uint `json:"uo,omitzero"`

Str struct{} `json:"str"`
Sto struct{} `json:"sto,omitzero"`

Time time.Time `json:"time,omitzero"`
TimeLocal time.Time `json:"timelocal,omitzero"`
Nzs NonZeroStruct `json:"nzs,omitzero"`

NilIsZeroer isZeroer `json:"niliszeroer,omitzero"` // nil interface
NonNilIsZeroer isZeroer `json:"nonniliszeroer,omitzero"` // non-nil interface
NoPanicStruct0 isZeroer `json:"nps0,omitzero"` // non-nil interface with nil pointer
NoPanicStruct1 isZeroer `json:"nps1,omitzero"` // non-nil interface with non-nil pointer
NoPanicStruct2 *NoPanicStruct `json:"nps2,omitzero"` // nil pointer
NoPanicStruct3 *NoPanicStruct `json:"nps3,omitzero"` // non-nil pointer
NoPanicStruct4 NoPanicStruct `json:"nps4,omitzero"` // concrete type
}

func TestOmitZero(t *testing.T) {
const want = `{
"sr": "",
"omitzero": 0,
"slr": null,
"slononnil": [],
"mr": {},
"Mo": {},
"fr": 0,
"br": false,
"ur": 0,
"str": {},
"nzs": {},
"nps1": {},
"nps3": {},
"nps4": {}
}`
var o OptionalsZero
o.Sw = "something"
o.SloNonNil = make([]string, 0)
o.Mr = map[string]any{}
o.Mo = map[string]any{}

o.Foo = -0
o.Foo2 = [2]float64{+0, -0}

o.TimeLocal = time.Time{}.Local()

o.NonNilIsZeroer = time.Time{}
o.NoPanicStruct0 = (*NoPanicStruct)(nil)
o.NoPanicStruct1 = &NoPanicStruct{}
o.NoPanicStruct3 = &NoPanicStruct{}

got, err := MarshalIndent(&o, "", " ")
if err != nil {
t.Fatalf("MarshalIndent error: %v", err)
}
if got := string(got); got != want {
t.Errorf("MarshalIndent:\n\tgot: %s\n\twant: %s\n", indentNewlines(got), indentNewlines(want))
}
}

func TestOmitZeroMap(t *testing.T) {
const want = `{
"foo": {
"sr": "",
"omitzero": 0,
"slr": null,
"mr": null,
"fr": 0,
"br": false,
"ur": 0,
"str": {},
"nzs": {},
"nps4": {}
}
}`
m := map[string]OptionalsZero{"foo": {}}
got, err := MarshalIndent(m, "", " ")
if err != nil {
t.Fatalf("MarshalIndent error: %v", err)
}
if got := string(got); got != want {
fmt.Println(got)
t.Errorf("MarshalIndent:\n\tgot: %s\n\twant: %s\n", indentNewlines(got), indentNewlines(want))
}
}

type OptionalsEmptyZero struct {
Sr string `json:"sr"`
So string `json:"so,omitempty,omitzero"`
Sw string `json:"-"`

Io int `json:"io,omitempty,omitzero"`

Slr []string `json:"slr,random"`
Slo []string `json:"slo,omitempty,omitzero"`
SloNonNil []string `json:"slononnil,omitempty,omitzero"`

Mr map[string]any `json:"mr"`
Mo map[string]any `json:",omitempty,omitzero"`

Fr float64 `json:"fr"`
Fo float64 `json:"fo,omitempty,omitzero"`

Br bool `json:"br"`
Bo bool `json:"bo,omitempty,omitzero"`

Ur uint `json:"ur"`
Uo uint `json:"uo,omitempty,omitzero"`

Str struct{} `json:"str"`
Sto struct{} `json:"sto,omitempty,omitzero"`

Time time.Time `json:"time,omitempty,omitzero"`
Nzs NonZeroStruct `json:"nzs,omitempty,omitzero"`
}

func TestOmitEmptyZero(t *testing.T) {
const want = `{
"sr": "",
"slr": null,
"mr": {},
"fr": 0,
"br": false,
"ur": 0,
"str": {},
"nzs": {}
}`
var o OptionalsEmptyZero
o.Sw = "something"
o.SloNonNil = make([]string, 0)
o.Mr = map[string]any{}
o.Mo = map[string]any{}

got, err := MarshalIndent(&o, "", " ")
if err != nil {
t.Fatalf("MarshalIndent error: %v", err)
}
if got := string(got); got != want {
t.Errorf("MarshalIndent:\n\tgot: %s\n\twant: %s\n", indentNewlines(got), indentNewlines(want))
}
}

type StringTag struct {
BoolStr bool `json:",string"`
IntStr int64 `json:",string"`
Expand Down

0 comments on commit e86982c

Please sign in to comment.