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:
Jakub Zych
2026-10-02 17:40:48 +02:00
parent 19f4cf8232
commit f48a886f94
7 changed files with 532 additions and 5 deletions

View File

@@ -72,6 +72,20 @@ Keep the key in the environment (`SUMMER_APP__KEY`), not in a committed file.
The ciphertext format is not Laravel's. To import rows that the PHP application encrypted, decrypt them once with `lagoon.DecryptLaravelPayload` and the old `APP_KEY`, then save them through `lagoon.Encrypted`. It is meant for a one-off import, never for reading live data. The ciphertext format is not Laravel's. To import rows that the PHP application encrypted, decrypt them once with `lagoon.DecryptLaravelPayload` and the old `APP_KEY`, then save them through `lagoon.Encrypted`. It is meant for a one-off import, never for reading live data.
## Date and time columns
Three Go types cover WinterCMS's date, datetime and time columns, and all of them fill from the JSON strings an admin form posts:
| Column | Go type | JSON form | Nullable variant |
|--------|---------|-----------|------------------|
| `DATE` | `lagoon.Date` | `"2026-10-02"` | `*lagoon.Date` |
| `TIME` | `lagoon.TimeOfDay` | `"14:30:00"` | `*lagoon.TimeOfDay` |
| `TIMESTAMPTZ` | `time.Time` | `"2026-10-02T12:30:00Z"` | `*time.Time` |
`lagoon.Date` has no time of day and no time zone. Build one with `lagoon.NewDate`, `lagoon.DateOf` (the calendar date of a `time.Time` in its own location) or `lagoon.ParseDate`, which accepts exactly `YYYY-MM-DD`. Reading a `DATE` column takes the calendar date the driver returns, with no time-zone conversion, so a date written as 2026-10-02 reads back as 2026-10-02 in every process time zone. `lagoon.TimeOfDay` holds hours, minutes and seconds; `lagoon.ParseTimeOfDay` accepts `HH:MM` and `HH:MM:SS` and drops fractional seconds, and `lagoon.NewTimeOfDay` builds one from numbers. A `time.Time` keeps the instant of a timestamp with an offset, and GORM writes `timestamptz` in UTC.
The zero value of `lagoon.Date` and `lagoon.TimeOfDay` means "no value": it is stored as NULL and marshals to JSON `null`, and an empty string fills it. Midnight, `00:00:00`, is a set time. For an optional column, prefer the pointer variants, which keep NULL distinct from a set value through every layer.
## Validation ## Validation
`lagoon.Validate` checks a map of input values against Laravel-style rule strings and returns the errors in Laravel's shape, a map from field to messages. It returns `nil` when the input is valid. The second return value is for failures that are not the user's fault, such as an unknown rule or a database error: `lagoon.Validate` checks a map of input values against Laravel-style rule strings and returns the errors in Laravel's shape, a map from field to messages. It returns `nil` when the input is valid. The second return value is for failures that are not the user's fault, such as an unknown rule or a database error:
@@ -94,7 +108,7 @@ fmt.Println(string(out))
// {"title":["The title field is required."],"views":["The views may not be greater than 1000."]} // {"title":["The title field is required."],"views":["The views may not be greater than 1000."]}
``` ```
The supported rules are `required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `confirmed`, `different` and `mimes`. Any other rule is an error, so a rule that SummerCMS does not implement cannot be skipped by accident. On a field that is `integer` or `numeric`, `min`, `max` and `between` compare the number; on other fields they compare the length. A number below the lower bound gets the `min` message, one above the upper bound the `max` message, and a bound that came from `between` gets the numeric `between` message (`The views must be between 0 and 10.`). A zero `time.Time`, `lagoon.Date` or `lagoon.TimeOfDay`, or a pointer to one, is empty for `required`, so a required date field with no value fails instead of saving year 1. The supported rules are `required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `confirmed`, `different` and `mimes`. Any other rule is an error, so a rule that SummerCMS does not implement cannot be skipped by accident. On a field that is `integer` or `numeric`, `min`, `max` and `between` compare the number; on other fields they compare the length. A number below the lower bound gets the `min` message, one above the upper bound the `max` message, and a bound that came from `between` gets the numeric `between` message (`The views must be between 0 and 10.`).
`unique:<table>` runs a query to check that no other row of the table has the value in the field's column, so it needs a database handle; pass the transaction you are writing in. Soft-deleted rows do not count, and when the model you pass has an ID, its own row does not count either, so the same rules work for create and update. Pass a `phrasebook.Translator` as the last argument to get the messages in the request locale from the `lagoon::validate` catalog; with `nil` they are in English. See [Localization](../services/localization.md). `unique:<table>` runs a query to check that no other row of the table has the value in the field's column, so it needs a database handle; pass the transaction you are writing in. Soft-deleted rows do not count, and when the model you pass has an ID, its own row does not count either, so the same rules work for create and update. Pass a `phrasebook.Translator` as the last argument to get the messages in the request locale from the `lagoon::validate` catalog; with `nil` they are in English. See [Localization](../services/localization.md).

View File

@@ -69,7 +69,7 @@ if errors.As(err, &typeErr) {
// invalid value for views // invalid value for views
``` ```
`lagoon.Fill` matches keys by the GORM column name, not the Go field name. A number decoded with `json.Decoder.UseNumber` fills integer and float fields; a fraction or an overflow for an integer field is a `lagoon.FillTypeError`. `lagoon.Fill` matches keys by the GORM column name, not the Go field name. A number decoded with `json.Decoder.UseNumber` fills integer and float fields; a fraction or an overflow for an integer field is a `lagoon.FillTypeError`. A string fills a date or time column: an RFC 3339 timestamp such as `2026-10-02T12:30:00Z` fills a `time.Time` or `*time.Time` field, `2026-10-02` a `lagoon.Date` and `14:30:00` a `lagoon.TimeOfDay`, pointer variants included, so a model needs no date type of its own. A string that does not parse is a `lagoon.FillTypeError`. See [Date and time columns](casts-and-validation.md#date-and-time-columns).
## Creating a record ## Creating a record

View File

@@ -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. - 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. - 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`. - 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. - 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. - 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`). - 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. - 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. - 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`. - 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.PublishEncryptionKeys` | Installs the keys used by `lagoon.Encrypted` columns. |
| `lagoon.Encrypted` | Encrypted text column; `lagoon.Encrypted.Reveal` is the only plaintext accessor. | | `lagoon.Encrypted` | Encrypted text column; `lagoon.Encrypted.Reveal` is the only plaintext accessor. |
| `lagoon.Jsonable` | Generic JSON-as-TEXT column with NULL tracking. | | `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.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.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. | | `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
View 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
View 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)
}
}

View File

@@ -2,6 +2,7 @@ package lagoon
import ( import (
"database/sql" "database/sql"
"encoding"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -184,7 +185,52 @@ func convertValue(src reflect.Value, destType reflect.Type) (reflect.Value, erro
if src.Type().ConvertibleTo(destType) { if src.Type().ConvertibleTo(destType) {
return src.Convert(destType), nil 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 // convertNumber parses a json.Number, which a request decoder using

View File

@@ -9,6 +9,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time"
"git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/phrasebook"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
@@ -265,6 +266,22 @@ func isEmptyValue(val any) bool {
if val == nil { if val == nil {
return true 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) rv := reflect.ValueOf(val)
switch rv.Kind() { switch rv.Kind() {
case reflect.Ptr, reflect.Interface: case reflect.Ptr, reflect.Interface: