package lagoon import ( "bytes" "database/sql/driver" "encoding/json" "fmt" "strings" "time" ) const dateLayout = "2006-01-02" // Date is a calendar date for a DATE column, with no time of day and no // time zone. Its JSON and text form is "2006-01-02". The zero Date means "no // date": it stores NULL, marshals to JSON null and counts as empty for the // required rule. Use *Date for a column whose NULL must stay distinct from a // set value through every layer. type Date struct { year int month time.Month day int valid bool } // NewDate returns the date y-m-d, normalised as time.Date normalises (the // 32nd of a month is the 1st of the next one). func NewDate(y int, m time.Month, d int) Date { t := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) return Date{year: t.Year(), month: t.Month(), day: t.Day(), valid: true} } // DateOf returns the calendar date of t in t's own location. A zero t gives // the zero Date. func DateOf(t time.Time) Date { if t.IsZero() { return Date{} } y, m, d := t.Date() return Date{year: y, month: m, day: d, valid: true} } // ParseDate parses exactly the "2006-01-02" form. func ParseDate(s string) (Date, error) { t, err := time.Parse(dateLayout, s) if err != nil { return Date{}, fmt.Errorf("lagoon: date %q is not YYYY-MM-DD", s) } return DateOf(t), nil } // Time returns midnight of the date in loc (UTC when loc is nil). The zero // Date gives the zero time.Time. func (d Date) Time(loc *time.Location) time.Time { if !d.valid { return time.Time{} } if loc == nil { loc = time.UTC } return time.Date(d.year, d.month, d.day, 0, 0, 0, 0, loc) } // String returns "2006-01-02", or "" for the zero Date. func (d Date) String() string { if !d.valid { return "" } return fmt.Sprintf("%04d-%02d-%02d", d.year, int(d.month), d.day) } // IsZero reports whether d is the zero Date (no date). func (d Date) IsZero() bool { return !d.valid } // Scan implements sql.Scanner. It accepts nil (the zero Date), a time.Time, // whose calendar date is taken as the driver returned it with no time zone // conversion (pgx returns a DATE as UTC midnight), and a string or []byte in // the "2006-01-02" form or starting with it followed by "T" or a space. func (d *Date) Scan(src any) error { if d == nil { return fmt.Errorf("lagoon: date scan on nil receiver") } switch v := src.(type) { case nil: *d = Date{} return nil case time.Time: *d = DateOf(v) return nil case string: return d.scanText(v) case []byte: return d.scanText(string(v)) default: return fmt.Errorf("lagoon: date scan unsupported type %T", src) } } func (d *Date) scanText(s string) error { if len(s) > len(dateLayout) && (s[len(dateLayout)] == 'T' || s[len(dateLayout)] == ' ') { s = s[:len(dateLayout)] } parsed, err := ParseDate(s) if err != nil { return err } *d = parsed return nil } // Value implements driver.Valuer: NULL for the zero Date, else the // "2006-01-02" string. func (d Date) Value() (driver.Value, error) { if !d.valid { return nil, nil } return d.String(), nil } // MarshalJSON writes "2006-01-02", or null for the zero Date. func (d Date) MarshalJSON() ([]byte, error) { if !d.valid { return []byte("null"), nil } return json.Marshal(d.String()) } // UnmarshalJSON reads a "2006-01-02" string; null and "" give the zero Date. func (d *Date) UnmarshalJSON(b []byte) error { if bytes.Equal(bytes.TrimSpace(b), []byte("null")) { *d = Date{} return nil } var s string if err := json.Unmarshal(b, &s); err != nil { return fmt.Errorf("lagoon: date must be a JSON string: %w", err) } return d.UnmarshalText([]byte(s)) } // MarshalText writes "2006-01-02", or nothing for the zero Date. func (d Date) MarshalText() ([]byte, error) { return []byte(d.String()), nil } // UnmarshalText parses "2006-01-02"; empty text gives the zero Date. func (d *Date) UnmarshalText(b []byte) error { s := strings.TrimSpace(string(b)) if s == "" { *d = Date{} return nil } parsed, err := ParseDate(s) if err != nil { return err } *d = parsed return nil } // TimeOfDay is a wall-clock time for a TIME column, with second precision // and no date or time zone. Its JSON and text form is "15:04:05". The zero // TimeOfDay means "no time" and stores NULL; midnight, "00:00:00", is a set // value. Use *TimeOfDay for a nullable column. type TimeOfDay struct { hour int minute int second int valid bool } // NewTimeOfDay returns h:m:s, normalised as time.Date normalises and wrapped // to one day. func NewTimeOfDay(h, m, s int) TimeOfDay { t := time.Date(2000, 1, 1, h, m, s, 0, time.UTC) return TimeOfDay{hour: t.Hour(), minute: t.Minute(), second: t.Second(), valid: true} } // ParseTimeOfDay parses "15:04" or "15:04:05"; fractional seconds // ("15:04:05.123") are truncated. func ParseTimeOfDay(s string) (TimeOfDay, error) { raw := s if i := strings.IndexByte(s, '.'); i >= 0 && strings.Count(s, ":") == 2 { frac := s[i+1:] if frac == "" || strings.Trim(frac, "0123456789") != "" { return TimeOfDay{}, fmt.Errorf("lagoon: time of day %q is not HH:MM or HH:MM:SS", raw) } s = s[:i] } layout := "15:04:05" if strings.Count(s, ":") == 1 { layout = "15:04" } t, err := time.Parse(layout, s) if err != nil { return TimeOfDay{}, fmt.Errorf("lagoon: time of day %q is not HH:MM or HH:MM:SS", raw) } return TimeOfDay{hour: t.Hour(), minute: t.Minute(), second: t.Second(), valid: true}, nil } // Hour returns the hour, 0 to 23. func (t TimeOfDay) Hour() int { return t.hour } // Minute returns the minute, 0 to 59. func (t TimeOfDay) Minute() int { return t.minute } // Second returns the second, 0 to 59. func (t TimeOfDay) Second() int { return t.second } // String returns "15:04:05", or "" for the zero TimeOfDay. func (t TimeOfDay) String() string { if !t.valid { return "" } return fmt.Sprintf("%02d:%02d:%02d", t.hour, t.minute, t.second) } // IsZero reports whether t is the zero TimeOfDay (no time). func (t TimeOfDay) IsZero() bool { return !t.valid } // Scan implements sql.Scanner. It accepts nil (the zero TimeOfDay), a // string or []byte in a form ParseTimeOfDay accepts (pgx returns TIME as // text), and a time.Time, whose clock is taken. func (t *TimeOfDay) Scan(src any) error { if t == nil { return fmt.Errorf("lagoon: time of day scan on nil receiver") } switch v := src.(type) { case nil: *t = TimeOfDay{} return nil case time.Time: h, m, s := v.Clock() *t = TimeOfDay{hour: h, minute: m, second: s, valid: true} return nil case string: parsed, err := ParseTimeOfDay(v) if err != nil { return err } *t = parsed return nil case []byte: parsed, err := ParseTimeOfDay(string(v)) if err != nil { return err } *t = parsed return nil default: return fmt.Errorf("lagoon: time of day scan unsupported type %T", src) } } // Value implements driver.Valuer: NULL for the zero TimeOfDay, else the // "15:04:05" string. func (t TimeOfDay) Value() (driver.Value, error) { if !t.valid { return nil, nil } return t.String(), nil } // MarshalJSON writes "15:04:05", or null for the zero TimeOfDay. func (t TimeOfDay) MarshalJSON() ([]byte, error) { if !t.valid { return []byte("null"), nil } return json.Marshal(t.String()) } // UnmarshalJSON reads a "15:04" or "15:04:05" string; null and "" give the // zero TimeOfDay. func (t *TimeOfDay) UnmarshalJSON(b []byte) error { if bytes.Equal(bytes.TrimSpace(b), []byte("null")) { *t = TimeOfDay{} return nil } var s string if err := json.Unmarshal(b, &s); err != nil { return fmt.Errorf("lagoon: time of day must be a JSON string: %w", err) } return t.UnmarshalText([]byte(s)) } // MarshalText writes "15:04:05", or nothing for the zero TimeOfDay. func (t TimeOfDay) MarshalText() ([]byte, error) { return []byte(t.String()), nil } // UnmarshalText parses "15:04" or "15:04:05"; empty text gives the zero // TimeOfDay. func (t *TimeOfDay) UnmarshalText(b []byte) error { s := strings.TrimSpace(string(b)) if s == "" { *t = TimeOfDay{} return nil } parsed, err := ParseTimeOfDay(s) if err != nil { return err } *t = parsed return nil }