The Lapidary Lemur

Musings from Brandon Weaver

Sublime Scoping With Rails

Even the most ardent adherent of skinny controllers will find themselves plagued by the ferocious number of filters demanded for any non-trivial search on their models. Given enough attributes, you’ll notice your controller starting to look a little hairy

1
2
3
4
5
6
7
class PeopleController
  def index
    @people = Person.where(name: params[:name]) if params[:name]
    @people = @people.where(birthday: params[:birthday_start]..params[:birthday_end]) if params[:birthday_start] && params[:birthday_end]
    @people = @people.where(sex: params[:sex]) if params[:sex]
  end
end

The horrifying trend will only continue as our demand for searching power grows, which begs the question: How can we tame this mess?

Strong Params

Your first line of defense against this will be using strong params to your advantage. They’re not only for creating objects.

Let’s try something out in the console:

1
2
3
4
5
[1] pry(main)> ActionController::Parameters.new({a: 1, b: 2})
=> {"a"=>1, "b"=>2}
[2] pry(main)> _.permit(:a)
Unpermitted parameter: b
=> {"a"=>1}

So by using permit on our parameters object, we can filter down a hash to only our permitted values. So what if we did something like this?

1
2
3
4
5
6
class PeopleController
  def index
    @people = Person.where(params.permit(:name, :sex))
    @people = @people.where(birthday: params[:birthday_start]..params[:birthday_end]) if params[:birthday_start] && params[:birthday_end]
  end
end

With that we’ve already cleaned out a lot of the cruft of our controller, but what about that last one?

Scoping and Class Methods

We can get rid of it as well, using either scoping or class methods to take care of it for us:

1
2
3
4
5
6
7
8
9
class Person
  # We can go with a scope:
  scope :born_between, -> start, end { where(age: start..end) }

  # ...or a class method:
  def self.born_between(start, end)
    where(age: start..end)
  end
end

Which will let us trim down our controller even a little more here:

1
2
3
4
5
class PeopleController
  def index
    @people = Person.where(params.permit(:name, :sex)).born_between(params[:age_start], params[:age_end])
  end
end

Conditional Scoping

The astute reader will note that the above method is going to fail gloriously should we forget either of those params. We could always drop it to another variable and mutate people, but that’s generally frowned upon and doesn’t normally produce superheroes.

What we can do, however, is introduce a more conditional scoping method. Class methods are, after all, ruby methods. Let’s use them to their potential a bit more:

1
2
3
4
5
class Person
  def self.born_between(start, end = Time.now)
    start ? where(age: start..end) : all
  end
end

By throwing in an all, we can conditionally chain freely.

Like Scoping

The problem is, that name search just isn’t doing it for us. We don’t want to break out solr or trigrams quite yet, but we can use some like queries to make it a bit more flexible:

1
2
3
4
5
6
class PeopleController
  def index
    @people = Person.where(params.permit(:sex)).born_between(params[:age_start], params[:age_end])
    @people = @people.where('name LIKE ?', params[:name]) if params[:name]
  end
end

Though we spend all that time getting rid of postfix if checks, can we do something about this one as well?

1
2
3
4
5
6
7
8
9
class Person
  def self.where_name_like(name)
    name ? where('name LIKE ?', name) : all
  end

  def self.born_between(start_date, end_date = Time.now)
    start_date ? where(age: start_date..end_date) : all
  end
end

That we can:

1
2
3
4
5
6
7
8
9
class PeopleController
  def index
    @people =
      Person
        .where(params.permit(:sex))
        .born_between(params[:age_start], params[:age_end])
        .where_name_like(params[:name])
  end
end

Like that, we’ve eliminated another suffix if.

More advanced filtering

Though most of these examples have been fairly straightforward, there will be times when you have to break out some joins and other operations depending on your parameters. Strong params aren’t going to cut it on those, but class methods just might do the trick.

We have a new model to work with, Post, and with it the following controller:

1
2
3
4
5
6
7
8
class PostsController
  def index
    @posts = Post.where(params.permit(:name))
    @posts = @posts.join(:users).where(users: {id: params[:user_id]}) if params[:user_id]
    @posts = @posts.includes(:comments) if params[:show_comments]
    @posts = @posts.includes(:tags).where(tag: {name: JSON.parse(params[:tags])}) if params[:tags]
  end
end

Some of those earlier techniques just aren’t going to cut it, and it’s going to be a lot more difficult to be intention revealing here. Including comments and tags unless we have to could be a big expense, so we need to keep those under conditionals to prevent unnecessary data from being fetched.

We’re going to have to use something new here. Let’s condense those conditionals into a scope:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Post
  def self.by_user(args = {})
    args[:if] ? join(:users).where(users: {id: args[:if]}) : all
  end

  def self.with_comments(args = {})
    args[:if] ? includes(:comments) : all
  end

  def self.with_tags(args = {})
    args[:if] ? includes(:tags).where(tag: {name: JSON.parse(args[:if])}) : all
  end
end

Noted that keyword arguments would be very unhappy with us using if there, making it a no-go.

Which allows us to write a much clearer controller:

1
2
3
4
5
6
7
8
9
10
class PostsController
  def index
    @posts =
      Post
        .where(params.permit(:name))
        .by_user(if: params[:user_id])
        .with_comments(if: params[:show_comments])
        .with_tags(if: params[:tags])
  end
end

Through just a few simple scoping mechanisms, we can trim down our controllers while still getting a very useful search from vanilla rails.

Comments