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.