Appreciating Rails - Part 1.1 : Model Callbacks

I've used Ruby on Rails for 3 years now, and I've dug into the code base once in a while, either to just find out how something works, or to figure out some issues.

But I don't think I've completely understood why a particular feature was built the way it was. I wanted to understand why things were written the way they were. I wanted to get into the shoes of people who wrote it.

"There's no better way to learn, than to reinvent the wheel" - a friend.

So in this series I attempt to build out smaller chunks that make up Rails, without peeking into the codebase, but just knowing what the final result should be like. And then compare the solution that I come up with, with the actual battle tested code in Rails. And hopefully learn a thing or two in the process.

Here we go.

The aim is to build the Model Callback system which Rails has.

So the methods that we need :

before_save, after_save, around_save  
before_create, after_create, around_create  
before_update, after_update, around_update  
after_commit  
before_destroy, after_destroy, around_destroy  

These are methods that take a symbol as its first argument ( which is a name of another method) followed by other options.

Our models in Rails inherit from ActiveRecord::Base and they automatically get these methods.

So, I'm going to make a module that has the above functions defined.

Quick Question (QQ) : Why should it be a module and not a class?

I think this functionality might be required in existing classes that are already inheriting from other classes. And the easiest way to drop this functionality into those classes would be to include the module.

QQ : Are these methods class methods or instance methods?

A : I didn't know for sure, but guessed they would be class methods as we are calling the methods inside class scope.

Example :

class User < ActiveRecord::Base  
    # scope here is class scope
    # And we are sending this message 'before_save' on current scope, 
    # so it should be on the class
    before_save :my_callback
end  

But the best way to confirm was fire up the rails c

Calling before_save on User (which is a Rails model) class doesn't give any error. But calling it on an instance of User does.

So that settles it : The callbacks are class methods.

Which means our module has to be extended and not included. But that leaves the decision to the user. Which I do not think is right. We dont want someone to include our module and run into issues, as these methods then become instance methods on the class and not class methods.

Is there a way that could give the control to us, such that we can decide irrespective of our module being extended or included, we can control which methods get defined on the instance and which methods get defined on the class.

module Callbacks  
  def self.included(base)
    base.extend(ClassMethods)
  end

  def self.extended(base)
    base.extend(ClassMethods)
  end

  module ClassMethods
    def before_save(*args)
      puts "class before_save"
    end

    def after_save(*args)
      puts "class after_save"
    end

    def around_save(*args)
      puts "class around_save"
    end
  end

end  
class Test  
  extend Callbacks
  before_save :blah
  def blah
  end
end  

Notice that I have used both included and extended hooks in our module, so if the module is either extended or included, it does the same thing. i.e defines the callback methods as class methods.

Also notice the (*args) as parameter to the methods. I did this because if you check the 1st image above, running User.before_save did not give any wrong number of arguments error.

Now, we need to simulate a model save, and make sure we are calling appropriate callbacks.

class Test  
  include Callbacks
  before_save :blah1

  def save
    # have to call all before_saves here
    _save
    # have to call all after_saves here
    # have to call all around_saves here
  end

  private

  def _save
    #this is where the actual save would happen
    puts "actual save happening"
  end
end  

Notice that I have a _save method and a save method.
Since Rails exposes save , there must be another method where the 'actual' save is happening (that's what I'm calling _save), so they can wrap the callbacks around the actual save ( _save ) method.

So we need to collect all our before_saves (in an Array maybe?) and call them before calling _save in our public save.

Let's get to it.

  module ClassMethods
    def before_save(*args)
      add_before_save_callback(*args)
    end

    def after_save(*args)
      puts "class after_save"
    end

    def around_save(*args)
      puts "class around_save"
    end

    def before_save_callbacks
      @_before_saves
    end

    private

    def add_before_save_callback(*args)
      (@_before_saves ||= []) << args
    end
  end

  class Test
    include Callbacks
    before_save :blah1

    def save
      self.class.before_save_callbacks.each do |callback|
        self.send(callback.first.to_sym)
      end
      _save
      # have to call all after_saves here
      # have to call all around_saves here
    end

    private

    def _save
      #this is where the actual save would happen
      puts "actual save happening"
    end
  end  

Note the following changes :

  1. @_before_saves instance variable :
    This is the instance variable where I'm storing all the before_save 'callbacks' (which for now are nothing but method names)

  2. the add_before_save_callback method
    This method pushes a 'callback' into our before save callback collection instance variable, whenever a before_save is called.

  3. before_save_callbacks method
    This returns all before save callbacks.

  4. The block of code :

self.class.before_save_callbacks.each do |callback|  
  self.send(callback.first.to_sym)
end  

Notice the self. above. Since before_save_callbacks method is defined inside ClassMethods module, this gets added as a class method on our 'model'.

Repeating this for other callback types.

A complete working ( sort of ) setup for save callbacks :

module Callbacks  
  def self.included(base)
    base.extend(ClassMethods)
  end

  def self.extended(base)
    base.extend(ClassMethods)
  end

  module ClassMethods
    def before_save(*args)
      add_before_save_callback(*args)
    end

    def after_save(*args)
      add_after_save_callback(*args)
    end

    def around_save(*args)
      add_around_save_callback(*args)
    end

    def before_save_callbacks
      @_before_saves
    end

    def after_save_callbacks
      @_after_saves
    end

    def around_save_callbacks
      @_around_saves
    end
    private

    def add_before_save_callback(*args)
      (@_before_saves ||= []) << args
    end

    def add_after_save_callback(*args)
      (@_after_saves ||= []) << args
    end

    def add_around_save_callback(*args)
      (@_around_saves ||= []) << args
    end
  end
end

class Test  
  include Callbacks
  before_save :blah1
  after_save :blah2
  around_save :blah3


  def save
    run_before_save_callbacks
    _save
    run_after_save_callbacks
    run_around_save_callbacks
  end

  private

  def run_before_save_callbacks
    self.class.before_save_callbacks.each do |callback|
      self.send(callback.first.to_sym)
    end
  end

  def run_after_save_callbacks
    self.class.after_save_callbacks.each do |callback|
      self.send(callback.first.to_sym)
    end
  end

  def run_around_save_callbacks
    self.class.around_save_callbacks.each do |callback|
      self.send(callback.first.to_sym)
    end
  end

  def _save
    #this is where the actual save would happen
    puts "actual save happening"
  end

  def blah1
    puts " i'm in blah1 "
  end

  def blah2
    puts " i'm in blah2 "
  end

  def blah3
    puts " i'm in blah3 "
  end
end  

Trying out Test.new.save returns the following output :

This works, but the code would get too repetitive once we do this for update, create, destroy and commit callbacks too.

Can we use some metaprogramming technique and reuse our code ?

We'll try that out in the next post.

Murtuza Kutub

Read more posts by this author.

Chennai, India

Subscribe to Murtuza's Blog

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!