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:
2026-06-29 18:01:08 -07:00
parent 49ac7dd22a
commit a1284075aa
7 changed files with 409 additions and 187 deletions
+54 -102
View File
@@ -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
}