Search and Autocomplete in Rails Apps

Ilya Bodrov-Krukowski
Share

Adding search to Rails apps

Searching is one of the most common features found on virtually any website. There are numerous solutions out there for easily incorporating search into your application, but in this article I’ll discuss Postgres’ native search in Rails applications powered by the pg_search gem. On top of that, I’ll show you how to add an autocomplete feature with the help of the select2 plugin.

I’ll explore three examples of employing search and autocomplete features in Rails applications. Specifically, this article covers:

  • building a basic search feature
  • discussing additional options supported by pg_search
  • building autocomplete functionality to display matched user names
  • using a third-party service to query for geographical locations based on the user’s input and powering this feature with autocomplete.

The source code can be found at GitHub.

Getting Started

Go ahead and create a new Rails application. I’ll be using Rails 5.0.1, but most of the concepts explained in this article apply to older versions as well. As long as we’re going to use Postgres’ search, the app should be initialized with the PostgreSQL database adapter:

rails new Autocomplete --database=postgresql

Create a new PG database and setup config/database.yml properly. To exclude my Postgres username and password
from the version control system, I’m using the dotenv-rails gem:

Gemfile

# ...
group :development do
  gem 'dotenv-rails'
end

To install it, run the following:

$ bundle install

and create a file in the project’s root:

.env

PG_USER: 'user'
PG_PASS: 'password'

Then exclude this file from version control:

.gitignore

.env

Your database configuration may look like this:

config/database.yml

development:
  adapter: postgresql
  database: autocomplete
  host: localhost
  user: < %= ENV['PG_USER'] %>
  password: < %= ENV['PG_PASS'] %>

Now let’s create a table and populate it with sample data. Rather than invent anything complex here, I’ll simply generate a users table with name and surname columns:

$ rails g model User name:string surname:string
$ rails db:migrate

Our sample users should have distinct names so that we can test the search feature. So I’ll use the Faker gem:

Gemfile

# ...
group :development do
  gem 'faker'
end

Install it by running this:

$ bundle install

Then tweak the seeds.rb file to create 50 users with random names and surnames:

db/seeds.rb

50.times do
  User.create({name: Faker::Name.first_name,
              surname: Faker::Name.last_name})
end

Run the script:

$ rails db:seed

Lastly, introduce a root route, controller with the corresponding action and a view. For now, it will only display
all the users:

config/routes.rb

# ...
resources :users, only: [:index]
root to: 'users#index'

users_controller.rb

class UsersController < ApplicationController
  def index
    @users = User.all
  end
end

views/users/index.html.erb

<ul>
  < %= render @users %>
</ul>

views/users/_user.html.erb

<li>
  < %= user.name %> < %= user.surname %>
</li>

That’s it; all preparations are done! Now we can proceed to the next section and add a search feature to the app.

Searching for Users

What I’d like to do is display a search field at the top of the root page saying “Enter user’s name or surname”
with a “Search!” button. When the form is submitted, only the users matching the entered term should be displayed.

Start off with the form:

views/users/index.html.erb

< %= form_tag users_path, method: :get do %>
  < %= text_field_tag 'term', params[:term], placeholder: "Enter user's name or surname" %>
  < %= submit_tag 'Search!' %>
< % end %>

Now we need to grab the entered term and do the actual search. That’s when the pg_search gem comes into play:

Gemfile

# ...
gem 'pg_search'

Don’t forget to install it:

$ bundle install

Pg_search supports two modes: multisearchable and pg_search_scope. In this article, I’ll use the latter option (multisearchable supports the same options, but allows global search to be crafted on multiple models). The idea is quite simple: we create a search scope that’s similar to ActiveRecord scopes. It has a name and a variety of options, like what fields to perform search against. Before doing that, the PgSearch module must be included in the model:

models/user.rb

# ...
include PgSearch
pg_search_scope :search_by_full_name, against: [:name, :surname]

In this example, the :name and :surname columns will be used for searching.

Now tweak the controller’s action and utilize the new search scope:

users_controller.rb

# ...
if params[:term]
  @users = User.search_by_full_name(params[:term])
else
  @users = User.all
end

The view itself doesn’t require any additional changes. You can now boot the server, visit the root page and enter a name or a surname of some user, and everything should be working properly.

Additional Options

Our searching works, but it’s not very convenient. For example, if I enter only a part of the name or a surname, it won’t return any results. This can be easily fixed by providing additional parameters.

First of all, you need to choose which searching technique to employ. The default one is Postgres’ full text search, which I’ll use in this article. Two others are trigram and metaphone searches, which require additional PG extensions to be installed. In order to set the technique, employ the :using option:

pg_search_scope :search_by_full_name, against: [:name, :surname], using: [:tsearch]

Each technique also accepts various options. For example, let’s enable searching for partial words:

models/user.rb

class User < ApplicationRecord
  include PgSearch
  pg_search_scope :search_by_full_name, against: [:name, :surname],
    using: {
      tsearch: {
        prefix: true
      }
    }

Now if you enter “jay” as a search term, you’ll see all users whose full name contains “jay”. Note that this feature is only supported starting from Postgres version 8.4.

Another interesting option is :negation, which allows exclusion criteria to be provided by adding the ! symbol. For example, Bob !Jones. Enable this option as follows:

models/user.rb

class User < ApplicationRecord
  include PgSearch
  pg_search_scope :search_by_full_name, against: [:name, :surname],
    using: {
      tsearch: {
        prefix: true,
        negation: true
      }
    }

Note, however, that sometimes this feature may provide unexpected results. (Read more here.)

It would be also cool to visually highlight the found match somehow. This is possible with :highlight option:

models/user.rb

# ...
include PgSearch
pg_search_scope :search_by_full_name, against: [:name, :surname],
  using: {
    tsearch: {
      prefix: true,
      negation: true,
      highlight: {
        start_sel: '<b>',
        stop_sel: '</b>',
      }
    }
  }

Here we are saying that the selection should be wrapped with the b tags. The highlight feature has some other options available, which can be seen here.

In order for the highlighting feature to work, we need to make two more changes. First, chain the with_pg_search_highlight method like this:

users_controller.rb

# ...
@users = User.search_by_full_name(params[:term]).with_pg_search_highlight

Second, tweak the partial:

views/users/_user.html.erb

<li>
  < % if user.respond_to?(:pg_search_highlight) %>
    < %= user.pg_search_highlight.html_safe %>
  < % else %>
    < %= user.name %> < %= user.surname %>
  < % end %>
</li>

The pg_search_highlight method returns the concatenated values of our search fields (:name and :surname) and highlights the found match.

Adding an Autocomplete Feature

Another popular feature found on many sites is autocomplete. Let’s see how it can be crafted with pg_search and the select2 plugin. Suppose we have a “Send message” page where users can select whom to write to. We won’t actually build the messaging logic, but if you’re interested in how this can be done, read my article Build a Messaging System with Rails and ActionCable.

Add two gems into the Gemfile:

Gemfile

# ...
gem 'select2-rails'
gem 'underscore-rails'

Select2 is a plugin to turn generic select fields into beautiful and powerful controls, whereas Underscore.js will provide some convenient methods. Hook the necessary files:

javascripts/application.js

//= require underscore
//= require select2
//= require messages

The messages.coffee file will be created in a moment. Select2 also requires some styles to function properly:

stylesheets/application.scss

@import 'select2';

Now let’s quickly add a new route, controller and view:

config/routes.rb

# ...
resources :messages, only: [:new]

messages_controller.rb

class MessagesController < ApplicationController
  def new
  end
end

views/messages/new.html.erb

<%= form_tag '' do %>
  < %= label_tag 'to' %>
  < %= select_tag 'to', nil, style: 'width: 100%' %>
< % end %>

The form is not finished, because it won’t be sent anywhere. Note that initially the select field doesn’t have any values; they’ll be displayed dynamically based on the user’s input.

Now the CoffeeScript:

javascripts/messages.coffee

jQuery(document).on 'turbolinks:load', ->
  $('#to').select2
    ajax: {
      url: '/users'
      data: (params) ->
        {
          term: params.term
        }
      dataType: 'json'
      delay: 500
      processResults: (data, params) ->
        {
          results: _.map(data, (el) ->
            {
              id: el.id
              name: "#{el.surname}, #{el.name}"
            }
          )
        }
      cache: true
    }
    escapeMarkup: (markup) -> markup
    minimumInputLength: 2
    templateResult: (item) -> item.name
    templateSelection: (item) -> item.name

I won’t dive into all the details explaining what this code does, as this article is not devoted to Select2 and JavaScript in general. However, let’s quickly review the main parts:

  • ajax says that the entered data should be asynchronously sent to the /users URL awaiting JSON in response (dataType).
  • delay: 500 means that the request will be delayed to 0.5 seconds after a user has finished typing.
  • processResults explains how to process the data received from the server. I’m constructing an array of objects
    containing users’ ids and full names. Note that the id attribute is required.
  • escapeMarkup overrides the default escapeMarkup function to prevent any escaping from happening.
  • minimumInputLength stipulates that a user should enter at least 2 symbols.
  • templateResult defines the look of the options displayed in dropdown.
  • templateSelection defines the look of the selected option.

The next step is to tweak our users#index action allowing it to respond with JSON:

users_controller.rb

# ...
def index
  respond_to do |format|
    if params[:term]
      @users = User.search_by_full_name(params[:term]).with_pg_search_highlight
    else
      @users = User.all
    end
    format.json
    format.html
  end
end

Lastly, the view:

views/users/index.json.jbuilder

json.array! @users do |user|
  json.id user.id
  json.name user.name
  json.surname user.surname
end

Note that in order for this to work, the jBuilder gem is needed. For Rails 5 apps, it’s present in the Gemfile by default.

So far, so good, but can’t we do a bit better? Remember that highlight feature that was added in the previous section? Let’s employ it here as well! What I want to do is highlight the found match in the dropdown, but not when the option is already selected. Therefore, we’ll need to keep the name and surname fields in the JSON response and also introduce the full_name field:

views/users/index.json.jbuilder

json.array! @users do |user|
  json.id user.id
  json.full_name user.pg_search_highlight.html_safe
  json.name user.name
  json.surname user.surname
end

Now tweak the CoffeeScript changing the processResults to the following:

processResults: (data, params) ->
  {
    results: _.map(data, (el) ->
      {
        name_highlight: el.full_name
        id: el.id
        name: "#{el.surname}, #{el.name}"
      }
    )
  }

Also change templateResult to this:

templateResult: (item) -> item.name_highlight

Note that if you don’t override the escapeMarkup function, all output will be escaped and the b tags used for highlighting will be rendered as plain text.

That’s it. Feel free to play around with this feature and extend it further!

Autocomplete with a Third-party Service

Students sometimes ask me how to build a field with an autocomplete feature that allows users to select their location. As a bonus, I’m going to show you one of the possible solutions in this section. In order to search for cities, I’ll use geonames.org, a free service that has information about virtually anything: big cities and small towns, countries, postal codes, various historical locations etc. All you need to do in order to start using it is register and enable API access inside your profile.

Start off with adding a new route, controller and view:

config/routes.rb

# ...
resources :cities, only: [:index]

cities_controller.rb

class CitiesController < ApplicationController
  def index
  end
end

views/cities/index.html.erb

<%= form_tag '' do %>
  < %= label_tag 'city' %>
  < %= select_tag 'city', '', style: 'width: 100%;' %>
< % end %>

The CoffeeScript code will be very similar to the code for adding the autocomplete feature for the messaging page:

cities.coffee

template = (item) -> item.text

jQuery(document).on 'turbolinks:load', ->
  $('#city').select2
    ajax: {
      url: 'http://ws.geonames.org/searchJSON'
      data: (params) ->
        {
          name: params.term,
          username: 'your_name',
          featureClass: 'p',
          cities: 'cities1000'
        }
      dataType: 'json'
      delay: 500
      processResults: (data, params) ->
        {
          results: _.map(data.geonames, (el) ->
            name = el.name + ' (' + el.adminName1 + ', ' + el.countryName + ')'
            {
              text: name
              id: name
            }
          )
        }
      cache: true
    }
    escapeMarkup: (markup) -> markup
    minimumInputLength: 2
    templateResult: template
    templateSelection: template

The three parts that have changes are url, data and processResults. As for url, we’re using the searchJSON API method. This method supports various parameters to define what objects, and of what type, we want to search for. Their options are passed inside the data attribute:

  • name is a required parameter that can contain the place’s full or partial name.
  • username indicates your GeoNames account.
  • featureClass: as long as GeoNames has a huge database with various geographical objects, we want to narrow
    the searching scope. Feature class P means that we wish to search only for cities, towns and villages. Other feature classes can be found here.
  • cities: I’ve limited the search only to cities populated with at least 1000 people. This is done because there are too many small towns and villages with identical names. For example, I was able to find at least four places named “Moscow”.

In the processResults we take the returned geonames and construct the string that will be displayed in the dropdown. For each city we display its name, area and country.

That’s pretty much it, so now you can observe this feature in action!

Conclusion

In this article, we have seen the pg_search gem and select2 plugins in action. With their help, we constructed search functionality and added an autocomplete feature into our application, making it a bit more user friendly. Pg_search has a lot more more exciting stuff to offer, so be sure to browse its docs.

What searching solutions do you use? What are their key features? Share your experience in the comments!

CSS Master, 3rd Edition