The Chain Gang

2010-10-25 17:42

The Chain Gang

by John Nunemaker

at 2010-10-25 09:42:28

original http://feedproxy.google.com/~r/railstips/~3/NMKB85YznZI/

Chain-able interfaces are all the rage — jQuery, ARel, etc. The thing a lot of people do not realize is how easy they are to create. Lets say we want to make the following work:

User.where(:first_name => 'John')
User.sort(:age)
User.where(:first_name => 'John').sort(:age)
User.sort(:age).where(:first_name => 'John')

First, we need to have a class method for both where and sort because we want to allow either one of them to be called and chained on each other.

class User
  def self.where(hash)
  end

  def self.sort(field)
  end
end

User.where(:first_name => 'John')
User.sort(:age)

Now we can call where or sort and we do not get errors, but we still cannot chain. In order to make this magic happen, lets make a query class. The query needs to know what model, what where conditions and what field to sort by.

class Query
  def initialize(model)
    @model = model
  end

  def where(hash)
    @where = hash
  end

  def sort(field)
    @sort = field
  end
end

Now that we have this, lets create new query objects when the User class methods are called and pass the arguments through.

class User
  def self.where(hash)
    Query.new(self).where(hash)
  end

  def self.sort(field)
    Query.new(self).sort(field)
  end
end

We might think we are done at this point, but the sauce that makes this all work is still missing. If you try our initial example, you end up with a cryptic error message.

ArgumentError: wrong number of arguments (1 for 0)

The reason is that in order for this to be chainable, we have to return self in Query#where and Query#sort.

class Query
  def initialize(model)
    @model = model
  end

  def where(hash)
    @where = hash
    self
  end

  def sort(field)
    @sort = field
    self
  end
end

Now, if we put it all together, you can see that this is the basics of creating a chain-able interface. Simply, do what you need to do and return self.

class Query
  def initialize(model)
    @model = model
  end

  def where(hash)
    @where = hash
    self
  end

  def sort(field)
    @sort = field
    self
  end
end

class User
  def self.where(hash)
    Query.new(self).where(hash)
  end

  def self.sort(field)
    Query.new(self).sort(field)
  end
end

puts User.where(:first_name => 'John').inspect
puts User.sort(:age).inspect
puts User.where(:first_name => 'John').sort(:age).inspect
puts User.sort(:age).where(:first_name => 'John').inspect

# #<Query:0x101020268 @model=User, @where={:first_name=>"John"}>
# #<Query:0x101020060 @model=User, @sort=:age>
# #<Query:0x10101fe30 @model=User, @where={:first_name=>"John"}, @sort=:age>
# #<Query:0x10101fbb0 @model=User, @where={:first_name=>"John"}, @sort=:age>

Conclusion

From here, all we need to do is define kickers, such as all, first, last, etc. that actually assemble and perform the query and return results. Hope this adds a little something to your repertoire next time you are building an interface. It does not work in every situation, but when applied correctly it can improve the usability of a library.

If you are interested in more on this, feel free to peak at the innards of Plucky, which provides a chain-able interface for querying MongoDB.