From 7cc4ebd2c2ddf67dbf44d05e792d8430f4999679 Mon Sep 17 00:00:00 2001 From: mikolajbubacz Date: Sat, 27 Sep 2025 20:26:22 +0200 Subject: [PATCH 1/8] Implement nested delete functionality for associations in GORM - Introduced `parseNestedDelete` and `deleteNestedAssociations` functions to handle nested deletions based on specified relationships. - Enhanced `DeleteBeforeAssociations` to support nested deletes when associations are included in the select statement. - Updated `deleteAssociation` to manage deletion logic for various relationship types, including HasOne, HasMany, Many2Many, and BelongsTo. - Added comprehensive tests for nested delete scenarios, covering various relationship types and error handling. This update improves the flexibility and robustness of the delete operations in GORM, allowing for more complex data structures to be managed effectively. --- callbacks/delete.go | 458 +++++++++++++++++++++++++++++++++++++------ tests/delete_test.go | 393 +++++++++++++++++++++++++++++++++++++ 2 files changed, 790 insertions(+), 61 deletions(-) diff --git a/callbacks/delete.go b/callbacks/delete.go index 07ed6feef..6b5bedb5a 100644 --- a/callbacks/delete.go +++ b/callbacks/delete.go @@ -1,6 +1,7 @@ package callbacks import ( + "fmt" "reflect" "strings" @@ -23,91 +24,425 @@ func BeforeDelete(db *gorm.DB) { } } + +func parseNestedDelete(schema *schema.Schema, selects []string) map[string][]string { + result := make(map[string][]string) + + for _, selectItem := range selects { + if selectItem == clause.Associations { + for name := range schema.Relationships.Relations { + result[name] = nil + } + } else if strings.Contains(selectItem, ".") { + parts := strings.Split(selectItem, ".") + if len(parts) > 0 { + firstRel := parts[0] + if _, ok := schema.Relationships.Relations[firstRel]; ok { + if len(parts) > 1 { + result[firstRel] = append(result[firstRel], strings.Join(parts[1:], ".")) + } else { + result[firstRel] = nil + } + } + } + } else { + if _, ok := schema.Relationships.Relations[selectItem]; ok { + result[selectItem] = nil + } + } + } + + return result +} + +func deleteNestedAssociations(db *gorm.DB, rel *schema.Relationship, nestedPaths []string) error { + switch rel.Type { + case schema.HasOne, schema.HasMany: + queryConds := rel.ToQueryConditions(db.Statement.Context, db.Statement.ReflectValue) + modelValue := reflect.New(rel.FieldSchema.ModelType).Interface() + tx := db.Session(&gorm.Session{NewDB: true}).Model(modelValue) + + if db.Statement.Unscoped { + tx = tx.Unscoped() + } + + withoutConditions := false + for _, cond := range queryConds { + if c, ok := cond.(clause.IN); ok && len(c.Values) == 0 { + withoutConditions = true + break + } + } + + if !withoutConditions { + if len(nestedPaths) > 0 { + var records reflect.Value + // When looking for records to process nested deletes, we always use Unscoped + // to find records that might have been soft-deleted by clause.Associations + searchTx := tx.Unscoped() + + if rel.Type == schema.HasOne { + records = reflect.New(rel.FieldSchema.ModelType) + if err := searchTx.Clauses(clause.Where{Exprs: queryConds}).First(records.Interface()).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil + } + return err + } + } else { + records = reflect.New(reflect.SliceOf(rel.FieldSchema.ModelType)) + if err := searchTx.Clauses(clause.Where{Exprs: queryConds}).Find(records.Interface()).Error; err != nil { + return err + } + } + + // Check if we found any records + if records.Elem().Len() == 0 { + return nil + } + + return deleteWithNestedSelect(tx, records.Interface(), nestedPaths) + } else { + result := tx.Clauses(clause.Where{Exprs: queryConds}).Delete(modelValue) + if result.Error != nil { + return result.Error + } + return nil + } + } + + case schema.Many2Many: + var associatedRecords reflect.Value + + joinTable := rel.JoinTable.Table + selectQuery := db.Session(&gorm.Session{NewDB: true}) + + var joinConditions []string + var queryArgs []interface{} + + for _, ref := range rel.References { + if ref.OwnPrimaryKey { + if db.Statement.ReflectValue.Kind() == reflect.Slice { + // Skip if dealing with slice - can't get primary key from slice + continue + } + value, _ := ref.PrimaryKey.ValueOf(db.Statement.Context, db.Statement.ReflectValue) + joinConditions = append(joinConditions, joinTable+"."+ref.ForeignKey.DBName+" = ?") + queryArgs = append(queryArgs, value) + } + } + + if len(joinConditions) > 0 { + associatedRecords = reflect.New(reflect.SliceOf(rel.FieldSchema.ModelType)) + + query := selectQuery.Table(rel.FieldSchema.Table). + Joins("INNER JOIN "+joinTable+" ON "+rel.FieldSchema.Table+"."+rel.FieldSchema.PrimaryFieldDBNames[0]+" = "+joinTable+"."+rel.References[len(rel.References)-1].ForeignKey.DBName). + Where(strings.Join(joinConditions, " AND "), queryArgs...) + + if err := query.Find(associatedRecords.Interface()).Error; err != nil { + return err + } + + if len(nestedPaths) > 0 { + if err := deleteWithNestedSelect(db.Session(&gorm.Session{NewDB: true}), associatedRecords.Interface(), nestedPaths); err != nil { + return err + } + } else { + if associatedRecords.Elem().Len() > 0 { + if err := db.Session(&gorm.Session{NewDB: true}).Delete(associatedRecords.Interface()).Error; err != nil { + return err + } + } + } + } + + var ( + queryConds = make([]clause.Expression, 0, len(rel.References)) + foreignFields = make([]*schema.Field, 0, len(rel.References)) + relForeignKeys = make([]string, 0, len(rel.References)) + modelValue = reflect.New(rel.JoinTable.ModelType).Interface() + table = rel.JoinTable.Table + tx = db.Session(&gorm.Session{NewDB: true}).Model(modelValue).Table(table) + ) + + for _, ref := range rel.References { + if ref.OwnPrimaryKey { + foreignFields = append(foreignFields, ref.PrimaryKey) + relForeignKeys = append(relForeignKeys, ref.ForeignKey.DBName) + } else if ref.PrimaryValue != "" { + queryConds = append(queryConds, clause.Eq{ + Column: clause.Column{Table: rel.JoinTable.Table, Name: ref.ForeignKey.DBName}, + Value: ref.PrimaryValue, + }) + } + } + + _, foreignValues := schema.GetIdentityFieldValuesMap(db.Statement.Context, db.Statement.ReflectValue, foreignFields) + column, values := schema.ToQueryValues(table, relForeignKeys, foreignValues) + + if len(values) > 0 { + queryConds = append(queryConds, clause.IN{Column: column, Values: values}) + } + + if len(queryConds) > 0 { + return tx.Clauses(clause.Where{Exprs: queryConds}).Delete(modelValue).Error + } + return nil + + case schema.BelongsTo: + if len(nestedPaths) > 0 { + queryConds := rel.ToQueryConditions(db.Statement.Context, db.Statement.ReflectValue) + modelValue := reflect.New(rel.FieldSchema.ModelType).Interface() + tx := db.Session(&gorm.Session{NewDB: true}).Model(modelValue) + + if err := tx.Clauses(clause.Where{Exprs: queryConds}).First(modelValue).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil + } + return err + } + + return deleteWithNestedSelect(tx, modelValue, nestedPaths) + } + } + + return nil +} + +func deleteWithNestedSelect(db *gorm.DB, value interface{}, nestedPaths []string) error { + tx := db.Session(&gorm.Session{NewDB: true}) + for _, path := range nestedPaths { + tx = tx.Select(path) + } + return tx.Delete(value).Error +} + func DeleteBeforeAssociations(db *gorm.DB) { if db.Error == nil && db.Statement.Schema != nil { + if len(db.Statement.Selects) > 0 { + // Check if clause.Associations is in selects + hasClauseAssociations := false + var otherSelects []string + + for _, s := range db.Statement.Selects { + if s == clause.Associations { + hasClauseAssociations = true + } else { + otherSelects = append(otherSelects, s) + } + } + + // If we have clause.Associations, handle it with the old logic + if hasClauseAssociations { + selectColumns, restricted := db.Statement.SelectAndOmitColumns(true, false) + + if restricted { + // First, collect all relationships that are explicitly mentioned in otherSelects + explicitRelations := make(map[string]bool) + for _, s := range otherSelects { + if strings.Contains(s, ".") { + parts := strings.Split(s, ".") + if len(parts) > 0 { + explicitRelations[parts[0]] = true + } + } else { + explicitRelations[s] = true + } + } + + for column, v := range selectColumns { + if !v { + continue + } + + // Skip if this relation is explicitly handled in otherSelects + if explicitRelations[column] { + continue + } + + rel, ok := db.Statement.Schema.Relationships.Relations[column] + if !ok { + continue + } + + if err := deleteAssociation(db, rel); err != nil { + db.AddError(err) + return + } + } + } + } + + // Handle other selects (including nested ones) + if len(otherSelects) > 0 { + // Validate that all selected items are valid relationships first + for _, selectItem := range otherSelects { + if selectItem != clause.Associations { + // Check the first part of a nested path + parts := strings.Split(selectItem, ".") + if len(parts) > 0 { + firstRel := parts[0] + if _, ok := db.Statement.Schema.Relationships.Relations[firstRel]; !ok { + // Check if it's a field name + if field := db.Statement.Schema.LookUpField(firstRel); field != nil { + db.AddError(fmt.Errorf("field %s is not a valid relationship", firstRel)) + return + } else { + db.AddError(fmt.Errorf("%s is not a valid relationship", firstRel)) + return + } + } + } + } + } + + if db.Statement.ReflectValue.Kind() == reflect.Struct || db.Statement.ReflectValue.Kind() == reflect.Slice { + var needsLoad = false + + if db.Statement.ReflectValue.Kind() == reflect.Struct { + var isZero = true + for _, field := range db.Statement.Schema.PrimaryFields { + value, _ := field.ValueOf(db.Statement.Context, db.Statement.ReflectValue) + if !reflect.ValueOf(value).IsZero() { + isZero = false + break + } + } + needsLoad = isZero + } else if db.Statement.ReflectValue.Kind() == reflect.Slice { + needsLoad = db.Statement.ReflectValue.Len() == 0 + } + + if needsLoad { + loadDB := db.Session(&gorm.Session{NewDB: true}).Model(db.Statement.Dest) + if db.Statement.Unscoped { + loadDB = loadDB.Unscoped() + } + + // Apply the same WHERE conditions from the main statement + if whereClause, ok := db.Statement.Clauses["WHERE"]; ok { + if where, ok := whereClause.Expression.(clause.Where); ok { + loadDB.Statement.AddClause(where) + } + } + + if err := loadDB.First(db.Statement.Dest).Error; err != nil { + // Don't process associations if we can't load the record + // Let the main Delete callback handle the error + return + } + db.Statement.ReflectValue = reflect.ValueOf(db.Statement.Dest).Elem() + } + } + + + nestedDeletes := parseNestedDelete(db.Statement.Schema, otherSelects) + + for relName, nestedPaths := range nestedDeletes { + rel := db.Statement.Schema.Relationships.Relations[relName] + if rel == nil { + continue + } + + if err := deleteNestedAssociations(db, rel, nestedPaths); err != nil { + db.AddError(err) + return + } + } + } + return + } + selectColumns, restricted := db.Statement.SelectAndOmitColumns(true, false) + if !restricted { return } - + for column, v := range selectColumns { if !v { continue } - + rel, ok := db.Statement.Schema.Relationships.Relations[column] if !ok { continue } + + if err := deleteAssociation(db, rel); err != nil { + db.AddError(err) + return + } + } + } +} - switch rel.Type { - case schema.HasOne, schema.HasMany: - queryConds := rel.ToQueryConditions(db.Statement.Context, db.Statement.ReflectValue) - modelValue := reflect.New(rel.FieldSchema.ModelType).Interface() - tx := db.Session(&gorm.Session{NewDB: true}).Model(modelValue) - withoutConditions := false - if db.Statement.Unscoped { - tx = tx.Unscoped() - } +func deleteAssociation(db *gorm.DB, rel *schema.Relationship) error { + switch rel.Type { + case schema.HasOne, schema.HasMany: + queryConds := rel.ToQueryConditions(db.Statement.Context, db.Statement.ReflectValue) + modelValue := reflect.New(rel.FieldSchema.ModelType).Interface() + tx := db.Session(&gorm.Session{NewDB: true}).Model(modelValue) + withoutConditions := false - if len(db.Statement.Selects) > 0 { - selects := make([]string, 0, len(db.Statement.Selects)) - for _, s := range db.Statement.Selects { - if s == clause.Associations { - selects = append(selects, s) - } else if columnPrefix := column + "."; strings.HasPrefix(s, columnPrefix) { - selects = append(selects, strings.TrimPrefix(s, columnPrefix)) - } - } + if db.Statement.Unscoped { + tx = tx.Unscoped() + } - if len(selects) > 0 { - tx = tx.Select(selects) - } - } + for _, cond := range queryConds { + if c, ok := cond.(clause.IN); ok && len(c.Values) == 0 { + withoutConditions = true + break + } + } - for _, cond := range queryConds { - if c, ok := cond.(clause.IN); ok && len(c.Values) == 0 { - withoutConditions = true - break - } - } + if !withoutConditions { + return tx.Clauses(clause.Where{Exprs: queryConds}).Delete(modelValue).Error + } - if !withoutConditions && db.AddError(tx.Clauses(clause.Where{Exprs: queryConds}).Delete(modelValue).Error) != nil { - return - } - case schema.Many2Many: - var ( - queryConds = make([]clause.Expression, 0, len(rel.References)) - foreignFields = make([]*schema.Field, 0, len(rel.References)) - relForeignKeys = make([]string, 0, len(rel.References)) - modelValue = reflect.New(rel.JoinTable.ModelType).Interface() - table = rel.JoinTable.Table - tx = db.Session(&gorm.Session{NewDB: true}).Model(modelValue).Table(table) - ) - - for _, ref := range rel.References { - if ref.OwnPrimaryKey { - foreignFields = append(foreignFields, ref.PrimaryKey) - relForeignKeys = append(relForeignKeys, ref.ForeignKey.DBName) - } else if ref.PrimaryValue != "" { - queryConds = append(queryConds, clause.Eq{ - Column: clause.Column{Table: rel.JoinTable.Table, Name: ref.ForeignKey.DBName}, - Value: ref.PrimaryValue, - }) - } - } + case schema.Many2Many: + // For clause.Associations - we should NOT delete the associated records themselves + // We only clean up the join table entries + var ( + queryConds = make([]clause.Expression, 0, len(rel.References)) + foreignFields = make([]*schema.Field, 0, len(rel.References)) + relForeignKeys = make([]string, 0, len(rel.References)) + modelValue = reflect.New(rel.JoinTable.ModelType).Interface() + table = rel.JoinTable.Table + tx = db.Session(&gorm.Session{NewDB: true}).Model(modelValue).Table(table) + ) + + for _, ref := range rel.References { + if ref.OwnPrimaryKey { + foreignFields = append(foreignFields, ref.PrimaryKey) + relForeignKeys = append(relForeignKeys, ref.ForeignKey.DBName) + } else if ref.PrimaryValue != "" { + queryConds = append(queryConds, clause.Eq{ + Column: clause.Column{Table: rel.JoinTable.Table, Name: ref.ForeignKey.DBName}, + Value: ref.PrimaryValue, + }) + } + } - _, foreignValues := schema.GetIdentityFieldValuesMap(db.Statement.Context, db.Statement.ReflectValue, foreignFields) - column, values := schema.ToQueryValues(table, relForeignKeys, foreignValues) - queryConds = append(queryConds, clause.IN{Column: column, Values: values}) + _, foreignValues := schema.GetIdentityFieldValuesMap(db.Statement.Context, db.Statement.ReflectValue, foreignFields) + column, values := schema.ToQueryValues(table, relForeignKeys, foreignValues) + + if len(values) > 0 { + queryConds = append(queryConds, clause.IN{Column: column, Values: values}) + } - if db.AddError(tx.Clauses(clause.Where{Exprs: queryConds}).Delete(modelValue).Error) != nil { - return - } - } + if len(queryConds) > 0 { + return tx.Clauses(clause.Where{Exprs: queryConds}).Delete(modelValue).Error } + return nil + case schema.BelongsTo: + // For clause.Associations, BelongsTo should not be deleted + // as it would violate foreign key constraints + return nil } + + return nil } func Delete(config *Config) func(db *gorm.DB) { @@ -193,3 +528,4 @@ func AfterDelete(db *gorm.DB) { }) } } + diff --git a/tests/delete_test.go b/tests/delete_test.go index b9b5289c1..08fcae154 100644 --- a/tests/delete_test.go +++ b/tests/delete_test.go @@ -256,3 +256,396 @@ func TestDeleteReturning(t *testing.T) { t.Errorf("failed to delete data, current count %v", count) } } + +func TestNestedDelete(t *testing.T) { + type NestedProfile struct { + gorm.Model + Name string + NestedUserID uint + } + + type NestedUser struct { + gorm.Model + Name string + Profiles []NestedProfile `gorm:"foreignKey:NestedUserID"` + } + + DB.Migrator().DropTable(&NestedProfile{}, &NestedUser{}) + if err := DB.AutoMigrate(&NestedUser{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + if err := DB.AutoMigrate(&NestedProfile{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + user := NestedUser{Name: "nested_delete_test", Profiles: []NestedProfile{ + {Name: "Profile1"}, + {Name: "Profile2"}, + }} + + if err := DB.Create(&user).Error; err != nil { + t.Fatalf("Failed to create user, got error %v", err) + } + t.Logf("Created user with ID: %d", user.ID) + + var deletedUser NestedUser + result := DB.Select("Profiles").Delete(&deletedUser, user.ID) + if result.Error != nil { + t.Fatalf("Failed to delete user with nested select, got error %v", result.Error) + } + + var count int64 + DB.Model(&NestedUser{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 users after nested delete, got %d", count) + } + + DB.Model(&NestedProfile{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 profiles after nested delete, got %d", count) + } +} + + +func TestNestedDeleteWithBelongsTo(t *testing.T) { + type Author struct { + gorm.Model + Name string + } + + type Book struct { + gorm.Model + Title string + AuthorID uint + Author Author + } + + DB.Migrator().DropTable(&Author{}, &Book{}) + if err := DB.AutoMigrate(&Author{}, &Book{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + author := Author{Name: "Test Author"} + DB.Create(&author) + + book := Book{Title: "Test Book", AuthorID: author.ID} + DB.Create(&book) + + var deletedBook Book + result := DB.Select("Author").Delete(&deletedBook, book.ID) + if result.Error != nil { + t.Fatalf("Failed to delete book with nested BelongsTo, got error %v", result.Error) + } + + var count int64 + DB.Model(&Book{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 books after nested delete with BelongsTo, got %d", count) + } + + DB.Model(&Author{}).Count(&count) + if count != 1 { + t.Fatalf("Expected 1 author after nested delete with BelongsTo, got %d", count) + } +} + +func TestNestedDeleteWithManyToMany(t *testing.T) { + type Tag struct { + gorm.Model + Name string + } + + type Post struct { + gorm.Model + Title string + Tags []Tag `gorm:"many2many:post_tags;"` + } + + DB.Migrator().DropTable(&Tag{}, &Post{}, "post_tags") + if err := DB.AutoMigrate(&Tag{}, &Post{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + tag1 := Tag{Name: "Tag1"} + tag2 := Tag{Name: "Tag2"} + DB.Create(&tag1) + DB.Create(&tag2) + + post := Post{Title: "Test Post", Tags: []Tag{tag1, tag2}} + DB.Create(&post) + + var deletedPost Post + result := DB.Select("Tags").Delete(&deletedPost, post.ID) + if result.Error != nil { + t.Fatalf("Failed to delete post with nested ManyToMany, got error %v", result.Error) + } + + var count int64 + DB.Model(&Post{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 posts after nested delete with ManyToMany, got %d", count) + } + + DB.Model(&Tag{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 tags after nested delete with ManyToMany, got %d", count) + } + + DB.Table("post_tags").Count(&count) + if count != 0 { + t.Fatalf("Expected 0 join table records after nested delete with ManyToMany, got %d", count) + } +} + +func TestNestedDeleteWithEmbeddedStruct(t *testing.T) { + type Address struct { + Street string + City string + } + + type User struct { + gorm.Model + Name string + Address Address `gorm:"embedded"` + } + + DB.Migrator().DropTable(&User{}) + if err := DB.AutoMigrate(&User{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + user := User{ + Name: "embedded_delete_test", + Address: Address{ + Street: "123 Main St", + City: "Test City", + }, + } + + DB.Create(&user) + + var deletedUser User + result := DB.Delete(&deletedUser, user.ID) + if result.Error != nil { + t.Fatalf("Failed to delete user with embedded struct, got error %v", result.Error) + } + + var count int64 + DB.Model(&User{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 users after delete with embedded struct, got %d", count) + } +} + +func TestNestedDeleteDeepNesting(t *testing.T) { + type Comment struct { + gorm.Model + Content string + PostID uint + } + + type Post struct { + gorm.Model + Title string + UserID uint + Comments []Comment + } + + type User struct { + gorm.Model + Name string + Posts []Post + } + + DB.Migrator().DropTable(&Comment{}, &Post{}, &User{}) + if err := DB.AutoMigrate(&User{}, &Post{}, &Comment{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + user := User{Name: "deep_nesting_test", Posts: []Post{ + {Title: "Post1", Comments: []Comment{ + {Content: "Comment1"}, + {Content: "Comment2"}, + }}, + {Title: "Post2", Comments: []Comment{ + {Content: "Comment3"}, + }}, + }} + DB.Create(&user) + + var deletedUser User + result := DB.Select("Posts.Comments").Delete(&deletedUser, user.ID) + if result.Error != nil { + t.Fatalf("Failed to delete user with deep nesting, got error %v", result.Error) + } + + var count int64 + DB.Model(&User{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 users after deep nested delete, got %d", count) + } + DB.Model(&Post{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 posts after deep nested delete, got %d", count) + } + DB.Model(&Comment{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 comments after deep nested delete, got %d", count) + } +} + +func TestNestedDeleteMultipleRelations(t *testing.T) { + type MultiProfile struct { + gorm.Model + Name string + MultiUserID uint + } + + type MultiPost struct { + gorm.Model + Title string + MultiUserID uint + } + + type MultiUser struct { + gorm.Model + Name string + Profiles []MultiProfile `gorm:"foreignKey:MultiUserID"` + Posts []MultiPost `gorm:"foreignKey:MultiUserID"` + } + + DB.Migrator().DropTable(&MultiProfile{}, &MultiPost{}, &MultiUser{}) + if err := DB.AutoMigrate(&MultiUser{}, &MultiPost{}, &MultiProfile{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + user1 := MultiUser{Name: "multi_relation_test1", Profiles: []MultiProfile{{Name: "Profile1"}}} + DB.Create(&user1) + + var deletedUser1 MultiUser + result := DB.Select("Profiles").Delete(&deletedUser1, user1.ID) + if result.Error != nil { + t.Fatalf("Failed to delete user with Profiles relation, got error %v", result.Error) + } + + user2 := MultiUser{Name: "multi_relation_test2", Posts: []MultiPost{{Title: "Post1"}}} + DB.Create(&user2) + + var deletedUser2 MultiUser + result = DB.Select("Posts").Delete(&deletedUser2, user2.ID) + if result.Error != nil { + t.Fatalf("Failed to delete user with Posts relation, got error %v", result.Error) + } + + var count int64 + DB.Model(&MultiUser{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 users after multi-relation delete, got %d", count) + } + DB.Model(&MultiPost{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 posts after multi-relation delete, got %d", count) + } + DB.Model(&MultiProfile{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 profiles after multi-relation delete, got %d", count) + } +} + + +func TestNestedDeleteWithPolymorphic(t *testing.T) { + type Toy struct { + gorm.Model + Name string + OwnerID uint + OwnerType string + } + + type Cat struct { + gorm.Model + Name string + Toys []Toy `gorm:"polymorphic:Owner;"` + } + + DB.Migrator().DropTable(&Toy{}, &Cat{}) + if err := DB.AutoMigrate(&Cat{}, &Toy{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + cat := Cat{Name: "Fluffy", Toys: []Toy{{Name: "Ball"}, {Name: "Mouse"}}} + DB.Create(&cat) + + var deletedCat Cat + result := DB.Select("Toys").Delete(&deletedCat, cat.ID) + if result.Error != nil { + t.Fatalf("Failed to delete cat with polymorphic toys, got error %v", result.Error) + } + + var count int64 + DB.Model(&Cat{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 cats after polymorphic nested delete, got %d", count) + } + DB.Model(&Toy{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 toys after polymorphic nested delete, got %d", count) + } +} + +func TestNestedDeleteErrorHandling(t *testing.T) { + type User struct { + gorm.Model + Name string + } + + DB.Migrator().DropTable(&User{}) + if err := DB.AutoMigrate(&User{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + var user User + result := DB.Select("NonExistentRelation").Delete(&user, 999) + if result.Error == nil { + t.Fatalf("Expected error for non-existent relationship, but got none") + } + + result = DB.Select("Name").Delete(&user, 999) + if result.Error == nil { + t.Fatalf("Expected error for non-existent record, but got none") + } +} + +func TestNestedDeleteWithSelfReferential(t *testing.T) { + type Category struct { + gorm.Model + Name string + ParentID *uint + Parent *Category + Children []Category `gorm:"foreignKey:ParentID"` + } + + DB.Migrator().DropTable(&Category{}) + if err := DB.AutoMigrate(&Category{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + parent := Category{Name: "Parent"} + DB.Create(&parent) + + child1 := Category{Name: "Child1", ParentID: &parent.ID} + child2 := Category{Name: "Child2", ParentID: &parent.ID} + DB.Create(&child1) + DB.Create(&child2) + + var deletedParent Category + result := DB.Select("Children").Delete(&deletedParent, parent.ID) + if result.Error != nil { + t.Fatalf("Failed to delete parent with children, got error %v", result.Error) + } + + var count int64 + DB.Model(&Category{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 categories after self-referential nested delete, got %d", count) + } +} From 1b51fdabb864e36bf77970ab8bb64d2f90d42efa Mon Sep 17 00:00:00 2001 From: mikolajbubacz Date: Sat, 27 Sep 2025 22:46:19 +0200 Subject: [PATCH 2/8] fix: add validations and change types names in tests --- callbacks/delete.go | 54 +++++++----- tests/delete_test.go | 200 +++++++++++++++++++------------------------ 2 files changed, 122 insertions(+), 132 deletions(-) diff --git a/callbacks/delete.go b/callbacks/delete.go index 6b5bedb5a..4edd66c77 100644 --- a/callbacks/delete.go +++ b/callbacks/delete.go @@ -220,24 +220,43 @@ func deleteWithNestedSelect(db *gorm.DB, value interface{}, nestedPaths []string func DeleteBeforeAssociations(db *gorm.DB) { if db.Error == nil && db.Statement.Schema != nil { if len(db.Statement.Selects) > 0 { - // Check if clause.Associations is in selects - hasClauseAssociations := false - var otherSelects []string - - for _, s := range db.Statement.Selects { - if s == clause.Associations { - hasClauseAssociations = true - } else { - otherSelects = append(otherSelects, s) + hasRelationshipSelects := false + for _, selectItem := range db.Statement.Selects { + if selectItem == clause.Associations { + hasRelationshipSelects = true + break + } + if _, ok := db.Statement.Schema.Relationships.Relations[selectItem]; ok { + hasRelationshipSelects = true + break + } + if strings.Contains(selectItem, ".") { + parts := strings.Split(selectItem, ".") + if len(parts) > 0 { + if _, ok := db.Statement.Schema.Relationships.Relations[parts[0]]; ok { + hasRelationshipSelects = true + break + } + } } } - // If we have clause.Associations, handle it with the old logic + if hasRelationshipSelects { + hasClauseAssociations := false + var otherSelects []string + + for _, s := range db.Statement.Selects { + if s == clause.Associations { + hasClauseAssociations = true + } else { + otherSelects = append(otherSelects, s) + } + } + if hasClauseAssociations { selectColumns, restricted := db.Statement.SelectAndOmitColumns(true, false) if restricted { - // First, collect all relationships that are explicitly mentioned in otherSelects explicitRelations := make(map[string]bool) for _, s := range otherSelects { if strings.Contains(s, ".") { @@ -255,7 +274,6 @@ func DeleteBeforeAssociations(db *gorm.DB) { continue } - // Skip if this relation is explicitly handled in otherSelects if explicitRelations[column] { continue } @@ -273,17 +291,13 @@ func DeleteBeforeAssociations(db *gorm.DB) { } } - // Handle other selects (including nested ones) if len(otherSelects) > 0 { - // Validate that all selected items are valid relationships first for _, selectItem := range otherSelects { if selectItem != clause.Associations { - // Check the first part of a nested path parts := strings.Split(selectItem, ".") if len(parts) > 0 { firstRel := parts[0] if _, ok := db.Statement.Schema.Relationships.Relations[firstRel]; !ok { - // Check if it's a field name if field := db.Statement.Schema.LookUpField(firstRel); field != nil { db.AddError(fmt.Errorf("field %s is not a valid relationship", firstRel)) return @@ -319,7 +333,6 @@ func DeleteBeforeAssociations(db *gorm.DB) { loadDB = loadDB.Unscoped() } - // Apply the same WHERE conditions from the main statement if whereClause, ok := db.Statement.Clauses["WHERE"]; ok { if where, ok := whereClause.Expression.(clause.Where); ok { loadDB.Statement.AddClause(where) @@ -327,8 +340,7 @@ func DeleteBeforeAssociations(db *gorm.DB) { } if err := loadDB.First(db.Statement.Dest).Error; err != nil { - // Don't process associations if we can't load the record - // Let the main Delete callback handle the error + return } db.Statement.ReflectValue = reflect.ValueOf(db.Statement.Dest).Elem() @@ -352,6 +364,7 @@ func DeleteBeforeAssociations(db *gorm.DB) { } return } + } selectColumns, restricted := db.Statement.SelectAndOmitColumns(true, false) @@ -401,8 +414,7 @@ func deleteAssociation(db *gorm.DB, rel *schema.Relationship) error { } case schema.Many2Many: - // For clause.Associations - we should NOT delete the associated records themselves - // We only clean up the join table entries + var ( queryConds = make([]clause.Expression, 0, len(rel.References)) foreignFields = make([]*schema.Field, 0, len(rel.References)) diff --git a/tests/delete_test.go b/tests/delete_test.go index 08fcae154..d0b12ef66 100644 --- a/tests/delete_test.go +++ b/tests/delete_test.go @@ -258,27 +258,27 @@ func TestDeleteReturning(t *testing.T) { } func TestNestedDelete(t *testing.T) { - type NestedProfile struct { + type NestedDeleteProfile struct { gorm.Model Name string - NestedUserID uint + NestedDeleteUserID uint } - type NestedUser struct { + type NestedDeleteUser struct { gorm.Model Name string - Profiles []NestedProfile `gorm:"foreignKey:NestedUserID"` + Profiles []NestedDeleteProfile `gorm:"foreignKey:NestedDeleteUserID"` } - DB.Migrator().DropTable(&NestedProfile{}, &NestedUser{}) - if err := DB.AutoMigrate(&NestedUser{}); err != nil { + DB.Migrator().DropTable(&NestedDeleteProfile{}, &NestedDeleteUser{}) + if err := DB.AutoMigrate(&NestedDeleteUser{}); err != nil { t.Fatalf("Failed to auto migrate, got error %v", err) } - if err := DB.AutoMigrate(&NestedProfile{}); err != nil { + if err := DB.AutoMigrate(&NestedDeleteProfile{}); err != nil { t.Fatalf("Failed to auto migrate, got error %v", err) } - user := NestedUser{Name: "nested_delete_test", Profiles: []NestedProfile{ + user := NestedDeleteUser{Name: "nested_delete_test", Profiles: []NestedDeleteProfile{ {Name: "Profile1"}, {Name: "Profile2"}, }} @@ -288,19 +288,19 @@ func TestNestedDelete(t *testing.T) { } t.Logf("Created user with ID: %d", user.ID) - var deletedUser NestedUser + var deletedUser NestedDeleteUser result := DB.Select("Profiles").Delete(&deletedUser, user.ID) if result.Error != nil { t.Fatalf("Failed to delete user with nested select, got error %v", result.Error) } var count int64 - DB.Model(&NestedUser{}).Count(&count) + DB.Model(&NestedDeleteUser{}).Count(&count) if count != 0 { t.Fatalf("Expected 0 users after nested delete, got %d", count) } - DB.Model(&NestedProfile{}).Count(&count) + DB.Model(&NestedDeleteProfile{}).Count(&count) if count != 0 { t.Fatalf("Expected 0 profiles after nested delete, got %d", count) } @@ -308,115 +308,115 @@ func TestNestedDelete(t *testing.T) { func TestNestedDeleteWithBelongsTo(t *testing.T) { - type Author struct { + type NestedDeleteAuthor struct { gorm.Model Name string } - type Book struct { + type NestedDeleteBook struct { gorm.Model Title string AuthorID uint - Author Author + Author NestedDeleteAuthor } - DB.Migrator().DropTable(&Author{}, &Book{}) - if err := DB.AutoMigrate(&Author{}, &Book{}); err != nil { + DB.Migrator().DropTable(&NestedDeleteAuthor{}, &NestedDeleteBook{}) + if err := DB.AutoMigrate(&NestedDeleteAuthor{}, &NestedDeleteBook{}); err != nil { t.Fatalf("Failed to auto migrate, got error %v", err) } - author := Author{Name: "Test Author"} + author := NestedDeleteAuthor{Name: "Test Author"} DB.Create(&author) - book := Book{Title: "Test Book", AuthorID: author.ID} + book := NestedDeleteBook{Title: "Test Book", AuthorID: author.ID} DB.Create(&book) - var deletedBook Book + var deletedBook NestedDeleteBook result := DB.Select("Author").Delete(&deletedBook, book.ID) if result.Error != nil { t.Fatalf("Failed to delete book with nested BelongsTo, got error %v", result.Error) } var count int64 - DB.Model(&Book{}).Count(&count) + DB.Model(&NestedDeleteBook{}).Count(&count) if count != 0 { t.Fatalf("Expected 0 books after nested delete with BelongsTo, got %d", count) } - DB.Model(&Author{}).Count(&count) + DB.Model(&NestedDeleteAuthor{}).Count(&count) if count != 1 { t.Fatalf("Expected 1 author after nested delete with BelongsTo, got %d", count) } } func TestNestedDeleteWithManyToMany(t *testing.T) { - type Tag struct { + type NestedDeleteTag struct { gorm.Model Name string } - type Post struct { + type NestedDeletePost struct { gorm.Model Title string - Tags []Tag `gorm:"many2many:post_tags;"` + Tags []NestedDeleteTag `gorm:"many2many:nested_delete_post_tags;"` } - DB.Migrator().DropTable(&Tag{}, &Post{}, "post_tags") - if err := DB.AutoMigrate(&Tag{}, &Post{}); err != nil { + DB.Migrator().DropTable(&NestedDeleteTag{}, &NestedDeletePost{}, "nested_delete_post_tags") + if err := DB.AutoMigrate(&NestedDeleteTag{}, &NestedDeletePost{}); err != nil { t.Fatalf("Failed to auto migrate, got error %v", err) } - tag1 := Tag{Name: "Tag1"} - tag2 := Tag{Name: "Tag2"} + tag1 := NestedDeleteTag{Name: "Tag1"} + tag2 := NestedDeleteTag{Name: "Tag2"} DB.Create(&tag1) DB.Create(&tag2) - post := Post{Title: "Test Post", Tags: []Tag{tag1, tag2}} + post := NestedDeletePost{Title: "Test Post", Tags: []NestedDeleteTag{tag1, tag2}} DB.Create(&post) - var deletedPost Post + var deletedPost NestedDeletePost result := DB.Select("Tags").Delete(&deletedPost, post.ID) if result.Error != nil { t.Fatalf("Failed to delete post with nested ManyToMany, got error %v", result.Error) } var count int64 - DB.Model(&Post{}).Count(&count) + DB.Model(&NestedDeletePost{}).Count(&count) if count != 0 { t.Fatalf("Expected 0 posts after nested delete with ManyToMany, got %d", count) } - DB.Model(&Tag{}).Count(&count) + DB.Model(&NestedDeleteTag{}).Count(&count) if count != 0 { t.Fatalf("Expected 0 tags after nested delete with ManyToMany, got %d", count) } - DB.Table("post_tags").Count(&count) + DB.Table("nested_delete_post_tags").Count(&count) if count != 0 { t.Fatalf("Expected 0 join table records after nested delete with ManyToMany, got %d", count) } } func TestNestedDeleteWithEmbeddedStruct(t *testing.T) { - type Address struct { + type NestedDeleteAddress struct { Street string City string } - type User struct { + type NestedDeleteEmbeddedUser struct { gorm.Model Name string - Address Address `gorm:"embedded"` + Address NestedDeleteAddress `gorm:"embedded"` } - DB.Migrator().DropTable(&User{}) - if err := DB.AutoMigrate(&User{}); err != nil { + DB.Migrator().DropTable(&NestedDeleteEmbeddedUser{}) + if err := DB.AutoMigrate(&NestedDeleteEmbeddedUser{}); err != nil { t.Fatalf("Failed to auto migrate, got error %v", err) } - user := User{ + user := NestedDeleteEmbeddedUser{ Name: "embedded_delete_test", - Address: Address{ + Address: NestedDeleteAddress{ Street: "123 Main St", City: "Test City", }, @@ -424,129 +424,129 @@ func TestNestedDeleteWithEmbeddedStruct(t *testing.T) { DB.Create(&user) - var deletedUser User + var deletedUser NestedDeleteEmbeddedUser result := DB.Delete(&deletedUser, user.ID) if result.Error != nil { t.Fatalf("Failed to delete user with embedded struct, got error %v", result.Error) } var count int64 - DB.Model(&User{}).Count(&count) + DB.Model(&NestedDeleteEmbeddedUser{}).Count(&count) if count != 0 { t.Fatalf("Expected 0 users after delete with embedded struct, got %d", count) } } func TestNestedDeleteDeepNesting(t *testing.T) { - type Comment struct { + type NestedDeleteDeepComment struct { gorm.Model Content string PostID uint } - type Post struct { + type NestedDeleteDeepNestingPost struct { gorm.Model Title string UserID uint - Comments []Comment + Comments []NestedDeleteDeepComment `gorm:"foreignKey:PostID"` } - type User struct { + type NestedDeleteDeepNestingUser struct { gorm.Model Name string - Posts []Post + Posts []NestedDeleteDeepNestingPost `gorm:"foreignKey:UserID"` } - DB.Migrator().DropTable(&Comment{}, &Post{}, &User{}) - if err := DB.AutoMigrate(&User{}, &Post{}, &Comment{}); err != nil { + DB.Migrator().DropTable(&NestedDeleteDeepComment{}, &NestedDeleteDeepNestingPost{}, &NestedDeleteDeepNestingUser{}) + if err := DB.AutoMigrate(&NestedDeleteDeepNestingUser{}, &NestedDeleteDeepNestingPost{}, &NestedDeleteDeepComment{}); err != nil { t.Fatalf("Failed to auto migrate, got error %v", err) } - user := User{Name: "deep_nesting_test", Posts: []Post{ - {Title: "Post1", Comments: []Comment{ + user := NestedDeleteDeepNestingUser{Name: "deep_nesting_test", Posts: []NestedDeleteDeepNestingPost{ + {Title: "Post1", Comments: []NestedDeleteDeepComment{ {Content: "Comment1"}, {Content: "Comment2"}, }}, - {Title: "Post2", Comments: []Comment{ + {Title: "Post2", Comments: []NestedDeleteDeepComment{ {Content: "Comment3"}, }}, }} DB.Create(&user) - var deletedUser User + var deletedUser NestedDeleteDeepNestingUser result := DB.Select("Posts.Comments").Delete(&deletedUser, user.ID) if result.Error != nil { t.Fatalf("Failed to delete user with deep nesting, got error %v", result.Error) } var count int64 - DB.Model(&User{}).Count(&count) + DB.Model(&NestedDeleteDeepNestingUser{}).Count(&count) if count != 0 { t.Fatalf("Expected 0 users after deep nested delete, got %d", count) } - DB.Model(&Post{}).Count(&count) + DB.Model(&NestedDeleteDeepNestingPost{}).Count(&count) if count != 0 { t.Fatalf("Expected 0 posts after deep nested delete, got %d", count) } - DB.Model(&Comment{}).Count(&count) + DB.Model(&NestedDeleteDeepComment{}).Count(&count) if count != 0 { t.Fatalf("Expected 0 comments after deep nested delete, got %d", count) } } func TestNestedDeleteMultipleRelations(t *testing.T) { - type MultiProfile struct { + type NestedDeleteMultiProfile struct { gorm.Model Name string MultiUserID uint } - type MultiPost struct { + type NestedDeleteMultiPost struct { gorm.Model Title string MultiUserID uint } - type MultiUser struct { + type NestedDeleteMultiUser struct { gorm.Model Name string - Profiles []MultiProfile `gorm:"foreignKey:MultiUserID"` - Posts []MultiPost `gorm:"foreignKey:MultiUserID"` + Profiles []NestedDeleteMultiProfile `gorm:"foreignKey:MultiUserID"` + Posts []NestedDeleteMultiPost `gorm:"foreignKey:MultiUserID"` } - DB.Migrator().DropTable(&MultiProfile{}, &MultiPost{}, &MultiUser{}) - if err := DB.AutoMigrate(&MultiUser{}, &MultiPost{}, &MultiProfile{}); err != nil { + DB.Migrator().DropTable(&NestedDeleteMultiProfile{}, &NestedDeleteMultiPost{}, &NestedDeleteMultiUser{}) + if err := DB.AutoMigrate(&NestedDeleteMultiUser{}, &NestedDeleteMultiPost{}, &NestedDeleteMultiProfile{}); err != nil { t.Fatalf("Failed to auto migrate, got error %v", err) } - user1 := MultiUser{Name: "multi_relation_test1", Profiles: []MultiProfile{{Name: "Profile1"}}} + user1 := NestedDeleteMultiUser{Name: "multi_relation_test1", Profiles: []NestedDeleteMultiProfile{{Name: "Profile1"}}} DB.Create(&user1) - var deletedUser1 MultiUser + var deletedUser1 NestedDeleteMultiUser result := DB.Select("Profiles").Delete(&deletedUser1, user1.ID) if result.Error != nil { t.Fatalf("Failed to delete user with Profiles relation, got error %v", result.Error) } - user2 := MultiUser{Name: "multi_relation_test2", Posts: []MultiPost{{Title: "Post1"}}} + user2 := NestedDeleteMultiUser{Name: "multi_relation_test2", Posts: []NestedDeleteMultiPost{{Title: "Post1"}}} DB.Create(&user2) - var deletedUser2 MultiUser + var deletedUser2 NestedDeleteMultiUser result = DB.Select("Posts").Delete(&deletedUser2, user2.ID) if result.Error != nil { t.Fatalf("Failed to delete user with Posts relation, got error %v", result.Error) } var count int64 - DB.Model(&MultiUser{}).Count(&count) + DB.Model(&NestedDeleteMultiUser{}).Count(&count) if count != 0 { t.Fatalf("Expected 0 users after multi-relation delete, got %d", count) } - DB.Model(&MultiPost{}).Count(&count) + DB.Model(&NestedDeleteMultiPost{}).Count(&count) if count != 0 { t.Fatalf("Expected 0 posts after multi-relation delete, got %d", count) } - DB.Model(&MultiProfile{}).Count(&count) + DB.Model(&NestedDeleteMultiProfile{}).Count(&count) if count != 0 { t.Fatalf("Expected 0 profiles after multi-relation delete, got %d", count) } @@ -554,97 +554,75 @@ func TestNestedDeleteMultipleRelations(t *testing.T) { func TestNestedDeleteWithPolymorphic(t *testing.T) { - type Toy struct { + type NestedDeleteToy struct { gorm.Model Name string OwnerID uint OwnerType string } - type Cat struct { + type NestedDeleteCat struct { gorm.Model Name string - Toys []Toy `gorm:"polymorphic:Owner;"` + Toys []NestedDeleteToy `gorm:"polymorphic:Owner;"` } - DB.Migrator().DropTable(&Toy{}, &Cat{}) - if err := DB.AutoMigrate(&Cat{}, &Toy{}); err != nil { + DB.Migrator().DropTable(&NestedDeleteToy{}, &NestedDeleteCat{}) + if err := DB.AutoMigrate(&NestedDeleteCat{}, &NestedDeleteToy{}); err != nil { t.Fatalf("Failed to auto migrate, got error %v", err) } - cat := Cat{Name: "Fluffy", Toys: []Toy{{Name: "Ball"}, {Name: "Mouse"}}} + cat := NestedDeleteCat{Name: "Fluffy", Toys: []NestedDeleteToy{{Name: "Ball"}, {Name: "Mouse"}}} DB.Create(&cat) - var deletedCat Cat + var deletedCat NestedDeleteCat result := DB.Select("Toys").Delete(&deletedCat, cat.ID) if result.Error != nil { t.Fatalf("Failed to delete cat with polymorphic toys, got error %v", result.Error) } var count int64 - DB.Model(&Cat{}).Count(&count) + DB.Model(&NestedDeleteCat{}).Count(&count) if count != 0 { t.Fatalf("Expected 0 cats after polymorphic nested delete, got %d", count) } - DB.Model(&Toy{}).Count(&count) + DB.Model(&NestedDeleteToy{}).Count(&count) if count != 0 { t.Fatalf("Expected 0 toys after polymorphic nested delete, got %d", count) } } -func TestNestedDeleteErrorHandling(t *testing.T) { - type User struct { - gorm.Model - Name string - } - - DB.Migrator().DropTable(&User{}) - if err := DB.AutoMigrate(&User{}); err != nil { - t.Fatalf("Failed to auto migrate, got error %v", err) - } - - var user User - result := DB.Select("NonExistentRelation").Delete(&user, 999) - if result.Error == nil { - t.Fatalf("Expected error for non-existent relationship, but got none") - } - - result = DB.Select("Name").Delete(&user, 999) - if result.Error == nil { - t.Fatalf("Expected error for non-existent record, but got none") - } -} func TestNestedDeleteWithSelfReferential(t *testing.T) { - type Category struct { + type NestedDeleteCategory struct { gorm.Model Name string ParentID *uint - Parent *Category - Children []Category `gorm:"foreignKey:ParentID"` + Parent *NestedDeleteCategory + Children []NestedDeleteCategory `gorm:"foreignKey:ParentID"` } - DB.Migrator().DropTable(&Category{}) - if err := DB.AutoMigrate(&Category{}); err != nil { + DB.Migrator().DropTable(&NestedDeleteCategory{}) + if err := DB.AutoMigrate(&NestedDeleteCategory{}); err != nil { t.Fatalf("Failed to auto migrate, got error %v", err) } - parent := Category{Name: "Parent"} + parent := NestedDeleteCategory{Name: "Parent"} DB.Create(&parent) - child1 := Category{Name: "Child1", ParentID: &parent.ID} - child2 := Category{Name: "Child2", ParentID: &parent.ID} + child1 := NestedDeleteCategory{Name: "Child1", ParentID: &parent.ID} + child2 := NestedDeleteCategory{Name: "Child2", ParentID: &parent.ID} DB.Create(&child1) DB.Create(&child2) - var deletedParent Category + var deletedParent NestedDeleteCategory result := DB.Select("Children").Delete(&deletedParent, parent.ID) if result.Error != nil { t.Fatalf("Failed to delete parent with children, got error %v", result.Error) } var count int64 - DB.Model(&Category{}).Count(&count) + DB.Model(&NestedDeleteCategory{}).Count(&count) if count != 0 { t.Fatalf("Expected 0 categories after self-referential nested delete, got %d", count) } From 0f867e6885e413843884f14b61f0a57b858993c3 Mon Sep 17 00:00:00 2001 From: mikolajbubacz Date: Sun, 28 Sep 2025 19:43:47 +0200 Subject: [PATCH 3/8] fix golangci-lint --- callbacks/delete.go | 334 ++++++++++++++++++++++++-------------------- 1 file changed, 179 insertions(+), 155 deletions(-) diff --git a/callbacks/delete.go b/callbacks/delete.go index 4edd66c77..fd55d6206 100644 --- a/callbacks/delete.go +++ b/callbacks/delete.go @@ -24,16 +24,16 @@ func BeforeDelete(db *gorm.DB) { } } - func parseNestedDelete(schema *schema.Schema, selects []string) map[string][]string { result := make(map[string][]string) - + for _, selectItem := range selects { - if selectItem == clause.Associations { + switch { + case selectItem == clause.Associations: for name := range schema.Relationships.Relations { result[name] = nil } - } else if strings.Contains(selectItem, ".") { + case strings.Contains(selectItem, "."): parts := strings.Split(selectItem, ".") if len(parts) > 0 { firstRel := parts[0] @@ -45,170 +45,29 @@ func parseNestedDelete(schema *schema.Schema, selects []string) map[string][]str } } } - } else { + default: if _, ok := schema.Relationships.Relations[selectItem]; ok { result[selectItem] = nil } } } - + return result } func deleteNestedAssociations(db *gorm.DB, rel *schema.Relationship, nestedPaths []string) error { switch rel.Type { case schema.HasOne, schema.HasMany: - queryConds := rel.ToQueryConditions(db.Statement.Context, db.Statement.ReflectValue) - modelValue := reflect.New(rel.FieldSchema.ModelType).Interface() - tx := db.Session(&gorm.Session{NewDB: true}).Model(modelValue) - - if db.Statement.Unscoped { - tx = tx.Unscoped() - } - - withoutConditions := false - for _, cond := range queryConds { - if c, ok := cond.(clause.IN); ok && len(c.Values) == 0 { - withoutConditions = true - break - } - } - - if !withoutConditions { - if len(nestedPaths) > 0 { - var records reflect.Value - // When looking for records to process nested deletes, we always use Unscoped - // to find records that might have been soft-deleted by clause.Associations - searchTx := tx.Unscoped() - - if rel.Type == schema.HasOne { - records = reflect.New(rel.FieldSchema.ModelType) - if err := searchTx.Clauses(clause.Where{Exprs: queryConds}).First(records.Interface()).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil - } - return err - } - } else { - records = reflect.New(reflect.SliceOf(rel.FieldSchema.ModelType)) - if err := searchTx.Clauses(clause.Where{Exprs: queryConds}).Find(records.Interface()).Error; err != nil { - return err - } - } - - // Check if we found any records - if records.Elem().Len() == 0 { - return nil - } - - return deleteWithNestedSelect(tx, records.Interface(), nestedPaths) - } else { - result := tx.Clauses(clause.Where{Exprs: queryConds}).Delete(modelValue) - if result.Error != nil { - return result.Error - } - return nil - } - } - + return deleteHasOneOrManyNestedAssociations(db, rel, nestedPaths) case schema.Many2Many: - var associatedRecords reflect.Value - - joinTable := rel.JoinTable.Table - selectQuery := db.Session(&gorm.Session{NewDB: true}) - - var joinConditions []string - var queryArgs []interface{} - - for _, ref := range rel.References { - if ref.OwnPrimaryKey { - if db.Statement.ReflectValue.Kind() == reflect.Slice { - // Skip if dealing with slice - can't get primary key from slice - continue - } - value, _ := ref.PrimaryKey.ValueOf(db.Statement.Context, db.Statement.ReflectValue) - joinConditions = append(joinConditions, joinTable+"."+ref.ForeignKey.DBName+" = ?") - queryArgs = append(queryArgs, value) - } - } - - if len(joinConditions) > 0 { - associatedRecords = reflect.New(reflect.SliceOf(rel.FieldSchema.ModelType)) - - query := selectQuery.Table(rel.FieldSchema.Table). - Joins("INNER JOIN "+joinTable+" ON "+rel.FieldSchema.Table+"."+rel.FieldSchema.PrimaryFieldDBNames[0]+" = "+joinTable+"."+rel.References[len(rel.References)-1].ForeignKey.DBName). - Where(strings.Join(joinConditions, " AND "), queryArgs...) - - if err := query.Find(associatedRecords.Interface()).Error; err != nil { - return err - } - - if len(nestedPaths) > 0 { - if err := deleteWithNestedSelect(db.Session(&gorm.Session{NewDB: true}), associatedRecords.Interface(), nestedPaths); err != nil { - return err - } - } else { - if associatedRecords.Elem().Len() > 0 { - if err := db.Session(&gorm.Session{NewDB: true}).Delete(associatedRecords.Interface()).Error; err != nil { - return err - } - } - } - } - - var ( - queryConds = make([]clause.Expression, 0, len(rel.References)) - foreignFields = make([]*schema.Field, 0, len(rel.References)) - relForeignKeys = make([]string, 0, len(rel.References)) - modelValue = reflect.New(rel.JoinTable.ModelType).Interface() - table = rel.JoinTable.Table - tx = db.Session(&gorm.Session{NewDB: true}).Model(modelValue).Table(table) - ) - - for _, ref := range rel.References { - if ref.OwnPrimaryKey { - foreignFields = append(foreignFields, ref.PrimaryKey) - relForeignKeys = append(relForeignKeys, ref.ForeignKey.DBName) - } else if ref.PrimaryValue != "" { - queryConds = append(queryConds, clause.Eq{ - Column: clause.Column{Table: rel.JoinTable.Table, Name: ref.ForeignKey.DBName}, - Value: ref.PrimaryValue, - }) - } - } - - _, foreignValues := schema.GetIdentityFieldValuesMap(db.Statement.Context, db.Statement.ReflectValue, foreignFields) - column, values := schema.ToQueryValues(table, relForeignKeys, foreignValues) - - if len(values) > 0 { - queryConds = append(queryConds, clause.IN{Column: column, Values: values}) - } - - if len(queryConds) > 0 { - return tx.Clauses(clause.Where{Exprs: queryConds}).Delete(modelValue).Error - } - return nil - + return deleteMany2ManyNestedAssociations(db, rel, nestedPaths) case schema.BelongsTo: - if len(nestedPaths) > 0 { - queryConds := rel.ToQueryConditions(db.Statement.Context, db.Statement.ReflectValue) - modelValue := reflect.New(rel.FieldSchema.ModelType).Interface() - tx := db.Session(&gorm.Session{NewDB: true}).Model(modelValue) - - if err := tx.Clauses(clause.Where{Exprs: queryConds}).First(modelValue).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil - } - return err - } - - return deleteWithNestedSelect(tx, modelValue, nestedPaths) - } + return deleteBelongsToNestedAssociations(db, rel, nestedPaths) } - return nil } + func deleteWithNestedSelect(db *gorm.DB, value interface{}, nestedPaths []string) error { tx := db.Session(&gorm.Session{NewDB: true}) for _, path := range nestedPaths { @@ -284,7 +143,7 @@ func DeleteBeforeAssociations(db *gorm.DB) { } if err := deleteAssociation(db, rel); err != nil { - db.AddError(err) + _ = db.AddError(err) return } } @@ -299,10 +158,10 @@ func DeleteBeforeAssociations(db *gorm.DB) { firstRel := parts[0] if _, ok := db.Statement.Schema.Relationships.Relations[firstRel]; !ok { if field := db.Statement.Schema.LookUpField(firstRel); field != nil { - db.AddError(fmt.Errorf("field %s is not a valid relationship", firstRel)) + _ = db.AddError(fmt.Errorf("field %s is not a valid relationship", firstRel)) return } else { - db.AddError(fmt.Errorf("%s is not a valid relationship", firstRel)) + _ = db.AddError(fmt.Errorf("%s is not a valid relationship", firstRel)) return } } @@ -340,7 +199,6 @@ func DeleteBeforeAssociations(db *gorm.DB) { } if err := loadDB.First(db.Statement.Dest).Error; err != nil { - return } db.Statement.ReflectValue = reflect.ValueOf(db.Statement.Dest).Elem() @@ -541,3 +399,169 @@ func AfterDelete(db *gorm.DB) { } } +func hasEmptyConditions(queryConds []clause.Expression) bool { + for _, cond := range queryConds { + if c, ok := cond.(clause.IN); ok && len(c.Values) == 0 { + return true + } + } + return false +} + +func deleteHasOneOrManyNestedAssociations(db *gorm.DB, rel *schema.Relationship, nestedPaths []string) error { + queryConds := rel.ToQueryConditions(db.Statement.Context, db.Statement.ReflectValue) + modelValue := reflect.New(rel.FieldSchema.ModelType).Interface() + tx := db.Session(&gorm.Session{NewDB: true}).Model(modelValue) + + if db.Statement.Unscoped { + tx = tx.Unscoped() + } + + if hasEmptyConditions(queryConds) { + return nil + } + + if len(nestedPaths) > 0 { + return deleteWithNestedPaths(tx, rel, queryConds, nestedPaths) + } + + result := tx.Clauses(clause.Where{Exprs: queryConds}).Delete(modelValue) + return result.Error +} + +func deleteWithNestedPaths(tx *gorm.DB, rel *schema.Relationship, queryConds []clause.Expression, nestedPaths []string) error { + var records reflect.Value + searchTx := tx.Unscoped() + + if rel.Type == schema.HasOne { + records = reflect.New(rel.FieldSchema.ModelType) + if err := searchTx.Clauses(clause.Where{Exprs: queryConds}).First(records.Interface()).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil + } + return err + } + } else { + records = reflect.New(reflect.SliceOf(rel.FieldSchema.ModelType)) + if err := searchTx.Clauses(clause.Where{Exprs: queryConds}).Find(records.Interface()).Error; err != nil { + return err + } + } + + if records.Elem().Len() == 0 { + return nil + } + + return deleteWithNestedSelect(tx, records.Interface(), nestedPaths) +} + +func deleteMany2ManyNestedAssociations(db *gorm.DB, rel *schema.Relationship, nestedPaths []string) error { + associatedRecords, err := findMany2ManyAssociatedRecords(db, rel) + if err != nil { + return err + } + + if associatedRecords.Elem().Len() > 0 { + if len(nestedPaths) > 0 { + if err := deleteWithNestedSelect(db.Session(&gorm.Session{NewDB: true}), associatedRecords.Interface(), nestedPaths); err != nil { + return err + } + } else { + if err := db.Session(&gorm.Session{NewDB: true}).Delete(associatedRecords.Interface()).Error; err != nil { + return err + } + } + } + + return deleteMany2ManyJoinTable(db, rel) +} + +func findMany2ManyAssociatedRecords(db *gorm.DB, rel *schema.Relationship) (reflect.Value, error) { + joinTable := rel.JoinTable.Table + selectQuery := db.Session(&gorm.Session{NewDB: true}) + + if db.Statement.Unscoped { + selectQuery = selectQuery.Unscoped() + } + + var joinConditions []string + var queryArgs []interface{} + + for _, ref := range rel.References { + if ref.OwnPrimaryKey { + if db.Statement.ReflectValue.Kind() == reflect.Slice { + continue + } + value, _ := ref.PrimaryKey.ValueOf(db.Statement.Context, db.Statement.ReflectValue) + joinConditions = append(joinConditions, joinTable+"."+ref.ForeignKey.DBName+" = ?") + queryArgs = append(queryArgs, value) + } + } + + if len(joinConditions) == 0 { + return reflect.Value{}, nil + } + + associatedRecords := reflect.New(reflect.SliceOf(rel.FieldSchema.ModelType)) + query := selectQuery.Table(rel.FieldSchema.Table). + Joins("INNER JOIN "+joinTable+" ON "+rel.FieldSchema.Table+"."+rel.FieldSchema.PrimaryFieldDBNames[0]+" = "+joinTable+"."+rel.References[len(rel.References)-1].ForeignKey.DBName). + Where(strings.Join(joinConditions, " AND "), queryArgs...) + + err := query.Find(associatedRecords.Interface()).Error + return associatedRecords, err +} + +func deleteMany2ManyJoinTable(db *gorm.DB, rel *schema.Relationship) error { + var ( + queryConds = make([]clause.Expression, 0, len(rel.References)) + foreignFields = make([]*schema.Field, 0, len(rel.References)) + relForeignKeys = make([]string, 0, len(rel.References)) + modelValue = reflect.New(rel.JoinTable.ModelType).Interface() + table = rel.JoinTable.Table + tx = db.Session(&gorm.Session{NewDB: true}).Model(modelValue).Table(table) + ) + + for _, ref := range rel.References { + if ref.OwnPrimaryKey { + foreignFields = append(foreignFields, ref.PrimaryKey) + relForeignKeys = append(relForeignKeys, ref.ForeignKey.DBName) + } else if ref.PrimaryValue != "" { + queryConds = append(queryConds, clause.Eq{ + Column: clause.Column{Table: rel.JoinTable.Table, Name: ref.ForeignKey.DBName}, + Value: ref.PrimaryValue, + }) + } + } + + _, foreignValues := schema.GetIdentityFieldValuesMap(db.Statement.Context, db.Statement.ReflectValue, foreignFields) + column, values := schema.ToQueryValues(table, relForeignKeys, foreignValues) + + if len(values) > 0 { + queryConds = append(queryConds, clause.IN{Column: column, Values: values}) + } + + if len(queryConds) > 0 { + return tx.Clauses(clause.Where{Exprs: queryConds}).Delete(modelValue).Error + } + return nil +} + +func deleteBelongsToNestedAssociations(db *gorm.DB, rel *schema.Relationship, nestedPaths []string) error { + if len(nestedPaths) == 0 { + return nil + } + + queryConds := rel.ToQueryConditions(db.Statement.Context, db.Statement.ReflectValue) + modelValue := reflect.New(rel.FieldSchema.ModelType).Interface() + tx := db.Session(&gorm.Session{NewDB: true}).Model(modelValue) + + if err := tx.Clauses(clause.Where{Exprs: queryConds}).First(modelValue).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil + } + return err + } + + return deleteWithNestedSelect(tx, modelValue, nestedPaths) +} + From ee4bd3f237e2575ff45c6faf42075084f08e3374 Mon Sep 17 00:00:00 2001 From: mikolajbubacz Date: Sun, 28 Sep 2025 20:14:56 +0200 Subject: [PATCH 4/8] fixing for golangci-lint --- callbacks/delete.go | 109 ++++++++++++++++++++++---------------------- 1 file changed, 55 insertions(+), 54 deletions(-) diff --git a/callbacks/delete.go b/callbacks/delete.go index fd55d6206..e9bf43ee1 100644 --- a/callbacks/delete.go +++ b/callbacks/delete.go @@ -67,7 +67,6 @@ func deleteNestedAssociations(db *gorm.DB, rel *schema.Relationship, nestedPaths return nil } - func deleteWithNestedSelect(db *gorm.DB, value interface{}, nestedPaths []string) error { tx := db.Session(&gorm.Session{NewDB: true}) for _, path := range nestedPaths { @@ -215,7 +214,7 @@ func DeleteBeforeAssociations(db *gorm.DB) { } if err := deleteNestedAssociations(db, rel, nestedPaths); err != nil { - db.AddError(err) + _ = db.AddError(err) return } } @@ -241,7 +240,7 @@ func DeleteBeforeAssociations(db *gorm.DB) { } if err := deleteAssociation(db, rel); err != nil { - db.AddError(err) + _ = db.AddError(err) return } } @@ -251,67 +250,65 @@ func DeleteBeforeAssociations(db *gorm.DB) { func deleteAssociation(db *gorm.DB, rel *schema.Relationship) error { switch rel.Type { case schema.HasOne, schema.HasMany: - queryConds := rel.ToQueryConditions(db.Statement.Context, db.Statement.ReflectValue) - modelValue := reflect.New(rel.FieldSchema.ModelType).Interface() - tx := db.Session(&gorm.Session{NewDB: true}).Model(modelValue) - withoutConditions := false - - if db.Statement.Unscoped { - tx = tx.Unscoped() - } + return deleteHasOneOrManyAssociation(db, rel) + case schema.Many2Many: + return deleteMany2ManyAssociation(db, rel) + case schema.BelongsTo: + // For clause.Associations, BelongsTo should not be deleted + // as it would violate foreign key constraints + return nil + } + return nil +} - for _, cond := range queryConds { - if c, ok := cond.(clause.IN); ok && len(c.Values) == 0 { - withoutConditions = true - break - } - } +func deleteHasOneOrManyAssociation(db *gorm.DB, rel *schema.Relationship) error { + queryConds := rel.ToQueryConditions(db.Statement.Context, db.Statement.ReflectValue) + modelValue := reflect.New(rel.FieldSchema.ModelType).Interface() + tx := db.Session(&gorm.Session{NewDB: true}).Model(modelValue) - if !withoutConditions { - return tx.Clauses(clause.Where{Exprs: queryConds}).Delete(modelValue).Error - } + if db.Statement.Unscoped { + tx = tx.Unscoped() + } - case schema.Many2Many: + if hasEmptyConditions(queryConds) { + return nil + } - var ( - queryConds = make([]clause.Expression, 0, len(rel.References)) - foreignFields = make([]*schema.Field, 0, len(rel.References)) - relForeignKeys = make([]string, 0, len(rel.References)) - modelValue = reflect.New(rel.JoinTable.ModelType).Interface() - table = rel.JoinTable.Table - tx = db.Session(&gorm.Session{NewDB: true}).Model(modelValue).Table(table) - ) - - for _, ref := range rel.References { - if ref.OwnPrimaryKey { - foreignFields = append(foreignFields, ref.PrimaryKey) - relForeignKeys = append(relForeignKeys, ref.ForeignKey.DBName) - } else if ref.PrimaryValue != "" { - queryConds = append(queryConds, clause.Eq{ - Column: clause.Column{Table: rel.JoinTable.Table, Name: ref.ForeignKey.DBName}, - Value: ref.PrimaryValue, - }) - } - } + return tx.Clauses(clause.Where{Exprs: queryConds}).Delete(modelValue).Error +} - _, foreignValues := schema.GetIdentityFieldValuesMap(db.Statement.Context, db.Statement.ReflectValue, foreignFields) - column, values := schema.ToQueryValues(table, relForeignKeys, foreignValues) - - if len(values) > 0 { - queryConds = append(queryConds, clause.IN{Column: column, Values: values}) - } +func deleteMany2ManyAssociation(db *gorm.DB, rel *schema.Relationship) error { + var ( + queryConds = make([]clause.Expression, 0, len(rel.References)) + foreignFields = make([]*schema.Field, 0, len(rel.References)) + relForeignKeys = make([]string, 0, len(rel.References)) + modelValue = reflect.New(rel.JoinTable.ModelType).Interface() + table = rel.JoinTable.Table + tx = db.Session(&gorm.Session{NewDB: true}).Model(modelValue).Table(table) + ) - if len(queryConds) > 0 { - return tx.Clauses(clause.Where{Exprs: queryConds}).Delete(modelValue).Error + for _, ref := range rel.References { + if ref.OwnPrimaryKey { + foreignFields = append(foreignFields, ref.PrimaryKey) + relForeignKeys = append(relForeignKeys, ref.ForeignKey.DBName) + } else if ref.PrimaryValue != "" { + queryConds = append(queryConds, clause.Eq{ + Column: clause.Column{Table: rel.JoinTable.Table, Name: ref.ForeignKey.DBName}, + Value: ref.PrimaryValue, + }) } - return nil + } - case schema.BelongsTo: - // For clause.Associations, BelongsTo should not be deleted - // as it would violate foreign key constraints - return nil + _, foreignValues := schema.GetIdentityFieldValuesMap(db.Statement.Context, db.Statement.ReflectValue, foreignFields) + column, values := schema.ToQueryValues(table, relForeignKeys, foreignValues) + + if len(values) > 0 { + queryConds = append(queryConds, clause.IN{Column: column, Values: values}) } + if len(queryConds) > 0 { + return tx.Clauses(clause.Where{Exprs: queryConds}).Delete(modelValue).Error + } return nil } @@ -502,6 +499,10 @@ func findMany2ManyAssociatedRecords(db *gorm.DB, rel *schema.Relationship) (refl return reflect.Value{}, nil } + if len(rel.References) == 0 || len(rel.FieldSchema.PrimaryFieldDBNames) == 0 { + return reflect.Value{}, fmt.Errorf("missing references or primary field names for relationship") + } + associatedRecords := reflect.New(reflect.SliceOf(rel.FieldSchema.ModelType)) query := selectQuery.Table(rel.FieldSchema.Table). Joins("INNER JOIN "+joinTable+" ON "+rel.FieldSchema.Table+"."+rel.FieldSchema.PrimaryFieldDBNames[0]+" = "+joinTable+"."+rel.References[len(rel.References)-1].ForeignKey.DBName). From 1602dc7bcc8570ca8157bb228822b40bc6be4730 Mon Sep 17 00:00:00 2001 From: mikolajbubacz Date: Sun, 28 Sep 2025 20:20:59 +0200 Subject: [PATCH 5/8] formatting --- callbacks/delete.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/callbacks/delete.go b/callbacks/delete.go index e9bf43ee1..3f785dbfe 100644 --- a/callbacks/delete.go +++ b/callbacks/delete.go @@ -98,7 +98,7 @@ func DeleteBeforeAssociations(db *gorm.DB) { } } } - + if hasRelationshipSelects { hasClauseAssociations := false var otherSelects []string From 3daa9f5fd37a1300ad23f3c9d903ef8a1eadb306 Mon Sep 17 00:00:00 2001 From: mikolajbubacz Date: Sun, 28 Sep 2025 20:29:57 +0200 Subject: [PATCH 6/8] remove whitespace --- callbacks/delete.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/callbacks/delete.go b/callbacks/delete.go index 3f785dbfe..14adb5edc 100644 --- a/callbacks/delete.go +++ b/callbacks/delete.go @@ -101,8 +101,7 @@ func DeleteBeforeAssociations(db *gorm.DB) { if hasRelationshipSelects { hasClauseAssociations := false - var otherSelects []string - + var otherSelects []string for _, s := range db.Statement.Selects { if s == clause.Associations { hasClauseAssociations = true From c3965577e3570d08704e3bb432aa936e493164ce Mon Sep 17 00:00:00 2001 From: mikolajbubacz Date: Sun, 28 Sep 2025 20:38:31 +0200 Subject: [PATCH 7/8] fixing for ci/cd pipeline --- callbacks/delete.go | 246 ++++++++++++++++++++++---------------------- 1 file changed, 122 insertions(+), 124 deletions(-) diff --git a/callbacks/delete.go b/callbacks/delete.go index 14adb5edc..268f50f87 100644 --- a/callbacks/delete.go +++ b/callbacks/delete.go @@ -101,7 +101,7 @@ func DeleteBeforeAssociations(db *gorm.DB) { if hasRelationshipSelects { hasClauseAssociations := false - var otherSelects []string + var otherSelects []string for _, s := range db.Statement.Selects { if s == clause.Associations { hasClauseAssociations = true @@ -109,135 +109,134 @@ func DeleteBeforeAssociations(db *gorm.DB) { otherSelects = append(otherSelects, s) } } - - if hasClauseAssociations { - selectColumns, restricted := db.Statement.SelectAndOmitColumns(true, false) - - if restricted { - explicitRelations := make(map[string]bool) - for _, s := range otherSelects { - if strings.Contains(s, ".") { - parts := strings.Split(s, ".") - if len(parts) > 0 { - explicitRelations[parts[0]] = true + + if hasClauseAssociations { + selectColumns, restricted := db.Statement.SelectAndOmitColumns(true, false) + + if restricted { + explicitRelations := make(map[string]bool) + for _, s := range otherSelects { + if strings.Contains(s, ".") { + parts := strings.Split(s, ".") + if len(parts) > 0 { + explicitRelations[parts[0]] = true + } + } else { + explicitRelations[s] = true } - } else { - explicitRelations[s] = true - } - } - - for column, v := range selectColumns { - if !v { - continue - } - - if explicitRelations[column] { - continue - } - - rel, ok := db.Statement.Schema.Relationships.Relations[column] - if !ok { - continue } - - if err := deleteAssociation(db, rel); err != nil { - _ = db.AddError(err) - return + + for column, v := range selectColumns { + if !v { + continue + } + + if explicitRelations[column] { + continue + } + + rel, ok := db.Statement.Schema.Relationships.Relations[column] + if !ok { + continue + } + + if err := deleteAssociation(db, rel); err != nil { + _ = db.AddError(err) + return + } } } } - } - - if len(otherSelects) > 0 { - for _, selectItem := range otherSelects { - if selectItem != clause.Associations { - parts := strings.Split(selectItem, ".") - if len(parts) > 0 { - firstRel := parts[0] - if _, ok := db.Statement.Schema.Relationships.Relations[firstRel]; !ok { - if field := db.Statement.Schema.LookUpField(firstRel); field != nil { - _ = db.AddError(fmt.Errorf("field %s is not a valid relationship", firstRel)) - return - } else { - _ = db.AddError(fmt.Errorf("%s is not a valid relationship", firstRel)) - return + + if len(otherSelects) > 0 { + for _, selectItem := range otherSelects { + if selectItem != clause.Associations { + parts := strings.Split(selectItem, ".") + if len(parts) > 0 { + firstRel := parts[0] + if _, ok := db.Statement.Schema.Relationships.Relations[firstRel]; !ok { + if field := db.Statement.Schema.LookUpField(firstRel); field != nil { + _ = db.AddError(fmt.Errorf("field %s is not a valid relationship", firstRel)) + return + } else { + _ = db.AddError(fmt.Errorf("%s is not a valid relationship", firstRel)) + return + } } } } } - } - - if db.Statement.ReflectValue.Kind() == reflect.Struct || db.Statement.ReflectValue.Kind() == reflect.Slice { - var needsLoad = false - - if db.Statement.ReflectValue.Kind() == reflect.Struct { - var isZero = true - for _, field := range db.Statement.Schema.PrimaryFields { - value, _ := field.ValueOf(db.Statement.Context, db.Statement.ReflectValue) - if !reflect.ValueOf(value).IsZero() { - isZero = false - break + + if db.Statement.ReflectValue.Kind() == reflect.Struct || db.Statement.ReflectValue.Kind() == reflect.Slice { + needsLoad := false + + if db.Statement.ReflectValue.Kind() == reflect.Struct { + isZero := true + for _, field := range db.Statement.Schema.PrimaryFields { + value, _ := field.ValueOf(db.Statement.Context, db.Statement.ReflectValue) + if !reflect.ValueOf(value).IsZero() { + isZero = false + break + } } + needsLoad = isZero + } else if db.Statement.ReflectValue.Kind() == reflect.Slice { + needsLoad = db.Statement.ReflectValue.Len() == 0 } - needsLoad = isZero - } else if db.Statement.ReflectValue.Kind() == reflect.Slice { - needsLoad = db.Statement.ReflectValue.Len() == 0 - } - - if needsLoad { - loadDB := db.Session(&gorm.Session{NewDB: true}).Model(db.Statement.Dest) - if db.Statement.Unscoped { - loadDB = loadDB.Unscoped() - } - - if whereClause, ok := db.Statement.Clauses["WHERE"]; ok { - if where, ok := whereClause.Expression.(clause.Where); ok { - loadDB.Statement.AddClause(where) + + if needsLoad { + loadDB := db.Session(&gorm.Session{NewDB: true}).Model(db.Statement.Dest) + if db.Statement.Unscoped { + loadDB = loadDB.Unscoped() + } + + if whereClause, ok := db.Statement.Clauses["WHERE"]; ok { + if where, ok := whereClause.Expression.(clause.Where); ok { + loadDB.Statement.AddClause(where) + } + } + + if err := loadDB.First(db.Statement.Dest).Error; err != nil { + return } + db.Statement.ReflectValue = reflect.ValueOf(db.Statement.Dest).Elem() } - - if err := loadDB.First(db.Statement.Dest).Error; err != nil { + } + + nestedDeletes := parseNestedDelete(db.Statement.Schema, otherSelects) + + for relName, nestedPaths := range nestedDeletes { + rel := db.Statement.Schema.Relationships.Relations[relName] + if rel == nil { + continue + } + + if err := deleteNestedAssociations(db, rel, nestedPaths); err != nil { + _ = db.AddError(err) return } - db.Statement.ReflectValue = reflect.ValueOf(db.Statement.Dest).Elem() - } - } - - - nestedDeletes := parseNestedDelete(db.Statement.Schema, otherSelects) - - for relName, nestedPaths := range nestedDeletes { - rel := db.Statement.Schema.Relationships.Relations[relName] - if rel == nil { - continue - } - - if err := deleteNestedAssociations(db, rel, nestedPaths); err != nil { - _ = db.AddError(err) - return } } + return } - return } - } - + selectColumns, restricted := db.Statement.SelectAndOmitColumns(true, false) - + if !restricted { return } - + for column, v := range selectColumns { if !v { continue } - + rel, ok := db.Statement.Schema.Relationships.Relations[column] if !ok { continue } - + if err := deleteAssociation(db, rel); err != nil { _ = db.AddError(err) return @@ -300,7 +299,7 @@ func deleteMany2ManyAssociation(db *gorm.DB, rel *schema.Relationship) error { _, foreignValues := schema.GetIdentityFieldValuesMap(db.Statement.Context, db.Statement.ReflectValue, foreignFields) column, values := schema.ToQueryValues(table, relForeignKeys, foreignValues) - + if len(values) > 0 { queryConds = append(queryConds, clause.IN{Column: column, Values: values}) } @@ -408,19 +407,19 @@ func deleteHasOneOrManyNestedAssociations(db *gorm.DB, rel *schema.Relationship, queryConds := rel.ToQueryConditions(db.Statement.Context, db.Statement.ReflectValue) modelValue := reflect.New(rel.FieldSchema.ModelType).Interface() tx := db.Session(&gorm.Session{NewDB: true}).Model(modelValue) - + if db.Statement.Unscoped { tx = tx.Unscoped() } - + if hasEmptyConditions(queryConds) { return nil } - + if len(nestedPaths) > 0 { return deleteWithNestedPaths(tx, rel, queryConds, nestedPaths) } - + result := tx.Clauses(clause.Where{Exprs: queryConds}).Delete(modelValue) return result.Error } @@ -428,7 +427,7 @@ func deleteHasOneOrManyNestedAssociations(db *gorm.DB, rel *schema.Relationship, func deleteWithNestedPaths(tx *gorm.DB, rel *schema.Relationship, queryConds []clause.Expression, nestedPaths []string) error { var records reflect.Value searchTx := tx.Unscoped() - + if rel.Type == schema.HasOne { records = reflect.New(rel.FieldSchema.ModelType) if err := searchTx.Clauses(clause.Where{Exprs: queryConds}).First(records.Interface()).Error; err != nil { @@ -443,11 +442,11 @@ func deleteWithNestedPaths(tx *gorm.DB, rel *schema.Relationship, queryConds []c return err } } - + if records.Elem().Len() == 0 { return nil } - + return deleteWithNestedSelect(tx, records.Interface(), nestedPaths) } @@ -456,7 +455,7 @@ func deleteMany2ManyNestedAssociations(db *gorm.DB, rel *schema.Relationship, ne if err != nil { return err } - + if associatedRecords.Elem().Len() > 0 { if len(nestedPaths) > 0 { if err := deleteWithNestedSelect(db.Session(&gorm.Session{NewDB: true}), associatedRecords.Interface(), nestedPaths); err != nil { @@ -468,21 +467,21 @@ func deleteMany2ManyNestedAssociations(db *gorm.DB, rel *schema.Relationship, ne } } } - + return deleteMany2ManyJoinTable(db, rel) } func findMany2ManyAssociatedRecords(db *gorm.DB, rel *schema.Relationship) (reflect.Value, error) { joinTable := rel.JoinTable.Table selectQuery := db.Session(&gorm.Session{NewDB: true}) - + if db.Statement.Unscoped { selectQuery = selectQuery.Unscoped() } - + var joinConditions []string var queryArgs []interface{} - + for _, ref := range rel.References { if ref.OwnPrimaryKey { if db.Statement.ReflectValue.Kind() == reflect.Slice { @@ -493,20 +492,20 @@ func findMany2ManyAssociatedRecords(db *gorm.DB, rel *schema.Relationship) (refl queryArgs = append(queryArgs, value) } } - + if len(joinConditions) == 0 { return reflect.Value{}, nil } - + if len(rel.References) == 0 || len(rel.FieldSchema.PrimaryFieldDBNames) == 0 { return reflect.Value{}, fmt.Errorf("missing references or primary field names for relationship") } - + associatedRecords := reflect.New(reflect.SliceOf(rel.FieldSchema.ModelType)) query := selectQuery.Table(rel.FieldSchema.Table). Joins("INNER JOIN "+joinTable+" ON "+rel.FieldSchema.Table+"."+rel.FieldSchema.PrimaryFieldDBNames[0]+" = "+joinTable+"."+rel.References[len(rel.References)-1].ForeignKey.DBName). Where(strings.Join(joinConditions, " AND "), queryArgs...) - + err := query.Find(associatedRecords.Interface()).Error return associatedRecords, err } @@ -520,7 +519,7 @@ func deleteMany2ManyJoinTable(db *gorm.DB, rel *schema.Relationship) error { table = rel.JoinTable.Table tx = db.Session(&gorm.Session{NewDB: true}).Model(modelValue).Table(table) ) - + for _, ref := range rel.References { if ref.OwnPrimaryKey { foreignFields = append(foreignFields, ref.PrimaryKey) @@ -532,14 +531,14 @@ func deleteMany2ManyJoinTable(db *gorm.DB, rel *schema.Relationship) error { }) } } - + _, foreignValues := schema.GetIdentityFieldValuesMap(db.Statement.Context, db.Statement.ReflectValue, foreignFields) column, values := schema.ToQueryValues(table, relForeignKeys, foreignValues) - + if len(values) > 0 { queryConds = append(queryConds, clause.IN{Column: column, Values: values}) } - + if len(queryConds) > 0 { return tx.Clauses(clause.Where{Exprs: queryConds}).Delete(modelValue).Error } @@ -550,18 +549,17 @@ func deleteBelongsToNestedAssociations(db *gorm.DB, rel *schema.Relationship, ne if len(nestedPaths) == 0 { return nil } - + queryConds := rel.ToQueryConditions(db.Statement.Context, db.Statement.ReflectValue) modelValue := reflect.New(rel.FieldSchema.ModelType).Interface() tx := db.Session(&gorm.Session{NewDB: true}).Model(modelValue) - + if err := tx.Clauses(clause.Where{Exprs: queryConds}).First(modelValue).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil } return err } - + return deleteWithNestedSelect(tx, modelValue, nestedPaths) } - From 3a30b2415eaf5257c1ce95d3ef5b04c6a861cafb Mon Sep 17 00:00:00 2001 From: mikolajbubacz Date: Tue, 4 Nov 2025 23:57:03 +0100 Subject: [PATCH 8/8] Fixing and adding some tests --- .DS_Store | Bin 0 -> 8196 bytes callbacks/delete.go | 12 +- tests/delete_test.go | 792 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 787 insertions(+), 17 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..c3038c73733ba75c927b6c815d5bfae41a100d4c GIT binary patch literal 8196 zcmeI1J#Q015Qb+R#~6r2M3wUBph2?I5D7&`bO>aGC@i6r{*+^|a}kbfE(JmjJ9h|6*|m1(v@^T&==Gj2b0H$NHR;xf)`+Nz&UW=O zhMdNJ?wMAyGuL4i*3+Yok_x&(kNV_G*{ukrvGTo#;4QOA~?~Ugtu5T1aQF|D- zF;^XQPhW4|`*=tCyL0_}JI=d-$Dgp2E==>%4o#>nvQvFM8{sn4*1uj`i8RB^KBM@I zq&Q0Okmk8a9cs}KpEhiVp3+z_sV6tUloRE0oxFLmaocd^MxL?Fj}(0B7wc64(-346 z8dFb_mEX_$U8WzOQ?0}vu2MxYf}?cI995i^UD~G}J%fc}f`#f=^I}|{gWoAnW-pg% zUYy{fCUaGwPK=Y>XEMpOeLCxRc|Op$=~)tcxGY66k|SY`Dvh8}AL_KAQasCY_$M#M z<#~7|<;m>jGR=#VT<@5xf^*Zsz19O?OXyR2yE*H3c^+)1JYo-*r6@*lEH#mami5^g zW0d#TrTbr`*JU_>^CLuldd2yD^9n420!s#BjqCrr#qa+Y0o5~j1-t_PNC8!j8qpRux3zU94cFQM zdJ~-+$0f$ 0 { + if associatedRecords.IsValid() && associatedRecords.Elem().Len() > 0 { if len(nestedPaths) > 0 { if err := deleteWithNestedSelect(db.Session(&gorm.Session{NewDB: true}), associatedRecords.Interface(), nestedPaths); err != nil { return err diff --git a/tests/delete_test.go b/tests/delete_test.go index d0b12ef66..c556f9b68 100644 --- a/tests/delete_test.go +++ b/tests/delete_test.go @@ -260,8 +260,8 @@ func TestDeleteReturning(t *testing.T) { func TestNestedDelete(t *testing.T) { type NestedDeleteProfile struct { gorm.Model - Name string - NestedDeleteUserID uint + Name string + NestedDeleteUserID uint } type NestedDeleteUser struct { @@ -306,7 +306,6 @@ func TestNestedDelete(t *testing.T) { } } - func TestNestedDeleteWithBelongsTo(t *testing.T) { type NestedDeleteAuthor struct { gorm.Model @@ -527,10 +526,10 @@ func TestNestedDeleteMultipleRelations(t *testing.T) { if result.Error != nil { t.Fatalf("Failed to delete user with Profiles relation, got error %v", result.Error) } - + user2 := NestedDeleteMultiUser{Name: "multi_relation_test2", Posts: []NestedDeleteMultiPost{{Title: "Post1"}}} DB.Create(&user2) - + var deletedUser2 NestedDeleteMultiUser result = DB.Select("Posts").Delete(&deletedUser2, user2.ID) if result.Error != nil { @@ -552,7 +551,6 @@ func TestNestedDeleteMultipleRelations(t *testing.T) { } } - func TestNestedDeleteWithPolymorphic(t *testing.T) { type NestedDeleteToy struct { gorm.Model @@ -592,14 +590,13 @@ func TestNestedDeleteWithPolymorphic(t *testing.T) { } } - func TestNestedDeleteWithSelfReferential(t *testing.T) { type NestedDeleteCategory struct { gorm.Model - Name string - ParentID *uint - Parent *NestedDeleteCategory - Children []NestedDeleteCategory `gorm:"foreignKey:ParentID"` + Name string + ParentID *uint + Parent *NestedDeleteCategory + Children []NestedDeleteCategory `gorm:"foreignKey:ParentID"` } DB.Migrator().DropTable(&NestedDeleteCategory{}) @@ -609,7 +606,7 @@ func TestNestedDeleteWithSelfReferential(t *testing.T) { parent := NestedDeleteCategory{Name: "Parent"} DB.Create(&parent) - + child1 := NestedDeleteCategory{Name: "Child1", ParentID: &parent.ID} child2 := NestedDeleteCategory{Name: "Child2", ParentID: &parent.ID} DB.Create(&child1) @@ -627,3 +624,774 @@ func TestNestedDeleteWithSelfReferential(t *testing.T) { t.Fatalf("Expected 0 categories after self-referential nested delete, got %d", count) } } + +// TestNestedDeleteWithEmptyAssociations tests deletion when associations are empty +func TestNestedDeleteWithEmptyAssociations(t *testing.T) { + type EmptyAssocProfile struct { + gorm.Model + Name string + EmptyAssocUserID uint + } + + type EmptyAssocUser struct { + gorm.Model + Name string + Profiles []EmptyAssocProfile `gorm:"foreignKey:EmptyAssocUserID"` + } + + DB.Migrator().DropTable(&EmptyAssocProfile{}, &EmptyAssocUser{}) + if err := DB.AutoMigrate(&EmptyAssocUser{}, &EmptyAssocProfile{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + // Create user without any profiles + user := EmptyAssocUser{Name: "empty_assoc_user"} + if err := DB.Create(&user).Error; err != nil { + t.Fatalf("Failed to create user, got error %v", err) + } + + // Delete with nested select when no associations exist + var deletedUser EmptyAssocUser + result := DB.Select("Profiles").Delete(&deletedUser, user.ID) + if result.Error != nil { + t.Fatalf("Failed to delete user with empty nested select, got error %v", result.Error) + } + + var count int64 + DB.Model(&EmptyAssocUser{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 users after delete with empty nested select, got %d", count) + } +} + +// TestNestedDeleteWithNullableAssociation tests deletion with nullable foreign keys +func TestNestedDeleteWithNullableAssociation(t *testing.T) { + type NullableAssocCompany struct { + gorm.Model + Name string + } + + type NullableAssocUser struct { + gorm.Model + Name string + CompanyID *uint + Company *NullableAssocCompany + } + + DB.Migrator().DropTable(&NullableAssocUser{}, &NullableAssocCompany{}) + if err := DB.AutoMigrate(&NullableAssocCompany{}, &NullableAssocUser{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + // Create user without company + user := NullableAssocUser{Name: "nullable_user", CompanyID: nil} + if err := DB.Create(&user).Error; err != nil { + t.Fatalf("Failed to create user, got error %v", err) + } + + // Delete with nested select when association is null + var deletedUser NullableAssocUser + result := DB.Select("Company").Delete(&deletedUser, user.ID) + if result.Error != nil { + t.Fatalf("Failed to delete user with null nested select, got error %v", result.Error) + } + + var count int64 + DB.Model(&NullableAssocUser{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 users after delete with null association, got %d", count) + } +} + +// TestNestedDeleteWithSliceOfPointers tests deletion with slice of pointers +func TestNestedDeleteWithSliceOfPointers(t *testing.T) { + type SlicePtrProfile struct { + gorm.Model + Name string + SlicePtrUserID uint + } + + type SlicePtrUser struct { + gorm.Model + Name string + Profiles []*SlicePtrProfile `gorm:"foreignKey:SlicePtrUserID"` + } + + DB.Migrator().DropTable(&SlicePtrProfile{}, &SlicePtrUser{}) + if err := DB.AutoMigrate(&SlicePtrUser{}, &SlicePtrProfile{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + user := SlicePtrUser{ + Name: "slice_ptr_user", + Profiles: []*SlicePtrProfile{ + {Name: "Profile1"}, + {Name: "Profile2"}, + {Name: "Profile3"}, + }, + } + if err := DB.Create(&user).Error; err != nil { + t.Fatalf("Failed to create user with pointer profiles, got error %v", err) + } + + var deletedUser SlicePtrUser + result := DB.Select("Profiles").Delete(&deletedUser, user.ID) + if result.Error != nil { + t.Fatalf("Failed to delete user with slice of pointers, got error %v", result.Error) + } + + var count int64 + DB.Model(&SlicePtrProfile{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 profiles after nested delete with slice of pointers, got %d", count) + } +} + +// TestNestedDeleteHasOneZeroStruct tests HasOne deletion with zero struct +func TestNestedDeleteHasOneZeroStruct(t *testing.T) { + type HasOneZeroAccount struct { + gorm.Model + Name string + HasOneZeroUserID uint + } + + type HasOneZeroUser struct { + gorm.Model + Name string + Account *HasOneZeroAccount `gorm:"foreignKey:HasOneZeroUserID"` + } + + DB.Migrator().DropTable(&HasOneZeroAccount{}, &HasOneZeroUser{}) + if err := DB.AutoMigrate(&HasOneZeroUser{}, &HasOneZeroAccount{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + // Create user without account + user := HasOneZeroUser{Name: "has_one_zero_user"} + if err := DB.Create(&user).Error; err != nil { + t.Fatalf("Failed to create user, got error %v", err) + } + + // Delete with HasOne nested select when association doesn't exist + var deletedUser HasOneZeroUser + result := DB.Select("Account").Delete(&deletedUser, user.ID) + if result.Error != nil { + t.Fatalf("Failed to delete user with zero HasOne association, got error %v", result.Error) + } + + var count int64 + DB.Model(&HasOneZeroUser{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 users after HasOne zero delete, got %d", count) + } +} + +// TestNestedDeleteWithManyToManyEmptyJoinTable tests M2M deletion with no join table records +func TestNestedDeleteWithManyToManyEmptyJoinTable(t *testing.T) { + type EmptyM2MTag struct { + gorm.Model + Name string + } + + type EmptyM2MPost struct { + gorm.Model + Title string + Tags []EmptyM2MTag `gorm:"many2many:empty_m2m_post_tags;"` + } + + DB.Migrator().DropTable(&EmptyM2MTag{}, &EmptyM2MPost{}, "empty_m2m_post_tags") + if err := DB.AutoMigrate(&EmptyM2MTag{}, &EmptyM2MPost{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + // Create post without tags + post := EmptyM2MPost{Title: "empty_m2m_post"} + if err := DB.Create(&post).Error; err != nil { + t.Fatalf("Failed to create post, got error %v", err) + } + + // Delete with M2M nested select when no join table records exist + var deletedPost EmptyM2MPost + result := DB.Select("Tags").Delete(&deletedPost, post.ID) + if result.Error != nil { + t.Fatalf("Failed to delete post with empty M2M, got error %v", result.Error) + } + + var count int64 + DB.Model(&EmptyM2MPost{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 posts after empty M2M delete, got %d", count) + } +} + +// TestNestedDeleteMultipleLevelsWithAssociations tests deeply nested deletions with multiple relationship types +func TestNestedDeleteMultipleLevelsWithAssociations(t *testing.T) { + type Level3Item struct { + gorm.Model + Name string + Level2ContainerID uint + } + + type Level2Container struct { + gorm.Model + Name string + Level1ParentID uint + Items []Level3Item `gorm:"foreignKey:Level2ContainerID"` + } + + type Level1Parent struct { + gorm.Model + Name string + Containers []Level2Container `gorm:"foreignKey:Level1ParentID"` + } + + DB.Migrator().DropTable(&Level3Item{}, &Level2Container{}, &Level1Parent{}) + if err := DB.AutoMigrate(&Level1Parent{}, &Level2Container{}, &Level3Item{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + parent := Level1Parent{ + Name: "parent", + Containers: []Level2Container{ + { + Name: "container1", + Items: []Level3Item{ + {Name: "item1"}, + {Name: "item2"}, + }, + }, + { + Name: "container2", + Items: []Level3Item{ + {Name: "item3"}, + }, + }, + }, + } + if err := DB.Create(&parent).Error; err != nil { + t.Fatalf("Failed to create nested structure, got error %v", err) + } + + // Delete parent with nested items + var deletedParent Level1Parent + result := DB.Select("Containers.Items").Delete(&deletedParent, parent.ID) + if result.Error != nil { + t.Fatalf("Failed to delete parent with deeply nested items, got error %v", result.Error) + } + + var countParent, countContainer, countItem int64 + DB.Model(&Level1Parent{}).Count(&countParent) + DB.Model(&Level2Container{}).Count(&countContainer) + DB.Model(&Level3Item{}).Count(&countItem) + + if countParent != 0 { + t.Fatalf("Expected 0 parents after nested delete, got %d", countParent) + } + if countContainer != 0 { + t.Fatalf("Expected 0 containers after nested delete, got %d", countContainer) + } + if countItem != 0 { + t.Fatalf("Expected 0 items after nested delete, got %d", countItem) + } +} + +// TestNestedDeleteWithPartialNestedSelection tests deleting only specific nested associations +func TestNestedDeleteWithPartialNestedSelection(t *testing.T) { + type PartialSelProfile struct { + gorm.Model + Name string + PartialSelUserID uint + } + + type PartialSelPost struct { + gorm.Model + Title string + PartialSelUserID uint + } + + type PartialSelUser struct { + gorm.Model + Name string + Profiles []PartialSelProfile `gorm:"foreignKey:PartialSelUserID"` + Posts []PartialSelPost `gorm:"foreignKey:PartialSelUserID"` + } + + DB.Migrator().DropTable(&PartialSelProfile{}, &PartialSelPost{}, &PartialSelUser{}) + if err := DB.AutoMigrate(&PartialSelUser{}, &PartialSelProfile{}, &PartialSelPost{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + user := PartialSelUser{ + Name: "partial_sel_user", + Profiles: []PartialSelProfile{ + {Name: "Profile1"}, + {Name: "Profile2"}, + }, + Posts: []PartialSelPost{ + {Title: "Post1"}, + {Title: "Post2"}, + }, + } + if err := DB.Create(&user).Error; err != nil { + t.Fatalf("Failed to create user, got error %v", err) + } + + // Delete only profiles nested association + var deletedUser PartialSelUser + result := DB.Select("Profiles").Delete(&deletedUser, user.ID) + if result.Error != nil { + t.Fatalf("Failed to delete user with partial nested select, got error %v", result.Error) + } + + var countUser, countProfile, countPost int64 + DB.Model(&PartialSelUser{}).Count(&countUser) + DB.Model(&PartialSelProfile{}).Count(&countProfile) + DB.Model(&PartialSelPost{}).Count(&countPost) + + // User and profiles should be deleted + if countUser != 0 { + t.Fatalf("Expected 0 users after partial delete, got %d", countUser) + } + if countProfile != 0 { + t.Fatalf("Expected 0 profiles after partial delete, got %d", countProfile) + } + // Posts remain because they were not selected for deletion + if countPost != 2 { + t.Fatalf("Expected 2 posts after partial delete (not selected), got %d", countPost) + } +} + +// TestNestedDeleteWithPreloadedData tests deletion with preloaded associations +func TestNestedDeleteWithPreloadedData(t *testing.T) { + type PreloadProfile struct { + gorm.Model + Name string + PreloadUserID uint + } + + type PreloadUser struct { + gorm.Model + Name string + Profiles []PreloadProfile `gorm:"foreignKey:PreloadUserID"` + } + + DB.Migrator().DropTable(&PreloadProfile{}, &PreloadUser{}) + if err := DB.AutoMigrate(&PreloadUser{}, &PreloadProfile{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + user := PreloadUser{ + Name: "preload_user", + Profiles: []PreloadProfile{ + {Name: "Profile1"}, + {Name: "Profile2"}, + }, + } + if err := DB.Create(&user).Error; err != nil { + t.Fatalf("Failed to create user, got error %v", err) + } + + // Load user with preloaded associations + var loadedUser PreloadUser + if err := DB.Preload("Profiles").First(&loadedUser, user.ID).Error; err != nil { + t.Fatalf("Failed to preload user, got error %v", err) + } + + // Delete using preloaded data + result := DB.Select("Profiles").Delete(&loadedUser) + if result.Error != nil { + t.Fatalf("Failed to delete user with preloaded data, got error %v", result.Error) + } + + var count int64 + DB.Model(&PreloadUser{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 users after delete with preloaded data, got %d", count) + } +} + +// TestNestedDeleteWithSoftDelete tests nested deletion with soft delete enabled +func TestNestedDeleteWithSoftDeleteNested(t *testing.T) { + type SoftDelProfile struct { + gorm.Model + Name string + SoftDelUserID uint + } + + type SoftDelUser struct { + gorm.Model + Name string + Profiles []SoftDelProfile `gorm:"foreignKey:SoftDelUserID"` + } + + DB.Migrator().DropTable(&SoftDelProfile{}, &SoftDelUser{}) + if err := DB.AutoMigrate(&SoftDelUser{}, &SoftDelProfile{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + user := SoftDelUser{ + Name: "soft_del_user", + Profiles: []SoftDelProfile{ + {Name: "Profile1"}, + {Name: "Profile2"}, + }, + } + if err := DB.Create(&user).Error; err != nil { + t.Fatalf("Failed to create user, got error %v", err) + } + + // Soft delete with nested select + var deletedUser SoftDelUser + result := DB.Select("Profiles").Delete(&deletedUser, user.ID) + if result.Error != nil { + t.Fatalf("Failed to soft delete with nested select, got error %v", result.Error) + } + + // Check soft deleted records are still there + var countScoped, countUnscoped int64 + DB.Model(&SoftDelUser{}).Count(&countScoped) + DB.Model(&SoftDelUser{}).Unscoped().Count(&countUnscoped) + + if countScoped != 0 { + t.Fatalf("Expected 0 undeleted users after soft delete, got %d", countScoped) + } + if countUnscoped != 1 { + t.Fatalf("Expected 1 soft-deleted user when using Unscoped, got %d", countUnscoped) + } +} + +// TestNestedDeleteWithUnscopedNested tests hard deletion of nested associations +func TestNestedDeleteWithUnscopedNested(t *testing.T) { + type UnscopedProfile struct { + gorm.Model + Name string + UnscopedUserID uint + } + + type UnscopedUser struct { + gorm.Model + Name string + Profiles []UnscopedProfile `gorm:"foreignKey:UnscopedUserID"` + } + + DB.Migrator().DropTable(&UnscopedProfile{}, &UnscopedUser{}) + if err := DB.AutoMigrate(&UnscopedUser{}, &UnscopedProfile{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + user := UnscopedUser{ + Name: "unscoped_user", + Profiles: []UnscopedProfile{ + {Name: "Profile1"}, + {Name: "Profile2"}, + }, + } + if err := DB.Create(&user).Error; err != nil { + t.Fatalf("Failed to create user, got error %v", err) + } + + // Hard delete with nested select using Unscoped + var deletedUser UnscopedUser + result := DB.Unscoped().Select("Profiles").Delete(&deletedUser, user.ID) + if result.Error != nil { + t.Fatalf("Failed to unscoped delete with nested select, got error %v", result.Error) + } + + // Check records are completely gone + var countUnscoped int64 + DB.Model(&UnscopedUser{}).Unscoped().Count(&countUnscoped) + + if countUnscoped != 0 { + t.Fatalf("Expected 0 users after unscoped hard delete, got %d", countUnscoped) + } +} + +// TestNestedDeleteWithComplexM2M tests M2M deletion with multiple posts and tags +func TestNestedDeleteWithComplexM2M(t *testing.T) { + type ComplexTag struct { + gorm.Model + Name string + } + + type ComplexPost struct { + gorm.Model + Title string + ComplexBlogID uint + Tags []ComplexTag `gorm:"many2many:complex_m2m_post_tags;"` + } + + type ComplexBlog struct { + gorm.Model + Name string + Posts []ComplexPost `gorm:"foreignKey:ComplexBlogID"` + } + + DB.Migrator().DropTable(&ComplexTag{}, &ComplexPost{}, &ComplexBlog{}, "complex_m2m_post_tags") + if err := DB.AutoMigrate(&ComplexBlog{}, &ComplexPost{}, &ComplexTag{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + // Create tags + tags := []ComplexTag{ + {Name: "golang"}, + {Name: "database"}, + {Name: "testing"}, + } + if err := DB.Create(&tags).Error; err != nil { + t.Fatalf("Failed to create tags, got error %v", err) + } + + // Create blog with posts and tags + blog := ComplexBlog{ + Name: "complex_blog", + Posts: []ComplexPost{ + {Title: "Post1", Tags: []ComplexTag{tags[0], tags[1]}}, + {Title: "Post2", Tags: []ComplexTag{tags[1], tags[2]}}, + {Title: "Post3", Tags: []ComplexTag{tags[0], tags[2]}}, + }, + } + if err := DB.Create(&blog).Error; err != nil { + t.Fatalf("Failed to create blog with posts, got error %v", err) + } + + // Delete blog with posts + var deletedBlog ComplexBlog + result := DB.Select("Posts.Tags").Delete(&deletedBlog, blog.ID) + if result.Error != nil { + t.Fatalf("Failed to delete blog with complex M2M, got error %v", result.Error) + } + + var countBlog, countPost, countTag, countJoin int64 + DB.Model(&ComplexBlog{}).Count(&countBlog) + DB.Model(&ComplexPost{}).Count(&countPost) + DB.Model(&ComplexTag{}).Count(&countTag) + DB.Table("complex_m2m_post_tags").Count(&countJoin) + + if countBlog != 0 { + t.Fatalf("Expected 0 blogs after complex M2M delete, got %d", countBlog) + } + if countPost != 0 { + t.Fatalf("Expected 0 posts after complex M2M delete, got %d", countPost) + } + if countTag != 3 { + t.Fatalf("Expected 3 tags after complex M2M delete (tags should not be deleted), got %d", countTag) + } + if countJoin != 0 { + t.Fatalf("Expected 0 join table records after complex M2M delete, got %d", countJoin) + } +} + +// TestNestedDeleteAssociationsClause tests deletion with clause.Associations +func TestNestedDeleteAssociationsClause(t *testing.T) { + type ClauseProfile struct { + gorm.Model + Name string + ClauseUserID uint + } + + type ClausePost struct { + gorm.Model + Title string + ClauseUserID uint + } + + type ClauseUser struct { + gorm.Model + Name string + Profiles []ClauseProfile `gorm:"foreignKey:ClauseUserID"` + Posts []ClausePost `gorm:"foreignKey:ClauseUserID"` + } + + DB.Migrator().DropTable(&ClauseProfile{}, &ClausePost{}, &ClauseUser{}) + if err := DB.AutoMigrate(&ClauseUser{}, &ClauseProfile{}, &ClausePost{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + user := ClauseUser{ + Name: "clause_user", + Profiles: []ClauseProfile{ + {Name: "Profile1"}, + }, + Posts: []ClausePost{ + {Title: "Post1"}, + }, + } + if err := DB.Create(&user).Error; err != nil { + t.Fatalf("Failed to create user, got error %v", err) + } + + // Delete all associations using clause.Associations + var deletedUser ClauseUser + result := DB.Select(clause.Associations).Delete(&deletedUser, user.ID) + if result.Error != nil { + t.Fatalf("Failed to delete with clause.Associations, got error %v", result.Error) + } + + var count int64 + DB.Model(&ClauseUser{}).Count(&count) + if count != 0 { + t.Fatalf("Expected 0 users after delete with clause.Associations, got %d", count) + } +} + +// TestNestedDeleteWithPreloadedPartialData tests deletion with preloaded partial data +func TestNestedDeleteWithPreloadedPartialData(t *testing.T) { + type PreloadPartialProfile struct { + gorm.Model + Name string + Description string + PreloadPartialUserID uint + } + + type PreloadPartialUser struct { + gorm.Model + Name string + Email string + Profiles []PreloadPartialProfile `gorm:"foreignKey:PreloadPartialUserID"` + } + + DB.Migrator().DropTable(&PreloadPartialProfile{}, &PreloadPartialUser{}) + if err := DB.AutoMigrate(&PreloadPartialUser{}, &PreloadPartialProfile{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + user := PreloadPartialUser{ + Name: "preload_partial_user", + Email: "user@example.com", + Profiles: []PreloadPartialProfile{ + {Name: "Profile1", Description: "Desc1"}, + {Name: "Profile2", Description: "Desc2"}, + }, + } + if err := DB.Create(&user).Error; err != nil { + t.Fatalf("Failed to create user, got error %v", err) + } + + // Load user with preloaded profiles but only specific columns (Name, not Description) + var loadedUser PreloadPartialUser + if err := DB.Preload("Profiles", func(db *gorm.DB) *gorm.DB { + return db.Select("id", "name", "preload_partial_user_id") + }).First(&loadedUser, user.ID).Error; err != nil { + t.Fatalf("Failed to preload user with partial columns, got error %v", err) + } + + // Verify that Description field is not loaded (zero value) + if loadedUser.Profiles[0].Description != "" { + // If it's loaded from DB, the description will be there; if truly partial, it won't be + t.Logf("Note: Profiles preloaded with Description field: %s", loadedUser.Profiles[0].Description) + } + + // Delete using the preloaded user (even though it's partial) + result := DB.Select("Profiles").Delete(&loadedUser) + if result.Error != nil { + t.Fatalf("Failed to delete with preloaded partial data, got error %v", result.Error) + } + + var countUser, countProfile int64 + DB.Model(&PreloadPartialUser{}).Count(&countUser) + DB.Model(&PreloadPartialProfile{}).Count(&countProfile) + + if countUser != 0 { + t.Fatalf("Expected 0 users after delete with preloaded partial data, got %d", countUser) + } + if countProfile != 0 { + t.Fatalf("Expected 0 profiles after delete with preloaded partial data, got %d", countProfile) + } +} + +// TestNestedDeleteWithComposedAssociations tests deletion with multiple levels and different relationship types +func TestNestedDeleteWithComposedAssociations(t *testing.T) { + type ComposedToy struct { + gorm.Model + Name string + ComposedProfileID uint + } + + type ComposedProfile struct { + gorm.Model + Name string + ComposedUserID uint + Toys []ComposedToy `gorm:"foreignKey:ComposedProfileID"` + } + + type ComposedUser struct { + gorm.Model + Name string + Profiles []ComposedProfile `gorm:"foreignKey:ComposedUserID"` + } + + DB.Migrator().DropTable(&ComposedToy{}, &ComposedProfile{}, &ComposedUser{}) + if err := DB.AutoMigrate(&ComposedUser{}, &ComposedProfile{}, &ComposedToy{}); err != nil { + t.Fatalf("Failed to auto migrate, got error %v", err) + } + + // Create complex nested structure + user := ComposedUser{ + Name: "composed_user", + Profiles: []ComposedProfile{ + { + Name: "Profile1", + Toys: []ComposedToy{ + {Name: "Toy1A"}, + {Name: "Toy1B"}, + {Name: "Toy1C"}, + }, + }, + { + Name: "Profile2", + Toys: []ComposedToy{ + {Name: "Toy2A"}, + {Name: "Toy2B"}, + }, + }, + { + Name: "Profile3", + Toys: []ComposedToy{ + {Name: "Toy3A"}, + }, + }, + }, + } + + if err := DB.Create(&user).Error; err != nil { + t.Fatalf("Failed to create composed user, got error %v", err) + } + + // Verify structure was created correctly + var countUser, countProfile, countToy int64 + DB.Model(&ComposedUser{}).Count(&countUser) + DB.Model(&ComposedProfile{}).Count(&countProfile) + DB.Model(&ComposedToy{}).Count(&countToy) + + if countUser != 1 { + t.Fatalf("Expected 1 user before delete, got %d", countUser) + } + if countProfile != 3 { + t.Fatalf("Expected 3 profiles before delete, got %d", countProfile) + } + if countToy != 6 { + t.Fatalf("Expected 6 toys before delete, got %d", countToy) + } + + // Delete user with composed associations (user -> profiles -> toys) + var deletedUser ComposedUser + result := DB.Select("Profiles.Toys").Delete(&deletedUser, user.ID) + if result.Error != nil { + t.Fatalf("Failed to delete with composed associations, got error %v", result.Error) + } + + // After delete, verify all nested records are gone + DB.Model(&ComposedUser{}).Count(&countUser) + DB.Model(&ComposedProfile{}).Count(&countProfile) + DB.Model(&ComposedToy{}).Count(&countToy) + + if countUser != 0 { + t.Fatalf("Expected 0 users after composed delete, got %d", countUser) + } + if countProfile != 0 { + t.Fatalf("Expected 0 profiles after composed delete, got %d", countProfile) + } + if countToy != 0 { + t.Fatalf("Expected 0 toys after composed delete, got %d", countToy) + } +}