1
0
Fork 0
mirror of https://github.com/rails/rails.git synced 2022-11-09 12:12:34 -05:00

Support strict_loading on association declarations

This commit is contained in:
Kevin Deisz 2020-02-21 13:11:24 -05:00
parent 1e7f867a7a
commit b8ae104318
No known key found for this signature in database
GPG key ID: D78C2D8FB232C59C
8 changed files with 140 additions and 2 deletions

View file

@ -1,3 +1,21 @@
* Add support for `strict_loading` mode on association declarations.
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.
Usage:
```
>> class Developer < ApplicationRecord
>> has_many :projects, strict_loading: true
>> end
>>
>> dev = Developer.first
>> dev.projects.first
=> ActiveRecord::StrictLoadingViolationError: The projects association is marked as strict_loading and cannot be lazily loaded.
```
*Kevin Deisz*
* Add support for `strict_loading` mode to prevent lazy loading of records. * Add support for `strict_loading` mode to prevent lazy loading of records.
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. 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.

View file

@ -1355,6 +1355,8 @@ module ActiveRecord
# Specifies a module or array of modules that will be extended into the association object returned. # Specifies a module or array of modules that will be extended into the association object returned.
# Useful for defining methods on associations, especially when they should be shared between multiple # Useful for defining methods on associations, especially when they should be shared between multiple
# association objects. # association objects.
# [:strict_loading]
# Enforces strict loading every time the associated record is loaded through this association.
# #
# Option examples: # Option examples:
# has_many :comments, -> { order("posted_on") } # has_many :comments, -> { order("posted_on") }
@ -1365,6 +1367,7 @@ module ActiveRecord
# has_many :tags, as: :taggable # has_many :tags, as: :taggable
# has_many :reports, -> { readonly } # has_many :reports, -> { readonly }
# has_many :subscribers, through: :subscriptions, source: :user # has_many :subscribers, through: :subscriptions, source: :user
# has_many :comments, strict_loading: true
def has_many(name, scope = nil, **options, &extension) def has_many(name, scope = nil, **options, &extension)
reflection = Builder::HasMany.build(self, name, scope, options, &extension) reflection = Builder::HasMany.build(self, name, scope, options, &extension)
Reflection.add_reflection self, name, reflection Reflection.add_reflection self, name, reflection
@ -1494,6 +1497,8 @@ module ActiveRecord
# When set to +true+, the association will also have its presence validated. # When set to +true+, the association will also have its presence validated.
# This will validate the association itself, not the id. You can use # This will validate the association itself, not the id. You can use
# +:inverse_of+ to avoid an extra query during validation. # +:inverse_of+ to avoid an extra query during validation.
# [:strict_loading]
# Enforces strict loading every time the associated record is loaded through this association.
# #
# Option examples: # Option examples:
# has_one :credit_card, dependent: :destroy # destroys the associated credit card # has_one :credit_card, dependent: :destroy # destroys the associated credit card
@ -1506,6 +1511,7 @@ module ActiveRecord
# has_one :club, through: :membership # has_one :club, through: :membership
# has_one :primary_address, -> { where(primary: true) }, through: :addressables, source: :addressable # has_one :primary_address, -> { where(primary: true) }, through: :addressables, source: :addressable
# has_one :credit_card, required: true # has_one :credit_card, required: true
# has_one :credit_card, strict_loading: true
def has_one(name, scope = nil, **options) def has_one(name, scope = nil, **options)
reflection = Builder::HasOne.build(self, name, scope, options) reflection = Builder::HasOne.build(self, name, scope, options)
Reflection.add_reflection self, name, reflection Reflection.add_reflection self, name, reflection
@ -1639,6 +1645,8 @@ module ActiveRecord
# [:default] # [:default]
# Provide a callable (i.e. proc or lambda) to specify that the association should # Provide a callable (i.e. proc or lambda) to specify that the association should
# be initialized with a particular record before validation. # be initialized with a particular record before validation.
# [:strict_loading]
# Enforces strict loading every time the associated record is loaded through this association.
# #
# Option examples: # Option examples:
# belongs_to :firm, foreign_key: "client_of" # belongs_to :firm, foreign_key: "client_of"
@ -1653,6 +1661,7 @@ module ActiveRecord
# belongs_to :company, touch: :employees_last_updated_at # belongs_to :company, touch: :employees_last_updated_at
# belongs_to :user, optional: true # belongs_to :user, optional: true
# belongs_to :account, default: -> { company.account } # belongs_to :account, default: -> { company.account }
# belongs_to :account, strict_loading: true
def belongs_to(name, scope = nil, **options) def belongs_to(name, scope = nil, **options)
reflection = Builder::BelongsTo.build(self, name, scope, options) reflection = Builder::BelongsTo.build(self, name, scope, options)
Reflection.add_reflection self, name, reflection Reflection.add_reflection self, name, reflection
@ -1815,6 +1824,8 @@ module ActiveRecord
# #
# Note that NestedAttributes::ClassMethods#accepts_nested_attributes_for sets # Note that NestedAttributes::ClassMethods#accepts_nested_attributes_for sets
# <tt>:autosave</tt> to <tt>true</tt>. # <tt>:autosave</tt> to <tt>true</tt>.
# [:strict_loading]
# Enforces strict loading every time an associated record is loaded through this association.
# #
# Option examples: # Option examples:
# has_and_belongs_to_many :projects # has_and_belongs_to_many :projects
@ -1822,6 +1833,7 @@ module ActiveRecord
# has_and_belongs_to_many :nations, class_name: "Country" # has_and_belongs_to_many :nations, class_name: "Country"
# has_and_belongs_to_many :categories, join_table: "prods_cats" # has_and_belongs_to_many :categories, join_table: "prods_cats"
# has_and_belongs_to_many :categories, -> { readonly } # has_and_belongs_to_many :categories, -> { readonly }
# has_and_belongs_to_many :categories, strict_loading: true
def has_and_belongs_to_many(name, scope = nil, **options, &extension) def has_and_belongs_to_many(name, scope = nil, **options, &extension)
habtm_reflection = ActiveRecord::Reflection::HasAndBelongsToManyReflection.new(name, scope, options, self) habtm_reflection = ActiveRecord::Reflection::HasAndBelongsToManyReflection.new(name, scope, options, self)
@ -1852,7 +1864,7 @@ module ActiveRecord
hm_options[:through] = middle_reflection.name hm_options[:through] = middle_reflection.name
hm_options[:source] = join_model.right_reflection.name hm_options[:source] = join_model.right_reflection.name
[:before_add, :after_add, :before_remove, :after_remove, :autosave, :validate, :join_table, :class_name, :extend].each do |k| [:before_add, :after_add, :before_remove, :after_remove, :autosave, :validate, :join_table, :class_name, :extend, :strict_loading].each do |k|
hm_options[k] = options[k] if options.key? k hm_options[k] = options[k] if options.key? k
end end

View file

@ -211,6 +211,10 @@ module ActiveRecord
raise StrictLoadingViolationError, "#{owner.class} is marked as strict_loading and #{klass} cannot be lazily loaded." raise StrictLoadingViolationError, "#{owner.class} is marked as strict_loading and #{klass} cannot be lazily loaded."
end end
if reflection.strict_loading?
raise StrictLoadingViolationError, "The #{reflection.name} association is marked as strict_loading and cannot be lazily loaded."
end
scope = self.scope scope = self.scope
return scope.to_a if skip_statement_cache?(scope) return scope.to_a if skip_statement_cache?(scope)

View file

@ -19,7 +19,7 @@ module ActiveRecord::Associations::Builder # :nodoc:
self.extensions = [] self.extensions = []
VALID_OPTIONS = [ VALID_OPTIONS = [
:class_name, :anonymous_class, :primary_key, :foreign_key, :dependent, :validate, :inverse_of :class_name, :anonymous_class, :primary_key, :foreign_key, :dependent, :validate, :inverse_of, :strict_loading
].freeze # :nodoc: ].freeze # :nodoc:
def self.build(model, name, scope, options, &block) def self.build(model, name, scope, options, &block)

View file

@ -309,6 +309,7 @@ module ActiveRecord
def find_from_target? def find_from_target?
loaded? || loaded? ||
owner.strict_loading? || owner.strict_loading? ||
reflection.strict_loading? ||
owner.new_record? || owner.new_record? ||
target.any? { |record| record.new_record? || record.changed? } target.any? { |record| record.new_record? || record.changed? }
end end

View file

@ -306,6 +306,10 @@ module ActiveRecord
active_record_primary_key active_record_primary_key
end end
def strict_loading?
options[:strict_loading]
end
protected protected
def actual_source_reflection # FIXME: this is a horrible name def actual_source_reflection # FIXME: this is a horrible name
self self

View file

@ -3,9 +3,14 @@
require "cases/helper" require "cases/helper"
require "models/developer" require "models/developer"
require "models/computer" require "models/computer"
require "models/mentor"
require "models/project"
require "models/ship"
class StrictLoadingTest < ActiveRecord::TestCase class StrictLoadingTest < ActiveRecord::TestCase
fixtures :developers fixtures :developers
fixtures :projects
fixtures :ships
def test_strict_loading def test_strict_loading
Developer.all.each { |d| assert_not d.strict_loading? } Developer.all.each { |d| assert_not d.strict_loading? }
@ -67,4 +72,88 @@ class StrictLoadingTest < ActiveRecord::TestCase
dev.audit_logs.first dev.audit_logs.first
end end
end end
def test_raises_on_lazy_loading_a_strict_loading_belongs_to_relation
mentor = Mentor.create!(name: "Mentor")
developer = Developer.first
developer.update_column(:mentor_id, mentor.id)
assert_raises ActiveRecord::StrictLoadingViolationError do
developer.strict_loading_mentor
end
end
def test_does_not_raise_on_eager_loading_a_strict_loading_belongs_to_relation
mentor = Mentor.create!(name: "Mentor")
Developer.first.update_column(:mentor_id, mentor.id)
developer = Developer.includes(:strict_loading_mentor).first
assert_nothing_raised { developer.strict_loading_mentor }
end
def test_raises_on_lazy_loading_a_strict_loading_has_one_relation
developer = Developer.first
ship = Ship.first
ship.update_column(:developer_id, developer.id)
assert_raises ActiveRecord::StrictLoadingViolationError do
developer.strict_loading_ship
end
end
def test_does_not_raise_on_eager_loading_a_strict_loading_has_one_relation
Ship.first.update_column(:developer_id, Developer.first.id)
developer = Developer.includes(:strict_loading_ship).first
assert_nothing_raised { developer.strict_loading_ship }
end
def test_raises_on_lazy_loading_a_strict_loading_has_many_relation
developer = Developer.first
AuditLog.create(
3.times.map do
{ developer_id: developer.id, message: "I am message" }
end
)
assert_raises ActiveRecord::StrictLoadingViolationError do
developer.strict_loading_opt_audit_logs.first
end
end
def test_does_not_raise_on_eager_loading_a_strict_loading_has_many_relation
developer = Developer.first
AuditLog.create(
3.times.map do
{ developer_id: developer.id, message: "I am message" }
end
)
developer = Developer.includes(:strict_loading_opt_audit_logs).first
assert_nothing_raised { developer.strict_loading_opt_audit_logs.first }
end
def test_raises_on_lazy_loading_a_strict_loading_habtm_relation
developer = Developer.first
developer.projects << Project.first
assert_not developer.strict_loading_projects.loaded?
assert_raises ActiveRecord::StrictLoadingViolationError do
developer.strict_loading_projects.first
end
end
def test_does_not_raise_on_eager_loading_a_strict_loading_habtm_relation
Developer.first.projects << Project.first
developer = Developer.includes(:strict_loading_projects).first
assert_nothing_raised { developer.strict_loading_projects.first }
end
end end

View file

@ -18,6 +18,7 @@ class Developer < ActiveRecord::Base
end end
belongs_to :mentor belongs_to :mentor
belongs_to :strict_loading_mentor, strict_loading: true, foreign_key: :mentor_id, class_name: "Mentor"
accepts_nested_attributes_for :projects accepts_nested_attributes_for :projects
@ -45,6 +46,12 @@ class Developer < ActiveRecord::Base
end end
end end
has_and_belongs_to_many :strict_loading_projects,
join_table: :developers_projects,
association_foreign_key: :project_id,
class_name: "Project",
strict_loading: true
has_and_belongs_to_many :special_projects, join_table: "developers_projects", association_foreign_key: "project_id" has_and_belongs_to_many :special_projects, join_table: "developers_projects", association_foreign_key: "project_id"
has_and_belongs_to_many :sym_special_projects, has_and_belongs_to_many :sym_special_projects,
join_table: :developers_projects, join_table: :developers_projects,
@ -53,11 +60,14 @@ class Developer < ActiveRecord::Base
has_many :audit_logs has_many :audit_logs
has_many :strict_loading_audit_logs, -> { strict_loading }, class_name: "AuditLog" has_many :strict_loading_audit_logs, -> { strict_loading }, class_name: "AuditLog"
has_many :strict_loading_opt_audit_logs, strict_loading: true, class_name: "AuditLog"
has_many :contracts has_many :contracts
has_many :firms, through: :contracts, source: :firm has_many :firms, through: :contracts, source: :firm
has_many :comments, ->(developer) { where(body: "I'm #{developer.name}") } has_many :comments, ->(developer) { where(body: "I'm #{developer.name}") }
has_many :ratings, through: :comments has_many :ratings, through: :comments
has_one :ship, dependent: :nullify has_one :ship, dependent: :nullify
has_one :strict_loading_ship, strict_loading: true, class_name: "Ship"
belongs_to :firm belongs_to :firm
has_many :contracted_projects, class_name: "Project" has_many :contracted_projects, class_name: "Project"