feat: honour the omitzero and omitempty tag options on encode
Assisted-by: GLM 5.3 Flash
This commit is contained in:
+5
-1
@@ -9,7 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
-
|
- `omitzero` and `omitempty` tag options on encode: `toml:"name,omitzero"`
|
||||||
|
skips a field whose value is the zero value of its type (a type with an
|
||||||
|
`IsZero() bool` method decides through the method), and
|
||||||
|
`toml:"name,omitempty"` skips a nil or empty slice, array, or map. The
|
||||||
|
decoder ignores both options.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ func structFields(t reflect.Type) map[string]int {
|
|||||||
}
|
}
|
||||||
name := f.Name
|
name := f.Name
|
||||||
if tag, ok := f.Tag.Lookup("toml"); ok {
|
if tag, ok := f.Tag.Lookup("toml"); ok {
|
||||||
tag = strings.Split(tag, ",")[0]
|
tag, _, _ = strings.Cut(tag, ",")
|
||||||
if tag == "-" {
|
if tag == "-" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
+24
@@ -233,6 +233,30 @@ Keys that match `[A-Za-z0-9_-]+` are emitted bare, all others quoted. A
|
|||||||
`map[string]V` emits its keys in sorted order for deterministic output, and a
|
`map[string]V` emits its keys in sorted order for deterministic output, and a
|
||||||
nil map emits nothing.
|
nil map emits nothing.
|
||||||
|
|
||||||
|
### Tag options
|
||||||
|
|
||||||
|
The part of a `toml` tag after the first comma carries options. Both options
|
||||||
|
shape emission only; the decoder ignores them.
|
||||||
|
|
||||||
|
- `omitzero` skips the field when its value is the zero value of its type. A
|
||||||
|
type with an `IsZero() bool` method (time.Time among them) decides through
|
||||||
|
that method, so a zero `time.Time` or an all-zero struct disappears from
|
||||||
|
the output.
|
||||||
|
- `omitempty` skips the field when it holds an empty collection: a nil or
|
||||||
|
empty slice or array, or a nil or empty map. Strings and other scalars are
|
||||||
|
not covered by `omitempty`; use `omitzero` for those.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Config struct {
|
||||||
|
Host string `toml:"host,omitzero"`
|
||||||
|
Started time.Time `toml:"started,omitzero"`
|
||||||
|
Tags []string `toml:"tags,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Options combine after the name: `toml:"name,omitempty,omitzero"` is valid, and
|
||||||
|
an unknown option is ignored.
|
||||||
|
|
||||||
Note the asymmetry: the encoder inlines untagged embedded structs, while the
|
Note the asymmetry: the encoder inlines untagged embedded structs, while the
|
||||||
decoder expects them under their lower-cased type name. A struct with an
|
decoder expects them under their lower-cased type name. A struct with an
|
||||||
untagged embedded struct therefore does not round-trip through `Unmarshal` into
|
untagged embedded struct therefore does not round-trip through `Unmarshal` into
|
||||||
|
|||||||
@@ -189,6 +189,9 @@ func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
|
|||||||
if name == "-" {
|
if name == "-" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if fieldOmitted(f, v.Field(i)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := addField(doc, name, v.Field(i), ctx); err != nil {
|
if err := addField(doc, name, v.Field(i), ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -196,6 +199,49 @@ func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isZeroer mirrors encoding/json's omitzero: a type that knows its own zero
|
||||||
|
// state decides through that method before reflection is consulted.
|
||||||
|
type isZeroer interface{ IsZero() bool }
|
||||||
|
|
||||||
|
// fieldOmitted reports whether the field's tag options drop it from the
|
||||||
|
// output: omitzero skips the zero value of the field's type, omitempty skips
|
||||||
|
// an empty collection (slice, array, or map). The decoder ignores both
|
||||||
|
// options; they shape emission only.
|
||||||
|
func fieldOmitted(f reflect.StructField, v reflect.Value) bool {
|
||||||
|
tag, ok := f.Tag.Lookup("toml")
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, opts, _ := strings.Cut(tag, ",")
|
||||||
|
for opts != "" {
|
||||||
|
var opt string
|
||||||
|
opt, opts, _ = strings.Cut(opts, ",")
|
||||||
|
switch opt {
|
||||||
|
case "omitzero":
|
||||||
|
if isZeroValue(v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case "omitempty":
|
||||||
|
switch v.Kind() {
|
||||||
|
case reflect.Slice, reflect.Array, reflect.Map:
|
||||||
|
if v.Len() == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isZeroValue(v reflect.Value) bool {
|
||||||
|
if v.CanInterface() {
|
||||||
|
if z, ok := v.Interface().(isZeroer); ok {
|
||||||
|
return z.IsZero()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v.IsZero()
|
||||||
|
}
|
||||||
|
|
||||||
// fieldName returns the TOML key for a struct field, honouring the `toml`
|
// fieldName returns the TOML key for a struct field, honouring the `toml`
|
||||||
// tag (name or `-`) and falling back to a lower-cased field name.
|
// tag (name or `-`) and falling back to a lower-cased field name.
|
||||||
func fieldName(f reflect.StructField) string {
|
func fieldName(f reflect.StructField) string {
|
||||||
|
|||||||
@@ -748,6 +748,85 @@ func TestMarshalEmbeddedStructAsTable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMarshalTagOptionOmitZero(t *testing.T) {
|
||||||
|
type Server struct {
|
||||||
|
Host string `toml:"host"`
|
||||||
|
}
|
||||||
|
type Cfg struct {
|
||||||
|
Name string `toml:"name,omitzero"`
|
||||||
|
Count int `toml:"count,omitzero"`
|
||||||
|
Ratio float64 `toml:"ratio,omitzero"`
|
||||||
|
When time.Time `toml:"when,omitzero"`
|
||||||
|
Server Server `toml:"server,omitzero"`
|
||||||
|
Always string `toml:"always"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{Always: "kept"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
// Every omitzero field sits at its zero value, so only always is emitted.
|
||||||
|
want := "always = \"kept\"\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
when := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)
|
||||||
|
out, err = Marshal(Cfg{Name: "x", Count: 1, Ratio: 0.5, When: when, Server: Server{Host: "h"}, Always: "kept"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want = "name = \"x\"\ncount = 1\nratio = 0.5\nwhen = 2026-09-17T12:00:00Z\nalways = \"kept\"\n\n[server]\nhost = \"h\"\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalTagOptionOmitEmpty(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Tags []string `toml:"tags,omitempty"`
|
||||||
|
Ports []int `toml:"ports,omitempty"`
|
||||||
|
Matrix [][]int `toml:"matrix,omitempty"`
|
||||||
|
Extra map[string]any `toml:"extra,omitempty"`
|
||||||
|
Name string `toml:"name,omitempty"`
|
||||||
|
Keep []string `toml:"keep"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{
|
||||||
|
Ports: []int{},
|
||||||
|
Matrix: [][]int{{1}},
|
||||||
|
Extra: map[string]any{},
|
||||||
|
Name: "set",
|
||||||
|
Keep: []string{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
// tags is nil (omitted anyway), ports and extra are empty collections
|
||||||
|
// dropped by omitempty, matrix is populated, name is a string the option
|
||||||
|
// does not cover, keep is empty but carries no option so it emits [].
|
||||||
|
want := "matrix = [[1]]\nname = \"set\"\nkeep = []\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalTagOptionOnTaggedEmbeddedStruct(t *testing.T) {
|
||||||
|
type Inner struct {
|
||||||
|
N int `toml:"n"`
|
||||||
|
}
|
||||||
|
type Cfg struct {
|
||||||
|
Inner Inner `toml:"inner,omitzero"`
|
||||||
|
Name string `toml:"name"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{Name: "x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "name = \"x\"\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMarshalMapKeysSorted(t *testing.T) {
|
func TestMarshalMapKeysSorted(t *testing.T) {
|
||||||
m := map[string]any{
|
m := map[string]any{
|
||||||
"zeta": 1,
|
"zeta": 1,
|
||||||
|
|||||||
+5
-2
@@ -150,8 +150,11 @@ type Unmarshaler interface {
|
|||||||
// - The top-level value must be a struct or a map[string]V. Pointers are
|
// - The top-level value must be a struct or a map[string]V. Pointers are
|
||||||
// followed; a nil top-level pointer is an error.
|
// followed; a nil top-level pointer is an error.
|
||||||
// - Struct fields are matched by `toml:"name"` tag (case-insensitive
|
// - Struct fields are matched by `toml:"name"` tag (case-insensitive
|
||||||
// fallback to field name; `-` skips). Anonymous (embedded) fields without
|
// fallback to field name; `-` skips). The tag options `omitzero` (skip
|
||||||
// a tag are inlined.
|
// the zero value of the field's type) and `omitempty` (skip an empty
|
||||||
|
// slice, array, or map) drop a field from the output on encode; the
|
||||||
|
// decoder ignores them. Anonymous (embedded) fields without a tag are
|
||||||
|
// inlined.
|
||||||
// - Maps use sorted keys for deterministic output.
|
// - Maps use sorted keys for deterministic output.
|
||||||
// - Slices and arrays of structs or maps become TOML arrays of tables; a
|
// - Slices and arrays of structs or maps become TOML arrays of tables; a
|
||||||
// nil or empty array of tables is omitted (TOML forbids an empty `[[a]]`),
|
// nil or empty array of tables is omitted (TOML forbids an empty `[[a]]`),
|
||||||
|
|||||||
Reference in New Issue
Block a user