Method chaining and lazy evaluation in Ruby
Join the DZone community and get the full member experience.
Join For FreeMethod chaining has been all the rage lately and every database wrapper or aything else that’s uses queries seems to be doing it. But, how does it work? To figure that out, we’ll write a library that can chain method calls to build up a MongoDB query in this article. Let’s get started!
The content of this article was originally posted on Jeff Kreeftmeijer.
Oh, and don’t worry if you haven’t used MongoDB before, I’m just using it as an example to query on. If you’re using this guide to build a querying library for something else, the MongoDB part should be easy to swap out.
Let’s say we’re working with a user collection and we want to be able to query it somewhat like this:
User.where(:name => 'Jeff').limit(5)
<span class="no"></span><span class="p"></span>
We’ll create a Criteria class to build queries. As you might have guessed, it needs two instance methods named where and limit.
When calling one of these methods, all our object needs to do is remember the criteria that were passed, so we’ll need to set up an instance variable – named @criteria – to store them in.
Our where method is used to specify conditions and we want it to return an empty array when none have been specified yet, so we’ll add an empty array to our criteria hash by default:
class Criteria
def criteria
@criteria ||= {:conditions => {}}
end
end
<code class="ruby"><span class="k"></span><span class="k"></span>
</code>
Now we’re able to remember conditions, we need a way to set them. We’ll create a where method that adds its arguments to the conditions array:
class Criteria
def criteria
@criteria ||= {:conditions => {}}
end
def where(args)
criteria[:conditions].merge!(args)
end
end
Great! Let’s give it a try:
ruby-1.9.3-p0 :001 > require File.expand_path 'criteria'
=> true
ruby-1.9.3-p0 :002 > c = Criteria.new
=> #<Criteria:0x007ff9db8bf1f0>
ruby-1.9.3-p0 :003 > c.where(:name => 'Jeff')
=> {:name=>"Jeff"}
ruby-1.9.3-p0 :004 > c
=> #<Criteria:0x007ff9db8bf1f0 @criteria={:conditions=>{:name=>"Jeff"}}>
<code class="irb"><span class="go"></span><span class="go"></span>
</code>
As you can see, our Criteria object successfully stores our condition in the @criteria variable. Let’s try to chain another where call:
ruby-1.9.3-p0 :001 > require File.expand_path 'criteria'
=> true
ruby-1.9.3-p0 :002 > c = Criteria.new
=> #<Criteria:0x007fbf5296d098>
ruby-1.9.3-p0 :003 > c.where(:name => 'Jeff').where(:login => 'jkreeftmeijer')
NoMethodError: undefined method `where' for {:name=>"Jeff"}:Hash
from (irb):3
from /Users/jeff/.rvm/rubies/ruby-1.9.3-p0/bin/irb:16:in `<main>'
<span class="go"></span><span class="go"></span>
Hm. That didn’t work, because where returns a hash and Hash doesn’t have a where method. We need to make sure the where method returns the Criteria object. Let’s update the where method so it returns self instead of the conditions variable:
class Criteria
def criteria
@criteria ||= {:conditions => {}}
end
def where(args)
criteria[:conditions].merge!(args)
self
end
end
<span class="k"></span><span class="k"></span>
Okay, let’s try it again:
ruby-1.9.3-p0 :001 > require File.expand_path 'criteria'
=> true
ruby-1.9.3-p0 :002 > c = Criteria.new
=> #<Criteria:0x007fe91117c738>
ruby-1.9.3-p0 :003 > c.where(:name => 'Jeff').where(:login => 'jkreeftmeijer')
=> #<Criteria:0x007fe91117c738 @criteria={:conditions=>{:name=>"Jeff", :login=>"jkreeftmeijer"}}>
<span class="go"></span><span class="go"></span>
Ha! Now we can chain as many conditions as we want. Let’s go ahead and implement that limit method right away, so we can limit our query’s results.
Of course, we only need one limit, as multiple limits wouldn’t make sense. This means we don’t need an array, we can just set criteria[:limit] instead of merging hashes, like we did with the conditions before:
class Criteria
def criteria
@criteria ||= {:conditions => {}}
end
def where(args)
criteria[:conditions].merge!(args)
self
end
def limit(limit)
criteria[:limit] = limit
self
end
end
<span class="k"></span><span class="k"></span>
Now we can chain conditions and even throw in a limit:
ruby-1.9.3-p0 :001 > require File.expand_path 'criteria'
=> true
ruby-1.9.3-p0 :002 > c = Criteria.new
=> #<Criteria:0x007fdb1b0ca528>
ruby-1.9.3-p0 :003 > c.where(:name => 'Jeff').limit(5)
=> #<Criteria:0x007fdb1b0ca528 @criteria={:conditions=>{:name=>"Jeff"}, :limit=>5}>
<span class="go"></span><span class="go"></span>
The model
There. We can collect query criteria now, but we’ll need a model to actually query on. For this example, let’s create a model named User.
Since we’re building a library that can query a MongoDB database, I’ve installed the mongo-ruby-driver and added a collection method to the User model:
require 'mongo'
class User
def self.collection
@collection ||= Mongo::Connection.new['criteria']['users']
end
end
<span class="nb"></span><span class="k"></span>
The collection method connects to the “criteria” database, looks up the “users” collection and returns an instance of Mongo::Collection, which we’ll use to query on later.
Remember when I said I wanted to be able to do something like User.where(:name => 'Jeff').limit(5)? Well, right now our model doesn’t implement where or limit, since we put them in the Criteria class. Let’s fix that by creating two methods on User that delegate to Criteria.
require 'mongo'
require File.expand_path 'criteria'
class User
def self.collection
@collection ||= Mongo::Connection.new['mongo_chain']['users']
end
def self.limit(*args)
Criteria.new.limit(*args)
end
def self.where(*args)
Criteria.new.where(*args)
end
end
<span class="nb"></span><span class="k"></span>
This allows us to call our criteria methods directly on our model:
ruby-1.9.3-p0 :001 > require File.expand_path 'user' => true ruby-1.9.3-p0 :002 > User.where(:name => 'Jeff').limit(5) => #<Criteria:0x007fca1c8b0bd0 @criteria={:conditions=>{:name=>"Jeff"}, :limit=>5}>
Great. Calling criteria on the User model returns a Criteria object now. But, maybe you already noticed it, the returned object has no idea what to query on. We need to let it know we want to search the users collection. To do that, we need to overwrite the Criteria’s initialize method:
class Criteria
def initialize(klass)
@klass = klass
end
def criteria
@criteria ||= {:conditions => {}}
end
def where(args)
criteria[:conditions].merge!(args)
self
end
def limit(limit)
criteria[:limit] = limit
self
end
end
<code class="ruby"><span class="k"></span><span class="k"></span>
</code>
With a slight change to our model – passing self to Criteria.new –, we can let the Criteria class know what we’re looking for:
require 'mongo'
require File.expand_path 'criteria'
class User
def self.collection
@collection ||= Mongo::Connection.new['criteria']['users']
end
def self.limit(*args)
Criteria.new(self).limit(*args)
end
def self.where(*args)
Criteria.new(self).where(*args)
end
end
<code class="ruby"><span class="nb"></span><span class="k"></span>
</code>
After a quick test, we can see that the Criteria instance successfully remembers our model class:
ruby-1.9.3-p0 :001 > require File.expand_path 'user'
=> true
ruby-1.9.3-p0 :002 > User.where(:name => 'Jeff')
=> #<Criteria:0x007ffdd30d4d68 @klass=User, @criteria={:conditions=>{:name=>"Jeff"}}>
<span class="go"></span><span class="go"></span>
Getting some results
The last thing we need to do is lazily querying our database and getting some results. To make sure our library doesn’t query before collecting all of the criteria, we’ll wait until each gets called – to loop over the query’s results – on the Criteria instance. Let’s see how our library handles that right now:
ruby-1.9.3-p0 :001 > require File.expand_path 'user'
=> true
ruby-1.9.3-p0 :002 > User.where(:name => 'Jeff').each { |u| puts u.inspect }
NoMethodError: undefined method `each' for #<Criteria:0x007fd0540cfea0>
from (irb):2
from /Users/jeff/.rvm/rubies/ruby-1.9.3-p0/bin/irb:16:in `<main>'
<span class="go"></span><span class="go"></span>
Of course, there’s no method named each on Criteria, because we don’t have anything to loop over yet. We’ll create Criteria#each, which will execute the query, giving us an array of results. We use that array’s each method to pass our block to:
class Criteria
def initialize(klass)
@klass = klass
end
def criteria
@criteria ||= {:conditions => {}}
end
def where(args)
criteria[:conditions].merge!(args)
self
end
def limit(limit)
criteria[:limit] = limit
self
end
def each(&block)
@klass.collection.find(
criteria[:conditions], {:limit => criteria[:limit]}
).each(&block)
end
end
<span class="k"></span><span class="k"></span>
And now, finally, our query works (don’t forget to add some user documents to your database):
ruby-1.9.3-p0 :001 > require File.expand_path 'user'
=> true
ruby-1.9.3-p0 :002 > User.where(:name => 'Jeff').limit(2).each { |u| puts u.inspect }
{"_id"=>BSON::ObjectId('4ed2603b368ff6d6bc000001'), "name"=>"Jeff"}
{"_id"=>BSON::ObjectId('4ed2603b368ff6d6bc000002'), "name"=>"Jeff"}
=> nil
<span class="go"></span><span class="go"></span>
Awesome! Now what?
Now you have a library that can do chained and lazy-evaluated queries on a MongoDB database. Of course, there’s a lot of stuff you could still add – for example, you could mix in Enumerable and do some metaprogramming magic to remove some of the duplication – but that’s beyond the scope of this article.
If you have any questions, ideas, suggestions or comments, or you just want more articles like this one be sure to let me know in the comments.
Opinions expressed by DZone contributors are their own.
Comments