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

@@ -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. |