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

Only replace association keys if they've changed [5.2 Regression] #32375

Closed
wants to merge 2 commits into from

Conversation

jturkel
Copy link
Contributor

@jturkel jturkel commented Mar 29, 2018

Summary

This is a bit obscure but as of Rails 5.2.0 RC1 (when commit a84c765 landed) the ActiveRecord::Associations::Preloader can no longer invoked on associations of a destroyed model. This worked fine in older versions of Rails.

This results in a stack trace something like the following:

RuntimeError: Can't modify frozen hash
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activemodel-5.2.0.rc2/lib/active_model/attribute_set/builder.rb:44:in `[]='
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activemodel-5.2.0.rc2/lib/active_model/attribute_set.rb:57:in `write_from_user'
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/attribute_methods/write.rb:51:in `_write_attribute'
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/attribute_methods/write.rb:45:in `write_attribute'
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/attribute_methods.rb:410:in `[]='
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/associations/belongs_to_association.rb:92:in `replace_keys'
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/associations/belongs_to_association.rb:33:in `target='
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/associations/preloader/association.rb:50:in `associate_records_to_owner'
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/associations/preloader/association.rb:26:in `block in run'
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/associations/preloader/association.rb:25:in `each'
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/associations/preloader/association.rb:25:in `run'
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/associations/preloader.rb:142:in `block (2 levels) in preloaders_for_one'
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/associations/preloader.rb:140:in `each'
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/associations/preloader.rb:140:in `map'
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/associations/preloader.rb:140:in `block in preloaders_for_one'
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/associations/preloader.rb:139:in `each'
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/associations/preloader.rb:139:in `flat_map'
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/associations/preloader.rb:139:in `preloaders_for_one'
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/associations/preloader.rb:106:in `preloaders_on'
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/associations/preloader.rb:93:in `block in preload'
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/associations/preloader.rb:92:in `each'
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/associations/preloader.rb:92:in `flat_map'
/Users/jturkel/.rvm/gems/ruby-2.4.3/gems/activerecord-5.2.0.rc2/lib/active_record/associations/preloader.rb:92:in `preload'

Other Information

This was discovered while testing the goldiloader gem with Rails 5.2. More details on the goldiloader side can be found in salsify/goldiloader#63 if you're interested.

@rails-bot
Copy link

Thanks for the pull request, and welcome! The Rails team is excited to review your changes, and you should hear from @kamipo (or someone else) soon.

If any changes to this PR are deemed necessary, please add them as extra commits. This ensures that the reviewer can see what has changed since they last reviewed the code. Due to the way GitHub handles out-of-date commits, this should also make it reasonably obvious what issues have or haven't been addressed. Large or tricky changes may require several passes of review and changes.

This repository is being automatically checked for code quality issues using Code Climate. You can see results for this analysis in the PR status below. Newly introduced issues should be fixed before a Pull Request is considered ready to review.

Please see the contribution instructions for more information.

jturkel added a commit to salsify/goldiloader that referenced this pull request Apr 10, 2018
@rafaelfranca
Copy link
Member

ActiveRecord::Associations::Preloader is private API, can you reproduce this problem using public API?

@jturkel
Copy link
Contributor Author

jturkel commented Apr 10, 2018

The closest I can come to do this with a public API is this:

class Post < ActiveRecord::Base
  belongs_to :blog

  after_destroy do
    association(:blog).target = blog
  end
end

The full gist is here. The association(..).target method is documented in the Associations Extensions section of ActiveRecord::Associations::ClassMethods. It only mentions the reader method explicitly though.

All that said the example above is contrived. I hit the issue because goldiloader uses the internal ActiveRecord::Associations::Preloader API :)

@@ -85,7 +85,7 @@ def update_counters_on_replace(record)

# Checks whether record is different to the current target, without loading it
def different_target?(record)
record.id != owner._read_attribute(reflection.foreign_key)
record.try(:id) != owner._read_attribute(reflection.foreign_key)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

record && record.id !=

post = posts(:welcome)
author = Author.find(post.author_id)
post.destroy!
ActiveRecord::Associations::Preloader.new.preload([post], [:author])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use your example that uses the public API here.

@eugeneius
Copy link
Member

Here's a repro that uses the public preloader API to trigger the problem:

# frozen_string_literal: true

begin
  require "bundler/inline"
rescue LoadError => e
  $stderr.puts "Bundler version 1.10 or later is required. Please update your Bundler"
  raise e
end

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

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

  gem "rails", path: "."
  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 :posts, force: true do |t|
  end

  create_table :comments, force: true do |t|
    t.integer :post_id
  end
end

class Post < ActiveRecord::Base
  has_many :comments
end

class Comment < ActiveRecord::Base
  belongs_to :post

  after_find { destroy! }
end

class BugTest < Minitest::Test
  def test_association_stuff
    post = Post.create!
    post.comments << Comment.create!

    comment = Comment.preload(:post).first
    assert_equal post, comment.post
  end
end

@jturkel
Copy link
Contributor Author

jturkel commented Apr 11, 2018

Thanks @eugeneius. That's a much cleaner (and devious) test case.

@@ -30,7 +30,7 @@ def replace(record)
end

def target=(record)
replace_keys(record)
replace_keys(record) if different_target?(record)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing nil to different_target? may break any other functionality (e.g. face.association(:polymorphic_man).target = nil).

def different_target?(record)
super || record.class != klass
end

record.nil? || different_target?(record) is better to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't making the condition record.nil? || different_target?(record) incorrectly replace the keys if target is nil and record is nil?

It looks like BelongsToPolymorphicAssociation needs to be changed to something like this to work properly for the null and non-null record cases (assuming it's safer than having BelongsToPolymorphicAssociation#klass return NilClass for the nil case):

def different_target?(record) 
  super || record && record.class != klass || record.nil? && klass
end 

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Originally assigning belongs_to on destroyed object raised frozen error if target is nil and record is nil. 9e72e8e
Since #32338 preloader doesn't assign nil to target, record.nil? || different_target?(record) is enough to prevent replacing the keys from preloader.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great point. I've pushed a change with a nil check.

@@ -85,7 +85,7 @@ def update_counters_on_replace(record)

# Checks whether record is different to the current target, without loading it
def different_target?(record)
record.id != owner._read_attribute(reflection.foreign_key)
(record && record.id) != owner._read_attribute(reflection.foreign_key)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary this since nil record won't be passed.

This ensures the Preloader workers on destroyed models.
@jturkel
Copy link
Contributor Author

jturkel commented Apr 30, 2018

@kamipo - I've made the requested change and rebased off master to fix the conflict introduced by 9e72e8e. The Travis build is passing now. Let me know if you have any additional feedback.

client.destroy!
assert_raise(frozen_error_class) { client.firm = nil }
assert_raise(frozen_error_class) { client.firm = Firm.new(name: "Firm") }
assert_raise(frozen_error_class) { client.firm = Firm.new(name: "New Firm") }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this change is required, it means that this PR will change existing public behavior in exchange for preloading on destroyed record.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup. The change in public behavior is that frozen errors will only be raised if the foreign key is actually changing (except for the nil case that we punted on). This behavior is also inconsistent with changing "regular" attributes on destroyed models which will always throw an exception. Thinking through it a bit more I can see a few ways forward:

  1. Make the assignments to any attributes (i.e. foreign keys or primitives) a no-op if they don't actually change anything but a) that doesn't feel particular useful outside of the preloader use case; b) the required additional equality checks could have a negative performance impact.
  2. Rollback Bugfix foreign key replacement in inverse association #31575 which caused the change in preloader behavior introduced in 5.2.0. That issue seems more serious than the preloader not working on destroyed models so I'm not crazy about rolling it back.
  3. Define a separate API on Association for setting the target that would have the behavior needed by the preloader (i.e. it should never change the foreign key).
  4. Punt on making the preloader work with destroyed models
  5. Move forward with the change as currently implemented which gets preloading working on destroyed models but is inconsistent with setting attributes on destroyed models

Option 3 seems like the best option but I'm not sure it's worth it given Rails 5.2 has already been released with the change in preloader behavior and I've released a workaround in goldiloader. What are your thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kamipo - I pushed a commit that implements option 3 above (define a separate private API on Association for the Preloader to set the target that bypasses any scenarios that change the attributes hash). Let me know what you think.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The behaviour of option 3 sounds correct, but the design feels wrong; we're adding a method that duplicates the implementation of target= so that we can avoid the behaviour added in BelongsToAssociation, in order to fix a bug that only affects belongs to associations.

Another potential solution is to revert #31575 and #32338, and solve the original problem in a different way: set both the target and the foreign key when assigning an inverse association.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, my preference is here:

diff --git a/activerecord/lib/active_record/associations/association.rb b/activerecord/lib/active_record/associations/association.rb
index d883eda52d..9301ccc124 100644
--- a/activerecord/lib/active_record/associations/association.rb
+++ b/activerecord/lib/active_record/associations/association.rb
@@ -18,8 +18,7 @@ module Associations
     #       HasManyAssociation + ForeignAssociation
     #         HasManyThroughAssociation + ThroughAssociation
     class Association #:nodoc:
-      attr_reader :owner, :target, :reflection
-      attr_accessor :inversed
+      attr_reader :owner, :target, :reflection, :inversed
 
       delegate :options, to: :reflection
 
@@ -76,11 +75,6 @@ def target=(target)
         loaded!
       end
 
-      def preloaded_target=(target) #:nodoc:
-        @target = target
-        loaded!
-      end
-
       def scope
         target_scope.merge!(association_scope)
       end
@@ -103,23 +97,24 @@ def reset_scope
 
       # Set the inverse association, if possible
       def set_inverse_instance(record)
-        if invertible_for?(record)
-          inverse = record.association(inverse_reflection_for(record).name)
-          inverse.target = owner
-          inverse.inversed = true
+        if inverse = inverse_association_for(record)
+          inverse.inversed_from(owner)
         end
         record
       end
 
       # Remove the inverse association, if possible
       def remove_inverse_instance(record)
-        if invertible_for?(record)
-          inverse = record.association(inverse_reflection_for(record).name)
-          inverse.target = nil
-          inverse.inversed = false
+        if inverse = inverse_association_for(record)
+          inverse.inversed_from(nil)
         end
       end
 
+      def inversed_from(record)
+        self.target = record
+        @inversed = !!record
+      end
+
       # Returns the class of the target. belongs_to polymorphic overrides this to look at the
       # polymorphic_type field on the owner.
       def klass
@@ -245,6 +240,12 @@ def raise_on_type_mismatch!(record)
           end
         end
 
+        def inverse_association_for(record)
+          if invertible_for?(record)
+            record.association(inverse_reflection_for(record).name)
+          end
+        end
+
         # Can be redefined by subclasses, notably polymorphic belongs_to
         # The record parameter is necessary to support polymorphic inverses as we must check for
         # the association in the specific class of the record.
diff --git a/activerecord/lib/active_record/associations/belongs_to_association.rb b/activerecord/lib/active_record/associations/belongs_to_association.rb
index 1109fee462..e5fcdb06dc 100644
--- a/activerecord/lib/active_record/associations/belongs_to_association.rb
+++ b/activerecord/lib/active_record/associations/belongs_to_association.rb
@@ -26,10 +26,12 @@ def replace(record)
           decrement_counters
         end
 
+        replace_keys(record)
+
         self.target = record
       end
 
-      def target=(record)
+      def inversed_from(record)
         replace_keys(record)
         super
       end
diff --git a/activerecord/lib/active_record/associations/preloader/association.rb b/activerecord/lib/active_record/associations/preloader/association.rb
index f076f53af0..d6f7359055 100644
--- a/activerecord/lib/active_record/associations/preloader/association.rb
+++ b/activerecord/lib/active_record/associations/preloader/association.rb
@@ -46,7 +46,7 @@ def associate_records_to_owner(owner, records)
             if reflection.collection?
               association.target.concat(records)
             else
-              association.preloaded_target = records.first unless records.empty?
+              association.target = records.first unless records.empty?
             end
           end
 

@rafaelfranca rafaelfranca added this to the 5.2.1 milestone May 22, 2018
kamipo added a commit that referenced this pull request May 25, 2018
Since #31575, `BelongsToAssociation#target=` replaces owner record's
foreign key to fix an inverse association bug.

But the method is not only used for inverse association but also used
for eager loading/preloading, it caused some public behavior changes
(#32338, #32375).

To avoid any side-effect in loading associations, I reverted the
overriding `#target=`, then introduced `#inversed_from` to replace
foreign key in `set_inverse_instance`.

Closes #32375.
@jturkel
Copy link
Contributor Author

jturkel commented May 25, 2018

Thanks @kamipo! Sorry I couldn't roll in the PR feedback more quickly.

@kamipo
Copy link
Member

kamipo commented May 25, 2018

No worries, thanks for the PR which demonstrates the issue anyway!

kamipo added a commit that referenced this pull request May 25, 2018
Since #31575, `BelongsToAssociation#target=` replaces owner record's
foreign key to fix an inverse association bug.

But the method is not only used for inverse association but also used
for eager loading/preloading, it caused some public behavior changes
(#32338, #32375).

To avoid any side-effect in loading associations, I reverted the
overriding `#target=`, then introduced `#inversed_from` to replace
foreign key in `set_inverse_instance`.

Closes #32375.
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

5 participants