Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

Method chaining and lazy evaluation in Ruby

DZone's Guide to

Method chaining and lazy evaluation in Ruby

· Database Zone
Free Resource

Download the Guide to Open Source Database Selection: MySQL vs. MariaDB and see how the side-by-side comparison of must-have features will ease the journey. Brought to you in partnership with MariaDB.

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!

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>
https://gist.github.com/1397738/946ce0…

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
https://gist.github.com/1397738/dacc04…

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>
https://gist.github.com/1397738/c5d222…

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>
https://gist.github.com/1397738/d28969…

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>
https://gist.github.com/1397738/2b9bd0…

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>
https://gist.github.com/1397738/6035ba…

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>
https://gist.github.com/1397738/4e2e0b…

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>
https://gist.github.com/1397738/97652e…

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>
https://gist.github.com/1397738/a1a254…

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.

Interested in reducing database costs by moving from Oracle Enterprise to open source subscription?  Read the total cost of ownership (TCO) analysis. Brought to you in partnership with MariaDB.

Topics:

Opinions expressed by DZone contributors are their own.

THE DZONE NEWSLETTER

Dev Resources & Solutions Straight to Your Inbox

Thanks for subscribing!

Awesome! Check your inbox to verify your email so you can start receiving the latest in tech news and resources.

X

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}