Hi, welcome to Word of Mike, my little corner of the internet. I am a Software/Web Developer working in North Yorkshire. I mainly write about programming but my other passion is politics so beware. (click to hide)

2014-08-06 13:25:28 UTC

Easy Active Record Search Pattern


I've released this as a small gem.


I've written Active Record searching and filtering methods for a number of Rails apps and my pattern for doing so has been gradually improving over time. Here I will explain how I currently roll my own Active Record searching.

Before I dive in to this, you should be under no illusion that this is a comprehensive ransack-esque search solution, but it should be sufficiently flexible for many uses.

If you were naively building a search, you might begin by having a bunch of form controls and inputs send parameters to your controller and then before long you end up with something like this hacked together (which I see quite a lot):

class PostsController < ApplicationController
  def search
    @posts = Post.all
    @posts = @posts.where("title like ?", "%#{params[:title_filter]}%") if params[:title_filter].present?
    @posts = @posts.where(published: true) if params[:published].present?

    if params[:tags].present?
      tags = params[:tags].split(",").map(&:strip)
      tags = Tag.where(name: tags).pluck(:id)
      @posts = @posts.joins(:tags).where(tags: { id: tag_ids })
    end

    # ..and so on and so forth..
  end
end

Any Rails dev beyond their first footsteps should be able to detect that this kind of sucks. We're going to use this as a starting point to refactor this in to a tidy, understandable, maintainable and reusable pattern.

The first obvious low hanging fruit is to increase reusability by moving the individual filters to class methods. I'll call these scopes throughout the rest of this article because they return the chainable ActiveRecord::Relation and I don't use traditional scopes.

class Post < ActiveRecord::Base
  def self.with_title_like(title)
    where("title like ?", "%#{title}%")
  end

  def self.published(tf)
    where(published: tf)
  end

  def self.with_tags(tag_string)
    tags = tag_string.split(",").map(&:strip)
    joins(:tags).where(tags: { name: tags })
  end
end
class PostsController < ApplicationController
  def search
    @posts = Post.all
    @posts = @posts.with_title_like(params[:title_filter]) if params[:title_filter].present?
    @posts = @posts.published(params[:published]) if params[:published].present?
    @posts = @posts.with_tags(params[:tags]) if params[:tags].present?
  end
end

That's a marked improvement, as we can use @posts.published, etc, elsewhere. However, we'd rather keep the controller code as simple interactions with the model, so let's move it in to our model.

class Post < ActiveRecord::Base
  def self.search(options)
    posts = Post.all
    posts = posts.with_title_like(options[:title_filter]) if optons[:title_filter].present?
    posts = posts.published(options[:published]) if options[:published].present?
    posts = posts.with_tags(options[:tags]) if options[:tags].present?
    return posts
  end

  def self.with_title_like(title)
    where("title like ?", "%#{title}%")
  end

  def self.published(tf)
    where(published: tf)
  end

  def self.with_tags(tag_string)
    tags = tag_string.split(",").map(&:strip)
    joins(:tags).where(tags: { name: tags })
  end
end

Now our controller can be concise and expressive:

class PostsController < ApplicationController
  def index
    @posts = Post.search(params)
  end
end

Controllers should be this simple where possible, handling requests and setting up responses is what they're for. You might have noticed that I'm using the index action now too. I prefer to use the index action for searches, as a search is really just an index with conditions, and a full index is a search without conditions.

Let's look how we can improve the model code. The worst thing about it is that we're having to check if we have an associated search parameter to decide whether to run that scope or not, so let's extract that logic out into a method:

def self.search(options)
  posts = Post.all
  posts = posts.filter(:with_title_like, options[:title_filter])
  posts = posts.filter(:published, options[:published])
  posts = posts.filter(:with_tags, options[:tags])
  return posts
end

def self.filter(name, arg)
  arg.present? ? send(name, arg) : all
end

Note that if you are using Rails 3.x you should use scoped() instead of all()

That's pretty nice, but actually, you might notice that if we just introduce a little constraint that our option (params) keys match the method names of the filter, we can take this a step further.

def self.search(options)
  filter_all([
    :with_title_like,
    :published,
    :with_tags
  ], options)
end

def self.filter_all(names, opts)
  result = self.all
  names.each do |n|
    result = result.filter(n, opts[n])
  end
  result
end

def self.filter(name, arg)
  arg.present? ? send(name, arg) : all
end

Now when we want to allow filtering on some new criteria, we can add the appropriate class method and then the name of that class method to the list in our search, then adjust our search form to submit a parameter with that name.

It gives some formality to, and simplifies the contract between form and model, with respect to applying scopes for searching.