From ac20d9e222400d7ad1963251b4aa2c589afe6901 Mon Sep 17 00:00:00 2001 From: black-06 Date: Fri, 21 Apr 2023 22:09:38 +0800 Subject: [PATCH 1/7] fix: unit test (#6250) * fix: unit test * fix create test https://github.com/go-gorm/gorm/pull/6127#discussion_r1171214125 * style: rename to adaptorSerializerModel --- tests/create_test.go | 3 +++ tests/go.mod | 13 ++++++------- tests/serializer_test.go | 40 ++++++++++++++++++++++++++++++++-------- 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/tests/create_test.go b/tests/create_test.go index 75aa8cba..02613b72 100644 --- a/tests/create_test.go +++ b/tests/create_test.go @@ -556,6 +556,9 @@ func TestCreateWithAutoIncrementCompositeKey(t *testing.T) { Name string } + if err := DB.Migrator().DropTable(&CompositeKeyProduct{}); err != nil { + t.Fatalf("failed to migrate, got error %v", err) + } if err := DB.AutoMigrate(&CompositeKeyProduct{}); err != nil { t.Fatalf("failed to migrate, got error %v", err) } diff --git a/tests/go.mod b/tests/go.mod index 306a530e..f47d175f 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -6,15 +6,14 @@ require ( github.com/google/uuid v1.3.0 github.com/jackc/pgx/v5 v5.3.1 // indirect github.com/jinzhu/now v1.1.5 - github.com/lib/pq v1.10.7 + github.com/lib/pq v1.10.8 github.com/mattn/go-sqlite3 v1.14.16 // indirect - github.com/microsoft/go-mssqldb v0.20.0 // indirect - golang.org/x/crypto v0.7.0 // indirect - gorm.io/driver/mysql v1.4.7 + golang.org/x/crypto v0.8.0 // indirect + gorm.io/driver/mysql v1.5.0 gorm.io/driver/postgres v1.5.0 - gorm.io/driver/sqlite v1.4.4 - gorm.io/driver/sqlserver v1.4.2 - gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11 + gorm.io/driver/sqlite v1.5.0 + gorm.io/driver/sqlserver v1.4.3 + gorm.io/gorm v1.25.0 ) replace gorm.io/gorm => ../ diff --git a/tests/serializer_test.go b/tests/serializer_test.go index a040a4db..f1b8a336 100644 --- a/tests/serializer_test.go +++ b/tests/serializer_test.go @@ -22,12 +22,36 @@ type SerializerStruct struct { Roles3 *Roles `gorm:"serializer:json;not null"` Contracts map[string]interface{} `gorm:"serializer:json"` JobInfo Job `gorm:"type:bytes;serializer:gob"` - CreatedTime int64 `gorm:"serializer:unixtime;type:time"` // store time in db, use int as field type - UpdatedTime *int64 `gorm:"serializer:unixtime;type:time"` // store time in db, use int as field type + CreatedTime int64 `gorm:"serializer:unixtime;type:datetime"` // store time in db, use int as field type + UpdatedTime *int64 `gorm:"serializer:unixtime;type:datetime"` // store time in db, use int as field type CustomSerializerString string `gorm:"serializer:custom"` EncryptedString EncryptedString } +type SerializerPostgresStruct struct { + gorm.Model + Name []byte `gorm:"json"` + Roles Roles `gorm:"serializer:json"` + Roles2 *Roles `gorm:"serializer:json"` + Roles3 *Roles `gorm:"serializer:json;not null"` + Contracts map[string]interface{} `gorm:"serializer:json"` + JobInfo Job `gorm:"type:bytes;serializer:gob"` + CreatedTime int64 `gorm:"serializer:unixtime;type:timestamptz"` // store time in db, use int as field type + UpdatedTime *int64 `gorm:"serializer:unixtime;type:timestamptz"` // store time in db, use int as field type + CustomSerializerString string `gorm:"serializer:custom"` + EncryptedString EncryptedString +} + +func (*SerializerPostgresStruct) TableName() string { return "serializer_structs" } + +func adaptorSerializerModel(s *SerializerStruct) interface{} { + if DB.Dialector.Name() == "postgres" { + sps := SerializerPostgresStruct(*s) + return &sps + } + return s +} + type Roles []string type Job struct { @@ -81,8 +105,8 @@ func (c *CustomSerializer) Value(ctx context.Context, field *schema.Field, dst r func TestSerializer(t *testing.T) { schema.RegisterSerializer("custom", NewCustomSerializer("hello")) - DB.Migrator().DropTable(&SerializerStruct{}) - if err := DB.Migrator().AutoMigrate(&SerializerStruct{}); err != nil { + DB.Migrator().DropTable(adaptorSerializerModel(&SerializerStruct{})) + if err := DB.Migrator().AutoMigrate(adaptorSerializerModel(&SerializerStruct{})); err != nil { t.Fatalf("no error should happen when migrate scanner, valuer struct, got error %v", err) } @@ -127,8 +151,8 @@ func TestSerializer(t *testing.T) { func TestSerializerZeroValue(t *testing.T) { schema.RegisterSerializer("custom", NewCustomSerializer("hello")) - DB.Migrator().DropTable(&SerializerStruct{}) - if err := DB.Migrator().AutoMigrate(&SerializerStruct{}); err != nil { + DB.Migrator().DropTable(adaptorSerializerModel(&SerializerStruct{})) + if err := DB.Migrator().AutoMigrate(adaptorSerializerModel(&SerializerStruct{})); err != nil { t.Fatalf("no error should happen when migrate scanner, valuer struct, got error %v", err) } @@ -156,8 +180,8 @@ func TestSerializerZeroValue(t *testing.T) { func TestSerializerAssignFirstOrCreate(t *testing.T) { schema.RegisterSerializer("custom", NewCustomSerializer("hello")) - DB.Migrator().DropTable(&SerializerStruct{}) - if err := DB.Migrator().AutoMigrate(&SerializerStruct{}); err != nil { + DB.Migrator().DropTable(adaptorSerializerModel(&SerializerStruct{})) + if err := DB.Migrator().AutoMigrate(adaptorSerializerModel(&SerializerStruct{})); err != nil { t.Fatalf("no error should happen when migrate scanner, valuer struct, got error %v", err) } From 32fc2015543c41557a364d45213ca6c710b478bd Mon Sep 17 00:00:00 2001 From: Zhiheng Lin Date: Fri, 21 Apr 2023 22:17:21 +0800 Subject: [PATCH 2/7] fix: avoid coroutine leaks when the dialecter initialization fails. (#6249) Co-authored-by: Kevin Lin --- gorm.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/gorm.go b/gorm.go index 4402a2df..07a913fc 100644 --- a/gorm.go +++ b/gorm.go @@ -179,6 +179,12 @@ func Open(dialector Dialector, opts ...Option) (db *DB, err error) { if config.Dialector != nil { err = config.Dialector.Initialize(db) + + if err != nil { + if db, err := db.DB(); err == nil { + _ = db.Close() + } + } } preparedStmt := &PreparedStmtDB{ From 1f763c81cb3ec1c2f2dfada9f42455278e33298c Mon Sep 17 00:00:00 2001 From: yikakia <59830508+yikakia@users.noreply.github.com> Date: Wed, 26 Apr 2023 22:19:06 +0800 Subject: [PATCH 3/7] fix typo chainable_api.go (#6266) --- chainable_api.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chainable_api.go b/chainable_api.go index 19d405cc..3dc7256e 100644 --- a/chainable_api.go +++ b/chainable_api.go @@ -60,7 +60,7 @@ var tableRegexp = regexp.MustCompile(`(?i)(?:.+? AS (\w+)\s*(?:$|,)|^\w+\s+(\w+) // Table specify the table you would like to run db operations // // // Get a user -// db.Table("users").take(&result) +// db.Table("users").Take(&result) func (db *DB) Table(name string, args ...interface{}) (tx *DB) { tx = db.getInstance() if strings.Contains(name, " ") || strings.Contains(name, "`") || len(args) > 0 { From 407bedae0a529f8512b44522b319aa8434249dee Mon Sep 17 00:00:00 2001 From: Cr <631807682@qq.com> Date: Wed, 26 Apr 2023 22:19:32 +0800 Subject: [PATCH 4/7] fix: nested joins alias (#6265) --- callbacks/query.go | 7 ++++++- tests/joins_test.go | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/callbacks/query.go b/callbacks/query.go index 95db1f0a..e89dd199 100644 --- a/callbacks/query.go +++ b/callbacks/query.go @@ -234,7 +234,12 @@ func BuildQuerySQL(db *gorm.DB) { fromClause.Joins = append(fromClause.Joins, genJoinClause(join.JoinType, parentTableName, rel)) specifiedRelationsName[nestedAlias] = nil } - parentTableName = rel.Name + + if parentTableName != clause.CurrentTable { + parentTableName = utils.NestedRelationName(parentTableName, rel.Name) + } else { + parentTableName = rel.Name + } } } else { fromClause.Joins = append(fromClause.Joins, clause.Join{ diff --git a/tests/joins_test.go b/tests/joins_test.go index e6715bbe..786fc37e 100644 --- a/tests/joins_test.go +++ b/tests/joins_test.go @@ -329,8 +329,19 @@ func TestJoinArgsWithDB(t *testing.T) { func TestNestedJoins(t *testing.T) { users := []User{ { - Name: "nested-joins-1", - Manager: GetUser("nested-joins-manager-1", Config{Company: true, NamedPet: true}), + Name: "nested-joins-1", + Manager: &User{ + Name: "nested-joins-manager-1", + Company: Company{ + Name: "nested-joins-manager-company-1", + }, + NamedPet: &Pet{ + Name: "nested-joins-manager-namepet-1", + Toy: Toy{ + Name: "nested-joins-manager-namepet-toy-1", + }, + }, + }, NamedPet: &Pet{Name: "nested-joins-namepet-1", Toy: Toy{Name: "nested-joins-namepet-toy-1"}}, }, { @@ -352,6 +363,7 @@ func TestNestedJoins(t *testing.T) { Joins("Manager"). Joins("Manager.Company"). Joins("Manager.NamedPet"). + Joins("Manager.NamedPet.Toy"). Joins("NamedPet"). Joins("NamedPet.Toy"). Find(&users2, "users.id IN ?", userIDs).Error; err != nil { From aeb298635b04ac7063b545badceeaf77c0eb6ef0 Mon Sep 17 00:00:00 2001 From: hanwn <30523763+Hanwn@users.noreply.github.com> Date: Wed, 26 Apr 2023 22:19:46 +0800 Subject: [PATCH 5/7] debug: use slice Stale sort (#6263) Co-authored-by: hanwang --- callbacks.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/callbacks.go b/callbacks.go index ca6b6d50..195d1720 100644 --- a/callbacks.go +++ b/callbacks.go @@ -249,7 +249,7 @@ func sortCallbacks(cs []*callback) (fns []func(*DB), err error) { names, sorted []string sortCallback func(*callback) error ) - sort.Slice(cs, func(i, j int) bool { + sort.SliceStable(cs, func(i, j int) bool { if cs[j].before == "*" && cs[i].before != "*" { return true } From 67642abfff798c25aade7f29c76654ab18e209c4 Mon Sep 17 00:00:00 2001 From: hykuan <33409123+hykuan@users.noreply.github.com> Date: Thu, 4 May 2023 19:29:31 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=F0=9F=90=9B=20numeric=20types=20in?= =?UTF-8?q?=20pointer=20embedded=20struct=20test=20failed=20(#6293)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- schema/field.go | 36 +++++++++++++++++++++++++++++++++++ tests/embedded_struct_test.go | 1 + 2 files changed, 37 insertions(+) diff --git a/schema/field.go b/schema/field.go index b5103d53..7d1a1789 100644 --- a/schema/field.go +++ b/schema/field.go @@ -604,6 +604,22 @@ func (field *Field) setupValuerAndSetter() { if data != nil && *data != nil { field.ReflectValueOf(ctx, value).SetInt(**data) } + case **int: + if data != nil && *data != nil { + field.ReflectValueOf(ctx, value).SetInt(int64(**data)) + } + case **int8: + if data != nil && *data != nil { + field.ReflectValueOf(ctx, value).SetInt(int64(**data)) + } + case **int16: + if data != nil && *data != nil { + field.ReflectValueOf(ctx, value).SetInt(int64(**data)) + } + case **int32: + if data != nil && *data != nil { + field.ReflectValueOf(ctx, value).SetInt(int64(**data)) + } case int64: field.ReflectValueOf(ctx, value).SetInt(data) case int: @@ -668,6 +684,22 @@ func (field *Field) setupValuerAndSetter() { if data != nil && *data != nil { field.ReflectValueOf(ctx, value).SetUint(**data) } + case **uint: + if data != nil && *data != nil { + field.ReflectValueOf(ctx, value).SetUint(uint64(**data)) + } + case **uint8: + if data != nil && *data != nil { + field.ReflectValueOf(ctx, value).SetUint(uint64(**data)) + } + case **uint16: + if data != nil && *data != nil { + field.ReflectValueOf(ctx, value).SetUint(uint64(**data)) + } + case **uint32: + if data != nil && *data != nil { + field.ReflectValueOf(ctx, value).SetUint(uint64(**data)) + } case uint64: field.ReflectValueOf(ctx, value).SetUint(data) case uint: @@ -720,6 +752,10 @@ func (field *Field) setupValuerAndSetter() { if data != nil && *data != nil { field.ReflectValueOf(ctx, value).SetFloat(**data) } + case **float32: + if data != nil && *data != nil { + field.ReflectValueOf(ctx, value).SetFloat(float64(**data)) + } case float64: field.ReflectValueOf(ctx, value).SetFloat(data) case float32: diff --git a/tests/embedded_struct_test.go b/tests/embedded_struct_test.go index 0d240fd8..3747dad9 100644 --- a/tests/embedded_struct_test.go +++ b/tests/embedded_struct_test.go @@ -107,6 +107,7 @@ func TestEmbeddedPointerTypeStruct(t *testing.T) { ID string Name string Email string + Age int } type HNPost struct { From 32045fdd7d7a298f09f7ffdca286c3097cfda293 Mon Sep 17 00:00:00 2001 From: black-06 Date: Thu, 4 May 2023 19:30:45 +0800 Subject: [PATCH 7/7] feat: unscoped association (#5899) (#6246) * feat: unscoped association (#5899) * modify name because mysql character is latin1 * work only on has association * format * Unscoped on belongs_to association --- association.go | 63 ++++++++++++++++++++--- tests/associations_belongs_to_test.go | 55 ++++++++++++++++++++ tests/associations_has_many_test.go | 74 +++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 6 deletions(-) diff --git a/association.go b/association.go index 6719a1d0..7c93ebea 100644 --- a/association.go +++ b/association.go @@ -14,6 +14,7 @@ import ( type Association struct { DB *DB Relationship *schema.Relationship + Unscope bool Error error } @@ -40,6 +41,15 @@ func (db *DB) Association(column string) *Association { return association } +func (association *Association) Unscoped() *Association { + return &Association{ + DB: association.DB, + Relationship: association.Relationship, + Error: association.Error, + Unscope: true, + } +} + func (association *Association) Find(out interface{}, conds ...interface{}) error { if association.Error == nil { association.Error = association.buildCondition().Find(out, conds...).Error @@ -64,14 +74,30 @@ func (association *Association) Append(values ...interface{}) error { func (association *Association) Replace(values ...interface{}) error { if association.Error == nil { + reflectValue := association.DB.Statement.ReflectValue + rel := association.Relationship + + var oldBelongsToExpr clause.Expression + // we have to record the old BelongsTo value + if association.Unscope && rel.Type == schema.BelongsTo { + var foreignFields []*schema.Field + for _, ref := range rel.References { + if !ref.OwnPrimaryKey { + foreignFields = append(foreignFields, ref.ForeignKey) + } + } + if _, fvs := schema.GetIdentityFieldValuesMap(association.DB.Statement.Context, reflectValue, foreignFields); len(fvs) > 0 { + column, values := schema.ToQueryValues(rel.FieldSchema.Table, rel.FieldSchema.PrimaryFieldDBNames, fvs) + oldBelongsToExpr = clause.IN{Column: column, Values: values} + } + } + // save associations if association.saveAssociation( /*clear*/ true, values...); association.Error != nil { return association.Error } // set old associations's foreign key to null - reflectValue := association.DB.Statement.ReflectValue - rel := association.Relationship switch rel.Type { case schema.BelongsTo: if len(values) == 0 { @@ -91,6 +117,9 @@ func (association *Association) Replace(values ...interface{}) error { association.Error = association.DB.UpdateColumns(updateMap).Error } + if association.Unscope && oldBelongsToExpr != nil { + association.Error = association.DB.Model(nil).Where(oldBelongsToExpr).Delete(reflect.New(rel.FieldSchema.ModelType).Interface()).Error + } case schema.HasOne, schema.HasMany: var ( primaryFields []*schema.Field @@ -119,7 +148,11 @@ func (association *Association) Replace(values ...interface{}) error { if _, pvs := schema.GetIdentityFieldValuesMap(association.DB.Statement.Context, reflectValue, primaryFields); len(pvs) > 0 { column, values := schema.ToQueryValues(rel.FieldSchema.Table, foreignKeys, pvs) - association.Error = tx.Where(clause.IN{Column: column, Values: values}).UpdateColumns(updateMap).Error + if association.Unscope { + association.Error = tx.Where(clause.IN{Column: column, Values: values}).Delete(modelValue).Error + } else { + association.Error = tx.Where(clause.IN{Column: column, Values: values}).UpdateColumns(updateMap).Error + } } case schema.Many2Many: var ( @@ -184,7 +217,8 @@ func (association *Association) Delete(values ...interface{}) error { switch rel.Type { case schema.BelongsTo: - tx := association.DB.Model(reflect.New(rel.Schema.ModelType).Interface()) + associationDB := association.DB.Session(&Session{}) + tx := associationDB.Model(reflect.New(rel.Schema.ModelType).Interface()) _, pvs := schema.GetIdentityFieldValuesMap(association.DB.Statement.Context, reflectValue, rel.Schema.PrimaryFields) if pcolumn, pvalues := schema.ToQueryValues(rel.Schema.Table, rel.Schema.PrimaryFieldDBNames, pvs); len(pvalues) > 0 { @@ -198,8 +232,21 @@ func (association *Association) Delete(values ...interface{}) error { conds = append(conds, clause.IN{Column: relColumn, Values: relValues}) association.Error = tx.Clauses(conds...).UpdateColumns(updateAttrs).Error + if association.Unscope { + var foreignFields []*schema.Field + for _, ref := range rel.References { + if !ref.OwnPrimaryKey { + foreignFields = append(foreignFields, ref.ForeignKey) + } + } + if _, fvs := schema.GetIdentityFieldValuesMap(association.DB.Statement.Context, reflectValue, foreignFields); len(fvs) > 0 { + column, values := schema.ToQueryValues(rel.FieldSchema.Table, rel.FieldSchema.PrimaryFieldDBNames, fvs) + association.Error = associationDB.Model(nil).Where(clause.IN{Column: column, Values: values}).Delete(reflect.New(rel.FieldSchema.ModelType).Interface()).Error + } + } case schema.HasOne, schema.HasMany: - tx := association.DB.Model(reflect.New(rel.FieldSchema.ModelType).Interface()) + model := reflect.New(rel.FieldSchema.ModelType).Interface() + tx := association.DB.Model(model) _, pvs := schema.GetIdentityFieldValuesMap(association.DB.Statement.Context, reflectValue, primaryFields) if pcolumn, pvalues := schema.ToQueryValues(rel.FieldSchema.Table, foreignKeys, pvs); len(pvalues) > 0 { @@ -212,7 +259,11 @@ func (association *Association) Delete(values ...interface{}) error { relColumn, relValues := schema.ToQueryValues(rel.FieldSchema.Table, rel.FieldSchema.PrimaryFieldDBNames, rvs) conds = append(conds, clause.IN{Column: relColumn, Values: relValues}) - association.Error = tx.Clauses(conds...).UpdateColumns(updateAttrs).Error + if association.Unscope { + association.Error = tx.Clauses(conds...).Delete(model).Error + } else { + association.Error = tx.Clauses(conds...).UpdateColumns(updateAttrs).Error + } case schema.Many2Many: var ( primaryFields, relPrimaryFields []*schema.Field diff --git a/tests/associations_belongs_to_test.go b/tests/associations_belongs_to_test.go index 99e8aa79..6befb5f2 100644 --- a/tests/associations_belongs_to_test.go +++ b/tests/associations_belongs_to_test.go @@ -251,3 +251,58 @@ func TestBelongsToDefaultValue(t *testing.T) { err := DB.Create(&user).Error AssertEqual(t, err, nil) } + +func TestBelongsToAssociationUnscoped(t *testing.T) { + type ItemParent struct { + gorm.Model + Logo string `gorm:"not null;type:varchar(50)"` + } + type ItemChild struct { + gorm.Model + Name string `gorm:"type:varchar(50)"` + ItemParentID uint + ItemParent ItemParent + } + + tx := DB.Session(&gorm.Session{}) + tx.Migrator().DropTable(&ItemParent{}, &ItemChild{}) + tx.AutoMigrate(&ItemParent{}, &ItemChild{}) + + item := ItemChild{ + Name: "name", + ItemParent: ItemParent{ + Logo: "logo", + }, + } + if err := tx.Create(&item).Error; err != nil { + t.Fatalf("failed to create items, got error: %v", err) + } + + tx = tx.Debug() + + // test replace + if err := tx.Model(&item).Association("ItemParent").Unscoped().Replace(&ItemParent{ + Logo: "updated logo", + }); err != nil { + t.Errorf("failed to replace item parent, got error: %v", err) + } + + var parents []ItemParent + if err := tx.Find(&parents).Error; err != nil { + t.Errorf("failed to find item parent, got error: %v", err) + } + if len(parents) != 1 { + t.Errorf("expected %d parents, got %d", 1, len(parents)) + } + + // test delete + if err := tx.Model(&item).Association("ItemParent").Unscoped().Delete(&parents); err != nil { + t.Errorf("failed to delete item parent, got error: %v", err) + } + if err := tx.Find(&parents).Error; err != nil { + t.Errorf("failed to find item parent, got error: %v", err) + } + if len(parents) != 0 { + t.Errorf("expected %d parents, got %d", 0, len(parents)) + } +} diff --git a/tests/associations_has_many_test.go b/tests/associations_has_many_test.go index 002ae636..c31c4b40 100644 --- a/tests/associations_has_many_test.go +++ b/tests/associations_has_many_test.go @@ -3,6 +3,7 @@ package tests_test import ( "testing" + "gorm.io/gorm" . "gorm.io/gorm/utils/tests" ) @@ -471,3 +472,76 @@ func TestPolymorphicHasManyAssociationForSlice(t *testing.T) { DB.Model(&users).Association("Toys").Clear() AssertAssociationCount(t, users, "Toys", 0, "After Clear") } + +func TestHasManyAssociationUnscoped(t *testing.T) { + type ItemContent struct { + gorm.Model + ItemID uint `gorm:"not null"` + Name string `gorm:"not null;type:varchar(50)"` + LanguageCode string `gorm:"not null;type:varchar(2)"` + } + type Item struct { + gorm.Model + Logo string `gorm:"not null;type:varchar(50)"` + Contents []ItemContent `gorm:"foreignKey:ItemID"` + } + + tx := DB.Session(&gorm.Session{}) + tx.Migrator().DropTable(&ItemContent{}, &Item{}) + tx.AutoMigrate(&ItemContent{}, &Item{}) + + item := Item{ + Logo: "logo", + Contents: []ItemContent{ + {Name: "name", LanguageCode: "en"}, + {Name: "ar name", LanguageCode: "ar"}, + }, + } + if err := tx.Create(&item).Error; err != nil { + t.Fatalf("failed to create items, got error: %v", err) + } + + // test Replace + if err := tx.Model(&item).Association("Contents").Unscoped().Replace([]ItemContent{ + {Name: "updated name", LanguageCode: "en"}, + {Name: "ar updated name", LanguageCode: "ar"}, + {Name: "le nom", LanguageCode: "fr"}, + }); err != nil { + t.Errorf("failed to replace item content, got error: %v", err) + } + + if count := tx.Model(&item).Association("Contents").Count(); count != 3 { + t.Errorf("expected %d contents, got %d", 3, count) + } + + var contents []ItemContent + if err := tx.Find(&contents).Error; err != nil { + t.Errorf("failed to find contents, got error: %v", err) + } + if len(contents) != 3 { + t.Errorf("expected %d contents, got %d", 3, len(contents)) + } + + // test delete + if err := tx.Model(&item).Association("Contents").Unscoped().Delete(&contents[0]); err != nil { + t.Errorf("failed to delete Contents, got error: %v", err) + } + if count := tx.Model(&item).Association("Contents").Count(); count != 2 { + t.Errorf("expected %d contents, got %d", 2, count) + } + + // test clear + if err := tx.Model(&item).Association("Contents").Unscoped().Clear(); err != nil { + t.Errorf("failed to clear contents association, got error: %v", err) + } + if count := tx.Model(&item).Association("Contents").Count(); count != 0 { + t.Errorf("expected %d contents, got %d", 0, count) + } + + if err := tx.Find(&contents).Error; err != nil { + t.Errorf("failed to find contents, got error: %v", err) + } + if len(contents) != 0 { + t.Errorf("expected %d contents, got %d", 0, len(contents)) + } +}