Extending ActiveRecord Attributes

David Black came to speak at the Boston Ruby Group on Tuesday (along with Zed Shaw, making it the most star-studded event since I started going). It was in one of the lecture halls at the Harvard Science Center (which, if you follow the pattern of every single other building on campus, is named after the "Science Center" family; seems like it should be Merrill, or Morrill, or something instead--here's your chance). I was expecting stand-up comedy, which turned out to be an unreasonable expectation because it was in lecture hall A, not C (which is where HSUCS (a group of stand-up comedians at Harvard) has had events in the past). But I digress.

David's talk was about "Per Object Behavior in Ruby" and about treating it as a prototype language (a logical follow-up to Dave Thomas' RailsConf keynote; he didn't disappoint and provided classless code (except for the tests; that would have been a nice touch)). I won't go much into the substance of the talk, except to say that he managed to clarify the meaning and use of an object's "singleton class" / "eigenclass" as well as further emphasizing that class != type, but that a class is merely a template from which new objects spring. He encouraged people to take advantage of Ruby's ability to extend individual instances of objects with methods (generally via modules), as that's a considerable part of what makes Ruby Ruby.

I was reminded that I had written some ActiveRecord code this spring that allows "primitive" (String, Fixnum) attributes of AR objects to be embellished by extending the attributes' objects before returning them from the containing object.

In our case, we had a notion of FormattedText objects, which were essentially Strings that could contain HTML. Unfiltered HTML, as it turned out. Rather than filtering during the input stage, we decided that we would store what the user provided and leave it to the output stage to handle the filtering. Instead of using a helper method in each and every case (with the downside of having to remember to use it), I decided that the filtering functionality would fit better within the object (so it would be used appropriately in the majority case without any extra thought). In this case, by overriding String#to_s.

module FormattedText
  def to_s
    # filter and output
  end
end

The next step was making sure that only attributes known to contain formatted text were extended by FormattedText (and that the internal representation remained untouched, so that the filtered version wouldn't be saved back to the database). A side-effect of this is that changing the value that was returned and saving the object will not save the new value back to the database; to get around this, you'll need to set the value on the AR object itself.

# Quick and dirty monkey-patch (should work with 1.1.x+)
class ActiveRecord::Base
  # Attribute extension
  cattr_accessor :extended_attributes
  @@extended_attributes = {}

  class << self
    # create the "extends" declaration
    def extends(attr_name, options)
      extended_attributes[attr_name.to_sym] = options[:with]
    end
  end

  alias_method :define_read_method_without_extends, :define_read_method
  def define_read_method_with_extends(symbol, attr_name, column)
    unless self.class.read_methods.include?(attr_name) ||
           self.class.extended_attributes.keys.include?(attr_name.to_sym)
      define_read_method_without_enhances(symbol, attr_name, column)
    else
      old_method_name = "#{symbol}_without_extends"
      define_read_method_without_extends(old_method_name.to_sym, attr_name, column)
      evaluate_read_method attr_name, "def #{symbol}; enhance(#{old_method_name}, '#{attr_name}'); end"
    end
  end
  alias_method :define_read_method, :define_read_method_with_extends

  # Override read_attribute for first call (pre-edge)
  # or when AR::Base.generate_read_methods = false
  alias_method :read_attribute_without_extends, :read_attribute
  def read_attribute_with_extends(attr_name)
    enhance(read_attribute_without_extends(attr_name), attr_name)
  end
  alias_method :read_attribute, :read_attribute_with_extends

  private

  # Enhance the attribute as appropriate
  def enhance(value, attr_name)
    mod = self.class.extended_attributes[attr_name.to_sym]
    if mod && !value.nil?
      value.dup.extend(mod)
    else
      value
    end
  end
end

An alternate way to implement this pattern would have been to use composed_of and to create a FormattedText class that extend String. That may be better for performance reasons (as it seems that it would simplify method lookup), but strikes me as being slightly less Rubylike.

What do you think?