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
- Update example for Association callbacks section There is an old open issue (https://github.com/rails/rails/issues/14365) saying that callbacks are not triggered when assocations are destroyed. I be... — committed to nickborromeo/rails by nickborromeo 4 years ago
- Update association callback docs There is an old open issue (https://github.com/rails/rails/issues/14365) saying that callbacks are not triggered when assocations are destroyed. I believe the issue ... — committed to nickborromeo/rails by nickborromeo 4 years ago
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: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 anafter_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 anActiveRecord 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 callinghandle_dependency
in theHasManyAssociation
class.:destroy
will then calldestroy_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 associationhttps://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 callbackshttps://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 thedestory
method inActiveRecord::Assocations::CollectionProxy
https://github.com/rails/rails/blob/02e8e6ebd4508f8474b11836f405da10cc59228f/activerecord/lib/active_record/associations/collection_proxy.rb#L690-L692
then calls the
destroy
method inActiveRecord::Associations::CollectionAssociation
, similar to the previous call when we didfirm.destroy
. However the main difference here is that*records
in this case is emptyhttps://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 calldelete_or_destroy
, therefore not calling any of the callbackshttps://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 notfirm.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
we are adding the object to the association which triggers the callback, the callback can also be triggered if we do something like
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
Guys! Stuck in the same shit. Found a simple and good solution!
My old code (with this bug… or feature… whatever that is):
On destroying user_organization its
after_destroy
callback not calling. Sadly. But by addingdependent: :destroy
tohas_many
withthrough
like this:It solves the problem.
dependent: :destroy
forhas_many :organizations
withthrough
parameter works not really as expected. It changes the way of removing associated objects. It forces to usedestroy
instead ofdelete
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:
The association concern:
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:…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:
do_something is not called
However, this works
@mattcampbell this should do the trick for you:
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:it looks like to trigger the
after_remove
callback you have to explicitly doobject.collection.delete(associated_object)
. Deleting the parent object or deleting the associated object directly do not trigger theafter_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 theafter_add
andafter_remove
callback. I tried adding theafter_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:And here’s the test:
Am I doing something stupid? I’m expecting
update_seats_available
to be called upon destroying thesubject
’s associated ticket, but it’s not…Thank you!
No. End neither does
clear
,destroy_all
, ordelete_all