Search and Filter Rails Models Without Bloating Your Controller

Searching, sorting, and filtering in Rails controllers can be a pain. ElasticSearch and Solr are great, high-powered solutions, but are really big dependencies for a small app.

Luckily, Rails includes scopes, which can provide you with a lot of what you need for simple searching, filtering, and sorting. If you take advantage of scope chaining, you can build the features you want without taking on big dependencies or writing a bunch of repetitive search code yourself.

Searching with scopes

Imagine an #index method on your RESTful controller that shows a table of products. The products can be active, pending, or inactive, are available in a single location, and have a name.

If you want to be able to filter these products, you can write some scopes:

app/models/product.rb
class Product < ActiveRecord::Base
  scope :filter_by_status, -> (status) { where status: status }
  scope :filter_by_location, -> (location_id) { where location_id: location_id }
  scope :filter_by_starts_with, -> (name) { where("name like ?", "#{name}%")}
end

Each of these scopes defines a class method on Product that you can use to limit the results you get back.

@products = Product.filter_by_status("active").filter_by_starts_with("Ruby") # => All active products whose names start with 'Ruby'

Your controller can use these scopes to filter your results:

app/controllers/product_controller.rb
def index
  @products = Product.where(nil) # creates an anonymous scope
  @products = @products.filter_by_status(params[:status]) if params[:status].present?
  @products = @products.filter_by_location(params[:location]) if params[:location].present?
  @products = @products.filter_by_starts_with(params[:starts_with]) if params[:starts_with].present?
end

And now you can show just the active products with names that start with ‘Ruby’.

http://example.com/products?status=active&starts_with=Ruby

Clearly, this needs some cleanup

You can see how this code starts to get unwieldy and repetitive! Of course, you’re using Ruby, so you can stuff this in a loop:

app/controllers/product_controller.rb
def index
  @products = Product.where(nil)
  filtering_params(params).each do |key, value|
    @products = @products.public_send("filter_by_#{key}", value) if value.present?
  end
end

private

# A list of the param names that can be used for filtering the Product list
def filtering_params(params)
  params.slice(:status, :location, :starts_with)
end

A more reusable solution

You can move this code into a module and include it into any model that supports filtering:

app/models/concerns/filterable.rb
module Filterable
  extend ActiveSupport::Concern

  module ClassMethods
    def filter(filtering_params)
      results = self.where(nil)
      filtering_params.each do |key, value|
        results = results.public_send("filter_by_#{key}", value) if value.present?
      end
      results
    end
  end
end
app/models/product.rb
class Product
  include Filterable
  ...
end
app/controllers/product_controller.rb
def index
  @products = Product.filter(params.slice(:status, :location, :starts_with))
end

You now have filtering and searching of your models with one line in the controller and one line in the model. How easy is that? You can also get built-in sorting by using the built-in order class method, but it’s probably a better idea to write your own scopes for sorting. That way you can sanity-check the input.

To save you some effort, I put Filterable into a gist. Give it a try in your own project, It’s saved me a lot of time and code.

Update: Thanks to Jan Sandbrink for pointing something out: It’s easy to forget to whitelist params in filtering_params. If you do forget, it can open your app up to serious security problems.

To avoid all that, instead of using scopes named status, location, and starts_with, I updated this article to those scopes are now named filter_by_status, filter_by_location, and filter_by_starts_with. They’re clearer and safer that way.

An important warning

Sending params to scopes is an easy way to get basic searching and filtering in your web app. But if you’re not careful, and accept whatever your users send you, your app could have some pretty nasty security bugs.

In particular, order is vulnerable to SQL injection. So if you’re using params to define your sort order, you should always check the column names your user is sending you and only allow values you know are safe.

The Rails SQL Injection site will help you learn about which ActiveRecord methods are vulnerable, so you can keep your app secure.

Do you learn better with video?

You can watch every step, from starting a brand new app to adding searching and filtering, in the companion screencast. We’ll create an app, fill it with sample data, add a search form, and hook it up. And you’ll get the source along with the videos, so you can refer back when you add simple searching and filtering to your own Rails apps.

Learn more about the screencast here!

Pushing through tutorials, and still not learning anything?

Have you slogged through the same guide three times and still don't know how to build a real app?

In this free 7-day Rails course, you'll learn specific steps to start your own Rails apps — without giving up, and without being overwhelmed.

You'll also discover the fastest way to learn new Rails features with your 32-page sample of Practicing Rails: Learn Rails Without Being Overwhelmed.

Sign up below to get started:

Powered by ConvertKit

Did you like this article? You should read these:

Comments