Skip to content

Commit 8f7f46a

Browse files
authored
Merge pull request #232 from sassafrastech/has_closure_tree_root
has_closure_tree_root - Better tree eager loading
2 parents 2449029 + 54c070a commit 8f7f46a

File tree

7 files changed

+304
-2
lines changed

7 files changed

+304
-2
lines changed

lib/closure_tree.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ module ClosureTree
44
extend ActiveSupport::Autoload
55

66
autoload :HasClosureTree
7+
autoload :HasClosureTreeRoot
78
autoload :Support
89
autoload :HierarchyMaintenance
910
autoload :Model
@@ -25,4 +26,5 @@ def self.configuration
2526

2627
ActiveSupport.on_load :active_record do
2728
ActiveRecord::Base.send :extend, ClosureTree::HasClosureTree
29+
ActiveRecord::Base.send :extend, ClosureTree::HasClosureTreeRoot
2830
end
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
module ClosureTree
2+
class MultipleRootError < StandardError; end
3+
4+
module HasClosureTreeRoot
5+
6+
def has_closure_tree_root(assoc_name, options = {})
7+
options.assert_valid_keys(
8+
:class_name,
9+
:foreign_key
10+
)
11+
12+
options[:class_name] ||= assoc_name.to_s.sub(/\Aroot_/, "").classify
13+
options[:foreign_key] ||= self.name.underscore << "_id"
14+
15+
has_one assoc_name, -> { where(parent: nil) }, options
16+
17+
# Fetches the association, eager loading all children and given associations
18+
define_method("#{assoc_name}_including_tree") do |assoc_map_or_reload = nil, assoc_map = nil|
19+
reload = false
20+
if assoc_map_or_reload.is_a?(::Hash)
21+
assoc_map = assoc_map_or_reload
22+
else
23+
reload = assoc_map_or_reload
24+
end
25+
26+
unless reload
27+
# Memoize
28+
@closure_tree_roots ||= {}
29+
@closure_tree_roots[assoc_name] ||= {}
30+
if @closure_tree_roots[assoc_name].has_key?(assoc_map)
31+
return @closure_tree_roots[assoc_name][assoc_map]
32+
end
33+
end
34+
35+
roots = options[:class_name].constantize.where(parent: nil, options[:foreign_key] => id).to_a
36+
37+
return nil if roots.empty?
38+
39+
if roots.size > 1
40+
raise MultipleRootError.new("#{self.class.name}: has_closure_tree_root requires a single root")
41+
end
42+
43+
temp_root = roots.first
44+
root = nil
45+
id_hash = {}
46+
parent_col_id = temp_root.class._ct.options[:parent_column_name]
47+
48+
# Lookup inverse belongs_to association reflection on target class.
49+
inverse = temp_root.class.reflections.values.detect do |r|
50+
r.macro == :belongs_to && r.klass == self.class
51+
end
52+
53+
# Fetch all descendants in constant number of queries.
54+
# This is the last query-triggering statement in the method.
55+
temp_root.self_and_descendants.includes(assoc_map).each do |node|
56+
id_hash[node.id] = node
57+
parent_node = id_hash[node[parent_col_id]]
58+
59+
# Pre-assign parent association
60+
parent_assoc = node.association(:parent)
61+
parent_assoc.loaded!
62+
parent_assoc.target = parent_node
63+
64+
# Pre-assign children association as empty for now,
65+
# children will be added in subsequent loop iterations
66+
children_assoc = node.association(:children)
67+
children_assoc.loaded!
68+
69+
if parent_node
70+
parent_node.association(:children).target << node
71+
else
72+
# Capture the root we're going to use
73+
root = node
74+
end
75+
76+
# Pre-assign inverse association back to this class, if it exists on target class.
77+
if inverse
78+
inverse_assoc = node.association(inverse.name)
79+
inverse_assoc.loaded!
80+
inverse_assoc.target = self
81+
end
82+
end
83+
84+
@closure_tree_roots[assoc_name][assoc_map] = root
85+
end
86+
end
87+
end
88+
end

spec/db/models.rb

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,30 @@ def add_destroyed_tag
3535
class DestroyedTag < ActiveRecord::Base
3636
end
3737

38+
class Group < ActiveRecord::Base
39+
has_closure_tree_root :root_user
40+
end
41+
42+
class Grouping < ActiveRecord::Base
43+
has_closure_tree_root :root_person, class_name: "User", foreign_key: :group_id
44+
end
45+
46+
class UserSet < ActiveRecord::Base
47+
has_closure_tree_root :root_user, class_name: "Useur"
48+
end
49+
50+
class Team < ActiveRecord::Base
51+
has_closure_tree_root :root_user, class_name: "User", foreign_key: :grp_id
52+
end
53+
3854
class User < ActiveRecord::Base
3955
acts_as_tree :parent_column_name => "referrer_id",
4056
:name_column => 'email',
4157
:hierarchy_class_name => 'ReferralHierarchy',
4258
:hierarchy_table_name => 'referral_hierarchies'
4359

44-
has_many :contracts
60+
has_many :contracts, inverse_of: :user
61+
belongs_to :group # Can't use and don't need inverse_of here when using has_closure_tree_root.
4562

4663
def indirect_contracts
4764
Contract.where(:user_id => descendant_ids)
@@ -53,7 +70,12 @@ def to_s
5370
end
5471

5572
class Contract < ActiveRecord::Base
56-
belongs_to :user
73+
belongs_to :user, inverse_of: :contracts
74+
belongs_to :contract_type, inverse_of: :contracts
75+
end
76+
77+
class ContractType < ActiveRecord::Base
78+
has_many :contracts, inverse_of: :contract_type
5779
end
5880

5981
class Label < ActiveRecord::Base

spec/db/schema.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,38 @@
4343
add_index "tag_hierarchies", [:ancestor_id, :descendant_id, :generations], :unique => true, :name => "tag_anc_desc_idx"
4444
add_index "tag_hierarchies", [:descendant_id], :name => "tag_desc_idx"
4545

46+
create_table "groups" do |t|
47+
t.string "name", null: false
48+
end
49+
50+
create_table "groupings" do |t|
51+
t.string "name", null: false
52+
end
53+
54+
create_table "user_sets" do |t|
55+
t.string "name", null: false
56+
end
57+
58+
create_table "teams" do |t|
59+
t.string "name", null: false
60+
end
61+
4662
create_table "users" do |t|
4763
t.string "email"
4864
t.integer "referrer_id"
65+
t.integer "group_id"
4966
t.timestamps null: false
5067
end
5168

5269
add_foreign_key(:users, :users, :column => 'referrer_id')
5370

5471
create_table "contracts" do |t|
5572
t.integer "user_id", :null => false
73+
t.integer "contract_type_id"
74+
end
75+
76+
create_table "contract_types" do |t|
77+
t.string "name", :null => false
5678
end
5779

5880
create_table "referral_hierarchies", :id => false do |t|

spec/has_closure_tree_root_spec.rb

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
require "spec_helper"
2+
3+
describe "has_closure_tree_root" do
4+
let!(:ct1) { ContractType.create!(name: "Type1") }
5+
let!(:ct2) { ContractType.create!(name: "Type2") }
6+
let!(:user1) { User.create!(email: "[email protected]", group_id: group.id) }
7+
let!(:user2) { User.create!(email: "[email protected]", group_id: group.id) }
8+
let!(:user3) { User.create!(email: "[email protected]", group_id: group.id) }
9+
let!(:user4) { User.create!(email: "[email protected]", group_id: group.id) }
10+
let!(:user5) { User.create!(email: "[email protected]", group_id: group.id) }
11+
let!(:user6) { User.create!(email: "[email protected]", group_id: group.id) }
12+
let!(:group_reloaded) { group.class.find(group.id) } # Ensures were starting fresh.
13+
14+
before do
15+
# The tree (contract types in parens)
16+
#
17+
# U1(1)
18+
# / \
19+
# U2(1) U3(1&2)
20+
# / / \
21+
# U4(2) U5(1) U6(2)
22+
23+
user1.children << user2
24+
user1.children << user3
25+
user2.children << user4
26+
user3.children << user5
27+
user3.children << user6
28+
29+
user1.contracts.create!(contract_type: ct1)
30+
user2.contracts.create!(contract_type: ct1)
31+
user3.contracts.create!(contract_type: ct1)
32+
user3.contracts.create!(contract_type: ct2)
33+
user4.contracts.create!(contract_type: ct2)
34+
user5.contracts.create!(contract_type: ct1)
35+
user6.contracts.create!(contract_type: ct2)
36+
end
37+
38+
context "with basic config" do
39+
let!(:group) { Group.create!(name: "TheGroup") }
40+
41+
it "loads all nodes and associations in a constant number of queries" do
42+
expect do
43+
root = group_reloaded.root_user_including_tree(contracts: :contract_type)
44+
expect(root.children[0].email).to eq "[email protected]"
45+
expect(root.children[0].parent.children[1].email).to eq "[email protected]"
46+
expect(root.children[1].contracts.map(&:contract_type).map(&:name)).to eq %w(Type1 Type2)
47+
expect(root.children[1].children[0].contracts[0].contract_type.name).to eq "Type1"
48+
expect(root.children[0].children[0].contracts[0].user.
49+
parent.parent.children[1].children[1].contracts[0].contract_type.name).to eq "Type2"
50+
end.to_not exceed_query_limit(4) # Without this feature, this is 15, and scales with number of nodes.
51+
end
52+
53+
it "memoizes by assoc_map" do
54+
group_reloaded.root_user_including_tree.email = "x"
55+
expect(group_reloaded.root_user_including_tree.email).to eq "x"
56+
group_reloaded.root_user_including_tree(contracts: :contract_type).email = "y"
57+
expect(group_reloaded.root_user_including_tree(contracts: :contract_type).email).to eq "y"
58+
expect(group_reloaded.root_user_including_tree.email).to eq "x"
59+
end
60+
61+
it "doesn't memoize if true argument passed" do
62+
group_reloaded.root_user_including_tree.email = "x"
63+
expect(group_reloaded.root_user_including_tree(true).email).to eq "[email protected]"
64+
group_reloaded.root_user_including_tree(contracts: :contract_type).email = "y"
65+
expect(group_reloaded.root_user_including_tree(true, contracts: :contract_type).email).
66+
67+
end
68+
69+
it "eager loads inverse association to group" do
70+
expect do
71+
root = group_reloaded.root_user_including_tree
72+
expect(root.group).to eq group
73+
expect(root.children[0].group).to eq group
74+
end.to_not exceed_query_limit(2)
75+
end
76+
77+
it "works if eager load association map is not given" do
78+
expect do
79+
root = group_reloaded.root_user_including_tree
80+
expect(root.children[0].email).to eq "[email protected]"
81+
expect(root.children[0].parent.children[1].children[0].email).to eq "[email protected]"
82+
end.to_not exceed_query_limit(2)
83+
end
84+
85+
context "with no tree root" do
86+
let(:group2) { Group.create!(name: "OtherGroup") }
87+
88+
it "should return nil" do
89+
expect(group2.root_user_including_tree(contracts: :contract_type)).to be_nil
90+
end
91+
end
92+
93+
context "with multiple tree roots" do
94+
let!(:other_root) { User.create!(email: "[email protected]", group_id: group.id) }
95+
96+
it "should error" do
97+
expect do
98+
root = group_reloaded.root_user_including_tree(contracts: :contract_type)
99+
end.to raise_error(ClosureTree::MultipleRootError)
100+
end
101+
end
102+
end
103+
104+
context "with explicit class_name and foreign_key" do
105+
let(:group) { Grouping.create!(name: "TheGrouping") }
106+
107+
it "should still work" do
108+
root = group_reloaded.root_person_including_tree(contracts: :contract_type)
109+
expect(root.children[0].email).to eq "[email protected]"
110+
end
111+
end
112+
113+
context "with bad class_name" do
114+
let(:group) { UserSet.create!(name: "TheUserSet") }
115+
116+
it "should error" do
117+
expect do
118+
root = group_reloaded.root_user_including_tree(contracts: :contract_type)
119+
end.to raise_error(NameError)
120+
end
121+
end
122+
123+
context "with bad foreign_key" do
124+
let(:group) { Team.create!(name: "TheTeam") }
125+
126+
it "should error" do
127+
expect do
128+
root = group_reloaded.root_user_including_tree(contracts: :contract_type)
129+
end.to raise_error(ActiveRecord::StatementInvalid)
130+
end
131+
end
132+
end

spec/support/exceed_query_limit.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Derived from http://stackoverflow.com/a/13423584/153896. Updated for RSpec 3.
2+
RSpec::Matchers.define :exceed_query_limit do |expected|
3+
supports_block_expectations
4+
5+
match do |block|
6+
query_count(&block) > expected
7+
end
8+
9+
failure_message_when_negated do |actual|
10+
"Expected to run maximum #{expected} queries, got #{@counter.query_count}"
11+
end
12+
13+
def query_count(&block)
14+
@counter = ActiveRecord::QueryCounter.new
15+
ActiveSupport::Notifications.subscribed(@counter.to_proc, 'sql.active_record', &block)
16+
@counter.query_count
17+
end
18+
end

spec/support/query_counter.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# From http://stackoverflow.com/a/13423584/153896
2+
module ActiveRecord
3+
class QueryCounter
4+
attr_reader :query_count
5+
6+
def initialize
7+
@query_count = 0
8+
end
9+
10+
def to_proc
11+
lambda(&method(:callback))
12+
end
13+
14+
def callback(name, start, finish, message_id, values)
15+
@query_count += 1 unless %w(CACHE SCHEMA).include?(values[:name])
16+
end
17+
end
18+
end

0 commit comments

Comments
 (0)