This commit is contained in:
Shourya Gautam 2026-07-04 13:10:36 -04:00 committed by GitHub
commit 110cf35fe2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 377 additions and 0 deletions

147
hscontrol/types/nulltime.go Normal file
View file

@ -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) }

View file

@ -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{}
}