212: Refactoring & Dynamic Delegator
(view original Railscast)
This week’s episode is a little different. It’s an exercise in refactoring that will show a fun Ruby technique that we’ll call Dynamic Delegator.
To demonstrate this technique we’ll use a simple store application that has a Product
model with an associated ProductsController
. The controller’s index
action allows the list of products to be filtered by name and price. By supplying one or more of the parameters name
, price_lt
and price_gt
in the querystring we can search for products that match name and price criteria finding, say, all products whose name contains “video” and that cost more that £50.
Before we refactor the index action let’s take a look at it to see what it does.
/app/controllers/products_controller.rb
class ProductsController < ApplicationController def index @products = Product.scoped @products = @products.where("name like ?", "%" + params[:name] + "%") if params[:name] @products = @products.where("price >= ?", params[:price_gt]) if params[:price_gt] @products = @products.where("price <= ?", params[:price_lt]) if params[:price_lt] end # Other actions end
This is a Rails 3 application so we’re using the where
method to add conditions to the query if the relevant parameter has been passed. Before we do that though we use Product.scoped
to get all of the products. This is a method you might not be familiar with but it is essentially another way to say Product.all
. The difference is that the all
method will trigger a database query as soon as it is executed and return an array of products. We don’t want to make a database call until we’ve applied our filters and by using scoped
we can add conditions to the query before it is executed.
Now let’s look at refactoring the action. The first step we’ll take will be to move out some of the logic from the controller as it doesn’t really belong there. In any object-orientated language if you find yourself in one object calling a lot of methods on another it generally means that you should move that logic into the other object. In this case in the ProductController
class’s index action we’re calling four methods on the Product
model to create our search and this suggests that this code belongs in the model instead.
We’ll replace the code in the index
action with a call to a new class method in the Product
model called search
, passing in the params
hash so it knows what to search against.
/app/controllers/products_controller.rb
class ProductsController < ApplicationController def index @products = Product.search(params) end # Other actions end
Next we’ll define the method in the Product
model. We want the method to be a class method so we’ll define it as self.search
. The code in the method is the same as we had in the controller but with a local variable replacing the instance variable we had, that variable being returned at the end of the method.
/app/models/product.rb
class Product < ActiveRecord::Base belongs_to :category def self.search(params) products = scoped products = products.where("name like ?", "%" + params[:name] + "%") if params[:name] products = products.where("price >= ?", params[:price_gt]) if params[:price_gt] products = products.where("price <= ?", params[:price_lt]) if params[:price_lt] products end end
If we reload the page now we’ll see that it looks like we haven’t broken anything.
Of course by reloading the page we’ve only proved that the code changes work for those specific parameters and it’s in scenarios like that that Test-Driven Development really proves itself. Reloading the page gets tedious fairly quickly and doesn’t test every branch of the code as the search is built up differently depending on the parameters that are passed. It’s a good idea, especially when you’re refactoring code, to have a comprehensive test suite so that you can be as sure as possible that your changes haven’t introduced any unexpected side effects.
Moving this code into the model has the additional benefit of making it easier to test, too, as we only have to write a unit test against the model code now instead of a full test across the entire stack.
Introducing The Dynamic Delegator
We’ve refactored our code a little by moving the search logic into the model and we’re going to take it a little further by removing the need to reset the products
variable every time we add a find condition. This is a common pattern when dealing with search options and if you see this a lot in your applications you might consider the technique we’re about to show, which we’ve called a dynamic delegator.
Rather than explain how a dynamic delegator works we’ll show you by using one to refactor our search code. We’ll start by creating the dynamic delegator class in our application’s /lib
directory.
/lib/dynamic_delegator.rb
class DynamicDelegator def initialize(target) @target = target end def method_missing(*args, &block) @target.send(*args, &block) end end
The DynamicDelegator
class takes one argument in its initializer, a target object, and sets an instance variable to that object. It also overrides so that any calls to this object which aren’t supported are caught and passed to the target object instead which the same methods and block.
We can think of our DynamicDelegator
as a proxy object that passes any calls to it to its target object and this means that we can use it anywhere we want. If we pass it a target object it will behave as if it were that object. This means that we can replace the scoped
object in our Product
’s search method with a new DynamicDelegator
that takes that object as an argument.
/app/models/product.rb
class Product < ActiveRecord::Base belongs_to :category def self.search(params) products = DynamicDelegator(scoped) products = products.where("name like ?", "%" + params[:name] + "%") if params[:name] products = products.where("price >= ?", params[:price_gt]) if params[:price_gt] products = products.where("price <= ?", params[:price_lt]) if params[:price_lt] products end end
We can check that this has worked by reloading that page again where we should see the same set of results.
This has worked but at this point you’re probably wondering what the point of it using a DynamicDelegator
rather than the original scoped
object is. The advantage of the delegator is that we can do whatever we like inside method_missing
. Instead of always delegating the same thing to the target we can modify our target and make it more dynamic.
For example, we want to capture the result of the method call in method_missing
and, if it returns an object of the same class as the target, set the target to the result.
/lib/dynamic_delegator.rb
class DynamicDelegator def initialize(target) @target = target end def method_missing(*args, &block) result = @target.send(*args, &block) @target = result if result.kind_of? @target.class result end end
Now we can remove the code that resets the products
variable in each line of the search
method in the Product
model.
/app/models/product.rb
class Product < ActiveRecord::Base belongs_to :category def self.search(params) products = DynamicDelegator.new(scoped) products.where("name like ?", "%" + params[:name] + "%") if params[:name] products.where("price >= ?", params[:price_gt]) if params[:price_gt] products.where("price <= ?", params[:price_lt]) if params[:price_lt] products end end
We can do this because calling where will return the same kind of object as scoped
is and so the target will be replaced each time. Let’s reload the page again and see if our page still works.
It doesn’t and the reason why is that we’re not delegating all of the methods to our target object. In this case the troublemaker is the class
method and we can use the console to show why. If we call Product.search
with an empty hash and call class
on the results we’ll see a DynamicDelegator
.
ruby-head > Product.search({}).class => DynamicDelegator
So, our dynamic delegator isn’t delegating everything to the target object as it has some methods defined on itself. This is because the DynamicDelegator
class inherits from Object
and Object
has a lot of methods defined on it, including class
.
ruby-head > Object.instance_methods.count => 108 ruby-head > Object.instance_methods.grep /class/ => [:subclasses_of, :class_eval, :class, :singleton_class]
We need a cleaner slate to start from and in Ruby 1.9 there’s another class we can use called BasicObject which has far fewer methods.
ruby-head > BasicObject.instance_methods => [:==, :equal?, :!, :!=, :instance_eval, :instance_exec, :__send__]
This class makes for a much better base when trying to make a delegator or proxy object which uses method_missing
to override method behaviours. We can change the DynamicDelegator
to inherit from BasicObject
so that the class
method won’t be defined and the call to it will fall through to method_missing
.
/lib/dynamic_delegator.rb
class DynamicDelegator < BasicObject def initialize(target) @target = target end def method_missing(*args, &block) result = @target.send(*args, &block) @target = result if result.kind_of? @target.class result end end
If we reload the page now it works again.
There is some more refactoring that we could consider doing to the Product
model. The DynamicDelegator
doesn’t express its intention very clearly and so we could write a method in the Product
class called scope_builder
and create the DynamicDelegator
there.
/app/models/product.rb
class Product < ActiveRecord::Base belongs_to :category def self.search(params) products = scope_builder products.where("name like ?", "%" + params[:name] + "%") if params[:name] products.where("price >= ?", params[:price_gt]) if params[:price_gt] products.where("price <= ?", params[:price_lt]) if params[:price_lt] products end def self.scope_builder DynamicDelegator.new(scoped) end end
Now it’s clearer to see that we’re dealing with a scope that we’re building up dynamically. If we’re using this technique in multiple model records then we could move this scope_builder
method into ActiveRecord::Base
so that it’s available in all models. This is something we could do in an initializer.
That’s it for this episode. This might seem like a simple technique but if you’re building up a lot of queries then it can clean your code up quite a bit.