Skip to content
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

Merged

Conversation

luanzeba
Copy link
Member

@luanzeba luanzeba commented Jun 2, 2022

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 a CollectionAssociation 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:

class Member < ActiveRecord::Base
  has_many :memberships, -> { where(favorite: true) }
end

Steps to reproduce

Using the example above:

member = Member.create!
membership = member.memberships.create!(favorite: true)
membership.update!(favorite: false)

assert_equal 0, member.memberships.size
assert_equal 0, member.memberships.size

# The first assertion passes and the second fails with:
# Expected: 0
#   Actual: 1
Full reproduction script
# frozen_string_literal: true

require 'bundler/inline'

gemfile(true) do
  source 'https://rubygems.org'

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  gem 'rails', github: 'rails/rails', branch: 'main'
  gem 'sqlite3'
end

require 'active_record'
require 'minitest/autorun'
require 'logger'

# This connection will do for database-independent bug reports.
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :members, force: true do |t|
  end

  create_table :memberships, force: true do |t|
    t.integer :member_id
    t.boolean :favorite
  end
end

class Member < ActiveRecord::Base
  has_many :memberships, -> { where(favorite: true) }
end

class Membership < ActiveRecord::Base
  belongs_to :member
end

class BugTest < Minitest::Test
  def test_associations_size
    member = Member.create!
    membership = member.memberships.create!(favorite: true)
    membership.update!(favorite: false)

    assert_equal 0, member.memberships.size
    assert_equal 0, member.memberships.size
  end
end

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

elsif !association_scope.distinct_value && !target.empty?
unsaved_records = target.select(&:new_record?)
unsaved_records.size + count_records

Inside the count_records method called above we mark the association as loaded and assume the target 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 above
# 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.
loaded! if count == 0

In the second call to #size, the association is loaded so we end up on this branch.

if !find_target? || loaded?
target.size

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 inside count_records if count is 0

Other Information

I ran this fix against the GitHub monolith tests and got a green build

@luanzeba luanzeba force-pushed the association_size_on_scoped_relations branch 2 times, most recently from 9fb068d to 1815ad4 Compare June 2, 2022 21:10
Comment on lines 87 to 90
if count == 0 && target.select(&:new_record?).empty?
self.target = []
loaded!
end
Copy link
Member

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.:

Suggested change
if count == 0 && target.select(&:new_record?).empty?
self.target = []
loaded!
end
if count == 0
target.select!(&:new_record?)
loaded!
end

Copy link
Member Author

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

Comment on lines 84 to 86
# 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.
Copy link
Member

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.

Copy link
Member Author

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.

@luanzeba luanzeba force-pushed the association_size_on_scoped_relations branch from 1815ad4 to 4e575b2 Compare June 6, 2022 13:38
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>
@luanzeba luanzeba force-pushed the association_size_on_scoped_relations branch from 4e575b2 to db0e0c0 Compare June 6, 2022 13:43
@jonathanhefner jonathanhefner merged commit 5fae32e into rails:main Jun 6, 2022
@jonathanhefner
Copy link
Member

jonathanhefner commented Jun 6, 2022

Thank you, @luanzeba! And especially thank you for the thorough report and testing! 😍

(Backported to 7-0-stable.)

@luanzeba luanzeba deleted the association_size_on_scoped_relations branch June 6, 2022 17:33
jonathanhefner added a commit to jonathanhefner/rails that referenced this pull request Jun 22, 2022
…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
Copy link
Contributor

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants