Method 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!
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)
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
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"}}>
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>'
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
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"}}>
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
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}>
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
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
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
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
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"}}>
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>'
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
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
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.
Source:http://jeffkreeftmeijer.com/2011/method-chaining-and-lazy-evaluation-in-ruby/