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.

calendar_date_select_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.

One thought on “Decorating ActiveRecord Accessor Methods

Comments are closed.