Data storage in Tire and wrapping the results

tieleman edited this page Oct 25, 2012 · 16 revisions

Data retrieval in Tire and different ways to wrap the results

There's been a fair bit of discussion regarding the way Tire should retrieve objects from elasticsearch (ES) and present them to your Ruby code. Ultimately, there is no one correct way to do this due to the very different use cases that exist. Fortunately, Tire gives you a couple of options to customise the way this should be handled. This page serves as a summary of the different ways that you can use Tire to retrieve your data and the options available to the developer for wrapping the results.

Using the standard wrapper (Tire::Results::Item)

First and foremost, any data you retrieve from ES using Tire will be an instance of Tire::Results::Item. This is the default behaviour of Tire. (see source). These Item objects are fairly straightforward objects, they simply map whatever fields are returned from ES to corresponding attributes. Basically, they are a very thin wrapper around nested hashes representing the data returned from ES.

This is the most lightweight way to use Tire/ES in your Ruby code. Simply include the gem, store some objects and retrieve them by id or by querying (or query another existing ES index that is not necessarily part of your application). Note that this way you are working completely agnostic of document types, you are simply searching through the entire index and getting generic Item objects/hashes. Ofcourse the corresponding type is still available in the _type field.

For use in Rails (and potentially other frameworks) Tire::Results::Item is extended with the ActiveModel::Naming module (Tire lists ActiveModel as a library dependency). This allows for naming, routing and using view templates. The instances of Item trick Rails in thinking it is a different class by overriding the #class method as such:

def class
  defined?(::Rails) && @attributes[:_type] ? @attributes[:_type].camelize.constantize : super
rescue NameError
  super
end

It attempts to deduce the correct (ActiveRecord) class and document type from the _type attribute in ES. If that is not present, or we are not in Rails it will just return super, which will default to Tire::Results::Item.

Rails will assume the Tire results are actually your domain objects as indicated by the following sample:

> s = Article.search "title:*"
> item = s.results.first
> item.class
 => Article

This is done by Tire to make sure that all the standard Rails helpers will work and allow you to, for example, do routing with your objects. Things like article_path @article will just work in your views (where @article is a result coming from ES).

Note that as of this momentTire::Results::Item does not override the is_a? method, so if you're using that to check what kind of an object you're dealing with you need to be prepared for that:

> item.class
 => Article
> item.is_a? Article
 => false

For more discussion on this subject, see issue #159.

Using a custom wrapper

For most general use cases the standard Tire::Results::Item wrapper will be sufficient. However, this comes with an important limitation if you're using this in your applications:

Items only appear to be a different class, they don't inherit any of the methods or properties of your domain class.

For example, suppose you have a model class that is searchable by ES, where only two attributes are indexed in ES (first_name, last_name):

class Author
  extend ActiveModel::Naming
  include Tire::Model::Search

  def name
    [first_name, last_name].join(' ')
  end
end

The name method is a convenience method to join the author's first name and last name and is not indexed in ES.

Now suppose we have an author in ES with first_name "Alexander" and last_name "the Great". If we search for this author and retrieve it from ES unexpected things can happen:

> s = Author.search "first_name:Alexander"
> author = s.results.first
> author.class
 => Author
> author.name
 => nil

Wait, nil? If you look closely at the source of Tire::Results::Item you'll see:

def method_missing(method_name, *arguments)
  @attributes[method_name.to_sym]
end

Attempting to call name on the Item results in a call to method_missing which simply attempts to map the called function to one of the attributes returned from ES. Of course, the attribute name is not available, as it does not exist in ES.

This can be a problem if you have defined lost of (instance) methods on your domain class that you use throughout your application.

The way to solve this would be to provide a custom wrapper class instead of Tire::Results::Item. We can do this by adding an extra option to the Tire configuration. You can create an initializer for this (config/initializers/tire.rb):

Tire.configure do
  wrapper ProxyObject
end

Where ProxyObject is the class that you are going to use to bring all the attributes from ES and still keep the instance methods from your domain classes. The definition of ProxyObject is (app/models/proxy_object.rb):

class ProxyObject < SimpleDelegator
  delegate :class, :is_a?, :to => :_proxied_object
  
  def initialize(attrs={})
    klass = attrs['_type'].camelize.classify.constantize
    @_proxied_object = klass.new
    _assign_attrs(attrs)
    super(_proxied_object)
  end
  
  private

  def _proxied_object
    @_proxied_object
  end

  def _assign_attrs(attrs={})
     attrs.each_pair do |key, value|
       unless _proxied_object.respond_to?("#{key}=".to_sym)
         _proxied_object.class.send(:attr_accessor, key.to_sym)
       end
       _proxied_object.send("#{key}=".to_sym, value)
     end
  end
end

There's a bunch of stuff going on here, first of all, this is a subclass of SimpleDelegator and its primary function is to "... delegate all supported method calls to the object passed into the constructor ...". Put simply, every method call is being delegated to whatever is passed into the constructor, in our case super(_proxied_object). Everything, except... is_a? and class, so we manually delegate these to the _proxied_object.

We construct the _proxied_object by looking at the _type information coming from ES and finding the appropriate class. Then we create a new instance and using _assign_attrs we assign non-existing attributes and fill it with the data from ES. We do this separately to circumvent the mass-assignment protection by Rails (see attr_accessible).

Now, with this new wrapper object in place we should be able to search for Authors and have actual instances of them:

> s = Author.search "first_name:Alexander"
> author = s.results.first
> author.class
 => Author
> author.is_a? Author
 => true
> author.name
 => "Alexander the Great"

And what's more, the extra ES information (such as _type and _score) is still available:

> author._type
 => "author"
> author._score
 => 0.30685282

Wrapping your ActiveRecord/Mongoid models

If you are interested in using this wrapper object to simultaneously retrieve your objects from ES and the database (because you are not indexing all your fields for example) you can use the :load option as provided by Tire:

> s = Author.search 'first_name:Alexander', :load => true
> author = s.results.first
> author.class
 => Author

This will return the actual instances from the database. However, you will not have access to the ES meta-information (such as _score).