Files
summercms/docs/database/relations.md
Jakub Zych efb35a2d35 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
2026-09-30 22:59:25 +02:00

3.8 KiB

title, description, section, order
title description section order
Relations Declare relations as GORM associations, write pivot tables with business columns explicitly, and cascade soft deletes inside the parent delete. database 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 describes. lagoon 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:

// 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:

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:

// 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.