SearchMagic

SearchMagic provides full-text search capabilities to mongoid documents, embedded documents, and referenced documents with a clean, consistent, and easy to use syntax. Searching can be performed on either word fragments, such as foo, or can use a selector-syntax, foo:bar, to target which fields of the document the search is to be restricted to.

Installation

SearchMagic is built on top of the latest pre-release version of mongoid; in all likelihood, it will only work with versions greater than or equal to 2.0.0.rc.8. The project can be installed as a gem on a target system:

gem install search_magic

For environments where bundler is being used, it can be installed by adding the following to your Gemfile and running bundle.

gem 'search_magic'

Getting Started

Adding FullTextSearch capabilities

Adding FullTextSearch is as simple as including the appropriate module into a mongoid document and defining which fields are to be searchable. In the following example, the SearchMagic::FullTextSearch module is included and each field of the model is made searchable.

class Address
  include Mongoid::Document
  include SearchMagic::FullTextSearch
  field :street
  field :city
  field :state
  field :post_code
  embedded_in :person</code>
  
<code>  search_on :street
  search_on :city
  search_on :state
  search_on :post_code
end

At this point, Address can be searched by calling its :search_for method:

Address.search_for("state:ca")

It is also possible to sort models on fields which have been marked as searchable through the :arrange method:

Address.arrange(:state, :asc)

:search_on

Fields that are made searchable by :search_on have their values cached in an embedded array within each document. This array, :searchable_values, should contain entries of the form field_name:value. The selector, field_name, represents a filter which can be used when searching to narrow the search space; it can be manually renamed by passing the :as option to :search_on:

search_on :post_code, :as => :zip_code 

The example in the previous section showcased using :search_on on basic Mongoid::Document fields. It can, however, be used on fields within a document which denote an association.

class Person
  include Mongoid::Document
  include SearchMagic::FullTextSearch
  field :name
  embeds_one :address</code>
  
<code>  search_on :name
  search_on :address
end

When an association is searched on, all of its searchable fields are automatically made searchable in the first document. In the previous example, this means that the four fields of Address, [:street, :city, :state, :post_code] are now searchable from within Person. As such, each association will end up adding entries into the :searchable_values array. The searchable fields which are introduced from an association can be restricted by use of the :only and :except options, which may either take an array or an individual field name:

search_on :address, :only => [:street, :state]
search_on :address, :except => :post_code

By default, an association’s fields will be prefixed by name of the association. Therefore, the previous example would add entries to :searchable_values with the selectors [:address_street, :address_city, :address_state, :address_post_code]. The :as option alters the prefix:

search_on :address, :as => :location # results in :location_street, :location_city, ...

It is also possible to prevent the prefix from being added to each absorbed searchable field through use of the :skip_prefix option:

search_on :address, :skip_prefix => true # results in :street, :city, ...

:skip_prefix and :as cannot be used concurrently: :skip_prefix will always take precedence.

Values added to :searchable_values automatically are split on whitespace and have their punctuation removed. For most cases, searches performed on models are not going to need punctuation support. However, if it is desired to keep the punctuation present on a particular field, that can easily be done through the :keep_punctuation option:

class Asset
  include Mongoid::Document
  include SearchMagic::FullTextSearch
  field :tags, :type => Array</code>
  
<code>  search_on :tags, :keep_punctuation => true
end

Now all entries within :searchable_values for :tags will retain meaningful punctuation. The previous example is interesting for another reason: embedded arrays are handled specially. Specifically, the selector for an embedded array will be singularized. In the case of the previous example, this would result in a selector of “tag”.

Two documents may search on each other’s fields; doing so will cause each document to only search upon those fields stemming from itself once. Given the following example, Foo would be able to search on [:name, :bar_value], while Bar would be able to search on [:value, :foo_name].

class Foo
  include Mongoid::Document
  include SearchMagic::FullTextSearch
  field :name
  references_many :bars
  search_on :name
  search_on :bars
end</code>

<code>class Bar
  include Mongoid::Document
  include SearchMagic::FullTextSearch
  field :value
  referenced_in :foo
  search_on :value
  search_on :foo
end</code>

<code>Finally, it should be noted that nesting of searchable documents is possible. If a given document searches on an association with another document which, in and of itself, searches on a third document, the first automatically has access to the third document's searchable fields.
class Part
  include Mongoid::Document
  include Mongoid::Timestamps
  include SearchMagic::FullTextSearch
  field :serial
  references_in :part_number</code>
  
<code>  search_on :serial
  search_on :part_number, :skip_prefix => true
end</code>

<code>class PartNumber
  include Mongoid::Document
  include SearchMagic::FullTextSearch
  field :value
  references_many :parts
  referenced_in :part_category</code>
  
<code>  search_on :number
  search_on :part_category, :as => :category
end</code>

<code>class PartCategory
  include Mongoid::Document
  include SearchMagic::FullTextSearch
  field :name
  references_many :part_numbers</code>
  
<code>  search_on :name
end

PartNumber will be able to search on both :number and :category_name. Part, on the other hand, will absorb all of the searchable fields of PartNumber, including its associations. So, it can be searched on :serial, :number, and :category_name.

:search_for

Searching a model with SearchMagic is simple: each model gains a class method called :search_for which accepts one parameter, the search pattern. This method is a mongoid scope; it will always return a criteria object after executing. As such, it plays nicely with other scopes on your models.

SearchMagic expects the incoming pattern to be a string containing whitespace delimited phrases. Each phrase can either be a single word, or a selector:value pair. Multiple phrases will narrow the search field: each additional phrase places an additional requirement on a matching document. Single word phrases are matched across all entries in a model’s :searchable_values array. The pairs, on the other hand, restrict the search for value against only those entries which match selector. In either case, word or value may contain fragments of whole entries stored within :searchable_values.

Using the models defined in the previous section, the following searches are all perfectly valid:

Part.search_for("table") # full text search on "table"
Part.search_for("category_name:table") # restricts the search for "table" to "category_name"
Part.search_for("bike serial:b1234") # full text search on "bike", with an extra requirement that the serial be "b1234"

As :search_for is a scope, it can be called on any previous scope within the call chain:

Part.where(:created_at.gt => 1.day.ago.time).search_for("table")

:search_for can be called multiple times within the same scope chain. Doing so will append each successive pattern to the previous searches. Effectively, this is the same as performing a single :search_for with whitespace delimited terms in the pattern. To make such expressions slightly more readable, :search_for is aliased as :and_for:

Part.search_for("bike").and_for("serial:b1234")

:arrange

SearchMagic also provides a utility scope for arranging the model by the searchables defined within it. This method, :arrange, has one required parameter specifying the searchable to sort on and one optional parameter specifying the sort direction. (If the second parameter is omitted, it will default to ascending.)

Part.arrange(:serial)
Part.arrange(:serial, :asc) # same as last example
Part.arrange(:category_name, :desc) # arrange the parts in descending order by :category_name

As mentioned, :arrange is a scope, so it can be chained with other scopes on a given model:

Part.search_for("category_name:table").arrange(:serial, :asc)

Problems? Comments?

Feel free to add an issue on GitHub or fork the project and send a pull request. I’m always looking for new ways of bending hardware to my will, so suggestions are welcome.