- Date (DATE) and TimeOfDay (TIME) with Scanner, Valuer, JSON and text forms - Fill falls back to encoding.TextUnmarshaler for string sources after every existing conversion, so time.Time and the new types fill from JSON - required treats a zero time.Time, Date or TimeOfDay as empty - lagoon README, models and casts-and-validation docs
306 lines
7.9 KiB
Go
306 lines
7.9 KiB
Go
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
|
|
}
|