Files
summercms/docs/database/casts-and-validation.md
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

136 lines
10 KiB
Markdown

---
title: Casts and validation
description: Store JSON and encrypted columns with lagoon.Jsonable and lagoon.Encrypted, and validate input with lagoon.Validate and lagoon.ValidateRequest.
section: database
order: 50
---
# Casts and validation
Eloquent casts a column through `$jsonable`, `$casts` and the `encrypted` cast, and WinterCMS models validate with a `$rules` array. [lagoon](../../modules/lagoon/README.md) keeps both: column types that implement `sql.Scanner` and `driver.Valuer`, and a validator that reads the same rule strings.
## JSON columns
`lagoon.Jsonable` is the Go form of `$jsonable`. It stores any Go value as JSON text in a `TEXT` column (not `jsonb`, so data copied from a WinterCMS database fits as it is). The value lives in `Data`; `Valid` tells SQL `NULL` apart from an empty value, because a nil slice and an empty one are different rows:
```go src=modules/lagoon/example_test.go#ExampleJsonable
tags := lagoon.Jsonable[[]string]{Data: []string{"go", "cms"}, Valid: true}
v, _ := tags.Value()
fmt.Println(v)
var none lagoon.Jsonable[[]string] // Valid false stores SQL NULL
v, _ = none.Value()
fmt.Println(v)
var read lagoon.Jsonable[[]string]
_ = read.Scan(`["winter"]`)
fmt.Println(read.Get(), read.Valid)
// Output:
// ["go","cms"]
// <nil>
// [winter] true
```
Set `NullOnEmpty` on a slice or map column that should store `NULL` rather than `[]` or `{}` when it is empty. Pick the behaviour the PHP table already has.
## Encrypted columns
`lagoon.Encrypted` is the `encrypted` cast. It stores AES-256-GCM ciphertext under a key derived from `app.key`, and decrypts values written under any key in `app.previous_keys`, so you can rotate the key without rewriting every row at once. The plaintext has one accessor, `lagoon.Encrypted.Reveal`; printing the value or marshalling it to JSON always gives `[redacted]`:
```go src=modules/lagoon/example_test.go#ExampleEncrypted
// The application publishes the keys from app.key at boot; a test can
// install a key directly.
key := []byte("0123456789abcdef0123456789abcdef")
if err := lagoon.PublishEncryptionKeys(nil, key, nil); err != nil {
fmt.Println(err)
return
}
token := lagoon.NewEncrypted("s3cret")
stored, _ := token.Value() // what the column holds
fmt.Println(strings.Contains(fmt.Sprint(stored), "s3cret"))
var read lagoon.Encrypted
if err := read.Scan(stored); err != nil {
fmt.Println(err)
return
}
out, _ := json.Marshal(map[string]any{"api_token": read})
fmt.Println(read, string(out))
fmt.Println(read.Reveal())
// Output:
// false
// [redacted] {"api_token":"[redacted]"}
// s3cret
```
The application loads the keys once at boot (`lagoon.OpenFromApp` calls `lagoon.LoadAppKey` and `lagoon.PublishEncryptionKeys`). A missing or short `app.key` stops the application with an error; there is no default key. Generate one with `key:generate`, which prints a key and writes nothing:
```sh
./bin/acme key:generate
```
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.
## 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
`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:
```go src=modules/lagoon/example_test.go#ExampleValidate
rules := map[string]string{
"title": "required|max:10",
"views": "nullable|integer|max:1000",
}
input := map[string]any{"title": "", "views": 5000}
// A nil translator gives the built-in English messages; unique: rules
// need a database handle instead of nil.
errs, err := lagoon.Validate(context.Background(), nil, &Post{}, rules, input, nil)
if err != nil {
fmt.Println(err)
}
out, _ := json.Marshal(errs)
fmt.Println(string(out))
// Output:
// {"title":["The title field is required."],"views":["The views may not be greater than 1000."]}
```
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).
A handler validates before it fills and saves the model, as the create example on [Models](models.md) shows. Answer a non-nil error map with status 422 and the body shape the endpoint's existing clients expect.
## Request validation
`lagoon.Validate` checks a model's rules. A ported API endpoint has a different job: its 422 body has to match the one the PHP endpoint returns, message for message, in the request's language. `lagoon.ValidateRequest` does that by following Laravel 9's request validator rather than the model rules.
It takes the decoded request input and a rule table: a slice of `lagoon.RequestRule`, one per attribute, in the order the PHP controller declares them. Build each attribute's rules from the PHP rule string with `lagoon.ParseRules`, add `lagoon.In` for an `in` list whose values hold commas or quotes, and `lagoon.CustomRule` for a PHP closure rule. A closure's failure message is used exactly as the closure returns it. Keep rule tables in package variables: `lagoon.ParseRules` panics on an unknown rule, a wrong number of parameters or a regular expression that Go cannot compile, so a mistake stops the application at start-up instead of failing a request.
The behaviour follows Laravel:
- An attribute name may contain `*`. It is expanded against the input, so `posts.*.title` checks `posts.0.title`, `posts.1.title` and so on, and the messages name those attributes. A wildcard with nothing to expand checks nothing.
- The rules of an attribute run in order. After a failed `required` (or another implicit rule: `present`, `filled`, `accepted`) the attribute stops, so `{"posts":[]}` against `required|array|min:1` gives the single message `The posts field is required.`. With `bail`, any failure stops the attribute.
- The other rules run only when there is a value: they skip an absent attribute, a blank string, `null` under `nullable` and an absent key under `sometimes`.
- `min`, `max`, `size` and `between` measure what Laravel measures: the number on an `integer` or `numeric` attribute, compared exactly as a decimal; the number of elements of an array; the size of an uploaded file in kilobytes; otherwise the length in characters, not bytes. Each picks the matching message, such as `max.string` or `max.file`.
- `email` is PHP's `FILTER_VALIDATE_EMAIL`, the check WinterCMS uses by default, so `user@localhost` is refused. `date` accepts ISO 8601 dates and date-times, `Y/m/d`, `m/d/Y`, `d.m.Y` and `d-m-Y`, and refuses dates that do not exist. `after_or_equal` and `before_or_equal` take a date, `today`, `tomorrow`, `yesterday` or `now` (in UTC), or the name of another field.
- `exists:table,column` counts matching rows in the database, so it needs the transaction handle. Like Laravel it does not skip soft-deleted rows, and it does not run once the attribute already has a message.
- File rules (`file`, `image`, `mimes` and the size rules) take a `lagoon.UploadedFile`; `lagoon.UploadedFileFromHeader` builds one from a parsed multipart part. The type is read from the file content, not from its name.
The messages come from the `lagoon::validation` catalog, a copy of WinterCMS's validator messages in English and Polish, in the request locale. A message missing in Polish, such as the one for `after_or_equal`, comes out in English, as it does in WinterCMS. Attribute names show with spaces instead of underscores (`market price`), and expanded attributes keep their dotted name.
The result is Laravel's errors object, a map from attribute to messages, or `nil` when the input passes. Go maps have no order, so when an endpoint's body must list the attributes in PHP's order, `lagoon.ErrorKeys` returns them in that order for the rule table.