Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ When you include ```has_closure_tree``` in your model, you can provide a hash to
* ```:hierarchy_table_name``` to override the hierarchy table name. This defaults to the singular name of the model + "_hierarchies", like ```tag_hierarchies```.
* ```:dependent``` determines what happens when a node is destroyed. Defaults to ```nullify```.
* ```:nullify``` will simply set the parent column to null. Each child node will be considered a "root" node. This is the default.
* ```:adopt``` will move children to their grandparent (parent's parent). If there is no grandparent, children become root nodes. This is useful for maintaining tree structure when removing intermediate nodes.
* ```:delete_all``` will delete all descendant nodes (which circumvents the destroy hooks)
* ```:destroy``` will destroy all descendant nodes (which runs the destroy hooks on each child node)
* ```nil``` does nothing with descendant nodes
Expand Down
8 changes: 8 additions & 0 deletions lib/closure_tree/arel_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,13 @@ def build_hierarchy_delete_query(hierarchy_table, id)

delete_manager
end

# Convert an Arel AST to SQL using the correct connection's visitor
# This ensures proper quoting for the specific database adapter (MySQL uses backticks, PostgreSQL uses double quotes)
def to_sql_with_connection(arel_manager)
collector = Arel::Collectors::SQLString.new
visitor = connection.send(:arel_visitor)
visitor.accept(arel_manager.ast, collector).value
end
end
end
4 changes: 2 additions & 2 deletions lib/closure_tree/association_setup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ module AssociationSetup

has_many :children, *_ct.has_many_order_with_option, class_name: _ct.model_class.to_s,
foreign_key: _ct.parent_column_name,
dependent: _ct.options[:dependent],
dependent: _ct.options[:dependent] == :adopt ? :nullify : _ct.options[:dependent],
inverse_of: :parent do
# We have to redefine hash_tree because the activerecord relation is already scoped to parent_id.
def hash_tree(options = {})
Expand All @@ -47,4 +47,4 @@ def hash_tree(options = {})
source: :descendant
end
end
end
end
16 changes: 15 additions & 1 deletion lib/closure_tree/hierarchy_maintenance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,26 @@ def _ct_after_save

def _ct_before_destroy
_ct.with_advisory_lock do
adopt_children_to_grandparent if _ct.options[:dependent] == :adopt
delete_hierarchy_references
self.class.find(id).children.find_each(&:rebuild!) if _ct.options[:dependent] == :nullify
end
true # don't prevent destruction
end

def adopt_children_to_grandparent
grandparent_id = read_attribute(_ct.parent_column_name)
children_ids = self.class.where(_ct.parent_column_name => id).pluck(:id)

return if children_ids.empty?

# Update all children's parent_id in a single query
self.class.where(id: children_ids).update_all(_ct.parent_column_name => grandparent_id)

# Rebuild hierarchy for each child
self.class.where(id: children_ids).find_each(&:rebuild!)
end

def rebuild!(called_by_rebuild = false)
_ct.with_advisory_lock do
delete_hierarchy_references unless (defined? @was_new_record) && @was_new_record
Expand Down Expand Up @@ -93,7 +107,7 @@ def delete_hierarchy_references

hierarchy_table = hierarchy_class.arel_table
delete_query = _ct.build_hierarchy_delete_query(hierarchy_table, id)
_ct.connection.execute(delete_query.to_sql)
_ct.connection.execute(_ct.to_sql_with_connection(delete_query))
end
end

Expand Down
2 changes: 1 addition & 1 deletion lib/closure_tree/support.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def initialize(model_class, options)

@options = {
parent_column_name: 'parent_id',
dependent: :nullify, # or :destroy or :delete_all -- see the README
dependent: :nullify, # or :destroy, :delete_all, or :adopt -- see the README
name_column: 'name',
with_advisory_lock: true, # This will be overridden by adapter support
numeric_order: false
Expand Down
270 changes: 270 additions & 0 deletions test/closure_tree/adopt_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
# frozen_string_literal: true

require 'test_helper'

def run_adopt_tests_for(model_class)
describe "#{model_class} with dependent: :adopt" do
before do
model_class.delete_all
model_class.hierarchy_class.delete_all
end

it 'moves children to grandparent when parent is destroyed and updates hierarchy table' do
p1 = model_class.create!(name: 'p1')
p2 = model_class.create!(name: 'p2', parent: p1)
p3 = model_class.create!(name: 'p3', parent: p2)
p4 = model_class.create!(name: 'p4', parent: p3)

# Verify initial structure: p1 -> p2 -> p3 -> p4
assert_equal p2, p3.parent
assert_equal p3, p4.parent
assert_equal p1, p2.parent

# Verify initial hierarchy table entries
hierarchy = model_class.hierarchy_class
assert hierarchy.where(ancestor_id: p1.id, descendant_id: p4.id, generations: 3).exists?
assert hierarchy.where(ancestor_id: p2.id, descendant_id: p4.id, generations: 2).exists?
assert hierarchy.where(ancestor_id: p3.id, descendant_id: p4.id, generations: 1).exists?
assert hierarchy.where(ancestor_id: p3.id, descendant_id: p3.id, generations: 0).exists?

# Destroy p3
p3.destroy

# After destroying p3, p4 should be adopted by p2 (p3's parent)
p4.reload
p2.reload
assert_equal p2, p4.parent, 'p4 should be moved to p2 (grandparent)'
assert_equal p1, p2.parent, 'p2 should still have p1 as parent'
assert_equal [p4], p2.children.to_a, 'p2 should have p4 as child'

# Verify hierarchy table was updated correctly
# p3 should be removed from hierarchy
assert_empty hierarchy.where(ancestor_id: p3.id)
assert_empty hierarchy.where(descendant_id: p3.id)

# p4 should now have p2 as direct parent (generations: 1)
assert hierarchy.where(ancestor_id: p2.id, descendant_id: p4.id, generations: 1).exists?
# p4 should have p1 as ancestor (generations: 2)
assert hierarchy.where(ancestor_id: p1.id, descendant_id: p4.id, generations: 2).exists?
# p4 should have itself (generations: 0)
assert hierarchy.where(ancestor_id: p4.id, descendant_id: p4.id, generations: 0).exists?
end

it 'moves children to root when parent without grandparent is destroyed and updates hierarchy table' do
p1 = model_class.create!(name: 'p1')
p2 = model_class.create!(name: 'p2', parent: p1)
p3 = model_class.create!(name: 'p3', parent: p2)

# Verify initial structure: p1 -> p2 -> p3
assert_equal p1, p2.parent
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.
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.

# Destroy p1 (root node)
p1.destroy

# After destroying p1, p2 should become root, and p3 should still be child of p2
p2.reload
p3.reload
assert_nil p2.parent, 'p2 should become root'
assert_equal p2, p3.parent, 'p3 should still have p2 as parent'
assert p2.root?, 'p2 should be a root node'
assert_equal [p3], p2.children.to_a, 'p2 should have p3 as child'

# Verify hierarchy table: p1 should be removed
assert_empty hierarchy.where(ancestor_id: p1.id)
assert_empty hierarchy.where(descendant_id: p1.id)

# p2 should now be a root (no ancestors)
assert hierarchy.where(ancestor_id: p2.id, descendant_id: p2.id, generations: 0).exists?
# p3 should still have p2 as parent
assert hierarchy.where(ancestor_id: p2.id, descendant_id: p3.id, generations: 1).exists?
end

it 'handles multiple children being adopted and updates hierarchy table' do
p1 = model_class.create!(name: 'p1')
p2 = model_class.create!(name: 'p2', parent: p1)
c1 = model_class.create!(name: 'c1', parent: p2)
c2 = model_class.create!(name: 'c2', parent: p2)
c3 = model_class.create!(name: 'c3', parent: p2)

# Verify initial structure: p1 -> p2 -> [c1, c2, c3]
assert_equal [c1, c2, c3].sort, p2.children.to_a.sort

hierarchy = model_class.hierarchy_class
# Verify initial hierarchy: all children should have p1 and p2 as ancestors
[c1, c2, c3].each do |child|
assert hierarchy.where(ancestor_id: p1.id, descendant_id: child.id, generations: 2).exists?
assert hierarchy.where(ancestor_id: p2.id, descendant_id: child.id, generations: 1).exists?
end

# Destroy p2
p2.destroy

# All children should be adopted by p1
p1.reload
c1.reload
c2.reload
c3.reload

assert_equal p1, c1.parent, 'c1 should be moved to p1'
assert_equal p1, c2.parent, 'c2 should be moved to p1'
assert_equal p1, c3.parent, 'c3 should be moved to p1'
assert_equal [c1, c2, c3].sort, p1.children.to_a.sort, 'p1 should have all three children'

# Verify hierarchy table: p2 should be removed
assert_empty hierarchy.where(ancestor_id: p2.id)
assert_empty hierarchy.where(descendant_id: p2.id)

# All children should now have p1 as direct parent (generations: 1)
[c1, c2, c3].each do |child|
assert hierarchy.where(ancestor_id: p1.id, descendant_id: child.id, generations: 1).exists?
# Should not have p2 in their ancestry anymore
assert_empty hierarchy.where(ancestor_id: p2.id, descendant_id: child.id)
end
end

it 'maintains hierarchy relationships after adoption' do
p1 = model_class.create!(name: 'p1')
p2 = model_class.create!(name: 'p2', parent: p1)
p3 = model_class.create!(name: 'p3', parent: p2)
p4 = model_class.create!(name: 'p4', parent: p3)
p5 = model_class.create!(name: 'p5', parent: p4)

# Verify initial structure: p1 -> p2 -> p3 -> p4 -> p5
assert_equal %w[p1 p2 p3 p4 p5], p5.ancestry_path

hierarchy = model_class.hierarchy_class
# Verify p5 has all ancestors in hierarchy
assert hierarchy.where(ancestor_id: p1.id, descendant_id: p5.id, generations: 4).exists?
assert hierarchy.where(ancestor_id: p2.id, descendant_id: p5.id, generations: 3).exists?
assert hierarchy.where(ancestor_id: p3.id, descendant_id: p5.id, generations: 2).exists?
assert hierarchy.where(ancestor_id: p4.id, descendant_id: p5.id, generations: 1).exists?

# Destroy p3
p3.destroy

# After adoption, p4 and p5 should still maintain their relationship
p4.reload
p5.reload
assert_equal p2, p4.parent, 'p4 should be adopted by p2'
assert_equal p4, p5.parent, 'p5 should still have p4 as parent'
assert_equal %w[p1 p2 p4 p5], p5.ancestry_path, 'ancestry path should be updated correctly'

# Verify hierarchy table: p3 should be removed
assert_empty hierarchy.where(ancestor_id: p3.id)
assert_empty hierarchy.where(descendant_id: p3.id)

# p5 should now have p2 as ancestor (generations: 2) and p4 as parent (generations: 1)
assert hierarchy.where(ancestor_id: p2.id, descendant_id: p5.id, generations: 2).exists?
assert hierarchy.where(ancestor_id: p4.id, descendant_id: p5.id, generations: 1).exists?
assert hierarchy.where(ancestor_id: p1.id, descendant_id: p5.id, generations: 3).exists?
# p5 should not have p3 in its ancestry anymore
assert_empty hierarchy.where(ancestor_id: p3.id, descendant_id: p5.id)
end

it 'handles deep nested structures correctly and updates hierarchy table' do
root = model_class.create!(name: 'root')
level1 = model_class.create!(name: 'level1', parent: root)
level2 = model_class.create!(name: 'level2', parent: level1)
level3 = model_class.create!(name: 'level3', parent: level2)
level4 = model_class.create!(name: 'level4', parent: level3)

hierarchy = model_class.hierarchy_class
# Verify initial hierarchy for level4
assert hierarchy.where(ancestor_id: root.id, descendant_id: level4.id, generations: 4).exists?
assert hierarchy.where(ancestor_id: level1.id, descendant_id: level4.id, generations: 3).exists?
assert hierarchy.where(ancestor_id: level2.id, descendant_id: level4.id, generations: 2).exists?
assert hierarchy.where(ancestor_id: level3.id, descendant_id: level4.id, generations: 1).exists?

# Destroy level2
level2.destroy

# level3 should be adopted by level1, and level4 should still be child of level3
level1.reload
level3.reload
level4.reload

assert_equal level1, level3.parent, 'level3 should be adopted by level1'
assert_equal level3, level4.parent, 'level4 should still have level3 as parent'
assert_equal %w[root level1 level3 level4], level4.ancestry_path

# Verify hierarchy table: level2 should be removed
assert_empty hierarchy.where(ancestor_id: level2.id)
assert_empty hierarchy.where(descendant_id: level2.id)

# level4 should now have correct ancestry without level2
assert hierarchy.where(ancestor_id: root.id, descendant_id: level4.id, generations: 3).exists?
assert hierarchy.where(ancestor_id: level1.id, descendant_id: level4.id, generations: 2).exists?
assert hierarchy.where(ancestor_id: level3.id, descendant_id: level4.id, generations: 1).exists?
# level4 should not have level2 in its ancestry anymore
assert_empty hierarchy.where(ancestor_id: level2.id, descendant_id: level4.id)
end

it 'handles destroying a node with no children' do
p1 = model_class.create!(name: 'p1')
p2 = model_class.create!(name: 'p2', parent: p1)
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.

# Destroy leaf (has no children)
leaf.destroy

# Should not raise any errors
p1.reload
p2.reload
assert_equal [p2], p1.children.to_a
assert_equal [], p2.children.to_a

# Hierarchy should be cleaned up
assert_empty hierarchy.where(ancestor_id: leaf.id)
assert_empty hierarchy.where(descendant_id: leaf.id)
end

it 'works with find_or_create_by_path' do
level3 = model_class.find_or_create_by_path(%w[root level1 level2 level3])
root = level3.root
level1 = root.children.find_by(name: 'level1')
level2 = level1.children.find_by(name: 'level2')

hierarchy = model_class.hierarchy_class
# Verify initial hierarchy
assert hierarchy.where(ancestor_id: root.id, descendant_id: level3.id).exists?
assert hierarchy.where(ancestor_id: level2.id, descendant_id: level3.id, generations: 1).exists?

# Destroy level2
level2.destroy

# level3 should be adopted by level1
level1.reload
level3.reload
assert_equal level1, level3.parent
assert_equal %w[root level1 level3], level3.ancestry_path

# Verify hierarchy table
assert_empty hierarchy.where(ancestor_id: level2.id)
assert hierarchy.where(ancestor_id: level1.id, descendant_id: level3.id, generations: 1).exists?
assert hierarchy.where(ancestor_id: root.id, descendant_id: level3.id, generations: 2).exists?
end
end
end

# Test with PostgreSQL
if postgresql?(ApplicationRecord.connection)
run_adopt_tests_for(AdoptableTag)
end

# Test with MySQL
if mysql?(MysqlRecord.connection)
run_adopt_tests_for(MysqlAdoptableTag)
end

# Test with SQLite
if sqlite?(SqliteRecord.connection)
run_adopt_tests_for(MemoryAdoptableTag)
end
7 changes: 7 additions & 0 deletions test/dummy/app/models/adoptable_tag.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class AdoptableTag < ApplicationRecord
has_closure_tree dependent: :adopt, name_column: 'name'
end


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.
6 changes: 6 additions & 0 deletions test/dummy/app/models/memory_adoptable_tag.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true

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.
6 changes: 6 additions & 0 deletions test/dummy/app/models/mysql_adoptable_tag.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true

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.
Loading