Embrace the Metaclass and Extend Your ActiveModels
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!