Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

Loading…

after_remove callbacks only run if record was removed. #9346

Open
wants to merge 1 commit into from

7 participants

@samdalton

If a record that does not belong to a HABTM collection is passed in to a collection to be deleted, the after_remove callback is run even though the record was not actually removed. This can cause unexpected behaviour, such as incorrectly decrementing a counter.

The new behaviour still has before_remove called, but after_remove called only if the record was actually removed from the collection.

Example of unexpected behaviour:

class Post < ActiveRecord::Base
  has_and_belongs_to_many :post_collections
end

class PostCollection < ActiveRecord::Base
  has_and_belongs_to_many :posts, after_remove: :decrememnt_posts_count

  def decrement_posts_count
     PostCollection.decrement_counter :posts_count, self.id
  end
end

post = Post.new
post_collection = PostCollection.new
post_collection.posts.delete(post)

# post_collection.posts_count is now -1

Addendum:
The pull request fixes behaviour that may or may not have been what was originally intended. If after_remove is to be called regardless of what happens to the data, this will invalidate the pull request. It seems intuitive however that the after_remove callback should only be run if the record was actually removed from the collection.

@samdalton samdalton after_remove callbacks only run if record was removed.
If a record that does not belong to a collection is
passed in to a collection to be deleted, the after_remove
callback is run even though the record was not actually
removed. This can cause invalid behaviour, such as decrementing
a cache counter.

The new behaviour still has before_remove called, but after_remove
called only if the record was actually removed from the collection.
b4ab206
@frodsan frodsan commented on the diff
.../active_record/associations/collection_association.rb
@@ -468,9 +468,10 @@ def remove_records(existing_records, records, method)
records.each { |record| callback(:before_remove, record) }
delete_records(existing_records, method) if existing_records.any?
- records.each { |record| target.delete(record) }
-
- records.each { |record| callback(:after_remove, record) }
+ records.each do |record|
+ deleted = target.delete(record)
+ callback(:after_remove, record) if deleted
+ end
@frodsan
frodsan added a note

Why don't you do this one line instead?

I thought it would be doing too much in one line, deleting and running the callback. Made it clearer to split it in 2.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@neerajdotname
Collaborator

after_remove callback should only be run if the record was actually removed from the collection.

@samdalton by that definition we should also check for if the element is present in the collection. So that we fire before_remove only if the item has a chance of getting removed. What do you think ?

@samdalton
@arthurnn
Collaborator

@samdalton is this is a broken behaviour on Rails 4.x ?

@rafaelfranca
Owner

This will change the existing behavior and people may be relying on it so I don't think it is safe to change it like this.

If we want to change this we need a better upgrade path, like a global option to let people to switch the behavior,

@kerrizor

If we want to change this we need a better upgrade path, like a global option to let people to switch the behavior

That seems "easy enough" - would you council it be the current behavior by default, toggled to use this proposed behavior? @samdalton as the original author are you interested in taking a run at that?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Feb 20, 2013
  1. @samdalton

    after_remove callbacks only run if record was removed.

    samdalton authored
    If a record that does not belong to a collection is
    passed in to a collection to be deleted, the after_remove
    callback is run even though the record was not actually
    removed. This can cause invalid behaviour, such as decrementing
    a cache counter.
    
    The new behaviour still has before_remove called, but after_remove
    called only if the record was actually removed from the collection.
This page is out of date. Refresh to see the latest.
View
7 activerecord/lib/active_record/associations/collection_association.rb
@@ -468,9 +468,10 @@ def remove_records(existing_records, records, method)
records.each { |record| callback(:before_remove, record) }
delete_records(existing_records, method) if existing_records.any?
- records.each { |record| target.delete(record) }
-
- records.each { |record| callback(:after_remove, record) }
+ records.each do |record|
+ deleted = target.delete(record)
+ callback(:after_remove, record) if deleted
+ end
@frodsan
frodsan added a note

Why don't you do this one line instead?

I thought it would be doing too much in one line, deleting and running the callback. Made it clearer to split it in 2.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
end
# Delete the given records from the association, using one of the methods :destroy,
View
13 activerecord/test/cases/associations/callbacks_test.rb
@@ -42,11 +42,12 @@ def test_removing_with_macro_callbacks
def test_removing_with_proc_callbacks
first_post, second_post = @david.posts_with_callbacks[0, 2]
+ @david.posts_with_proc_callbacks << first_post
@david.posts_with_proc_callbacks.delete(first_post)
- assert_equal ["before_removing#{first_post.id}", "after_removing#{first_post.id}"], @david.post_log
+ assert_equal ["before_adding#{first_post.id}", "after_adding#{first_post.id}", "before_removing#{first_post.id}", "after_removing#{first_post.id}"], @david.post_log
@david.posts_with_proc_callbacks.delete(second_post)
- assert_equal ["before_removing#{first_post.id}", "after_removing#{first_post.id}", "before_removing#{second_post.id}",
- "after_removing#{second_post.id}"], @david.post_log
+
+ assert_equal ["before_adding#{first_post.id}", "after_adding#{first_post.id}", "before_removing#{first_post.id}", "after_removing#{first_post.id}", "before_removing#{second_post.id}"], @david.post_log
end
def test_multiple_callbacks
@@ -121,12 +122,12 @@ def test_has_and_belongs_to_many_remove_callback
jamis = developers(:jamis)
activerecord = projects(:active_record)
assert activerecord.developers_log.empty?
+ activerecord.developers_with_callbacks << david
activerecord.developers_with_callbacks.delete(david)
- assert_equal ["before_removing#{david.id}", "after_removing#{david.id}"], activerecord.developers_log
+ assert_equal ["before_adding#{david.id}", "after_adding#{david.id}", "before_removing#{david.id}", "after_removing#{david.id}"], activerecord.developers_log
activerecord.developers_with_callbacks.delete(jamis)
- assert_equal ["before_removing#{david.id}", "after_removing#{david.id}", "before_removing#{jamis.id}",
- "after_removing#{jamis.id}"], activerecord.developers_log
+ assert_equal ["before_adding#{david.id}", "after_adding#{david.id}", "before_removing#{david.id}", "after_removing#{david.id}", "before_removing#{jamis.id}"], activerecord.developers_log
end
def test_has_and_belongs_to_many_remove_callback_on_clear
Something went wrong with that request. Please try again.