In The Internet Pale

Ruby DSL and instance_eval

Showing Sunspot library to different people I have noticed their confusion about Sunspot search API. Sunspot provides the following API for search

search = Sunspot.search(Post) do
  keywords "Mark Twain"
end

And all rails developers try to do the following in the first place

class SearchController < Application
  def index
    @search = Sunspot.search(Post) do
      keywords params[:q]
    end
  end
end

Pretty obvious, huh? But they are facing the problem, cause this code throws an exception

 undefined method 'params' for <Sunspot::Query>

Some of them are trying to do the following:

def index
  query_string = params[:q]

  @search = Sunspot.search(Post) do
    keywords query_string
  end
end

And everything works as expected. At this place many of them are starting to understand what Sunspot uses instance_eval to provide this nice DSL.

instance_eval allows you to execute the block of the code in context of any object. In context means if you do

@object.instance_eval { puts self }

self in the block will be an @object itself.

Back to our sunspot problem. params in Rails is actually a method on ActiveController::Base so instance_eval on Suspot::Query object doesn’t have it. The code with the local variable (2nd variant) is working because you have access to all variables defined in it’s lexical scope.

What to do with this “problem”? The idea was to catch the object in which the block was created and pass it to Query. Query is implementing method_missing with fallback to the caught object. Everything works as expected. But we have 2 new problems.

  • How to get the caller object?
  • What to do if the method is not found neither in Query nor in caller object?

I’ve found the answer to the first question inside the ruby Kernel#eval docs. Kernel#eval accepts the special object of class Binding which encapsulates the execution context at some place in the code (sounds like a continuation? yep) or objects of class Proc. (In Ruby 1.9 API has changed and Kernel#eval accepts only objects of class Binding).

Actually blocks are the objects of class Proc (lambdas are Procs too). But I recommend passing a binding to keep compatibility with Ruby 1.9. Luckily for us there is a method Proc#binding which does the thing you expect. How to get required object from Proc and eval? Really simple:

eval 'self', block.binding

The second question is open for discussion.

Now we know enough to implement “smart” instance_eval which can try to find missing methods in context in which block was created.

def self.search_with_context(&blk)
  caller = eval('self', blk.binding)

  QueryWithContext.new(caller).tap do |query|
    query.instance_eval(&blk)
  end
end

Look in the sources on Github for full implementation.