Quantcast
Channel: Kerry Buckley » Rails
Viewing all articles
Browse latest Browse all 10

Memoised remote attribute readers for ActiveRecord

$
0
0

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:
  1. class MyModel <ActiveRecord::Base
  2.   attr_writer :foo, :bar
  3.  
  4.   def foo
  5.     (new_record? || @foo) ? @foo : remote_object.foo
  6.   end
  7.  
  8.   def bar
  9.     (new_record? || @bar) ? @bar : remote_object.bar
  10.   end
  11.  
  12.   def remote_object
  13.     @remote_object ||= RemoteService.remote_object
  14.   end
  15. 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:

RUBY:
  1. class MyModel <ActiveRecord::Base
  2.   remote_attr_reader :foo, :bar
  3.  
  4.   def remote_foo
  5.     remote_object.foo
  6.   end
  7.  
  8.   def remote_bar
  9.     remote_object.bar
  10.   end
  11.  
  12.   def remote_object
  13.     @remote_object ||= RemoteService.remote_object
  14.   end
  15. end

Here's the RemoteAttrReader module that makes it possible:

RUBY:
  1. module RemoteAttrReader
  2.   def remote_attr_reader *names
  3.     names.each do |name|
  4.       attr_writer name
  5.       define_method name do
  6.         if new_record? || instance_variable_get("@#{name}")
  7.           instance_eval "@#{name}"
  8.         else
  9.           instance_eval "remote_#{name}"
  10.         end
  11.       end
  12.     end
  13.   end
  14. end

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

RUBY:
  1. ActiveRecord::Base.send :extend, RemoteAttrReader

Here's the spec for the module:

RUBY:
  1. require File.dirname(__FILE__) + '/../spec_helper'
  2.  
  3. class RemoteAttrReaderTestClass
  4.   extend RemoteAttrReader
  5.   remote_attr_reader :foo
  6.  
  7.   def remote_foo
  8.     "remote value"
  9.   end
  10. end
  11.  
  12. describe RemoteAttrReader do
  13.   let(:model) { RemoteAttrReaderTestClass.new }
  14.  
  15.   describe "for an unsaved object" do
  16.     before do
  17.       model.stub(:new_record?).and_return true
  18.     end
  19.  
  20.     describe "When the attribute is not set" do
  21.       it "returns nil" do
  22.         model.foo.should be_nil
  23.       end
  24.     end
  25.  
  26.     describe "When the attribute is set" do
  27.       before do
  28.         model.foo = "foo"
  29.       end
  30.  
  31.       it "returns the attribute" do
  32.         model.foo.should == "foo"
  33.       end
  34.     end
  35.   end
  36.  
  37.   describe "for a saved object" do
  38.     before do
  39.       model.stub(:new_record?).and_return false
  40.     end
  41.  
  42.     describe "When the attribute is set" do
  43.       before do
  44.         model.foo = "foo"
  45.       end
  46.  
  47.       it "returns the attribute" do
  48.         model.foo.should == "foo"
  49.       end
  50.     end
  51.  
  52.     describe "When the attribute is not set" do
  53.       it "returns the result of calling remote_<attribute>" do
  54.         model.foo.should == "remote value"
  55.       end
  56.     end
  57.   end
  58. end

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

RUBY:
  1. class ExposeRemoteAttribute
  2.   def initialize attribute
  3.     @attribute = attribute
  4.   end
  5.  
  6.   def matches? model
  7.     @model = model
  8.     return false unless model.send(@attribute).nil?
  9.     model.send "#{@attribute}=", "foo"
  10.     return false unless model.send(@attribute) == "foo"
  11.     model.stub(:new_record?).and_return false
  12.     return false unless model.send(@attribute) == "foo"
  13.     model.send "#{@attribute}=", nil
  14.     model.stub("remote_#{@attribute}").and_return "bar"
  15.     model.send(@attribute) == "bar"
  16.   end
  17.  
  18.   def failure_message_for_should
  19.     "expected #{@model.class} to expose remote attribute #{@attribute}"
  20.   end
  21.  
  22.   def failure_message_for_should_not
  23.     "expected #{@model.class} not to expose remote attribute #{@attribute}"
  24.   end
  25.  
  26.   def description
  27.     "expose remote attribute #{@attribute}"
  28.   end
  29. end
  30.  
  31. def expose_remote_attribute expected
  32.   ExposeRemoteAttribute.new expected
  33. 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).

RUBY:
  1. require File.dirname(__FILE__) + '/../spec_helper'
  2.  
  3. describe MyModel do
  4.   it { should expose_remote_attribute(:name) }
  5.   it { should expose_remote_attribute(:origin_server) }
  6.   it { should expose_remote_attribute(:delivery_domain) }
  7.  
  8.   describe "reading remote foo" do
  9.     # test as a normal method
  10.   end
  11. end

Technorati Tags: , , , , , , ,


Viewing all articles
Browse latest Browse all 10

Latest Images

Trending Articles





Latest Images