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
|
||||
|
||||
-
|
||||
- `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
|
||||
|
||||
|
||||
@@ -230,7 +230,7 @@ func structFields(t reflect.Type) map[string]int {
|
||||
}
|
||||
name := f.Name
|
||||
if tag, ok := f.Tag.Lookup("toml"); ok {
|
||||
tag = strings.Split(tag, ",")[0]
|
||||
tag, _, _ = strings.Cut(tag, ",")
|
||||
if tag == "-" {
|
||||
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
|
||||
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
|
||||
decoder expects them under their lower-cased type name. A struct with an
|
||||
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 == "-" {
|
||||
continue
|
||||
}
|
||||
if fieldOmitted(f, v.Field(i)) {
|
||||
continue
|
||||
}
|
||||
if err := addField(doc, name, v.Field(i), ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -196,6 +199,49 @@ func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
|
||||
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`
|
||||
// tag (name or `-`) and falling back to a lower-cased field name.
|
||||
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) {
|
||||
m := map[string]any{
|
||||
"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
|
||||
// followed; a nil top-level pointer is an error.
|
||||
// - Struct fields are matched by `toml:"name"` tag (case-insensitive
|
||||
// fallback to field name; `-` skips). Anonymous (embedded) fields without
|
||||
// a tag are inlined.
|
||||
// fallback to field name; `-` skips). The tag options `omitzero` (skip
|
||||
// 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.
|
||||
// - 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]]`),
|
||||
|
||||
Reference in New Issue
Block a user