Decorating ActiveRecord Accessor Methods
Recently, I was faced with a quandary. I needed to impart some of our ActiveRecord accessor methods with special functionality, but I also needed to maintain the default behavior for those fields. This was done to support a new input type which we added to the venerable CalendarDateSelect plugin which allows a user to enter a date, time, and meridian, all in separate fields.
Though I could parse each of the fields in the controller and pass the resulting time to the model, this is messy, error-prone, and unnecessary.
What I really want to be able to do is something like this:
>> CalendarEvent.new( :start_time => {:date => '6/23/2009', :time => '12:30', :meridian => 'pm'}) => #<CalendarEvent start_time: "2009-06-23 19:30:00">
My first thought was to simply override the accessor methods like so:
CalendarEvent < ActiveRecord::Base ... def start_time=(new_time) self[:start_time] = parse_time_from_hash(new_time) end end
However, as I alluded to before, there’s a problem with this approach. Rails already does some magic behind the scenes to ensure that time values stored to your ActiveRecord objects are automatically cast to and from UTC. The code above will completely overwrite these default accessors, and time zone casting will cease to function for those fields. What I really want to do is decorate these accessor methods, so that I can add to the existing functionality without completely circumventing it.
One way we might think about accomplishing this would be to redefine our accessor method and add behavior to it using alias_method_chain.
def start_time_with_hash_parsing=(new_time) new_time = parse_time_from_hash(new_time) start_time_without_hash_parsing = new_time end alias_method_chain :start_time=, :hash_parsing
Try that, though, and you’ll get this fun little error
`alias_method':NameError: undefined method `start_time=' for class `CalendarEvent'
It doesn’t work because there is no original start_time=
method to alias; ActiveRecord creates those methods only when the class is loaded, because it has to read the columns from the database at runtime. So, how do you decorate methods which haven’t even been created yet? Well, it turns out you have to decorate ActiveRecord::Base.define_attribute_methods
, which is responsible for creating accessor methods for each model.
There are a couple of ways you can go about doing this, depending on your goals. I wanted to be able to use these enhanced date/time inputs with any model in our application and trigger it from an initializer, so my approach uses alias_method_chain
to modify ActiveRecord::Base
directly.
module TimeAttributeExtensions module ClassMethods def define_attribute_methods_with_time_parsing if define_attribute_methods_without_time_parsing columns_hash.each do |name, column| if [:datetime, :timestamp].include?(column.type) unless method_defined?(:"#{name}_without_time_parsing=") define_method("#{name}_with_time_parsing=") do |time| send(:"#{name}_without_time_parsing=", parse_time_from_hash(time)) end alias_method_chain :"#{name}=", :time_parsing end end end return true end end end module InstanceMethods def parse_time_from_hash(time) # Do time parsing here end end def self.included(receiver) receiver.send(:include, InstanceMethods) receiver.extend ClassMethods unless receiver.respond_to?(:define_attribute_methods_without_time_parsing) class << receiver alias_method_chain :define_attribute_methods, :time_parsing end end end end
Ok, so what exactly does that code do? First, we call the original version of define_attribute_methods
. If that returns true, then we know that the attribute accessor methods have been generated. We loop over each column in the model, checking to see whether it stores either a datetime or a timestamp. If so, this is an accessor we’re interested in decorating. We do a quick check to make sure we haven’t already decorated the accessor (which can bite us in development mode), then we redefine the accessor with time parsing, and use the magic of alias_method_chain
to alias our original method but still maintain a reference to it.
At the bottom, we use the Module#included
callback to add the methods contained in our module, and we once again use alias_method_chain
to redefine our version of define_attribute_methods
.
Then, in the initializer, you would write something like
ActiveRecord::Base.send(:include, TimeAttributeExtensions)
At this point, any time-based field in any of your models will be able to accept a hash as a value, and properly parse it.
This works well, but one thing I don’t like about this solution is its rampant use of alias_method_chain
. There has been some debate in the Rails community over its validity as a design choice, though there really are no other options if you need to modify all subclasses of ActiveRecord::Base
(as you would to support a plugin like CalendarDateSelect).
Another Approach
If modifying every subclass isn’t a design requirement, you can clean up the code significantly:
module TimeAttributeExtensions module ClassMethods def define_attribute_methods if super columns_hash.each do |name, column| if [:datetime, :timestamp].include?(column.type) define_method("#{name}=") do |time| super parse_time_from_hash(time) end end end return true end end end module InstanceMethods def parse_time_from_hash(time) # Do time parsing here end end def self.included(receiver) receiver.send(:include, InstanceMethods) receiver.extend ClassMethods end end
Notice here we’re just defining define_attribute_methods
and calling super
when we want access to the previous implementation. The same goes for decorating the actual accessor methods.
The downside of this approach is that you have to explicitly include the module in your ActiveRecord models:
CalendarEvent < ActiveRecord::Base include TimeAttributeExtensions ...
As with any decision, the approach you take will depend on your requirements, and tradeoffs you’re willing to make between explicitness and ease of use.