October 24, 2010

Posted by John

Tagged ruby

Older: Stop Googling

Newer: Hunt, An Experiment in Search

The Chain Gang

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.

10 Comments

  1. modsognir modsognir

    Oct 24, 2010

    What are the benefits (in your eyes) in doing it as a seperate class instead of including it in the main class?

    I think this is a cool technique regardless.

  2. What about User.where(:first_name=&gt;'John').where(:last_name=&gt;'Nunemaker') ? Instead of simple variables there should be a stack for wheres and sorts, so they don’t override earlier ones.

  3. Katie’s right. This implementation wouldn’t work when you chain two of the same query method.

  4. @Katie: the intent was not to show how to build a query class but rather how to chain. If one was building a query class you would have to keep track of that.

  5. Katie et al: In case the question is of how you could do it, just initialize a hash if one doesn’t exist and then do a merge. Example:

    def where(hash)
      @where ||= {}
      @where.merge!(hash)
    end
  6. @Peter Cooper: Yep. One could also do some kind of smart deep merging, which is basically what plucky does.

  7. One of the strange things that happens in Rails 3 is when you call a missing method on a model you get an method missing error on Relation instead of your model. It’s pretty confusing and I suspect it’s due to returning an instance of your query class instead of the actual class you’re working with.

    In the case of your example do you know a reason we wouldn’t want to return the User class instead of the Query instance? It would still work and seems it would be much better for debugging. Thoughts?

  8. @Joe, I was thinking the same thing. The only reason I can see for returning the Query object instead of the User class, is that if you return the User class, then when you chain the methods, it would create a new Query object for each method.

    Then again, seems like that could be solved by creating a User #query instance method that stores the Query as an attribute of User. Then you could use a Query = self.query || Query.new in both User class-level methods.

    
    class User
      def query=(obj)
        @query = obj
      end
      def query
        @query
      end
      def self.where(hash)
        self.query ||= Query.new
        self.query.where(hash)
      end
      def self.sort(field)
        self.query ||= Query.new
        self.query.sort(field)
      end
    end
    
  9. Duh, I realized in the car, that my previous example was trying to reference instance methods from the class-level. So that code above obviously wouldn’t run.

    Now, this code would run:

    
    class User
      def self.query=(obj)
        @query = obj
      end
      def self.query
        @query || nil
      end
      def self.where(hash)
        self.query= Query.new(self)
        self.query.where(hash)
      end
      def self.sort(field)
        self.query ||= Query.new(self)
        self.query.sort(field)
      end
    end
    

    But now, your Query objects clash between instances, because the Query is being stored on the User class.

    I guess my point is, I can see how returning the Query class instead of the User class is much simpler.

  10. One good reason for not putting everything in the base class is that you don’t want to chain a class method on the result of a relationship query. Something like Person.order(:name).validates_presence_of(:name) would be weird

    Putting that into better words, in Rails, as in life, once you get into a Relation you cannot back to be your old “self” any more :D

Sorry, comments are closed for this article to ease the burden of pruning spam.

About

Authored by John Nunemaker (Noo-neh-maker), a web developer and programmer who has fallen deeply in love with Ruby. More about John.

Syndication

Feed IconRailsTips Articles - An assortment of howto's and thoughts on Ruby and Rails.

Feed IconRails Quick Tips - Ruby and Rails related links.