From 733bbae0a5fade604fbdaa3cec638b2a754aa5ce Mon Sep 17 00:00:00 2001 From: Shourya Gautam Date: Fri, 29 May 2026 20:42:52 +0530 Subject: [PATCH] types: add NullTime for nullable timestamps --- hscontrol/types/nulltime.go | 147 ++++++++++++++++++++ hscontrol/types/nulltime_test.go | 230 +++++++++++++++++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 hscontrol/types/nulltime.go create mode 100644 hscontrol/types/nulltime_test.go diff --git a/hscontrol/types/nulltime.go b/hscontrol/types/nulltime.go new file mode 100644 index 000000000..93170ec04 --- /dev/null +++ b/hscontrol/types/nulltime.go @@ -0,0 +1,147 @@ +package types + +import ( + "bytes" + "database/sql" + "database/sql/driver" + "fmt" + "time" +) + +// NullTime is a [time.Time] that maps the zero value to SQL NULL on the +// database side and JSON null on the wire. Use it instead of [*time.Time] +// for nullable timestamp columns so a non-nil pointer to a zero time stops +// being representable. +// +// The bug class fixed here: GORM persists a non-nil [*time.Time] pointing +// at a zero [time.Time] as the literal timestamp '0001-01-01 00:00:00' +// rather than NULL, and the JSON encoder emits "0001-01-01T00:00:00Z" +// rather than null. Consumers that test for NULL or null then see the +// wrong state. See #3201 for the full history. +type NullTime time.Time + +// Value implements [driver.Valuer]. A zero NullTime returns (nil, nil), +// which GORM and database/sql translate to a SQL NULL. +func (t NullTime) Value() (driver.Value, error) { + if time.Time(t).IsZero() { + return nil, nil + } + + return time.Time(t), nil +} + +// scanTimeFormats are the timestamp string formats SQLite and Postgres +// drivers may surface via [sql.Scanner]. mattn/go-sqlite3 and pgx +// normally return [time.Time] directly, but GORM-with-string-scanning +// and older drivers can hand us strings — accept the common shapes +// rather than fail. +var scanTimeFormats = []string{ + "2006-01-02 15:04:05.999999999-07:00", + "2006-01-02 15:04:05-07:00", + "2006-01-02 15:04:05.999999999", + "2006-01-02 15:04:05", + "2006-01-02T15:04:05.999999999Z07:00", + time.RFC3339Nano, + time.RFC3339, +} + +// Scan implements [sql.Scanner]. SQL NULL, a nil driver value, and any +// year-1 timestamp (the legacy '0001-01-01' rows from pre-fix headscale +// versions) all collapse to a zero NullTime. +func (t *NullTime) Scan(v any) error { + if v == nil { + *t = NullTime{} + return nil + } + + switch src := v.(type) { + case time.Time: + if src.IsZero() { + *t = NullTime{} + return nil + } + + *t = NullTime(src) + + return nil + case []byte: + return t.Scan(string(src)) + case string: + for _, f := range scanTimeFormats { + if parsed, err := time.Parse(f, src); err == nil { + if parsed.IsZero() { + *t = NullTime{} + } else { + *t = NullTime(parsed) + } + + return nil + } + } + + return fmt.Errorf("scanning NullTime: unrecognised string format %q", src) + } + + var nt sql.NullTime + if err := nt.Scan(v); err != nil { + return fmt.Errorf("scanning NullTime: %w", err) + } + + if !nt.Valid || nt.Time.IsZero() { + *t = NullTime{} + return nil + } + + *t = NullTime(nt.Time) + + return nil +} + +// MarshalJSON implements [json.Marshaler]. A zero NullTime marshals as +// the JSON literal null. +func (t NullTime) MarshalJSON() ([]byte, error) { + if time.Time(t).IsZero() { + return []byte("null"), nil + } + + return time.Time(t).MarshalJSON() +} + +// UnmarshalJSON implements [json.Unmarshaler]. The JSON literal null and +// a year-1 RFC3339 timestamp ("0001-01-01T00:00:00Z") both collapse to a +// zero NullTime. The year-1 case keeps wire-compat with headplane builds +// or stored payloads written by pre-fix headscale. +func (t *NullTime) UnmarshalJSON(b []byte) error { + if bytes.Equal(b, []byte("null")) { + *t = NullTime{} + return nil + } + + var parsed time.Time + if err := parsed.UnmarshalJSON(b); err != nil { + return fmt.Errorf("unmarshaling NullTime: %w", err) + } + + if parsed.IsZero() { + *t = NullTime{} + return nil + } + + *t = NullTime(parsed) + + return nil +} + +// IsZero reports whether t represents the unset (NULL) state. +func (t NullTime) IsZero() bool { return time.Time(t).IsZero() } + +// Time returns the underlying [time.Time]. The zero NullTime returns the +// zero [time.Time], which is the documented sentinel for "no expiry" in +// upstream [tailcfg.Node.KeyExpiry] and friends. +func (t NullTime) Time() time.Time { return time.Time(t) } + +// NullTimeFrom constructs a NullTime from a [time.Time]. A zero input +// produces a zero NullTime (i.e. NullTimeFrom is the identity wrt. zero +// values); callers can use it instead of the bare conversion when intent +// matters at the call site. +func NullTimeFrom(t time.Time) NullTime { return NullTime(t) } diff --git a/hscontrol/types/nulltime_test.go b/hscontrol/types/nulltime_test.go new file mode 100644 index 000000000..a0104849b --- /dev/null +++ b/hscontrol/types/nulltime_test.go @@ -0,0 +1,230 @@ +package types + +import ( + "database/sql/driver" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNullTime_Value(t *testing.T) { + t.Run("zero returns nil for SQL NULL", func(t *testing.T) { + v, err := NullTime{}.Value() + require.NoError(t, err) + assert.Nil(t, v) + }) + + t.Run("non-zero returns underlying time.Time", func(t *testing.T) { + now := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC) + v, err := NullTime(now).Value() + require.NoError(t, err) + + gotTime, ok := v.(time.Time) + require.True(t, ok, "expected time.Time, got %T", v) + assert.True(t, gotTime.Equal(now)) + }) +} + +func TestNullTime_Scan(t *testing.T) { + tests := []struct { + name string + input any + wantZero bool + wantTime time.Time + }{ + { + name: "nil produces zero", + input: nil, + wantZero: true, + }, + { + name: "zero time.Time produces zero", + input: time.Time{}, + wantZero: true, + }, + { + name: "year-1 string (legacy SQLite row) produces zero", + input: "0001-01-01 00:00:00+00:00", + wantZero: true, + }, + { + name: "year-1 time.Time (legacy Postgres row) produces zero", + input: time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC), + wantZero: true, + }, + { + name: "valid time.Time round-trips", + input: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC), + wantZero: false, + wantTime: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC), + }, + { + name: "valid string round-trips", + input: "2026-05-29 12:00:00+00:00", + wantZero: false, + wantTime: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var nt NullTime + require.NoError(t, nt.Scan(tt.input)) + + if tt.wantZero { + assert.True(t, nt.IsZero(), "expected zero NullTime, got %v", nt.Time()) + return + } + + assert.False(t, nt.IsZero()) + assert.True(t, nt.Time().Equal(tt.wantTime), + "expected %v, got %v", tt.wantTime, nt.Time()) + }) + } +} + +func TestNullTime_MarshalJSON(t *testing.T) { + t.Run("zero marshals as null", func(t *testing.T) { + b, err := json.Marshal(NullTime{}) + require.NoError(t, err) + assert.Equal(t, "null", string(b)) + }) + + t.Run("non-zero marshals as RFC3339 string", func(t *testing.T) { + ts := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC) + b, err := json.Marshal(NullTime(ts)) + require.NoError(t, err) + assert.Equal(t, `"2026-05-29T12:00:00Z"`, string(b)) + }) + + t.Run("zero NullTime inside a struct marshals as null field", func(t *testing.T) { + // Mirrors the headplane consumer scenario: JSON-encoded Node + // with zero Expiry should expose "expiry":null, not the year-1 + // string. + s := struct { + Expiry NullTime `json:"expiry"` + }{} + b, err := json.Marshal(s) + require.NoError(t, err) + assert.JSONEq(t, `{"expiry":null}`, string(b)) + }) +} + +func TestNullTime_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + jsonIn string + wantZero bool + wantTime time.Time + }{ + { + name: "literal null produces zero", + jsonIn: "null", + wantZero: true, + }, + { + name: "legacy year-1 string produces zero (backward compat)", + jsonIn: `"0001-01-01T00:00:00Z"`, + wantZero: true, + }, + { + name: "valid RFC3339 round-trips", + jsonIn: `"2026-05-29T12:00:00Z"`, + wantZero: false, + wantTime: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var nt NullTime + require.NoError(t, json.Unmarshal([]byte(tt.jsonIn), &nt)) + + if tt.wantZero { + assert.True(t, nt.IsZero()) + return + } + + assert.False(t, nt.IsZero()) + assert.True(t, nt.Time().Equal(tt.wantTime)) + }) + } + + t.Run("malformed JSON returns error", func(t *testing.T) { + var nt NullTime + err := json.Unmarshal([]byte(`"not-a-time"`), &nt) + require.Error(t, err) + }) +} + +func TestNullTime_RoundTrip_DB(t *testing.T) { + // Value → Scan must preserve a non-zero time and collapse zero/NULL. + t.Run("non-zero", func(t *testing.T) { + original := NullTime(time.Date(2026, 5, 29, 12, 34, 56, 0, time.UTC)) + + v, err := original.Value() + require.NoError(t, err) + require.NotNil(t, v) + + var roundTripped NullTime + require.NoError(t, roundTripped.Scan(v)) + assert.True(t, roundTripped.Time().Equal(original.Time())) + }) + + t.Run("zero → nil → zero", func(t *testing.T) { + v, err := NullTime{}.Value() + require.NoError(t, err) + require.Nil(t, v) + + var roundTripped NullTime + require.NoError(t, roundTripped.Scan(v)) + assert.True(t, roundTripped.IsZero()) + }) +} + +func TestNullTime_RoundTrip_JSON(t *testing.T) { + t.Run("non-zero", func(t *testing.T) { + original := NullTime(time.Date(2026, 5, 29, 12, 34, 56, 0, time.UTC)) + + b, err := json.Marshal(original) + require.NoError(t, err) + + var roundTripped NullTime + require.NoError(t, json.Unmarshal(b, &roundTripped)) + assert.True(t, roundTripped.Time().Equal(original.Time())) + }) + + t.Run("zero round-trips through null", func(t *testing.T) { + b, err := json.Marshal(NullTime{}) + require.NoError(t, err) + assert.Equal(t, "null", string(b)) + + var roundTripped NullTime + require.NoError(t, json.Unmarshal(b, &roundTripped)) + assert.True(t, roundTripped.IsZero()) + }) +} + +func TestNullTime_Helpers(t *testing.T) { + t.Run("NullTimeFrom is the identity wrt. zero", func(t *testing.T) { + assert.True(t, NullTimeFrom(time.Time{}).IsZero()) + }) + + t.Run("NullTimeFrom preserves non-zero", func(t *testing.T) { + ts := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC) + assert.True(t, NullTimeFrom(ts).Time().Equal(ts)) + }) + + t.Run("Time on zero returns the zero time.Time", func(t *testing.T) { + assert.True(t, NullTime{}.Time().IsZero()) + }) +} + +// TestNullTime_DriverValuerInterface confirms NullTime satisfies the +// database/sql driver.Valuer contract at compile time. +func TestNullTime_DriverValuerInterface(t *testing.T) { + var _ driver.Valuer = NullTime{} +}