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:
Jakub Zych
2026-09-30 22:59:25 +02:00
parent 9d37d56486
commit efb35a2d35
28 changed files with 3138 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
---
title: Attachments
description: Attach files to models through WinterCMS-compatible system_files rows, serve originals and thumbnails, and delete blobs only after the transaction commits.
section: database
order: 60
---
# Attachments
WinterCMS attaches files to models with `$attachOne` and `$attachMany`, storing a row per file in `system_files` and the bytes on a disk. The `attach` package of [lagoon](../../modules/lagoon/README.md) ports the same table and storage layout, so files uploaded to a WinterCMS site keep working after a data copy. The bytes live in a [gocloud.dev](https://gocloud.dev/howto/blob/) bucket, configured with the `storage.uploads.*` keys.
## The system_files row
`attach.File` is the `system_files` row. It links a file to its owner with the WinterCMS polymorphic columns: `AttachmentType` holds the owner's morph name and `AttachmentID` its ID, as a string, and `Field` names the relation, such as `cover` or `gallery`. The `system_files` table is created by the framework migrations, so a plugin does not migrate it.
An owner model implements `attach.Owner`. Its `MorphName` returns the PHP class name, so the `attachment_type` values copied from WinterCMS still match:
```go src=modules/lagoon/attach/example_test.go#Post.MorphName
func (Post) MorphName() string { return `Acme\Blog\Models\Post` }
```
## Storing and serving files
The original is stored under WinterCMS's partitioned key: `attach.PartitionDirectory` splits the first nine characters of the random `disk_name` into three directories, and `attach.BlobKey` appends the name. `attach.File.Thumb` returns the public URL of a thumbnail, generating it in the same partition on first use and reusing it afterwards:
```go src=modules/lagoon/attach/example_test.go#ExampleFile_Thumb
ctx := context.Background()
dir, err := os.MkdirTemp("", "acme-config")
if err != nil {
fmt.Println(err)
return
}
defer os.RemoveAll(dir)
// storage.uploads.bucket_url is a file:// URL in production; mem:// keeps
// the example in memory.
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "development", Environ: []string{}})
if err != nil {
fmt.Println(err)
return
}
_ = cfg.Set("storage.uploads.bucket_url", "mem://")
bucket, err := attach.OpenBucket(ctx, cfg)
if err != nil {
fmt.Println(err)
return
}
defer bucket.Close()
// The system_files row of a post's cover image.
var owner attach.Owner = Post{ID: 1}
f := attach.File{
ID: 7,
DiskName: "5f1d0c2e9a7b4c3d8e6f.jpg",
FileName: "cover.jpg",
ContentType: "image/jpeg",
Field: "cover",
AttachmentType: owner.MorphName(),
AttachmentID: "1",
}
// The original is stored under its partitioned WinterCMS key.
var img bytes.Buffer
if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 640, 480)), nil); err != nil {
fmt.Println(err)
return
}
if err := bucket.WriteAll(ctx, attach.BlobKey(f.DiskName), img.Bytes(), nil); err != nil {
fmt.Println(err)
return
}
fmt.Println(attach.BlobKey(f.DiskName))
// A thumbnail is generated on first use and reused afterwards.
url, err := f.Thumb(ctx, bucket, 200, 200, "crop")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(url)
// Output:
// 5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.jpg
// /storage/uploads/5f1/d0c/2e9/thumb_7_200_200_0_0_crop.jpg
```
URLs start with `storage.uploads.public_path_prefix` (`/storage/uploads` by default). `attach.StaticHandler` serves originals and thumbnails under that prefix; `attach.StaticHandlerPublic` does the same and answers 404 for a row whose `is_public` flag is false. Mount the gated handler when a bucket holds any private file.
> [!WARNING]
> Serve uploads from a separate origin, or at least never mount the ungated handler on the application's own origin. An uploaded file served with its own content type from the API's origin can run script in that origin.
## Deleting files after commit
A rolled-back transaction can restore a row but not the bytes of a deleted blob. Deleting an owner's files is therefore split in two:
1. Inside the transaction that deletes the owner, `attach.DeleteForOwner` deletes the owner's `system_files` rows and passes their blob keys to a callback. The callback only records the keys; it must not delete anything.
2. After the transaction commits, `attach.DeleteKeys` deletes the originals and their thumbnails from the bucket.
A soft-deleted owner keeps its rows and files, so only a force delete (`Unscoped().Delete`) runs this. `lagoon.AfterCommit` is a natural place for the second step; see [Transactions](transactions.md).

View File

@@ -0,0 +1,104 @@
---
title: Casts and validation
description: Store JSON and encrypted columns with lagoon.Jsonable and lagoon.Encrypted, and validate input with Laravel-style rule strings through lagoon.Validate.
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.
## 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."]}
```
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.
> [!NOTE]
> A failed numeric range check is currently always reported with the `max` message, even when the value is below `min`, and a `min`-only rule then shows an empty limit. Check range errors by field, not by message text.
`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.

115
docs/database/migrations.md Normal file
View File

@@ -0,0 +1,115 @@
---
title: Migrations
description: Ship a plugin's schema as an ordered gormigrate set through pact.HasMigrations, and run, inspect and roll back migrations per plugin.
section: database
order: 20
---
# Migrations
WinterCMS plugins keep their schema in the `updates/` directory and list the steps in `version.yaml`. A SummerCMS plugin keeps the same `updates/` directory, but each step is a Go migration in a [gormigrate](https://github.com/go-gormigrate/gormigrate) set that the plugin returns from `pact.HasMigrations`. [lagoon](../../modules/lagoon/README.md) runs the sets and records each plugin's history in its own table.
## Writing migrations
`summer make:model` writes a create-table migration with the model, and `summer make:migration` writes an empty one to fill in:
```sh
summer make:migration acme.blog AddPublishedAt
```
The file is `updates/<timestamp>_add_published_at.go`, with a migration ID that starts with the same timestamp. `summer build` generates the plugin's list of migrations in file name order, so the timestamp is also the order in which they run. The scaffolded plugin returns that generated list from its `Migrations` method.
A migration has an ID, a `Migrate` function and a `Rollback` function, both given the transaction to run in. Write DDL as SQL with `tx.Exec`: the migration then says exactly what the database gets, and it keeps working when the model struct changes later. This is the whole set of an example plugin, written out by hand:
```go src=modules/lagoon/example_test.go#BlogPlugin.Migrations
// Migrations returns the plugin's schema as an ordered gormigrate set. In a
// scaffolded plugin each migration is a file in updates/ and this list is
// generated in file name order.
func (p *BlogPlugin) Migrations() []*gormigrate.Migration {
return []*gormigrate.Migration{
{
ID: "20260101000100_create_posts",
Migrate: func(tx *gorm.DB) error {
return tx.Exec(`CREATE TABLE acme_blog_posts (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT NOT NULL,
views INTEGER NOT NULL DEFAULT 0,
tags TEXT,
api_token TEXT,
deleted_at TIMESTAMPTZ
)`).Error
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP TABLE IF EXISTS acme_blog_posts`).Error
},
},
{
ID: "20260101000200_create_comments_and_categories",
Migrate: func(tx *gorm.DB) error {
for _, stmt := range []string{
`CREATE TABLE acme_blog_comments (id SERIAL PRIMARY KEY, post_id INTEGER NOT NULL, body TEXT NOT NULL, deleted_at TIMESTAMPTZ)`,
`CREATE TABLE acme_blog_categories (id SERIAL PRIMARY KEY, name TEXT NOT NULL)`,
`CREATE TABLE acme_blog_post_categories (post_id INTEGER NOT NULL, category_id INTEGER NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (post_id, category_id))`,
} {
if err := tx.Exec(stmt).Error; err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP TABLE IF EXISTS acme_blog_post_categories, acme_blog_categories, acme_blog_comments`).Error
},
},
}
}
```
Treat an ID as permanent once the migration has run anywhere. gormigrate records the IDs it has applied, so renaming one makes it run again.
## Running migrations
The application binary has the migration commands:
```sh
./bin/acme migrate
./bin/acme migrate:status
./bin/acme migrate:rollback --plugin acme.blog
```
`migrate` runs the framework's own sets first (file attachments, the admin users and roles, and the job queue), then each plugin's set in plugin activation order, so a plugin's migrations run after those of the plugins it requires. Each plugin has its own history table, `summer_migrations_<plugin id>` with dots replaced by underscores, as `lagoon.HistoryTableName` returns.
`migrate:rollback` rolls back the last applied migration of one plugin. Without `--plugin` it picks the last activated plugin that has migrations. To fix a migration you just wrote, roll it back, edit it and run `migrate` again.
`migrate:status` prints each plugin's history table and applied IDs.
The same operations are Go functions, which is how tests migrate a fresh database:
```go src=modules/lagoon/example_test.go#migrate
plugins := []party.Plugin{&BlogPlugin{}}
if err := lagoon.Migrate(db, plugins); err != nil {
return nil, err
}
rows, err := lagoon.Status(db, plugins)
if err != nil {
return nil, err
}
for _, row := range rows {
lines = append(lines, fmt.Sprintf("%s %s %v", row.Plugin, row.Table, row.IDs))
}
if err := lagoon.RollbackLast(db, plugins, "acme.blog"); err != nil {
return nil, err
}
```
`lagoon.Migrate`, `lagoon.Status` and `lagoon.RollbackLast` take the activated plugins; a plugin that does not implement `pact.HasMigrations` is skipped.
## Porting version.yaml
WinterCMS runs a plugin's update scripts by version number and seeds data from the same files. When you port a plugin:
- Fold the existing tables into one create migration per table, matching the final PHP schema column for column, so data copied from the PHP database fits.
- Keep data changes (backfills, seeds) as their own migrations, written in SQL.
- Give every `Rollback` a real inverse. It is what makes `migrate:rollback` safe while you develop.
The migration commands open the database through `database.dsn` and load `app.key`; see [Configuration](../setup/configuration.md).

147
docs/database/models.md Normal file
View 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.

View File

@@ -0,0 +1,81 @@
---
title: Queries and pagination
description: Query models with GORM, sort by a client-chosen column safely with lagoon.OrderBy, and return Laravel-shaped pages with lagoon.Paginate.
section: database
order: 30
---
# Queries and pagination
Queries are plain GORM: `Where`, `Joins`, `Preload`, `Count`, `Find` and the rest work as the [GORM documentation](https://gorm.io/docs/) describes. [lagoon](../../modules/lagoon/README.md) adds two helpers for list endpoints, which almost always let the client choose the sort order and ask for one page.
Pass the request context to every query with `WithContext`, so a cancelled request stops its query.
## Sorting by a column the client names
A list endpoint usually takes `?sort=title&order=desc`. Never pass those values to `Order` directly: a column name is SQL, not a bound parameter. `lagoon.OrderBy` appends the ORDER BY only when the column is in an allow-list you give it and the direction is `asc` or `desc`, and returns an error for anything else:
```go src=modules/lagoon/example_test.go#ExampleOrderBy
// A dry-run handle shows the SQL without a database.
db, _ := gorm.Open(postgres.New(postgres.Config{DSN: "host=127.0.0.1"}), &gorm.Config{DryRun: true, DisableAutomaticPing: true})
allowed := []string{"title", "views"}
q, err := lagoon.OrderBy(db.Model(&Post{}), "views", "desc", allowed)
if err != nil {
fmt.Println(err)
return
}
var posts []Post
fmt.Println(q.Find(&posts).Statement.SQL.String())
_, err = lagoon.OrderBy(db, "api_token", "asc", allowed)
fmt.Println(err)
_, err = lagoon.OrderBy(db, "title", "asc; DROP TABLE acme_blog_posts", allowed)
fmt.Println(err)
// Output:
// SELECT * FROM "acme_blog_posts" WHERE "acme_blog_posts"."deleted_at" IS NULL ORDER BY views DESC
// lagoon: order column "api_token" is not allow-listed
// lagoon: order direction "asc; DROP TABLE acme_blog_posts" is not allow-listed
```
Answer the error as a validation failure (422). The column must match an allow-list entry exactly, so list the qualified name (`acme_blog_posts.title`) when the query joins another table.
`lagoon.OrderBy` never adds a `COLLATE` clause. Text sorts by the database's ICU `pl-PL` default collation, which lagoon checks when it connects.
## Pagination
`lagoon.Paginate` wraps the rows of one page in the envelope Laravel's paginator produces for the API: `data` and a `meta` object with `current_page`, `last_page`, `per_page` and `total`. You run the count and the page query yourself, so the query stays under your control:
```go src=modules/lagoon/example_test.go#list-posts
q, err := lagoon.OrderBy(db.WithContext(ctx).Model(&Post{}), sort, dir, []string{"title", "views"})
if err != nil {
return lagoon.Page[Post]{}, err // answer 422: the client asked for a column it may not sort by
}
var total int64
if err := q.Count(&total).Error; err != nil {
return lagoon.Page[Post]{}, err
}
var posts []Post
if err := q.Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil {
return lagoon.Page[Post]{}, err
}
return lagoon.Paginate(posts, page, perPage, total), nil
```
The result is a `lagoon.Page` whose `lagoon.PageMeta` marshals to the Laravel field names:
```go src=modules/lagoon/example_test.go#ExamplePaginate
rows := []map[string]any{{"id": 3, "title": "Third"}}
page := lagoon.Paginate(rows, 2, 2, 3)
out, _ := json.Marshal(page)
fmt.Println(string(out))
empty, _ := json.Marshal(lagoon.Paginate[map[string]any](nil, 1, 15, 0))
fmt.Println(string(empty))
// Output:
// {"data":[{"id":3,"title":"Third"}],"meta":{"current_page":2,"last_page":2,"per_page":2,"total":3}}
// {"data":[],"meta":{"current_page":1,"last_page":1,"per_page":15,"total":0}}
```
A nil slice becomes `[]`, and a zero or negative page size gives one page instead of dividing by zero. There is no `links` block. When a ported endpoint's response has a different shape, build that shape yourself: the existing clients define the contract.
Clamp `page` and `per_page` from the request before you use them, for example to at least 1 and at most 100, so a client cannot ask for the whole table in one page.

View File

@@ -0,0 +1,73 @@
---
title: Relations
description: Declare relations as GORM associations, write pivot tables with business columns explicitly, and cascade soft deletes inside the parent delete.
section: database
order: 40
---
# Relations
Eloquent relations (`$belongsTo`, `$hasMany`, `$belongsToMany`) become GORM associations: struct fields whose type is another model, configured with `gorm` tags. Load them with `Preload` and filter through them with `Joins`, as the [GORM association documentation](https://gorm.io/docs/associations.html) describes. [lagoon](../../modules/lagoon/README.md) adds two helpers for the cases GORM leaves open.
## Many-to-many with a pivot model
A `many2many` field joins two models through a join table. When the join table has columns of its own, such as a sort order or a role, describe it as a model:
```go src=modules/lagoon/example_test.go#PostCategory
// PostCategory is the pivot model: the join table has a business column.
type PostCategory struct {
PostID uint `gorm:"column:post_id;primaryKey"`
CategoryID uint `gorm:"column:category_id;primaryKey"`
SortOrder int `gorm:"column:sort_order"`
}
```
and register it for the field with `lagoon.RegisterJoinTable`, which calls GORM's `SetupJoinTable` and returns an error instead of panicking on a nil handle. GORM's association mode cannot set the extra columns, so write the pivot rows yourself: delete the post's rows and insert the new ones in one transaction, in the same transaction as the parent save when there is one. To read the rows in pivot order, join the pivot table:
```go src=modules/lagoon/example_test.go#pivot
if err := lagoon.RegisterJoinTable(db, &Post{}, "Categories", &PostCategory{}); err != nil {
return nil, err
}
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.Where("post_id = ?", post.ID).Delete(&PostCategory{}).Error; err != nil {
return err
}
rows := make([]PostCategory, len(categoryIDs))
for i, id := range categoryIDs {
rows[i] = PostCategory{PostID: post.ID, CategoryID: id, SortOrder: i}
}
return tx.Create(&rows).Error
})
if err != nil {
return nil, err
}
var categories []Category
err = db.WithContext(ctx).
Joins("JOIN acme_blog_post_categories pc ON pc.category_id = acme_blog_categories.id").
Where("pc.post_id = ?", post.ID).
Order("pc.sort_order").
Find(&categories).Error
return categories, err
```
After `lagoon.RegisterJoinTable`, `Preload("Categories")` also loads the categories through the pivot model, without an order.
> [!WARNING]
> Do not order a `Preload` of a many-to-many field by a pivot column. GORM preloads the related rows in a query that does not join the pivot table, so the query fails. Use a join, as above.
## Soft deletes and cascades
A model with a `gorm.DeletedAt` field is soft-deleted: `Delete` sets `deleted_at`, and queries skip deleted rows unless you call `Unscoped`. This is the SummerCMS form of the `SoftDelete` trait.
WinterCMS cascades a delete to dependent records through `$hasMany` options. In SummerCMS, the parent model does it in its `BeforeDelete` hook with `lagoon.WithSoftDeleteCascade`. GORM already runs the hook inside the transaction of the parent's delete, so the cascade commits or rolls back with it, and an error from the cascade aborts the parent delete:
```go src=modules/lagoon/example_test.go#Post.BeforeDelete
// BeforeDelete soft-deletes the post's comments in the transaction of the
// post's own delete; an error aborts that delete.
func (p *Post) BeforeDelete(tx *gorm.DB) error {
return lagoon.WithSoftDeleteCascade(tx, func(tx *gorm.DB) error {
return tx.Where("post_id = ?", p.ID).Delete(&Comment{}).Error
})
}
```
Deleting a post then soft-deletes its comments in the same transaction. Files attached to a record are deleted differently, after the transaction commits; see [Attachments](attachments.md).

View File

@@ -0,0 +1,91 @@
---
title: Transactions
description: Run writes in lagoon.Transaction, defer side effects with lagoon.AfterCommit until the commit, and install GORM callbacks from Boot with lagoon.OnDatabase.
section: database
order: 70
---
# Transactions
Laravel's `DB::transaction` runs a closure in a transaction, and `DB::afterCommit` defers work until it commits. [lagoon](../../modules/lagoon/README.md) has the same pair, `lagoon.Transaction` and `lagoon.AfterCommit`, and the rest of the framework relies on them: realtime broadcasts, search index updates and blob deletions wait for the commit, so no client hears about a row that was rolled back.
## Running a transaction
`lagoon.Transaction` runs a function in a transaction and commits when it returns `nil`. Inside the function, use the `ctx` and `tx` it receives for every write. Work registered with `lagoon.AfterCommit` runs, in registration order, only after the commit succeeds; when the function returns an error, the transaction rolls back and the work is dropped:
```go src=modules/lagoon/example_test.go#publish
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.Model(&Post{}).Where("id = ?", id).Update("title", "Published").Error; err != nil {
return err
}
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, db *gorm.DB) {
*log = append(*log, fmt.Sprintf("post %d published", id)) // broadcast, index, send mail...
})
if fail {
return errors.New("rolled back") // the AfterCommit work never runs
}
return nil
})
```
The callback receives a database handle with an empty statement, so a query it runs never continues from the written model's statement. A panicking callback is logged and does not turn a committed write into an error.
`lagoon.AfterCommit` behaves differently depending on where it is called:
| Called | The work runs |
|--------|---------------|
| Inside `lagoon.Transaction` | After the outermost transaction commits; never after a rollback. |
| In a GORM callback of a single-statement write (GORM's own implicit transaction) | After GORM commits that write; never when the write fails. |
| Inside a plain `gorm.DB.Transaction` or another transaction lagoon did not open | Never. lagoon cannot see whether that transaction commits, so it logs a warning and skips the work. |
| Outside any transaction | Immediately. |
The third row is deliberate: running the work early could announce a write that later rolls back. When code in a transaction needs after-commit work, open the transaction with `lagoon.Transaction`.
## Nested transactions
A `lagoon.Transaction` inside another becomes a savepoint. Its after-commit work joins the outer transaction's only when its own function succeeds, so work dropped with a failed savepoint never runs:
```go src=modules/lagoon/example_test.go#nested
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "outer") })
// A nested Transaction is a savepoint. Pass it the outer tx: given
// the root db handle it returns an error instead.
_ = lagoon.Transaction(ctx, tx, func(ctx context.Context, tx *gorm.DB) error {
lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "dropped") })
return errors.New("savepoint rolled back")
})
return lagoon.Transaction(ctx, tx, func(ctx context.Context, tx *gorm.DB) error {
lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { *log = append(*log, "inner") })
return nil
})
})
```
Pass the nested call the outer transaction's `tx`. Given the root database handle instead, the nested `lagoon.Transaction` returns an error without running its function. Otherwise it would open a second, independent transaction whose after-commit work would wait for the outer one.
## Callbacks registered at boot
A hook that calls a service, such as a broadcast or a job dispatch, is a GORM callback rather than a model method (see [Models](models.md)). A plugin registers it from `Boot`, but `Boot` runs before the `serve` command opens the database. `lagoon.OnDatabase` bridges the gap: it runs your function as soon as the database is published, immediately when it already is.
Register the callback before GORM's `gorm:commit_or_rollback_transaction` step and defer its side effect with `lagoon.AfterCommit`:
```go src=modules/lagoon/example_test.go#on-database
return lagoon.OnDatabase(app, func(_ *sql.DB, gdb *gorm.DB) error {
return gdb.Callback().Create().After("gorm:create").Before("gorm:commit_or_rollback_transaction").Register("acme:post_created", func(db *gorm.DB) {
post, ok := db.Statement.Dest.(*Post)
if db.Error != nil || !ok {
return
}
lagoon.AfterCommit(db.Statement.Context, db, func(ctx context.Context, db *gorm.DB) {
*log = append(*log, "created "+post.Slug) // runs only once the insert is committed
})
})
})
```
> [!WARNING]
> Register such a callback with a `Before("gorm:commit_or_rollback_transaction")` constraint, as above. lagoon runs a single-statement write's after-commit work from its own callback right after that commit step; a callback that GORM sorts after it buffers work that is never run, without an error.
## Boot-time work that needs the database
`lagoon.OnDatabase` is also the place for anything else a plugin must do with the database handle at start-up, such as registering a join table with `lagoon.RegisterJoinTable`. The error of a queued function is returned by `lagoon.Publish`, so a failing callback stops the start-up.