7

Is there a way to dynamically add after_add and after_remove callbacks to an existing has_many or has_and_belongs_to_many relationship?

For example, suppose I have models User, Thing, and a join model UserThingRelationship, and the User model is something like this:

class User < ActiveRecord::Base
  has_many :user_thing_relationships
  has_many :things, :through => :user_thing_relationships
end

I'd like to be able to, in a module that extends User, add :after_add and :after_remove callbacks to the User.has_many(:things, ...) relationship. I.e., have something like

module DoesAwesomeStuff
  def does_awesome_stuff relationship, callback
    # or however this can be achieved...
    after_add(relationship) callback
    after_remove(relationship) callback
  end
end

So that

class User < ActiveRecord::Base
  has_many :user_thing_relationships
  has_many :things, :through => :user_thing_relationships

  does_awesome_stuff :things, :my_callback
  def my_callback; puts "awesome"; end
end

Is effectively the same as

class User < ActiveRecord::Base
  has_many :user_thing_relationships
  has_many :things, :through => :user_thing_relationships, :after_add => :my_callback, :after_remove => :my_callback

  def my_callback; puts "awesome"; end
end

This can be done pretty effectively for adding after_save, etc, callbacks to the model that's being extended, since ActiveRecord::Base#after_save is just a class method.

dantswain
  • 5,427
  • 1
  • 29
  • 37

2 Answers2

13

The easiest would be

User.after_add_for_things << lambda do |user, thing| 
  Rails.logger.info "#{thing} added to #{user}"
end

Note: No longer works in Rails 7 (thanks to Andrew Hodgkinson for pointing this out)

glebm
  • 20,282
  • 8
  • 51
  • 67
  • i'm curious on how you were able to find out about this dynamic feature – davidtingsu Jul 16 '13 at 18:21
  • This feature is located in `define_callback` in `lib/active_record/associations/builder/collection_association.rb`. (https://github.com/rails/rails/blob/master/activerecord/lib/active_record/associations/builder/collection_association.rb) – Daniel Rikowski Mar 03 '16 at 09:16
  • *IMPORTANT*: Note that this mechanism is gone in Rails 7 and it looks like it was considered internal previously. The relevant module has `:nodoc:` all over it now, so it's pretty explicit. Change was in https://github.com/rails/rails/commit/bfcac1359e6a1848c07ced1b301a339b54fb260d#diff-0e0ba9f548037ce5d4f147573d47ac5fe619c12f7ebb2d2f24090306a2bf7133. – Andrew Hodgkinson Jan 20 '22 at 01:03
7

I was able to come up with the following by using ActiveRecord::Reflection:

module AfterAdd
  def after_add rel, callback
    a = reflect_on_association(rel)
    send(a.macro, rel, a.options.merge(:after_add => callback))
  end
end

class User < ActiveRecord::Base
  extend AfterAdd

  has_many :user_thing_relationships
  has_many :things, :through => :user_thing_relationships

  after_add :things, :my_callback

  def my_callback
    puts "Hello"
  end
end

I don't want to answer my own question, so I won't give myself answer credit if someone else can come up with a better solution in the next few days.

dantswain
  • 5,427
  • 1
  • 29
  • 37
  • 1
    Sorry for burying this out, but afaik, your callback will leak out, no? I.e. if two threads were to run after_add at the same time, you wouldn't know which callback is valid? – nambrot Mar 19 '15 at 17:16
  • 1
    There is a drawback of your method. If it's a `has_and_belongs_to_many` association, you will call `has_and_belongs_to_many` twice and this will define an extra `dependent: :delete` for `user_thing_relationships` – khiav reoy Jan 13 '19 at 08:48