Skip to content

Commit bfd0b8b

Browse files
committed
feat(mapper): support ,omitzero tag option
omitempty conflates nil and len-0 slices/maps, both skipped. That makes it impossible to clear a NOT NULL DEFAULT '{}' array column to empty via Save/Map-driven UPDATE: the empty slice is treated as zero, the column is omitted, and the row keeps its stale value. omitzero, mirroring encoding/json's Go 1.24+ semantics, skips only the type's true zero: nil pointers, nil slices, nil maps, primitive zeros, or any type whose IsZero() reports true. A non-nil empty slice/map is not zero and stays in the UPDATE SET clause, so a clear actually clears. - mapper.go: parse omitzero alongside omitempty; for slices/maps the legacy "empty == zero" rule only applies to omitempty - mapper_test.go: regression matrix for both options across nil, empty, populated, time.IsZero, primitives, IncludeZeroed and IncludeNil - doc comment on Map summarizes both options
1 parent 7f7dc8e commit bfd0b8b

2 files changed

Lines changed: 199 additions & 11 deletions

File tree

mapper.go

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,21 @@ type MapOptions struct {
3838
// which can be directly passed to a query executor. This allows you to use structs/objects
3939
// to build easy insert/update queries without having to specify the column names manually.
4040
// The mapper works by reading the column names from a struct fields `db:""` struct tag.
41-
// If you specify `,omitempty` as a tag option, then it will omit the column from the list,
42-
// which allows the database to take over and use its default value.
41+
//
42+
// Two tag options control how zero values are emitted:
43+
//
44+
// - `,omitempty` omits the column when the field's value is empty: a nil
45+
// pointer, an empty string, a zero number, a zero-length slice or map
46+
// (nil OR non-nil), or any type whose IsZero() reports true. This lets
47+
// the database fall back to a column DEFAULT on INSERT, but on UPDATE
48+
// it silently leaves the column untouched — which prevents clearing a
49+
// NOT NULL DEFAULT '{}' array to empty.
50+
// - `,omitzero` omits the column only when the field holds the type's
51+
// true zero value: a nil pointer, a nil slice or map (non-nil empty is
52+
// INCLUDED), or any type whose IsZero() reports true. Use this on
53+
// columns you want to be able to clear to empty via UPDATE while still
54+
// getting the DEFAULT on INSERT when the field is nil. Mirrors the
55+
// semantics of encoding/json's `omitzero` (Go 1.24+).
4356
func Map(record interface{}) ([]string, []interface{}, error) {
4457
return MapWithOptions(record, nil)
4558
}
@@ -86,15 +99,16 @@ func MapWithOptions(record interface{}, options *MapOptions) ([]string, []interf
8699

87100
// Field options
88101
_, tagOmitEmpty := fi.Options["omitempty"]
102+
_, tagOmitZero := fi.Options["omitzero"]
89103

90104
fld := reflectx.FieldByIndexesReadOnly(recordV, fi.Index)
91105

92106
if fld.Kind() == reflect.Ptr && fld.IsNil() {
93-
if tagOmitEmpty && !options.IncludeNil {
107+
if (tagOmitEmpty || tagOmitZero) && !options.IncludeNil {
94108
continue
95109
}
96110
fv.fields = append(fv.fields, fi.Name)
97-
if tagOmitEmpty {
111+
if tagOmitEmpty || tagOmitZero {
98112
fv.values = append(fv.values, sqlDefault)
99113
} else {
100114
fv.values = append(fv.values, nil)
@@ -104,20 +118,33 @@ func MapWithOptions(record interface{}, options *MapOptions) ([]string, []interf
104118

105119
value := fld.Interface()
106120

107-
isZero := false
121+
// isEmpty matches the legacy omitempty rule: nil/empty slices and
122+
// maps both count. isStrictZero matches Go 1.24+ json's omitzero:
123+
// only the type's true zero value (nil slice/map, not empty).
124+
var isEmpty, isStrictZero bool
108125
if t, ok := fld.Interface().(hasIsZero); ok {
109126
if t.IsZero() {
110-
isZero = true
127+
isEmpty, isStrictZero = true, true
111128
}
112-
} else if fld.Kind() == reflect.Array || fld.Kind() == reflect.Slice {
129+
} else if fld.Kind() == reflect.Slice || fld.Kind() == reflect.Map {
130+
if fld.IsNil() {
131+
isEmpty, isStrictZero = true, true
132+
} else if fld.Len() == 0 {
133+
isEmpty = true
134+
}
135+
} else if fld.Kind() == reflect.Array {
113136
if fld.Len() == 0 {
114-
isZero = true
137+
isEmpty = true
138+
}
139+
if fld.IsZero() {
140+
isStrictZero = true
115141
}
116142
} else if reflect.DeepEqual(fi.Zero.Interface(), value) {
117-
isZero = true
143+
isEmpty, isStrictZero = true, true
118144
}
119145

120-
if isZero && tagOmitEmpty && !options.IncludeZeroed {
146+
skip := (isEmpty && tagOmitEmpty) || (isStrictZero && tagOmitZero)
147+
if skip && !options.IncludeZeroed {
121148
continue
122149
}
123150

@@ -127,7 +154,7 @@ func MapWithOptions(record interface{}, options *MapOptions) ([]string, []interf
127154
// return nil, nil, err
128155
// }
129156
v := value
130-
if isZero && tagOmitEmpty {
157+
if skip {
131158
v = sqlDefault
132159
}
133160
fv.values = append(fv.values, v)

mapper_test.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package pgkit_test
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
sq "github.com/Masterminds/squirrel"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/goware/pgkit/v2"
12+
)
13+
14+
// mapFields runs Map and returns a column->value lookup for easier asserts.
15+
func mapFields(t *testing.T, record any) map[string]any {
16+
t.Helper()
17+
cols, vals, err := pgkit.Map(record)
18+
require.NoError(t, err)
19+
require.Equal(t, len(cols), len(vals))
20+
out := make(map[string]any, len(cols))
21+
for i, c := range cols {
22+
out[c] = vals[i]
23+
}
24+
return out
25+
}
26+
27+
func TestMap_OmitEmpty(t *testing.T) {
28+
type Record struct {
29+
Bare string `db:"bare"`
30+
Str string `db:"str,omitempty"`
31+
Slice []string `db:"slice,omitempty"`
32+
PtrStr *string `db:"ptr_str,omitempty"`
33+
PtrSet *string `db:"ptr_set,omitempty"`
34+
Number int `db:"number,omitempty"`
35+
Filled []string `db:"filled,omitempty"`
36+
Boolean bool `db:"boolean,omitempty"`
37+
}
38+
39+
set := "value"
40+
r := &Record{PtrSet: &set, Filled: []string{"a"}}
41+
42+
got := mapFields(t, r)
43+
44+
assert.Contains(t, got, "bare", "bare column always present")
45+
assert.NotContains(t, got, "str", "zero string skipped")
46+
assert.NotContains(t, got, "slice", "nil slice skipped")
47+
assert.NotContains(t, got, "ptr_str", "nil pointer skipped")
48+
assert.NotContains(t, got, "number", "zero int skipped")
49+
assert.NotContains(t, got, "boolean", "false bool skipped")
50+
assert.Contains(t, got, "ptr_set", "set pointer included")
51+
assert.Equal(t, "value", *(got["ptr_set"].(*string)))
52+
assert.Contains(t, got, "filled", "populated slice included")
53+
}
54+
55+
func TestMap_OmitEmpty_EmptyNonNilSlice(t *testing.T) {
56+
// Regression guard: omitempty skips both nil and len-0 slices.
57+
type Record struct {
58+
Slice []string `db:"slice,omitempty"`
59+
}
60+
got := mapFields(t, &Record{Slice: []string{}})
61+
assert.NotContains(t, got, "slice", "omitempty drops empty-but-non-nil slice")
62+
}
63+
64+
func TestMap_OmitZero_NilSlice(t *testing.T) {
65+
// nil slice is the zero value of a slice type: omitzero skips the column
66+
// so the DB DEFAULT applies on INSERT and the column is left untouched on
67+
// UPDATE.
68+
type Record struct {
69+
Slice []string `db:"slice,omitzero"`
70+
}
71+
got := mapFields(t, &Record{Slice: nil})
72+
assert.NotContains(t, got, "slice", "omitzero drops nil slice")
73+
}
74+
75+
func TestMap_OmitZero_EmptyNonNilSlice(t *testing.T) {
76+
// The behavioral split versus omitempty: a non-nil empty slice is NOT
77+
// the zero value, so omitzero includes the column. This is what lets a
78+
// PATCH-style Update clear a NOT NULL DEFAULT '{}' array column without
79+
// the silent no-op trap omitempty has.
80+
type Record struct {
81+
Slice []string `db:"slice,omitzero"`
82+
}
83+
got := mapFields(t, &Record{Slice: []string{}})
84+
require.Contains(t, got, "slice", "omitzero keeps empty-but-non-nil slice")
85+
v, ok := got["slice"].([]string)
86+
require.True(t, ok, "value type preserved")
87+
assert.Len(t, v, 0)
88+
}
89+
90+
func TestMap_OmitZero_PopulatedSlice(t *testing.T) {
91+
type Record struct {
92+
Slice []string `db:"slice,omitzero"`
93+
}
94+
got := mapFields(t, &Record{Slice: []string{"a", "b"}})
95+
require.Contains(t, got, "slice")
96+
assert.Equal(t, []string{"a", "b"}, got["slice"])
97+
}
98+
99+
func TestMap_OmitZero_PrimitiveZeros(t *testing.T) {
100+
// Primitive zero values follow the same skip rule as omitempty.
101+
type Record struct {
102+
Str string `db:"str,omitzero"`
103+
Number int `db:"number,omitzero"`
104+
Boolean bool `db:"boolean,omitzero"`
105+
PtrStr *string `db:"ptr_str,omitzero"`
106+
}
107+
got := mapFields(t, &Record{})
108+
assert.NotContains(t, got, "str")
109+
assert.NotContains(t, got, "number")
110+
assert.NotContains(t, got, "boolean")
111+
assert.NotContains(t, got, "ptr_str")
112+
}
113+
114+
func TestMap_OmitZero_TimeIsZero(t *testing.T) {
115+
// time.Time implements IsZero(); omitzero honors the interface like
116+
// omitempty does, so a zero-valued time is skipped.
117+
type Record struct {
118+
At time.Time `db:"at,omitzero"`
119+
}
120+
got := mapFields(t, &Record{})
121+
assert.NotContains(t, got, "at", "zero time skipped via IsZero")
122+
123+
got = mapFields(t, &Record{At: time.Unix(1, 0)})
124+
assert.Contains(t, got, "at", "non-zero time included")
125+
}
126+
127+
func TestMap_OmitZero_NilMap(t *testing.T) {
128+
type Record struct {
129+
Tags map[string]string `db:"tags,omitzero"`
130+
}
131+
got := mapFields(t, &Record{})
132+
assert.NotContains(t, got, "tags", "nil map skipped")
133+
134+
got = mapFields(t, &Record{Tags: map[string]string{}})
135+
assert.Contains(t, got, "tags", "empty-but-non-nil map included")
136+
}
137+
138+
func TestMapWithOptions_OmitZero_IncludeZeroed(t *testing.T) {
139+
// IncludeZeroed surfaces the skipped column with a SQL DEFAULT marker
140+
// instead of dropping it; same contract as omitempty.
141+
type Record struct {
142+
Slice []string `db:"slice,omitzero"`
143+
}
144+
cols, vals, err := pgkit.MapWithOptions(&Record{Slice: nil}, &pgkit.MapOptions{IncludeZeroed: true})
145+
require.NoError(t, err)
146+
require.Len(t, cols, 1)
147+
require.Equal(t, "slice", cols[0])
148+
assert.Equal(t, sq.Expr("DEFAULT"), vals[0])
149+
}
150+
151+
func TestMapWithOptions_OmitZero_IncludeNil_Pointer(t *testing.T) {
152+
// IncludeNil surfaces a nil pointer with a DEFAULT marker.
153+
type Record struct {
154+
Name *string `db:"name,omitzero"`
155+
}
156+
cols, vals, err := pgkit.MapWithOptions(&Record{}, &pgkit.MapOptions{IncludeNil: true})
157+
require.NoError(t, err)
158+
require.Len(t, cols, 1)
159+
require.Equal(t, "name", cols[0])
160+
assert.Equal(t, sq.Expr("DEFAULT"), vals[0])
161+
}

0 commit comments

Comments
 (0)