It’s time for something new

After 14 years we’re hanging up our keyboards. Our team is joining the lovely people at Culture Amp, where we’ll be helping build a better world of work.

Icelab

Embrace the Metaclass and Extend Your ActiveModels

By Tim Riley25 Mar 2011

Part of the challenge in building RentMonkey is dealing with the widely varying set of requirements that each Australian jurisdiction (ie., a state or territory) imposes on the rental tenancy agreement. In practical terms, this means that each instance of our “Lease” class need to store a different set of data and exhibit differing behaviour, depending on its jurisdiction.

One obvious method for tackling this is to cosy up to a a database that is flexible with the format and structure of the data it stores. Something in the NoSQL camp, perhaps. We’re currently doing this with MongoDB. This takes care of the varying data, but what about the object behaviour? We can’t just just put all the custom ActiveModel validations or callbacks for every jurisdiction in our main Lease class, since we only want them to apply in particular cases. Fortunately, Ruby allows us to create new class-level semantics for a single instance of that class, by applying them to that instances metaclass.

The metaclass, also known as the singleton class or eigenclass, is that special place you see other ruby devs accessing via class << self, when inside a class definition, or class << obj, if referring to an object that already exists. In essence, it is a place you can tuck away extra methods for particular objects. Of course, a full explanation is rather more nuanced, but the important thing for us is that we can use the metaclass of a single Lease object to define specific extra ActiveModel-provided behaviour. Let’s get down to some code:

class Lease
  include Mongoid::Document

  field :jurisdiction_code
  field :landlord_name

  validates_presence_of :landlord_name, :jurisdiction_code

  after_initialize :extend_for_jurisdiction

  protected

  def extend_for_jurisdiction
    if self.jurisdiction_code == 'act'
      metaclass = class << self; self; end

      metaclass.class_eval do
        field :landlord_abn
        validates_presence_of :landlord_abn
      end
    end
  end
end

Our basic Lease class defines field for the jurisdiction and the landlord name, and validates that both are present. Then, in an after_initialize callback, we can define extra fields and behaviour for particular jurisdictions. You should note that the above technique can work with any ORM backed by ActiveModel, which includes the trusty ActiveRecord as well as Mongoid, which I’m using above. Anyway, here it is working in practice:

>> l = Lease.new(:jurisdiction_code => 'act')
=> #<Lease _id: 4d8c26eaac50cc135a000002, jurisdiction_code: "act", landlord_name: nil>
>> l.landlord_abn
=> nil
>> l.valid?
=> false
>> l.errors
=> #<OrderedHash {:landlord_name=>["can't be blank"], :landlord_abn=>["can't be blank"]}>

There you go: a lease object with a particular jurisdiction_code gets the extra fields and behaviour we defined in the callback. Now let’s verify it on one with a different code:

>> l = Lease.new(:jurisdiction_code => 'nsw')
=> #<Lease _id: 4d8c27a1ac50cc135a000003, jurisdiction_code: "nsw", landlord_name: nil>
>> l.landlord_abn
NoMethodError: undefined method `landlord_abn' for #<Lease:0x106be6fc0>
>> l.valid?
=> false
>> l.errors
=> #<OrderedHash {:landlord_name=>["can't be blank"]}>

This object just has the fields and behaviour that we’ve defined in the basic class. There you go!

Of course, in our working RentMonkey codebase, our approach is a little less simplistic, but the approach is the same. We’ve developed a nice DSL for jurisdictions that gets appropriately evaluated in the callback:

Jurisdiction.define :act do
  name    'Australian Capital Territory'
  abbrev  'ACT'

  lease do
    clause :other_people_living_at_premises
    clause :nominee_name_for_urgent_repairs
    clause :nominee_number_for_urgent_repairs
    clause :tradespeople_for_urgent_repairs
    clause :fair_clause_for_posted_people, :type => Boolean
    clause :custom_clauses

    validates_presence_of :nominee_name_for_urgent_repairs
    validates_presence_of :nominee_number_for_urgent_repairs
  end
end

Implementing this DSL is a discussion for another day, but know this: learn your metaclass, and you can unshackle your Ruby objects from their original definitions! It will also help greatly with your general Ruby-fu, since it and all the different types of eval form the fundamentals for all metaprogramming in Ruby.

Now, one final trick. If you want to do any of this in Rails 3.0.x, it will not work. You need to apply single monkey-patch to your project. Create a file called lib/activesupport_callbacks_fix.rb and paste the following:

module ActiveSupport
  module Callbacks
    module ClassMethods
      def define_callbacks(*callbacks)
        config = callbacks.last.is_a?(Hash) ? callbacks.pop : {}
        callbacks.each do |callback|
          class_attribute "_#{callback}_callbacks"
          send("_#{callback}_callbacks=", CallbackChain.new(callback, config))
          __define_runner(callback)
        end
      end
    end
  end
end

Then incude it in your application.rb:

require 'lib/activesupport_callbacks_fix'

This is necessary because in the current Rails release (3.0.5 at the time of publishing), the validation callbacks are assigned to their respective class using a method called extlib_inheritable_reader, which doesn’t make the callback chain available in the metaclass. A fix for this was identified in November 2010 and committed to Rails in that same month, but it hasn’t been included with any of the ActiveSupport gem releases since then. Hopefully we’ll see this fixed in Rails 3.1.0, but the above monkey patch means there’s nothing stopping you from doing it now. Get to it! Embrace this dynamism and set your objects free!