RelationContract (hasMany) and FieldRelationContract (belongsTo) accept ClearOnChange, a list of nullable columns set to NULL whenever the relation changes the foreign key: a hasMany link and unlink (deferred ones included) clear them on the child, and a form save that moves a belongsTo key to another record or to none clears them on the record unless the same save writes the column through its own field. Boot refuses the option on a belongsToMany and for a missing, non-nullable, foreign-key or primary-key column. The zero value changes nothing.
223 lines
7.9 KiB
Go
223 lines
7.9 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
)
|
|
|
|
// cocTeam is the related model of the ClearOnChange belongsTo fixture.
|
|
type cocTeam struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Name string `gorm:"column:name"`
|
|
}
|
|
|
|
func (cocTeam) TableName() string { return "cabana_coc_teams" }
|
|
|
|
// cocMember belongs to a team through the protected organisation_id; its
|
|
// role only makes sense inside that team.
|
|
type cocMember struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Name string `gorm:"column:name"`
|
|
OrganisationID *uint `gorm:"column:organisation_id"`
|
|
Role *string `gorm:"column:role"`
|
|
Note *string `gorm:"column:note"`
|
|
}
|
|
|
|
func (cocMember) TableName() string { return "cabana_coc_members" }
|
|
func (cocMember) Fillable() []string { return []string{"name", "role", "note"} }
|
|
func (cocMember) Rules() map[string]string { return map[string]string{"name": "required"} }
|
|
|
|
type cocController struct {
|
|
relations []FieldRelationContract
|
|
}
|
|
|
|
func (cocController) ID() string { return "acme.demo.members" }
|
|
func (cocController) ModelName() string { return "Member" }
|
|
func (cocController) ConfigDir() string { return "controllers/members" }
|
|
func (cocController) NewRecord() any { return &cocMember{} }
|
|
func (c cocController) AdminFieldRelations() []FieldRelationContract {
|
|
return c.relations
|
|
}
|
|
|
|
// cocTeamContract is the team field's contract with ClearOnChange role.
|
|
func cocTeamContract(clear ...string) FieldRelationContract {
|
|
return FieldRelationContract{
|
|
Field: "team", Kind: relationKindBelongsTo, NewRelated: func() any { return &cocTeam{} },
|
|
ForeignKey: "organisation_id", WritableForeignKey: true, ClearOnChange: clear,
|
|
}
|
|
}
|
|
|
|
func cocFS() fstest.MapFS {
|
|
return fstest.MapFS{
|
|
"controllers/members/config_list.yaml": &fstest.MapFile{Data: []byte("modelClass: Member\nlist: ~/plugins/acme/demo/models/member/columns.yaml\n")},
|
|
"controllers/members/config_form.yaml": &fstest.MapFile{Data: []byte("name: members\nform: ~/plugins/acme/demo/models/member/fields.yaml\nmodelClass: Member\n")},
|
|
"models/member/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n")},
|
|
"models/member/fields.yaml": &fstest.MapFile{Data: []byte(`fields:
|
|
name:
|
|
label: Name
|
|
type: text
|
|
team:
|
|
label: Team
|
|
type: relation
|
|
nameFrom: name
|
|
role:
|
|
label: Role
|
|
type: text
|
|
note:
|
|
label: Note
|
|
type: text
|
|
`)},
|
|
}
|
|
}
|
|
|
|
func cocCompile(relations ...FieldRelationContract) (*CompiledController, error) {
|
|
reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: cocFS()}, ctl: cocController{relations: relations}}})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cc, _ := reg.Get("acme.demo.members")
|
|
return cc, nil
|
|
}
|
|
|
|
// TestClearOnChangeFieldBoot: ClearOnChange on a form relation field is
|
|
// refused at boot on a belongsToMany, and for a column the record lacks, a
|
|
// column that is not nullable, the foreign key and the primary key; the
|
|
// error names the controller, the field and the column.
|
|
func TestClearOnChangeFieldBoot(t *testing.T) {
|
|
if _, err := cocCompile(cocTeamContract("role", "note")); err != nil {
|
|
t.Fatalf("a valid ClearOnChange: %v", err)
|
|
}
|
|
many := cocTeamContract("role")
|
|
many.Kind, many.ForeignKey, many.WritableForeignKey = relationKindBelongsToMany, "", false
|
|
for name, tc := range map[string]struct {
|
|
contract FieldRelationContract
|
|
want string
|
|
}{
|
|
"belongsToMany": {many, "ClearOnChange is only valid on belongsTo"},
|
|
"missing column": {cocTeamContract("rank"), "ClearOnChange column rank is not a column of the model"},
|
|
"not nullable": {cocTeamContract("name"), "ClearOnChange column name is not nullable"},
|
|
"foreign key": {cocTeamContract("organisation_id"), "ClearOnChange column organisation_id is the foreign key"},
|
|
"primary key": {cocTeamContract("id"), "ClearOnChange column id is the primary key"},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
_, err := cocCompile(tc.contract)
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "acme.demo.members") || !strings.Contains(err.Error(), "field team") {
|
|
t.Fatalf("err = %v, want %q naming the controller and the field", err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestClearOnChangeBelongsTo: a save that changes a belongsTo key clears the
|
|
// contract's ClearOnChange columns unless the same save writes them; a save
|
|
// that keeps the key, and a contract without ClearOnChange, leave them.
|
|
func TestClearOnChangeBelongsTo(t *testing.T) {
|
|
_, db := newListService(t)
|
|
if err := db.Migrator().DropTable(&cocMember{}, &cocTeam{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.AutoMigrate(&cocMember{}, &cocTeam{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
red, blue := cocTeam{Name: "Red"}, cocTeam{Name: "Blue"}
|
|
for _, team := range []*cocTeam{&red, &blue} {
|
|
if err := db.Create(team).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
cc, err := cocCompile(cocTeamContract("role"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
svc := CRUDService{DB: db}
|
|
ctx := context.Background()
|
|
str := func(s string) *string { return &s }
|
|
seed := func(t *testing.T, team *uint, role string) uint {
|
|
t.Helper()
|
|
m := cocMember{Name: "M", OrganisationID: team, Role: str(role), Note: str("note")}
|
|
if err := db.Create(&m).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return m.ID
|
|
}
|
|
load := func(t *testing.T, id uint) cocMember {
|
|
t.Helper()
|
|
var m cocMember
|
|
if err := db.Take(&m, id).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return m
|
|
}
|
|
update := func(t *testing.T, cc *CompiledController, id uint, body map[string]any) cocMember {
|
|
t.Helper()
|
|
body["name"] = "M"
|
|
if _, err := svc.Update(ctx, cc, id, RecordInput{Body: body}); err != nil {
|
|
t.Fatalf("update %v: %v", body, err)
|
|
}
|
|
return load(t, id)
|
|
}
|
|
roleIs := func(t *testing.T, m cocMember, want string) {
|
|
t.Helper()
|
|
switch {
|
|
case want == "" && m.Role != nil:
|
|
t.Fatalf("role = %q, want NULL", *m.Role)
|
|
case want != "" && (m.Role == nil || *m.Role != want):
|
|
t.Fatalf("role = %v, want %q", m.Role, want)
|
|
}
|
|
if m.Note == nil || *m.Note != "note" {
|
|
t.Fatalf("a column outside ClearOnChange changed: note = %v", m.Note)
|
|
}
|
|
}
|
|
|
|
t.Run("another record clears", func(t *testing.T) {
|
|
m := update(t, cc, seed(t, &red.ID, "owner"), map[string]any{"team": blue.ID})
|
|
if m.OrganisationID == nil || *m.OrganisationID != blue.ID {
|
|
t.Fatalf("organisation_id = %v", m.OrganisationID)
|
|
}
|
|
roleIs(t, m, "")
|
|
})
|
|
t.Run("none clears", func(t *testing.T) {
|
|
m := update(t, cc, seed(t, &red.ID, "owner"), map[string]any{"team": nil})
|
|
if m.OrganisationID != nil {
|
|
t.Fatalf("organisation_id = %v", *m.OrganisationID)
|
|
}
|
|
roleIs(t, m, "")
|
|
})
|
|
t.Run("the same key keeps", func(t *testing.T) {
|
|
roleIs(t, update(t, cc, seed(t, &red.ID, "owner"), map[string]any{"team": red.ID}), "owner")
|
|
})
|
|
t.Run("no relation value keeps", func(t *testing.T) {
|
|
roleIs(t, update(t, cc, seed(t, &red.ID, "owner"), map[string]any{}), "owner")
|
|
})
|
|
t.Run("a submitted column wins", func(t *testing.T) {
|
|
roleIs(t, update(t, cc, seed(t, &red.ID, "owner"), map[string]any{"team": blue.ID, "role": "member"}), "member")
|
|
})
|
|
t.Run("a create that sets the key", func(t *testing.T) {
|
|
rec, err := svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "New", "team": red.ID, "note": "note"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id, _ := asUint(rec["id"])
|
|
m := load(t, id)
|
|
if m.OrganisationID == nil || *m.OrganisationID != red.ID {
|
|
t.Fatalf("organisation_id = %v", m.OrganisationID)
|
|
}
|
|
roleIs(t, m, "")
|
|
rec, err = svc.Create(ctx, cc, RecordInput{Body: map[string]any{"name": "New", "team": red.ID, "role": "admin", "note": "note"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id, _ = asUint(rec["id"])
|
|
roleIs(t, load(t, id), "admin")
|
|
})
|
|
t.Run("without ClearOnChange nothing is cleared", func(t *testing.T) {
|
|
plain, err := cocCompile(cocTeamContract())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
roleIs(t, update(t, plain, seed(t, &red.ID, "owner"), map[string]any{"team": blue.ID}), "owner")
|
|
})
|
|
}
|