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 <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
coverage.out
|
||||||
|
coverage.html
|
||||||
|
*.test
|
||||||
@@ -1,21 +1,36 @@
|
|||||||
# clocktime
|
# 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)
|
```go
|
||||||
- Marshals to `[]byte` containing string `HH:MM:SS` for SQL
|
ct, err := clocktime.ParseClockTime("09:30:00")
|
||||||
- Marshals to `string` in format `HH:MM:SS` for JSON & gqlgen
|
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))
|
```go
|
||||||
- Marshals to `uint64` with nanosecond precision. Largest representatable duration is about 290 years, limited by underlying time.Duration type.
|
d, err := clocktime.ParseISODuration("PT1H30M")
|
||||||
- Marshals to `string` in ISO8061 format for JSON & gqlgen
|
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`.
|
||||||
|
|||||||
+54
-102
@@ -1,70 +1,94 @@
|
|||||||
package clocktime
|
package clocktime
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql/driver"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClockTime represents a time of day without a date component in 24-hour format.
|
// ClockTime represents a time of day without a date component (24-hour HH:MM:SS).
|
||||||
// It is used to represent times in a way that is independent of any specific date.
|
|
||||||
type ClockTime struct {
|
type ClockTime struct {
|
||||||
Hour int `json:"hour"`
|
Hour int `json:"hour"`
|
||||||
Minute int `json:"minute"`
|
Minute int `json:"minute"`
|
||||||
Second int `json:"second"`
|
Second int `json:"second"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClockTime creates a new ClockTime instance.
|
// NewClockTime creates a ClockTime after validating component ranges.
|
||||||
func NewClockTime(hour, minute, second int) ClockTime {
|
func NewClockTime(hour, minute, second int) (ClockTime, error) {
|
||||||
return ClockTime{
|
t := ClockTime{Hour: hour, Minute: minute, Second: second}
|
||||||
Hour: hour,
|
if err := t.validate(); err != nil {
|
||||||
Minute: minute,
|
return ClockTime{}, err
|
||||||
Second: second,
|
|
||||||
}
|
}
|
||||||
|
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) {
|
func ClockTimeFromString(s string) (ClockTime, error) {
|
||||||
var hour, minute, second int
|
if s == "" {
|
||||||
n, err := fmt.Sscanf(s, "%d:%d:%d", &hour, &minute, &second)
|
return ClockTime{}, fmt.Errorf("invalid time format: empty string")
|
||||||
if err != nil || n != 3 {
|
}
|
||||||
|
parsed, err := time.Parse("15:04:05", s)
|
||||||
|
if err != nil {
|
||||||
return ClockTime{}, fmt.Errorf("invalid time format: %s", s)
|
return ClockTime{}, fmt.Errorf("invalid time format: %s", s)
|
||||||
}
|
}
|
||||||
if hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59 {
|
return ClockTime{Hour: parsed.Hour(), Minute: parsed.Minute(), Second: parsed.Second()}, nil
|
||||||
return ClockTime{}, fmt.Errorf("time out of range: %s", s)
|
|
||||||
}
|
|
||||||
return NewClockTime(hour, minute, 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 {
|
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 {
|
func (t ClockTime) String() string {
|
||||||
return fmt.Sprintf("%02d:%02d:%02d", t.Hour, t.Minute, t.Second)
|
return fmt.Sprintf("%02d:%02d:%02d", t.Hour, t.Minute, t.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Time returns a time.Time representation of the ClockTime.
|
// Time returns a UTC time.Time anchored on 1970-01-01.
|
||||||
// It uses a fixed date (January 1, 1970) to create a time.Time object.
|
|
||||||
func (t ClockTime) Time() time.Time {
|
func (t ClockTime) Time() time.Time {
|
||||||
return time.Date(1970, 1, 1, t.Hour, t.Minute, t.Second, 0, time.UTC)
|
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.
|
// IsMidnight reports whether the time is exactly 00:00:00.
|
||||||
func (t *ClockTime) IsZero() bool {
|
func (t ClockTime) IsMidnight() bool {
|
||||||
return t == nil || (t.Hour == 0 && t.Minute == 0 && t.Second == 0)
|
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 {
|
func (t ClockTime) Equal(other ClockTime) bool {
|
||||||
return t.Hour == other.Hour && t.Minute == other.Minute && t.Second == other.Second
|
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 {
|
func (t ClockTime) Before(other ClockTime) bool {
|
||||||
if t.Hour != other.Hour {
|
if t.Hour != other.Hour {
|
||||||
return 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
|
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 {
|
func (t ClockTime) After(other ClockTime) bool {
|
||||||
if t.Hour != other.Hour {
|
if t.Hour != other.Hour {
|
||||||
return 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
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
+88
-10
@@ -1,19 +1,97 @@
|
|||||||
package clocktime
|
package clocktime_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.auvem.com/go-toolkit/clocktime"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_Comparison(t *testing.T) {
|
func TestComparison(t *testing.T) {
|
||||||
assert := assert.New(t)
|
a, err := clocktime.NewClockTime(12, 30, 0)
|
||||||
time1 := NewClockTime(10, 30, 0)
|
require.NoError(t, err)
|
||||||
time2 := NewClockTime(11, 0, 0)
|
b, err := clocktime.NewClockTime(13, 0, 0)
|
||||||
time3 := NewClockTime(10, 30, 0)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.True(time1.Before(time2))
|
assert.True(t, a.Before(b))
|
||||||
assert.False(time1.After(time2))
|
assert.True(t, b.After(a))
|
||||||
assert.False(time1.Equal(time2))
|
assert.False(t, a.Equal(b))
|
||||||
assert.True(time1.Equal(time3))
|
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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
+13
-63
@@ -1,20 +1,16 @@
|
|||||||
package clocktime
|
package clocktime
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sosodev/duration"
|
"github.com/sosodev/duration"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ISODuration wraps a time.Duration to provide custom JSON and SQL
|
// ISODuration wraps time.Duration with ISO 8601 string formatting.
|
||||||
// serialization and full ISO8061 compatibility. Note: ISODuration is limited
|
|
||||||
// to the same range as time.Duration despite ISO8061 supporting larger durations.
|
|
||||||
type ISODuration time.Duration
|
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 {
|
func NewISODuration(hours, minutes, seconds int) ISODuration {
|
||||||
return ISODuration(
|
return ISODuration(
|
||||||
time.Hour*time.Duration(hours) +
|
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")
|
// ParseISODuration parses an ISO 8601 duration string (for example PT1H30M).
|
||||||
// and returns an ISODuration.
|
func ParseISODuration(s string) (ISODuration, error) {
|
||||||
|
return DurationFromISOString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DurationFromISOString parses an ISO 8601 duration string.
|
||||||
func DurationFromISOString(s string) (ISODuration, error) {
|
func DurationFromISOString(s string) (ISODuration, error) {
|
||||||
d, err := duration.Parse(s)
|
d, err := duration.Parse(s)
|
||||||
if err != nil {
|
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
|
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 {
|
func ISODurationFromDuration(d time.Duration) ISODuration {
|
||||||
return ISODuration(d)
|
return ISODuration(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
// String returns the ISO8601 string representation of the ISODuration.
|
// String returns the ISO 8601 representation.
|
||||||
func (d ISODuration) String() string {
|
func (d ISODuration) String() string {
|
||||||
td := time.Duration(d)
|
return duration.FromTimeDuration(time.Duration(d)).String()
|
||||||
isoDur := duration.FromTimeDuration(td)
|
|
||||||
return isoDur.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Duration returns the time.Duration representation of the ISODuration.
|
// Duration returns the underlying time.Duration.
|
||||||
func (d ISODuration) Duration() time.Duration {
|
func (d ISODuration) Duration() time.Duration {
|
||||||
return time.Duration(d)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user