ben tedder : code things

Creating a basic autocomplete search endpoint in Ruby on Rails

Here's a quick and straightforward way to create a search endpoint in Ruby on Rails that allows a user to search through a database of products. For this tutorial I'll be using Rails 5 in API mode, but this should work in the most recent versions of Rails.

1. Define the route

There are two different ways we can create our route for the autocomplete endpoint. The first is by creating just a plain route like this:

Rails.application.routes.draw do

  get '/search', to: 'products#search'

end

Now any GET request that comes to http://localhost:3000/search will get routed to the search method in the products_controller.rb file.

But there is another way to create a route here. Let's say we're dealing with a resource route. How can we add a route to an already established resourceful route for our products? Check the following out:

Rails.application.routes.draw do

  resources :products do
    get 'search', on: :collection
  end

end

Now this way we get to take advantage of the resourceful route (which includes the simple CRUD actions as you'd expect), while also getting another method from which we can handle a user's search.

2. Define the search method

The search method we define in our controller needs to do a couple of things:

  • receive a string (or in most cases a substring) of a word
  • search a column (or multiple columns) for that substring
  • return back a list of products in a usable format

Here's a basic definition of this method. Explanation to follow the code:

class ProductsController < ApplicationController

  def search
    term = params[:term] || nil
    products = []
    products = Product.where('name LIKE ?', "%#{term}%") if term
    render json: products
  end

end

So what's happening here? First, we look for the parameter of 'term' (user sends an ajax request to http://localhost:3000/products/search?term=cardig). If it exists, we'll run a fairly basic LIKE query against our Active Record Product model. Notice the double quotes, percentage signs, and then the ruby hash interpolation around the term. This is so we can wildcard match any substring against all the names in the products table. Finally, we render that back as json to the client.

Improving

Now, let's say the user enters the letter 'a'. Well, first of all we need to modify our form on the front-end to only kick off a query after an intelligent number of letters been entered. Depending on the application, 2-3 characters is a pretty good limit before kicking off a search. Also, don't forget to debounce those queries so there is a delay of 300-500 milliseconds between the user typing and a query being sent off.

tip: if you're using angular add the ng-model-options="{ debounce: 300 }" attribute to your search box.

Let's say the user enters three characters and stops typing. Query gets fired, route handles it, method grabs the term the user typed, and boom, we get back thousands and thousands of results. What do we do? We need to be more intelligent about how we return results.

Straight-up limit

A fairly common pattern I have seen (and used) is just to limit the number of results to something reasonable like 25. Depending on the application and subject matter this could be hindering to the user, as sometimes the item they are searching for cannot really be specified by using more letters. If we went this route, our method would change to something like this:

class ProductsController < ApplicationController

  def search
    term = params[:term] || nil
    products = []
    products = Product.where('name LIKE ?', "%#{term}%").limit(25) if term
    render json: products
  end

end

Pagination

If you don't want to set an arbitrary limit on your response, consider letting the user do it, or provide pagination on the front-end to look through results. It just takes a few tweaks to our code to allow for this:

class ProductsController < ApplicationController

  def search
    limit = params[:limit] || 10
    page = params[:page] || 10
    offset = ((page.to_i - 1) * limit.to_i) || 0
    term = params[:term] || nil
    products = []
    products = Product
               .where('name LIKE ?', "%#{term}%")
               .limit(limit)
               .offset(offset) if term
    render json: products
  end

end

Notice now we have two more parameters the user can send down, limit, and page. From the limit and page we can calculate the offset to send to our Active Record query. This allows us to run faster queries and send smaller payloads to the user. I prefer this method because it's not limiting to the user, and it's not heavy on the database.

Searching multiple fields

In the case of our products, what if we want the user to not just search the name, but also the description, or the color of the item? Active Record makes that pretty easy by still allowing safe interaction with MySQL queries:

products = Product
    .where('name LIKE ? '\
    'OR description LIKE ? '\
    'OR color LIKE ?', "%#{term}%", "%#{term}%", "%#{term}%")

In this block we've swapped out our query for a query that performs LIKE queries on three different fields. We can still apply the limit and offset to this too if we like.

Conclusion

As you can see, it's not a ton of work to add something like this to a resourceful route you already have setup. Even the front-end isn't terribly complicated. Good luck! Leave comments below if you have questions or concerns.