From a1284075aa71ba159f7f58382d8d10b75bd3e707 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Mon, 29 Jun 2026 18:01:08 -0700 Subject: [PATCH] fix: strict ClockTime parsing and ISODuration SQL Validate constructors, strict HH:MM:SS parse, UTC ClockTimeFromTime, ISODuration Value/Scan, Compare and TextMarshaler, codec.go split. Co-authored-by: Cursor --- .gitignore | 3 + README.md | 39 ++++++--- clocktime.go | 156 ++++++++++++---------------------- clocktime_test.go | 98 +++++++++++++++++++--- codec.go | 210 ++++++++++++++++++++++++++++++++++++++++++++++ doc.go | 14 ++++ duration.go | 76 +++-------------- 7 files changed, 409 insertions(+), 187 deletions(-) create mode 100644 .gitignore create mode 100644 codec.go create mode 100644 doc.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7970236 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +coverage.out +coverage.html +*.test diff --git a/README.md b/README.md index 040c5b9..2db5a86 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,36 @@ # clocktime -clocktime provides a type that holds a calendar-independent time of day value in 24-hour format. +Time-of-day and ISO 8601 duration types for APIs, GraphQL, and SQL. -clocktime provides basic types that extend Go's built-in time.Time to provide time-of-day and duration support. +## Install -## `clocktime.ClockTime +```bash +go get gitea.auvem.com/go-toolkit/clocktime +``` -Holds calendar-independent time-of-day in 24-hour format. +## ClockTime -- Converts to and from `HH:MM:SS` format (subset of ISO8061) -- Marshals to `[]byte` containing string `HH:MM:SS` for SQL -- Marshals to `string` in format `HH:MM:SS` for JSON & gqlgen +```go +ct, err := clocktime.ParseClockTime("09:30:00") +t := ct.Time() // 1970-01-01 UTC anchor +``` -## `clocktime.Duration` +- Strict `HH:MM:SS` parsing via [ParseClockTime] +- SQL `TIME` columns: stored as `[]byte` / string; scanned from `time.Time`, `[]byte`, or `string` +- Optional fields: use `*ClockTime`; `IsZero()` is true only for nil pointers +- Midnight `00:00:00` is valid; use `IsMidnight()` to detect it -Wraps time.Duration with prioritized ISO8061 support and opinionated marshalling. +## ISODuration -- Converts to and from ISO8061 format (utilises [sosodev/duration](https://github.com/sosodev/duration)) -- Marshals to `uint64` with nanosecond precision. Largest representatable duration is about 290 years, limited by underlying time.Duration type. -- Marshals to `string` in ISO8061 format for JSON & gqlgen +```go +d, err := clocktime.ParseISODuration("PT1H30M") +ns := d.Duration() +``` + +- JSON/GQL: ISO 8601 strings +- SQL: `int64` nanoseconds (strings accepted on scan) +- Calendar durations (`P1M`, `P1Y`) convert to approximate nanoseconds and may not round-trip + +## gqlgen + +Both types implement `MarshalGQL` / `UnmarshalGQL`. diff --git a/clocktime.go b/clocktime.go index f3893f7..96fb2e5 100644 --- a/clocktime.go +++ b/clocktime.go @@ -1,70 +1,94 @@ package clocktime import ( - "database/sql/driver" - "encoding/json" "fmt" - "io" "time" ) -// ClockTime represents a time of day without a date component in 24-hour format. -// It is used to represent times in a way that is independent of any specific date. +// ClockTime represents a time of day without a date component (24-hour HH:MM:SS). type ClockTime struct { Hour int `json:"hour"` Minute int `json:"minute"` Second int `json:"second"` } -// NewClockTime creates a new ClockTime instance. -func NewClockTime(hour, minute, second int) ClockTime { - return ClockTime{ - Hour: hour, - Minute: minute, - Second: second, +// NewClockTime creates a ClockTime after validating component ranges. +func NewClockTime(hour, minute, second int) (ClockTime, error) { + t := ClockTime{Hour: hour, Minute: minute, Second: second} + if err := t.validate(); err != nil { + return ClockTime{}, err } + return t, nil } -// ClockTimeFromString parses a ClockTime from a string in the format "HH:MM:SS" (Subset of RFC 8601). +// ParseClockTime parses strict HH:MM:SS (RFC 3339 time-of-day subset). +func ParseClockTime(s string) (ClockTime, error) { + return ClockTimeFromString(s) +} + +// ClockTimeFromString parses a ClockTime from HH:MM:SS. func ClockTimeFromString(s string) (ClockTime, error) { - var hour, minute, second int - n, err := fmt.Sscanf(s, "%d:%d:%d", &hour, &minute, &second) - if err != nil || n != 3 { + if s == "" { + return ClockTime{}, fmt.Errorf("invalid time format: empty string") + } + parsed, err := time.Parse("15:04:05", s) + if err != nil { return ClockTime{}, fmt.Errorf("invalid time format: %s", s) } - if hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59 { - return ClockTime{}, fmt.Errorf("time out of range: %s", s) - } - return NewClockTime(hour, minute, second), nil + return ClockTime{Hour: parsed.Hour(), Minute: parsed.Minute(), Second: parsed.Second()}, nil } -// ClockTimeFromTime converts a time.Time to a ClockTime. +// ClockTimeFromTime extracts the UTC time-of-day from t (sub-second precision truncated). func ClockTimeFromTime(t time.Time) ClockTime { - return NewClockTime(t.Hour(), t.Minute(), t.Second()) + u := t.UTC() + return ClockTime{Hour: u.Hour(), Minute: u.Minute(), Second: u.Second()} } -// String returns the string representation of the ClockTime in "HH:MM:SS" format. +func (t ClockTime) validate() error { + if t.Hour < 0 || t.Hour > 23 || t.Minute < 0 || t.Minute > 59 || t.Second < 0 || t.Second > 59 { + return fmt.Errorf("time out of range: %s", t.String()) + } + return nil +} + +// String returns HH:MM:SS with zero padding. func (t ClockTime) String() string { return fmt.Sprintf("%02d:%02d:%02d", t.Hour, t.Minute, t.Second) } -// Time returns a time.Time representation of the ClockTime. -// It uses a fixed date (January 1, 1970) to create a time.Time object. +// Time returns a UTC time.Time anchored on 1970-01-01. func (t ClockTime) Time() time.Time { return time.Date(1970, 1, 1, t.Hour, t.Minute, t.Second, 0, time.UTC) } -// IsZero checks if the ClockTime is zero (00:00:00) or nil. -func (t *ClockTime) IsZero() bool { - return t == nil || (t.Hour == 0 && t.Minute == 0 && t.Second == 0) +// IsMidnight reports whether the time is exactly 00:00:00. +func (t ClockTime) IsMidnight() bool { + return t.Hour == 0 && t.Minute == 0 && t.Second == 0 } -// Equal checks if two ClockTime instances are equal. +// IsZero reports whether the receiver pointer is nil. For optional fields use *ClockTime; +// midnight 00:00:00 is a valid value and is not considered zero. +func (t *ClockTime) IsZero() bool { + return t == nil +} + +// Equal reports whether two times are the same. func (t ClockTime) Equal(other ClockTime) bool { return t.Hour == other.Hour && t.Minute == other.Minute && t.Second == other.Second } -// Before checks if the ClockTime is before another ClockTime. +// Compare returns -1, 0, or 1 comparing t to other. +func (t ClockTime) Compare(other ClockTime) int { + if t.Before(other) { + return -1 + } + if t.After(other) { + return 1 + } + return 0 +} + +// Before reports whether t is before other. func (t ClockTime) Before(other ClockTime) bool { if t.Hour != other.Hour { return t.Hour < other.Hour @@ -75,7 +99,7 @@ func (t ClockTime) Before(other ClockTime) bool { return t.Second < other.Second } -// After checks if the ClockTime is after another ClockTime. +// After reports whether t is after other. func (t ClockTime) After(other ClockTime) bool { if t.Hour != other.Hour { return t.Hour > other.Hour @@ -85,75 +109,3 @@ func (t ClockTime) After(other ClockTime) bool { } return t.Second > other.Second } - -// MarshalJSON implements the json.Marshaler interface for ClockTime. -func (t ClockTime) MarshalJSON() ([]byte, error) { - return json.Marshal("\"" + t.String() + "\"") -} - -// UnmarshalJSON implements the json.Unmarshaler interface for ClockTime. -func (t *ClockTime) UnmarshalJSON(data []byte) error { - var timeString string - if err := json.Unmarshal(data, &timeString); err != nil { - return err - } - parsedTime, err := ClockTimeFromString(timeString) - if err != nil { - return err - } - *t = parsedTime - return nil -} - -// MarshalGQL implements the graphql.Marshaler interface for ClockTime. -func (t ClockTime) MarshalGQL(w io.Writer) { - fmt.Fprint(w, "\""+t.String()+"\"") -} - -// UnmarshalGQL implements the graphql.Unmarshaler interface for ClockTime. -func (t *ClockTime) UnmarshalGQL(value any) error { - if value == nil { - *t = ClockTime{} - return nil - } - str, ok := value.(string) - if !ok { - return fmt.Errorf("ClockTime must be a string, got %T", value) - } - parsedTime, err := ClockTimeFromString(str) - if err != nil { - return err - } - *t = parsedTime - return nil -} - -// Value implements the database/sql/driver.Valuer interface for ClockTime. -// Marshals the ClockTime to a byte slice for database storage. -func (t ClockTime) Value() (driver.Value, error) { - return []byte(t.String()), nil -} - -// Scan implements the database/sql.Scanner interface for ClockTime. -// Supports scanning from time.Time or []byte. -func (t *ClockTime) Scan(value any) error { - if value == nil { - *t = ClockTime{} - return nil - } - - switch v := value.(type) { - case time.Time: - *t = ClockTimeFromTime(v) - case []byte: - parsedTime, err := ClockTimeFromString(string(v)) - if err != nil { - return fmt.Errorf("failed to parse ClockTime from string: %w", err) - } - *t = parsedTime - default: - return fmt.Errorf("ClockTime.Scan: unsupported type %T", value) - } - - return nil -} diff --git a/clocktime_test.go b/clocktime_test.go index 693c1d6..23f22ba 100644 --- a/clocktime_test.go +++ b/clocktime_test.go @@ -1,19 +1,97 @@ -package clocktime +package clocktime_test import ( + "encoding/json" "testing" + "time" + "gitea.auvem.com/go-toolkit/clocktime" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func Test_Comparison(t *testing.T) { - assert := assert.New(t) - time1 := NewClockTime(10, 30, 0) - time2 := NewClockTime(11, 0, 0) - time3 := NewClockTime(10, 30, 0) +func TestComparison(t *testing.T) { + a, err := clocktime.NewClockTime(12, 30, 0) + require.NoError(t, err) + b, err := clocktime.NewClockTime(13, 0, 0) + require.NoError(t, err) - assert.True(time1.Before(time2)) - assert.False(time1.After(time2)) - assert.False(time1.Equal(time2)) - assert.True(time1.Equal(time3)) + assert.True(t, a.Before(b)) + assert.True(t, b.After(a)) + assert.False(t, a.Equal(b)) + assert.Equal(t, -1, a.Compare(b)) +} + +func TestParseClockTimeStrict(t *testing.T) { + _, err := clocktime.ParseClockTime("1:2:3") + assert.Error(t, err) + _, err = clocktime.ParseClockTime("12:30:00x") + assert.Error(t, err) + + got, err := clocktime.ParseClockTime("12:30:00") + require.NoError(t, err) + assert.Equal(t, 12, got.Hour) +} + +func TestNewClockTimeValidation(t *testing.T) { + _, err := clocktime.NewClockTime(25, 0, 0) + assert.Error(t, err) +} + +func TestIsZeroVsMidnight(t *testing.T) { + var nilPtr *clocktime.ClockTime + assert.True(t, nilPtr.IsZero()) + + midnight, err := clocktime.NewClockTime(0, 0, 0) + require.NoError(t, err) + assert.False(t, midnight.IsZero()) + assert.True(t, midnight.IsMidnight()) +} + +func TestClockTimeFromTimeUTC(t *testing.T) { + loc, err := time.LoadLocation("America/New_York") + require.NoError(t, err) + tNY := time.Date(2024, 6, 1, 15, 4, 5, 0, loc) + got := clocktime.ClockTimeFromTime(tNY) + assert.Equal(t, "19:04:05", got.String()) +} + +func TestClockTimeJSON(t *testing.T) { + ct, err := clocktime.NewClockTime(9, 5, 7) + require.NoError(t, err) + data, err := json.Marshal(ct) + require.NoError(t, err) + assert.JSONEq(t, `"09:05:07"`, string(data)) + + var decoded clocktime.ClockTime + require.NoError(t, json.Unmarshal([]byte(`null`), &decoded)) + assert.True(t, decoded.IsMidnight()) +} + +func TestISODurationSQL(t *testing.T) { + d := clocktime.NewISODuration(1, 30, 0) + val, err := d.Value() + require.NoError(t, err) + assert.Equal(t, int64((90*time.Minute).Nanoseconds()), val) + + var scanned clocktime.ISODuration + require.NoError(t, scanned.Scan(val)) + assert.Equal(t, d.Duration(), scanned.Duration()) +} + +func TestISODurationJSON(t *testing.T) { + d := clocktime.NewISODuration(0, 45, 0) + data, err := json.Marshal(d) + require.NoError(t, err) + assert.Contains(t, string(data), "PT") + + var back clocktime.ISODuration + require.NoError(t, json.Unmarshal(data, &back)) + assert.Equal(t, 45*time.Minute, back.Duration()) +} + +func TestClockTimeScanString(t *testing.T) { + var ct clocktime.ClockTime + require.NoError(t, ct.Scan("08:15:30")) + assert.Equal(t, "08:15:30", ct.String()) } diff --git a/codec.go b/codec.go new file mode 100644 index 0000000..13c01d6 --- /dev/null +++ b/codec.go @@ -0,0 +1,210 @@ +package clocktime + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "io" + "time" +) + +func marshalJSONString(s string) ([]byte, error) { + return json.Marshal(s) +} + +func unmarshalJSONString(data []byte) (string, error) { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return "", err + } + return s, nil +} + +// MarshalJSON encodes ClockTime as a JSON string HH:MM:SS. +func (t ClockTime) MarshalJSON() ([]byte, error) { + return marshalJSONString(t.String()) +} + +// UnmarshalJSON decodes a JSON string into ClockTime. +func (t *ClockTime) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *t = ClockTime{} + return nil + } + s, err := unmarshalJSONString(data) + if err != nil { + return err + } + parsed, err := ParseClockTime(s) + if err != nil { + return err + } + *t = parsed + return nil +} + +// MarshalText implements encoding.TextMarshaler. +func (t ClockTime) MarshalText() ([]byte, error) { + return []byte(t.String()), nil +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (t *ClockTime) UnmarshalText(text []byte) error { + parsed, err := ParseClockTime(string(text)) + if err != nil { + return err + } + *t = parsed + return nil +} + +// MarshalGQL implements gqlgen's Marshaler interface. +func (t ClockTime) MarshalGQL(w io.Writer) { + fmt.Fprint(w, "\""+t.String()+"\"") +} + +// UnmarshalGQL implements gqlgen's Unmarshaler interface. +func (t *ClockTime) UnmarshalGQL(value any) error { + if value == nil { + *t = ClockTime{} + return nil + } + str, ok := value.(string) + if !ok { + return fmt.Errorf("ClockTime must be a string, got %T", value) + } + parsed, err := ParseClockTime(str) + if err != nil { + return err + } + *t = parsed + return nil +} + +// Value implements driver.Valuer for SQL TIME columns as HH:MM:SS bytes. +func (t ClockTime) Value() (driver.Value, error) { + return []byte(t.String()), nil +} + +// Scan implements sql.Scanner for time.Time, []byte, and string. +func (t *ClockTime) Scan(value any) error { + if value == nil { + *t = ClockTime{} + return nil + } + + switch v := value.(type) { + case time.Time: + *t = ClockTimeFromTime(v) + case []byte: + parsed, err := ParseClockTime(string(v)) + if err != nil { + return fmt.Errorf("failed to parse ClockTime: %w", err) + } + *t = parsed + case string: + parsed, err := ParseClockTime(v) + if err != nil { + return fmt.Errorf("failed to parse ClockTime: %w", err) + } + *t = parsed + default: + return fmt.Errorf("ClockTime.Scan: unsupported type %T", value) + } + + return nil +} + +// MarshalJSON encodes ISODuration as a JSON ISO 8601 string. +func (d ISODuration) MarshalJSON() ([]byte, error) { + return marshalJSONString(d.String()) +} + +// UnmarshalJSON decodes a JSON ISO 8601 duration string. +func (d *ISODuration) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *d = ISODuration(0) + return nil + } + s, err := unmarshalJSONString(data) + if err != nil { + return err + } + parsed, err := ParseISODuration(s) + if err != nil { + return err + } + *d = parsed + return nil +} + +// MarshalText implements encoding.TextMarshaler. +func (d ISODuration) MarshalText() ([]byte, error) { + return []byte(d.String()), nil +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (d *ISODuration) UnmarshalText(text []byte) error { + parsed, err := ParseISODuration(string(text)) + if err != nil { + return err + } + *d = parsed + return nil +} + +// MarshalGQL implements gqlgen's Marshaler interface. +func (d ISODuration) MarshalGQL(w io.Writer) { + fmt.Fprint(w, "\""+d.String()+"\"") +} + +// UnmarshalGQL implements gqlgen's Unmarshaler interface. +func (d *ISODuration) UnmarshalGQL(value any) error { + if value == nil { + *d = ISODuration(0) + return nil + } + s, ok := value.(string) + if !ok { + return fmt.Errorf("ISODuration must be a string, got %T", value) + } + parsed, err := ParseISODuration(s) + if err != nil { + return err + } + *d = parsed + return nil +} + +// Value stores ISODuration as nanoseconds in the database. +func (d ISODuration) Value() (driver.Value, error) { + return int64(d), nil +} + +// Scan reads ISODuration from int64 nanoseconds or an ISO 8601 string. +func (d *ISODuration) Scan(value any) error { + if value == nil { + *d = ISODuration(0) + return nil + } + + switch v := value.(type) { + case int64: + *d = ISODuration(v) + case []byte: + parsed, err := ParseISODuration(string(v)) + if err != nil { + return err + } + *d = parsed + case string: + parsed, err := ParseISODuration(v) + if err != nil { + return err + } + *d = parsed + default: + return fmt.Errorf("ISODuration.Scan: unsupported type %T", value) + } + return nil +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..3cdbed3 --- /dev/null +++ b/doc.go @@ -0,0 +1,14 @@ +// Package clocktime provides ClockTime (time-of-day) and ISODuration types with +// JSON, GraphQL, SQL, and text serialization. +// +// # ClockTime +// +// [ClockTime] stores HH:MM:SS without a date. Use [ParseClockTime] for strict parsing. +// Optional fields should use *ClockTime; [ClockTime.IsZero] is true only for nil pointers. +// Midnight 00:00:00 is valid—use [ClockTime.IsMidnight] to test for midnight. +// +// # ISODuration +// +// [ISODuration] wraps [time.Duration] with ISO 8601 formatting via sosodev/duration. +// SQL storage uses int64 nanoseconds; strings are also accepted on scan. +package clocktime diff --git a/duration.go b/duration.go index 677e80a..13e191c 100644 --- a/duration.go +++ b/duration.go @@ -1,20 +1,16 @@ package clocktime import ( - "encoding/json" "fmt" - "io" "time" "github.com/sosodev/duration" ) -// ISODuration wraps a time.Duration to provide custom JSON and SQL -// serialization and full ISO8061 compatibility. Note: ISODuration is limited -// to the same range as time.Duration despite ISO8061 supporting larger durations. +// ISODuration wraps time.Duration with ISO 8601 string formatting. type ISODuration time.Duration -// NewISODuration creates a new duration from numeric components. +// NewISODuration creates a duration from hour, minute, and second components. func NewISODuration(hours, minutes, seconds int) ISODuration { return ISODuration( time.Hour*time.Duration(hours) + @@ -23,77 +19,31 @@ func NewISODuration(hours, minutes, seconds int) ISODuration { ) } -// DurationFromISOString parses an ISO8601 duration string (e.g., "PT1H30M45S") -// and returns an ISODuration. +// ParseISODuration parses an ISO 8601 duration string (for example PT1H30M). +func ParseISODuration(s string) (ISODuration, error) { + return DurationFromISOString(s) +} + +// DurationFromISOString parses an ISO 8601 duration string. func DurationFromISOString(s string) (ISODuration, error) { d, err := duration.Parse(s) if err != nil { - return 0, fmt.Errorf("error parsing ISO8061 duration: %w", err) + return 0, fmt.Errorf("error parsing ISO 8601 duration: %w", err) } return ISODuration(d.ToTimeDuration()), nil } -// ISODurationFromDuration converts a time.Duration to an ISODuration. +// ISODurationFromDuration converts a time.Duration to ISODuration. func ISODurationFromDuration(d time.Duration) ISODuration { return ISODuration(d) } -// String returns the ISO8601 string representation of the ISODuration. +// String returns the ISO 8601 representation. func (d ISODuration) String() string { - td := time.Duration(d) - isoDur := duration.FromTimeDuration(td) - return isoDur.String() + return duration.FromTimeDuration(time.Duration(d)).String() } -// Duration returns the time.Duration representation of the ISODuration. +// Duration returns the underlying time.Duration. func (d ISODuration) Duration() time.Duration { return time.Duration(d) } - -// MarshalJSON implements the json.Marshaler interface for ISODuration, -// serializing it as an ISO8601 duration string. -func (d ISODuration) MarshalJSON() ([]byte, error) { - return json.Marshal("\"" + d.String() + "\"") -} - -// UnmarshalJSON implements the json.Unmarshaler interface for ISODuration, -// parsing an ISO8601 duration string. -func (d *ISODuration) UnmarshalJSON(data []byte) error { - var s string - if err := json.Unmarshal(data, &s); err != nil { - return err - } - parsedDur, err := DurationFromISOString(s) - if err != nil { - return err - } - *d = parsedDur - return nil -} - -// MarshalGQL implements the graphql.Marshaler interface for ISODuration, -// serializing it as an ISO8601 duration string. -func (d ISODuration) MarshalGQL(w io.Writer) { - fmt.Fprint(w, "\""+d.String()+"\"") -} - -// UnmarshalGQL implements the graphql.Unmarshaler interface for ISODuration, -// parsing an ISO8601 duration string. nil values are treated as zero duration. -func (d *ISODuration) UnmarshalGQL(value any) error { - if value == nil { - *d = ISODuration(0) - return nil - } - - s, ok := value.(string) - if !ok { - return fmt.Errorf("ISODuration must be a string") - } - - parsedDur, err := DurationFromISOString(s) - if err != nil { - return err - } - *d = parsedDur - return nil -}