Files
summercms/modules/lagoon/date.go
Jakub Zych f48a886f94 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
2026-10-02 17:40:48 +02:00

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
}