Skip to content

Commit 7a5d11f

Browse files
committed
Add dependent: :adopt option for has_closure_tree
1 parent 4c40dbc commit 7a5d11f

File tree

7 files changed

+191
-3
lines changed

7 files changed

+191
-3
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@ When you include ```has_closure_tree``` in your model, you can provide a hash to
314314
* ```:hierarchy_table_name``` to override the hierarchy table name. This defaults to the singular name of the model + "_hierarchies", like ```tag_hierarchies```.
315315
* ```:dependent``` determines what happens when a node is destroyed. Defaults to ```nullify```.
316316
* ```:nullify``` will simply set the parent column to null. Each child node will be considered a "root" node. This is the default.
317+
* ```: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.
317318
* ```:delete_all``` will delete all descendant nodes (which circumvents the destroy hooks)
318319
* ```:destroy``` will destroy all descendant nodes (which runs the destroy hooks on each child node)
319320
* ```nil``` does nothing with descendant nodes

lib/closure_tree/association_setup.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ module AssociationSetup
2020

2121
has_many :children, *_ct.has_many_order_with_option, class_name: _ct.model_class.to_s,
2222
foreign_key: _ct.parent_column_name,
23-
dependent: _ct.options[:dependent],
23+
dependent: _ct.options[:dependent] == :adopt ? :nullify : _ct.options[:dependent],
2424
inverse_of: :parent do
2525
# We have to redefine hash_tree because the activerecord relation is already scoped to parent_id.
2626
def hash_tree(options = {})
@@ -47,4 +47,4 @@ def hash_tree(options = {})
4747
source: :descendant
4848
end
4949
end
50-
end
50+
end

lib/closure_tree/hierarchy_maintenance.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,24 @@ def _ct_after_save
5252

5353
def _ct_before_destroy
5454
_ct.with_advisory_lock do
55+
adopt_children_to_grandparent if _ct.options[:dependent] == :adopt
5556
delete_hierarchy_references
5657
self.class.find(id).children.find_each(&:rebuild!) if _ct.options[:dependent] == :nullify
5758
end
5859
true # don't prevent destruction
5960
end
6061

62+
def adopt_children_to_grandparent
63+
grandparent_id = read_attribute(_ct.parent_column_name)
64+
children_ids = self.class.where(_ct.parent_column_name => id).pluck(:id)
65+
66+
children_ids.each do |child_id|
67+
child = self.class.find(child_id)
68+
child.update_column(_ct.parent_column_name, grandparent_id)
69+
child.rebuild!
70+
end
71+
end
72+
6173
def rebuild!(called_by_rebuild = false)
6274
_ct.with_advisory_lock do
6375
delete_hierarchy_references unless (defined? @was_new_record) && @was_new_record

lib/closure_tree/support.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def initialize(model_class, options)
1616

1717
@options = {
1818
parent_column_name: 'parent_id',
19-
dependent: :nullify, # or :destroy or :delete_all -- see the README
19+
dependent: :nullify, # or :destroy, :delete_all, or :adopt -- see the README
2020
name_column: 'name',
2121
with_advisory_lock: true, # This will be overridden by adapter support
2222
numeric_order: false

test/closure_tree/adopt_test.rb

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# frozen_string_literal: true
2+
3+
require 'test_helper'
4+
5+
describe 'AdoptableTag with dependent: :adopt' do
6+
before do
7+
AdoptableTag.delete_all
8+
AdoptableTag.hierarchy_class.delete_all
9+
end
10+
11+
it 'moves children to grandparent when parent is destroyed' do
12+
p1 = AdoptableTag.create!(name: 'p1')
13+
p2 = AdoptableTag.create!(name: 'p2', parent: p1)
14+
p3 = AdoptableTag.create!(name: 'p3', parent: p2)
15+
p4 = AdoptableTag.create!(name: 'p4', parent: p3)
16+
17+
# Verify initial structure: p1 -> p2 -> p3 -> p4
18+
assert_equal p2, p3.parent
19+
assert_equal p3, p4.parent
20+
assert_equal p1, p2.parent
21+
22+
# Destroy p3
23+
p3.destroy
24+
25+
# After destroying p3, p4 should be adopted by p2 (p3's parent)
26+
p4.reload
27+
p2.reload
28+
assert_equal p2, p4.parent, 'p4 should be moved to p2 (grandparent)'
29+
assert_equal p1, p2.parent, 'p2 should still have p1 as parent'
30+
assert_equal [p4], p2.children.to_a, 'p2 should have p4 as child'
31+
end
32+
33+
it 'moves children to root when parent without grandparent is destroyed' do
34+
p1 = AdoptableTag.create!(name: 'p1')
35+
p2 = AdoptableTag.create!(name: 'p2', parent: p1)
36+
p3 = AdoptableTag.create!(name: 'p3', parent: p2)
37+
38+
# Verify initial structure: p1 -> p2 -> p3
39+
assert_equal p1, p2.parent
40+
assert_equal p2, p3.parent
41+
42+
# Destroy p1 (root node)
43+
p1.destroy
44+
45+
# After destroying p1, p2 should become root, and p3 should still be child of p2
46+
p2.reload
47+
p3.reload
48+
assert_nil p2.parent, 'p2 should become root'
49+
assert_equal p2, p3.parent, 'p3 should still have p2 as parent'
50+
assert p2.root?, 'p2 should be a root node'
51+
assert_equal [p3], p2.children.to_a, 'p2 should have p3 as child'
52+
end
53+
54+
it 'handles multiple children being adopted' do
55+
p1 = AdoptableTag.create!(name: 'p1')
56+
p2 = AdoptableTag.create!(name: 'p2', parent: p1)
57+
c1 = AdoptableTag.create!(name: 'c1', parent: p2)
58+
c2 = AdoptableTag.create!(name: 'c2', parent: p2)
59+
c3 = AdoptableTag.create!(name: 'c3', parent: p2)
60+
61+
# Verify initial structure: p1 -> p2 -> [c1, c2, c3]
62+
assert_equal [c1, c2, c3].sort, p2.children.to_a.sort
63+
64+
# Destroy p2
65+
p2.destroy
66+
67+
# All children should be adopted by p1
68+
p1.reload
69+
c1.reload
70+
c2.reload
71+
c3.reload
72+
73+
assert_equal p1, c1.parent, 'c1 should be moved to p1'
74+
assert_equal p1, c2.parent, 'c2 should be moved to p1'
75+
assert_equal p1, c3.parent, 'c3 should be moved to p1'
76+
assert_equal [c1, c2, c3].sort, p1.children.to_a.sort, 'p1 should have all three children'
77+
end
78+
79+
it 'maintains hierarchy relationships after adoption' do
80+
p1 = AdoptableTag.create!(name: 'p1')
81+
p2 = AdoptableTag.create!(name: 'p2', parent: p1)
82+
p3 = AdoptableTag.create!(name: 'p3', parent: p2)
83+
p4 = AdoptableTag.create!(name: 'p4', parent: p3)
84+
p5 = AdoptableTag.create!(name: 'p5', parent: p4)
85+
86+
# Verify initial structure: p1 -> p2 -> p3 -> p4 -> p5
87+
assert_equal %w[p1 p2 p3 p4 p5], p5.ancestry_path
88+
89+
# Destroy p3
90+
p3.destroy
91+
92+
# After adoption, p4 and p5 should still maintain their relationship
93+
p4.reload
94+
p5.reload
95+
assert_equal p2, p4.parent, 'p4 should be adopted by p2'
96+
assert_equal p4, p5.parent, 'p5 should still have p4 as parent'
97+
assert_equal %w[p1 p2 p4 p5], p5.ancestry_path, 'ancestry path should be updated correctly'
98+
end
99+
100+
it 'handles deep nested structures correctly' do
101+
root = AdoptableTag.create!(name: 'root')
102+
level1 = AdoptableTag.create!(name: 'level1', parent: root)
103+
level2 = AdoptableTag.create!(name: 'level2', parent: level1)
104+
level3 = AdoptableTag.create!(name: 'level3', parent: level2)
105+
level4 = AdoptableTag.create!(name: 'level4', parent: level3)
106+
107+
# Destroy level2
108+
level2.destroy
109+
110+
# level3 should be adopted by level1, and level4 should still be child of level3
111+
level1.reload
112+
level3.reload
113+
level4.reload
114+
115+
assert_equal level1, level3.parent, 'level3 should be adopted by level1'
116+
assert_equal level3, level4.parent, 'level4 should still have level3 as parent'
117+
assert_equal %w[root level1 level3 level4], level4.ancestry_path
118+
end
119+
120+
it 'handles destroying a node with no children' do
121+
p1 = AdoptableTag.create!(name: 'p1')
122+
p2 = AdoptableTag.create!(name: 'p2', parent: p1)
123+
leaf = AdoptableTag.create!(name: 'leaf', parent: p2)
124+
125+
# Destroy leaf (has no children)
126+
leaf.destroy
127+
128+
# Should not raise any errors
129+
p1.reload
130+
p2.reload
131+
assert_equal [p2], p1.children.to_a
132+
assert_equal [], p2.children.to_a
133+
end
134+
135+
it 'works with find_or_create_by_path' do
136+
level3 = AdoptableTag.find_or_create_by_path(%w[root level1 level2 level3])
137+
root = level3.root
138+
level1 = root.children.find_by(name: 'level1')
139+
level2 = level1.children.find_by(name: 'level2')
140+
141+
# Destroy level2
142+
level2.destroy
143+
144+
# level3 should be adopted by level1
145+
level1.reload
146+
level3.reload
147+
assert_equal level1, level3.parent
148+
assert_equal %w[root level1 level3], level3.ancestry_path
149+
end
150+
end
151+
152+
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# frozen_string_literal: true
2+
3+
class AdoptableTag < ApplicationRecord
4+
has_closure_tree dependent: :adopt, name_column: 'name'
5+
end
6+
7+

test/dummy/db/schema.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,4 +220,20 @@
220220
add_index 'scoped_item_hierarchies', %i[ancestor_id descendant_id generations], unique: true,
221221
name: 'scoped_item_anc_desc_idx'
222222
add_index 'scoped_item_hierarchies', [:descendant_id], name: 'scoped_item_desc_idx'
223+
224+
create_table 'adoptable_tags' do |t|
225+
t.string 'name'
226+
t.references 'parent'
227+
t.timestamps null: false
228+
end
229+
230+
create_table 'adoptable_tag_hierarchies', id: false do |t|
231+
t.references 'ancestor', null: false
232+
t.references 'descendant', null: false
233+
t.integer 'generations', null: false
234+
end
235+
236+
add_index 'adoptable_tag_hierarchies', %i[ancestor_id descendant_id generations], unique: true,
237+
name: 'adoptable_tag_anc_desc_idx'
238+
add_index 'adoptable_tag_hierarchies', [:descendant_id], name: 'adoptable_tag_desc_idx'
223239
end

0 commit comments

Comments
 (0)