Files
headscale/hscontrol/types/slices.go
Kristoffer Dalby 7a20db9f49 types: persist Node JSON slices via named IsZero types
Endpoints, Tags and ApprovedRoutes serialize as JSON on Node. GORM's
struct Updates path skips fields it considers zero, and reflect treats
a nil slice as zero — clearing any of these columns via the State
persist path would leave the previous value in the database.

Introduce Strings, Prefixes and AddrPorts as named slice types whose
IsZero() always reports false, so GORM keeps the column in the UPDATE
regardless of the slice being nil or empty. JSON marshalling is
unchanged: nil serializes to null, empty to []. List() returns the
underlying unnamed slice for callers (mainly testify assertions over
reflect.DeepEqual) that distinguish the named type from its base.

Regenerated types_clone.go and types_view.go follow the field-type
swap. Test assertions across hscontrol/{db,state,servertest} updated
to call .List() where reflect.DeepEqual previously matched the raw
slice type.

Fixes #3110
2026-05-15 11:21:58 +02:00

47 lines
1.9 KiB
Go

package types
import "net/netip"
// The named slice types below are used for GORM-persisted Node columns
// that serialise as JSON. GORM v2's struct-based Updates skips fields
// it considers zero — for unnamed slice types that is nil — and the
// default reflect.Value.IsZero treats a nil slice as zero. By giving
// each slice an IsZero() that always returns false, the column is
// always included in UPDATE statements regardless of whether the
// caller is clearing the field. JSON marshalling is unchanged: a nil
// value serialises to null and an empty value serialises to [].
//
// The .List() helpers return the underlying unnamed slice for the
// places (mainly testify assertions over reflect.DeepEqual) where the
// distinction between the named and unnamed type matters.
// Strings is a []string with a GORM-friendly IsZero.
type Strings []string
// IsZero implements GORM's zeroer interface to keep the column in the
// UPDATE set even when the slice is nil or empty.
func (Strings) IsZero() bool { return false }
// List returns the underlying []string.
func (s Strings) List() []string { return []string(s) }
// Prefixes is a []netip.Prefix with a GORM-friendly IsZero.
type Prefixes []netip.Prefix
// IsZero implements GORM's zeroer interface to keep the column in the
// UPDATE set even when the slice is nil or empty.
func (Prefixes) IsZero() bool { return false }
// List returns the underlying []netip.Prefix.
func (s Prefixes) List() []netip.Prefix { return []netip.Prefix(s) }
// AddrPorts is a []netip.AddrPort with a GORM-friendly IsZero.
type AddrPorts []netip.AddrPort
// IsZero implements GORM's zeroer interface to keep the column in the
// UPDATE set even when the slice is nil or empty.
func (AddrPorts) IsZero() bool { return false }
// List returns the underlying []netip.AddrPort.
func (s AddrPorts) List() []netip.AddrPort { return []netip.AddrPort(s) }