homeASCIIcasts

212: Refactoring & Dynamic Delegator 

(view original Railscast)

Other translations: Es It

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.

Filtering the list of products.

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.

The page still works.

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 method_missing 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.

The page still works.

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.

The dynamic delegator returns itself rather than its target object.

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.

The page 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.