A Lightweight, Powerful Search Engine For Rails

by Mike Zazaian at 2009-08-24 21:18:00 UTC in rails

an easy way to implement a powerful, custom search engine alongside a useful related articles function

no comments 3 links ["

searching is neat

\n\n

Seriously. As evidenced by the massive success of Google, one of the most useful capabilities afforded us by database-driven web applications is the ability to search through massive amounts of data without having to review each record individually.

\n\n

And it's really easy to implement this kind of thing in Rails. So easy, and SOOO userful in fact, that it's an absolute wonder that sites like Ruby Inside (built on Rails, I'd assume) don't implement even a basic search engine for the convenience of their users. Tisk tisk.

\n\n

There are some really great solutions out there too, like Thinking Sphinx and ultrasphinx, both of which are built on plain ol' Sphinx. Ferret, too, which while not as fast as the Sphinx-based solutions is also powerful and easy-to-implement.

\n\n

the caveats

\n\n

There are some issues, though, with Sphinx-and-Ferret-based solutions.

\n\n

The first being is that none of these enormously popular search engines support SQLite or any variants thereof. This makes it very difficult for developers using SQLite as their development database against a MySQL or PostgreSQL production database. And it's downright impossible to implement any of these solutions on an SQLite production database.

\n\n

So that's one thing. The other is that as extensible and configurable as Thinking Sphinx and other solutions may be, you can never achieve the same level of customization as you would with a custom search engine. I'm talking here in terms of the immediate weighing and searching through of app-specific factors that abstracted plugins simply can't take into consideration.

\n\n

the solution

\n\n

With this in mind, having once developed a Rails app in SQLite (and having found out rather quickly that I had been left out in the cold by developers of these respective search search solutions), I decided to assemble a custom search solution to delve through the title, summary and body fields of my articles. This is what it looks like:

\n", nil, "
\n
#!/app/models/article.rb\n\nActsAsNil = [0, :none, false]\n\ndef self.search(keywords, opts={})\n  s = Article::Search.new(keywords, opts)\n  return s.go!\nend\n\nclass Article::Search\n  attr_accessor :phrase, :keys, :fields, :conditions, :query, :query_string, :results, :limit, :user\n  def initialize(phrase, opts={}) \n    @phrase = phrase\n    split_phrase\n    if @keys.size > 0\n      @keys.collect! {|k| k.upcase.strip }\n      filter_soft_words\n    end\n\n    # Parse values passed into opts Hash\n    @fields = opts[:fields] ? opts[:fields] : ["title", "body", "summary"]\n    raise "@fields must be an Array of strings" unless @fields.class == Array\n    \n    @user = opts[:user] ? opts[:user] : :guest\n    @limit = opts[:limit] ? opts[:limit] : nil\n    @limit = nil if ActsAsNil.member?(@limit)\n  end\n\n  SoftWords = ["A", "IN", "IT", "AND", "OR", "TO", "FOR", "ON", "WITH", "THE", "HOW", "I", "WHAT", "WHO", "IF", ""]   \n\n  def go!\n    return [] if @keys == [] || @keys == nil\n    build_conditions_and_query\n    adjust_query_for_user\n    return get_map_weight_and_sort_results\n  end\n\n  def get_map_weight_and_sort_results\n    get_results\n    map_results\n    weight_map\n    return sort_map_and_return_articles\n  end\n\n  def build_conditions_and_query\n    @conditions = []\n    @query = []\n\n    @keys.each do |k|\n      @fields.each {|sf| @query << "upper(\#{sf}) LIKE ?"}\n      @fields.size.times { @conditions << "%\#{k}%" }\n    end\n  end\n\n  def adjust_query_for_user \n    # Convert the array of query fragments into a unified query string\n    assemble_query\n    \n    # If the basic search is called with a published state, then add that\n    # state to the conditions_array[0] item and add that published state\n    # boolean as the last item in the array\n    if @user.nil? || @user == :guest\n      @query_string += " AND published=?"\n      @conditions << true\n    elsif @user.can_view_unpublished_articles?\n    elsif @user.can_edit_articles? || @user.can_create_articles?\n      @query_string = "(\#{@query_string} AND published=?) OR (\#{@query_string} AND user_id=?)"\n      @conditions << true\n      @conditions += @conditions\n      @conditions.pop\n      @conditions << @user.id\n    else\n      @query_string += " AND published=?"\n      @conditions << true\n    end\n  end\n\n  # Return an array of all articles that match the general search terms\n  def get_results\n    search_opts = {:conditions => [@query_string, @conditions].flatten}\n    search_opts[:limit] = @limit if @limit\n    @results = Article.all(search_opts)\n  end\n \n  # Map each article by the total number of instances of ALL keywords within the\n  # article, as well as how many times each article matches ONE of the keywords\n  def map_results\n    @map = {} unless @map\n    @results.each do |a|\n      @map[a] = {} unless @map[a].class == Hash\n      @map[a][:instances] = 0\n      @map[a][:matches] = 0\n      @keys.each do |k|\n        total = 0\n        [a.title, a.body, a.summary].each do |text|\n          total += self.count_words_in_text(k, text)\n        end          \n        \n        if total > 0\n          @map[a][:instances] += total\n          @map[a][:matches] += 1\n        end\n      end\n    end\n  end\n\n  def weight_map(opts={})\n    instance_weight = opts[:instances] ? opts[:matches] : 1.5\n    match_weight = opts[:matches] ? opts[:matches] : 1\n    \n    @map.each_key do |k|\n      @map[k][:weighted] = (@map[k][:instances] * instance_weight) + (@map[k][:matches] * match_weight)\n    end\n  end\n\n  def sort_map_and_return_articles \n    @map.sort {|x,y| y[1][:weighted] <=> x[1][:weighted]}.collect{|r| r[0]}\n  end\n  \n  def count_words_in_text(word, text)\n    count = 0\n    text.downcase! && word.downcase!\n    while text.index(word)\n      count += 1\n      text = text[0, text.index(word)] + text[text.index(word) + word.size, text.size]\n    end\n    return count\n  end    \n   \n  protected\n\n  def split_phrase\n    if @phrase.size > 0 && @phrase.match(/\\w+/)\n      @keys = @phrase.split(/\\W+/)\n    else\n      []\n    end\n  end\n  \n  def assemble_query      \n    @query_string = "(\#{@query.join(" OR ")})"\n  end\n\n  def filter_soft_words\n    # Filter out weak search terms\n    SoftWords.each do |sw|\n      @keys.delete sw \n    end\n  end\nend
\n
\n", nil, "

That's the bulk of the model. It won't cut-and-paste directly into your application, but should come close. Either way, I'll walk you through step-by-step to explain my reasoning, and then give you a hand with the controller and view bits necessary to expose it to your site's users.

\n\n

Let's start at the top.

\n\n

def initialize

\n\n

For reference, here are the first couple of the bits of the class that I've called Article::Search:

\n", nil, "
\n
attr_accessor :phrase, :keys, :fields, :conditions, :query, :query_string, :results, :limit, :user\n  def initialize(phrase, opts={}) \n    @phrase = phrase\n    split_phrase\n    filter_soft_words if @keys.size > 0\n\n    # Parse values passed into opts Hash\n    @fields = opts[:fields] ? opts[:fields] : ["title", "body", "summary"]\n    raise "@fields must be an Array of strings" unless @fields.class == Array\n    \n    @user = opts[:user] ? opts[:user] : :guest\n    @limit = opts[:limit] ? opts[:limit] : nil\n    @limit = nil if ActsAsNil.member?(@limit)\n  end\n\n  SoftWords = ["A", "IN", "IT", "AND", "OR", "TO", "FOR", "ON", "WITH", "THE", "HOW", "I", "WHAT", "WHO", "IF", ""]
\n
\n", nil, "

def initialize is, of course, the method that called upon instantiation (creation) of a new Article::Search object, so code here is mostly self-explanatory. First we're using attr_accessor to create variables for a bunch of data that we're going to need over the course of processing the search, as well as setters and getters for each of them. Here's what they do:

\n\n

@phrase -- a String containing the exact search phrase as it's passed from the search input box.

\n\n

@keys -- an Array containing the individual keywords contained within and split from the phrase string.

\n\n

@fields == an Array containing the fields that will be searched through in the Article model.

\n\n

@conditions -- an Array the individual values for the Article.all :conditions => array that we'll ultimately use to provide the information for our query. Don't worry, I'll cover this in greater depth later as I get to it.

\n\n

@query -- an Array of search individual query term, which correlate to the values of conditions.

\n\n

@query_string -- a String that will ultimately be passed into Article.all :conditions => once we've created all of the necessary query terms.

\n\n

@results -- the results of the actual query, an Array of Article objects

\n\n

@limit -- the limit of the results returned, a Fixnum if it's set at all

\n\n

@user -- the user performing the search, which will determine how the query is adjusted based on the adjust_query_for_user method

\n\n

That's all of the good stuff. As you can see, the first that happens when a new Article::Search object is created, like this:

\n", nil, "
\n
>> s = Article::Search.new("what the hell am I searching for?")
\n
\n", nil, "

Is that the split_phrase method splits the phrase into an array of keys, provided that the phrase contains more than zero characters, and that at least one of those characters is alphanumeric. Just to show you again, here's the split_phrase

\n", nil, "
\n
def split_phrase\n  if @phrase.size > 0 && @phrase.match(/\\w+/)\n    @keys = @phrase.split(/\\W+/)\n    else\n      []\n    end\n  end\nend
\n
\n", nil, "

And here's what it produces:

\n", nil, "
\n
>> s.keys\n=> ["what", "the", "hell", "am", "I", "searching", "for"]
\n
\n", nil, "

As you see, if there AREN'T any alphanumeric characters in the phrase string, then split_phrase will just set @keys to an empty Array. Also, I haven't bothered to allow users to cordon off specific phrases using double or single quotes, but might do this later and will post an update if I do.

\n\n

For the time being, though, you have your Array of keywords stored in @keys, which is good.

\n\n

Next, if there were indeed any recognizable keywords, the initialize method performs the .upcase method on all of them to help us perform a case insensitive search, like this:

\n", nil, "
\n
>> @keys.collect! {|k| k.upcase}\n=> ["WHAT", "THE", "HELL", "AM", "I", "SEARCHING", "FOR"]
\n
\n", nil, "

This will help us find matches on words spelled with odd or inconsistent cases like \"SearCHing\" or \"HeLL\".

\n\n

The next step is to filter out any words that are considered soft, like \"and\" or \"or\", which don't really hold any meaning in the actual search and could throw off our results. For this it uses the filter_soft_words method and the SoftWords constant Array. Again, they look like this:

\n", nil, "
\n
SoftWords = ["A", "IN", "IT", "AND", "OR", "TO", "FOR", "ON", "WITH", "THE", "HOW", "I", "WHAT", "WHO", "IF", ""]  \n\ndef filter_soft_words\n  # Filter out weak search terms\n  SoftWords.each do |sw|\n    @keys.delete sw \n  end\nend
\n
\n", nil, "

Using these in tandem allows us to remove any and all words in the SoftWords array from the @keys array, so that @keys would end up looking like this:

\n", nil, "
\n
>> s.filter_soft_words\n>> s.keys\n=> ["HELL", "SEARCHING"]
\n
\n", nil, "

As you can see, that substantially improves the accuracy of our search, as the words \"HELL\" and \"SEARCHING\" are more relevant than any of the soft words that we've filtered.

\n\n

Once that's done, a couple of ternary operators are used to parse the :user and :limit options that were passed into the object when the object was instantiated.

\n\n

building the query

\n\n

The next step is to build the query that we're going to use to actually retrieve our records from the database. To do this we're going to use the @fields Array and the build_conditions_any_query method, which look like this:

\n", nil, "
\n
def build_conditions_and_query\n  @conditions = []\n  @query = []\n\n  @keys.each do |k|\n    @fields.each {|sf| @query << "upper(\#{sf}) LIKE ?"}\n    @fields.size.times { @conditions << "%\#{k}%" }\n  end\nend
\n
\n", nil, "

As you can see, what this does is insert a term in the @query Array that attempts to match instances of each Keyword in each of the field names in the @fields Array. The corresponding keyword is then inserted into the @conditions Array, which we'll use later to build our final query.

\n\n

You'll notice a couple of things here. First, a function called upper() is actually called inside of the database query, and the fields are being searched are passed into it as an argument. This will make all of the data that we're attempting to match in these fields UPPERCASE so that they can match our already uppercase @keys values.

\n\n

Next, you'll see that our query terms are trying to match fields LIKE ? rather than any strings or data in particular. This is because using specific data in these situations leaves you vulnerable to SQL injection exploits. Instead the preferred syntax, which we'll be using later, is:

\n", nil, "
\n
Article.all( :conditions => ["some_field = ?", some_field_value] )
\n
\n", nil, "

You can read up on the use of conditional searches here or, for more on the specific risks of SQL injections, you can take a peek here.

\n\n

Also, if your fields are named differently, or if you have different fields that you're trying to match altogether, all you have to do is pass an Array of fields into your Article::Search object on instantiation, like this:

\n", nil, "
\n
s = Article::Search.new("searching for pandamonium", :fields => ["title", "body", "pandamonium_field", "another_field"])
\n
\n", nil, "

adjusting the query by user

\n\n

This part may not apply at all to you. In my application, however, I've got multiple user roles and those roles can be called with methods from the actual user objects to determine what the user can and cannot do. Syntax like this is not uncommon:

\n", nil, "
\n
>> @user.can_fly_to_the_moon?\n=> true\n>> @user.can_eat_cabbage_all_day_long?\n=> false
\n
\n", nil, "

So in using a system like this, I want to filter specific search results from being seen by users that don't have permission to view them. Unpublished articles, for example, should only be seen by the article's author(s) and super users. Guest or regular users should only see published articles. Super users should be able to see everything. And adjust_query_for_user takes this into account. Here's what it looks like:

\n", nil, "
\n
def adjust_query_for_user \n  # Convert the array of query fragments into a unified query string\n  assemble_query\n  \n  # If the basic search is called with a published state, then add that\n  # state to the conditions_array[0] item and add that published state\n  # boolean as the last item in the array\n  if @user.nil? || @user == :guest\n    @query_string += " AND published=?"\n    @conditions << true\n  elsif @user.can_view_unpublished_articles?\n  elsif @user.can_edit_articles? || @user.can_create_articles?\n    @query_string = "(\#{@query_string} AND published=?) OR (\#{@query_string} AND user_id=?)"\n    @conditions << true\n    @conditions += @conditions\n    @conditions.pop\n    @conditions << @user.id\n  else\n    @query_string += " AND published=?"\n    @conditions << true\n  end\nend\n\nprotected\ndef assemble_query      \n  @query_string = "(\#{@query.join(" OR ")})"\nend
\n
\n", nil, "

The first thing that it does, as you'll see, is assemble all of the query fragments that we created with build_conditions_and_query and joins them into a long string with that we can use in our actual database query. This is done with the assemble_query method, which saves the formatted string into the @query_string variable, which we haven't really played with yet.

\n\n

AN IMPORTANT NOTE -- You'll need to call assemble_query regardless of whether or not you're adjusting your search results by user. One way or another, we're going to be using the value in @query_string as the base for our search conditions.

\n\n

The rest of this method, as I described, simply adds bits to the query string based on which articles the user can see. Again, for :guest or regular users, we just add \"AND published=?\" to the @query_string, then add the desired value, true, as its condition in the @conditions Array, like this:

\n", nil, "
\n
if @user.nil? || @user == :guest\n  @query_string += " AND published=?"\n  @conditions << true\nelsif\n...
\n
\n", nil, "

I'm not going to go into any more depth here, as your user authentication systems will likely be very different from mine, and there's no use explaining something that you'll have to figure out on your own anyway. But the above example should really give you an idea of the use of the adjust_query_for_user method and how it should be implemented.

\n\n

getting the preliminary, unsorted results

\n\n

Before we go any further we're going to get all of the records that match our search terms in any capacity. To preserve our server resources, we're going to do all of this in a single query, then run some methods over those results to actually sort and organize our data.

\n\n

But we'll get to that later. First we're just going to take our @query_string and its matching @conditions, and store all of the Article objects that match them in @results.

\n", nil, "
\n
def get_results\n  search_opts = {:conditions => [@query_string, @conditions].flatten}\n  search_opts[:limit] = @limit if @limit\n  @results = Article.all(search_opts)\nend
\n
\n", nil, "

This is all pretty standard. The method simply formats our query into the :conditions => format that you may have read about here.

\n\n

This is also where we invoke the :limit option that may or may not have been called when the Article::Search object was actually created. There aren't any options for pagination yet (I currently don't have enough articles to need them) but if you want it should be easy enough to add pagination by creating :page, :page_size options which you'd then multiply to create the :offset value, which you could add to search_opts to return paginated values. Yeah, that was kind of a mouthful, but you're a brilliant individual and I have no doubt that you can figure out how to implement what I've just described.

\n\n

mapping our results by keyword occurrences

\n\n

Okay, so we've fetched all of the articles from the database that match our query in some capacity, and stored all of those articles in the @results variable as an Array, but there's still a long way to go. It's certainly useful have all of these articles we've found, but surely we can do something more meaningful with the results than just returning them verbatim.

\n\n

And indeed we can. This is where mapping and weighting our results comes in really handy. Observe:

\n", nil, "
\n
# Map each article by the total number of instances of ALL keywords within the\n# article, as well as how many times each article matches ONE of the keywords\ndef map_results\n  @map = {} unless @map\n  @results.each do |a|\n    @map[a] = {} unless @map[a].class == Hash\n    @map[a][:instances] = 0\n    @map[a][:matches] = 0\n  \n    @keys.each do |k|\n      total = 0\n      [a.title, a.body, a.summary].each do |text|\n        total += self.count_words_in_text(k, text)\n      end                  \n      if total > 0\n        @map[a][:instances] += total\n        @map[a][:matches] += 1\n      end\n    end\n  end\nend
\n
\n", nil, "

As you can see we're doing more than a couple of things here. The first is to establish the @map instance variable as a hash if this hasn't already been done. Once that's been done, we iterate over all of the articles in @results, establish each article as a key that points to a new hash, and then set the values for the keys :instances and :matches in that second hash to 0.

\n\n

Once we've done this we iterate over all of the keywords in @keys, and count the total number of occurrences of the given key in the article's title, body and summary fields using the count_words_in_text method:

\n", nil, "
\n
def count_words_in_text(word, text)\n  count = 0\n  text.downcase! && word.downcase!\n  while text.index(word)\n    count += 1\n    text = text[0, text.index(word)] + text[text.index(word) + word.size, text.size]\n  end\n  return count\nend
\n
\n", nil, "

Cool. So we've mapped the total number of instances of ALL keys in a given article, but it might be useful to map the results by another dimension as well. So we will. In addition to just counting the total number of keys found in a given article, we're also going to record the total number of times a key is found ONCE in ANY of the relevant article fields. This allows us to simulate a query in which we search the database once for each individual key rather than grouping all of the keywords together, and adds another dimension of relevance to our search results. In case you didn't pick up on this before, this is the value stored in @map[a][:matches].

\n\n

lending weight to the map

\n\n

So now that we've got all of our articles laid out in our multi-dimensional @map hash, we've got a little bit of flexibility in terms of how we actually assign search value to the :instances and :matches values that we've created. And we give ourselves that flexibility with the weight_map method:

\n", nil, "
\n
def weight_map(opts={})\n  instance_weight = opts[:instances] ? opts[:matches] : 1.5\n  match_weight = opts[:matches] ? opts[:matches] : 1\n  \n  @map.each_key do |k|\n    @map[k][:weighted] = (@map[k][:instances] * instance_weight) + (@map[k][:matches] * match_weight)\n  end\nend
\n
\n", nil, "

It seems like, more than anything, the total number of occurrences of all keywords in all fields of a given article should be given the greatest priority, so we weight that value at 50% greater than, or 1.5x the value of the number of times an article matched a keyword at all.

\n\n

Once we've assigned those values it's easy enough to create a new :weighted value in our second-degree hash, which combines the weighted values that we assigned to :instances and :matches respectively.

\n\n

sorting and returning the weighted map

\n\n

Now that we've got that squared away, and we've figured out which articles should prove the most valuable to our user, all we have to do is define a custom sort function to sort those articles by the new :weighted value that we've created. And it looks like this:

\n", nil, "
\n
def sort_map_and_return_articles \n  @map.sort {|x,y| y[1][:weighted] <=> x[1][:weighted]}.collect{|r| r[0]}\nend
\n
\n", nil, "

Simple enough. Because we're passing hashes into the @map.sort block, they're converted into Arrays and need to be treated as such. That's why we're sorting the hash found at y[1][:weighted] instead of, as we saw before, the hash at y[:weighted]. It's also worth noting that we put the y before the x in the sort method because we want the articles with the greatest weight to be listed first in our subsequent, sorted Array.

\n\n

The result of the custom sort method is then collected with .collect{|r| r[0]} which returns an array of JUST the articles that we've already sorted, with none of the Hash data.

\n\n

putting it all together

\n\n

So we've got all of our class methods in order now, and it's really nice to have such a well-seperated process for defining and sorting the results of our custom search, so that we can intervene at various intervals later for more specific uses.

\n\n

That said, it's also really nice to be able to process all of those methods in a logical order without having to call all of them individually. It's for this reason that I grouped all of these steps into the go! method.

\n\n

Go! Go Gadget Search Engine!

\n\n

I guess it's a bit presumptuous to assume that this is the search engine that Inspector Gadget would use were he to need one (I'm no Sergey Brin or Larry Page at the end of the day), but it's such flights of fancy that keep lowly developers like myself in the game, striving for a cameo one day in a Saturday morning children's cartoon. A boy can dream....

\n\n

Anyway, go!. It's a useful and all-encompassing method that basically performs our entire search for us. And it looks like this:

\n", nil, "
\n
def go!\n  return [] if @keys == [] || @keys == nil\n  build_conditions_and_query\n  adjust_query_for_user\n  return get_map_weight_and_sort_results\nend\n  \ndef get_map_weight_and_sort_results\n  get_results\n  map_results\n  weight_map\n  return sort_map_and_return_articles\nend
\n
\n", nil, "

You'll recognize all of these methods except for get_map_weight_and_sort_results, which I've listed above for reference. I basically just groups all of the post-query-building methods together and allows you to call them in one fell swoop if you're so inclined. Having been so inclined, I did just that, and called the method at the end of go! to return our pretty, mapped, weighted and sorted array of Articles.

\n\n

The other important part that you'll notice is the first line:

\n", nil, "
\n
return [] if @keys == [] || @keys == nil
\n
\n", nil, "

Which pre-emptively returns an empty Array if there are no values in the @keys Array, saving our system from squandering precious resources just for the sake of it. Note that the use of return here on the first line here is what ceases execution of go! if @keys is an empty Array or is nil. It's a nifty workaround for the more lengthy if/else convention.

\n\n

With that in place, it's now really easy to create a straightforward, powerful search method that we can call with Article.search, like this:

\n", nil, "
\n
def self.search(keywords, opts={})\n  s = Article::Search.new(keywords, opts)\n  return s.go!\nend
\n
\n", nil, "

beauty is within the eyes of the controller

\n\n

Or beholder. I forget which, now. Either way, you'll probably want to add the following code to your Articles/Posts/WhateverContentNameYouUsed controller:

\n", nil, "
\n
#!/app/controllers/articles_controller.rb\n\ndef search\n  phrase = params[:search] ? params[:search] : ""\n  @results = Article.search(phrase, :user => @current_user)\n\n  if phrase == ""\n    flash.now[:warning] = %q{\n      Sorry to tell you, but you're going to\n      have to actually search for something\n    }\n  elsif @results.size == 0\n    flash.now[:notice] = %q{\n      Hmm, didn't find anything.  Give it\n      another shot, maybe?\n    }\n  end\nend
\n
\n", nil, "

As you see all of the search results will be saved into the aptly-named @results variable, which we'll implement in our views in a moment.

\n\n

You should be able to cut and paste this right into your code, unless you don't have user roles or any kind of session handling. If that's the case, then you can just get rid of the :user => @current_user option in Article.search. And if your app does utilize roles or sessions, just make sure to change @current_user to whatever variable you're using to hold the active user object.

\n\n

a room with a view

\n\n

I'm really getting nonsensical with these section headings, but if you just gather that this next bit's about the views that you'll need then we're all good.

\n\n

The first bit that you'll need is the actual search input field and submit button, which I personally included as a partial for use on almost all of my site pages. Here's what that looks like:

\n", nil, "
\n
#!/app/views/articles/_search.erb\n\n<% form_tag :controller => 'articles', :action => 'search' do %>\n  <span class="textFields">\n    <%= text_field_tag :search, params[:search], :id => 'search_field' %>\n  </span>\n  <span class="submit">\n    <%= submit_tag "Search Articles", :name => nil %>\n  </span>\n<% end %>
\n
\n", nil, "

Yeah, it's in ERB format, but HAML was giving me fits for some reason and after the hour that I spent troubleshooting I thought the better of spending another and just did the dirty ERB deed. Whatever, it works. And it's not wholly illegible. Anyway, this is another one that should be easy enough to copy and paste into your app. There's nothing terribly app specific except for, perhaps, the :controller => 'name' bit.

\n\n

Cool, so that's in place. The last piece is, of course, the view for the 'search' page that this form is going to propel us to upon submission.\nWhich is really, really alarmingly simple since we've done all of the work behind the scenes.

\n\n

It looks like this in HAML:

\n", nil, "
\n
#!/app/views/articles/search.haml\n\n#primary\n  - total = pluralize @results.size, "result"\n  %h2.center="\#{total} for \\"\#{params[:search]}\\""\n  = render :partial => @results
\n
\n", nil, "

Yeah, I know that I slightly obfuscated our glorious MVC principles by defining total in the view, but it was really only one fewer word to call it from a helper, and it seemed really silly to do so. Hopefully the Rails gods will forgive me (and, in turn, you).

\n\n

Anyway, this is pretty straightforward. We calculate the total number of search results and display it as a pluralize term using the Rails pluralize helper. Then, under that, we simply render all of the search results as partials. I'm assuming here, of course, that you already have an _article.haml partial, or whatever equivalent that you should be using to display those records as short-hand. Otherwise you'll have to create one, which you should really do anyway.

\n\n

Note that the partial should be named classname.haml rather than result.haml, because render :partial => assigns partial views to the data based on the Classes of the rendered objects, not the name of the variable from which they're called. This was a really, really, really smart way of executing this and really personifies the logic and wonder that is The Rails Way.

\n\n

finally, a bit o' config

\n\n

The last thing that you'll have to do before actually using your new, glorious search engine, is to create route in /config/routes.rb to set the location of our search action. It's easy enough:

\n", nil, "
\n
#!/config/routes.rb\n\nmap.connect '/articles/search', :controller => 'articles', :action => 'search'
\n
\n", nil, "

Now just add a bit of style to your stylesheets and you'll be searching like a pro in no time. Wasn't that easy?

\n\n

on a related note

\n\n

Yes it was. Or maybe it wasn't. Either way, you've got your Article.search function installed now and you can get a little creative, even funky with it by going outside the parameters of the conventional search utility. You could (and should), for example, use it to create a method in your Article model that displays related article by using the current article title as a search phrase.

\n\n

Wow! What a great idea? Let's try it:

\n", nil, "
\n
#!/app/model/article.rb\n\ndef related(limit=5)\n  s = Article::Search.new(title, :user => :guest)\n  return [] if s.keys == [] || s.keys == nil\n  s.build_conditions_and_query\n  s.adjust_query_for_user\n  \n  # Adjust query string to not return self\n  s.query_string.insert(0, "(") << ") AND id != ?"\n  s.conditions << self.id\n\n  # Get and adjust results\n  return s.get_map_weight_and_sort_results[0, limit]\nend
\n
\n", nil, "

As you see above, calling the related method from the article creates a new Article::Search object, with the article's title as the search phrase. It then calls the build_conditions_and_query and adjust_quest_for_user methods without calling go!, so that it can, in the proceeding step, exclude itself from the query results by adding \"AND id != ?\" to the query string, and its own id to @conditions.

\n\n

It then just calls get_map_weight_and_sort_results to get and adjust the results, and returns only as many as limit is set to.

\n\n

With this in place, you can now display the related articles on your Show Article pages by including render :partial => @article.related or @article.related.each {|a| some_action_here }. Either way, this is an enormously user-friendly way to improve the browsing experience of your users, or even just to improve the SEO relevance or your article pages.

\n\n

in reflection

\n\n

So what did we learn today? We learned that there are ninety-seven thousand ways to skin a cat, and that the Sphinx-based search solutions aren't the only ones available to you. Even if you don't want to use my code verbatim, this should give you an idea of the principles that should help to assemble a lightweight, effective search engine for your Rails app.

\n\n

No, your site may not have millions of daily unique visitors like rubyinside.com, but at least you'll have a search engine.

\n\n

Take that, RubyInside.

\n"]

no comments

login to post comments, or register to post a comment

latest links

Help.GitHub - Multiple SSH keys The article from github help mirroring this process
ones zeros majors and minors ones zeros majors and minors: esoteric adventures in solipsism, by chris wanstrath
ActiveScaffold A Ruby on Rails plugin for dynamic, AJAX CRUD interfaces

login

register activate reset

feeds

articles/rss

topics

staff

editor

about

doblock focuses on ruby, rails, and all things that can help ruby and/or rails programmers hone their skills.

Techniques, tutorials, news, and even free open-source applications, doblock seeks to fill in the cracks of the ruby/rails blogosphere.

doblock v. 0.10.1 powered by Rails