Using ActiveRecord Composed Of
1:02 PM EDT Saturday, April 19 2008
ActiveRecord has a feature called composed_of that allows you to take multiple fields of a model and treat them as one object. Let's say you have a requirement for your application to track the phone number of each user. Also, you need to be able to query for users by parts of the phone number like area code, prefix, line number and extenstion. So the phone number (212) 123-4567 x55 needs to be stored in separate fields as :area_code => 212, :prefix => 123, :line_number => 4567 and :extenstion => 55. So first we create a rails app with a user record with those fields:
$ rails myapp
$ cd myapp
$ script/generate model user name:string \
phone_number_area_code:integer \
phone_number_prefix:integer \
phone_number_line_number:integer \
phone_number_extension:integer
$ rake db:migrate
What we would like to do is treat all of those phone_number_* columns as one object. So first let's create a base object that has some of the plumbing functionality. Put this in app/models/value_object.rb:
class ValueObject
class << self
def has_fields(*args)
(class << self; self; end).send(:define_method, :fields) do
args.map(&:to_s)
end
fields.each{|f| attr_reader f}
end
def mapping
fields.map{|e| ["#{name.underscore}_#{e}", e]}
end
def mapper
lambda do |params|
PhoneNumber.new *PhoneNumber.fields.map{|e| params[e].to_i}
end
end
end
def initialize(*args)
args.each_with_index do |a, i|
instance_variable_set "@#{self.class.fields[i]}", args[i]
end
end
def to_s
"(#{area_code}) #{prefix}-#{line_number} x#{extension}"
end
def ==(o)
o && self.class.fields.all?{|f| self.send(f) == o.send(f)}
end
end
And here's our subclass of that specific for the phone number. Add the PhoneNumber class at app/models/phone_number.rb:
class PhoneNumber < ValueObject
has_fields :area_code, :prefix, :line_number, :extension
def to_s
"(#{area_code}) #{prefix}-#{line_number} x#{extension}"
end
end
Rather than explain what this does, I'll just give you the specs:
require File.dirname(__FILE__) + '/../spec_helper'
describe PhoneNumber do
before(:each) do
@phone_number = PhoneNumber.new(212, 123, 4567, 55)
end
it "should have an area_code getter" do
@phone_number.area_code.should == 212
end
it "should have an area_code getter" do
@phone_number.prefix.should == 123
end
it "should have an area_code getter" do
@phone_number.line_number.should == 4567
end
it "should have an area_code getter" do
@phone_number.extension.should == 55
end
describe "#==" do
it "should be equal if both number match" do
@phone_number.==(PhoneNumber.new(212, 123, 4567, 55)).should be_true
end
it "should not be equal if both numbers do not match" do
@phone_number.==(PhoneNumber.new(212, 123, 4567, 54)).should be_false
end
end
describe "#to_s" do
it "should format the phone number as (212) 123-4567 x55" do
@phone_number.to_s.should == "(212) 123-4567 x55"
end
end
end
After you've had a chance to read through that, it should be pretty clear what the PhoneNumber class does. Next we modify the User model to use this class:
class User < ActiveRecord::Base
composed_of :phone_number, :mapping => PhoneNumber.mapping, &PhoneNumber.mapper
end
The ValueObject base class provides us with the methods mapping and mapper, which are needed to get our User model's phone_number method to do what we want. Here's what we want it to do:
it "should be able to set phone number from hash" do
@user.phone_number = {"area_code" => "212", "prefix" => "123",
"line_number" => "4567", "extension" => "55"}
@user.phone_number.should == PhoneNumber.new(212, 123, 4567, 55)
end
it "should be able to find a user by area code" do
@user.phone_number = PhoneNumber.new(212, 123, 4567, 55)
@user.save
@user.should == User.find_by_phone_number_area_code(212)
end
Now we can treat our phone number as a single object, but collect and store the values as separate fields.