rails: Fails to invoke `after_remove` callback when deleting associated record

I have a class with a has_many association. I have a after_add and an after_remove callback on the association, like this:

# rails 4.2.0.alpha
has_many :character_levels,
  after_add: :log_adding_event,
  after_remove: :log_removing_event

def log_adding_event
  Log.create!({ event: "adding" })
end

def log_removing_event
  Log.create!({ event: "removing" })
end

When adding a character level, I can see that a Log instance is created. I can even replicate in tests. But when deleting an associated record, the callback isn’t getting executed correctly:

Character.find(1).character_levels << CharacterLevel.find(1) 
# => Results in a Log record being created

Character.find(1).character_levels.delete(CharacterLevel.find(1))
# => Should result in a Log being created, but doesn't

About this issue

  • Original URL
  • State: closed
  • Created 10 years ago
  • Reactions: 5
  • Comments: 30 (11 by maintainers)

Commits related to this issue

Most upvoted comments

I just ran into this situation in my application, when I noticed that my after_remove callbacks weren’t being triggered as I expected. This thread helped me, so thanks to everyone who contributed (@nickborromeo @MatheusRich especially).

The TLDR for me is that if you want an after_remove callback to be triggered when removing one item of an association collection, you need to use this form:

firm.clients.destroy(client)

Other ways of destroying the record (e.g. firm.clients.last.destroy, client.destroy) do not trigger it.

I’m happy to live to fight another day, but also thought I’m mention that I found this behavior a bit surprising, and, after reading the explanation, am not sure I’m completely satisfied. I think the confusion is due to an expectation mismatch between how long-standing and well understood Active Record callbacks behave and how (lesser known) Association Callbacks behave.

With Active Record, callbacks are “opt-out” – as long as you’re in object land instead of SQL land, they’ll fire. update_attribute? Fires callbacks. touch? Yup. Skip callbacks at your own peril. From The Rails Guide on methods that skip callbacks:

These methods should be used with caution, however, because important business rules and application logic may be kept in callbacks.

The after_remove callback by contrast, might be more properly characterized as “opt in” due to the level of knowledge required to understand when it fires and when it doesn’t. Should “important business rules and application logic” live in an after_remove callback? I’d argue probably not.

Now I get it: they are different. But it’s a tough distinction: both deal with the addition and removal of Active Record objects, so there’s plenty of daylight for confusion to creep in.

👋 everyone, I dug into this a little bit today and I think I am leaning towards this not being a but and maybe can benefit from a little more clarification in the docs.

TLDR; I think we can go ahead and close this issue due this not being a bug but more of an incorrect expectation when removing associations. I can try to follow up on updating the docs based on the details in this comment.

cc @ekampp @zenati @rafaelfranca

First thing we have to clarify that this is an Association Callback not an ActiveRecord Callback they are a little different and could explain the behavior that is being shown here.

I found a test in rails that we can use as an example https://github.com/rails/rails/blob/b368511db24fb126545327984511281153730ec4/activerecord/test/cases/associations/callbacks_test.rb#L105-L111

Why this test works?

The association is defined with dependent: :destroy, this will result in calling handle_dependency in the HasManyAssociation class. :destroy will then call destroy_all

https://github.com/rails/rails/blob/3e970d09802600e68bdcc9e385f99d173e549c66/activerecord/lib/active_record/associations/has_many_association.rb#L25-L28

https://github.com/rails/rails/blob/3dff30c14fa25f211088d62da26611616ab5616f/activerecord/lib/active_record/associations/collection_association.rb#L174-L179

which then calls the destroy on the collection association

https://github.com/rails/rails/blob/3dff30c14fa25f211088d62da26611616ab5616f/activerecord/lib/active_record/associations/collection_association.rb#L174-L179

and the finally it will call remove_records which will call the callbacks

https://github.com/rails/rails/blob/3dff30c14fa25f211088d62da26611616ab5616f/activerecord/lib/active_record/associations/collection_association.rb#L393-L403

Why doesn’t firm.clients.destroy work?

When we call destroy on this association we call the destory method in ActiveRecord::Assocations::CollectionProxy

https://github.com/rails/rails/blob/02e8e6ebd4508f8474b11836f405da10cc59228f/activerecord/lib/active_record/associations/collection_proxy.rb#L690-L692

then calls the destroy method in ActiveRecord::Associations::CollectionAssociation, similar to the previous call when we did firm.destroy. However the main difference here is that *records in this case is empty

https://github.com/rails/rails/blob/3dff30c14fa25f211088d62da26611616ab5616f/activerecord/lib/active_record/associations/collection_association.rb#L197-L199

When records is empty, we return early when we call delete_or_destroy, therefore not calling any of the callbacks

https://github.com/rails/rails/blob/86480e71faea7ed152efcba633f0011a7c21ee33/activerecord/lib/active_record/associations/collection_association.rb#L379-L380

This tells me that me the way we should be calling destroy on associations is the way that @sebasoga suggested, where we pass the object we want to remove to destroy.

Why does firm.clients.destroy_all work?

As we know, this call stack is very similar to firm.destroy, this is why callbacks are triggered. Also, this should be the way we should remove all associations and not firm.client.destroy as we just saw above. 🎉

Why does after_add work?

This callback works because we are calling it on the association. In the test when we call

client = firm.clients.create! name: "Client"

we are adding the object to the association which triggers the callback, the callback can also be triggered if we do something like

client = Client.create! name: "Client"
firm.clients << client

by simply creating a client object like client = Client.create! name: "Client" this will not trigger any callbacks.

I hope this clarifies this issue for folks, at the very least it did for me 😅.

3 years later, on rails 5 this is still an issue

class User
  has_many :payments, inverse_of: :user, after_add: :create_traveler_or_update_balance, after_remove: :set_balance
end

class Payment < ApplicationRecord
  after_commit :send_receipt, on: :create
  belongs_to :user, inverse_of: :payments
  attribute :amount, :money_column

  def send_receipt
    PaymentMailer.receipt(self.id).deliver_later
  end
end

# destroying a payment never calls the `set_balance` method:
user.payments.last.destroy

# yet, creating a payment always runs `create_traveler_or_update_balance`:
user.payments.create(payment_attributes)

Guys! Stuck in the same shit. Found a simple and good solution!

My old code (with this bug… or feature… whatever that is):

  has_many :user_organizations, dependent: :destroy
  has_many :organizations, through: :user_organizations

On destroying user_organization its after_destroy callback not calling. Sadly. But by adding dependent: :destroy to has_many with through like this:

  has_many :user_organizations, dependent: :destroy
  has_many :organizations, through: :user_organizations, dependent: :destroy

It solves the problem. dependent: :destroy for has_many :organizations with through parameter works not really as expected. It changes the way of removing associated objects. It forces to use destroy instead of delete so callbacks are called.

@JerryGreen’s fix worked for me. In my case, I have an invite/uninvite Mailer set to send when a Membership association is created/destroyed through a polymorphic Group model:

  Module Invitable
  after_save -> { invite_to_group }     
  after_commit -> { uninvite_from_group }, on: :destroy
  ...

The association concern:

  Class Memberable
  has_many :memberships, as: :memberable, dependent: :destroy     
  has_many :members, through: :memberships, source: :user     
  has_many :users, -> { distinct }, through: :memberships
  ...

When the Group record is created/destroyed, the Membership association callbacks work as expected. When the Group record is updated, the association callback for after_save -> { invite_to_group } works, however, { uninvite_from_group } doesn’t get called.

Looking at my logs, and only during the removal of associated records during the Update action, the destroy method is being called via mass assignment directly on the DB and bypassing the Membership model thus never triggering the after_commit -> { uninvite_from_group } callback.

So, applying @JerryGreen’s workaround I added the additional dependent: :destroy to Memberable:

 Class Memberable
 has_many :memberships, as: :memberable, dependent: :destroy     
 has_many :members, through: :memberships, source: :user     
 has_many :users, -> { distinct }, through: :memberships, dependent: :destroy <--
 ...

…and all is well. Rails 5.2

Hope it helps someone!

I’m on Rails 4.2.8. Noticed the lambda does not fire but referencing a method does work.

Assuming the removal is performed as such:

user.delete(foo)

do_something is not called

# (not working)
has_many :foos, through: :foos_bars, after_remove: -> { do_something }

However, this works

# (working)
has_many :foos, through: :foos_bars, after_remove: :after_remove_do_something

def after_remove_do_something(assoc)
  do_something
end

@mattcampbell this should do the trick for you:

it "updates available seats when a ticket association is removed" do
  subject.save 
  ticket = create(:ticket)
  subject.tickets << ticket
  expect(subject).to receive(:update_seats_available).exactly(1).times
  subject.tickets.destroy(ticket) #passing the object you want to destroy as an argument
end

Although this is a very odd way of destroying records to get the callback triggered. The documentation on how to use this callbacks in a model is good, but not on how to get them triggered.

The same thing happens in rails 5 with mongoid. The after_remove method is called when doing assigning the association an empty array or calling #pop to it. Example:

class Playlist
  embeds_many :videos, after_add: :after_add_method, after_remove: :after_remove_method
end

class Video
  embedded_in :playlist
end

###
playlist.playlist_items.destroy_all # Calls after_remove_method
playlist.playlist_items = [] # Calls after_remove_method
playlist.playlist_items.pop = [] # Calls after_remove_method

playlist.playlist_items.first.destroy! # Doesn't call after_remove_method
playlist.playlist_items.first.destroy # Doesn't call after_remove_method

it looks like to trigger the after_remove callback you have to explicitly do object.collection.delete(associated_object). Deleting the parent object or deleting the associated object directly do not trigger the after_remove callback.

Is this by design?

I can also confirm that this is a regression in rails 5.1.4. Updating our app from rails 4.2 to 5 this is a roadblock as we heavily depend on the functionality.

We have a has_many through association with the after_add and after_remove callback. I tried adding the after_remove to the main association and has through association nothing seems to work.

First off, woah(!), I can’t believe I raised this three years ago…

@SampsonCrowley according to @chris-roerig referencing the after_remove method by name, like you do works, but then he’s on Rails 4.2.8, and you seem to be on Rails 5.x. So perhaps there’s a regression there somewhere. Just mentioning this in case someone felt like looking into this.

Hey guys -

Jumping on here. I’m on Rails 4.0.2 and having a similar issue where my after_remove callback isn’t executing. Here’s my code:

  has_many :tickets, after_add: :update_seats_available, after_remove: :update_seats_available
  def update_seats_available(ticket)
    sold_tickets = tickets_count
    if sold_tickets != (capacity - seats_available)
      update_attributes(seats_available: (capacity - sold_tickets))
    end
  end

And here’s the test:

    it "updates available seats when a ticket association is removed" do
      subject.save #subject is not yet persisted
      subject.tickets << create(:ticket)
      expect(subject).to receive(:update_seats_available).exactly(1).times
      subject.tickets.first.destroy
    end

Am I doing something stupid? I’m expecting update_seats_available to be called upon destroying the subject’s associated ticket, but it’s not…

Thank you!

No. End neither does clear, destroy_all, or delete_all