CheeseCloth
Makes filtering in Rails based on params less of a pain. CheeseCloth provides a transparent, tiny DSL to help you chain filters together that only run if a given param is present.
Introduction
Want to skip the intro? Check out the examples section.
Dealing with filtering based on params in Rails is a pain.
Let's say the boss tells you that you need to implement an endpoint for fetching Events. This endpoint needs to allow you to filter by a (possibly one-sided) date range, and also optionally only include events that the current user is attending.
GET /api/events
?filter[start_date]=2016-10-1
&filter[end_date]=2016-11-1
&filter[current_user_attending]=true
Your controller action quickly becomes a nightmare. But wait—you're a
good developer, and you extract these filters out into an EventFilterer object:
class EventFilterer
attr_reader :scope, :user, :params
def initialize(params, user:, scope: Event.all)
@params = params
@user = user
@scope = scope
end
def filtered_scope
if start_date
@scope = @scope.where("starts_at > ?", start_date)
end
if end_date
@scope = @scope.where("ends_at < ?", end_date)
end
if current_user_attending?
@scope = @scope.where_user_attending(user)
end
@scope
end
private
def start_date
parse_date(params[:start_date])
end
def end_date
parse_date(params[:end_date])
end
def current_user_attending?
parse_boolean(params[:current_user_attending])
end
def parse_date(iso_string)
Time.zone.parse(iso_string || "")
end
def parse_boolean(bool_string)
!["f", "false", "0", ""].includes?(bool_string)
end
end
This is a win, right? Sure! At least, it flies with your boss. But there's so much boilerplate. We can do better.
class EventFilterer
include CheeseCloth
attr_reader :user, :params
def initialize(params, user:)
@params = params
@user = user
end
scope -> { Event.all }
filter :start_date do
scope.where("starts_at > ?", start_date)
end
filter :end_date do
scope.where("ends_at < ?", end_date)
end
filter :current_user_attending? do
scope.where_user_attending(user)
end
private
def start_date
parse_date(params[:start_date])
end
def end_date
parse_date(params[:end_date])
end
def current_user_attending?
parse_boolean(params[:current_user_attending])
end
def parse_date(iso_string)
Time.zone.parse(iso_string || "")
end
def parse_boolean(bool_string)
!["f", "false", "0", ""].includes?(bool_string)
end
end
Neat! We could stop here, and we'd be fully utilizing CheeseCloth—but deserializing params is a solved problem, and you have many options. I like using Virtus to do it, but you can use anything that makes your params accessible via methods. Let's see what that looks like.
class EventFilterer
include CheeseCloth
include Virtus.model
attribute :start_date, DateTime
attribute :end_date, DateTime
attribute :current_user_attending, Boolean
def initialize(params, user:)
@user = user
super(params) # mass-assignment via Virtus
end
scope -> { Event.all }
filter :start_date do
scope.where("starts_at > ?", start_date)
end
filter :end_date do
scope.where("ends_at < ?", end_date)
end
filter :current_user_attending? do
scope.where_user_attending(user)
end
end
Now we're talkin'. While there's no hard dependency, CheeseCloth works really well when paired with Virtus. Here's our controller, by the way:
class EventsController < ApplicationController
def index
render json: filterer.filtered_scope
end
private
def filterer
EventFilterer.new(params[:filter], user: current_user)
end
end
Nice and simple. You can check out more use cases below.
Installation
Add this line to your application's Gemfile:
gem "cheesecloth"
And then execute:
$ bundle
Or install it yourself as:
$ gem install cheesecloth
Examples
Filtering based on a single parameter
class FooFilterer
include CheeseCloth
attr_reader :foo
def initialize(foo:)
@foo = foo
end
scope -> { [1, 2, 3] }
filter :foo do
# this will only run if self.foo is truthy.
scope.reverse
end
end
FooFilterer.new(foo: true).filtered_scope #=> [3, 2, 1]
FooFilterer.new(foo: false).filtered_scope #=> [1, 2, 3]
Filtering based on multiple parameters
class FooFilterer
include CheeseCloth
attr_reader :foo, :bar
def initialize(foo:, bar:)
@foo, @bar = foo, bar
end
scope -> { [1, 2, 3] }
filter [:foo, :bar] do
# this will only run if self.foo && self.bar
scope - [2]
end
end
FooFilterer.new(foo: true, bar: true).filtered_scope #=> [1, 3]
FooFilterer.new(foo: true, bar: false).filtered_scope #=> [1, 2, 3]
Applying a filter unconditionally
class FooFilterer
include CheeseCloth
scope -> { [1, 2, 3] }
filter do
scope + [4, 5, 6]
# this will always run
end
end
FooFilterer.new.filtered_scope #=> [1, 2, 3, 4, 5, 6]
Overriding the starting scope
If you need to, you can override the starting scope at "runtime" (a.k.a, right before the filters
are ran). #filtered_scope takes an optional scope keyword argument.
class FooFilterer
include CheeseCloth
scope -> { [1, 2, 3] }
filter do
scope + [4, 5, 6]
# this will always run
end
end
FooFilterer.new.filtered_scope(scope: [1]) #=> [1, 4, 5, 6]
Validating parameters
CheeseCloth doesn't have any mechanism for validation by design. I'd recommend turning your filterer into an ActiveModel:
class FooFilterer
include CheeseCloth
include ActiveModel::Model
# ...
validates :foo, presence: true
end
class FooController < ActionController::Base
def index
if filterer.valid?
render json: filterer.filtered_scope
else
render json: filterer.errors
end
end
private
def filterer
FooFilterer.new(...)
end
end
Real-world example (Virtus + ActiveModel)
The previous examples could have, of course, been simplified with the use of Virtus to handle mass assignment and deserialization, and using ActiveModel's validations. Here's a real-world scenario, with a corresponding controller action. Imagine our endpoint had the following criteria:
- Venue type must be specified.
- Start date and end date will either both be specified, or neither will be. If only one is specified, don't filter based on date.
class EventsFilterer
include CheeseCloth
include Virtus.model
include ActiveModel::Model
attribute :venue_type, String
attribute :start_date, DateTime
attribute :end_date, DateTime
validates :venue_type, presence: true
scope -> { Event.all }
filter :venue_type do
scope.at_venue_type(venue_type)
end
filter [:start_date, :end_date] do
scope.within_dates(start_date, end_date)
end
end
class EventsController < ApplicationController
def index
if filterer.valid?
# Note that we limit the scope to only the current user's events. Nifty!
render json: filterer.filtered_scope(scope: current_user.events)
else
render json: filterer
end
end
private
def filterer
EventsFilterer.new(params[:filter])
end
end
class Event < ApplicationRecord
scope :at_venue_type, ->(type) { where(venue_type: type) }
scope :within_dates, ->(start_date, end_date) do
where("starts_at BETWEEN ? and ?", start_date, end_date)
end
# ...
end
Development
After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run
the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new
version, update the version number in version.rb, and then run bundle exec rake release, which
will create a git tag for the version, push git commits and tags, and push the .gem file to
rubygems.org.
Contributing
- Fork this repo
- Add your feature in a branch
- Open a pull request
Before making a commit, please run rake spec and rubocop to ensure it will pass CI.
Please write good commit messages, be polite, and be open to discussing ways to improve on the code you've contributed.
License
The gem is available as open source under the terms of the MIT License.