Skip to content

Commit d47d7c9

Browse files
authored
feat: Add dependent: :adopt option for has_closure_tree (#471)
1 parent 73b07a6 commit d47d7c9

File tree

12 files changed

+388
-4
lines changed

12 files changed

+388
-4
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/arel_helpers.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,5 +79,13 @@ def build_hierarchy_delete_query(hierarchy_table, id)
7979

8080
delete_manager
8181
end
82+
83+
# Convert an Arel AST to SQL using the correct connection's visitor
84+
# This ensures proper quoting for the specific database adapter (MySQL uses backticks, PostgreSQL uses double quotes)
85+
def to_sql_with_connection(arel_manager)
86+
collector = Arel::Collectors::SQLString.new
87+
visitor = connection.send(:arel_visitor)
88+
visitor.accept(arel_manager.ast, collector).value
89+
end
8290
end
8391
end

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: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,26 @@ 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+
return if children_ids.empty?
67+
68+
# Update all children's parent_id in a single query
69+
self.class.where(id: children_ids).update_all(_ct.parent_column_name => grandparent_id)
70+
71+
# Rebuild hierarchy for each child
72+
self.class.where(id: children_ids).find_each(&:rebuild!)
73+
end
74+
6175
def rebuild!(called_by_rebuild = false)
6276
_ct.with_advisory_lock do
6377
delete_hierarchy_references unless (defined? @was_new_record) && @was_new_record
@@ -93,7 +107,7 @@ def delete_hierarchy_references
93107

94108
hierarchy_table = hierarchy_class.arel_table
95109
delete_query = _ct.build_hierarchy_delete_query(hierarchy_table, id)
96-
_ct.connection.execute(delete_query.to_sql)
110+
_ct.connection.execute(_ct.to_sql_with_connection(delete_query))
97111
end
98112
end
99113

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: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
# frozen_string_literal: true
2+
3+
require 'test_helper'
4+
5+
def run_adopt_tests_for(model_class)
6+
describe "#{model_class} with dependent: :adopt" do
7+
before do
8+
model_class.delete_all
9+
model_class.hierarchy_class.delete_all
10+
end
11+
12+
it 'moves children to grandparent when parent is destroyed and updates hierarchy table' do
13+
p1 = model_class.create!(name: 'p1')
14+
p2 = model_class.create!(name: 'p2', parent: p1)
15+
p3 = model_class.create!(name: 'p3', parent: p2)
16+
p4 = model_class.create!(name: 'p4', parent: p3)
17+
18+
# Verify initial structure: p1 -> p2 -> p3 -> p4
19+
assert_equal p2, p3.parent
20+
assert_equal p3, p4.parent
21+
assert_equal p1, p2.parent
22+
23+
# Verify initial hierarchy table entries
24+
hierarchy = model_class.hierarchy_class
25+
assert hierarchy.where(ancestor_id: p1.id, descendant_id: p4.id, generations: 3).exists?
26+
assert hierarchy.where(ancestor_id: p2.id, descendant_id: p4.id, generations: 2).exists?
27+
assert hierarchy.where(ancestor_id: p3.id, descendant_id: p4.id, generations: 1).exists?
28+
assert hierarchy.where(ancestor_id: p3.id, descendant_id: p3.id, generations: 0).exists?
29+
30+
# Destroy p3
31+
p3.destroy
32+
33+
# After destroying p3, p4 should be adopted by p2 (p3's parent)
34+
p4.reload
35+
p2.reload
36+
assert_equal p2, p4.parent, 'p4 should be moved to p2 (grandparent)'
37+
assert_equal p1, p2.parent, 'p2 should still have p1 as parent'
38+
assert_equal [p4], p2.children.to_a, 'p2 should have p4 as child'
39+
40+
# Verify hierarchy table was updated correctly
41+
# p3 should be removed from hierarchy
42+
assert_empty hierarchy.where(ancestor_id: p3.id)
43+
assert_empty hierarchy.where(descendant_id: p3.id)
44+
45+
# p4 should now have p2 as direct parent (generations: 1)
46+
assert hierarchy.where(ancestor_id: p2.id, descendant_id: p4.id, generations: 1).exists?
47+
# p4 should have p1 as ancestor (generations: 2)
48+
assert hierarchy.where(ancestor_id: p1.id, descendant_id: p4.id, generations: 2).exists?
49+
# p4 should have itself (generations: 0)
50+
assert hierarchy.where(ancestor_id: p4.id, descendant_id: p4.id, generations: 0).exists?
51+
end
52+
53+
it 'moves children to root when parent without grandparent is destroyed and updates hierarchy table' do
54+
p1 = model_class.create!(name: 'p1')
55+
p2 = model_class.create!(name: 'p2', parent: p1)
56+
p3 = model_class.create!(name: 'p3', parent: p2)
57+
58+
# Verify initial structure: p1 -> p2 -> p3
59+
assert_equal p1, p2.parent
60+
assert_equal p2, p3.parent
61+
62+
hierarchy = model_class.hierarchy_class
63+
hierarchy.where(ancestor_id: p2.id).count
64+
hierarchy.where(descendant_id: p3.id).count
65+
66+
# Destroy p1 (root node)
67+
p1.destroy
68+
69+
# After destroying p1, p2 should become root, and p3 should still be child of p2
70+
p2.reload
71+
p3.reload
72+
assert_nil p2.parent, 'p2 should become root'
73+
assert_equal p2, p3.parent, 'p3 should still have p2 as parent'
74+
assert p2.root?, 'p2 should be a root node'
75+
assert_equal [p3], p2.children.to_a, 'p2 should have p3 as child'
76+
77+
# Verify hierarchy table: p1 should be removed
78+
assert_empty hierarchy.where(ancestor_id: p1.id)
79+
assert_empty hierarchy.where(descendant_id: p1.id)
80+
81+
# p2 should now be a root (no ancestors)
82+
assert hierarchy.where(ancestor_id: p2.id, descendant_id: p2.id, generations: 0).exists?
83+
# p3 should still have p2 as parent
84+
assert hierarchy.where(ancestor_id: p2.id, descendant_id: p3.id, generations: 1).exists?
85+
end
86+
87+
it 'handles multiple children being adopted and updates hierarchy table' do
88+
p1 = model_class.create!(name: 'p1')
89+
p2 = model_class.create!(name: 'p2', parent: p1)
90+
c1 = model_class.create!(name: 'c1', parent: p2)
91+
c2 = model_class.create!(name: 'c2', parent: p2)
92+
c3 = model_class.create!(name: 'c3', parent: p2)
93+
94+
# Verify initial structure: p1 -> p2 -> [c1, c2, c3]
95+
assert_equal [c1, c2, c3].sort, p2.children.to_a.sort
96+
97+
hierarchy = model_class.hierarchy_class
98+
# Verify initial hierarchy: all children should have p1 and p2 as ancestors
99+
[c1, c2, c3].each do |child|
100+
assert hierarchy.where(ancestor_id: p1.id, descendant_id: child.id, generations: 2).exists?
101+
assert hierarchy.where(ancestor_id: p2.id, descendant_id: child.id, generations: 1).exists?
102+
end
103+
104+
# Destroy p2
105+
p2.destroy
106+
107+
# All children should be adopted by p1
108+
p1.reload
109+
c1.reload
110+
c2.reload
111+
c3.reload
112+
113+
assert_equal p1, c1.parent, 'c1 should be moved to p1'
114+
assert_equal p1, c2.parent, 'c2 should be moved to p1'
115+
assert_equal p1, c3.parent, 'c3 should be moved to p1'
116+
assert_equal [c1, c2, c3].sort, p1.children.to_a.sort, 'p1 should have all three children'
117+
118+
# Verify hierarchy table: p2 should be removed
119+
assert_empty hierarchy.where(ancestor_id: p2.id)
120+
assert_empty hierarchy.where(descendant_id: p2.id)
121+
122+
# All children should now have p1 as direct parent (generations: 1)
123+
[c1, c2, c3].each do |child|
124+
assert hierarchy.where(ancestor_id: p1.id, descendant_id: child.id, generations: 1).exists?
125+
# Should not have p2 in their ancestry anymore
126+
assert_empty hierarchy.where(ancestor_id: p2.id, descendant_id: child.id)
127+
end
128+
end
129+
130+
it 'maintains hierarchy relationships after adoption' do
131+
p1 = model_class.create!(name: 'p1')
132+
p2 = model_class.create!(name: 'p2', parent: p1)
133+
p3 = model_class.create!(name: 'p3', parent: p2)
134+
p4 = model_class.create!(name: 'p4', parent: p3)
135+
p5 = model_class.create!(name: 'p5', parent: p4)
136+
137+
# Verify initial structure: p1 -> p2 -> p3 -> p4 -> p5
138+
assert_equal %w[p1 p2 p3 p4 p5], p5.ancestry_path
139+
140+
hierarchy = model_class.hierarchy_class
141+
# Verify p5 has all ancestors in hierarchy
142+
assert hierarchy.where(ancestor_id: p1.id, descendant_id: p5.id, generations: 4).exists?
143+
assert hierarchy.where(ancestor_id: p2.id, descendant_id: p5.id, generations: 3).exists?
144+
assert hierarchy.where(ancestor_id: p3.id, descendant_id: p5.id, generations: 2).exists?
145+
assert hierarchy.where(ancestor_id: p4.id, descendant_id: p5.id, generations: 1).exists?
146+
147+
# Destroy p3
148+
p3.destroy
149+
150+
# After adoption, p4 and p5 should still maintain their relationship
151+
p4.reload
152+
p5.reload
153+
assert_equal p2, p4.parent, 'p4 should be adopted by p2'
154+
assert_equal p4, p5.parent, 'p5 should still have p4 as parent'
155+
assert_equal %w[p1 p2 p4 p5], p5.ancestry_path, 'ancestry path should be updated correctly'
156+
157+
# Verify hierarchy table: p3 should be removed
158+
assert_empty hierarchy.where(ancestor_id: p3.id)
159+
assert_empty hierarchy.where(descendant_id: p3.id)
160+
161+
# p5 should now have p2 as ancestor (generations: 2) and p4 as parent (generations: 1)
162+
assert hierarchy.where(ancestor_id: p2.id, descendant_id: p5.id, generations: 2).exists?
163+
assert hierarchy.where(ancestor_id: p4.id, descendant_id: p5.id, generations: 1).exists?
164+
assert hierarchy.where(ancestor_id: p1.id, descendant_id: p5.id, generations: 3).exists?
165+
# p5 should not have p3 in its ancestry anymore
166+
assert_empty hierarchy.where(ancestor_id: p3.id, descendant_id: p5.id)
167+
end
168+
169+
it 'handles deep nested structures correctly and updates hierarchy table' do
170+
root = model_class.create!(name: 'root')
171+
level1 = model_class.create!(name: 'level1', parent: root)
172+
level2 = model_class.create!(name: 'level2', parent: level1)
173+
level3 = model_class.create!(name: 'level3', parent: level2)
174+
level4 = model_class.create!(name: 'level4', parent: level3)
175+
176+
hierarchy = model_class.hierarchy_class
177+
# Verify initial hierarchy for level4
178+
assert hierarchy.where(ancestor_id: root.id, descendant_id: level4.id, generations: 4).exists?
179+
assert hierarchy.where(ancestor_id: level1.id, descendant_id: level4.id, generations: 3).exists?
180+
assert hierarchy.where(ancestor_id: level2.id, descendant_id: level4.id, generations: 2).exists?
181+
assert hierarchy.where(ancestor_id: level3.id, descendant_id: level4.id, generations: 1).exists?
182+
183+
# Destroy level2
184+
level2.destroy
185+
186+
# level3 should be adopted by level1, and level4 should still be child of level3
187+
level1.reload
188+
level3.reload
189+
level4.reload
190+
191+
assert_equal level1, level3.parent, 'level3 should be adopted by level1'
192+
assert_equal level3, level4.parent, 'level4 should still have level3 as parent'
193+
assert_equal %w[root level1 level3 level4], level4.ancestry_path
194+
195+
# Verify hierarchy table: level2 should be removed
196+
assert_empty hierarchy.where(ancestor_id: level2.id)
197+
assert_empty hierarchy.where(descendant_id: level2.id)
198+
199+
# level4 should now have correct ancestry without level2
200+
assert hierarchy.where(ancestor_id: root.id, descendant_id: level4.id, generations: 3).exists?
201+
assert hierarchy.where(ancestor_id: level1.id, descendant_id: level4.id, generations: 2).exists?
202+
assert hierarchy.where(ancestor_id: level3.id, descendant_id: level4.id, generations: 1).exists?
203+
# level4 should not have level2 in its ancestry anymore
204+
assert_empty hierarchy.where(ancestor_id: level2.id, descendant_id: level4.id)
205+
end
206+
207+
it 'handles destroying a node with no children' do
208+
p1 = model_class.create!(name: 'p1')
209+
p2 = model_class.create!(name: 'p2', parent: p1)
210+
leaf = model_class.create!(name: 'leaf', parent: p2)
211+
212+
hierarchy = model_class.hierarchy_class
213+
hierarchy.count
214+
215+
# Destroy leaf (has no children)
216+
leaf.destroy
217+
218+
# Should not raise any errors
219+
p1.reload
220+
p2.reload
221+
assert_equal [p2], p1.children.to_a
222+
assert_equal [], p2.children.to_a
223+
224+
# Hierarchy should be cleaned up
225+
assert_empty hierarchy.where(ancestor_id: leaf.id)
226+
assert_empty hierarchy.where(descendant_id: leaf.id)
227+
end
228+
229+
it 'works with find_or_create_by_path' do
230+
level3 = model_class.find_or_create_by_path(%w[root level1 level2 level3])
231+
root = level3.root
232+
level1 = root.children.find_by(name: 'level1')
233+
level2 = level1.children.find_by(name: 'level2')
234+
235+
hierarchy = model_class.hierarchy_class
236+
# Verify initial hierarchy
237+
assert hierarchy.where(ancestor_id: root.id, descendant_id: level3.id).exists?
238+
assert hierarchy.where(ancestor_id: level2.id, descendant_id: level3.id, generations: 1).exists?
239+
240+
# Destroy level2
241+
level2.destroy
242+
243+
# level3 should be adopted by level1
244+
level1.reload
245+
level3.reload
246+
assert_equal level1, level3.parent
247+
assert_equal %w[root level1 level3], level3.ancestry_path
248+
249+
# Verify hierarchy table
250+
assert_empty hierarchy.where(ancestor_id: level2.id)
251+
assert hierarchy.where(ancestor_id: level1.id, descendant_id: level3.id, generations: 1).exists?
252+
assert hierarchy.where(ancestor_id: root.id, descendant_id: level3.id, generations: 2).exists?
253+
end
254+
end
255+
end
256+
257+
# Test with PostgreSQL
258+
run_adopt_tests_for(AdoptableTag) if postgresql?(ApplicationRecord.connection)
259+
260+
# Test with MySQL
261+
run_adopt_tests_for(MysqlAdoptableTag) if mysql?(MysqlRecord.connection)
262+
263+
# Test with SQLite
264+
run_adopt_tests_for(MemoryAdoptableTag) if sqlite?(SqliteRecord.connection)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# frozen_string_literal: true
2+
3+
class AdoptableTag < ApplicationRecord
4+
has_closure_tree dependent: :adopt, name_column: 'name'
5+
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# frozen_string_literal: true
2+
3+
class MemoryAdoptableTag < SqliteRecord
4+
has_closure_tree dependent: :adopt, name_column: 'name'
5+
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# frozen_string_literal: true
2+
3+
class MysqlAdoptableTag < MysqlRecord
4+
has_closure_tree dependent: :adopt, name_column: 'name'
5+
end

0 commit comments

Comments
 (0)