Skip to content

feat(mapper): support ,omitzero tag option#50

Merged
klaidliadon merged 10 commits into
masterfrom
feat-omitzero-tag
Jun 2, 2026
Merged

feat(mapper): support ,omitzero tag option#50
klaidliadon merged 10 commits into
masterfrom
feat-omitzero-tag

Conversation

@klaidliadon
Copy link
Copy Markdown
Contributor

omitempty on a []string field paired with NOT NULL DEFAULT '{}' looks like a clean way to get an INSERT default, but it silently breaks the UPDATE path: pgkit treats an empty slice as zero and drops the column from SET, so a "clear my list" call no-ops and the row keeps its stale entries. The only workarounds today are a normalize helper that coerces nil → []string{} before Save (and dropping omitempty), or post-Update re-Get assertions in every test that touches the column.

Adding ,omitzero with the same semantics as encoding/json's Go 1.24+ option fixes this cleanly: only the type's true zero is skipped, so a non-nil empty slice/map stays in the UPDATE SET and the clear actually clears. nil still falls through to the column DEFAULT on INSERT.

field value bare omitempty omitzero
nil slice/map ✓ writes NULL ✗ skipped (DEFAULT) ✗ skipped (DEFAULT)
[]T{} / map{} ✗ skipped ✓ writes empty
populated
nil ptr ✓ writes NULL ✗ skipped (DEFAULT) ✗ skipped (DEFAULT)
"" / 0 / false ✗ skipped ✗ skipped
time.Time{} (IsZero) ✗ skipped ✗ skipped

omitempty behavior is untouched, so this is additive.


Test plan

  • make db-reset test-all against PostgreSQL 18.3 (devbox-postgres) — all 6 packages green (pgkit/v2, db, dbtype, internal/reflectx, pgerr, tests)
  • New regression matrix in mapper_test.go covers nil / empty / populated slices and maps, primitive zeros, time.IsZero, and the IncludeZeroed / IncludeNil knobs for both tag options
  • go vet ./... clean

Notes

  • Driving use case: in 0xPolygon/omsx#1211 a NOT NULL DEFAULT '{}' allowed_origins TEXT[] column needs both INSERT-default and clearable-via-UPDATE semantics. The current workaround there is a normalize helper plus dropping omitempty; once this lands and is released, that file collapses to a single ,omitzero tag.
  • Matches encoding/json's omitzero so callers don't have to learn pgkit-specific vocabulary.

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
The first cut of ,omitzero accidentally rewrote omitempty behavior
for two kinds it had no business touching:

- map fields: pre-omitzero, omitempty only skipped a nil map (DeepEqual
  nil-typed-nil vs non-nil-empty is false). A non-nil empty map stayed,
  so "clear my jsonb" on UPDATE actually cleared. The new generic
  slice+map branch made empty maps skip, silently breaking that.
- array fields: pre-omitzero, omitempty skipped an all-zero array via
  the DeepEqual fallback (think [16]byte UUIDs, [32]byte hashes). The
  new array branch checked Len()==0, which is only ever true for the
  degenerate [0]T, so all-zero arrays of any normal size started
  emitting where they used to skip.

Split slice, map and array into separate branches with the right legacy
rules; omitzero keeps its nil-only / IsZero()-true semantics on top.
The compose 'skip = (isEmpty if omitempty) or (isStrictZero if omitzero)'
falls out cleanly.

Tests:
- regression guards for the two breakages above
- omitzero on a struct without IsZero() (DeepEqual fallback)
- both tags on one field (omitempty wins, the broader rule)
- map-as-record path unchanged
- godoc tightened to 7 lines; drop em-dash

Addresses self-review and Codex review on pgkit#50.
@klaidliadon
Copy link
Copy Markdown
Contributor Author

Pushed 24721e6 addressing the empty-map regression Codex caught plus a sibling all-zero-array regression a self-review pass turned up.

Root cause for both: the first cut added kind-specific branches for reflect.Slice|Map and reflect.Array that didn't reproduce the pre-PR reflect.DeepEqual(fi.Zero, value) fallback semantics for those kinds. The slice path was actually right (legacy slice omitempty did conflate nil and empty via Len()==0), but maps and arrays both had legacy DeepEqual semantics:

  • Map: DeepEqual(typed-nil-map, non-nil-empty-map) == false so only nil maps were skipped pre-PR. My code lumped non-nil empty maps in with nil, breaking it.
  • Array: DeepEqual([N]byte{}, [N]byte{}) so all-zero arrays were skipped pre-PR. My Len() == 0 check only ever fires for [0]T, so all-zero [16]byte etc. started emitting where they used to skip.

Fix: split slice/map/array into separate branches with the right legacy rules. omitzero keeps its nil-only / IsZero()-true semantics on top. The compose skip = (isEmpty && omitempty) || (isStrictZero && omitzero) falls out cleanly.

} else if fld.Kind() == reflect.Slice {
    if fld.IsNil() {
        isEmpty, isStrictZero = true, true
    } else if fld.Len() == 0 {
        isEmpty = true
    }
} else if fld.Kind() == reflect.Map {
    if fld.IsNil() {
        isEmpty, isStrictZero = true, true
    }
} else if fld.Kind() == reflect.Array {
    if fld.IsZero() {
        isEmpty, isStrictZero = true, true
    }
}

Extra coverage from this round:

  • TestMap_OmitEmpty_EmptyNonNilMap — regression guard for the Codex finding
  • TestMap_OmitEmpty_AllZeroArray — regression guard for the array case
  • TestMap_OmitZero_StructWithoutIsZero — plain struct falls through to DeepEqual arm (was uncovered)
  • TestMap_BothTags_OmitEmptyWins,omitempty,omitzero together: broader rule wins
  • TestMap_MapRecord_Unchanged — map-as-record path independent of field-walking changes

15 subtests now. make db-reset test-all against PostgreSQL 18.3: all 6 packages green.

Also tightened the godoc to 7 lines (was 17) and dropped the em-dash.

Thanks for the catch.

Project style prefers switch when every branch falls through to the
same downstream code. The hasIsZero check stays as a pre-guard since
it overrides kind-specific handling (a slice type with its own IsZero
should use that, not the slice arm).
The previous fix-up rewrote the array branch to skip on fld.IsZero()
for both flags. That over-corrected: pre-omitzero, the original code's
else-if chain entered the Array|Slice arm and only set isZero on
Len()==0. Normal-length all-zero arrays ([16]byte UUIDs, [32]byte
hashes, ed25519 keys) were always emitted under omitempty.

Split the array branch so omitempty stays Len()==0 (matches legacy)
and omitzero gets the strict IsZero() rule (Go 1.24+ json semantic).

Tests:
- TestMap_OmitEmpty_AllZeroArray flipped to assert pre-PR behavior
  (all-zero [16]byte kept). Was previously asserting the regression.
- TestMap_OmitZero_AllZeroArray added for the strict-zero side.

Caught by an adversarial Codex pass on pgkit#50; the earlier
self-review traced the pre-PR else-if chain incorrectly and asked
for a "fix" that wasn't actually a regression.
Trim WHAT-padding and em-dashes per project style: exported godoc keeps
its 1-line WHAT, multi-line content carries WHY only. Test-helper and
test-comment edits follow the same rule.

No behavior changes; 16 subtests still green.
MapWithOptions was doing struct-walking, zero-classification, and skip
composition in one stretch. Pull the classification into a free
function so each case becomes a direct return instead of poking shared
mutable flags from nested branches. Call site shrinks to one line and
the asymmetric slice case (non-nil empty is empty but not strict-zero)
is explicit instead of inferred.

No behavior changes; 16 subtests still green, full pgkit test suite
green.
Copy link
Copy Markdown
Member

@david-littlefarmer david-littlefarmer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, just to note for description:
map{} under omitempty should be ✓ (writes empty), not ✗ skipped.

But TestMap_OmitEmpty_EmptyNonNilMap tests covers this.

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.
Previously the nil-pointer branch lumped ,omitzero and ,omitempty into
the same forced-include behavior: both emitted sqlDefault. That made
"clear a nullable column to NULL via PATCH" inexpressible through any
tagged field.

,omitempty keeps its legacy DEFAULT-on-IncludeNil behavior (would have
been a breaking change otherwise, even though the option currently has
no callers). ,omitzero is the strict tag and surfaces a forced-include
as literal NULL — matching the read "user asked to show the nil, show
the nil." Documented in the Map godoc.

Caught by the third adversarial Codex pass.
@klaidliadon
Copy link
Copy Markdown
Contributor Author

Pushed 80b0965 + 5a30a5f addressing both findings from the third adversarial pass.

Finding #2,omitempty + ,omitzero on the same field had type-dependent precedence

zeroFlags returns (isEmpty=false, isStrictZero=true) for an all-zero [16]byte, so OR-composing the skip rule meant the broader winner switched per type (slices: omitempty wins, arrays: omitzero wins) — and that silently reversed the array preservation in 1550bde. No coherent precedence to defend.

Fix: reject the combination at Map time with field %q has both ,omitempty and ,omitzero tags (mutually exclusive). TestMap_BothTags_OmitEmptyWins replaced with TestMap_BothTags_Rejected. Godoc gained "(mutually exclusive)".

Finding #1,omitzero + IncludeNil + nil pointer emitting DEFAULT instead of NULL

This one had a real design question behind it. My initial push-back was "preserve symmetry with ,omitempty's pre-PR DEFAULT-on-IncludeNil behavior." Then I checked: no callers of IncludeNil or IncludeZeroed anywhere in pgkit, omsx, or my workspace. The pre-existing semantic is unexercised API surface, so there was no observable contract to protect — only internal pgkit design consistency, which is the weaker argument.

With the symmetry-preservation argument weak, Codex's read wins: under ,omitzero (the strict tag), IncludeNil should mean "show the nil literally" (SQL NULL), not "fall back to the DB default." Lets a PATCH caller clear a nullable column with a non-null default — the use case Codex flagged is real.

Fix in the Ptr && IsNil() branch: only ,omitempty emits sqlDefault; ,omitzero (and bare-tag) emits literal nil. Test TestMapWithOptions_OmitZero_IncludeNil_Pointer flipped to assert assert.Nil(t, vals[0]). Godoc gained: "IncludeNil surfaces nil pointers as DEFAULT under ,omitempty and as NULL under ,omitzero."

Branch state (8 commits, additive)

  • bfd0b8b initial omitzero
  • 24721e6 empty-map regression fix
  • dbc97c6 switch refactor
  • 1550bde all-zero array regression fix
  • 7aa507f comment sanity pass
  • 587447b extract zeroFlags helper
  • 80b0965 reject ,omitempty + ,omitzero combination
  • 5a30a5f ,omitzero + IncludeNil emits NULL

16 subtests, full pgkit suite green, go vet clean.

InsertRecords froze the column list from row 0 and appended values for
later rows without re-checking shape. Fine when every row produces the
same column set, but ,omitzero (and pre-existing ,omitempty on map
fields) make per-row shape vary: nil slice/map gets skipped, non-nil
empty gets emitted. Mixed batches produced malformed multi-row SQL that
only surfaced as a Postgres parse error at exec time.

Track the row-0 column set, slices.Equal-check every subsequent row,
return a build-time error on drift. Tests cover both the new ,omitzero
path and the latent ,omitempty + map path.

Caught by the fourth adversarial Codex pass.
A record whose mapped fields all get skipped (every field tagged
,omitempty / ,omitzero with zero values) makes Map return no columns.
squirrel then emits INSERT INTO t VALUES () which Postgres rejects at
parse time. Fail fast at builder time and point at sq.Expr as the
escape for an all-default INSERT.

Native INSERT ... DEFAULT VALUES support is tracked in #51.

Caught by the fifth adversarial Codex pass.
@klaidliadon
Copy link
Copy Markdown
Contributor Author

Pushed f1e3f71 addressing the final adversarial finding.

Finding (round 5): if every field on a record is tagged ,omitempty / ,omitzero and all are zero, Map returns empty cols. InsertRecord / InsertRecords then unconditionally called .Columns(cols...).Values(vals...), which squirrel happily turned into INSERT INTO t VALUES () — invalid SQL caught only at exec time by Postgres.

Light fix here: reject empty-column records at builder time with a clear error pointing at sq.Expr as the escape hatch:

if len(cols) == 0 {
    return InsertBuilder{InsertBuilder: insert, err: wrapErr(fmt.Errorf(
        "Map returned no columns for %T; for an all-default INSERT use sq.Expr(\"INSERT INTO %s DEFAULT VALUES\")",
        record, tableName,
    ))}
}

Tests: TestInsertRecord_EmptyColumnsRejected + TestInsertRecords_EmptyColumnsRejected.

Native support deferred to goware/pgkit#51 — supporting INSERT ... DEFAULT VALUES cleanly requires an override path on pgkit's InsertBuilder.ToSql (so .Suffix("RETURNING ...") etc. still compose), which expands the API surface beyond this PR's scope.


Branch state (10 commits, ready for review):

Commit Subject
bfd0b8b feat(mapper): support ,omitzero tag option
24721e6 fix(mapper): preserve omitempty semantics for maps and arrays
dbc97c6 refactor(mapper): switch on Kind instead of chained else-if
1550bde fix(mapper): keep all-zero arrays under omitempty (legacy behavior)
7aa507f docs(mapper): comment sanity pass
587447b refactor(mapper): extract zeroFlags from MapWithOptions
80b0965 feat(mapper): reject ,omitempty + ,omitzero on the same field
5a30a5f feat(mapper): omitzero + IncludeNil emits NULL, not DEFAULT
db94885 fix(builder): reject column drift across batch InsertRecords rows
f1e3f71 fix(builder): reject empty-column records with sq.Expr hint

17 subtests covering the new mapper + builder behavior, full pgkit test suite green against PostgreSQL 18.3, go vet ./... clean. Five adversarial Codex passes — last one's only finding is now addressed (light fix here, native fix tracked in #51).

Ready for human review on the goware side.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants