Rails acts_as_ferret Plugin
There is a Rails plugin that I have been playing with since reading some stuff about it on RailsEnvy that is called actsasferret. It provides the functionality to do full text searches of information in a database, including indexing and such, which is great because searching is extremely complex. I’m not going to spend much time going through how to install the plug-in, as that information is easily available via the link above, and by searching for the plugin. I would, however, like to outline a couple of things that I ran into while exploring this plugin, and some solutions to them.
First thing’s first, what are my goals? In this case I was working on some functionality for our new website (which is going to be super nice!) where I simply wanted to search a couple of models (or database tables) and return the results. More specifically, my application uses a live search form to send search requests via Ajax, and updates the page display to narrow results as a user types in their search query. One additional piece of information is that I only wanted to index certain fields in my models.
Let’s start with the models, since that is where it all begins anyway. In my case, I want to search through a User model, which contains information about user accounts. Each user also has a Profile model that belongs to them, via a :hasone relationship, which holds personal information about that user. Our goal is to search both of these models, but only specific fields in them. After all, there are some fields in the User model that are private and we don’t want searched, such as their password (which is encrypted anyway, but still). Let’s take a look at setting up the models, assuming that you have already installed the Ferret gem and the actsas_ferret plugin to your application. The first step is to tell Rails that you want your model to be searchable by adding:
acts_as_ferret :fields => ['username', 'email_address']
As you can see, the only fields that I want the plugin to index are the username and the email_address fields, since this is the only data that is human readable and would possibly match a text search. Likewise, we add a similar declaration to our Profile model, telling it that we want it to be searchable as well, but only on certain fields:
acts_as_ferret :fields => ['first_name', 'last_name', 'position', 'company', 'description']
The reason for this is that since the Profile belongs to a User, there is a foreign key field called “user_id” which tracks this relationship. Since there is no reason for Ferret to index that information, we tell it which fields we do want it to index. The default behavior is to index and search through all the fields in the table.
The happy part is that there is nothing else that we need to do in order to set up text searching in our models. We now have new methods available such as findbycontents in our models, which will let us perform searches in the same way that we would normally query a database. Let’s take a second to marvel at the beauty of Rails, and simplicity with which we can add this functionality. Thanks to Rails I can spend time worrying about how my application works, which we will get into below, rather than coding up bunch of libraries and stitching things together.
Next up is the actual search. There two issues that I faced initially, despite how easy these things are to use. The first issue was that since my search form is an Ajax live search form, the default Ferret behavior only matches by word, so there would be instances of “No Results Found” appearing as a user typed their query, matching only complete words. What I wanted was for the search results to narrow the more a user types, so that there would always be results. After all, if you type “a” into a Live Search text field, you would expect a lot of results, rather than “No Results Found”. Indeed, the answer to this one lies in some search tailoring that many people do not know about or take advantage of. I’ll admit that I had to warm up to these quite a bit in the past year, and before that didn’t really know about them at all.
These nuggets of search magic are simple operators that help your search out. A couple that are worth noting are fuzzy search, which does not solve my problem but is pretty cool, and wildcard searches, which is a little reckless but solves my problem. Here are examples of each:
Fuzzy search: Add a tilde (
~) to the end of your query. Ex. "keyword~"
Returns fuzzy results, in that a search for "Smmith" will still return "Smith".Wildcard search: Add an asterisk (
*) anywhere in your query. Ex. "keyword" or "keywrd"
Returns wildcards. This is a common thing in programming, so obviously the search will match anything in place of the wildcard asteriks.
In my application, the wildcard indicator (*) is added to the end of the query that the use inputs, so that the application will return a lot of results at first, and narrow those results down as the user types more into the search form. It works like a charm, and with a few other custom search changes I am pretty happy with the behavior of the plugin.
If you are following along, you have probably figured out that the other problem that I faced was with the fact that I needed to search more than one model. As it stands, every User has a Profile, so that solves one issue with that. The rest comes down to some array manipulation, as well as customizing a pagination function to receive arrays in addition to objects and strings. I’m not going to go into that here, especially since I got some great information off of the demo files for the RailsSpace book, in which the author does a great job of customizing the pagination functions. Since that info is in there, I’ll just show some of the array code that I used:
## Grab the search results for both models
@users = User.find_by_contents(@query+"*", :limit => :all)
@profiles = Profile.find_by_contents(@query+"*", :limit => :all)
## Combine the two result sets and eliminate duplicates
@users.concat(profiles.collect {|result| result.user}).uniq!
## Sort our new combined array by username
@users = @users.sort_by {|user| user. username}
## Feed the new array into our customized paginate method
@pages, @users = paginate(@users, :per_page => 15)
This is a little simplified, since my actual application does quite a bit more procession on the arrays once they are combined. However, it illustrates how to combine the two result sets, and to manipulate the arrays a bit. Again, this relies on a custom paginate function outlined in the book RailsSpace.
Let’s take a look at that code, which starts by grabbing two sets of results. The first is from the User model and the second is from the Profile model. It’s important to recognize here that this is a :has_one relationship and there is never a time when a user does not have a profile. Because of this, I can use fields from either model to sort and manipulate the array. If there were an instance when a User is returned, but not a corresponding Profile, you will get errors since the fields that you need will not be present in all of the results.
In the next block we combine the arrays using the concat method, after we collect the results from the profile set together. Finally, we make sure that there are no duplicate results by running the combined array through the uniq! method. Next we sort the array by the “username” field, which we know every record will have. There are some subtleties to doing this, but it’s worth in the end because you can search through multiple models and display combined results. In our case, this is useful because we have many tables that together describe a User in our system, and we need to be able to search for information in all of them in order to find a User.
Hopefully this is somewhat informative. For the most part, I followed directions that other people have written, but I ran into some specific problems that I had to figure out. Just thought I would share the solutions here, in the event that others are running into this same issue.
This post is filed under Developers' Corner and has the following keyword tags: ruby, rails, plugin, acts_as_ferret.
