Skip to content

Commit dedefdd

Browse files
authored
Merge pull request #43137 from composerinteralia/preload-already-loaded
Use loaded records where possible when preloading
2 parents 43b9078 + 605acb9 commit dedefdd

File tree

4 files changed

+166
-59
lines changed

4 files changed

+166
-59
lines changed

activerecord/lib/active_record/associations/preloader/association.rb

Lines changed: 66 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,7 @@ def hash
2323
end
2424

2525
def records_for(loaders)
26-
ids = loaders.flat_map(&:owner_keys).uniq
27-
28-
scope.where(association_key_name => ids).load do |record|
29-
loaders.each { |l| l.set_inverse(record) }
30-
end
26+
LoaderRecords.new(loaders, self).records
3127
end
3228

3329
def load_records_in_batch(loaders)
@@ -38,6 +34,52 @@ def load_records_in_batch(loaders)
3834
loader.run
3935
end
4036
end
37+
38+
def load_records_for_keys(keys, &block)
39+
scope.where(association_key_name => keys).load(&block)
40+
end
41+
end
42+
43+
class LoaderRecords
44+
def initialize(loaders, loader_query)
45+
@loader_query = loader_query
46+
@loaders = loaders
47+
@keys_to_load = Set.new
48+
@already_loaded_records_by_key = {}
49+
50+
populate_keys_to_load_and_already_loaded_records
51+
end
52+
53+
def records
54+
load_records + already_loaded_records
55+
end
56+
57+
private
58+
attr_reader :loader_query, :loaders, :keys_to_load, :already_loaded_records_by_key
59+
60+
def populate_keys_to_load_and_already_loaded_records
61+
loaders.each do |loader|
62+
loader.owners_by_key.each do |key, owners|
63+
if loaded_owner = owners.find { |owner| loader.loaded?(owner) }
64+
already_loaded_records_by_key[key] = loader.target_for(loaded_owner)
65+
else
66+
keys_to_load << key
67+
end
68+
end
69+
end
70+
71+
@keys_to_load.subtract(already_loaded_records_by_key.keys)
72+
end
73+
74+
def load_records
75+
loader_query.load_records_for_keys(keys_to_load) do |record|
76+
loaders.each { |l| l.set_inverse(record) }
77+
end
78+
end
79+
80+
def already_loaded_records
81+
already_loaded_records_by_key.values.flatten
82+
end
4183
end
4284

4385
attr_reader :klass
@@ -57,12 +99,8 @@ def table_name
5799
@klass.table_name
58100
end
59101

60-
def data_available?
61-
already_loaded?
62-
end
63-
64102
def future_classes
65-
if run? || already_loaded?
103+
if run?
66104
[]
67105
else
68106
[@klass]
@@ -81,11 +119,6 @@ def run
81119
return self if run?
82120
@run = true
83121

84-
if already_loaded?
85-
fetch_from_preloaded_records
86-
return self
87-
end
88-
89122
records = records_by_owner
90123

91124
owners.each do |owner|
@@ -96,25 +129,17 @@ def run
96129
end
97130

98131
def records_by_owner
99-
ensure_loaded unless defined?(@records_by_owner)
132+
load_records unless defined?(@records_by_owner)
100133

101134
@records_by_owner
102135
end
103136

104137
def preloaded_records
105-
ensure_loaded unless defined?(@preloaded_records)
138+
load_records unless defined?(@preloaded_records)
106139

107140
@preloaded_records
108141
end
109142

110-
def ensure_loaded
111-
if already_loaded?
112-
fetch_from_preloaded_records
113-
else
114-
load_records
115-
end
116-
end
117-
118143
# The name of the key on the associated records
119144
def association_key_name
120145
reflection.join_primary_key(klass)
@@ -124,8 +149,19 @@ def loader_query
124149
LoaderQuery.new(scope, association_key_name)
125150
end
126151

127-
def owner_keys
128-
@owner_keys ||= owners_by_key.keys
152+
def owners_by_key
153+
@owners_by_key ||= owners.each_with_object({}) do |owner, result|
154+
key = convert_key(owner[owner_key_name])
155+
(result[key] ||= []) << owner if key
156+
end
157+
end
158+
159+
def loaded?(owner)
160+
owner.association(reflection.name).loaded?
161+
end
162+
163+
def target_for(owner)
164+
Array.wrap(owner.association(reflection.name).target)
129165
end
130166

131167
def scope
@@ -185,39 +221,23 @@ def associate_records_from_unscoped(unscoped_records)
185221
private
186222
attr_reader :owners, :reflection, :preload_scope, :model
187223

188-
def already_loaded?
189-
@already_loaded ||= owners.all? { |o| o.association(reflection.name).loaded? }
190-
end
191-
192-
def fetch_from_preloaded_records
193-
@records_by_owner = owners.index_with do |owner|
194-
Array(owner.association(reflection.name).target)
195-
end
196-
197-
@preloaded_records = records_by_owner.flat_map(&:last)
198-
end
199-
200224
# The name of the key on the model which declares the association
201225
def owner_key_name
202226
reflection.join_foreign_key
203227
end
204228

205229
def associate_records_to_owner(owner, records)
230+
return if loaded?(owner)
231+
206232
association = owner.association(reflection.name)
233+
207234
if reflection.collection?
208235
association.target = records
209236
else
210237
association.target = records.first
211238
end
212239
end
213240

214-
def owners_by_key
215-
@owners_by_key ||= owners.each_with_object({}) do |owner, result|
216-
key = convert_key(owner[owner_key_name])
217-
(result[key] ||= []) << owner if key
218-
end
219-
end
220-
221241
def key_conversion_required?
222242
unless defined?(@key_conversion_required)
223243
@key_conversion_required = (association_key_type != owner_key_type)

activerecord/lib/active_record/associations/preloader/batch.rb

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,7 @@ def call
1616

1717
loaders.each { |loader| loader.associate_records_from_unscoped(@available_records[loader.klass]) }
1818

19-
already_loaded = loaders.select(&:data_available?)
20-
if already_loaded.any?
21-
already_loaded.each(&:run)
22-
elsif loaders.any?
19+
if loaders.any?
2320
future_tables = branches.flat_map do |branch|
2421
branch.future_classes - branch.runnable_loaders.map(&:klass)
2522
end.map(&:table_name).uniq

activerecord/lib/active_record/associations/preloader/through_association.rb

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ def preloaded_records
1010

1111
def records_by_owner
1212
return @records_by_owner if defined?(@records_by_owner)
13-
source_records_by_owner = source_preloaders.map(&:records_by_owner).reduce(:merge)
14-
through_records_by_owner = through_preloaders.map(&:records_by_owner).reduce(:merge)
1513

1614
@records_by_owner = owners.each_with_object({}) do |owner, result|
15+
if loaded?(owner)
16+
result[owner] = target_for(owner)
17+
next
18+
end
19+
1720
through_records = through_records_by_owner[owner] || []
1821

1922
if owners.first.association(through_reflection.name).loaded?
@@ -35,12 +38,6 @@ def records_by_owner
3538
end
3639
end
3740

38-
def data_available?
39-
return true if super()
40-
through_preloaders.all?(&:run?) &&
41-
source_preloaders.all?(&:run?)
42-
end
43-
4441
def runnable_loaders
4542
if data_available?
4643
[self]
@@ -52,7 +49,7 @@ def runnable_loaders
5249
end
5350

5451
def future_classes
55-
if run? || data_available?
52+
if run?
5653
[]
5754
elsif through_preloaders.all?(&:run?)
5855
source_preloaders.flat_map(&:future_classes).uniq
@@ -67,6 +64,11 @@ def future_classes
6764
end
6865

6966
private
67+
def data_available?
68+
owners.all? { |owner| loaded?(owner) } ||
69+
through_preloaders.all?(&:run?) && source_preloaders.all?(&:run?)
70+
end
71+
7072
def source_preloaders
7173
@source_preloaders ||= ActiveRecord::Associations::Preloader.new(records: middle_records, associations: source_reflection.name, scope: scope, associate_by_default: false).loaders
7274
end
@@ -87,6 +89,14 @@ def source_reflection
8789
reflection.source_reflection
8890
end
8991

92+
def source_records_by_owner
93+
@source_records_by_owner ||= source_preloaders.map(&:records_by_owner).reduce(:merge)
94+
end
95+
96+
def through_records_by_owner
97+
@through_records_by_owner ||= through_preloaders.map(&:records_by_owner).reduce(:merge)
98+
end
99+
90100
def preload_index
91101
@preload_index ||= preloaded_records.each_with_object({}).with_index do |(record, result), index|
92102
result[record] = index

activerecord/test/cases/associations_test.rb

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,18 @@ def test_preload_groups_queries_with_same_scope
443443
end
444444
end
445445

446+
def test_preload_grouped_queries_with_already_loaded_records
447+
book = books(:awdr)
448+
post = posts(:welcome)
449+
book.author
450+
451+
assert_no_queries do
452+
ActiveRecord::Associations::Preloader.new(records: [book, post], associations: :author).call
453+
book.author
454+
post.author
455+
end
456+
end
457+
446458
def test_preload_grouped_queries_of_middle_records
447459
comments = [
448460
comments(:eager_sti_on_associations_s_comment1),
@@ -792,6 +804,41 @@ def test_preload_with_available_records
792804
end
793805
end
794806

807+
def test_preload_with_only_some_records_available
808+
bob_post = posts(:misc_by_bob)
809+
mary_post = posts(:misc_by_mary)
810+
bob = authors(:bob)
811+
mary = authors(:mary)
812+
813+
assert_queries(1) do
814+
ActiveRecord::Associations::Preloader.new(records: [bob_post, mary_post], associations: :author, available_records: [bob]).call
815+
end
816+
817+
assert_no_queries do
818+
assert_same bob, bob_post.author
819+
assert_equal mary, mary_post.author
820+
end
821+
end
822+
823+
def test_preload_with_some_records_already_loaded
824+
bob_post = posts(:misc_by_bob)
825+
mary_post = posts(:misc_by_mary)
826+
bob = bob_post.author
827+
mary = authors(:mary)
828+
829+
assert bob_post.association(:author).loaded?
830+
assert_not mary_post.association(:author).loaded?
831+
832+
assert_queries(1) do
833+
ActiveRecord::Associations::Preloader.new(records: [bob_post, mary_post], associations: :author).call
834+
end
835+
836+
assert_no_queries do
837+
assert_same bob, bob_post.author
838+
assert_equal mary, mary_post.author
839+
end
840+
end
841+
795842
def test_preload_with_available_records_with_through_association
796843
author = authors(:david)
797844
categories = Category.all.to_a
@@ -805,6 +852,25 @@ def test_preload_with_available_records_with_through_association
805852
assert categories.map(&:object_id).include?(author.essay_category.object_id)
806853
end
807854

855+
def test_preload_with_only_some_records_available_with_through_associations
856+
mary = authors(:mary)
857+
mary_essay = essays(:mary_stay_home)
858+
mary_category = categories(:technology)
859+
mary_essay.update!(category: mary_category)
860+
861+
dave = authors(:david)
862+
dave_category = categories(:general)
863+
864+
assert_queries(2) do
865+
ActiveRecord::Associations::Preloader.new(records: [mary, dave], associations: :essay_category, available_records: [mary_category]).call
866+
end
867+
868+
assert_no_queries do
869+
assert_same mary_category, mary.essay_category
870+
assert_equal dave_category, dave.essay_category
871+
end
872+
end
873+
808874
def test_preload_with_available_records_with_multiple_classes
809875
essay = essays(:david_modest_proposal)
810876
general = categories(:general)
@@ -858,6 +924,20 @@ def test_preload_with_available_records_queries_when_incomplete
858924
assert_equal david, post.author
859925
end
860926
end
927+
928+
def test_preload_with_unpersisted_records_no_ops
929+
author = Author.new
930+
new_post_with_author = Post.new(author: author)
931+
new_post_without_author = Post.new
932+
posts = [new_post_with_author, new_post_without_author]
933+
934+
assert_no_queries do
935+
ActiveRecord::Associations::Preloader.new(records: posts, associations: :author).call
936+
937+
assert_same author, new_post_with_author.author
938+
assert_nil new_post_without_author.author
939+
end
940+
end
861941
end
862942

863943
class GeneratedMethodsTest < ActiveRecord::TestCase

0 commit comments

Comments
 (0)