Overriding But Preserving Ruby Methods

Recently I found myself needing to overwrite ActiveRecord's default save method but still retain the ability to call the original method. I know, I know, that's crazy talk, right? What could you possibly need to do that for? Well, in my case it was to provide a way to create "drafts" of my models under certain conditions when save is called. Rather than have all sorts of messy logic repeated over an over my controllers or tucked away in an awkward helper method it made much more sense to me to attach the functionality on my models as I need it. The ever so sublime paper_trail gem does something quite similar with ActiveRecord callbacks. But that isn't quite what I needed. What I really wanted was the ability to prevent a model from being saved in the first place. After all, what good is saving a draft if we've overwritten the original in the process? I particularly had in mind a use case where some users could only save drafts, which could be approved at a later time by more privileged users.

So now that we know the why of doing something that at first seems crazy (and more than a bit dangerous), what about the how? The core of how to override but preserve a method is pretty simple, but I think it might be helpful to provide some context, so bear with me.

Just like paper_trail, and many other gems, we start off with the following to get our module to load whenever ActiveRecord is loaded. This ensures that we don't have to manually include our module.

# /lib/kentouzu.rb
ActiveSupport.on_load(:active_record) do
  include Kentouzu::Model
end

Next we define self.included in or Model module so that when it's included we extend the base class with the ClassMethods module. This provides a slew of class methods to our model, the most important of which for the purpose of this post is the has_drafts method.

# /lib/kentouzu/has_drafts.rb
module Kentouzu
  module Model
    def self.included(base)
      base.send :extend, ClassMethods
    end

The has_drafts method provides us with a nice way of making it so we only include our InstanceMethods when we actually need it. It'd be really bad if we always override a vital method like save! If we just included the code to orverride the method without going through this it would lead to all sorts of disasterous behavior as our earlier hook into ActiveSupport#on_load would include it in every model in our application even when it doesn't make sense.

By providing this method we give a nice clean way to add functionality to our models (or really, any class) in the same way paper_trail's has_paper_trail does. Lots of gems take advantage of this pattern.

    module ClassMethods
      def has_drafts options = {}
        send :include, InstanceMethods
      end
    end

Here's where things start to get interesting (and relevant). In our InstanceMethods module we use the same self.included method as before. But this time we call instance_method(:save) on the base class to get an UnboundMethod for save. This allows us to reuse it later.

    module InstanceMethods
      def self.included(base)
        default_save = base.instance_method(:save)

After getting a reference to the old save method we then override it with define_method, sent to the base class. define_method is important because it allows access to the surrounding scope where default_save is defined. This lets us use it even after its out of scope. Inside the block the key is the if statement. It checks for the conditions for using our new save method. In my particular case I check to make sure that everything is enabled on the model (in pretty much the same way paper_trail does) and that the conditions for saving are met and then create draft from the model and save the draft without saving the model. The details of what happens here are up to you.

        base.send :define_method, :save do
          if switched_on? && save_draft?
            draft = Draft.new(:item_type => self.class.base_class.to_s, :item_id => self.id, :event => self.persisted? ? "update" : "create", :source_type => Kentouzu.source.present? ? Kentouzu.source.class.to_s : nil, :source_id => Kentouzu.source.present? ? Kentouzu.source.id : nil, :object => self.to_yaml)

            draft.save

And now for the magic. If the conditions for using our new version of the save method aren't met we take our unbound reference to the old save and bind it to self which, since this is an instance method on our model now, is our model. Finally we call it with the () method. You could also use call.

          else
            default_save.bind(self).()
          end
        end
      end
    end
  end
end

Now whenever we call the save method on our model so long as switched_on? and save_draft? return true we'll get a copy of the model as a draft. Of course we could strip this down to something much simpler without all the fancy including, but in my opinion all that is what makes this so useful, we only get it when and where we want it. That's pretty important because overriding methods like this can be very dangerous. I strongly suggest that before you do this you make sure you actually need to.

The source for the gem this is from is on GitHub.