mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-21 18:39:30 +00:00
types: add NullTime for nullable timestamps
This commit is contained in:
parent
ea8fc72570
commit
733bbae0a5
2 changed files with 377 additions and 0 deletions
147
hscontrol/types/nulltime.go
Normal file
147
hscontrol/types/nulltime.go
Normal 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) }
|
||||
230
hscontrol/types/nulltime_test.go
Normal file
230
hscontrol/types/nulltime_test.go
Normal 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{}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue