New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Clear out target when no new records #45244
Clear out target when no new records #45244
Conversation
9fb068d
to
1815ad4
Compare
if count == 0 && target.select(&:new_record?).empty? | ||
self.target = [] | ||
loaded! | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does everything still work as expected if we apply this optimization even with new_record?
records? i.e.:
if count == 0 && target.select(&:new_record?).empty? | |
self.target = [] | |
loaded! | |
end | |
if count == 0 | |
target.select!(&:new_record?) | |
loaded! | |
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That worked!! Thanks for the suggestion 😄 Got all tests passing
# If there's nothing in the database and @target has no new records | ||
# we are certain the current target is an empty array. This is a | ||
# documented side-effect of the method that may avoid an extra SELECT. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If my previous suggestion works, we'll need to update this comment ("target is an empty array") too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good call, updated!
# If there's nothing in the database, @target should only contain new | |
# records or be an empty array. This is a documented side-effect of | |
# the method that may avoid an extra SELECT. |
1815ad4
to
4e575b2
Compare
When counting records for a has_many association, we were previously getting in an out-of-sync state if there were records in memory that were persisted to the database, but did not count for the scoped query. This impacts associations with a scope like so: ``` class Coupon < ActiveRecord::Base has_many :coupon_redemptions, -> { where(expired: false) } end ``` Using the example above, let's say we hold a loaded `coupon_redemption` record in memory and it switches from `expired: true` to `expired: false`. Assuming that change is persisted to the database, we should respect it when working with that record in memory. When we first call `coupon.coupon_redemptions.size`, we get the appropriate response: 0. But when we call it again, we get 1 record. That's because in the second call, the record has already been loaded from the DB and we get into the out-of-sync state. The fix was to ensure the has_many association target list is empty when counting records in that scenario. Co-authored-by: Daniel Colson <composerinteralia@github.com>
4e575b2
to
db0e0c0
Compare
Thank you, @luanzeba! And especially thank you for the thorough report and testing! 😍 (Backported to |
…ed_relations Clear out target when no new records (cherry picked from commit 5fae32e)
# the first call will mark the association as loaded and the second call will | ||
# take a different code path, so it's important to keep both assertions | ||
assert_equal 0, member.favorite_memberships.size | ||
assert_equal 0, member.favorite_memberships.size |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm having a hard time reasoning about why the size of member.favorite_memberships.size
is 0. The count of member.favorite_memberships.count
is 1, right? Shouldn't the addition of a persisted record to the relation therefore change the size to 1 as well? we're coming across this case where we are surprised that the following is now true
membership = member.favorite_memberships.create!
refute_included membership, member.favorite_memberships
This fix was originally part of #45019 but that PR was solving different issues at once, so I decided to break that up into separate PRs for each problem.
Summary
Calling
#size
on aCollectionAssociation
can yield the wrong results when the collection association's target value gets out of sync. This issue can happen for associations with a scope, like so:Steps to reproduce
Using the example above:
Full reproduction script
It's important to have both assertions because
CollectionAssociation#size
has a conditional with multiple branches and each time we call#size
in the test above it takes a different code path.In the first call the association isn't loaded, so we end up on this branch of the conditional
rails/activerecord/lib/active_record/associations/collection_association.rb
Lines 208 to 210 in 5fdbd21
Inside the
count_records
method called above we mark the association as loaded and assume thetarget
is empty, but we never ensure that's the case. This assumption can be incorrect when you have a scope on the association as in the example aboverails/activerecord/lib/active_record/associations/has_many_association.rb
Lines 82 to 85 in 5fdbd21
In the second call to
#size
, the association is loaded so we end up on this branch.rails/activerecord/lib/active_record/associations/collection_association.rb
Lines 202 to 203 in 5fdbd21
Now, the target should be empty, but it actually has 1 record, so we get the test failure.
The fix is to ensure
target
is empty insidecount_records
ifcount
is 0Other Information
I ran this fix against the GitHub monolith tests and got a green build