Skip to content

Commit 7f8eccc

Browse files
authored
Merge pull request rails#42654 from dinahshi/preloader-from-available
Add `available_records` argument to Associations::Preloader
2 parents e70b0a4 + 2a3f175 commit 7f8eccc

File tree

5 files changed

+113
-4
lines changed

5 files changed

+113
-4
lines changed

activerecord/lib/active_record/associations/preloader.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,20 @@ class Preloader #:nodoc:
8787
# [ :books, :author ]
8888
# { author: :avatar }
8989
# [ :books, { author: :avatar } ]
90+
#
91+
# +available_records+ is an array of ActiveRecord::Base. The Preloader
92+
# will try to use the objects in this array to preload the requested
93+
# associations before querying the database. This can save database
94+
# queries by reusing in-memory objects. The optimization is only applied
95+
# to single associations (i.e. :belongs_to, :has_one) with no scopes.
9096
def initialize(associate_by_default: true, **kwargs)
9197
if kwargs.empty?
9298
ActiveSupport::Deprecation.warn("Calling `Preloader#initialize` without arguments is deprecated and will be removed in Rails 7.0.")
9399
else
94100
@records = kwargs[:records]
95101
@associations = kwargs[:associations]
96102
@scope = kwargs[:scope]
103+
@available_records = kwargs[:available_records] || []
97104
@associate_by_default = associate_by_default
98105

99106
@tree = Branch.new(
@@ -112,7 +119,7 @@ def empty?
112119
end
113120

114121
def call
115-
Batch.new([self]).call
122+
Batch.new([self], available_records: @available_records).call
116123

117124
loaders
118125
end

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,25 @@ def load_records(raw_records = nil)
163163
end
164164
end
165165

166+
def associate_records_from_unscoped(unscoped_records)
167+
return if unscoped_records.nil? || unscoped_records.empty?
168+
return if !reflection_scope.empty_scope?
169+
return if preload_scope && !preload_scope.empty_scope?
170+
return if reflection.collection?
171+
172+
unscoped_records.each do |record|
173+
owners = owners_by_key[convert_key(record[association_key_name])]
174+
owners&.each_with_index do |owner, i|
175+
association = owner.association(reflection.name)
176+
association.target = record
177+
178+
if i == 0 # Set inverse on first owner
179+
association.set_inverse_instance(record)
180+
end
181+
end
182+
end
183+
end
184+
166185
private
167186
attr_reader :owners, :reflection, :preload_scope, :model
168187

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,18 @@ module ActiveRecord
44
module Associations
55
class Preloader
66
class Batch #:nodoc:
7-
def initialize(preloaders)
7+
def initialize(preloaders, available_records:)
88
@preloaders = preloaders.reject(&:empty?)
9+
@available_records = available_records.flatten.group_by(&:class)
910
end
1011

1112
def call
1213
branches = @preloaders.flat_map(&:branches)
1314
until branches.empty?
1415
loaders = branches.flat_map(&:runnable_loaders)
1516

17+
loaders.each { |loader| loader.associate_records_from_unscoped(@available_records[loader.klass]) }
18+
1619
already_loaded = loaders.select(&:data_available?)
1720
if already_loaded.any?
1821
already_loaded.each(&:run)

activerecord/test/cases/associations_test.rb

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
require "models/discount"
3232
require "models/line_item"
3333
require "models/shipping_line"
34+
require "models/essay"
3435

3536
class AssociationsTest < ActiveRecord::TestCase
3637
fixtures :accounts, :companies, :developers, :projects, :developers_projects,
@@ -384,7 +385,7 @@ def test_associations_raise_with_name_error_if_associated_to_classes_that_do_not
384385
end
385386

386387
class PreloaderTest < ActiveRecord::TestCase
387-
fixtures :posts, :comments, :books, :authors, :tags, :taggings
388+
fixtures :posts, :comments, :books, :authors, :tags, :taggings, :essays, :categories
388389

389390
def test_preload_with_scope
390391
post = posts(:welcome)
@@ -760,6 +761,85 @@ def test_preload_does_not_group_same_scope_different_key_name
760761
postesque.author
761762
end
762763
end
764+
765+
def test_preload_with_available_records
766+
post = posts(:welcome)
767+
david = authors(:david)
768+
769+
assert_no_queries do
770+
ActiveRecord::Associations::Preloader.new(records: [post], associations: :author, available_records: [[david]]).call
771+
772+
assert_predicate post.association(:author), :loaded?
773+
assert_same david, post.author
774+
end
775+
end
776+
777+
def test_preload_with_available_records_with_through_association
778+
author = authors(:david)
779+
categories = Category.all.to_a
780+
781+
assert_queries(1) do
782+
# One query to get the middle records (i.e. essays)
783+
ActiveRecord::Associations::Preloader.new(records: [author], associations: :essay_category, available_records: categories).call
784+
end
785+
786+
assert_predicate author.association(:essay_category), :loaded?
787+
assert categories.map(&:object_id).include?(author.essay_category.object_id)
788+
end
789+
790+
def test_preload_with_available_records_with_multiple_classes
791+
essay = essays(:david_modest_proposal)
792+
general = categories(:general)
793+
david = authors(:david)
794+
795+
assert_no_queries do
796+
ActiveRecord::Associations::Preloader.new(records: [essay], associations: [:category, :author], available_records: [general, david]).call
797+
798+
assert_predicate essay.association(:category), :loaded?
799+
assert_predicate essay.association(:author), :loaded?
800+
assert_same general, essay.category
801+
assert_same david, essay.author
802+
end
803+
end
804+
805+
def test_preload_with_available_records_queries_when_scoped
806+
post = posts(:welcome)
807+
david = authors(:david)
808+
809+
assert_queries(1) do
810+
ActiveRecord::Associations::Preloader.new(records: [post], associations: :author, scope: Author.where(name: "David"), available_records: [david]).call
811+
end
812+
813+
assert_predicate post.association(:author), :loaded?
814+
assert_not_equal david.object_id, post.author.object_id
815+
end
816+
817+
def test_preload_with_available_records_queries_when_collection
818+
post = posts(:welcome)
819+
comments = Comment.all.to_a
820+
821+
assert_queries(1) do
822+
ActiveRecord::Associations::Preloader.new(records: [post], associations: :comments, available_records: comments).call
823+
end
824+
825+
assert_predicate post.association(:comments), :loaded?
826+
assert_empty post.comments.map(&:object_id) & comments.map(&:object_id)
827+
end
828+
829+
def test_preload_with_available_records_queries_when_incomplete
830+
post = posts(:welcome)
831+
bob = authors(:bob)
832+
david = authors(:david)
833+
834+
assert_queries(1) do
835+
ActiveRecord::Associations::Preloader.new(records: [post], associations: :author, available_records: [bob]).call
836+
end
837+
838+
assert_no_queries do
839+
assert_predicate post.association(:author), :loaded?
840+
assert_equal david, post.author
841+
end
842+
end
763843
end
764844

765845
class GeneratedMethodsTest < ActiveRecord::TestCase

activerecord/test/models/essay.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# frozen_string_literal: true
22

33
class Essay < ActiveRecord::Base
4-
belongs_to :author
4+
belongs_to :author, primary_key: :name
55
belongs_to :writer, primary_key: :name, polymorphic: true
66
belongs_to :category, primary_key: :name
77
has_one :owner, primary_key: :name

0 commit comments

Comments
 (0)