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:
@@ -17,10 +17,11 @@ Postgres data layer: the shared GORM connection, per-plugin migrations, model he
|
||||
- After-commit work: `lagoon.Transaction` runs a function in a transaction and then the callbacks registered with `lagoon.AfterCommit`, in order, only after the commit succeeds; a nested `lagoon.Transaction` is a savepoint whose callbacks are dropped with it when it fails. A nested `lagoon.Transaction` must be given the outer transaction's handle: given a root handle it returns an error without running its function, rather than open an independent transaction whose callbacks would wait on the outer one. A single-statement write for which GORM opens its own implicit transaction runs its callbacks from `lagoon:after_commit` once GORM commits, and never when the write fails. A callback registered inside a foreign plain GORM transaction is unsafe because Lagoon cannot observe its commit, so `lagoon.AfterCommit` warns and skips it. Outside a transaction, callbacks run immediately. The handle a supported callback receives always has an empty statement on the connection its work belongs to. A panicking callback is logged and never turns a committed write into an error.
|
||||
- Per-plugin migrations: `lagoon.Migrate` runs the framework's `system_files` set (`attach.Migrations`), backend admin identity set (`lagoon.BackendAdminMigrations`), `deferred_bindings` set (`lagoon.DeferredBindingMigrations`, under the `lagoon.DeferredHistoryID` history) and job-queue set (`lagoon.QueueMigrations`: River's schema pinned at `lagoon.RiverSchemaVersion`, then the `lagoon.JobsTable` record table, under the `lagoon.QueueHistoryID` history), then every `pact.HasMigrations` set in plugin activation order, each in its own `summer_migrations_<plugin_id>` history table (`lagoon.HistoryTableName`). `lagoon.RollbackLast` and `lagoon.Status` cover rollback and history.
|
||||
- Mass assignment: `lagoon.Fill` copies only allow-listed keys onto a model by GORM column name and silently drops the rest, logging each dropped key once outside production. A `json.Number` (from a decoder using `UseNumber`) fills integer, unsigned and float fields. A value that does not fit its column (a fraction, an exponent or an overflow for an integer field, or a value of the wrong type) is a `lagoon.FillTypeError` naming the key, so a caller can answer it as a validation failure on that field. `lagoon.HasFillable` and `lagoon.HasHidden` are the Go forms of `$fillable` and `$hidden`.
|
||||
- Validation: `lagoon.Validate` accepts Laravel-style rule strings (`required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `confirmed`, `different`, `mimes`) and returns a field-to-messages map, translated through phrasebook when a translator is given. Unknown rule tokens are an error. A failed numeric range reports the bound that failed: the `min` message below the lower bound, the `max` message above the upper one, and the numeric `between` message when the bound came from `between`.
|
||||
- Validation: `lagoon.Validate` (where `required` fails on a zero date or time) accepts Laravel-style rule strings (`required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `confirmed`, `different`, `mimes`) and returns a field-to-messages map, translated through phrasebook when a translator is given. Unknown rule tokens are an error. A failed numeric range reports the bound that failed: the `min` message below the lower bound, the `max` message above the upper one, and the numeric `between` message when the bound came from `between`.
|
||||
- Request validation: `lagoon.ValidateRequest` reproduces Laravel 9 request validation for ported API endpoints, so a 422 body matches the PHP one message for message. It takes the decoded input and an ordered `lagoon.RequestRule` table (attribute names may hold `*` wildcards, expanded against the input to `posts.0.title`), runs the rules of each attribute in order and stops an attribute after a failed implicit rule (`required`, `present`, `filled`, `accepted`) or, under `bail`, after any failure. A non-implicit rule is skipped for an absent attribute, a blank string, a null value under `nullable` and an absent key under `sometimes`. Supported rules: `required`, `present`, `filled`, `accepted`, `nullable`, `sometimes`, `bail`, `array`, `string`, `integer`, `numeric`, `boolean`, `email` (PHP `FILTER_VALIDATE_EMAIL`, WinterCMS's default), `url`, `date`, `after`, `after_or_equal`, `before`, `before_or_equal` (a date, a relative word such as `tomorrow`, or another field), `exists:table,column`, `regex`, `not_regex`, `in`, `not_in`, `file`, `image`, `mimes`, `min`, `max`, `size` and `between`, plus closure rules built with `lagoon.CustomRule`. The size rules compare the number under `numeric` or `integer` (exactly, as decimals), the element count of an array, kilobytes of a `lagoon.UploadedFile`, and otherwise the length in characters, and pick the matching message. Messages come from the `lagoon::validation` catalog in the request locale; `lagoon.ErrorKeys` gives the attribute order of PHP's message bag.
|
||||
- Safe ordering: `lagoon.OrderBy` appends an ORDER BY only for an allow-listed column and an `asc` or `desc` direction, and `lagoon.Collate` adds a validated `COLLATE` clause for language-specific text order (for example the ICU collation `pl-x-icu`); lagoon puts no requirement on the database's default locale.
|
||||
- Pagination: `lagoon.Paginate` builds a `lagoon.Page` with `data` and `meta` (`current_page`, `last_page`, `per_page`, `total`).
|
||||
- Date and time columns: `lagoon.Date` (a `DATE` column, JSON `"2026-10-02"`) and `lagoon.TimeOfDay` (a `TIME` column, JSON `"14:30:00"`) implement `sql.Scanner`, `driver.Valuer`, JSON and text marshalling, and store NULL for their zero value; `*lagoon.Date` and `*lagoon.TimeOfDay` are the nullable variants, next to `time.Time` and `*time.Time` for `timestamptz`. Build them with `lagoon.NewDate`, `lagoon.DateOf`, `lagoon.ParseDate`, `lagoon.NewTimeOfDay` and `lagoon.ParseTimeOfDay`. `lagoon.Fill` fills all six from JSON strings (RFC 3339 for `time.Time`) through their text unmarshalling, after every conversion it already made. Behaviour change: `required` now treats a zero `time.Time`, `lagoon.Date` or `lagoon.TimeOfDay` (or a pointer to one) as empty, so declare optional dates as pointer fields.
|
||||
- Column types: `lagoon.Encrypted` stores AES-256-GCM ciphertext under a key derived from `app.key`, decrypts with previous keys during rotation, and always redacts itself in JSON and string output; `lagoon.Jsonable` stores JSON as TEXT and keeps SQL NULL distinct from an empty value.
|
||||
- Lifecycle and relations: hook interfaces matching GORM's native method names (`lagoon.HasBeforeCreate`, `lagoon.HasBeforeSave`, `lagoon.HasBeforeDelete`, `lagoon.HasAfterDelete`) plus `lagoon.HasBeforeValidate`; `lagoon.WithSoftDeleteCascade` runs a cascade inside the parent delete; `lagoon.RegisterJoinTable` wires pivot models with business columns.
|
||||
- Deferred binding: WinterCMS's `deferred_bindings` table holds the uploads and related-record changes of a form whose record is not saved yet. Every operation takes a `lagoon.DeferredKey` (the form's session key, the owning backend admin's id and the master record's morph type from `lagoon.MorphType`) and never reads or changes another admin's rows, since each row stores `backend_user_id`. `lagoon.DeferredBind` and `lagoon.DeferredUnbind` port WinterCMS's duplicate and cancel rules: a repeated bind writes nothing, and an unbind of a slave with a pending bind deletes that bind and returns it so the caller can remove what it created. `lagoon.DeferredBindings` reads and locks a session's bindings for the save that commits them, `lagoon.DeferredForget` deletes them once applied, and `lagoon.DeferredSlaves` is the subquery a list uses to include pending rows. A child created under deferral carries the `lagoon.DeferredEnvelope` (`{"created":true,"pivot":{...}}`) in `pivot_data`. `lagoon.PurgeDeferred` removes expired bindings: it deletes an unattached `system_files` row a bind points at, and its blobs only after the commit, deletes a child only when its binding carries the created envelope, keeps records that were only linked, and locks each batch with `FOR UPDATE SKIP LOCKED`.
|
||||
@@ -149,7 +150,14 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
|
||||
| `lagoon.PublishEncryptionKeys` | Installs the keys used by `lagoon.Encrypted` columns. |
|
||||
| `lagoon.Encrypted` | Encrypted text column; `lagoon.Encrypted.Reveal` is the only plaintext accessor. |
|
||||
| `lagoon.Jsonable` | Generic JSON-as-TEXT column with NULL tracking. |
|
||||
| `lagoon.Fill` | Allow-listed mass assignment by column name. |
|
||||
| `lagoon.Fill` | Allow-listed mass assignment by column name; date and time strings fill `time.Time`, `lagoon.Date` and `lagoon.TimeOfDay` fields. |
|
||||
| `lagoon.Date` | Calendar date for a `DATE` column; JSON `"2006-01-02"`, NULL when zero. |
|
||||
| `lagoon.NewDate` | Builds a `lagoon.Date` from year, month and day. |
|
||||
| `lagoon.DateOf` | The calendar date of a `time.Time` in its own location. |
|
||||
| `lagoon.ParseDate` | Parses `YYYY-MM-DD` into a `lagoon.Date`. |
|
||||
| `lagoon.TimeOfDay` | Wall-clock time for a `TIME` column; JSON `"15:04:05"`, NULL when zero. |
|
||||
| `lagoon.NewTimeOfDay` | Builds a `lagoon.TimeOfDay` from hours, minutes and seconds. |
|
||||
| `lagoon.ParseTimeOfDay` | Parses `HH:MM` or `HH:MM:SS` into a `lagoon.TimeOfDay`. |
|
||||
| `lagoon.FillTypeError` | Returned by `lagoon.Fill` when a requested value does not fit its column; `Key` names the column. |
|
||||
| `lagoon.Validate` | Laravel-style rule validation with a `unique` database check. |
|
||||
| `lagoon.ValidateRequest` | Laravel 9 request validation of decoded input against an ordered rule table; returns Laravel's errors object. |
|
||||
|
||||
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
|
||||
}
|
||||
137
modules/lagoon/date_test.go
Normal file
137
modules/lagoon/date_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDateSmoke(t *testing.T) {
|
||||
d, err := ParseDate("2026-10-02")
|
||||
if err != nil || d.String() != "2026-10-02" || d.IsZero() {
|
||||
t.Fatalf("parse: %v %v", d, err)
|
||||
}
|
||||
if _, err := ParseDate("2026-10-02T00:00:00Z"); err == nil {
|
||||
t.Fatal("ParseDate must accept only YYYY-MM-DD")
|
||||
}
|
||||
b, err := json.Marshal(struct {
|
||||
Day Date `json:"day"`
|
||||
None *Date `json:"none"`
|
||||
Zero Date `json:"zero"`
|
||||
}{Day: d})
|
||||
if err != nil || string(b) != `{"day":"2026-10-02","none":null,"zero":null}` {
|
||||
t.Fatalf("marshal %s %v", b, err)
|
||||
}
|
||||
var back struct {
|
||||
Day Date `json:"day"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(`{"day":"2026-10-02"}`), &back); err != nil || back.Day != d {
|
||||
t.Fatalf("unmarshal %v %v", back.Day, err)
|
||||
}
|
||||
|
||||
// pgx returns DATE as UTC midnight; the calendar date is kept whatever
|
||||
// the process time zone.
|
||||
var scanned Date
|
||||
if err := scanned.Scan(time.Date(2026, 10, 2, 0, 0, 0, 0, time.UTC)); err != nil || scanned != d {
|
||||
t.Fatalf("scan time %v %v", scanned, err)
|
||||
}
|
||||
if err := scanned.Scan("2026-10-03"); err != nil || scanned.String() != "2026-10-03" {
|
||||
t.Fatalf("scan string %v %v", scanned, err)
|
||||
}
|
||||
if err := scanned.Scan(nil); err != nil || !scanned.IsZero() {
|
||||
t.Fatalf("scan nil %v %v", scanned, err)
|
||||
}
|
||||
if v, err := (Date{}).Value(); v != nil || err != nil {
|
||||
t.Fatalf("zero value %v %v", v, err)
|
||||
}
|
||||
if v, err := d.Value(); v != "2026-10-02" || err != nil {
|
||||
t.Fatalf("value %v %v", v, err)
|
||||
}
|
||||
if got := NewDate(2026, 9, 31).String(); got != "2026-10-01" {
|
||||
t.Fatalf("NewDate normalises: %s", got)
|
||||
}
|
||||
if got := DateOf(time.Date(2026, 10, 2, 23, 30, 0, 0, time.FixedZone("x", -7*3600))).String(); got != "2026-10-02" {
|
||||
t.Fatalf("DateOf keeps the location's date: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeOfDaySmoke(t *testing.T) {
|
||||
tod, err := ParseTimeOfDay("14:30")
|
||||
if err != nil || tod.String() != "14:30:00" {
|
||||
t.Fatalf("parse: %v %v", tod, err)
|
||||
}
|
||||
if tod, err := ParseTimeOfDay("14:30:15.987654"); err != nil || tod.String() != "14:30:15" {
|
||||
t.Fatalf("fraction: %v %v", tod, err)
|
||||
}
|
||||
if _, err := ParseTimeOfDay("25:00"); err == nil {
|
||||
t.Fatal("25:00 must fail")
|
||||
}
|
||||
midnight := NewTimeOfDay(0, 0, 0)
|
||||
if midnight.IsZero() {
|
||||
t.Fatal("midnight is a set value")
|
||||
}
|
||||
b, err := json.Marshal([]TimeOfDay{tod, {}})
|
||||
if err != nil || string(b) != `["14:30:00",null]` {
|
||||
t.Fatalf("marshal %s %v", b, err)
|
||||
}
|
||||
var scanned TimeOfDay
|
||||
if err := scanned.Scan("09:05:07"); err != nil || scanned.Hour() != 9 || scanned.Minute() != 5 || scanned.Second() != 7 {
|
||||
t.Fatalf("scan %v %v", scanned, err)
|
||||
}
|
||||
if v, err := (TimeOfDay{}).Value(); v != nil || err != nil {
|
||||
t.Fatalf("zero value %v %v", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
type fillDates struct {
|
||||
At time.Time `gorm:"column:at"`
|
||||
AtPtr *time.Time `gorm:"column:at_ptr"`
|
||||
Day Date `gorm:"column:day"`
|
||||
DayPtr *Date `gorm:"column:day_ptr"`
|
||||
Clock TimeOfDay `gorm:"column:clock"`
|
||||
ClkPtr *TimeOfDay `gorm:"column:clock_ptr"`
|
||||
}
|
||||
|
||||
func TestFillTextSmoke(t *testing.T) {
|
||||
var m fillDates
|
||||
allowed := []string{"at", "at_ptr", "day", "day_ptr", "clock", "clock_ptr"}
|
||||
err := Fill(&m, allowed, map[string]any{
|
||||
"at": "2026-10-02T12:30:00Z",
|
||||
"at_ptr": "2026-10-02T14:30:00+02:00",
|
||||
"day": "2026-10-02",
|
||||
"day_ptr": "2026-10-03",
|
||||
"clock": "14:30",
|
||||
"clock_ptr": "08:15:00",
|
||||
}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := time.Date(2026, 10, 2, 12, 30, 0, 0, time.UTC)
|
||||
if !m.At.Equal(want) || m.AtPtr == nil || !m.AtPtr.Equal(want) {
|
||||
t.Fatalf("times %v %v", m.At, m.AtPtr)
|
||||
}
|
||||
if m.Day.String() != "2026-10-02" || m.DayPtr == nil || m.DayPtr.String() != "2026-10-03" {
|
||||
t.Fatalf("dates %v %v", m.Day, m.DayPtr)
|
||||
}
|
||||
if m.Clock.String() != "14:30:00" || m.ClkPtr == nil || m.ClkPtr.String() != "08:15:00" {
|
||||
t.Fatalf("clocks %v %v", m.Clock, m.ClkPtr)
|
||||
}
|
||||
err = Fill(&m, allowed, map[string]any{"day": "next tuesday"}, true)
|
||||
var fte *FillTypeError
|
||||
if !errors.As(err, &fte) || fte.Key != "day" {
|
||||
t.Fatalf("garbage date: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequiredZeroDateSmoke(t *testing.T) {
|
||||
rules := map[string]string{"day": "required"}
|
||||
errs, err := Validate(t.Context(), nil, &fillDates{}, rules, map[string]any{"day": Date{}}, nil)
|
||||
if err != nil || len(errs["day"]) != 1 {
|
||||
t.Fatalf("zero date: %v %v", errs, err)
|
||||
}
|
||||
errs, err = Validate(t.Context(), nil, &fillDates{}, rules, map[string]any{"day": NewDate(2026, 10, 2)}, nil)
|
||||
if err != nil || errs != nil {
|
||||
t.Fatalf("set date: %v %v", errs, err)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package lagoon
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -184,7 +185,52 @@ func convertValue(src reflect.Value, destType reflect.Type) (reflect.Value, erro
|
||||
if src.Type().ConvertibleTo(destType) {
|
||||
return src.Convert(destType), nil
|
||||
}
|
||||
return reflect.Value{}, fmt.Errorf("cannot assign %s to %s", src.Type(), destType)
|
||||
assignErr := fmt.Errorf("cannot assign %s to %s", src.Type(), destType)
|
||||
if out, handled, err := convertText(src, destType); handled {
|
||||
if err != nil {
|
||||
return reflect.Value{}, fmt.Errorf("%w: %w", assignErr, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return reflect.Value{}, assignErr
|
||||
}
|
||||
|
||||
var (
|
||||
textUnmarshalerType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem()
|
||||
scannerType = reflect.TypeOf((*sql.Scanner)(nil)).Elem()
|
||||
dateType = reflect.TypeOf(Date{})
|
||||
timeOfDayType = reflect.TypeOf(TimeOfDay{})
|
||||
)
|
||||
|
||||
// convertText fills a destination type whose pointer implements
|
||||
// encoding.TextUnmarshaler (time.Time, Date, TimeOfDay) from a string or
|
||||
// []byte source, so a JSON date string fills a date column. It runs only
|
||||
// after every other conversion failed, and it skips types that also
|
||||
// implement sql.Scanner (other than Date and TimeOfDay), which setField
|
||||
// already fills through Scan, so nothing that filled before changes path.
|
||||
// handled is false when the fallback does not apply.
|
||||
func convertText(src reflect.Value, destType reflect.Type) (reflect.Value, bool, error) {
|
||||
var text []byte
|
||||
switch v := src.Interface().(type) {
|
||||
case string:
|
||||
text = []byte(v)
|
||||
case []byte:
|
||||
text = v
|
||||
default:
|
||||
return reflect.Value{}, false, nil
|
||||
}
|
||||
ptrType := reflect.PointerTo(destType)
|
||||
if !ptrType.Implements(textUnmarshalerType) {
|
||||
return reflect.Value{}, false, nil
|
||||
}
|
||||
if ptrType.Implements(scannerType) && destType != dateType && destType != timeOfDayType {
|
||||
return reflect.Value{}, false, nil
|
||||
}
|
||||
ptr := reflect.New(destType)
|
||||
if err := ptr.Interface().(encoding.TextUnmarshaler).UnmarshalText(text); err != nil {
|
||||
return reflect.Value{}, true, err
|
||||
}
|
||||
return ptr.Elem(), true, nil
|
||||
}
|
||||
|
||||
// convertNumber parses a json.Number, which a request decoder using
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"github.com/go-playground/validator/v10"
|
||||
@@ -265,6 +266,22 @@ func isEmptyValue(val any) bool {
|
||||
if val == nil {
|
||||
return true
|
||||
}
|
||||
// A zero time.Time, Date or TimeOfDay is "no value" for required. Only
|
||||
// these three types are checked, so no other type's emptiness changes.
|
||||
switch v := val.(type) {
|
||||
case time.Time:
|
||||
return v.IsZero()
|
||||
case *time.Time:
|
||||
return v == nil || v.IsZero()
|
||||
case Date:
|
||||
return v.IsZero()
|
||||
case *Date:
|
||||
return v == nil || v.IsZero()
|
||||
case TimeOfDay:
|
||||
return v.IsZero()
|
||||
case *TimeOfDay:
|
||||
return v == nil || v.IsZero()
|
||||
}
|
||||
rv := reflect.ValueOf(val)
|
||||
switch rv.Kind() {
|
||||
case reflect.Ptr, reflect.Interface:
|
||||
|
||||
Reference in New Issue
Block a user