Separate your filters

Mike Burns

Filtering and searching gets out of hand. Split it off into its own class.

The idea is that we’ll use it like this:

class ArticlesController < ApplicationController
  def index
    @articles = Article.filtered(params[:article_filters])
  end
end

First we’ll need a mock filter. This is an object with a #restrict method (the filtering itself), and an equality check (the test magic):

class MockFilter
  def initialize
    @filters = []
  end

  def restrict(filters)
    @filters = filters
    self
  end

  def ==(other)
    other.filters == self.filters
  end

  protected

  attr_reader :filters
end

Once we have this mock, we can write a quick test. Here we’re filtering an article. The test ensures that we pass the right data:

describe Article, 'filtered' do
  let(:filter) { MockFilter.new }
  let(:filtered_articles) { filter.restrict(filters) }
  let(:filters) { {'a' => 'b', 'c' => 'd'} }

  around do |example|
    @old_filter = Article.filter
    Article.filter = filter

    example.run

    Article.filter = @old_filter
  end

  subject { FactoryBot.create(:article) }

  it "produces filtered articles" do
    Article.filtered(filters).should == filtered_articles
  end
end

This test sets the class-wide filter (needed because Rails does class-level programming instead of object-oriented programming), but cleans up afterward. We pass the ActiveRecord::Relation given by #scoped so any existing chains persist.

Given the above test we can write the article class quickly and simply:

class Article < ActiveRecord::Base
  cattr_writer :filter

  def self.filtered(params)
    filter.restrict(params)
  end

  protected

  def self.filter
    if defined?(@@filter) && @@filter
      @@filter
    else
      @@filter = ArticleFilter.new(self.scoped)
    end
  end
end

Finally we can write the filter itself, encapsulating all filtering logic in one place. All the tests (not shown) are boring, tedious, and needed, and also all in one compact place.

class ArticleFilter
  def initialize(relation)
    @relation = relation
  end

  def restrict(restrictions)
    published! if restrictions.try(:[], :published) == '1'
    this_week! if restrictions.try(:[], :recent) == '1'

    @relation
  end

  protected

  def published!
    where('published')
  end

  def this_week!
    where('created_at > ?', 1.week.ago)
  end

  def where(*a)
    @relation = @relation.where(*a)
  end
end

Unobtrusive Ruby helps you avoid fat models.