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,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).