Skip to content

Commit 80b0965

Browse files
committed
feat(mapper): reject ,omitempty + ,omitzero on the same field
OR-composing the skip rule made the winner type-dependent (slices got omitempty, all-zero fixed-size arrays got omitzero), silently reversing the array preservation in 1550bde. No coherent precedence to defend, so fail fast at Map time instead of shipping a footgun. Caught by the third adversarial Codex pass.
1 parent 587447b commit 80b0965

2 files changed

Lines changed: 15 additions & 9 deletions

File tree

mapper.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ type MapOptions struct {
3636

3737
// Map converts a struct to (column, value) slices using `db:""` struct tags.
3838
//
39-
// Both ,omitempty and ,omitzero skip zero values, but ,omitzero keeps non-nil
40-
// empty slices/maps so a clear-to-empty UPDATE actually clears the column.
41-
// Matches encoding/json's omitzero (Go 1.24+).
39+
// ,omitempty and ,omitzero (mutually exclusive) both skip zero values, but
40+
// ,omitzero keeps non-nil empty slices/maps so a clear-to-empty UPDATE
41+
// actually clears the column. Matches encoding/json's omitzero (Go 1.24+).
4242
func Map(record interface{}) ([]string, []interface{}, error) {
4343
return MapWithOptions(record, nil)
4444
}
@@ -86,6 +86,9 @@ func MapWithOptions(record interface{}, options *MapOptions) ([]string, []interf
8686
// Field options
8787
_, tagOmitEmpty := fi.Options["omitempty"]
8888
_, tagOmitZero := fi.Options["omitzero"]
89+
if tagOmitEmpty && tagOmitZero {
90+
return nil, nil, fmt.Errorf("field %q has both ,omitempty and ,omitzero tags (mutually exclusive)", fi.Name)
91+
}
8992

9093
fld := reflectx.FieldByIndexesReadOnly(recordV, fi.Index)
9194

mapper_test.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -219,15 +219,18 @@ func TestMap_OmitZero_StructWithoutIsZero(t *testing.T) {
219219
assert.Contains(t, got, "inner", "non-zero struct kept")
220220
}
221221

222-
func TestMap_BothTags_OmitEmptyWins(t *testing.T) {
223-
// A field tagged with both options gets the broader omitempty skip
224-
// (zero-length non-nil slice is dropped) because (isEmpty && omitempty)
225-
// fires when (isStrictZero && omitzero) wouldn't.
222+
func TestMap_BothTags_Rejected(t *testing.T) {
223+
// OR-composing the skip rule made the winner type-dependent (slices
224+
// got omitempty, all-zero arrays got omitzero), silently reversing
225+
// the array preservation we explicitly fixed earlier. No coherent
226+
// precedence, so reject the combination.
226227
type Record struct {
227228
Slice []string `db:"slice,omitempty,omitzero"`
228229
}
229-
got := mapFields(t, &Record{Slice: []string{}})
230-
assert.NotContains(t, got, "slice", "omitempty wins on non-nil empty slice")
230+
_, _, err := pgkit.Map(&Record{})
231+
require.Error(t, err)
232+
assert.Contains(t, err.Error(), "slice")
233+
assert.Contains(t, err.Error(), "mutually exclusive")
231234
}
232235

233236
func TestMap_MapRecord_Unchanged(t *testing.T) {

0 commit comments

Comments
 (0)