Kerry Buckley

What’s the simplest thing that could possibly go wrong?

Archive for April, 2010

Memoised remote attribute readers for ActiveRecord

leave a comment

I was recently working with an ActiveRecord class that exposed some attributes retrieved from a remote API, rather than from the database. The rules for handling the remote attributes were as follows:

  • If the record is unsaved, return the local value of the attribute, even if it’s nil.
  • If the record is saved and we don’t have a local value, call the remote API and remember and return the value.
  • If the record is saved and we already have a local value, return that.

Here’s the original code (names changed to protect the innocent):

[ruby]
class MyModel < ActiveRecord::Base
attr_writer :foo, :bar

def foo
(new_record? || @foo) ? @foo : remote_object.foo
end

def bar
(new_record? || @bar) ? @bar : remote_object.bar
end

def remote_object
@remote_object ||= RemoteService.remote_object
end
end
[/ruby]

The remote_object method makes a call to the remote service, and memoises the returned object (which contains all the attributes we are interested in).

I didn’t really like the duplication in all these accessor methods – we had more than the two I’ve shown here – so decided to factor it out into a common remote_attr_reader class method. Originally I had the method take a block which returned the remote value, but that made the tests more complicated, so I ended up using convention over configuration and having the accessor for foo call a remote_foo method.

Here’s the new code in the model:

[ruby]
class MyModel < ActiveRecord::Base
remote_attr_reader :foo, :bar

def remote_foo
remote_object.foo
end

def remote_bar
remote_object.bar
end

def remote_object
@remote_object ||= RemoteService.remote_object
end
end
[/ruby]

Here's the RemoteAttrReader module that makes it possible:

[ruby]
module RemoteAttrReader
def remote_attr_reader *names
names.each do |name|
attr_writer name
define_method name do
if new_record? || instance_variable_get(“@#{name}”)
instance_eval “@#{name}”
else
instance_eval “remote_#{name}”
end
end
end
end
end
[/ruby]

To make the module available to all models, I added an initialiser containing this line:

[ruby]
ActiveRecord::Base.send :extend, RemoteAttrReader
[/ruby]

Here’s the spec for the module:

[ruby]
require File.dirname(__FILE__) + ‘/../spec_helper’

class RemoteAttrReaderTestClass
extend RemoteAttrReader
remote_attr_reader :foo

def remote_foo
“remote value”
end
end

describe RemoteAttrReader do
let(:model) { RemoteAttrReaderTestClass.new }

describe “for an unsaved object” do
before do
model.stub(:new_record?).and_return true
end

describe “When the attribute is not set” do
it “returns nil” do
model.foo.should be_nil
end
end

describe “When the attribute is set” do
before do
model.foo = “foo”
end

it “returns the attribute” do
model.foo.should == “foo”
end
end
end

describe “for a saved object” do
before do
model.stub(:new_record?).and_return false
end

describe “When the attribute is set” do
before do
model.foo = “foo”
end

it “returns the attribute” do
model.foo.should == “foo”
end
end

describe “When the attribute is not set” do
it “returns the result of calling remote_” do
model.foo.should == “remote value”
end
end
end
end
[/ruby]

To simplify testing of the model, I created a matcher, which I put into a file in spec/support:

[ruby]
class ExposeRemoteAttribute
def initialize attribute
@attribute = attribute
end

def matches? model
@model = model
return false unless model.send(@attribute).nil?
model.send “#{@attribute}=”, “foo”
return false unless model.send(@attribute) == “foo”
model.stub(:new_record?).and_return false
return false unless model.send(@attribute) == “foo”
model.send “#{@attribute}=”, nil
model.stub(“remote_#{@attribute}”).and_return “bar”
model.send(@attribute) == “bar”
end

def failure_message_for_should
“expected #{@model.class} to expose remote attribute #{@attribute}”
end

def failure_message_for_should_not
“expected #{@model.class} not to expose remote attribute #{@attribute}”
end

def description
“expose remote attribute #{@attribute}”
end
end

def expose_remote_attribute expected
ExposeRemoteAttribute.new expected
end
[/ruby]

Testing the model now becomes a simple case of testing the remote_ methods in isolation, and using the matcher to test the behaviour of the remote_attr_reader call(s).

[ruby]
require File.dirname(__FILE__) + ‘/../spec_helper’

describe MyModel do
it { should expose_remote_attribute(:name) }
it { should expose_remote_attribute(:origin_server) }
it { should expose_remote_attribute(:delivery_domain) }

describe “reading remote foo” do
# test as a normal method
end
end
[/ruby]

Technorati Tags: , , , , , , ,

Written by Kerry

April 27th, 2010 at 11:29 am

Posted in Rails,Ruby