Method chaining and lazy evaluation in Ruby

By on

To investigate method chaining in Ruby, we’ll write a library that chains method calls to build up queries for a MongoDB database.

Don’t worry if you haven’t used MongoDB before, we’re just using it as an example to query on. When using this guide to build a querying library for something else, the MongoDB part can be swapped out.

Let’s say we’re working with a user collection and we want to be able to query it like this:

User.where(:name => 'Jeff').limit(5)

We’ll create a Criteria class to build queries. 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:

irb> require_relative 'criteria'
 => true
irb> c = Criteria.new
 => #<Criteria:0x007ff9db8bf1f0>
irb> c.where(:name => 'Jeff')
 => {:name=>"Jeff"}
irb> 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:

irb> require_relative 'criteria'
 => true
irb> c = Criteria.new
 => #<Criteria:0x007fbf5296d098>
irb> c.where(:name => 'Jeff').where(:login => 'jkreeftmeijer')
NoMethodError: undefined method where' for {:name=&gt;"Jeff"}:Hash</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

Okay, let’s try it again:

irb> require File.expand_path 'criteria'
 => true
irb> c = Criteria.new
 => #<Criteria:0x007fe91117c738>
irb> 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.

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:

irb> require_relative 'criteria'
 => true
irb> c = Criteria.new
 => #<Criteria:0x007fdb1b0ca528>
irb> 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:

irb> require File.expand_path 'user'
 => true
irb> 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:

irb> require_relative 'user'
 => true
irb> 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:

irb> require File.expand_path 'user'
 => true
irb> User.where(:name => 'Jeff').each { |u| puts u.inspect }
NoMethodError: undefined method `each' for #<Criteria:0x007fd0540cfea0>
   from (irb):2
   from /Users/jeff/.rvm/rubies/irbrb: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):

irb> require_relative 'user'
 => true
irb> User.where(:name => 'Jeff').limit(2).each { |u| puts u.inspect }
{"id"=>BSON::ObjectId('4ed2603b368ff6d6bc000001'), "name"=>"Jeff"}
{"id"=>BSON::ObjectId('4ed2603b368ff6d6bc000002'), "name"=>"Jeff"}
 => nil

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.