feat(12.2-01): add lagoon.Date and lagoon.TimeOfDay with Fill and required support
- 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
This commit is contained in:
305
modules/lagoon/date.go
Normal file
305
modules/lagoon/date.go
Normal file
@@ -0,0 +1,305 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user