Friday, February 09, 2007

Ruby: Forwardable addition

Almost every project I work on ends up using the Forwardable module included in the Ruby Standard Library. In fact, I use it so often I thought it was worth putting together an entry about using Forwardable to avoid violating the Law of Demeter.

These days I'm working on a project that aggregates a large amount of data on almost every screen. To increase testability of the application we introduced the Presenter pattern. I've previously written an entry introducing the Presenter pattern and a follow up with an additional Presenter example.

In practice our presenters almost always use Forwardable. For example a summary page could display shipping and billing information.
class SummaryPresenter
def_delegator :shipping_address, :first_name, :shipping_first_name
def_delegator :shipping_address, :last_name, :shipping_last_name
def_delegator :shipping_address, :first_name=, :shipping_first_name=
def_delegator :shipping_address, :last_name=, :shipping_last_name=
...
def_delegator :billing_address, :first_name, :billing_first_name
def_delegator :billing_address, :last_name, :billing_last_name
def_delegator :billing_address, :first_name=, :billing_first_name=
def_delegator :billing_address, :last_name=, :billing_last_name=
...
end
Now, I love forwardable, but it's pretty clear that I can come up with a better solution that will allow me to clean up the def_delegator code.

We decided to create another method, delegate_to, which looks very similar to def_delegators, but also takes a hash that allows you to specify a prefix. After making this change the code can be cleaned up to the following.
class SummaryPresenter
delegate_to :shipping_address, :first_name, :last_name, ..., :prefix => 'shipping'
delegate_to :shipping_address, :first_name=, :last_name=, ..., :prefix => 'shipping'
delegate_to :billing_address, :first_name, :last_name, ..., :prefix => 'billing'
delegate_to :billing_address, :first_name=, :last_name=, ..., :prefix => 'billing'
end
This was a good first step, but there is obviously more we can clean up.

We also added the ability to create writers by simply adding :writer => true to the options hash. Following this change the code is concise but just as readable as the original version, if not more because of the noise reduction.
class SummaryPresenter
delegate_to :shipping_address, :first_name, :last_name, ...,
:prefix => 'shipping', :writer => true
delegate_to :billing_address, :first_name, :last_name, ...,
:prefix => 'billing', :writer => true
end
Below is the full code required to add this functionality.
module ForwardableExtension
def delegate_to(object, *args)
options = args.last.is_a?(Hash) ? args.pop : {}
args.each do |element|
def_delegator object, element, name_with_prefix(element, options).to_sym
if options[:writer]
def_delegator object, :"#{element}=", :"#{name_with_prefix(element, options)}="
end
end
end

protected
def name_with_prefix(element, options)
"#{options[:prefix] + "_" unless options[:prefix].nil? }#{element}"
end
end

Forwardable.send :include, ForwardableExtension
And, of course, the tests.
require File.dirname(__FILE__) + '/../unit_test_helper'

class ForwardableExtensionTest < Test::Unit::TestCase
class Bar
attr_accessor :cat, :dog, :monkey
end

class Foo
extend Forwardable

delegate_to :bar, :cat, :dog, :prefix => 'baz', :writer => true
delegate_to :bar, :monkey

def bar
@bar ||= Bar.new
end
end

test "getter and setter are delegated correctly" do
f = Foo.new
f.baz_cat = "cat"
assert_equal "cat", f.baz_cat

f = Foo.new
f.baz_dog = "dog"
assert_equal "dog", f.baz_dog
end

test "setter is created only when writer is true" do
f = Foo.new
assert_raises NoMethodError do
f.monkey = "raise"
end
end
end
Post a Comment