Hi, I'm Samuel Cochran

on Twitter, Facebook, Google, LinkedIn, GitHub, Stack Overflow, and Xbox Live.

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 :through option is used, then the join records are deleted (rather than nullified) by default, but you can specify :dependent => :destroy or :dependent => :nullify to 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.