Have you ever written a plugin or a piece of code that was more fun to write than it was actually worth? I seem to do that a lot—I enjoy experimenting with Ruby. Recently, I needed to search ActiveRecord models individually, and was in the mood to write a simple DSL. It’s now in plugin form, called EasySearch, and can be found on GitHub.
It doesn’t support joins or shared indexes or anything else you might come to expect from a search plugin. I’m posting about it not because I think you should use it, but because I think it’s a pretty cool API for searching.
So, install the plugin and do something like this:
1 2 3 | class Search include RPH::EasySearch end |
(I originally had a nice acts_as_easy_search class method that hooked up the behavior, but I didn’t feel right about mixing that into Object. I wanted this plugin to work without the need of an ActiveRecord superclass. Plus, AR would then be expecting a corresponding table in the database, which is another unnecessary need. So, manual inclusion is the answer until I come to terms with a better alternative. Continuing…)
The Search class now has the means to easily search any ActiveRecord model within an application. Let’s say I want to find all of the users who mention ‘ruby’. Let’s reword that: search users with the term ‘ruby’.
1 2 | $> Search.users.with('ruby') $> # => [<#User ...>, <#User ...>, ...] |
Makes sense, huh?
I have a confession to make: I lied about only having to include the module. Before anything will actually work, I’ll need to tell EasySearch about the tables I want to easily search.
EasySearch Configuration
For any generic search plugin to work, there needs to be a way to specify which database columns the search terms should be matched against. Here’s an example of how it works:
1 2 3 4 5 6 7 8 9 10 11 | # in Rails 2.0+ you could put this in # config/initializers/easy_search_setup.rb # (otherwise just use config/environment.rb) RPH::EasySearch::Setup.config do setup_tables do users :first_name, :last_name, :email, :bio projects :title, :description groups :name end end |
There, that’s it (for real this time).
If you notice, it looks like there are “users”, “projects”, and “groups” methods that are called from within the block. And actually, that’s correct. The only difference is those methods don’t exist until they’re called, and even then they don’t “exist” (which is kind of weird, I know, but incredibly awesome nonetheless). Don’t worry about how it works (read through the code if you’re interested), just follow the pattern (use the table names instead of the model names).
After this one-time configuration is done, I can do things like:
1 2 3 4 5 6 | $> Search.users.with('ryan') $> # => [<#User ...>] $> Search.projects.with('design') $> # => [<#Project ...>] $> Search.groups.with('friends') $> # => [<#Group ...>] |
If you try to search a model that hasn’t been configured, EasySearch will let you know. After all, how else would it know which columns to look in? It’s not that magical.
And getting a hold of the current table configuration is easy:
1 2 | $> RPH::EasySearch::Setup.table_settings $> # => {"users" => [:first_name, :last_name, :email, :bio], "projects" => ...} |
How it Works
Really, all EasySearch is doing is building a WHERE clause and using conventions to pass that on to the appropriate finder. So Search.users is really doing User.find with a custom conditions clause.
Let’s say I want to search my User model for “ryan heath ruby”.
1 | Search.users.with('ryan heath ruby') |
This would not only compare “ryan heath ruby” with each of the specified columns (you know, from the configuration), but it’d compare “ryan”, “heath”, and “ruby” individually against each of the specified columns, providing more accurate results.
This, however, is an incredible performance limitation and is why this plugin is not meant for those gigantic applications with tons and tons of database records. It builds a potentially large WHERE clause using LIKE, which is quite slow in the computer world. For those large applications, it’s far better to use a full text solution. But again, this is all (more-or-less) for fun with little profit. I had a need to search individual models separately in a small application, so there you go. I can always go back and rework the back-end to operate differently/more efficient.
Oh, and since a rather large WHERE clause is being constructed, checking each term individually, I want to ignore meaningless words to avoid the extra overhead. For example, let’s say I tweak my search to be:
1 | Search.users.with('ryan heath is a ruby') |
EasySearch would still only search “ryan”, “heath”, and “ruby”, ignoring the “is” and “a” (because they’re dull). You can easily see what keywords are considered “dull” by doing:
1 2 | $> RPH::EasySearch::Setup.dull_keywords $> # => ['the', 'a', ...] |
Now I know what you’re thinking, “Who gets to decide what keywords are dull keywords?” The programmer, of course! By default, EasySearch has a predefined list of keywords that it thinks are meaningless. But you can add to that list quite easily. Using the same Setup.config block:
1 2 3 4 5 6 7 8 9 | RPH::EasySearch::Setup.config do setup_tables do # ... end strip_keywords do ['other', 'words', 'you', 'do', 'not', 'want', 'to', 'search'] end end |
That will append those keywords to the default list (and will only count a dull keyword once, so don’t worry about duplication). If I wanted to ignore the default list completely (instead of appending to it), I’d just have to pass true to the strip_keywords method, like so:
1 2 3 | strip_keywords(true) do ['other', 'words', 'you', 'do', 'not', 'want', 'to', 'search'] end |
So that’s about it. It’s a fun plugin to play with, and like I said, it was more fun to write than it’s probably worth. But honestly, I think this sort of experimentation is nothing but good for programmer experience. And besides, every now and then, an “experiment” turns into something awesome (see Ambition for a prime example).
01
Nick on Wed May 21 at 04:17AM
This is pretty cool, but it seems like it has quite a few caveats for what it’s actually able to provide. I do like its simplicity, however.
02
Bob Walsh on Sun Jul 27 at 09:38AM
Very cool, elegant, but what if you want to apply a condition to all results from a particular table, such as not returning Profiles with a visiblestatus of 0?
(ala @profiles = Profile.find(:all, :conditions => “visiblestatus > 0”) – I’m definitely still in ror newbie mode.)
Thanks!
03
Ryan on Mon Jul 28 at 07:22AM
Bob -
I had a similar need awhile ago and didn’t realize I hadn’t updated the plugin itself (rather, only within my application). But that has been fixed. Now, you should be able to do something like this (given your example):
That will search all profiles for those search terms while also limiting the results to your specific conditions (in this case, having a visible status).
Hopefully that helps :-)
04
Bob Walsh on Mon Jul 28 at 01:52PM
Thanks Ryan – I’ll give it a try!