Categories
Rails Ruby

Memoised remote attribute readers for ActiveRecord

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_" 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

[tags]ruby,rails,activerecord,metaprogramming,rspec,matcher,refactoring,dry[/tags]

Leave a Reply