Kerry Buckley

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

Memoised remote attribute readers for ActiveRecord

one 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):

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

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:

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

Here's the RemoteAttrReader module that makes it possible:

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

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

ActiveRecord::Base.send :extend, RemoteAttrReader

Here's the spec for the module:

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_<attribute>" do
        model.foo.should == "remote value"
      end
    end
  end
end

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

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

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).

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

Technorati Tags: , , , , , , ,

Written by Kerry

April 27th, 2010 at 11:29 am

Posted in Rails,Ruby

One Response to 'Memoised remote attribute readers for ActiveRecord'

Subscribe to comments with RSS or TrackBack to 'Memoised remote attribute readers for ActiveRecord'.

  1. click the following page

    Memoised remote attribute readers for ActiveRecord at Kerry Buckley

Leave a Reply

You must be logged in to post a comment.