Database

Relations

Declare relations as GORM associations, write pivot tables with business columns explicitly, and cascade soft deletes inside the parent delete.

On this page

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:

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:

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.

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:

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.