ActiveRecord destroy callbacks on has many through
I came across some behaviour in ActiveRecord today that I found counter–intuitive. When I #delete a record from a has_many ..., through: ... collection association I expected the join record's after_destroy association to run, just like the after_create and friends which do run.
Pretend you're designing a super–simplified activity stream for an app storing event attendances for users. I'm horrendously over–simplifying for the sake of the example.
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
class User < ActiveRecord::Base
has_many :attendances, dependent: :destroy
has_many :events, through: :attendances
end
User.connection.create_table :users do |t|
t.string :name, null: false
end
class Event < ActiveRecord::Base
has_many :attendances, dependent: :destroy
has_many :users, through: :attendances
end
Event.connection.create_table :events do |t|
t.string :name, null: false
end
class Attendance < ActiveRecord::Base
belongs_to :event
belongs_to :user
after_create do
Activity.where(user_id: user, verb: "attending", event_id: event).create!
end
after_destroy do
Activity.where(user_id: user, verb: "attending", event_id: event).destroy_all
end
end
Attendance.connection.create_table :attendances do |t|
t.references :user, :event, null: false
end
class Activity < ActiveRecord::Base
belongs_to :user
belongs_to :event
end
Activity.connection.create_table :activities do |t|
t.string :verb, null: false
t.references :user, :event, null: false
end
Let's create a user who is hosting a party.
>> bob = User.create! name: "Bob"
=> #<User id: 1, name: "Bob">
>> party = Event.create! name: "Bob's Super Party"
=> #<Event id: 1, name: "Bob's Super Party">
So we want to say bob is attending his party:
>> party.users << bob
=> [#<User id: 1, name: "Bob">]
Which should have created an Activity, so we can get our Facebook on:
>> Activity.all
=> [#<Activity id: 1, verb: "attending", user_id: 1, event_id: 1>]
Great!
But Bob is bailing on his own party. Perhaps it ain't so super. According to the has_many api docs and association rails guide, we use #delete:
>> party.users.delete bob
=> [#<User id: 1, name: "Bob">]
>> party.users.count
=> 0
Also great! But we don't want an activity stream full of outdated facts so our callback should have cleaned up, right?
>> Activity.all
=> [#<Activity id: 1, verb: "attending", user_id: 1, event_id: 1>]
... oh dear.
Let's read the documentation for #delete for a second:
Removes one or more objects from the collection by setting their foreign keys to
NULL. Objects will be in addition destroyed if they’re associated with:dependent => :destroy, and deleted if they’re associated with:dependent => :delete_all.If the
:throughoption is used, then the join records are deleted (rather than nullified) by default, but you can specify:dependent => :destroyor:dependent => :nullifyto override this.
Ah, okay! We could add dependent: :destroy to our has_many :users, through: :attendances in Event which would work.
Or we could use party.users.destroy bob. Yep, there's a semi–undocumented method. Strange, right?
>> party.users << bob
=> [#<User id: 1, name: "Bob">]
>> party.users.destroy bob
=> [#<User id: 1, name: "Bob">]
>> Activity.all
=> []
Neat!
It turns out this method is documented deep inside the rails source where the API docs do not reach, and is mentioned in the ActiveRecord::Associations::ClassMethods docs, but isn't covered anywhere I had looked. I've added documentation into the association guide and will probably also add it to the api docs.
On models we're used to using #destroy to ensure callbacks are run, while #delete is the quick way to simply remove something from the database. I like that symmetry reflected in association macros.
I'm also a little divided. This is a problem I've heard several people complain about. I've even defended Rails' current position on the topic several times. But it comes up too many times suggesting it's counter–intuitive. For example, this breaks counter caching. The documentation event suggests there is a potential pitfall here. Is it okay to make delete perform a default behaviour and have destroy as an override?
If we bolster the documentation for #destroy is this sufficient? Or do we need to rethink association removal and callbacks?
What do you think? Tweet @sj26.