Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions common/types/timestamp.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
package types

import (
"encoding/json"
"errors"
"fmt"
"reflect"
"regexp"
Expand Down Expand Up @@ -258,6 +260,90 @@ func (t Timestamp) format(sb *strings.Builder) {
fmt.Fprintf(sb, `timestamp("%s")`, t.Time.UTC().Format(time.RFC3339Nano))
}

// ParseTimestamp attempts to parse a timestamp from various supported types and representations:
// - time.Time, Timestamp, *timestamppb.Timestamp
// - RFC 3339 and RFC 3339Nano formatted strings (e.g. "2023-01-01T00:00:00Z")
// - Unix epoch integers (int, int32, int64)
// - Unix epoch floating-point seconds (float32, float64)
// - json.Number
// - String representations of integers or floating-point epoch seconds
//
// If the parsed timestamp falls outside the supported range [minUnixTime, maxUnixTime], an error is returned.
func ParseTimestamp(val any) (time.Time, error) {
if val == nil {
return time.Time{}, errors.New("invalid timestamp: nil value")
}
switch v := val.(type) {
case time.Time:
return validateTimestampRange(v.UTC())
case Timestamp:
return validateTimestampRange(v.Time.UTC())
case *tpb.Timestamp:
if v == nil {
return time.Time{}, nil
}
return validateTimestampRange(v.AsTime().UTC())
case int:
return validateTimestampRange(time.Unix(int64(v), 0).UTC())
case int32:
return validateTimestampRange(time.Unix(int64(v), 0).UTC())
case int64:
return validateTimestampRange(time.Unix(v, 0).UTC())
case float32:
return unixTimeFromFloat(float64(v))
case float64:
return unixTimeFromFloat(v)
case json.Number:
if i, err := v.Int64(); err == nil {
return validateTimestampRange(time.Unix(i, 0).UTC())
}
if f, err := v.Float64(); err == nil {
return unixTimeFromFloat(f)
}
return ParseTimestamp(v.String())
case string:
s := strings.TrimSpace(v)
if s == "" {
return time.Time{}, errors.New("invalid RFC 3339 timestamp: ''")
}
if isStrictRFC3339(s) {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return time.Time{}, fmt.Errorf("invalid RFC 3339 timestamp %q", s)
}
return validateTimestampRange(t.UTC())
}
if i, err := strconv.ParseInt(s, 10, 64); err == nil {
return validateTimestampRange(time.Unix(i, 0).UTC())
}
if f, err := strconv.ParseFloat(s, 64); err == nil {
return unixTimeFromFloat(f)
}
return time.Time{}, fmt.Errorf("unsupported timestamp format: %q", s)
default:
return time.Time{}, fmt.Errorf("unsupported timestamp type: %T", val)
}
}

func unixTimeFromFloat(f float64) (time.Time, error) {
sec, err := doubleToInt64Checked(f)
if err != nil {
return time.Time{}, err
}
nsec := int64((f - float64(sec)) * 1e9)
return validateTimestampRange(time.Unix(sec, nsec).UTC())
}

func validateTimestampRange(t time.Time) (time.Time, error) {
if t.IsZero() {
return t, nil
}
if t.Unix() < minUnixTime || t.Unix() > maxUnixTime {
return time.Time{}, fmt.Errorf("timestamp overflow: %v", t)
}
return t, nil
}

var (
timestampValueType = reflect.TypeOf(&tpb.Timestamp{})

Expand Down
176 changes: 176 additions & 0 deletions common/types/timestamp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package types

import (
"encoding/json"
"errors"
"math"
"reflect"
Expand Down Expand Up @@ -551,3 +552,178 @@ func TestIsStrictRFC3339MatchesPattern(t *testing.T) {
}
}
}

func TestParseTimestamp(t *testing.T) {
now := time.Now().UTC()
epoch := int64(1700000000)
epochTime := time.Unix(epoch, 0).UTC()
epochFloatTime := time.Unix(epoch, 500000000).UTC()
var nilPbTs *tpb.Timestamp

tests := []struct {
name string
val any
want time.Time
wantErr bool
}{
{
name: "nil",
val: nil,
wantErr: true,
},
{
name: "empty string",
val: "",
wantErr: true,
},
{
name: "time.Time",
val: now,
want: now,
},
{
name: "Timestamp struct",
val: Timestamp{Time: now},
want: now,
},
{
name: "*tpb.Timestamp",
val: tpb.New(now),
want: now,
},
{
name: "nil *tpb.Timestamp",
val: nilPbTs,
want: time.Time{},
},
{
name: "int",
val: int(epoch),
want: epochTime,
},
{
name: "int32",
val: int32(epoch),
want: epochTime,
},
{
name: "int64",
val: int64(epoch),
want: epochTime,
},
{
name: "float64",
val: float64(1700000000.5),
want: epochFloatTime,
},
Comment thread
TristonianJones marked this conversation as resolved.
{
name: "float64 negative",
val: float64(-1700000000.5),
want: time.Unix(-1700000000, -500000000).UTC(),
},
{
name: "float64 MaxFloat64 overflow",
val: math.MaxFloat64,
wantErr: true,
},
{
name: "float64 NaN overflow",
val: math.NaN(),
wantErr: true,
},
{
name: "float64 Inf overflow",
val: math.Inf(1),
wantErr: true,
},
{
name: "float64 -Inf overflow",
val: math.Inf(-1),
wantErr: true,
},
{
name: "float32",
val: float32(1700000000.5),
want: epochTime,
},
{
name: "float32 negative",
val: float32(-1700000000.5),
want: time.Unix(-1700000000, 0).UTC(),
},
{
name: "json.Number int",
val: json.Number("1700000000"),
want: epochTime,
},
{
name: "json.Number float",
val: json.Number("1700000000.5"),
want: epochFloatTime,
},
{
name: "json.Number invalid",
val: json.Number("invalid"),
wantErr: true,
},
{
name: "string RFC3339",
val: "2026-08-10T12:00:00Z",
want: time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC),
},
{
name: "string RFC3339Nano",
val: "2026-08-10T12:00:00.500Z",
want: time.Date(2026, 8, 10, 12, 0, 0, 500000000, time.UTC),
},
{
name: "string RFC3339 invalid",
val: "2026-99-99T99:99:99Z",
wantErr: true,
},
{
name: "string epoch int",
val: "1700000000",
want: epochTime,
},
{
name: "string epoch float",
val: "1700000000.5",
want: epochFloatTime,
},
{
name: "string invalid",
val: "not-a-timestamp",
wantErr: true,
},
{
name: "unsupported map type",
val: map[string]any{},
wantErr: true,
},
{
name: "overflow",
val: int64(999999999999999),
wantErr: true,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ts, err := ParseTimestamp(tc.val)
if tc.wantErr {
if err == nil {
t.Errorf("ParseTimestamp(%v) succeeded, wanted error", tc.val)
}
return
}
if err != nil {
t.Errorf("ParseTimestamp(%v) unexpected error: %v", tc.val, err)
return
}
if !ts.Equal(tc.want) {
t.Errorf("ParseTimestamp(%v) = %v, wanted %v", tc.val, ts, tc.want)
}
})
}
}
Loading