-
Notifications
You must be signed in to change notification settings - Fork 533
Data storage in Tire and wrapping 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.
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.
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 Author
s 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
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
).