Skip to content

Conversation

@vsmay98
Copy link
Contributor

@vsmay98 vsmay98 commented Nov 22, 2025

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:

  • :nullify makes all children root nodes, which can break tree relationships
  • :destroy and :delete_all remove 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

  1. Association Setup: The has_many :children association uses :nullify when dependent: :adopt is set (since ActiveRecord doesn't support :adopt directly), but the actual adoption logic is handled in the before_destroy callback.

  2. Adoption Logic: The adopt_children_to_grandparent method:

    • Retrieves the grandparent ID (or nil for root nodes)
    • Finds all children of the node being destroyed
    • Updates each child's parent_id to the grandparent ID
    • Rebuilds the hierarchy for each child to maintain closure table integrity
  3. 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.

@vsmay98
Copy link
Contributor Author

vsmay98 commented Nov 22, 2025

@seuros
This PR is ready for review. Please take a look when you get a chance and share your thoughts. Thanks!

@seuros seuros changed the title Add dependent: :adopt option for has_closure_tree feat: Add dependent: :adopt option for has_closure_tree Nov 22, 2025
@seuros
Copy link
Member

seuros commented Nov 22, 2025

Love this feature. I can see the pattern used by many cases.

The test need to assert that the hierarchy table was mutated, and that it work with the 3 adapters.

@seuros
Copy link
Member

seuros commented Dec 6, 2025

@vsmay98 you will finish the pr ?

@vsmay98
Copy link
Contributor Author

vsmay98 commented Dec 9, 2025

Yeah @seuros , I haven’t had much time recently, but I’ll try to finish this PR soon. I was having some trouble running the MySQL specs locally and needed to look into it.

@seuros
Copy link
Member

seuros commented Dec 9, 2025

Docker

@vsmay98
Copy link
Contributor Author

vsmay98 commented Dec 10, 2025

Thanks @seuros
The PR is ready for review

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 introduces a new dependent: :adopt option for has_closure_tree, enabling intermediate nodes to be removed while preserving tree structure by moving children to their grandparent. This fills a gap between the existing :nullify (which makes all children root nodes) and :destroy/:delete_all (which remove entire subtrees).

Key Changes:

  • Added adoption logic that reassigns children to their grandparent (or makes them root nodes if no grandparent exists) before destroying a node
  • Enhanced the Arel helpers with a to_sql_with_connection method to ensure proper SQL quoting across different database adapters
  • Comprehensive test coverage across PostgreSQL, MySQL, and SQLite databases

Reviewed changes

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

Show a summary per file
File Description
lib/closure_tree/hierarchy_maintenance.rb Implements adopt_children_to_grandparent method that updates children's parent_id and rebuilds their hierarchies before node destruction
lib/closure_tree/association_setup.rb Maps :adopt to :nullify for the children association since ActiveRecord doesn't support :adopt natively, with adoption handled by callback
lib/closure_tree/arel_helpers.rb Adds to_sql_with_connection helper to convert Arel queries with proper database-specific quoting
lib/closure_tree/support.rb Updates documentation comment to include :adopt as a valid dependent option
README.md Documents the new :adopt option with clear explanation of its behavior and use cases
test/closure_tree/adopt_test.rb Comprehensive test suite covering adoption scenarios: grandparent adoption, root node destruction, multiple children, deep nesting, and integration with existing methods
test/dummy/app/models/adoptable_tag.rb Test model for PostgreSQL using :adopt option
test/dummy/app/models/mysql_adoptable_tag.rb Test model for MySQL using :adopt option
test/dummy/app/models/memory_adoptable_tag.rb Test model for SQLite using :adopt option
test/dummy/db/schema.rb Schema definitions for adoptable_tags test tables across all database types
test/dummy/db/sqlite_schema.rb SQLite-specific schema for memory_adoptable_tags table
test/dummy/db/secondary_schema.rb MySQL-specific schema for mysql_adoptable_tags table

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

Comment on lines +6 to +7


Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

This file has two blank lines at the end instead of one. Per the codebase convention, Ruby files should end with exactly one blank line. Remove the extra blank line.

Suggested change

Copilot uses AI. Check for mistakes.
class MemoryAdoptableTag < SqliteRecord
has_closure_tree dependent: :adopt, name_column: 'name'
end

Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

This file has two blank lines at the end instead of one. Per the codebase convention, Ruby files should end with exactly one blank line. Remove the extra blank line.

Suggested change

Copilot uses AI. Check for mistakes.
class MysqlAdoptableTag < MysqlRecord
has_closure_tree dependent: :adopt, name_column: 'name'
end

Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

This file has two blank lines at the end instead of one. Per the codebase convention, Ruby files should end with exactly one blank line. Remove the extra blank line.

Suggested change

Copilot uses AI. Check for mistakes.
assert_equal p2, p3.parent

hierarchy = model_class.hierarchy_class
initial_p2_hierarchies = hierarchy.where(ancestor_id: p2.id).count
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

This assignment to initial_p2_hierarchies is useless, since its value is never read.

Suggested change
initial_p2_hierarchies = hierarchy.where(ancestor_id: p2.id).count

Copilot uses AI. Check for mistakes.

hierarchy = model_class.hierarchy_class
initial_p2_hierarchies = hierarchy.where(ancestor_id: p2.id).count
initial_p3_hierarchies = hierarchy.where(descendant_id: p3.id).count
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

This assignment to initial_p3_hierarchies is useless, since its value is never read.

Suggested change
initial_p3_hierarchies = hierarchy.where(descendant_id: p3.id).count

Copilot uses AI. Check for mistakes.
leaf = model_class.create!(name: 'leaf', parent: p2)

hierarchy = model_class.hierarchy_class
initial_count = hierarchy.count
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

This assignment to initial_count is useless, since its value is never read.

Suggested change
initial_count = hierarchy.count

Copilot uses AI. Check for mistakes.
@seuros
Copy link
Member

seuros commented Dec 10, 2025

@vsmay98 pretty close, the linter and the unused code.

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.

2 participants