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]