feat: Add `dependent: :adopt` option for `has_closure_tree`
Summary
This PR adds a new dependent: :adopt option to has_closure_tree that automatically moves children to their grandparent when a parent node is destroyed. If the node being destroyed is a root (has no grandparent), children become root nodes instead.
Motivation
When working with hierarchical data structures, there are scenarios where you want to remove intermediate nodes while preserving the tree structure. The existing dependent options (:nullify, :destroy, :delete_all) don't provide this behavior:
-
:nullifymakes all children root nodes, which can break tree relationships -
:destroyand:delete_allremove the entire subtree, which may not be desired
The :adopt option fills this gap by maintaining tree continuity when removing nodes, which is particularly useful for:
- Category hierarchies: When removing a category, its subcategories should move up to the parent category
- Organizational structures: When a department is dissolved, its teams should be reassigned to the parent department
- File systems: When a folder is deleted, its contents should move to the parent folder
- Menu structures: When a menu item is removed, its sub-items should be promoted to the parent level
Example Usage
class Category < ApplicationRecord
has_closure_tree dependent: :adopt, name_column: 'name'
end
# Create a hierarchy: Electronics -> Computers -> Laptops -> Gaming Laptops
root = Category.create!(name: 'Electronics')
computers = Category.create!(name: 'Computers', parent: root)
laptops = Category.create!(name: 'Laptops', parent: computers)
gaming = Category.create!(name: 'Gaming Laptops', parent: laptops)
# Remove the intermediate "Laptops" category
laptops.destroy
# Gaming Laptops is now directly under Computers
gaming.reload
computers.reload
gaming.parent == computers # => true
gaming.ancestry_path # => ["Electronics", "Computers", "Gaming Laptops"]
Behavior
- When destroying a node with a parent (grandparent exists): All children are moved to the grandparent
- When destroying a root node (no grandparent): All children become root nodes
- Hierarchy maintenance: The closure table is automatically rebuilt for each adopted child to maintain correct ancestor/descendant relationships
- Deep nesting: Works correctly with deeply nested structures - only immediate children are adopted, maintaining their own subtree structure
Implementation Details
-
Association Setup: The
has_many :childrenassociation uses:nullifywhendependent: :adoptis set (since ActiveRecord doesn't support:adoptdirectly), but the actual adoption logic is handled in thebefore_destroycallback. -
Adoption Logic: The
adopt_children_to_grandparentmethod:- Retrieves the grandparent ID (or
nilfor root nodes) - Finds all children of the node being destroyed
- Updates each child's
parent_idto the grandparent ID - Rebuilds the hierarchy for each child to maintain closure table integrity
- Retrieves the grandparent ID (or
-
Order of Operations: Adoption happens before hierarchy references are deleted to ensure children can be properly identified and updated.
Backward Compatibility
This change is fully backward compatible. The new :adopt option is optional and doesn't affect existing behavior. All existing dependent options (:nullify, :destroy, :delete_all, nil) continue to work as before.