Finding All ActiveRecord Callbacks

Most of the time ActiveRecord Callbacks are pretty straight forward. But sometimes in larger projects or when using certain gems you can end up with more callbacks happening than you realize. If you're curious about just what is happening when on your model there's no straight forward way that I'm aware of to find out. However, it's actually not too difficult to do yourself.

If you look at the methods available on an ActiveRecord model you'll find several related to callbacks. Here's what we find when inspecting a model that has a Paperclip attachment (you'll see why in a minute).

~/my_project% rails c
Loading development environment (Rails 4.2.0)
2.2.1 :001 > MyModel.methods.select { |method| method.to_s.include?('callback') }
 => [:_validate_callbacks,
 :_save_callbacks,
 :_destroy_callbacks,
 :_commit_callbacks,
 :_post_process_callbacks,
 :_post_process_callbacks?,
 :_post_process_callbacks=,
 :_file_post_process_callbacks,
 :_file_post_process_callbacks?,
 :_file_post_process_callbacks=,
 :_validate_callbacks?,
 :_validate_callbacks=,
 :_validation_callbacks,
 :_validation_callbacks?,
 :_validation_callbacks=,
 :_initialize_callbacks,
 :_initialize_callbacks?,
 :_initialize_callbacks=,
 :_find_callbacks,
 :_find_callbacks?,
 :_find_callbacks=,
 :_touch_callbacks,
 :_touch_callbacks?,
 :_touch_callbacks=,
 :_save_callbacks?,
 :_save_callbacks=,
 :_create_callbacks,
 :_create_callbacks?,
 :_create_callbacks=,
 :_update_callbacks,
 :_update_callbacks?,
 :_update_callbacks=,
 :_destroy_callbacks?,
 :_destroy_callbacks=,
 :_commit_callbacks?,
 :_commit_callbacks=,
 :_rollback_callbacks,
 :_rollback_callbacks?,
 :_rollback_callbacks=,
 :raise_in_transactional_callbacks,
 :raise_in_transactional_callbacks=,
 :define_paperclip_callbacks,
 :normalize_callback_params,
 :__update_callbacks,
 :set_callback,
 :skip_callback,
 :reset_callbacks,
 :define_callbacks,
 :get_callbacks,
 :set_callbacks,
 :define_model_callbacks]

That's a pretty lengthy list, and just by glancing at it we can see several methods like _initialize_callbacks= and skip_callback that aren't likely to be relevant to the problem at hand. The protected method get_callbacks looks promising, but if you look at the source:

def get_callbacks(name)
  send "_#{name}_callbacks"
end

it quickly becomes obvious that it wasn't meant to be used to get a comprehensive list of all the callbacks on a model. Instead it just gives us the callbacks related to one particular event. That's great, but what about when we don't know all of the events? I deliberately chose a model with a Paperclip attachment because Paperclip provides some of its own callback events. They could easily be missed if we assumed only the standard ActiveRecord callbacks were available. Without knowing otherwise before hand that's a fair, but potentially incorrect, assumption.

From get_callbacks we can see that the methods it calls all take the form of "_#{name}_callbacks" where name is the name of the event. Well, a few methods in our list from before seem to match that pattern, so with a little help from a regular expression we can get just those:

2.2.1 :002 > MyModel.methods.select { |method| method.to_s =~ /^_{1}[^_].+_callbacks$/ }
 => [:_validate_callbacks,
 :_save_callbacks,
 :_destroy_callbacks,
 :_commit_callbacks,
 :_post_process_callbacks,
 :_file_post_process_callbacks,
 :_validation_callbacks,
 :_initialize_callbacks,
 :_find_callbacks,
 :_touch_callbacks,
 :_create_callbacks,
 :_update_callbacks,
 :_rollback_callbacks]

This is great, but still not quite what we want. Each of these methods returns an array-like CallbackChain object containing a set of Callback objects:

2.2.1 :003 > MyModel._save_callbacks
 => #<ActiveSupport::Callbacks::CallbackChain:0x007fbf7567e918
 @callbacks=nil,
 @chain=
  [#<ActiveSupport::Callbacks::Callback:0x007fbf7362c098
    @chain_config=
     {:scope=>[:kind, :name],
      :terminator=>
       #<Proc:0x007fbf73237cf8@/Users/sean_eshbaugh/.rvm/gems/ruby-2.2.1@my_project/gems/activemodel-4.2.0/lib/active_model/callbacks.rb:106 (lambda)>,
      :skip_after_callbacks_if_terminated=>true},
    @filter=
     #<Proc:0x007fbf7362c390@/Users/sean_eshbaugh/.rvm/gems/ruby-2.2.1@my_project/gems/paperclip-4.2.1/lib/paperclip/has_attached_file.rb:91>,
    @if=
     [#<ActiveSupport::Callbacks::Conditionals::Value:0x007fbf7362c340
       @block=
        #<Proc:0x007fbf7362c2f0@/Users/sean_eshbaugh/.rvm/gems/ruby-2.2.1@my_project/gems/activemodel-4.2.0/lib/active_model/callbacks.rb:141>>],
    @key=70230125666760,
    @kind=:after,
    @name=:save,
    @unless=[]>,
   #<ActiveSupport::Callbacks::Callback:0x007fbf75684ae8
    @chain_config=
     {:scope=>[:kind, :name],
      :terminator=>
       #<Proc:0x007fbf73237cf8@/Users/sean_eshbaugh/.rvm/gems/ruby-2.2.1@my_project/gems/activemodel-4.2.0/lib/active_model/callbacks.rb:106 (lambda)>,
      :skip_after_callbacks_if_terminated=>true},
    @filter=:autosave_associated_records_for_document,
    @if=[],
    @key=:autosave_associated_records_for_document,
    @kind=:before,
    @name=:save,
    @unless=[]>,
   #<ActiveSupport::Callbacks::Callback:0x007fbf7567ea80
    @chain_config=
     {:scope=>[:kind, :name],
      :terminator=>
       #<Proc:0x007fbf73237cf8@/Users/sean_eshbaugh/.rvm/gems/ruby-2.2.1@my_project/gems/activemodel-4.2.0/lib/active_model/callbacks.rb:106 (lambda)>,
      :skip_after_callbacks_if_terminated=>true},
    @filter=:autosave_associated_records_for_uploader,
    @if=[],
    @key=:autosave_associated_records_for_uploader,
    @kind=:before,
    @name=:save,
    @unless=[]>],
 @config=
  {:scope=>[:kind, :name],
   :terminator=>
    #<Proc:0x007fbf73237cf8@/Users/sean_eshbaugh/.rvm/gems/ruby-2.2.1@my_project/gems/activemodel-4.2.0/lib/active_model/callbacks.rb:106 (lambda)>,
   :skip_after_callbacks_if_terminated=>true},
 @mutex=#<Mutex:0x007fbf7567e8c8>,
 @name=:save>

Each of these has an interesting method named raw_filter which returns either a method name Symbol or a Proc object. Let's see what we get when we inspect that for each of our model's save callbacks:

2.2.1 :004 > MyModel._save_callbacks.map { |callback| callback.raw_filter }
 => [#<Proc:0x007fbf7362c390@/Users/sean_eshbaugh/.rvm/gems/ruby-2.2.1@my_project/gems/paperclip-4.2.1/lib/paperclip/has_attached_file.rb:91>,
 :autosave_associated_records_for_document,
 :autosave_associated_records_for_uploader]

We get an array with a Proc and a couple of Symbols which starts to give us a much better sense of what will happen when we save a model. There's one more important detail though that we've overlooked, each Callback object has a kind property that will tell us whether the callback gets called before, after, or around the event. Let's group our callbacks by kind:

2.2.1 :005 > MyModel._save_callbacks.group_by(&:kind).each { |_, callbacks| callbacks.map! { |callback| callback.raw_filter } }
 => {:after=>
  [#<Proc:0x007fbf7362c390@/Users/sean_eshbaugh/.rvm/gems/ruby-2.2.1@my_project/gems/paperclip-4.2.1/lib/paperclip/has_attached_file.rb:91>],
 :before=>
  [:autosave_associated_records_for_document,
   :autosave_associated_records_for_uploader]}
 => {:after=>[#<Proc:0x007fbf7362c390@/Users/sean_eshbaugh/.rvm/gems/ruby-2.2.1@my_project/gems/paperclip-4.2.1/lib/paperclip/has_attached_file.rb:91>], :before=>[:autosave_associated_records_for_document, :autosave_associated_records_for_uploader]}

Awesome! Finally something that will start to give us real insight into what happens when. But we can still do better, what about all the callbacks? If we combine the regular expression filter of the class methods from before with the above we get a complete picture for the whole model:

2.2.1 :006 > MyModel.methods.select { |method| method.to_s =~ /^_{1}[^_].+_callbacks$/ }.each_with_object({}) { |method, memo| memo[method] = MyModel.send(method).group_by(&:kind).each { |_, callbacks| callbacks.map! { |callback| callback.raw_filter } } }
 => {:_validate_callbacks=>
  {:before=>
    [#<ActiveModel::BlockValidator:0x007fbf7362d3f8
      @attributes=[:file],
      @block=
       #<Proc:0x007fbf7362d510@/Users/sean_eshbaugh/.rvm/gems/ruby-2.2.1@my_project/gems/paperclip-4.2.1/lib/paperclip/has_attached_file.rb:27>,
      @options={}>,
     #<Paperclip::Validators::MediaTypeSpoofDetectionValidator:0x007fbf73624320
      @attributes=[:file],
      @options=
       {:if=>
         #<Proc:0x007fbf736245f0@/Users/sean_eshbaugh/.rvm/gems/ruby-2.2.1@my_project/gems/paperclip-4.2.1/lib/paperclip/has_attached_file.rb:85 (lambda)>}>,
     #<ActiveRecord::Validations::PresenceValidator:0x007fbf7567e440
      @attributes=[:document],
      @options={}>,
     #<ActiveRecord::Validations::PresenceValidator:0x007fbf7567dc20
      @attributes=[:uploader],
      @options={}>,
     #<ActiveRecord::Validations::UniquenessValidator:0x007fbf7567d400
      @attributes=[:file_fingerprint],
      @klass=
       MyModel(id: integer, file_file_name: string, file_content_type: string, file_file_size: integer, file_updated_at: datetime, file_fingerprint: string, created_at: datetime, updated_at: datetime),
      @options=
       {:case_sensitive=>true,
        :if=>
         #<Proc:0x007fbf7567d5b8@/Users/sean_eshbaugh/sites/clickherelabs/hub/app/models/attachment.rb:22 (lambda)>}>,
     #<Paperclip::Validators::AttachmentPresenceValidator:0x007fbf7567c4b0
      @attributes=[:file],
      @options={}>,
     #<Paperclip::Validators::AttachmentSizeValidator:0x007fbf756774d8
      @attributes=[:file],
      @options={:less_than=>1073741824}>,
     #<Paperclip::Validators::AttachmentFileTypeIgnoranceValidator:0x007fbf75676510
      @attributes=[:file],
      @options={}>]},
 :_save_callbacks=>
  {:after=>
    [#<Proc:0x007fbf7362c390@/Users/sean_eshbaugh/.rvm/gems/ruby-2.2.1@my_project/gems/paperclip-4.2.1/lib/paperclip/has_attached_file.rb:91>],
   :before=>
    [:autosave_associated_records_for_document,
     :autosave_associated_records_for_uploader]},
 :_destroy_callbacks=>
  {:before=>
    [#<Proc:0x007fbf73627f48@/Users/sean_eshbaugh/.rvm/gems/ruby-2.2.1@my_project/gems/paperclip-4.2.1/lib/paperclip/has_attached_file.rb:92>]},
 :_commit_callbacks=>
  {:after=>
    [#<Proc:0x007fbf736279f8@/Users/sean_eshbaugh/.rvm/gems/ruby-2.2.1@my_project/gems/paperclip-4.2.1/lib/paperclip/has_attached_file.rb:93>]},
 :_post_process_callbacks=>{},
 :_file_post_process_callbacks=>
  {:before=>
    [#<Proc:0x007fbf75687888@/Users/sean_eshbaugh/.rvm/gems/ruby-2.2.1@my_project/gems/paperclip-4.2.1/lib/paperclip/validators.rb:67>,
     #<Proc:0x007fbf75677b18@/Users/sean_eshbaugh/.rvm/gems/ruby-2.2.1@my_project/gems/paperclip-4.2.1/lib/paperclip/validators.rb:67>,
     #<Proc:0x007fbf75676bf0@/Users/sean_eshbaugh/.rvm/gems/ruby-2.2.1@my_project/gems/paperclip-4.2.1/lib/paperclip/validators.rb:67>]},
 :_validation_callbacks=>{},
 :_initialize_callbacks=>{},
 :_find_callbacks=>{},
 :_touch_callbacks=>{},
 :_create_callbacks=>{},
 :_update_callbacks=>{},
 :_rollback_callbacks=>{}}

And for the sake of reusability we can easily wrap this up in a module (pardon the terrible name):

module ShowCallbacks
  def show_callbacks
    _callback_methods = methods.select do |method|
      method.to_s =~ /^_{1}[^_].+_callbacks$/
    end

    _callback_methods.each_with_object({}) do |method, memo|
      memo[method] = send(method).group_by(&:kind).each do |_, callbacks|
        callbacks.map! do |callback|
          callback.raw_filter
        end
      end
    end
  end
end

class MyModel
  extend ShowCallbacks
  ...
end