feat(11.1-04): add the Database section and the core Services pages
- docs/database: models, migrations, queries and pagination, relations, casts and validation, attachments and transactions (lagoon.Transaction, lagoon.AfterCommit, nested savepoints, lagoon.OnDatabase) - docs/services: configuration, events, routing with auth groups, rate limiting, authentication, the OAuth server, mail and localization - runnable Examples for lagoon, attach, compass, surf, wire, bouncer, wristband, postcard, phrasebook and festival; lagoon TestDocs* regions run on the package's Postgres harness through DocsDB - 15 new required pages
This commit is contained in:
147
docs/database/models.md
Normal file
147
docs/database/models.md
Normal file
@@ -0,0 +1,147 @@
|
||||
---
|
||||
title: Models
|
||||
description: Define models as GORM structs with lagoon helpers for mass assignment, hidden columns and lifecycle hooks, and keep the models package a leaf.
|
||||
section: database
|
||||
order: 10
|
||||
---
|
||||
# Models
|
||||
|
||||
A WinterCMS model extends Eloquent and describes its behaviour with properties such as `$fillable`, `$hidden` and `$jsonable`. A SummerCMS model is a plain GORM struct in the plugin's `models` package, and [lagoon](../../modules/lagoon/README.md) supplies the Eloquent conventions that GORM does not have: allow-listed mass assignment, JSON and encrypted columns, Laravel-style validation and pagination.
|
||||
|
||||
The data layer supports PostgreSQL only. `lagoon.Open` refuses a database whose default collation is not the ICU `pl-PL` locale (`lagoon.CheckLocale`), so text ordering is the same in every query without a `COLLATE` clause. Create the database with that locale, as shown in [Installation](../setup/installation.md).
|
||||
|
||||
## Defining a model
|
||||
|
||||
`summer make:model acme.blog Post` writes `models/post.go` and a migration that creates the table. The model is an ordinary struct with `gorm` column tags. Keep the WinterCMS table name with a `TableName` method, and use the lagoon column types where Eloquent used casts:
|
||||
|
||||
```go src=modules/lagoon/example_test.go#Post
|
||||
// Post is the acme.blog post model: a plain GORM struct with lagoon column
|
||||
// types.
|
||||
type Post struct {
|
||||
ID uint `gorm:"column:id;primaryKey" json:"id"`
|
||||
Title string `gorm:"column:title" json:"title"`
|
||||
Slug string `gorm:"column:slug" json:"slug"`
|
||||
Views int `gorm:"column:views" json:"views"`
|
||||
Tags lagoon.Jsonable[[]string] `gorm:"column:tags" json:"-"`
|
||||
APIToken lagoon.Encrypted `gorm:"column:api_token" json:"-"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"-"`
|
||||
|
||||
Categories []Category `gorm:"many2many:acme_blog_post_categories" json:"-"`
|
||||
}
|
||||
```
|
||||
|
||||
```go src=modules/lagoon/example_test.go#Post.TableName
|
||||
// TableName keeps the WinterCMS table name.
|
||||
func (Post) TableName() string { return "acme_blog_posts" }
|
||||
```
|
||||
|
||||
The column types are described in [Casts and validation](casts-and-validation.md). Relations are ordinary GORM fields; see [Relations](relations.md).
|
||||
|
||||
## Mass assignment
|
||||
|
||||
`$fillable` becomes a `Fillable` method that returns the column names mass assignment may set. The model implements `lagoon.HasFillable`:
|
||||
|
||||
```go src=modules/lagoon/example_test.go#Post.Fillable
|
||||
// Fillable is the Go form of $fillable: the keys mass assignment may set.
|
||||
func (Post) Fillable() []string { return []string{"title", "views"} }
|
||||
```
|
||||
|
||||
`lagoon.Fill` copies the keys of a request map onto the model, but only keys that are also in the allow-list you pass. Other keys are dropped without an error, as Eloquent does. Outside production, pass `false` as the last argument and each dropped key is logged once, which catches typos in development.
|
||||
|
||||
A value that does not fit its column, such as text for an integer field, is a `lagoon.FillTypeError` whose `Key` names the column, so a handler can answer it as a validation error on that field:
|
||||
|
||||
```go src=modules/lagoon/example_test.go#ExampleFill
|
||||
input := map[string]any{"title": "Hello", "views": 3, "slug": "forged"}
|
||||
var post Post
|
||||
// production=false logs each dropped key once, to catch typos in development.
|
||||
if err := lagoon.Fill(&post, post.Fillable(), input, true); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
fmt.Printf("%q %q %d\n", post.Title, post.Slug, post.Views)
|
||||
|
||||
err := lagoon.Fill(&post, post.Fillable(), map[string]any{"views": "many"}, true)
|
||||
var typeErr *lagoon.FillTypeError
|
||||
if errors.As(err, &typeErr) {
|
||||
fmt.Println("invalid value for", typeErr.Key)
|
||||
}
|
||||
// Output:
|
||||
// "Hello" "" 3
|
||||
// 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`.
|
||||
|
||||
## Creating a record
|
||||
|
||||
A handler usually validates the input, fills the model and creates it. `lagoon.Validate` is described in [Casts and validation](casts-and-validation.md):
|
||||
|
||||
```go src=modules/lagoon/example_test.go#create-post
|
||||
rules := map[string]string{
|
||||
"title": "required|max:255|unique:acme_blog_posts",
|
||||
"views": "nullable|integer|min:0",
|
||||
}
|
||||
var post Post
|
||||
errs, err := lagoon.Validate(ctx, db, &post, rules, input, nil)
|
||||
if err != nil || errs != nil {
|
||||
return nil, errs, err
|
||||
}
|
||||
if err := lagoon.Fill(&post, post.Fillable(), input, false); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := db.WithContext(ctx).Create(&post).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &post, nil, nil
|
||||
```
|
||||
|
||||
## Hidden columns
|
||||
|
||||
`$hidden` becomes a `Hidden` method, the `lagoon.HasHidden` interface. It lists the columns that must never appear in a JSON response. The list documents the rule; the `json:"-"` tag on each field is what enforces it, because the Go JSON encoder reads struct tags, not methods:
|
||||
|
||||
```go src=modules/lagoon/example_test.go#Post.Hidden
|
||||
// Hidden is the Go form of $hidden. The json:"-" tags are what keep these
|
||||
// columns out of JSON; the list documents them for tooling.
|
||||
func (Post) Hidden() []string { return []string{"tags", "api_token", "deleted_at"} }
|
||||
```
|
||||
|
||||
```go src=modules/lagoon/example_test.go#ExampleHasHidden
|
||||
post := Post{ID: 1, Title: "Hello", APIToken: lagoon.NewEncrypted("s3cret")}
|
||||
out, _ := json.Marshal(post)
|
||||
fmt.Println(string(out))
|
||||
var _ lagoon.HasHidden = post
|
||||
// Output:
|
||||
// {"id":1,"title":"Hello","slug":"","views":0}
|
||||
```
|
||||
|
||||
A `lagoon.Encrypted` column is also redacted when it is marshalled by mistake, so a secret never reaches a response even without the tag.
|
||||
|
||||
> [!TIP]
|
||||
> Ported endpoints rarely marshal the model itself. Build a response struct with exactly the fields the API returns, and use the [wire](../../modules/wire/README.md) helpers for Carbon-style timestamps and `[]` for empty lists. See [Routing](../services/routing.md).
|
||||
|
||||
## Lifecycle hooks
|
||||
|
||||
GORM calls hook methods by name, so a model that needs `beforeCreate` or `beforeDelete` defines `BeforeCreate` or `BeforeDelete` with GORM's signature. lagoon names them as interfaces (`lagoon.HasBeforeCreate`, `lagoon.HasBeforeSave`, `lagoon.HasBeforeDelete`, `lagoon.HasAfterDelete`) so a compile-time assertion can check the signature. `lagoon.HasBeforeValidate` is the WinterCMS `beforeValidate` hook. GORM does not call it: the admin form and settings saves call it before they validate, and your own handlers call it when they need it.
|
||||
|
||||
A hook that only touches the model itself stays on the model:
|
||||
|
||||
```go src=modules/lagoon/example_test.go#Post.BeforeCreate
|
||||
// BeforeCreate fills the slug from the title. A hook that only touches the
|
||||
// model stays on the model.
|
||||
func (p *Post) BeforeCreate(tx *gorm.DB) error {
|
||||
if p.Slug == "" {
|
||||
p.Slug = strings.ReplaceAll(strings.ToLower(strings.TrimSpace(p.Title)), " ", "-")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
GORM runs the hook inside the transaction of the write, and an error from it aborts the write.
|
||||
|
||||
## The models package is a leaf
|
||||
|
||||
A plugin's `models` package never imports another package of the same plugin. `summer build` fails when it does. This keeps the dependency graph one way: `classes`, `controllers`, `console` and `jobs` import `models`, never the reverse.
|
||||
|
||||
The rule decides where two kinds of WinterCMS model code go when you port a plugin:
|
||||
|
||||
- Casts and other types the model owns, such as JSON value objects, move into `models`.
|
||||
- A hook that calls a service, such as a model event that dispatches a job, broadcasts or clears a cache, becomes a GORM callback registered from the plugin's `Boot` step or from `classes`. See [Transactions](transactions.md) for registering callbacks with `lagoon.OnDatabase` and deferring their side effects until the write commits.
|
||||
Reference in New Issue
Block a user