Implementing a Live Search

Problem

You want to add a real-time search feature to your site. Instead of rendering the results of each query in a new page, you want to display continually updating results within the current page, as users enter their query terms.

Solution

Use Rails Ajax helpers to create a live search.

Your site allows users to search for books. The first thing you'll need for this is a Book model. Create it with:

$ Ruby script/generate model Book

Then in the generated migration, create the books table and populate it with a few titles:

db/migrate/001_create_books.rb:

class CreateBooks < ActiveRecord::Migration
 def self.up
 create_table :books do |t|
 t.column :title, :string
 end
 Book.create :title => 'Perl Best Practices'
 Book.create :title => 'Learning Python'
 Book.create :title => 'Unix in a Nutshell'
 Book.create :title => 'Classic Shell Scripting'
 Book.create :title => 'Photoshop Elements 3: The Missing Manual'
 Book.create :title => 'Linux Network Administrator's Guide'
 Book.create :title => 'C++ Cookbook'
 Book.create :title => 'UML 2.0 in a Nutshell'
 Book.create :title => 'Home Networking: The Missing Manual'
 Book.create :title => 'AI for Game Developers'
 Book.create :title => 'JavaServer Faces'
 Book.create :title => 'Astronomy Hacks'
 Book.create :title => 'Understanding the Linux Kernel'
 Book.create :title => 'XML Pocket Reference'
 Book.create :title => 'Understanding Linux Network Internals'
 end
 def self.down
 drop_table :books
 end end

Next, include the script.aculo.us and Prototype libraries in your layout using javascript_include_tag:

app/views/layouts/books.rhtml:

<html>
 <head>
 <title>Books</title>
 <%= javascript_include_tag :defaults %>
 </head>
 <body>
 <%= yield %>
 </body>
</html>

Create a Books controller that defines index and search methods. The search method responds to Ajax calls from the index view:

app/controllers/books_controller.rb:

class BooksController < ApplicationController
 def index
 end
 def get_results
 if request.xhr?
 if params['search_text'].strip.length > 0
 terms = params['search_text'].split.collect do |word| 
 "%#{word.downcase}%" 
 end 
 @books = Book.find(
 :all, 
 :conditions => [
 ( ["(LOWER(title) LIKE ?)"] * terms.size ).join(" AND "),
 * terms.flatten
 ] 
 ) 
 end 
 render :partial => "search" 
 else 
 redirect_to :action => "index" 
 end
 end end

The index.rhtml view displays the search field and defines an observer on that field with the observe_field JavaScript helper. An image tag is defined as well, with its CSS display property set to none.

app/views/books/index.rhtml:

<h1>Books</h1>
Search: <input type="text" id="search_form" />
<img id="spinner" src="/images/indicator.gif" /> 
<div id="results"></div>
<%= observe_field 'search_form',
 :frequency => 0.5, 
 :update => 'results',
 :url => { :controller => 'books', :action=> 'get_results' },
 :with => "'search_text=' + escape(value)",
 :loading => "document.getElementById('spinner').style.display='inline'",
 :loaded => "document.getElementById('spinner').style.display='none'" %>

Finally, create a partial to display search results as a bulleted list of book titles:

app/views/books/_search.rhtml:

<% if @books %>
 <ul>
 <% for book in @books %>
 <li> 
 <%= h(book.title) %>
 </li> 
 <% end %>
 </ul>
<% end %>

Discussion

When new users first arrive at your site, you don't have much time to make a first impression. You need to show them quickly that your site has what they're looking for. One way to make a good impression quickly is to provide a live search that displays query results while the search terms are being entered.

The solution defines an observer that periodically responds to text as it's entered into the search field. The call to observe_field takes the id of the element being observedthe search field in this case. The :frequency option defines how often the contents of the field are checked for changes.

When changes in the value of the search field are detected, the :url option specifies that the get_results is called with the search_text parameter specified by the :with option. The final two options handle the display of the "spinner" images, which indicate that a search is in progress. The image used in this context is typically an animated GIF. Any results returned are displayed in the element specified by the :update option.

The get_results method in the Book controller handles the XMLHttpRequests generated by the observer. This method first checks that the request is an Ajax call. If it isn't, a redirect is issued. If the request.xhr? test succeeds, then the search_text value of the params hash is checked for nonzero length after any leading or trailing whitespace is removed.

If params['search_text'] contains text, it's split on spaces, and the resulting array of words is stored in the terms variable. collect is also called on the array of words to ensure that each word is in lowercase.

The find method of the Book class does the actual search. The conditions option creates a number of SQL LIKE clauses, one for each word in the terms array. These SQL fragments are then joined together with AND to form a valid statement.

The array passed to the :conditions option has two elements. The first being the SQL with bind variable place holders (i.e., ?). The asterisk operator before terms.flatten expands the array returned by the flatten method into individual arguments. This is required because the number of bind parameters must match the number bind positions in the SQL string.

Finally, the _search.rhtml partial is rendered, displaying any contents in the @books array as an unordered list within the results div element in the index view.

demonstrates that multiple terms can produce a match regardless of their order.

Figure 8-11. A live search of books returning a list of matching titles

See Also