Skip to content

Commit ad3baf7

Browse files
committed
Add has_closure_tree_roots (plural) to allow has_many in related AR models
1 parent 509f6df commit ad3baf7

File tree

4 files changed

+260
-17
lines changed

4 files changed

+260
-17
lines changed

lib/closure_tree/has_closure_tree_root.rb

+52-17
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,27 @@ class MultipleRootError < StandardError; end
33
class RootOrderingDisabledError < StandardError; end
44

55
module HasClosureTreeRoot
6-
76
def has_closure_tree_root(assoc_name, options = {})
8-
options[:class_name] ||= assoc_name.to_s.sub(/\Aroot_/, "").classify
7+
options[:class_name] ||= assoc_name.to_s.sub(/\Aroot_/, "").classify
98
options[:foreign_key] ||= self.name.underscore << "_id"
109

1110
has_one assoc_name, -> { where(parent: nil) }, **options
11+
define_closure_tree_method(assoc_name, options, allow_multiple_roots: false)
12+
connection_pool.release_connection
13+
end
1214

13-
# Fetches the association, eager loading all children and given associations
15+
def has_closure_tree_roots(assoc_name, options = {})
16+
options[:class_name] ||= assoc_name.to_s.sub(/\Aroots_/, "").classify
17+
options[:foreign_key] ||= self.name.underscore << "_id"
18+
19+
has_many assoc_name, -> { where(parent: nil) }, **options
20+
define_closure_tree_method(assoc_name, options, allow_multiple_roots: true)
21+
connection_pool.release_connection
22+
end
23+
24+
private
25+
26+
def define_closure_tree_method(assoc_name, options, allow_multiple_roots: false)
1427
define_method("#{assoc_name}_including_tree") do |*args|
1528
reload = false
1629
reload = args.shift if args && (args.first == true || args.first == false)
@@ -28,25 +41,45 @@ def has_closure_tree_root(assoc_name, options = {})
2841

2942
roots = options[:class_name].constantize.where(parent: nil, options[:foreign_key] => id).to_a
3043

31-
return nil if roots.empty?
44+
if roots.empty?
45+
return allow_multiple_roots ? [] : nil
46+
end
3247

33-
if roots.size > 1
48+
unless allow_multiple_roots || roots.size <= 1
3449
raise MultipleRootError.new("#{self.class.name}: has_closure_tree_root requires a single root")
3550
end
3651

37-
temp_root = roots.first
38-
root = nil
39-
id_hash = {}
40-
parent_col_id = temp_root.class._ct.options[:parent_column_name]
41-
4252
# Lookup inverse belongs_to association reflection on target class.
43-
inverse = temp_root.class.reflections.values.detect do |r|
53+
inverse = roots.first.class.reflections.values.detect do |r|
4454
r.macro == :belongs_to && r.klass == self.class
4555
end
4656

4757
# Fetch all descendants in constant number of queries.
4858
# This is the last query-triggering statement in the method.
49-
temp_root.self_and_descendants.includes(*assoc_map).each do |node|
59+
nodes_to_process = if allow_multiple_roots && roots.size > 0
60+
# For multiple roots, fetch all descendants at once to avoid N+1 queries
61+
root_ids = roots.map(&:id)
62+
klass = roots.first.class
63+
hierarchy_table = klass._ct.quoted_hierarchy_table_name
64+
65+
# Get all descendants of all roots including the roots themselves
66+
# (generations 0 = self, > 0 = descendants)
67+
descendant_scope = klass.
68+
joins("INNER JOIN #{hierarchy_table} ON #{hierarchy_table}.descendant_id = #{klass.quoted_table_name}.#{klass.primary_key}").
69+
where("#{hierarchy_table}.ancestor_id IN (?)", root_ids).
70+
includes(*assoc_map).
71+
distinct
72+
73+
descendant_scope
74+
else
75+
roots.first.self_and_descendants.includes(*assoc_map)
76+
end
77+
78+
id_hash = {}
79+
parent_col_id = roots.first.class._ct.options[:parent_column_name]
80+
root = nil
81+
82+
nodes_to_process.each do |node|
5083
id_hash[node.id] = node
5184
parent_node = id_hash[node[parent_col_id]]
5285

@@ -62,8 +95,8 @@ def has_closure_tree_root(assoc_name, options = {})
6295

6396
if parent_node
6497
parent_node.association(:children).target << node
65-
else
66-
# Capture the root we're going to use
98+
elsif !allow_multiple_roots
99+
# Capture the root we're going to use (only for single root case)
67100
root = node
68101
end
69102

@@ -75,10 +108,12 @@ def has_closure_tree_root(assoc_name, options = {})
75108
end
76109
end
77110

78-
@closure_tree_roots[assoc_name][assoc_map] = root
79-
end
111+
result = allow_multiple_roots ?
112+
roots.map { |root| id_hash[root.id] } :
113+
root
80114

81-
connection_pool.release_connection
115+
@closure_tree_roots[assoc_name][assoc_map] = result
116+
end
82117
end
83118
end
84119
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
require "spec_helper"
2+
3+
RSpec.describe "has_closure_tree_roots" do
4+
let!(:post) { Post.create!(title: "Test Post") }
5+
let!(:post_reloaded) { post.class.find(post.id) } # Ensures we're starting fresh
6+
7+
before do
8+
# Create a structure like this:
9+
# Post
10+
# |- Comment1
11+
# | |- Reply1-1
12+
# | |- Reply1-2
13+
# | |- Reply1-2-1
14+
# |- Comment2
15+
# |- Reply2-1
16+
17+
@comment1 = Comment.create!(body: "Top comment 1", post: post)
18+
@comment2 = Comment.create!(body: "Top comment 2", post: post)
19+
20+
@reply1_1 = Comment.create!(body: "Reply 1-1", post: post, parent: @comment1)
21+
@reply1_2 = Comment.create!(body: "Reply 1-2", post: post, parent: @comment1)
22+
@reply2_1 = Comment.create!(body: "Reply 2-1", post: post, parent: @comment2)
23+
24+
@reply1_2_1 = Comment.create!(body: "Reply 1-2-1", post: post, parent: @reply1_2)
25+
end
26+
27+
context "with basic config" do
28+
it "loads all root comments in a constant number of queries" do
29+
expect do
30+
roots = post_reloaded.comments_including_tree
31+
expect(roots.size).to eq 2
32+
expect(roots[0].body).to eq "Top comment 1"
33+
expect(roots[1].body).to eq "Top comment 2"
34+
expect(roots[0].children[0].body).to eq "Reply 1-1"
35+
expect(roots[0].children[1].body).to eq "Reply 1-2"
36+
expect(roots[0].children[1].children[0].body).to eq "Reply 1-2-1"
37+
end.to_not exceed_query_limit(2)
38+
end
39+
40+
it "eager loads inverse association to post" do
41+
expect do
42+
roots = post_reloaded.comments_including_tree
43+
expect(roots[0].post).to eq post
44+
expect(roots[1].post).to eq post
45+
expect(roots[0].children[0].post).to eq post
46+
expect(roots[0].children[1].children[0].post).to eq post
47+
end.to_not exceed_query_limit(2)
48+
end
49+
50+
it "memoizes by assoc_map" do
51+
post_reloaded.comments_including_tree.first.body = "changed1"
52+
expect(post_reloaded.comments_including_tree.first.body).to eq "changed1"
53+
expect(post_reloaded.comments_including_tree(true).first.body).to eq "Top comment 1"
54+
end
55+
56+
it "works if true passed on first call" do
57+
expect(post_reloaded.comments_including_tree(true).first.body).to eq "Top comment 1"
58+
end
59+
60+
it "loads all nodes plus single association in a constant number of queries" do
61+
# Add some attributes to test with - similar to contracts in the root spec
62+
@comment1.update!(likes_count: 10)
63+
@comment2.update!(likes_count: 5)
64+
@reply1_1.update!(likes_count: 3)
65+
@reply1_2.update!(likes_count: 7)
66+
@reply2_1.update!(likes_count: 2)
67+
@reply1_2_1.update!(likes_count: 4)
68+
69+
expect do
70+
roots = post_reloaded.comments_including_tree
71+
expect(roots.size).to eq 2
72+
expect(roots[0].body).to eq "Top comment 1"
73+
expect(roots[0].likes_count).to eq 10
74+
expect(roots[0].children[1].likes_count).to eq 7
75+
expect(roots[0].children[1].children[0].likes_count).to eq 4
76+
expect(roots[1].children[0].body).to eq "Reply 2-1"
77+
end.to_not exceed_query_limit(2)
78+
end
79+
80+
it "loads all nodes and nested associations in a constant number of queries" do
81+
# Create some nested associations to test with
82+
user1 = User.create!(email: "[email protected]")
83+
user2 = User.create!(email: "[email protected]")
84+
user3 = User.create!(email: "[email protected]")
85+
86+
# Create comment_likes instead of using contracts
87+
@comment1.comment_likes.create!(user: user1)
88+
@comment2.comment_likes.create!(user: user2)
89+
@reply1_1.comment_likes.create!(user: user3)
90+
91+
expect do
92+
roots = post_reloaded.comments_including_tree(comment_likes: :user)
93+
expect(roots.size).to eq 2
94+
expect(roots[0].body).to eq "Top comment 1"
95+
expect(roots[0].comment_likes.first.user.email).to eq "[email protected]"
96+
expect(roots[1].comment_likes.first.user.email).to eq "[email protected]"
97+
expect(roots[0].children[0].comment_likes.first.user.email).to eq "[email protected]"
98+
end.to_not exceed_query_limit(4) # Without optimization, this would scale with number of nodes
99+
end
100+
101+
context "with no comment roots" do
102+
let(:empty_post) { Post.create!(title: "Empty Post") }
103+
104+
it "should return empty array" do
105+
expect(empty_post.comments_including_tree).to eq([])
106+
end
107+
end
108+
end
109+
110+
context "when comment is destroyed" do
111+
it "properly maintains the hierarchy" do
112+
@comment1.destroy
113+
roots = post_reloaded.comments_including_tree
114+
expect(roots.size).to eq 1
115+
expect(roots[0].body).to eq "Top comment 2"
116+
expect(roots[0].children[0].body).to eq "Reply 2-1"
117+
end
118+
end
119+
120+
context "when comment is added after initial load" do
121+
it "includes the new comment when reloaded" do
122+
roots = post_reloaded.comments_including_tree
123+
expect(roots.size).to eq 2
124+
125+
new_comment = Comment.create!(body: "New top comment", post: post)
126+
127+
# Should be memoized, so still 2
128+
expect(post_reloaded.comments_including_tree.size).to eq 2
129+
130+
# With true, should reload and find 3
131+
expect(post_reloaded.comments_including_tree(true).size).to eq 3
132+
end
133+
end
134+
135+
context "with nested comment creation" do
136+
it "properly builds the hierarchy" do
137+
# Create a new root comment with nested children
138+
new_comment = Comment.new(body: "New root", post: post)
139+
reply1 = Comment.new(body: "New reply 1", post: post)
140+
reply2 = Comment.new(body: "New reply 2", post: post)
141+
142+
new_comment.children << reply1
143+
new_comment.children << reply2
144+
145+
new_comment.save!
146+
147+
roots = post_reloaded.comments_including_tree(true)
148+
new_root = roots.find { |r| r.body == "New root" }
149+
150+
expect(new_root.children.size).to eq 2
151+
expect(new_root.children.map(&:body)).to include("New reply 1", "New reply 2")
152+
end
153+
end
154+
end

spec/support/models.rb

+22
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,28 @@ def add_destroyed_tag
4848
class DestroyedTag < ApplicationRecord
4949
end
5050

51+
class Post < ApplicationRecord
52+
# A post may have many comments, and each comment may have many replies. It makes little
53+
# sense to have a "Root comment" in this context, so we use has_closure_tree_roots.
54+
has_closure_tree_roots :comments
55+
end
56+
57+
class Comment < ApplicationRecord
58+
has_closure_tree dependent: :destroy
59+
belongs_to :post
60+
belongs_to :user, optional: true
61+
has_many :comment_likes
62+
63+
# This is just for testing the eager loading of associations
64+
attribute :likes_count, :integer
65+
end
66+
67+
# To test eager loading of descendants with multiple roots, we need a model that has many
68+
class CommentLike < ApplicationRecord
69+
belongs_to :comment
70+
belongs_to :user
71+
end
72+
5173
class Group < ApplicationRecord
5274
has_closure_tree_root :root_user
5375
end

spec/support/schema.rb

+32
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,33 @@
138138
t.integer 'generations', null: false
139139
end
140140

141+
create_table 'posts' do |t|
142+
t.string 'title'
143+
t.text 'content'
144+
t.timestamps null: false
145+
end
146+
147+
create_table 'comments' do |t|
148+
t.text 'body'
149+
t.references 'post'
150+
t.references 'parent'
151+
t.references 'user'
152+
t.integer 'likes_count'
153+
t.timestamps null: false
154+
end
155+
156+
create_table 'comment_likes' do |t|
157+
t.references 'comment', null: false
158+
t.references 'user', null: false
159+
t.timestamps null: false
160+
end
161+
162+
create_table 'comment_hierarchies', id: false do |t|
163+
t.references 'ancestor', null: false
164+
t.references 'descendant', null: false
165+
t.integer 'generations', null: false
166+
end
167+
141168
add_index 'label_hierarchies', %i[ancestor_id descendant_id generations], unique: true,
142169
name: 'lh_anc_desc_idx'
143170
add_index 'label_hierarchies', [:descendant_id], name: 'lh_desc_idx'
@@ -154,4 +181,9 @@
154181
add_foreign_key(:menu_item_hierarchies, :menu_items, column: 'descendant_id', on_delete: :cascade)
155182
add_foreign_key(:tag_hierarchies, :tags, column: 'ancestor_id', on_delete: :cascade)
156183
add_foreign_key(:tag_hierarchies, :tags, column: 'descendant_id', on_delete: :cascade)
184+
add_foreign_key(:comments, :comments, column: 'parent_id', on_delete: :cascade)
185+
add_foreign_key(:comment_hierarchies, :comments, column: 'ancestor_id', on_delete: :cascade)
186+
add_foreign_key(:comment_hierarchies, :comments, column: 'descendant_id', on_delete: :cascade)
187+
add_foreign_key(:comment_likes, :comments, column: 'comment_id', on_delete: :cascade)
188+
add_foreign_key(:comment_likes, :users, column: 'user_id', on_delete: :cascade)
157189
end

0 commit comments

Comments
 (0)