Search and Autocomplete in 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 theid
attribute is required.escapeMarkup
overrides the defaultescapeMarkup
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 classP
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!