October 24, 2010
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
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.
Oct 24, 2010
What about
User.where(:first_name=>'John').where(:last_name=>'Nunemaker')
? Instead of simple variables there should be a stack for wheres and sorts, so they don’t override earlier ones.Oct 24, 2010
Katie’s right. This implementation wouldn’t work when you chain two of the same query method.
Oct 24, 2010
@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.
Oct 25, 2010
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:
Oct 25, 2010
@Peter Cooper: Yep. One could also do some kind of smart deep merging, which is basically what plucky does.
Oct 28, 2010
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?
Oct 29, 2010
@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.
Oct 29, 2010
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:
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.
Oct 30, 2010
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.