Skip to content

Commit 3dff30c

Browse files
authored
Merge pull request rails#38541 from kddeisz/strict-loading-associations
Support strict_loading on association declarations
2 parents 30207c5 + b8ae104 commit 3dff30c

File tree

8 files changed

+140
-2
lines changed

8 files changed

+140
-2
lines changed

activerecord/CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,21 @@
1+
* Add support for `strict_loading` mode on association declarations.
2+
3+
Raise an error if attempting to load a record from an association that has been marked as `strict_loading` unless it was explicitly eager loaded.
4+
5+
Usage:
6+
7+
```
8+
>> class Developer < ApplicationRecord
9+
>> has_many :projects, strict_loading: true
10+
>> end
11+
>>
12+
>> dev = Developer.first
13+
>> dev.projects.first
14+
=> ActiveRecord::StrictLoadingViolationError: The projects association is marked as strict_loading and cannot be lazily loaded.
15+
```
16+
17+
*Kevin Deisz*
18+
119
* Add support for `strict_loading` mode to prevent lazy loading of records.
220
321
Raise an error if a parent record is marked as `strict_loading` and attempts to lazily load its associations. This is useful for finding places you may want to preload an association and avoid additional queries.

activerecord/lib/active_record/associations.rb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1355,6 +1355,8 @@ module ClassMethods
13551355
# Specifies a module or array of modules that will be extended into the association object returned.
13561356
# Useful for defining methods on associations, especially when they should be shared between multiple
13571357
# association objects.
1358+
# [:strict_loading]
1359+
# Enforces strict loading every time the associated record is loaded through this association.
13581360
#
13591361
# Option examples:
13601362
# has_many :comments, -> { order("posted_on") }
@@ -1365,6 +1367,7 @@ module ClassMethods
13651367
# has_many :tags, as: :taggable
13661368
# has_many :reports, -> { readonly }
13671369
# has_many :subscribers, through: :subscriptions, source: :user
1370+
# has_many :comments, strict_loading: true
13681371
def has_many(name, scope = nil, **options, &extension)
13691372
reflection = Builder::HasMany.build(self, name, scope, options, &extension)
13701373
Reflection.add_reflection self, name, reflection
@@ -1494,6 +1497,8 @@ def has_many(name, scope = nil, **options, &extension)
14941497
# When set to +true+, the association will also have its presence validated.
14951498
# This will validate the association itself, not the id. You can use
14961499
# +:inverse_of+ to avoid an extra query during validation.
1500+
# [:strict_loading]
1501+
# Enforces strict loading every time the associated record is loaded through this association.
14971502
#
14981503
# Option examples:
14991504
# has_one :credit_card, dependent: :destroy # destroys the associated credit card
@@ -1506,6 +1511,7 @@ def has_many(name, scope = nil, **options, &extension)
15061511
# has_one :club, through: :membership
15071512
# has_one :primary_address, -> { where(primary: true) }, through: :addressables, source: :addressable
15081513
# has_one :credit_card, required: true
1514+
# has_one :credit_card, strict_loading: true
15091515
def has_one(name, scope = nil, **options)
15101516
reflection = Builder::HasOne.build(self, name, scope, options)
15111517
Reflection.add_reflection self, name, reflection
@@ -1639,6 +1645,8 @@ def has_one(name, scope = nil, **options)
16391645
# [:default]
16401646
# Provide a callable (i.e. proc or lambda) to specify that the association should
16411647
# be initialized with a particular record before validation.
1648+
# [:strict_loading]
1649+
# Enforces strict loading every time the associated record is loaded through this association.
16421650
#
16431651
# Option examples:
16441652
# belongs_to :firm, foreign_key: "client_of"
@@ -1653,6 +1661,7 @@ def has_one(name, scope = nil, **options)
16531661
# belongs_to :company, touch: :employees_last_updated_at
16541662
# belongs_to :user, optional: true
16551663
# belongs_to :account, default: -> { company.account }
1664+
# belongs_to :account, strict_loading: true
16561665
def belongs_to(name, scope = nil, **options)
16571666
reflection = Builder::BelongsTo.build(self, name, scope, options)
16581667
Reflection.add_reflection self, name, reflection
@@ -1815,13 +1824,16 @@ def belongs_to(name, scope = nil, **options)
18151824
#
18161825
# Note that NestedAttributes::ClassMethods#accepts_nested_attributes_for sets
18171826
# <tt>:autosave</tt> to <tt>true</tt>.
1827+
# [:strict_loading]
1828+
# Enforces strict loading every time an associated record is loaded through this association.
18181829
#
18191830
# Option examples:
18201831
# has_and_belongs_to_many :projects
18211832
# has_and_belongs_to_many :projects, -> { includes(:milestones, :manager) }
18221833
# has_and_belongs_to_many :nations, class_name: "Country"
18231834
# has_and_belongs_to_many :categories, join_table: "prods_cats"
18241835
# has_and_belongs_to_many :categories, -> { readonly }
1836+
# has_and_belongs_to_many :categories, strict_loading: true
18251837
def has_and_belongs_to_many(name, scope = nil, **options, &extension)
18261838
habtm_reflection = ActiveRecord::Reflection::HasAndBelongsToManyReflection.new(name, scope, options, self)
18271839

@@ -1852,7 +1864,7 @@ def destroy_associations
18521864
hm_options[:through] = middle_reflection.name
18531865
hm_options[:source] = join_model.right_reflection.name
18541866

1855-
[:before_add, :after_add, :before_remove, :after_remove, :autosave, :validate, :join_table, :class_name, :extend].each do |k|
1867+
[:before_add, :after_add, :before_remove, :after_remove, :autosave, :validate, :join_table, :class_name, :extend, :strict_loading].each do |k|
18561868
hm_options[k] = options[k] if options.key? k
18571869
end
18581870

activerecord/lib/active_record/associations/association.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,10 @@ def find_target
211211
raise StrictLoadingViolationError, "#{owner.class} is marked as strict_loading and #{klass} cannot be lazily loaded."
212212
end
213213

214+
if reflection.strict_loading?
215+
raise StrictLoadingViolationError, "The #{reflection.name} association is marked as strict_loading and cannot be lazily loaded."
216+
end
217+
214218
scope = self.scope
215219
return scope.to_a if skip_statement_cache?(scope)
216220

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class << self
1919
self.extensions = []
2020

2121
VALID_OPTIONS = [
22-
:class_name, :anonymous_class, :primary_key, :foreign_key, :dependent, :validate, :inverse_of
22+
:class_name, :anonymous_class, :primary_key, :foreign_key, :dependent, :validate, :inverse_of, :strict_loading
2323
].freeze # :nodoc:
2424

2525
def self.build(model, name, scope, options, &block)

activerecord/lib/active_record/associations/collection_association.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ def null_scope?
309309
def find_from_target?
310310
loaded? ||
311311
owner.strict_loading? ||
312+
reflection.strict_loading? ||
312313
owner.new_record? ||
313314
target.any? { |record| record.new_record? || record.changed? }
314315
end

activerecord/lib/active_record/reflection.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,10 @@ def join_foreign_key
306306
active_record_primary_key
307307
end
308308

309+
def strict_loading?
310+
options[:strict_loading]
311+
end
312+
309313
protected
310314
def actual_source_reflection # FIXME: this is a horrible name
311315
self

activerecord/test/cases/strict_loading_test.rb

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@
33
require "cases/helper"
44
require "models/developer"
55
require "models/computer"
6+
require "models/mentor"
7+
require "models/project"
8+
require "models/ship"
69

710
class StrictLoadingTest < ActiveRecord::TestCase
811
fixtures :developers
12+
fixtures :projects
13+
fixtures :ships
914

1015
def test_strict_loading
1116
Developer.all.each { |d| assert_not d.strict_loading? }
@@ -67,4 +72,88 @@ def test_raises_on_unloaded_relation_methods_if_strict_loading
6772
dev.audit_logs.first
6873
end
6974
end
75+
76+
def test_raises_on_lazy_loading_a_strict_loading_belongs_to_relation
77+
mentor = Mentor.create!(name: "Mentor")
78+
79+
developer = Developer.first
80+
developer.update_column(:mentor_id, mentor.id)
81+
82+
assert_raises ActiveRecord::StrictLoadingViolationError do
83+
developer.strict_loading_mentor
84+
end
85+
end
86+
87+
def test_does_not_raise_on_eager_loading_a_strict_loading_belongs_to_relation
88+
mentor = Mentor.create!(name: "Mentor")
89+
90+
Developer.first.update_column(:mentor_id, mentor.id)
91+
developer = Developer.includes(:strict_loading_mentor).first
92+
93+
assert_nothing_raised { developer.strict_loading_mentor }
94+
end
95+
96+
def test_raises_on_lazy_loading_a_strict_loading_has_one_relation
97+
developer = Developer.first
98+
ship = Ship.first
99+
100+
ship.update_column(:developer_id, developer.id)
101+
102+
assert_raises ActiveRecord::StrictLoadingViolationError do
103+
developer.strict_loading_ship
104+
end
105+
end
106+
107+
def test_does_not_raise_on_eager_loading_a_strict_loading_has_one_relation
108+
Ship.first.update_column(:developer_id, Developer.first.id)
109+
developer = Developer.includes(:strict_loading_ship).first
110+
111+
assert_nothing_raised { developer.strict_loading_ship }
112+
end
113+
114+
def test_raises_on_lazy_loading_a_strict_loading_has_many_relation
115+
developer = Developer.first
116+
117+
AuditLog.create(
118+
3.times.map do
119+
{ developer_id: developer.id, message: "I am message" }
120+
end
121+
)
122+
123+
assert_raises ActiveRecord::StrictLoadingViolationError do
124+
developer.strict_loading_opt_audit_logs.first
125+
end
126+
end
127+
128+
def test_does_not_raise_on_eager_loading_a_strict_loading_has_many_relation
129+
developer = Developer.first
130+
131+
AuditLog.create(
132+
3.times.map do
133+
{ developer_id: developer.id, message: "I am message" }
134+
end
135+
)
136+
137+
developer = Developer.includes(:strict_loading_opt_audit_logs).first
138+
139+
assert_nothing_raised { developer.strict_loading_opt_audit_logs.first }
140+
end
141+
142+
def test_raises_on_lazy_loading_a_strict_loading_habtm_relation
143+
developer = Developer.first
144+
developer.projects << Project.first
145+
146+
assert_not developer.strict_loading_projects.loaded?
147+
148+
assert_raises ActiveRecord::StrictLoadingViolationError do
149+
developer.strict_loading_projects.first
150+
end
151+
end
152+
153+
def test_does_not_raise_on_eager_loading_a_strict_loading_habtm_relation
154+
Developer.first.projects << Project.first
155+
developer = Developer.includes(:strict_loading_projects).first
156+
157+
assert_nothing_raised { developer.strict_loading_projects.first }
158+
end
70159
end

activerecord/test/models/developer.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def find_most_recent
1818
end
1919

2020
belongs_to :mentor
21+
belongs_to :strict_loading_mentor, strict_loading: true, foreign_key: :mentor_id, class_name: "Mentor"
2122

2223
accepts_nested_attributes_for :projects
2324

@@ -45,6 +46,12 @@ def find_least_recent
4546
end
4647
end
4748

49+
has_and_belongs_to_many :strict_loading_projects,
50+
join_table: :developers_projects,
51+
association_foreign_key: :project_id,
52+
class_name: "Project",
53+
strict_loading: true
54+
4855
has_and_belongs_to_many :special_projects, join_table: "developers_projects", association_foreign_key: "project_id"
4956
has_and_belongs_to_many :sym_special_projects,
5057
join_table: :developers_projects,
@@ -53,11 +60,14 @@ def find_least_recent
5360

5461
has_many :audit_logs
5562
has_many :strict_loading_audit_logs, -> { strict_loading }, class_name: "AuditLog"
63+
has_many :strict_loading_opt_audit_logs, strict_loading: true, class_name: "AuditLog"
5664
has_many :contracts
5765
has_many :firms, through: :contracts, source: :firm
5866
has_many :comments, ->(developer) { where(body: "I'm #{developer.name}") }
5967
has_many :ratings, through: :comments
68+
6069
has_one :ship, dependent: :nullify
70+
has_one :strict_loading_ship, strict_loading: true, class_name: "Ship"
6171

6272
belongs_to :firm
6373
has_many :contracted_projects, class_name: "Project"

0 commit comments

Comments
 (0)