Skip to content

Commit 0a8c4dc

Browse files
authored
Merge pull request rails#43358 from composerinteralia/automatic-inverse-of-with-scopes
Automatically infer inverse_of with scopes
2 parents fe43770 + 518b9a3 commit 0a8c4dc

File tree

13 files changed

+202
-16
lines changed

13 files changed

+202
-16
lines changed

activerecord/CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,32 @@
1+
* Allow automatic `inverse_of` detection for associations with scopes.
2+
3+
Automatic `inverse_of` detection now works for associations with scopes. For
4+
example, the `comments` association here now automatically detects
5+
`inverse_of: :post`, so we don't need to pass that option:
6+
7+
```ruby
8+
class Post < ActiveRecord::Base
9+
has_many :comments, -> { visible }
10+
end
11+
12+
class Comment < ActiveRecord::Base
13+
belongs_to :post
14+
end
15+
```
16+
17+
Note that the automatic detection still won't work if the inverse
18+
association has a scope. In this example a scope on the `post` association
19+
would still prevent Rails from finding the inverse for the `comments`
20+
association.
21+
22+
This will be the default for new apps in Rails 7. To opt in:
23+
24+
```ruby
25+
config.active_record.automatic_scope_inversing = true
26+
```
27+
28+
*Daniel Colson*, *Chris Bloom*
29+
130
* Accept optional transaction args to `ActiveRecord::Locking::Pessimistic#with_lock`
231
332
`#with_lock` now accepts transaction options like `requires_new:`,

activerecord/lib/active_record/associations.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -748,9 +748,10 @@ def association_instance_set(name, association)
748748
# inverse detection only works on #has_many, #has_one, and
749749
# #belongs_to associations.
750750
#
751-
# <tt>:foreign_key</tt> and <tt>:through</tt> options on the associations,
752-
# or a custom scope, will also prevent the association's inverse
753-
# from being found automatically.
751+
# <tt>:foreign_key</tt> and <tt>:through</tt> options on the associations
752+
# will also prevent the association's inverse from being found automatically,
753+
# as will a custom scopes in some cases. See further details in the
754+
# {Active Record Associations guide}[https://guides.rubyonrails.org/association_basics.html#bi-directional-associations].
754755
#
755756
# The automatic guessing of the inverse association uses a heuristic based
756757
# on the name of the class, so it may not work for all associations,

activerecord/lib/active_record/reflection.rb

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ module Reflection # :nodoc:
1010
included do
1111
class_attribute :_reflections, instance_writer: false, default: {}
1212
class_attribute :aggregate_reflections, instance_writer: false, default: {}
13+
class_attribute :automatic_scope_inversing, instance_writer: false, default: false
1314
end
1415

1516
class << self
@@ -633,7 +634,7 @@ def valid_inverse_reflection?(reflection)
633634
reflection &&
634635
foreign_key == reflection.foreign_key &&
635636
klass <= reflection.active_record &&
636-
can_find_inverse_of_automatically?(reflection)
637+
can_find_inverse_of_automatically?(reflection, true)
637638
end
638639

639640
# Checks to see if the reflection doesn't have any options that prevent
@@ -642,14 +643,25 @@ def valid_inverse_reflection?(reflection)
642643
# have <tt>has_many</tt>, <tt>has_one</tt>, <tt>belongs_to</tt> associations.
643644
# Third, we must not have options such as <tt>:foreign_key</tt>
644645
# which prevent us from correctly guessing the inverse association.
645-
#
646-
# Anything with a scope can additionally ruin our attempt at finding an
647-
# inverse, so we exclude reflections with scopes.
648-
def can_find_inverse_of_automatically?(reflection)
646+
def can_find_inverse_of_automatically?(reflection, inverse_reflection = false)
649647
reflection.options[:inverse_of] != false &&
650648
!reflection.options[:through] &&
651649
!reflection.options[:foreign_key] &&
650+
scope_allows_automatic_inverse_of?(reflection, inverse_reflection)
651+
end
652+
653+
# Scopes on the potential inverse reflection prevent automatic
654+
# <tt>inverse_of</tt>, since the scope could exclude the owner record
655+
# we would inverse from. Scopes on the reflection itself allow for
656+
# automatic <tt>inverse_of</tt> as long as
657+
# <tt>config.active_record.automatic_scope_inversing<tt> is set to
658+
# +true+ (the default for new applications).
659+
def scope_allows_automatic_inverse_of?(reflection, inverse_reflection)
660+
if inverse_reflection
652661
!reflection.scope
662+
else
663+
!reflection.scope || reflection.klass.automatic_scope_inversing
664+
end
653665
end
654666

655667
def derive_class_name
@@ -736,7 +748,7 @@ def join_foreign_type
736748
end
737749

738750
private
739-
def can_find_inverse_of_automatically?(_)
751+
def can_find_inverse_of_automatically?(*)
740752
!polymorphic? && super
741753
end
742754
end

activerecord/test/cases/associations/inverse_associations_test.rb

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,12 @@
2222
require "models/author"
2323
require "models/user"
2424
require "models/room"
25+
require "models/contract"
26+
require "models/subscription"
27+
require "models/book"
2528

2629
class AutomaticInverseFindingTests < ActiveRecord::TestCase
27-
fixtures :ratings, :comments, :cars
30+
fixtures :ratings, :comments, :cars, :books
2831

2932
def test_has_one_and_belongs_to_should_find_inverse_automatically_on_multiple_word_name
3033
monkey_reflection = MixedCaseMonkey.reflect_on_association(:human)
@@ -109,6 +112,51 @@ def test_has_one_and_belongs_to_with_custom_association_name_should_not_find_wro
109112
assert_not_equal room_reflection, owner_reflection.inverse_of
110113
end
111114

115+
def test_has_many_and_belongs_to_with_a_scope_and_automatic_scope_inversing_should_find_inverse_automatically
116+
contacts_reflection = Company.reflect_on_association(:special_contracts)
117+
company_reflection = SpecialContract.reflect_on_association(:company)
118+
119+
assert contacts_reflection.scope
120+
assert_not company_reflection.scope
121+
122+
with_automatic_scope_inversing(contacts_reflection, company_reflection) do
123+
assert_predicate contacts_reflection, :has_inverse?
124+
assert_equal company_reflection, contacts_reflection.inverse_of
125+
assert_not_equal contacts_reflection, company_reflection.inverse_of
126+
end
127+
end
128+
129+
def test_has_one_and_belongs_to_with_a_scope_and_automatic_scope_inversing_should_find_inverse_automatically
130+
post_reflection = Author.reflect_on_association(:recent_post)
131+
author_reflection = Post.reflect_on_association(:author)
132+
133+
assert post_reflection.scope
134+
assert_not author_reflection.scope
135+
136+
with_automatic_scope_inversing(post_reflection, author_reflection) do
137+
assert_predicate post_reflection, :has_inverse?
138+
assert_equal author_reflection, post_reflection.inverse_of
139+
assert_not_equal post_reflection, author_reflection.inverse_of
140+
end
141+
end
142+
143+
def test_has_many_with_scoped_belongs_to_does_not_find_inverse_automatically
144+
book = books(:tlg)
145+
book.update_attribute(:author_visibility, :invisible)
146+
147+
assert_nil book.subscriptions.new.book
148+
149+
subscription_reflection = Book.reflect_on_association(:subscriptions)
150+
book_reflection = Subscription.reflect_on_association(:book)
151+
152+
assert_not subscription_reflection.scope
153+
assert book_reflection.scope
154+
155+
with_automatic_scope_inversing(book_reflection, subscription_reflection) do
156+
assert_nil book.subscriptions.new.book
157+
end
158+
end
159+
112160
def test_has_one_and_belongs_to_automatic_inverse_shares_objects
113161
car = Car.first
114162
bulb = Bulb.create!(car: car)

activerecord/test/cases/associations/nested_through_associations_test.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ def test_has_many_through_has_many_with_has_many_through_source_reflection_prelo
6868
assert_no_queries do
6969
assert_equal [general, general], author.tags
7070
end
71+
72+
# Preloading with automatic scope inversing reduces the number of queries
73+
tag_reflection = Tagging.reflect_on_association(:tag)
74+
taggings_reflection = Tag.reflect_on_association(:taggings)
75+
76+
assert tag_reflection.scope
77+
assert_not taggings_reflection.scope
78+
79+
with_automatic_scope_inversing(tag_reflection, taggings_reflection) do
80+
assert_queries(4) { Author.includes(:tags).first }
81+
end
7182
end
7283

7384
def test_has_many_through_has_many_with_has_many_through_source_reflection_preload_via_joins
@@ -309,6 +320,17 @@ def test_has_many_through_has_many_through_with_belongs_to_source_reflection_pre
309320
assert_no_queries do
310321
assert_equal [general, general], author.tagging_tags
311322
end
323+
324+
# Preloading with automatic scope inversing reduces the number of queries
325+
tag_reflection = Tagging.reflect_on_association(:tag)
326+
taggings_reflection = Tag.reflect_on_association(:taggings)
327+
328+
assert tag_reflection.scope
329+
assert_not taggings_reflection.scope
330+
331+
with_automatic_scope_inversing(tag_reflection, taggings_reflection) do
332+
assert_queries(4) { Author.includes(:tagging_tags).first }
333+
end
312334
end
313335

314336
def test_has_many_through_has_many_through_with_belongs_to_source_reflection_preload_via_joins

activerecord/test/cases/associations_test.rb

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -702,15 +702,33 @@ def test_preload_can_group_multi_level_ping_pong_through
702702

703703
AuthorFavorite.create!(author: mary, favorite_author: bob)
704704

705+
associations = { similar_posts: :comments, favorite_authors: { similar_posts: :comments } }
706+
705707
assert_queries(9) do
706-
preloader = ActiveRecord::Associations::Preloader.new(records: [mary], associations: { similar_posts: :comments, favorite_authors: { similar_posts: :comments } })
708+
preloader = ActiveRecord::Associations::Preloader.new(records: [mary], associations: associations)
707709
preloader.call
708710
end
709711

710712
assert_no_queries do
711713
mary.similar_posts.map(&:comments).each(&:to_a)
712714
mary.favorite_authors.flat_map(&:similar_posts).map(&:comments).each(&:to_a)
713715
end
716+
717+
# Preloading with automatic scope inversing reduces the number of queries
718+
tag_reflection = Tagging.reflect_on_association(:tag)
719+
taggings_reflection = Tag.reflect_on_association(:taggings)
720+
721+
assert tag_reflection.scope
722+
assert_not taggings_reflection.scope
723+
724+
with_automatic_scope_inversing(tag_reflection, taggings_reflection) do
725+
mary.reload
726+
727+
assert_queries(8) do
728+
preloader = ActiveRecord::Associations::Preloader.new(records: [mary], associations: associations)
729+
preloader.call
730+
end
731+
end
714732
end
715733

716734
def test_preload_does_not_group_same_class_different_scope

activerecord/test/cases/test_case.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,24 @@ def with_has_many_inversing(model = ActiveRecord::Base)
9191
end
9292
end
9393

94+
def with_automatic_scope_inversing(*reflections)
95+
old = reflections.map { |reflection| reflection.klass.automatic_scope_inversing }
96+
97+
reflections.each do |reflection|
98+
reflection.klass.automatic_scope_inversing = true
99+
reflection.remove_instance_variable(:@inverse_name) if reflection.instance_variable_defined?(:@inverse_name)
100+
reflection.remove_instance_variable(:@inverse_of) if reflection.instance_variable_defined?(:@inverse_of)
101+
end
102+
103+
yield
104+
ensure
105+
reflections.each_with_index do |reflection, i|
106+
reflection.klass.automatic_scope_inversing = old[i]
107+
reflection.remove_instance_variable(:@inverse_name) if reflection.instance_variable_defined?(:@inverse_name)
108+
reflection.remove_instance_variable(:@inverse_of) if reflection.instance_variable_defined?(:@inverse_of)
109+
end
110+
end
111+
94112
def reset_callbacks(klass, kind)
95113
old_callbacks = {}
96114
old_callbacks[klass] = klass.send("_#{kind}_callbacks").dup

activerecord/test/models/subscription.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
class Subscription < ActiveRecord::Base
44
belongs_to :subscriber, counter_cache: :books_count
5-
belongs_to :book
5+
belongs_to :book, -> { author_visibility_visible }
66

77
validates_presence_of :subscriber_id, :book_id
88
end

guides/source/association_basics.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -773,10 +773,13 @@ irb> a.first_name == b.author.first_name
773773
=> true
774774
```
775775

776-
Active Record supports automatic identification for most associations with standard names. However, Active Record will not automatically identify bi-directional associations that contain a scope or any of the following options:
777-
778-
* `:through`
779-
* `:foreign_key`
776+
Active Record supports automatic identification for most associations with
777+
standard names. However, Active Record will not automatically identify
778+
bi-directional associations that contain the `:through` or `:foreign_key`
779+
options. Custom scopes on the opposite association also prevent automatic
780+
identification, as do custom scopes on the association itself unless
781+
`config.active_record.automatic_scope_inversing` is set to true (the default for
782+
new applications).
780783

781784
For example, consider the following model declarations:
782785

guides/source/configuring.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,10 @@ support recycling cache key.
711711
Enables setting the inverse record when traversing `belongs_to` to `has_many`
712712
associations.
713713

714+
#### `config.active_record.automatic_scope_inversing`
715+
716+
Enables automatically inferring the `inverse_of` for associations with a scope.
717+
714718
#### `config.active_record.legacy_connection_handling`
715719

716720
Allows to enable new connection handling API. For applications using multiple
@@ -1701,6 +1705,7 @@ Accepts a string for the HTML tag used to wrap attachments. Defaults to `"action
17011705
- `config.action_controller.silence_disabled_session_errors`: `false`
17021706
- `config.action_mailer.smtp_timeout`: `5`
17031707
- `config.active_storage.video_preview_arguments`: `"-vf 'select=eq(n\\,0)+eq(key\\,1)+gt(scene\\,0.015),loop=loop=-1:size=2,trim=start_frame=1' -frames:v 1 -f image2"`
1708+
- `config.active_record.automatic_scope_inversing`: `true`
17041709
- `config.active_record.verify_foreign_keys_for_fixtures`: `true`
17051710
- `config.active_record.partial_inserts`: `false`
17061711
- `config.active_storage.variant_processor`: `:vips`

0 commit comments

Comments
 (0)