Files
summercms/docs/database/models.md
Jakub Zych 037dc53030 feat(lagoon): per-query collation for OrderBy, drop the database locale check
- lagoon.OrderBy takes variadic lagoon.OrderOption values; lagoon.Collate(name)
  emits a validated, double-quoted COLLATE clause (e.g. "pl-x-icu")
- remove the exported CheckLocale and the ICU pl-PL check from Open and Use
- framework test containers and per-test databases are plain PostgreSQL
- lagoon README, root README and docs pages drop the locale requirement;
  queries-and-pagination gains a "Sorting with a collation" section
  backed by ExampleCollate
2026-10-01 09:51:03 +02:00

148 lines
7.6 KiB
Markdown

---
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 and puts no requirement on the database's default locale. A list that must sort text in one language's order passes a collation to `lagoon.OrderBy`, as described in [Queries and pagination](queries-and-pagination.md#sorting-with-a-collation).
## 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.