Skip to content

Conversation

@mic3b
Copy link

@mic3b mic3b commented Sep 28, 2025

  • Do only one thing
  • Non breaking API changes
  • Tested

What did this pull request do?

close #6687

User Case Description

- 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.
@propel-code-bot
Copy link
Contributor

propel-code-bot bot commented Sep 28, 2025

Add nested association delete support

Adds first-class support for cascading/nested deletes when Delete is executed with Select()/clause.Associations. Core logic lives in callbacks/delete.go; it analyses the selected paths, walks GORM relationship metadata, and recursively deletes HasOne, HasMany, Many2Many, and (optionally) BelongsTo graphs. Extensive test-suite (~1 300 LOC) covers deep nesting, polymorphic, self-referential and edge-cases (soft-delete, empty/join-table absence, partial preload, etc.). API surface is unchanged but execution path for deletes containing association selects is now much larger.

Key Changes

• New helper functions parseNestedDelete, deleteNestedAssociations, deleteWithNestedSelect, plus type-specific helpers (e.g. deleteMany2ManyAssociation) to recurse through associations
• Re-write of DeleteBeforeAssociations to (1) detect relationship selects, (2) load root records when needed, (3) call recursive deleter, (4) fall back to previous behaviour when not used
• Additional safety helpers (hasEmptyConditions) and validation of empty reference slices
• Extensive test coverage in tests/delete_test.go for HasOne/Many, M2M, polymorphic, embedded, self-ref, nullable FKs, soft-delete, unscoped, partial selects, deep multi-level cases
• Incremental commits fixing lint, edge cases and adding missing validation

Affected Areas

callbacks/delete.go – delete callback and helper logic
tests/delete_test.go – new tests
• Indirectly: all delete executions that use association selection

This summary was automatically generated by @propel-code-bot

}
}

if err := loadDB.First(db.Statement.Dest).Error; err != nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CriticalError]

Missing error handling: The database query on line 342 silently ignores errors, which can lead to incomplete deletion operations. The error should be handled properly:

Suggested change
if err := loadDB.First(db.Statement.Dest).Error; err != nil {
if err := loadDB.First(db.Statement.Dest).Error; err != nil {
db.AddError(err)
return
}

Committable suggestion

Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Context for Agents
[**CriticalError**]

Missing error handling: The database query on line 342 silently ignores errors, which can lead to incomplete deletion operations. The error should be handled properly:

```suggestion
if err := loadDB.First(db.Statement.Dest).Error; err != nil {
	db.AddError(err)
	return
}
```

⚡ **Committable suggestion**

Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

File: callbacks/delete.go
Line: 342

@propel-code-bot propel-code-bot bot changed the title Add nested delete Add nested delete support for associations in GORM Sep 28, 2025
Comment on lines +505 to +508
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...)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CriticalError]

Array bounds access without validation: Line 507 accesses rel.References[len(rel.References)-1] without checking if rel.References is empty. If rel.References has length 0, this will panic with an index out of bounds error.

Suggested Change
Suggested change
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 len(rel.References) == 0 {
return reflect.Value{}, fmt.Errorf("no references found 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...)

Committable suggestion

Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Context for Agents
[**CriticalError**]

Array bounds access without validation: Line 507 accesses `rel.References[len(rel.References)-1]` without checking if `rel.References` is empty. If `rel.References` has length 0, this will panic with an index out of bounds error.

<details>
<summary>Suggested Change</summary>

```suggestion
if len(rel.References) == 0 {
	return reflect.Value{}, fmt.Errorf("no references found 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...)
```

⚡ **Committable suggestion**

Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

</details>

File: callbacks/delete.go
Line: 508

Comment on lines +505 to +508
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...)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CriticalError]

Array bounds access without validation: Line 507 also accesses rel.FieldSchema.PrimaryFieldDBNames[0] without checking if the slice is empty. If PrimaryFieldDBNames is empty, this will panic with an index out of bounds error.

Suggested Change
Suggested change
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 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...)

Committable suggestion

Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Context for Agents
[**CriticalError**]

Array bounds access without validation: Line 507 also accesses `rel.FieldSchema.PrimaryFieldDBNames[0]` without checking if the slice is empty. If `PrimaryFieldDBNames` is empty, this will panic with an index out of bounds error.

<details>
<summary>Suggested Change</summary>

```suggestion
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...)
```

⚡ **Committable suggestion**

Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

</details>

File: callbacks/delete.go
Line: 508

@propel-code-bot propel-code-bot bot changed the title Add nested delete support for associations in GORM Add nested delete functionality for associations in GORM Sep 28, 2025
@propel-code-bot propel-code-bot bot changed the title Add nested delete functionality for associations in GORM Add nested delete support for associations in GORM Sep 28, 2025
@jinzhu jinzhu requested a review from Copilot October 30, 2025 12:03
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR adds support for nested delete operations in GORM, allowing cascading deletion of associated records when using Select() with relationship names during delete operations.

Key changes:

  • Added logic to parse and handle nested relationship deletion via Select() clause (e.g., Select("Posts.Comments"))
  • Refactored the delete association logic to support HasOne, HasMany, BelongsTo, and ManyToMany relationships
  • Added comprehensive test coverage for nested deletion scenarios including deep nesting, polymorphic relations, and self-referential structures

Reviewed Changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
callbacks/delete.go Implements nested delete functionality with new helper functions for parsing nested selects and deleting associations based on relationship types
tests/delete_test.go Adds 8 new test functions covering various nested delete scenarios including HasMany, BelongsTo, ManyToMany, embedded structs, deep nesting, multiple relations, polymorphic, and self-referential relationships

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 446 to 447
if records.Elem().Len() == 0 {
return nil
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential panic when calling Len() on a non-slice/array reflect.Value. For HasOne relationships, records is a pointer to a single struct (line 432), not a slice, so Elem().Len() will panic. Check if the value is actually a slice before calling Len(), or handle HasOne separately.

Suggested change
if records.Elem().Len() == 0 {
return nil
if rel.Type == schema.HasOne {
// For HasOne, check if the struct is zero (not found)
if records.Elem().IsZero() {
return nil
}
} else {
// For HasMany, check if the slice is empty
if records.Elem().Len() == 0 {
return nil
}

Copilot uses AI. Check for mistakes.
return err
}

if associatedRecords.Elem().Len() > 0 {
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential panic if findMany2ManyAssociatedRecords returns an empty reflect.Value{} (line 497). Before calling Elem().Len(), check if associatedRecords.IsValid() to avoid panicking on an invalid reflect.Value.

Suggested change
if associatedRecords.Elem().Len() > 0 {
if associatedRecords.IsValid() && associatedRecords.Elem().Len() > 0 {

Copilot uses AI. Check for mistakes.
DB.Create(&user)

var deletedUser NestedDeleteDeepNestingUser
result := DB.Select("Posts.Comments").Delete(&deletedUser, user.ID)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this is the only test for nested associations, there must many cases are not covered.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added more tests and solved issues pointed out higher. Please let me know if it's enough, or if you have other idea about what tests should be there :) Thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: support nested delete

2 participants